Compare commits
41 commits
Author | SHA1 | Date | |
---|---|---|---|
4cbf1ca7c3 | |||
297f5cfa92 | |||
8179c83383 | |||
b696ee8894 | |||
03f6c58482 | |||
d746e4787d | |||
47f0a08ea3 | |||
2a4bda70c6 | |||
882ae66a6a | |||
98c62ac460 | |||
ad796dcb57 | |||
9ed5f15b9e | |||
f4f03ab491 | |||
34e54fa4af | |||
a69c474d1d | |||
c6abf633f8 | |||
88a5040f9c | |||
cdb344609c | |||
4a3155c33a | |||
a4eebb944f | |||
d5e9a9469f | |||
f76e7fd97f | |||
068c7961ae | |||
3b2f34952a | |||
fd4cb0c96f | |||
3aba85e411 | |||
cbc7d65103 | |||
5b3da498d6 | |||
5d30c7bb85 | |||
a5117b1515 | |||
04f8be4681 | |||
c6ffea2739 | |||
c21f9d1fdd | |||
04d9883c96 | |||
8d9b75c5fd | |||
cc1ebaff1a | |||
009729b877 | |||
74742ae61b | |||
d7ecac657f | |||
fb958c0c55 | |||
215414fb87 |
72 changed files with 751 additions and 218 deletions
|
@ -2,8 +2,11 @@
|
||||||
node_modules
|
node_modules
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/package
|
/package
|
||||||
|
/playwright-report
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
.eslintcache
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
vitest.config.*.js.timestamp-*
|
||||||
|
vitest.config.*.ts.timestamp-*
|
||||||
|
|
|
@ -16,9 +16,19 @@ jobs:
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: "test"
|
POSTGRES_DB: "test"
|
||||||
POSTGRES_PASSWORD: "1234"
|
POSTGRES_PASSWORD: "1234"
|
||||||
|
oidc:
|
||||||
|
image: thetadev256/oidc-mock-server
|
||||||
|
env:
|
||||||
|
CLIENT_ID: visitenbuch
|
||||||
|
CLIENT_SECRET: supersecret
|
||||||
|
CLIENT_REDIRECT_URIS: http://localhost:4173/auth/callback/keycloak
|
||||||
|
CLIENT_LOGOUT_REDIRECT_URIS: http://localhost:4173/login?noAuto=1
|
||||||
|
ISSUER_HOST: oidc:3000
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://postgres:1234@postgres:5432/test?schema=public"
|
DATABASE_URL: "postgresql://postgres:1234@postgres:5432/test?schema=public"
|
||||||
TEST_DATABASE_URL: "postgresql://postgres:1234@postgres:5432/test?schema=public"
|
TEST_DATABASE_URL: "postgresql://postgres:1234@postgres:5432/test?schema=public"
|
||||||
|
KEYCLOAK_ISSUER: http://oidc:3000
|
||||||
|
KEYCLOAK_LOGOUT: http://oidc:3000/session/end
|
||||||
steps:
|
steps:
|
||||||
- name: 👁️ Checkout repository
|
- name: 👁️ Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
@ -28,14 +38,26 @@ jobs:
|
||||||
cp .env.test .env
|
cp .env.test .env
|
||||||
- name: 🧐 lint
|
- name: 🧐 lint
|
||||||
run: |
|
run: |
|
||||||
npm run check
|
pnpm run check
|
||||||
npm run lint
|
pnpm run lint
|
||||||
- name: 🧪 Unit test
|
- name: 🧪 Unit test
|
||||||
run: npm run test:unit
|
run: pnpm run test:unit
|
||||||
- name: 🧪 Integration test
|
- name: 🧪 Integration test
|
||||||
run: |
|
run: |
|
||||||
npx prisma migrate reset --force
|
npx prisma migrate reset --force
|
||||||
npm run test:integration
|
pnpm run test:integration
|
||||||
|
- name: 👨🔬 E2E test
|
||||||
|
id: e2etest
|
||||||
|
run: |
|
||||||
|
pnpm run build -l silent
|
||||||
|
npx playwright install chromium
|
||||||
|
pnpm run test:e2e
|
||||||
|
- name: 💢 Upload E2E report
|
||||||
|
if: ${{ failure() && steps.e2etest.conclusion == 'failure' }}
|
||||||
|
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: cimaster-latest
|
runs-on: cimaster-latest
|
||||||
|
@ -45,11 +67,13 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: 👁️ Checkout repository
|
- name: 👁️ Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # important to fetch tag logs
|
||||||
|
|
||||||
- name: 📦 pnpm install
|
- name: 📦 pnpm install
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
- name: ⚒️ Build web application
|
- name: ⚒️ Build web application
|
||||||
run: npm run build
|
run: pnpm run build
|
||||||
- name: 🐋 Build docker image
|
- name: 🐋 Build docker image
|
||||||
uses: https://code.thetadev.de/ThetaDev/action-kaniko@v1
|
uses: https://code.thetadev.de/ThetaDev/action-kaniko@v1
|
||||||
with:
|
with:
|
||||||
|
@ -78,4 +102,4 @@ jobs:
|
||||||
- name: 🚀 Deploy to server
|
- name: 🚀 Deploy to server
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
run: |
|
run: |
|
||||||
curl -H "Authorization: Bearer ${{ secrets.THETADEV_DE_WATCHTOWER_TOKEN }}" https://watchtower.thetadev.de/v1/update
|
curl -s -H "Authorization: Bearer ${{ secrets.THETADEV_DE_WATCHTOWER_TOKEN }}" https://watchtower.thetadev.de/v1/update
|
||||||
|
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -3,6 +3,10 @@ node_modules
|
||||||
/build
|
/build
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/package
|
/package
|
||||||
|
/playwright-report
|
||||||
.env
|
.env
|
||||||
|
.eslintcache
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
vitest.config.*.js.timestamp-*
|
||||||
|
vitest.config.*.ts.timestamp-*
|
||||||
|
|
|
@ -13,4 +13,5 @@ repos:
|
||||||
entry: npx eslint
|
entry: npx eslint
|
||||||
args:
|
args:
|
||||||
- "--max-warnings=0"
|
- "--max-warnings=0"
|
||||||
|
- "--cache"
|
||||||
files: \.(js|ts|svelte)$
|
files: \.(js|ts|svelte)$
|
||||||
|
|
62
CHANGELOG.md
62
CHANGELOG.md
|
@ -3,6 +3,68 @@
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.3.5](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.3.4..v0.3.5) - 2024-05-20
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- Focus filter bar when pressing F - ([ad796dc](https://code.thetadev.de/HSA/Visitenbuch/commit/ad796dcb578b79b566559d1c22c99f0231a03251))
|
||||||
|
- Add optional Keycloak endpoint config - ([d746e47](https://code.thetadev.de/HSA/Visitenbuch/commit/d746e4787d70080bbc22b37263bbfa695c1a7d72))
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Dont create entry executions if entry is only postponed - ([34e54fa](https://code.thetadev.de/HSA/Visitenbuch/commit/34e54fa4afdf17e7258cbc5ccac5a3d094ee161b))
|
||||||
|
- HumanDate capitalization - ([f4f03ab](https://code.thetadev.de/HSA/Visitenbuch/commit/f4f03ab4914f850b15acf7bb39da34b1abb587a7))
|
||||||
|
- Filterbar does not exclude present filters from URL, text filters dont confirm when defocused - ([9ed5f15](https://code.thetadev.de/HSA/Visitenbuch/commit/9ed5f15b9ef237cc400b069928baeb920b2d3681))
|
||||||
|
- [**breaking**] Ensure category, room and station names are unique - ([98c62ac](https://code.thetadev.de/HSA/Visitenbuch/commit/98c62ac4603fa6d7c97e1a439f613379db7a2587))
|
||||||
|
- Allow multiple date filters - ([2a4bda7](https://code.thetadev.de/HSA/Visitenbuch/commit/2a4bda70c6cfd85b4a32989a2e19ba718cc7717e))
|
||||||
|
- Add dumb-init to docker image - ([47f0a08](https://code.thetadev.de/HSA/Visitenbuch/commit/47f0a08ea3dbd8b1721a11c52b36c42ad56e8e29))
|
||||||
|
- Remove test route - ([03f6c58](https://code.thetadev.de/HSA/Visitenbuch/commit/03f6c5848201eff02999c6b9323ec1515d68fd5a))
|
||||||
|
|
||||||
|
### ⚙️ Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Remove unused zod-form-data dependency - ([882ae66](https://code.thetadev.de/HSA/Visitenbuch/commit/882ae66a6a137259388525df2c91b8e1ed924d86))
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.3.4](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.3.3..v0.3.4) - 2024-05-16
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- Select table entries on doubleclick - ([c6abf63](https://code.thetadev.de/HSA/Visitenbuch/commit/c6abf633f8ae5e9b562dda36f9f7ab4d6adcb4e1))
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Escape HTML for licenses file - ([f76e7fd](https://code.thetadev.de/HSA/Visitenbuch/commit/f76e7fd97f62d9b41ecbabc3334c2c1876be253d))
|
||||||
|
- Use btn-id class for all tables - ([d5e9a94](https://code.thetadev.de/HSA/Visitenbuch/commit/d5e9a9469f0c57939367141985a97d8404fd6fbe))
|
||||||
|
- Avoid global state, use context for savedFilters - ([a4eebb9](https://code.thetadev.de/HSA/Visitenbuch/commit/a4eebb944f55da8e87cc899eebada0bd3fd37aa8))
|
||||||
|
- Close autocomplete on defocus - ([4a3155c](https://code.thetadev.de/HSA/Visitenbuch/commit/4a3155c33aa354973d4e0ca3ffeab2b7fd442040))
|
||||||
|
- Remove process.on hooks (not necessary) - ([cdb3446](https://code.thetadev.de/HSA/Visitenbuch/commit/cdb344609cde80084876faea9f80e7b26b01d0f2))
|
||||||
|
- Autocomplete not closing on tab - ([88a5040](https://code.thetadev.de/HSA/Visitenbuch/commit/88a5040f9c4e19ae3efb5ad0894c8dc5b905a92e))
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.3.3](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.3.2..v0.3.3) - 2024-05-14
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- Add E2E testing - ([8d9b75c](https://code.thetadev.de/HSA/Visitenbuch/commit/8d9b75c5fd634ae547c2690a68957264a6d447e4))
|
||||||
|
- Make page printable - ([04d9883](https://code.thetadev.de/HSA/Visitenbuch/commit/04d9883c9655379301e0c41cc55ebdaa90c68821))
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Update ESLint config and fix lints - ([009729b](https://code.thetadev.de/HSA/Visitenbuch/commit/009729b877e4f050fa0d1159aaa86dd43d534621))
|
||||||
|
- FilterList selection hides items from other FilterLists - ([cc1ebaf](https://code.thetadev.de/HSA/Visitenbuch/commit/cc1ebaff1a4573970f04dc44591ee7e9afb9a842))
|
||||||
|
|
||||||
|
### 🧪 Testing
|
||||||
|
|
||||||
|
- Use fixtures for E2E tests, fix wrong OIDC URL in CI - ([cbc7d65](https://code.thetadev.de/HSA/Visitenbuch/commit/cbc7d65103695565db64b4770cce71f5d37920b6))
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.3.2](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.3.1..v0.3.2) - 2024-05-13
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Disable NPM update notifier - ([fb958c0](https://code.thetadev.de/HSA/Visitenbuch/commit/fb958c0c5592229037e50407af9f79d0894eb369))
|
||||||
|
|
||||||
|
|
||||||
## [v0.3.1](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.3.0..v0.3.1) - 2024-05-13
|
## [v0.3.1](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.3.0..v0.3.1) - 2024-05-13
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
|
@ -7,7 +7,12 @@ COPY package.json pnpm-lock.yaml run/entrypoint.sh ./
|
||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
|
|
||||||
# Setup pnpm, install Prisma CLI (for generating client) and install dependencies
|
# Setup pnpm, install Prisma CLI (for generating client) and install dependencies
|
||||||
RUN corepack enable && pnpm i --prod && pnpm audit fix && npx prisma generate
|
RUN apk add dumb-init && \
|
||||||
|
npm config set update-notifier false && \
|
||||||
|
corepack enable && \
|
||||||
|
pnpm i --prod && \
|
||||||
|
pnpm audit fix && \
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
# copy built SvelteKit app to /app
|
# copy built SvelteKit app to /app
|
||||||
COPY build ./
|
COPY build ./
|
||||||
|
|
44
README.md
44
README.md
|
@ -4,21 +4,34 @@ for the university hospital in Augsburg
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The project template was created using [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
|
The project template was created using
|
||||||
|
[`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
Once you've created a project and installed dependencies with `npm install` (or
|
||||||
|
`pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
pnpm run dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
# or start the server and open the app in a new browser tab
|
||||||
npm run dev -- --open
|
pnpm run dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
### Handle the prisma ORM
|
### Test environment
|
||||||
|
|
||||||
|
Copy the `.env.example` file to `.env` to get access to the required configuration
|
||||||
|
variables.
|
||||||
|
|
||||||
|
The project depends on a PostgreSQL database and an OIDC authentication server. You can
|
||||||
|
setup both using the `run/db_up.sh` script. This creates a new testing environment using
|
||||||
|
docker-compose and fills the test database with mock data.
|
||||||
|
|
||||||
|
### Use the Pisma ORM
|
||||||
|
|
||||||
|
If you apply changes to the database scheme, you have to create a new migration to apply
|
||||||
|
these changes to the database.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./run/db_up.sh # Start the docker container, create a new database and run migrations + insert test data
|
|
||||||
npx prisma migrate dev --name my_migration --create-only # Create a new migration
|
npx prisma migrate dev --name my_migration --create-only # Create a new migration
|
||||||
npx prisma migrate dev # Apply migrations to the database
|
npx prisma migrate dev # Apply migrations to the database
|
||||||
```
|
```
|
||||||
|
@ -28,12 +41,20 @@ npx prisma migrate dev # Apply migrations to the database
|
||||||
To create a production version of your app:
|
To create a production version of your app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
> To deploy your app, you may need to install an
|
||||||
|
> [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
||||||
|
|
||||||
|
### Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test # Unit- und Integrationstests
|
||||||
|
pnpm test:e2e # End2End-Tests
|
||||||
|
```
|
||||||
|
|
||||||
### Release
|
### Release
|
||||||
|
|
||||||
|
@ -42,3 +63,10 @@ To release a new version, tun the release script with the new version as a param
|
||||||
```bash
|
```bash
|
||||||
./release.sh 1.0.0
|
./release.sh 1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Building docker image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build
|
||||||
|
docker build -t thetadev256/visitenbuch .
|
||||||
|
```
|
||||||
|
|
|
@ -14,7 +14,7 @@ import ts from "typescript-eslint";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommendedTypeChecked,
|
||||||
...svelte.configs["flat/recommended"],
|
...svelte.configs["flat/recommended"],
|
||||||
// TS-Svelte
|
// TS-Svelte
|
||||||
{
|
{
|
||||||
|
@ -585,10 +585,16 @@ export default [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"@typescript-eslint/return-await": "error",
|
"@typescript-eslint/return-await": "error",
|
||||||
|
|
||||||
"@typescript-eslint/no-shadow": ["error", { allow: ["i", "j"] }],
|
"@typescript-eslint/no-shadow": ["error", { allow: ["i", "j"] }],
|
||||||
"no-shadow-restricted-names": "error",
|
"no-shadow-restricted-names": "error",
|
||||||
"@typescript-eslint/no-loss-of-precision": "error",
|
"@typescript-eslint/promise-function-async": "error",
|
||||||
|
"@typescript-eslint/no-base-to-string": "off",
|
||||||
|
|
||||||
|
// these clash with Svelte generics
|
||||||
|
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-call": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-argument": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -812,7 +818,7 @@ export default [
|
||||||
".svelte-kit/",
|
".svelte-kit/",
|
||||||
"*.config.cjs",
|
"*.config.cjs",
|
||||||
"vite.config.js.timestamp-*",
|
"vite.config.js.timestamp-*",
|
||||||
"vite.config.ts.timestamp-*",
|
"vitest.config.*.timestamp-*",
|
||||||
".tmp/",
|
".tmp/",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "visitenbuch",
|
"name": "visitenbuch",
|
||||||
"version": "0.3.1",
|
"version": "0.3.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -10,7 +10,7 @@
|
||||||
"test": "vitest --run && vitest --config vitest.config.integration.js --run",
|
"test": "vitest --run && vitest --config vitest.config.integration.js --run",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "eslint . --max-warnings=0",
|
"lint": "eslint . --max-warnings=0 --cache",
|
||||||
"format": "eslint . --fix",
|
"format": "eslint . --fix",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test:integration": "vitest --config vitest.config.integration.js",
|
"test:integration": "vitest --config vitest.config.integration.js",
|
||||||
|
@ -28,8 +28,7 @@
|
||||||
"qs": "^6.12.1",
|
"qs": "^6.12.1",
|
||||||
"set-cookie-parser": "^2.6.0",
|
"set-cookie-parser": "^2.6.0",
|
||||||
"svelte-floating-ui": "^1.5.8",
|
"svelte-floating-ui": "^1.5.8",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8"
|
||||||
"zod-form-data": "^2.0.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
|
|
|
@ -1,10 +1,21 @@
|
||||||
import { defineConfig } from "@playwright/test";
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const CI = Boolean(process.env.CI);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run build && npm run preview",
|
command: "npm run preview -m test",
|
||||||
port: 4173,
|
port: 4173,
|
||||||
|
reuseExistingServer: !CI,
|
||||||
|
},
|
||||||
|
testDir: "tests/e2e",
|
||||||
|
globalSetup: "tests/helpers/generate-mockdata.ts",
|
||||||
|
outputDir: ".svelte-kit/test-results",
|
||||||
|
maxFailures: 0,
|
||||||
|
retries: CI ? 2 : 0,
|
||||||
|
reporter: CI ? [["line"], ["html"]] : "list",
|
||||||
|
use: {
|
||||||
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
testDir: "tests",
|
|
||||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -41,9 +41,6 @@ dependencies:
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.23.8
|
specifier: ^3.23.8
|
||||||
version: 3.23.8
|
version: 3.23.8
|
||||||
zod-form-data:
|
|
||||||
specifier: ^2.0.2
|
|
||||||
version: 2.0.2(zod@3.23.8)
|
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@faker-js/faker':
|
'@faker-js/faker':
|
||||||
|
@ -5961,14 +5958,6 @@ packages:
|
||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/zod-form-data@2.0.2(zod@3.23.8):
|
|
||||||
resolution: {integrity: sha512-sKTi+k0fvkxdakD0V5rq+9WVJA3cuTQUfEmNqvHrTzPLvjfLmkkBLfR0ed3qOi9MScJXTHIDH/jUNnEJ3CBX4g==}
|
|
||||||
peerDependencies:
|
|
||||||
zod: '>= 3.11.0'
|
|
||||||
dependencies:
|
|
||||||
zod: 3.23.8
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/zod-to-json-schema@3.23.0(zod@3.23.8):
|
/zod-to-json-schema@3.23.0(zod@3.23.8):
|
||||||
resolution: {integrity: sha512-az0uJ243PxsRIa2x1WmNE/pnuA05gUq/JB8Lwe1EDCCL/Fz9MgjYQ0fPlyc2Tcv6aF2ZA7WM5TWaRZVEFaAIag==}
|
resolution: {integrity: sha512-az0uJ243PxsRIa2x1WmNE/pnuA05gUq/JB8Lwe1EDCCL/Fz9MgjYQ0fPlyc2Tcv6aF2ZA7WM5TWaRZVEFaAIag==}
|
||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[name]` on the table `categories` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[name]` on the table `rooms` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[name]` on the table `stations` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "categories_name_key" ON "categories"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "rooms_name_key" ON "rooms"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "stations_name_key" ON "stations"("name");
|
|
@ -50,7 +50,7 @@ model User {
|
||||||
// Hospital station
|
// Hospital station
|
||||||
model Station {
|
model Station {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String @unique
|
||||||
Room Room[]
|
Room Room[]
|
||||||
hidden Boolean @default(false)
|
hidden Boolean @default(false)
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ model Station {
|
||||||
// Hospital room
|
// Hospital room
|
||||||
model Room {
|
model Room {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String @unique
|
||||||
station Station @relation(fields: [station_id], references: [id], onDelete: Restrict)
|
station Station @relation(fields: [station_id], references: [id], onDelete: Restrict)
|
||||||
station_id Int
|
station_id Int
|
||||||
Patient Patient[]
|
Patient Patient[]
|
||||||
|
@ -90,7 +90,7 @@ model Patient {
|
||||||
// Entry category (e.g. Blood test, Exams, ...)
|
// Entry category (e.g. Blood test, Exams, ...)
|
||||||
model Category {
|
model Category {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String @unique
|
||||||
color String?
|
color String?
|
||||||
description String?
|
description String?
|
||||||
EntryVersion EntryVersion[]
|
EntryVersion EntryVersion[]
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
#!/bin/sh
|
#!/usr/bin/dumb-init /bin/sh
|
||||||
set -e
|
set -e
|
||||||
# Migrate database before starting server
|
# Migrate database before starting server
|
||||||
npx prisma migrate deploy
|
npx prisma migrate deploy
|
||||||
|
|
4
src/app.d.ts
vendored
4
src/app.d.ts
vendored
|
@ -8,7 +8,9 @@ declare global {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
// interface PageData {}
|
interface PageData {
|
||||||
|
session: Session | null;
|
||||||
|
}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
|
79
src/app.pcss
79
src/app.pcss
|
@ -43,6 +43,7 @@
|
||||||
|
|
||||||
button {
|
button {
|
||||||
text-align: initial;
|
text-align: initial;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
|
@ -72,10 +73,6 @@ button {
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
@apply flex;
|
@apply flex;
|
||||||
}
|
|
||||||
|
|
||||||
.row,
|
|
||||||
.rowb {
|
|
||||||
@apply p-2;
|
@apply p-2;
|
||||||
@apply border-t border-solid border-base-content/30;
|
@apply border-t border-solid border-base-content/30;
|
||||||
}
|
}
|
||||||
|
@ -109,3 +106,77 @@ button {
|
||||||
transform: scale(0.99);
|
transform: scale(0.99);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Make all text black for best look on B/W printers */
|
||||||
|
:root {
|
||||||
|
--bc: 0% 0 0;
|
||||||
|
--pc: 0% 0 0;
|
||||||
|
--sc: 0% 0 0;
|
||||||
|
--ac: 0% 0 0;
|
||||||
|
--nc: 0% 0 0;
|
||||||
|
--inc: 0% 0 0;
|
||||||
|
--suc: 0% 0 0;
|
||||||
|
--wac: 0% 0 0;
|
||||||
|
--erc: 0% 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(.btn-id) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-id {
|
||||||
|
font-size: 100%;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
@apply rounded-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card2 {
|
||||||
|
@apply rounded-none;
|
||||||
|
background: none !important;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
|
||||||
|
.row:first-child {
|
||||||
|
@apply border-solid border-base-content/50 border-t-0 border-b-2;
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child,
|
||||||
|
.row:first-child {
|
||||||
|
@apply rounded-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-light,
|
||||||
|
.c-vlight,
|
||||||
|
.c-primary {
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card2,
|
||||||
|
.card2 > .row {
|
||||||
|
@apply border-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
td, th {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
@apply border-base-content/50 border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: #000 !important;
|
||||||
|
padding: 0;
|
||||||
|
@apply rounded-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { TRPCClientError } from "@trpc/client";
|
||||||
|
|
||||||
const CHECK_CONNECTION = "Die Seite konnte nicht geladen werden, prüfen sie ihre Verbindung";
|
const CHECK_CONNECTION = "Die Seite konnte nicht geladen werden, prüfen sie ihre Verbindung";
|
||||||
|
|
||||||
export const handleError: HandleClientError = async ({ error, message, status }) => {
|
export const handleError: HandleClientError = ({ error, message, status }) => {
|
||||||
// If there are client-side errors, SvelteKit always returns the nondescript
|
// If there are client-side errors, SvelteKit always returns the nondescript
|
||||||
// "Internal error" message. The most common errors should be mapped to a more
|
// "Internal error" message. The most common errors should be mapped to a more
|
||||||
// detailed description
|
// detailed description
|
||||||
|
|
|
@ -31,7 +31,3 @@ export const handle = sequence(
|
||||||
authorization,
|
authorization,
|
||||||
createTRPCHandle({ router, createContext }),
|
createTRPCHandle({ router, createContext }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Allow server application to exit
|
|
||||||
process.on("SIGINT", process.exit); // Ctrl+C
|
|
||||||
process.on("SIGTERM", process.exit); // docker stop
|
|
||||||
|
|
|
@ -61,7 +61,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<Markdown src={entry.current_version.text} />
|
<Markdown src={entry.current_version.text} />
|
||||||
</div>
|
</div>
|
||||||
<div class="rowb c-vlight text-sm">
|
<div class="row c-vlight text-sm block">
|
||||||
Zuletzt bearbeitet am {formatDate(entry.current_version.created_at, true)} von
|
Zuletzt bearbeitet am {formatDate(entry.current_version.created_at, true)} von
|
||||||
<UserField filterName="author" user={entry.current_version.author} />
|
<UserField filterName="author" user={entry.current_version.author} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
<script lang="ts">
|
<script generics="T extends BaseItem" lang="ts">
|
||||||
|
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
import { mdiClose } from "@mdi/js";
|
import { mdiClose } from "@mdi/js";
|
||||||
|
import type { MaybePromise } from "@sveltejs/kit";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { createFloatingActions } from "svelte-floating-ui";
|
import { createFloatingActions } from "svelte-floating-ui";
|
||||||
import { shift } from "svelte-floating-ui/dom";
|
import { shift } from "svelte-floating-ui/dom";
|
||||||
|
@ -22,10 +23,8 @@
|
||||||
* MIT License
|
* MIT License
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type T = $$Generic<BaseItem>;
|
|
||||||
|
|
||||||
/** List of items to choose from (or an async function fetching them) */
|
/** List of items to choose from (or an async function fetching them) */
|
||||||
export let items: T[] | (() => Promise<T[]>);
|
export let items: T[] | (() => MaybePromise<T[]>);
|
||||||
/** Current selection of the autocomplete field */
|
/** Current selection of the autocomplete field */
|
||||||
export let selection: T | null = null;
|
export let selection: T | null = null;
|
||||||
/** Set of item IDs that should be hidden from the list */
|
/** Set of item IDs that should be hidden from the list */
|
||||||
|
@ -96,7 +95,7 @@
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
items().then((fetchedItems) => {
|
Promise.resolve(items()).then((fetchedItems) => {
|
||||||
srcItems = fetchedItems;
|
srcItems = fetchedItems;
|
||||||
if (cacheKey) cache[cacheKey] = fetchedItems;
|
if (cacheKey) cache[cacheKey] = fetchedItems;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
@ -167,6 +166,14 @@
|
||||||
if (opened) {
|
if (opened) {
|
||||||
onClose(kb);
|
onClose(kb);
|
||||||
}
|
}
|
||||||
|
// select remaining item if autoselect is enabled
|
||||||
|
if (!selection) {
|
||||||
|
if (!noAutoselect1 && filteredItems.length === 1) {
|
||||||
|
selectListItem(filteredItems[0], true);
|
||||||
|
} else {
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
}
|
||||||
opened = false;
|
opened = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -186,43 +193,38 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent): void {
|
function onKeyDown(e: KeyboardEvent): void {
|
||||||
let { key } = e;
|
switch (e.key) {
|
||||||
if (key === "Tab" && e.shiftKey) key = "ShiftTab";
|
case "Tab":
|
||||||
const fnmap: Record<string, () => void> = {
|
close();
|
||||||
Tab: () => close,
|
break;
|
||||||
ShiftTab: () => close,
|
case "ArrowDown":
|
||||||
ArrowDown: () => {
|
|
||||||
open();
|
open();
|
||||||
if (highlightIndex < filteredItems.length - 1) {
|
if (highlightIndex < filteredItems.length - 1) {
|
||||||
highlightIndex++;
|
highlightIndex++;
|
||||||
highlight();
|
highlight();
|
||||||
}
|
}
|
||||||
},
|
break;
|
||||||
ArrowUp: () => {
|
case "ArrowUp":
|
||||||
open();
|
open();
|
||||||
if (highlightIndex > 0) {
|
if (highlightIndex > 0) {
|
||||||
highlightIndex--;
|
highlightIndex--;
|
||||||
highlight();
|
highlight();
|
||||||
}
|
}
|
||||||
},
|
break;
|
||||||
Escape: () => {
|
case "Escape":
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (opened) {
|
if (opened) {
|
||||||
if (inputElm) inputElm.focus();
|
if (inputElm) inputElm.focus();
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
},
|
break;
|
||||||
Backspace: () => {
|
case "Backspace":
|
||||||
if (inputValue().length === 0) {
|
if (inputValue().length === 0) {
|
||||||
onBackspace();
|
onBackspace();
|
||||||
} else if (selection) {
|
} else if (selection) {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
},
|
break;
|
||||||
};
|
|
||||||
const fn = fnmap[key];
|
|
||||||
if (typeof fn === "function") {
|
|
||||||
fn();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -235,16 +237,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBlur(): void {
|
|
||||||
if (!selection) {
|
|
||||||
if (!noAutoselect1 && filteredItems.length === 1) {
|
|
||||||
selectListItem(filteredItems[0], true);
|
|
||||||
} else {
|
|
||||||
setInputValue("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlight(): void {
|
function highlight(): void {
|
||||||
if (browser && opened) {
|
if (browser && opened) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
|
@ -304,12 +296,11 @@
|
||||||
on:focus={open}
|
on:focus={open}
|
||||||
on:keydown={onKeyDown}
|
on:keydown={onKeyDown}
|
||||||
on:keypress={onKeyPress}
|
on:keypress={onKeyPress}
|
||||||
on:blur={onBlur}
|
|
||||||
use:floatingRef
|
use:floatingRef
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if opened && filteredItems.length > 0}
|
{#if opened && filteredItems.length > 0}
|
||||||
<div bind:this={listElm} class="autocomplete-list" use:floatingContent>
|
<div bind:this={listElm} class="autocomplete-list" tabindex="-1" use:floatingContent>
|
||||||
{#each filteredItems as item, i}
|
{#each filteredItems as item, i}
|
||||||
<div
|
<div
|
||||||
class="autocomplete-list-item"
|
class="autocomplete-list-item"
|
||||||
|
@ -345,7 +336,12 @@
|
||||||
|
|
||||||
{#if clearBtn && selection}
|
{#if clearBtn && selection}
|
||||||
<div class="absolute bottom-0 right-0 h-full flex items-center">
|
<div class="absolute bottom-0 right-0 h-full flex items-center">
|
||||||
<IconButton cls="" path={mdiClose} title="Löschen" on:click={clearSelection} />
|
<IconButton
|
||||||
|
cls=""
|
||||||
|
path={mdiClose}
|
||||||
|
tabindex={-1}
|
||||||
|
title="Löschen"
|
||||||
|
on:click={clearSelection} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import { mdiClose } from "@mdi/js";
|
import { mdiClose } from "@mdi/js";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
import { Debouncer } from "$lib/shared/util";
|
import { Debouncer } from "$lib/shared/util";
|
||||||
|
|
||||||
|
@ -55,7 +56,7 @@
|
||||||
// Filter menu items to be hidden
|
// Filter menu items to be hidden
|
||||||
$: hiddenIds = new Set([
|
$: hiddenIds = new Set([
|
||||||
...Object.values(FILTERS).flatMap((f) => {
|
...Object.values(FILTERS).flatMap((f) => {
|
||||||
return f.inputType === 2
|
return f.inputType === InputType.FilterList
|
||||||
|| activeFilters.every((af) => af.id !== f.id)
|
|| activeFilters.every((af) => af.id !== f.id)
|
||||||
? []
|
? []
|
||||||
: [f.id];
|
: [f.id];
|
||||||
|
@ -96,10 +97,15 @@
|
||||||
activeFilters = filters;
|
activeFilters = filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get a list of item IDs to hide for the given filter
|
||||||
|
* This ensures that a filter item cannot be selected twice
|
||||||
|
*/
|
||||||
function getHiddenIds(fid: string, fpos: number): Set<string | number> {
|
function getHiddenIds(fid: string, fpos: number): Set<string | number> {
|
||||||
if (FILTERS[fid].inputType === 2) {
|
if (FILTERS[fid].inputType === InputType.FilterList) {
|
||||||
return new Set(
|
return new Set(
|
||||||
activeFilters.flatMap((f, i) => (i !== fpos && f.selection?.id ? [f.selection?.id] : [])),
|
activeFilters.flatMap((f, i) => {
|
||||||
|
return i !== fpos && f.id === fid && f.selection?.id ? [f.selection?.id] : [];
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new Set();
|
return new Set();
|
||||||
|
@ -130,7 +136,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (val !== null && val !== undefined) {
|
if (val !== null && val !== undefined) {
|
||||||
if (filter.inputType === 2) {
|
if (filter.inputType === InputType.FilterList) {
|
||||||
// @ts-expect-error fd[key] is checked
|
// @ts-expect-error fd[key] is checked
|
||||||
if (Array.isArray(fd[key])) fd[key].push(...val);
|
if (Array.isArray(fd[key])) fd[key].push(...val);
|
||||||
else fd[key] = val;
|
else fd[key] = val;
|
||||||
|
@ -153,7 +159,7 @@
|
||||||
const valueless = isFilterValueless(FILTERS[item.id].inputType);
|
const valueless = isFilterValueless(FILTERS[item.id].inputType);
|
||||||
|
|
||||||
let selection = null;
|
let selection = null;
|
||||||
if (FILTERS[item.id].inputType === 3) {
|
if (FILTERS[item.id].inputType === InputType.Boolean) {
|
||||||
selection = { toggle: item.toggle ?? true };
|
selection = { toggle: item.toggle ?? true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -187,8 +193,23 @@
|
||||||
searchDebounce.now();
|
searchDebounce.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onWindowKeyup(e: KeyboardEvent): void {
|
||||||
|
// Dont catch keybinds when inputting text
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
if (e.key === "f") {
|
||||||
|
e.preventDefault();
|
||||||
|
focusInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (hiddenIds.size === 0) activeFilters = activeFilters;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keyup={onWindowKeyup} />
|
||||||
|
|
||||||
<div class="filterbar-outer">
|
<div class="filterbar-outer">
|
||||||
<div class="filterbar-inner input input-sm input-bordered">
|
<div class="filterbar-inner input input-sm input-bordered">
|
||||||
{#each activeFilters as fdata, i}
|
{#each activeFilters as fdata, i}
|
||||||
|
@ -260,4 +281,10 @@
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.filterbar-outer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -41,6 +41,15 @@
|
||||||
}
|
}
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
function acceptTextInput(e: Event): void {
|
||||||
|
// @ts-expect-error Event is from HTML input
|
||||||
|
if (e.target?.value) {
|
||||||
|
// @ts-expect-error Input value is checked
|
||||||
|
fdata.selection = { id: null, name: e.target.value };
|
||||||
|
}
|
||||||
|
stopEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
$: if (fdata.editing && autocomplete) {
|
$: if (fdata.editing && autocomplete) {
|
||||||
autocomplete.open();
|
autocomplete.open();
|
||||||
}
|
}
|
||||||
|
@ -52,7 +61,7 @@
|
||||||
? filter.name
|
? filter.name
|
||||||
: filter.toggleOff?.name ?? filter.name + TOFF;
|
: filter.toggleOff?.name ?? filter.name + TOFF;
|
||||||
$: filterIcon = toggleState ? filter.icon : filter.toggleOff?.icon ?? filter.icon;
|
$: filterIcon = toggleState ? filter.icon : filter.toggleOff?.icon ?? filter.icon;
|
||||||
$: hasInputField = filter.inputType !== 0 && filter.inputType !== 3;
|
$: hasInputField = filter.inputType !== InputType.None && filter.inputType !== InputType.Boolean;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -61,7 +70,7 @@
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1"
|
class="flex items-center gap-1"
|
||||||
disabled={filter.inputType !== 3}
|
disabled={filter.inputType !== InputType.Boolean}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
if (fdata.selection) {
|
if (fdata.selection) {
|
||||||
fdata.selection.toggle = !fdata.selection.toggle;
|
fdata.selection.toggle = !fdata.selection.toggle;
|
||||||
|
@ -118,14 +127,10 @@
|
||||||
}}
|
}}
|
||||||
on:keypress={(e) => {
|
on:keypress={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
// @ts-expect-error Input value is checked
|
acceptTextInput(e);
|
||||||
if (e.target?.value) {
|
|
||||||
// @ts-expect-error Input value is checked
|
|
||||||
fdata.selection = { id: null, name: e.target.value };
|
|
||||||
}
|
|
||||||
stopEditing(true);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
on:blur={acceptTextInput}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
@ -8,12 +8,14 @@
|
||||||
import { toastError, toastInfo } from "$lib/shared/util/toast";
|
import { toastError, toastInfo } from "$lib/shared/util/toast";
|
||||||
|
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import { savedFilters } from "$lib/stores";
|
import { getSavedFilters } from "$lib/stores";
|
||||||
|
|
||||||
import Chip from "./SavedFilterChip.svelte";
|
import Chip from "./SavedFilterChip.svelte";
|
||||||
|
|
||||||
export let view: string;
|
export let view: string;
|
||||||
|
|
||||||
|
const savedFilters = getSavedFilters();
|
||||||
|
|
||||||
$: filters = $savedFilters[view] ?? [];
|
$: filters = $savedFilters[view] ?? [];
|
||||||
|
|
||||||
function getQuery(): string {
|
function getQuery(): string {
|
||||||
|
@ -77,7 +79,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="saved-filters">
|
||||||
<div class="text-sm h-8 flex items-center">
|
<div class="text-sm h-8 flex items-center">
|
||||||
Gespeicherte Filter:
|
Gespeicherte Filter:
|
||||||
</div>
|
</div>
|
||||||
|
@ -97,3 +99,15 @@
|
||||||
Neu
|
Neu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
.saved-filters {
|
||||||
|
@apply flex flex-wrap items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.saved-filters {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -107,7 +107,7 @@ export const ENTRY_FILTERS: Record<string, FilterDef> = {
|
||||||
name: "Datum",
|
name: "Datum",
|
||||||
icon: mdiCalendar,
|
icon: mdiCalendar,
|
||||||
inputType: InputType.FilterList,
|
inputType: InputType.FilterList,
|
||||||
options: async () => weekFilterItems(),
|
options: () => weekFilterItems(),
|
||||||
textToItem: (s) => {
|
textToItem: (s) => {
|
||||||
const parsed = DateRange.parseHuman(s);
|
const parsed = DateRange.parseHuman(s);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { MaybePromise } from "@sveltejs/kit";
|
||||||
|
|
||||||
export enum InputType {
|
export enum InputType {
|
||||||
None = 0,
|
None = 0,
|
||||||
FreeText = 1,
|
FreeText = 1,
|
||||||
|
@ -27,7 +29,7 @@ export type FilterDef = {
|
||||||
name: string;
|
name: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
};
|
};
|
||||||
options?: () => Promise<BaseItem[]>;
|
options?: () => MaybePromise<BaseItem[]>;
|
||||||
textToItem?: (s: string) => BaseItem | void;
|
textToItem?: (s: string) => BaseItem | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
import type { RouterOutput } from "$lib/shared/trpc";
|
import type { RouterOutput } from "$lib/shared/trpc";
|
||||||
import { formatDate } from "$lib/shared/util";
|
import { formatDate } from "$lib/shared/util";
|
||||||
|
|
||||||
|
@ -16,7 +18,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table" data-testid="entry-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortHeader key="id" {sortData} {sortUpdate} title="ID" />
|
<SortHeader key="id" {sortData} {sortUpdate} title="ID" />
|
||||||
|
@ -39,10 +41,11 @@
|
||||||
class="transition-colors hover:bg-neutral-content/10"
|
class="transition-colors hover:bg-neutral-content/10"
|
||||||
class:done={entry.execution?.done}
|
class:done={entry.execution?.done}
|
||||||
class:priority={entry.current_version.priority}
|
class:priority={entry.current_version.priority}
|
||||||
|
on:dblclick={() => { void goto("/entry/" + entry.id); }}
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
><a
|
><a
|
||||||
class="btn btn-xs btn-primary"
|
class="btn btn-xs btn-primary btn-id"
|
||||||
aria-label="Eintrag anzeigen"
|
aria-label="Eintrag anzeigen"
|
||||||
href="/entry/{entry.id}">{entry.id}</a
|
href="/entry/{entry.id}">{entry.id}</a
|
||||||
></td
|
></td
|
||||||
|
|
|
@ -48,7 +48,7 @@
|
||||||
|
|
||||||
// Update page URL
|
// Update page URL
|
||||||
const url = getQueryUrl(q, baseUrl);
|
const url = getQueryUrl(q, baseUrl);
|
||||||
goto(url, { replaceState: true, keepFocus: true });
|
void goto(url, { replaceState: true, keepFocus: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -44,7 +44,7 @@
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// Update page URL
|
// Update page URL
|
||||||
const url = getQueryUrl(q, baseUrl);
|
const url = getQueryUrl(q, baseUrl);
|
||||||
goto(url, { replaceState: true, keepFocus: true });
|
void goto(url, { replaceState: true, keepFocus: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
import { mdiFilter } from "@mdi/js";
|
import { mdiFilter } from "@mdi/js";
|
||||||
|
|
||||||
import { URL_ENTRIES } from "$lib/shared/constants";
|
import { URL_ENTRIES } from "$lib/shared/constants";
|
||||||
|
@ -17,7 +19,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table" data-testid="patient-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortHeader key="id" {sortData} {sortUpdate} title="ID" />
|
<SortHeader key="id" {sortData} {sortUpdate} title="ID" />
|
||||||
|
@ -34,10 +36,11 @@
|
||||||
<tr
|
<tr
|
||||||
class="transition-colors hover:bg-neutral-content/10"
|
class="transition-colors hover:bg-neutral-content/10"
|
||||||
class:p-hidden={patient.hidden}
|
class:p-hidden={patient.hidden}
|
||||||
|
on:dblclick={() => { void goto("/patient/" + patient.id); }}
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
><a
|
><a
|
||||||
class="btn btn-xs btn-primary"
|
class="btn btn-xs btn-primary btn-id"
|
||||||
aria-label="Eintrag anzeigen"
|
aria-label="Eintrag anzeigen"
|
||||||
href="/patient/{patient.id}">{patient.id}</a
|
href="/patient/{patient.id}">{patient.id}</a
|
||||||
></td
|
></td
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
} else if (sorting === 2) {
|
} else if (sorting === 2) {
|
||||||
delete sortData[index];
|
delete sortData[index];
|
||||||
} else if (index !== -1) {
|
} else if (index !== -1) {
|
||||||
sortData[index] = sortData[index].split(":", 1) + ":dsc";
|
sortData[index] = sortData[index].split(":", 1)[0] + ":dsc";
|
||||||
} else {
|
} else {
|
||||||
sortData.push(key);
|
sortData.push(key);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { humanDate } from "$lib/shared/util";
|
||||||
|
|
||||||
|
function dateIn(n: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + n);
|
||||||
|
return humanDate(date);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="join">
|
<div class="join">
|
||||||
{#each { length: 4 } as _, i}
|
{#each { length: 4 } as _, i}
|
||||||
<button name="todo" class="join-item btn btn-sm" type="submit" value={i}>
|
<button
|
||||||
|
name="todo"
|
||||||
|
class="join-item btn btn-sm"
|
||||||
|
title={i > 0 ? `Eintrag auf ${dateIn(i)} verschieben` : undefined}
|
||||||
|
type="submit"
|
||||||
|
value={i}>
|
||||||
{#if i === 0}
|
{#if i === 0}
|
||||||
Notiz
|
Notiz
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let cls = "";
|
export let cls = "";
|
||||||
|
export let fixedTop = false;
|
||||||
export let alwaysShown = false;
|
export let alwaysShown = false;
|
||||||
|
|
||||||
let navprogress = 0;
|
let navprogress = 0;
|
||||||
|
@ -41,6 +42,7 @@
|
||||||
class={cls}
|
class={cls}
|
||||||
class:active={alwaysShown || showProgress}
|
class:active={alwaysShown || showProgress}
|
||||||
class:error={showError}
|
class:error={showError}
|
||||||
|
class:loading-bar-top={fixedTop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@ -51,6 +53,10 @@
|
||||||
@apply bg-primary;
|
@apply bg-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-bar-top {
|
||||||
|
@apply fixed top-0 left-0 z-50;
|
||||||
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
height: 0.2rem;
|
height: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
bind:this={autocomplete}
|
bind:this={autocomplete}
|
||||||
items={async () => weekFilterItems()}
|
items={() => weekFilterItems()}
|
||||||
noAutoselect1
|
noAutoselect1
|
||||||
onClose={stopEditing}
|
onClose={stopEditing}
|
||||||
onSelect={(item) => {
|
onSelect={(item) => {
|
||||||
|
|
|
@ -24,6 +24,16 @@ export const AUTH_CFG: AuthConfig = {
|
||||||
clientId: env.KEYCLOAK_CLIENT_ID,
|
clientId: env.KEYCLOAK_CLIENT_ID,
|
||||||
clientSecret: env.KEYCLOAK_CLIENT_SECRET,
|
clientSecret: env.KEYCLOAK_CLIENT_SECRET,
|
||||||
issuer: env.KEYCLOAK_ISSUER,
|
issuer: env.KEYCLOAK_ISSUER,
|
||||||
|
/*
|
||||||
|
Optional manual OIDC endpoint config.
|
||||||
|
Normally this is configured via the issuer URL
|
||||||
|
(KEYCLOAK_ISSUER/.well-known/openid-configuration),
|
||||||
|
but if the OIDC server is available under a different
|
||||||
|
internal domain, these variables must be manually set
|
||||||
|
*/
|
||||||
|
authorization: env.KEYCLOAK_EP_AUTHORIZATION,
|
||||||
|
token: env.KEYCLOAK_EP_TOKEN,
|
||||||
|
userinfo: env.KEYCLOAK_EP_USERINFO,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
session: {
|
session: {
|
||||||
|
@ -107,7 +117,7 @@ export async function auth(event: RequestEvent): Promise<Session | null> {
|
||||||
const { status = 200 } = response;
|
const { status = 200 } = response;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data || !Object.keys(data).length) return null;
|
if (!data || !Object.keys(data).length) return null;
|
||||||
if (status === 200) return data;
|
if (status === 200) return data as Session;
|
||||||
throw new Error(data.message);
|
throw new Error(data.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -287,6 +287,25 @@ left join stations s on s.id = r.station_id`,
|
||||||
qb.addFilterClause(`ev.date <= ${qb.pvar()}`, dateRange.end);
|
qb.addFilterClause(`ev.date <= ${qb.pvar()}`, dateRange.end);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dfClauses: string[] = [];
|
||||||
|
const dfParams: Date[] = [];
|
||||||
|
filterListToArray(filter.date).forEach((itm) => {
|
||||||
|
const dateRange = DateRange.parse(itm, true);
|
||||||
|
const cl = [];
|
||||||
|
if (dateRange?.start) {
|
||||||
|
cl.push(`ev.date >= ${qb.pvar()}`);
|
||||||
|
dfParams.push(dateRange.start);
|
||||||
|
}
|
||||||
|
if (dateRange?.end) {
|
||||||
|
cl.push(`ev.date <= ${qb.pvar()}`);
|
||||||
|
dfParams.push(dateRange.end);
|
||||||
|
}
|
||||||
|
dfClauses.push(cl.join(" and "));
|
||||||
|
});
|
||||||
|
if (dfClauses.length > 0) {
|
||||||
|
qb.addFilterClause(dfClauses.join(" or "), ...dfParams);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SORT_FIELDS: Record<string, string[]> = {
|
const SORT_FIELDS: Record<string, string[]> = {
|
||||||
|
|
|
@ -15,6 +15,7 @@ export function filterListToArray<T>(fl: FilterList<T>): T[] {
|
||||||
// @ts-expect-error checked if id is present
|
// @ts-expect-error checked if id is present
|
||||||
if (fl[0].id) {
|
if (fl[0].id) {
|
||||||
// @ts-expect-error checked if id is present
|
// @ts-expect-error checked if id is present
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
return fl.map((itm) => itm.id);
|
return fl.map((itm) => itm.id);
|
||||||
}
|
}
|
||||||
// @ts-expect-error output type checked
|
// @ts-expect-error output type checked
|
||||||
|
@ -129,6 +130,10 @@ class SearchQueryComponents {
|
||||||
* Supported search syntax:
|
* Supported search syntax:
|
||||||
* - Negative query `-word`
|
* - Negative query `-word`
|
||||||
* - Exact query `"word"`
|
* - Exact query `"word"`
|
||||||
|
*
|
||||||
|
* The last word in a search query is prefix-matched (i.e.
|
||||||
|
* the search returns all results starting with the given characters).
|
||||||
|
* This allows for meaningful results in Search-as-you-type applications.
|
||||||
*/
|
*/
|
||||||
export function parseSearchQuery(q: string): SearchQueryComponents {
|
export function parseSearchQuery(q: string): SearchQueryComponents {
|
||||||
const regexpParts = /(-)?(?:"([^"]*)"|([^"\s]+))(?:\s|$)/g;
|
const regexpParts = /(-)?(?:"([^"]*)"|([^"\s]+))(?:\s|$)/g;
|
||||||
|
@ -200,7 +205,7 @@ export class QueryBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add a simple filter checking for equality */
|
/** Add a simple filter checking for equality */
|
||||||
addFilter(fname: string, val: unknown | undefined): void {
|
addFilter(fname: string, val: unknown): void {
|
||||||
if (val === undefined) return;
|
if (val === undefined) return;
|
||||||
|
|
||||||
this.params.push(val);
|
this.params.push(val);
|
||||||
|
@ -214,7 +219,7 @@ export class QueryBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add a list filter (value matches any item from the filter list) */
|
/** Add a list filter (value matches any item from the filter list) */
|
||||||
addFilterList(fname: string, fl: FilterList<unknown> | undefined): void {
|
addFilterList(fname: string, fl: FilterList<unknown>): void {
|
||||||
if (fl === undefined) return;
|
if (fl === undefined) return;
|
||||||
|
|
||||||
this.filterClauses.push(`${fname} = any (${this.pvar()})`);
|
this.filterClauses.push(`${fname} = any (${this.pvar()})`);
|
||||||
|
|
|
@ -4,9 +4,7 @@ import { type inferAsyncReturnType, TRPCError } from "@trpc/server";
|
||||||
import type { User } from "$lib/shared/model";
|
import type { User } from "$lib/shared/model";
|
||||||
import { ZUser } from "$lib/shared/model/validation";
|
import { ZUser } from "$lib/shared/model/validation";
|
||||||
|
|
||||||
// we're not using the event parameter is this example,
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
// hence the eslint-disable rule
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
export async function createContext(event: RequestEvent): Promise<{ user: User }> {
|
export async function createContext(event: RequestEvent): Promise<{ user: User }> {
|
||||||
if (!event.locals.session?.user) {
|
if (!event.locals.session?.user) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { ErrorInvalidInput } from "$lib/shared/util/error";
|
|
||||||
|
|
||||||
import { t } from ".";
|
import { t } from ".";
|
||||||
import { categoryRouter } from "./routes/category";
|
import { categoryRouter } from "./routes/category";
|
||||||
import { entryRouter } from "./routes/entry";
|
import { entryRouter } from "./routes/entry";
|
||||||
|
@ -10,12 +8,6 @@ import { stationRouter } from "./routes/station";
|
||||||
import { userRouter } from "./routes/user";
|
import { userRouter } from "./routes/user";
|
||||||
|
|
||||||
export const router = t.router({
|
export const router = t.router({
|
||||||
greeting: t.procedure.query(
|
|
||||||
async () => `Hello tRPC @ ${new Date().toLocaleTimeString()}`,
|
|
||||||
),
|
|
||||||
testError: t.procedure.query(async () => {
|
|
||||||
throw new ErrorInvalidInput("here is your error");
|
|
||||||
}),
|
|
||||||
category: categoryRouter,
|
category: categoryRouter,
|
||||||
entry: entryRouter,
|
entry: entryRouter,
|
||||||
station: stationRouter,
|
station: stationRouter,
|
||||||
|
|
|
@ -77,6 +77,13 @@ function dateDiffInDays(a: Date, b: Date): number {
|
||||||
return Math.round((ts2 - ts1) / MS_PER_DAY);
|
return Math.round((ts2 - ts1) / MS_PER_DAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format a date with a human-readable format
|
||||||
|
* - If the date is within +/- 3 days, output a textual format ("heute", "morgen", "vor 2 Tagen")
|
||||||
|
* - Otherwise, format it as DD.MM.YYYY (or DD.MM.YYYY, hh:mm if `time=true`)
|
||||||
|
*
|
||||||
|
* @param [time=false] Enable time display
|
||||||
|
* @param [cap=false] Enable capitalized format
|
||||||
|
*/
|
||||||
export function humanDate(date: Date | string, time = false, cap = false): string {
|
export function humanDate(date: Date | string, time = false, cap = false): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dt = coerceDate(date);
|
const dt = coerceDate(date);
|
||||||
|
@ -96,7 +103,7 @@ export function humanDate(date: Date | string, time = false, cap = false): strin
|
||||||
if (diffDays !== 0) {
|
if (diffDays !== 0) {
|
||||||
if (diffDays === 1) return outstr("morgen");
|
if (diffDays === 1) return outstr("morgen");
|
||||||
if (diffDays === -1) return outstr("gestern");
|
if (diffDays === -1) return outstr("gestern");
|
||||||
return intl.format(diffDays, "day");
|
return outstr(intl.format(diffDays, "day"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time) {
|
if (time) {
|
||||||
|
@ -227,7 +234,7 @@ export function shiftDateRange(dateRange: DateRange, week: boolean, fwd: boolean
|
||||||
modDir = true;
|
modDir = true;
|
||||||
}
|
}
|
||||||
if (dateRange.end === null) {
|
if (dateRange.end === null) {
|
||||||
dateRange.end = new Date(dateRange.start!);
|
dateRange.end = new Date(dateRange.start);
|
||||||
modDir = false;
|
modDir = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,11 +38,11 @@ export function gotoEntityQuery(query: EntityQuery, basePath: string): void {
|
||||||
filter: { ...oldQuery.filter, ...query.filter },
|
filter: { ...oldQuery.filter, ...query.filter },
|
||||||
sort: query.sort,
|
sort: query.sort,
|
||||||
};
|
};
|
||||||
goto(getQueryUrl(newQuery, basePath));
|
void goto(getQueryUrl(newQuery, basePath));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
goto(getQueryUrl(query, basePath));
|
void goto(getQueryUrl(query, basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrap a page load query to handle occuring errors
|
/** Wrap a page load query to handle occuring errors
|
||||||
|
@ -158,7 +158,11 @@ export function defaultVisitUrl(): string {
|
||||||
}, URL_VISIT);
|
}, URL_VISIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveEntryTodoDate(id: number, nTodoDays: number, init?: TRPCClientInit) {
|
export async function moveEntryTodoDate(
|
||||||
|
id:number,
|
||||||
|
nTodoDays: number,
|
||||||
|
init?: TRPCClientInit,
|
||||||
|
): Promise<Date | null> {
|
||||||
if (nTodoDays > 0) {
|
if (nTodoDays > 0) {
|
||||||
const entry = await trpc(init).entry.get.query(id);
|
const entry = await trpc(init).entry.get.query(id);
|
||||||
const newDate = new Date();
|
const newDate = new Date();
|
||||||
|
@ -171,5 +175,7 @@ export async function moveEntryTodoDate(id: number, nTodoDays: number, init?: TR
|
||||||
date: utcDateToYMD(newDate),
|
date: utcDateToYMD(newDate),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return newDate;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { getContext } from "svelte";
|
||||||
import { derived, writable, type Writable } from "svelte/store";
|
import { derived, writable, type Writable } from "svelte/store";
|
||||||
|
|
||||||
import type { SavedFilter } from "$lib/shared/model";
|
import type { SavedFilter } from "$lib/shared/model";
|
||||||
|
@ -6,4 +7,6 @@ import type { SavedFilter } from "$lib/shared/model";
|
||||||
export const screenWidth = writable(0);
|
export const screenWidth = writable(0);
|
||||||
export const screenWidthSmall = derived(screenWidth, ($mainWidth) => $mainWidth < 500);
|
export const screenWidthSmall = derived(screenWidth, ($mainWidth) => $mainWidth < 500);
|
||||||
|
|
||||||
export const savedFilters: Writable<Record<string, SavedFilter[]>> = writable({});
|
// Context key: "savedFilters"
|
||||||
|
export type SavedFilters = Writable<Record<string, SavedFilter[]>>;
|
||||||
|
export const getSavedFilters: () => SavedFilters = () => getContext("savedFilters");
|
||||||
|
|
|
@ -3,22 +3,25 @@
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
|
|
||||||
import { mdiAccount, mdiHome } from "@mdi/js";
|
import { mdiAccount, mdiHome } from "@mdi/js";
|
||||||
|
import { setContext } from "svelte";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
import { defaultFilterUrl, defaultVisitUrl } from "$lib/shared/util";
|
import { defaultFilterUrl, defaultVisitUrl } from "$lib/shared/util";
|
||||||
|
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import NavLink from "$lib/components/ui/NavLink.svelte";
|
import NavLink from "$lib/components/ui/NavLink.svelte";
|
||||||
import { savedFilters } from "$lib/stores";
|
import type { SavedFilters } from "$lib/stores";
|
||||||
|
|
||||||
export let data: LayoutData;
|
export let data: LayoutData;
|
||||||
|
|
||||||
|
const savedFilters: SavedFilters = writable();
|
||||||
|
|
||||||
$: savedFilters.set(data.savedFilters);
|
$: savedFilters.set(data.savedFilters);
|
||||||
|
|
||||||
|
setContext("savedFilters", savedFilters);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="navbar-outer">
|
||||||
class="sticky top-0 z-30 flex h-12 w-full
|
|
||||||
justify-center bg-neutral text-neutral-content"
|
|
||||||
>
|
|
||||||
<nav class="navbar w-full min-h-12">
|
<nav class="navbar w-full min-h-12">
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<NavLink
|
<NavLink
|
||||||
|
@ -65,6 +68,22 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-w-[100vw] p-4 pb-8 flex flex-col gap-4">
|
<main>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
.navbar-outer {
|
||||||
|
@apply sticky top-0 z-30 flex h-12 w-full justify-center bg-neutral text-neutral-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
@apply max-w-[100vw] p-4 pb-8 flex flex-col gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.navbar-outer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -4,9 +4,11 @@
|
||||||
|
|
||||||
import { defaultFilterUrl } from "$lib/shared/util";
|
import { defaultFilterUrl } from "$lib/shared/util";
|
||||||
|
|
||||||
import { savedFilters } from "$lib/stores";
|
import { getSavedFilters } from "$lib/stores";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
|
const savedFilters = getSavedFilters();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
@ -35,7 +37,10 @@
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title">Visite</h2>
|
<h2 class="card-title">Visite</h2>
|
||||||
<p>Hier können sie Visitenbucheinträge abarbeiten.</p>
|
<p>Hier können sie Visitenbucheinträge abarbeiten.</p>
|
||||||
<p>Heute müssen {data.nTodo} Einträge erledigt werden.</p>
|
<p>Heute müssen
|
||||||
|
<span data-testid="n-entries-todo">{data.nTodo}</span>
|
||||||
|
Einträge erledigt werden.
|
||||||
|
</p>
|
||||||
<div class="card-actions justify-end">
|
<div class="card-actions justify-end">
|
||||||
<a class="btn btn-primary" href="/visit">Visite</a>
|
<a class="btn btn-primary" href="/visit">Visite</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table" data-testid="category-table">
|
||||||
<thead>
|
<thead>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Beschreibung</th>
|
<th>Beschreibung</th>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { superValidate, message } from "sveltekit-superforms";
|
||||||
|
|
||||||
import { ZUrlEntityId } from "$lib/shared/model/validation";
|
import { ZUrlEntityId } from "$lib/shared/model/validation";
|
||||||
import { trpc } from "$lib/shared/trpc";
|
import { trpc } from "$lib/shared/trpc";
|
||||||
import { loadWrap, moveEntryTodoDate } from "$lib/shared/util";
|
import { humanDate, loadWrap, moveEntryTodoDate } from "$lib/shared/util";
|
||||||
|
|
||||||
import { SchemaNewExecution } from "./schema";
|
import { SchemaNewExecution } from "./schema";
|
||||||
|
|
||||||
|
@ -22,18 +22,21 @@ export const actions: Actions = {
|
||||||
const done = todoDays === null;
|
const done = todoDays === null;
|
||||||
const nTodoDays = todoDays ? parseInt(todoDays.toString()) : 0;
|
const nTodoDays = todoDays ? parseInt(todoDays.toString()) : 0;
|
||||||
|
|
||||||
await loadWrap(async () => {
|
if (form.data.text.length > 0) {
|
||||||
await trpc(event).entry.newExecution.mutate({
|
await trpc(event).entry.newExecution.mutate({
|
||||||
id,
|
id,
|
||||||
old_execution_id: form.data.old_execution_id,
|
old_execution_id: form.data.old_execution_id,
|
||||||
execution: { text: form.data.text, done: todoDays === null },
|
execution: { text: form.data.text, done: todoDays === null },
|
||||||
});
|
});
|
||||||
await moveEntryTodoDate(id, nTodoDays, event);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nTodoDays > 0) {
|
|
||||||
return message(form, `Eintrag um ${nTodoDays} Tage in die Zukunft verschoben`);
|
|
||||||
}
|
}
|
||||||
|
const newTodoDate = await moveEntryTodoDate(id, nTodoDays, event);
|
||||||
|
|
||||||
|
if (newTodoDate) {
|
||||||
|
return message(form, `Eintrag auf ${humanDate(newTodoDate)} verschoben`);
|
||||||
|
}
|
||||||
|
if (form.data.text.length > 0) {
|
||||||
return message(form, done ? "Eintrag erledigt" : "Eintrag mit Notiz versehen");
|
return message(form, done ? "Eintrag erledigt" : "Eintrag mit Notiz versehen");
|
||||||
|
}
|
||||||
|
return { form };
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
import { superForm } from "sveltekit-superforms";
|
import { superForm } from "sveltekit-superforms";
|
||||||
|
@ -25,7 +24,7 @@
|
||||||
<EntryBody entry={data.entry} withExecution />
|
<EntryBody entry={data.entry} withExecution />
|
||||||
|
|
||||||
{#if !data.entry.execution?.done}
|
{#if !data.entry.execution?.done}
|
||||||
<form method="POST" use:enhance>
|
<form class="print:hidden" method="POST" use:enhance>
|
||||||
<MarkdownInput
|
<MarkdownInput
|
||||||
name="text"
|
name="text"
|
||||||
ariaInvalid={Boolean($errors.text)}
|
ariaInvalid={Boolean($errors.text)}
|
||||||
|
@ -33,7 +32,7 @@
|
||||||
label="Eintrag erledigen"
|
label="Eintrag erledigen"
|
||||||
bind:value={$form.text}
|
bind:value={$form.text}
|
||||||
>
|
>
|
||||||
<div class="row c-vlight gap-2">
|
<div class="row c-vlight gap-2 flex-wrap">
|
||||||
<button class="btn btn-sm btn-primary" type="submit">Erledigt</button>
|
<button class="btn btn-sm btn-primary" type="submit">Erledigt</button>
|
||||||
<EntryTodoButton />
|
<EntryTodoButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { superValidate } from "sveltekit-superforms";
|
||||||
|
|
||||||
import { ZUrlEntityId } from "$lib/shared/model/validation";
|
import { ZUrlEntityId } from "$lib/shared/model/validation";
|
||||||
import { trpc } from "$lib/shared/trpc";
|
import { trpc } from "$lib/shared/trpc";
|
||||||
import { loadWrap, moveEntryTodoDate } from "$lib/shared/util";
|
import { loadWrap } from "$lib/shared/util";
|
||||||
|
|
||||||
import { SchemaNewExecution } from "../schema";
|
import { SchemaNewExecution } from "../schema";
|
||||||
|
|
||||||
|
@ -29,9 +29,6 @@ export const actions: Actions = {
|
||||||
old_execution_id: form.data.old_execution_id,
|
old_execution_id: form.data.old_execution_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nTodoDays = todoDays ? parseInt(todoDays.toString()) : 0;
|
|
||||||
await moveEntryTodoDate(id, nTodoDays, event);
|
|
||||||
|
|
||||||
redirect(302, `/entry/${id}`);
|
redirect(302, `/entry/${id}`);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
import { superformConfig } from "$lib/shared/util";
|
import { superformConfig } from "$lib/shared/util";
|
||||||
|
|
||||||
import EntryBody from "$lib/components/entry/EntryBody.svelte";
|
import EntryBody from "$lib/components/entry/EntryBody.svelte";
|
||||||
import EntryTodoButton from "$lib/components/ui/EntryTodoButton.svelte";
|
|
||||||
import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte";
|
import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte";
|
||||||
|
|
||||||
import { SchemaNewExecution } from "../schema";
|
import { SchemaNewExecution } from "../schema";
|
||||||
|
@ -32,7 +31,9 @@
|
||||||
>
|
>
|
||||||
<div class="row c-vlight gap-2">
|
<div class="row c-vlight gap-2">
|
||||||
<button class="btn btn-sm btn-primary" type="submit">Speichern</button>
|
<button class="btn btn-sm btn-primary" type="submit">Speichern</button>
|
||||||
<EntryTodoButton />
|
<button name="todo" class="join-item btn btn-sm" type="submit" value="0">
|
||||||
|
Notiz
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</MarkdownInput>
|
</MarkdownInput>
|
||||||
<input name="old_execution_id" type="hidden" bind:value={$form.old_execution_id} />
|
<input name="old_execution_id" type="hidden" bind:value={$form.old_execution_id} />
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if version.text.length > 0}
|
{#if version.text.length > 0}
|
||||||
<div class="rowb whitespace-pre-wrap">
|
<div class="row whitespace-pre-wrap block">
|
||||||
{#each version.text as change}
|
{#each version.text as change}
|
||||||
<span class:added={change.added} class:removed={change.removed}>
|
<span class:added={change.added} class:removed={change.removed}>
|
||||||
{change.value}
|
{change.value}
|
||||||
|
|
|
@ -5,6 +5,6 @@
|
||||||
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
|
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
|
||||||
<h1 class="text-xl mt-4">Möchten sie sich abmelden?</h1>
|
<h1 class="text-xl mt-4">Möchten sie sich abmelden?</h1>
|
||||||
<form method="POST" use:enhance>
|
<form method="POST" use:enhance>
|
||||||
<button class="btn btn-primary mt-4" type="submit">Abmelden</button>
|
<button class="btn btn-primary mt-4" data-testid="btn-logout" type="submit">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table" data-testid="room-table">
|
||||||
<thead>
|
<thead>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Station</th>
|
<th>Station</th>
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.rooms as room (room.id)}
|
{#each data.rooms as room (room.id)}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a class="btn btn-sm" href="/room/{room.id}">{room.name}</a></td>
|
<td><a class="btn btn-sm btn-id" href="/room/{room.id}">{room.name}</a></td>
|
||||||
<td>{room.station.name}</td>
|
<td>{room.station.name}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table" data-testid="stations-table">
|
||||||
<thead>
|
<thead>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
{#each data.stations as station (station.id)}
|
{#each data.stations as station (station.id)}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a class="btn btn-sm" href="/station/{station.id}">{station.name}</a>
|
<a class="btn btn-sm btn-id" href="/station/{station.id}">{station.name}</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { toast } from "@zerodevx/svelte-toast";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button class="btn" on:click={() => toast.push({ msg: "Hello" })}>Ok</button>
|
|
||||||
<button class="btn" on:click={() => toast.push({ msg: "Error", classes: ["toast-error"] })}>Error</button>
|
|
||||||
</div>
|
|
|
@ -77,18 +77,21 @@
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// Update page URL
|
// Update page URL
|
||||||
const url = getQueryUrl(q, URL_VISIT);
|
const url = getQueryUrl(q, URL_VISIT);
|
||||||
goto(url, { replaceState: true, keepFocus: true });
|
void goto(url, { replaceState: true, keepFocus: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Visite</title>
|
<title>Visite {dateRange.format()}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex gap-4 items-baseline">
|
||||||
<h1 class="heading">Visite</h1>
|
<h1 class="heading">Visite</h1>
|
||||||
|
<span class="hidden print:block">{dateRange.format()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 justify-between">
|
<div class="flex flex-wrap gap-2 justify-between print:hidden">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<!-- <span>Zeitraum:</span> -->
|
<!-- <span>Zeitraum:</span> -->
|
||||||
<WeekSelector onSelect={filterUpdate} bind:dateRange />
|
<WeekSelector onSelect={filterUpdate} bind:dateRange />
|
||||||
|
@ -118,7 +121,7 @@
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{#each data.groups as group}
|
{#each data.groups as group}
|
||||||
{@const first = group.items[0] ?? group.prio[0]}
|
{@const first = group.items[0] ?? group.prio[0]}
|
||||||
<div class="bg-base-content/15 rounded-xl px-2 font-bold">
|
<div class="bg-base-content/15 rounded-xl px-2 font-bold print:hidden">
|
||||||
{#if data.groupByStation}
|
{#if data.groupByStation}
|
||||||
{#if first.patient.room}
|
{#if first.patient.room}
|
||||||
Station {first.patient.room?.station.name}
|
Station {first.patient.room?.station.name}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { LayoutServerLoad } from "./$types";
|
import type { LayoutServerLoad } from "./$types";
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async (event) => ({
|
export const load: LayoutServerLoad = (event) => ({
|
||||||
session: event.locals.session,
|
session: event.locals.session,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "../app.pcss"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths
|
import "../app.pcss";
|
||||||
|
|
||||||
import { navigating } from "$app/stores";
|
import { navigating } from "$app/stores";
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
const options = { pausable: true };
|
const options = { pausable: true };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LoadingBar bind:this={loadingBar} cls="fixed top-0 left-0 z-50" />
|
<LoadingBar bind:this={loadingBar} fixedTop />
|
||||||
<SvelteToast {options} />
|
<SvelteToast {options} />
|
||||||
|
|
||||||
<div class="bg-base-100 text-base-content" bind:clientWidth={$screenWidth}>
|
<div class="bg-base-100 text-base-content" bind:clientWidth={$screenWidth}>
|
||||||
|
|
|
@ -5,6 +5,6 @@
|
||||||
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
|
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
|
||||||
<h1 class="text-4xl mt-4">Visitenbuch</h1>
|
<h1 class="text-4xl mt-4">Visitenbuch</h1>
|
||||||
<form method="POST" use:enhance>
|
<form method="POST" use:enhance>
|
||||||
<button class="btn btn-primary mt-4" type="submit">Anmelden</button>
|
<button class="btn btn-primary mt-4" data-testid="btn-login" type="submit">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
90
tests/e2e/_test.ts
Normal file
90
tests/e2e/_test.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { encode } from "@auth/core/jwt";
|
||||||
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { prisma } from "$lib/server/prisma";
|
||||||
|
|
||||||
|
export const OIDC_BASE_URL = process.env.KEYCLOAK_ISSUER + "/";
|
||||||
|
const AUTH_COOKIE = "authjs.session-token";
|
||||||
|
|
||||||
|
type Account = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
accessToken: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function isLoggedIn(page: Page): Promise<boolean> {
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
return cookies.findIndex((c) => c.name === AUTH_COOKIE) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newSessionToken(user: Account): Promise<string> {
|
||||||
|
return encode({
|
||||||
|
salt: AUTH_COOKIE,
|
||||||
|
secret: process.env.AUTH_SECRET!,
|
||||||
|
token: {
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
sub: user.id.toString(),
|
||||||
|
id: user.id.toString(),
|
||||||
|
accessToken: user.accessToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<object, {
|
||||||
|
account: Account,
|
||||||
|
}>({
|
||||||
|
account: [async ({ browser }, use, workerInfo) => {
|
||||||
|
// Unique username
|
||||||
|
const name = "user" + workerInfo.workerIndex;
|
||||||
|
const email = name + "@example.org";
|
||||||
|
|
||||||
|
// Create the account
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
if (page.url().startsWith(OIDC_BASE_URL)) {
|
||||||
|
await page.locator('input[name="login"]').fill(name);
|
||||||
|
await page.locator('input[name="password"]').fill("1234");
|
||||||
|
await page.locator("button.login-submit").click();
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
expect(await isLoggedIn(page)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user data
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
accounts: {
|
||||||
|
select: { access_token: true },
|
||||||
|
where: { provider: "keycloak" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
expect(user.accounts.length).toBe(1);
|
||||||
|
|
||||||
|
// Use the account data when calling the fixture
|
||||||
|
await use({
|
||||||
|
id: user.id, name, email, accessToken: user.accounts[0].access_token,
|
||||||
|
});
|
||||||
|
}, { scope: "worker" }],
|
||||||
|
}).extend<{
|
||||||
|
login: Page,
|
||||||
|
}>({
|
||||||
|
login: async ({ page, account }, use) => {
|
||||||
|
const token = await newSessionToken(account);
|
||||||
|
await page.context().addCookies([{
|
||||||
|
name: AUTH_COOKIE,
|
||||||
|
value: token,
|
||||||
|
domain: "localhost",
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
}]);
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from "@playwright/test";
|
37
tests/e2e/login.test.ts
Normal file
37
tests/e2e/login.test.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import {
|
||||||
|
test, expect, isLoggedIn, OIDC_BASE_URL,
|
||||||
|
} from "./_test";
|
||||||
|
|
||||||
|
test("login", async ({ login: page, account }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page).toHaveTitle("Visitenbuch");
|
||||||
|
await expect(page.locator("h1.heading")).toHaveText("Hallo, " + account.name);
|
||||||
|
// Test cases may create more entries
|
||||||
|
expect(parseInt(await page.getByTestId("n-entries-todo").innerText()))
|
||||||
|
.toBeGreaterThanOrEqual(193);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout", async ({ login: page, baseURL }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.goto("/logout");
|
||||||
|
await page.getByTestId("btn-logout").click();
|
||||||
|
|
||||||
|
// Sometimes the OIDC provider asks for login confirmation
|
||||||
|
if (page.url().startsWith(OIDC_BASE_URL)) {
|
||||||
|
await page.locator('button[value="yes"]').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForURL("/login?noAuto=1");
|
||||||
|
await expect(page.getByTestId("btn-login")).toBeVisible();
|
||||||
|
expect(await isLoggedIn(page)).toBe(false);
|
||||||
|
|
||||||
|
// Check if application is not accessible if unauthorized
|
||||||
|
await page.goto("/plan");
|
||||||
|
expect(page.url() === baseURL + "/login?returnURL=%2Fplan" || page.url().startsWith(OIDC_BASE_URL)).toBe(true);
|
||||||
|
|
||||||
|
// Check if TRPC API is not accessible if unauthorized
|
||||||
|
const apiResponse = await page.context().request.get("/trpc/savedFilter.getAll");
|
||||||
|
expect(apiResponse.status()).toBe(401);
|
||||||
|
expect(await apiResponse.json()).toMatchObject({ error: { message: "not logged in" } });
|
||||||
|
});
|
25
tests/e2e/plan.test.ts
Normal file
25
tests/e2e/plan.test.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { test, expect } from "./_test";
|
||||||
|
|
||||||
|
test("filter", async ({ login: page }) => {
|
||||||
|
await page.goto("/plan");
|
||||||
|
await expect(page).toHaveTitle("Planung");
|
||||||
|
|
||||||
|
const filterbar = page.locator(".filterbar-outer");
|
||||||
|
const filterIn = filterbar.getByPlaceholder("Filter");
|
||||||
|
await filterIn.click();
|
||||||
|
await filterbar.getByRole("option", { name: "Kategorie" }).click();
|
||||||
|
await filterbar.getByRole("option", { name: "Laborabnahme" }).click();
|
||||||
|
await filterIn.click();
|
||||||
|
await filterbar.getByRole("option", { name: "Zimmer" }).click();
|
||||||
|
await filterbar.getByRole("option", { name: "R1.5" }).click();
|
||||||
|
await filterIn.click();
|
||||||
|
await filterbar.getByRole("option", { name: "Autor" }).click();
|
||||||
|
await filterbar.getByRole("option", { name: "Akeem Wisozk" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL("http://localhost:4173/plan?filter%5Bcategory%5D%5B0%5D%5Bid%5D=1&filter%5Bcategory%5D%5B0%5D%5Bname%5D=Laborabnahme&filter%5Broom%5D%5B0%5D%5Bid%5D=5&filter%5Broom%5D%5B0%5D%5Bname%5D=R1.5&filter%5Bauthor%5D%5B0%5D%5Bid%5D=5&filter%5Bauthor%5D%5B0%5D%5Bname%5D=Akeem%20Wisozk");
|
||||||
|
|
||||||
|
const table = page.getByTestId("entry-table");
|
||||||
|
const firstRow = table.locator("tbody > tr").first();
|
||||||
|
|
||||||
|
await expect(firstRow.locator("td > a").first()).toHaveText("275");
|
||||||
|
});
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable no-await-in-loop */
|
/* eslint-disable no-await-in-loop */
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { performance } from "perf_hooks";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
|
@ -29,9 +30,17 @@ function randomId(len: number): number {
|
||||||
return faker.number.int({ min: 1, max: len - 1 });
|
return faker.number.int({ min: 1, max: len - 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function randomUserId(): number {
|
||||||
|
return randomId(N_USERS) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset database and create extensive test data for development and E2E tests */
|
||||||
export default async () => {
|
export default async () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
// Reset database
|
// Reset database
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
|
prisma.savedFilter.deleteMany(),
|
||||||
prisma.entryExecution.deleteMany(),
|
prisma.entryExecution.deleteMany(),
|
||||||
prisma.entryVersion.deleteMany(),
|
prisma.entryVersion.deleteMany(),
|
||||||
prisma.entry.deleteMany(),
|
prisma.entry.deleteMany(),
|
||||||
|
@ -58,9 +67,16 @@ export default async () => {
|
||||||
const entryMockdata: MockEntry[] = file
|
const entryMockdata: MockEntry[] = file
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((l) => JSON.parse(l));
|
.map((l) => JSON.parse(l) as MockEntry);
|
||||||
|
|
||||||
for (let i = 1; i <= N_USERS; i++) {
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
name: "Tico Testboy",
|
||||||
|
email: "t.testboy@example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (let i = 2; i <= N_USERS + 1; i++) {
|
||||||
const firstName = faker.person.firstName();
|
const firstName = faker.person.firstName();
|
||||||
const lastName = faker.person.lastName();
|
const lastName = faker.person.lastName();
|
||||||
|
|
||||||
|
@ -109,7 +125,7 @@ export default async () => {
|
||||||
await prisma.entryVersion.create({
|
await prisma.entryVersion.create({
|
||||||
data: {
|
data: {
|
||||||
entry_id: entry.id,
|
entry_id: entry.id,
|
||||||
author_id: randomId(N_USERS),
|
author_id: randomUserId(),
|
||||||
category_id: CATEGORY_IDS[e.category],
|
category_id: CATEGORY_IDS[e.category],
|
||||||
date: todo_date,
|
date: todo_date,
|
||||||
priority,
|
priority,
|
||||||
|
@ -123,7 +139,7 @@ export default async () => {
|
||||||
await prisma.entryVersion.create({
|
await prisma.entryVersion.create({
|
||||||
data: {
|
data: {
|
||||||
entry_id: entry.id,
|
entry_id: entry.id,
|
||||||
author_id: randomId(N_USERS),
|
author_id: randomUserId(),
|
||||||
category_id: CATEGORY_IDS[e.category],
|
category_id: CATEGORY_IDS[e.category],
|
||||||
date: todo_date,
|
date: todo_date,
|
||||||
priority,
|
priority,
|
||||||
|
@ -137,7 +153,7 @@ export default async () => {
|
||||||
await prisma.entryExecution.create({
|
await prisma.entryExecution.create({
|
||||||
data: {
|
data: {
|
||||||
entry_id: entry.id,
|
entry_id: entry.id,
|
||||||
author_id: randomId(N_USERS),
|
author_id: randomUserId(),
|
||||||
text: e.result,
|
text: e.result,
|
||||||
created_at: faker.date.soon({ refDate: todo_date, days: 2 }),
|
created_at: faker.date.soon({ refDate: todo_date, days: 2 }),
|
||||||
},
|
},
|
||||||
|
@ -146,7 +162,7 @@ export default async () => {
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.$executeRawUnsafe(
|
prisma.$executeRawUnsafe(
|
||||||
`alter sequence users_id_seq restart with ${N_USERS + 1}`,
|
`alter sequence users_id_seq restart with ${N_USERS + 2}`,
|
||||||
),
|
),
|
||||||
prisma.$executeRawUnsafe(
|
prisma.$executeRawUnsafe(
|
||||||
`alter sequence categories_id_seq restart with ${CATEGORIES.length + 1}`,
|
`alter sequence categories_id_seq restart with ${CATEGORIES.length + 1}`,
|
||||||
|
@ -162,4 +178,6 @@ export default async () => {
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Generated mock data in ${performance.now() - startTime} ms`);
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
CATEGORIES, ROOMS, STATIONS, USERS,
|
CATEGORIES, ROOMS, STATIONS, USERS,
|
||||||
} from "./testdata";
|
} from "./testdata";
|
||||||
|
|
||||||
|
/** Reset database and create basic test data for integration tests */
|
||||||
export default async () => {
|
export default async () => {
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.savedFilter.deleteMany(),
|
prisma.savedFilter.deleteMany(),
|
||||||
|
|
|
@ -26,7 +26,7 @@ test("get categories", async () => {
|
||||||
|
|
||||||
test("delete categories", async () => {
|
test("delete categories", async () => {
|
||||||
await deleteCategory(6);
|
await deleteCategory(6);
|
||||||
expect(getCategory(6)).rejects.toThrowError("No Category found");
|
await expect(getCategory(6)).rejects.toThrowError("No Category found");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hide category", async () => {
|
test("hide category", async () => {
|
||||||
|
|
|
@ -149,7 +149,7 @@ test("create entry version (wrong old vid)", async () => {
|
||||||
});
|
});
|
||||||
const entry = await getEntry(eId);
|
const entry = await getEntry(eId);
|
||||||
|
|
||||||
expect(async () => {
|
await expect(async () => {
|
||||||
await newEntryVersion(
|
await newEntryVersion(
|
||||||
1,
|
1,
|
||||||
eId,
|
eId,
|
||||||
|
@ -202,7 +202,7 @@ test("create entry execution (wrong old xid)", async () => {
|
||||||
});
|
});
|
||||||
const x1 = await newEntryExecution(1, eId, { text: "x1", done: true }, null);
|
const x1 = await newEntryExecution(1, eId, { text: "x1", done: true }, null);
|
||||||
|
|
||||||
expect(async () => newEntryExecution(1, eId, { text: "x2", done: true }, x1 + 1)).rejects.toThrowError(new ErrorConflict("old execution id does not match"));
|
await expect(async () => newEntryExecution(1, eId, { text: "x2", done: true }, x1 + 1)).rejects.toThrowError(new ErrorConflict("old execution id does not match"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("get entries", async () => {
|
test("get entries", async () => {
|
||||||
|
|
|
@ -49,7 +49,7 @@ test("update patient", async () => {
|
||||||
|
|
||||||
test("delete patient", async () => {
|
test("delete patient", async () => {
|
||||||
await deletePatient(1);
|
await deletePatient(1);
|
||||||
expect(async () => getPatient(1)).rejects.toThrowError("No Patient found");
|
await expect(async () => getPatient(1)).rejects.toThrowError("No Patient found");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delete patient (restricted)", async () => {
|
test("delete patient (restricted)", async () => {
|
||||||
|
@ -64,7 +64,7 @@ test("delete patient (restricted)", async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(async () => deletePatient(pId)).rejects.toThrowError();
|
await expect(async () => deletePatient(pId)).rejects.toThrowError();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hide patient", async () => {
|
test("hide patient", async () => {
|
||||||
|
|
|
@ -37,7 +37,7 @@ test("update room", async () => {
|
||||||
|
|
||||||
test("delete room", async () => {
|
test("delete room", async () => {
|
||||||
await deleteRoom(ROOMS[3].id);
|
await deleteRoom(ROOMS[3].id);
|
||||||
expect(async () => getRoom(ROOMS[3].id)).rejects.toThrowError("No Room found");
|
await expect(async () => getRoom(ROOMS[3].id)).rejects.toThrowError("No Room found");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hide room", async () => {
|
test("hide room", async () => {
|
||||||
|
|
|
@ -96,7 +96,7 @@ test("update filter", async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("update filter not found", async () => {
|
test("update filter not found", async () => {
|
||||||
expect(updateSavedFilter(1, "Hello World", 1)).rejects.toThrowError();
|
await expect(updateSavedFilter(1, "Hello World", 1)).rejects.toThrowError();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delete filter", async () => {
|
test("delete filter", async () => {
|
||||||
|
|
|
@ -25,7 +25,7 @@ test("update station", async () => {
|
||||||
|
|
||||||
test("delete station", async () => {
|
test("delete station", async () => {
|
||||||
await deleteStation(S3.id);
|
await deleteStation(S3.id);
|
||||||
expect(async () => getStation(S3.id)).rejects.toThrowError("No Station found");
|
await expect(async () => getStation(S3.id)).rejects.toThrowError("No Station found");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hide station", async () => {
|
test("hide station", async () => {
|
||||||
|
|
|
@ -16,7 +16,7 @@ test("create entry", async () => {
|
||||||
});
|
});
|
||||||
expect(eID).gt(0);
|
expect(eID).gt(0);
|
||||||
|
|
||||||
const entry = await caller().entry.get(eID!);
|
const entry = await caller().entry.get(eID);
|
||||||
expect(entry.patient.id).toBe(1);
|
expect(entry.patient.id).toBe(1);
|
||||||
expect(entry.execution).toBeNull();
|
expect(entry.execution).toBeNull();
|
||||||
expect(entry.current_version.id).gt(0);
|
expect(entry.current_version.id).gt(0);
|
||||||
|
|
|
@ -2,12 +2,12 @@ import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
|
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import { createViteLicensePlugin } from "rollup-license-plugin";
|
import { createViteLicensePlugin, type LicenseMeta } from "rollup-license-plugin";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
// Get current tag/commit and last commit date from git
|
// Get current tag/commit and last commit date from git
|
||||||
const pexec = promisify(exec);
|
const pexec = promisify(exec);
|
||||||
let [version, lastmod] = (
|
const [version, lastmod] = (
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
pexec("git describe --tags || git rev-parse --short HEAD"),
|
pexec("git describe --tags || git rev-parse --short HEAD"),
|
||||||
pexec('git log -1 --format=%cd --date=format:"%Y-%m-%d %H:%M"'),
|
pexec('git log -1 --format=%cd --date=format:"%Y-%m-%d %H:%M"'),
|
||||||
|
@ -23,7 +23,8 @@ export default defineConfig({
|
||||||
createViteLicensePlugin({
|
createViteLicensePlugin({
|
||||||
additionalFiles: {
|
additionalFiles: {
|
||||||
"oss-licenses.html": (packages) => {
|
"oss-licenses.html": (packages) => {
|
||||||
let res = `<html>
|
let res = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Visitenbuch - Lizenzen</title>
|
<title>Visitenbuch - Lizenzen</title>
|
||||||
</head>
|
</head>
|
||||||
|
@ -31,13 +32,23 @@ export default defineConfig({
|
||||||
<h1>Open-Source-Lizenzen</h1>
|
<h1>Open-Source-Lizenzen</h1>
|
||||||
<a href="./oss-licenses.json">JSON-formatted license list</a>
|
<a href="./oss-licenses.json">JSON-formatted license list</a>
|
||||||
`;
|
`;
|
||||||
for (const p of packages) {
|
const escapeHTML = (s: string | null) => s ? s.replaceAll("&", "&")
|
||||||
// @ts-expect-error repo not present in type definition
|
.replaceAll("<", "<")
|
||||||
let rp = p.repository;
|
.replaceAll(">", ">")
|
||||||
// @ts-expect-error author not present in type definition
|
.replaceAll('"', """)
|
||||||
let aut = p.author;
|
.replaceAll("'", "'") : "";
|
||||||
if (typeof aut === "object") {
|
|
||||||
aut = aut.name;
|
for (const _p of packages) {
|
||||||
|
type LicenseMetaExt = LicenseMeta & {
|
||||||
|
repository: string | null,
|
||||||
|
author: string | { name: string } | null
|
||||||
|
};
|
||||||
|
const p = _p as LicenseMetaExt;
|
||||||
|
const rp = p.repository;
|
||||||
|
let aut = null;
|
||||||
|
if (p.author) {
|
||||||
|
if (typeof p.author === "object" && p.author.name) aut = p.author.name;
|
||||||
|
else if (typeof p.author === "string") aut = p.author;
|
||||||
}
|
}
|
||||||
|
|
||||||
let repoUrl = null;
|
let repoUrl = null;
|
||||||
|
@ -50,13 +61,13 @@ export default defineConfig({
|
||||||
}
|
}
|
||||||
|
|
||||||
res += `<div class="package">\n`;
|
res += `<div class="package">\n`;
|
||||||
res += `<h3><a href="https://www.npmjs.com/package/${p.name}" target="_blank" rel="noopener noreferrer">${p.name}</a></h3>\n`;
|
res += `<h3><a href="https://www.npmjs.com/package/${escapeHTML(p.name)}" target="_blank" rel="noopener noreferrer">${escapeHTML(p.name)}</a></h3>\n`;
|
||||||
res += `<table>\n`;
|
res += `<table>\n`;
|
||||||
res += `<tr><td>Version:</td><td>${p.version}</td></tr>\n`;
|
res += `<tr><td>Version:</td><td>${escapeHTML(p.version)}</td></tr>\n`;
|
||||||
if (aut) res += `<tr><td>Author:</td><td>${aut}</td></tr>\n`;
|
if (aut) res += `<tr><td>Author:</td><td>${escapeHTML(aut)}</td></tr>\n`;
|
||||||
res += `<tr><td>License:</td><td>${p.license}</td></tr>\n`;
|
res += `<tr><td>License:</td><td>${escapeHTML(p.license)}</td></tr>\n`;
|
||||||
if (repoUrl) res += `<tr><td>Repository:</td><td><a href="${repoUrl}" target="_blank" rel="noopener noreferrer">${repoUrl}</a></td></tr>\n`;
|
if (repoUrl) res += `<tr><td>Repository:</td><td><a href="${escapeHTML(repoUrl)}" target="_blank" rel="noopener noreferrer">${escapeHTML(repoUrl)}</a></td></tr>\n`;
|
||||||
else if (rp) res += `<tr><td>Repository:</td><td>${rp}</td></tr>\n`;
|
else if (rp) res += `<tr><td>Repository:</td><td>${escapeHTML(rp)}</td></tr>\n`;
|
||||||
res += `</table>\n`;
|
res += `</table>\n`;
|
||||||
res += "</div>";
|
res += "</div>";
|
||||||
}
|
}
|
|
@ -4,6 +4,11 @@
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const DATABASE_URL = process.env.TEST_DATABASE_URL ?? "postgresql://postgres:1234@localhost:5432/test?schema=public";
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("TEST_DATABASE_URL", DATABASE_URL);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
test: {
|
test: {
|
||||||
|
@ -13,8 +18,7 @@ export default defineConfig({
|
||||||
maxConcurrency: 1,
|
maxConcurrency: 1,
|
||||||
setupFiles: ["tests/helpers/setup.ts"],
|
setupFiles: ["tests/helpers/setup.ts"],
|
||||||
env: {
|
env: {
|
||||||
// eslint-disable-next-line no-undef
|
DATABASE_URL,
|
||||||
DATABASE_URL: process.env.TEST_DATABASE_URL ?? "postgresql://postgres:1234@localhost:5432/test?schema=public",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue