diff --git a/.dockerignore b/.dockerignore index 91f5648..8567a5b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,8 +2,11 @@ node_modules /.svelte-kit /package +/playwright-report .env .env.* -!.env.example +.eslintcache vite.config.js.timestamp-* vite.config.ts.timestamp-* +vitest.config.*.js.timestamp-* +vitest.config.*.ts.timestamp-* diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 4c334fb..c286741 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -16,9 +16,19 @@ jobs: env: POSTGRES_DB: "test" 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: 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: - name: 👁️ Checkout repository uses: actions/checkout@v4 @@ -28,14 +38,26 @@ jobs: cp .env.test .env - name: 🧐 lint run: | - npm run check - npm run lint + pnpm run check + pnpm run lint - name: 🧪 Unit test - run: npm run test:unit + run: pnpm run test:unit - name: 🧪 Integration test run: | 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: runs-on: cimaster-latest @@ -45,11 +67,13 @@ jobs: steps: - name: 👁️ Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 # important to fetch tag logs - name: 📦 pnpm install run: pnpm install - name: ⚒️ Build web application - run: npm run build + run: pnpm run build - name: 🐋 Build docker image uses: https://code.thetadev.de/ThetaDev/action-kaniko@v1 with: @@ -78,4 +102,4 @@ jobs: - name: 🚀 Deploy to server if: ${{ startsWith(github.ref, 'refs/tags/v') }} 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 diff --git a/.gitignore b/.gitignore index cdc9c6b..2b7e811 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ node_modules /build /.svelte-kit /package +/playwright-report .env +.eslintcache vite.config.js.timestamp-* vite.config.ts.timestamp-* +vitest.config.*.js.timestamp-* +vitest.config.*.ts.timestamp-* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 24bca84..6d1751c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,4 +13,5 @@ repos: entry: npx eslint args: - "--max-warnings=0" + - "--cache" files: \.(js|ts|svelte)$ diff --git a/CHANGELOG.md b/CHANGELOG.md index f556d50..0540fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,68 @@ 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 ### 🚀 Features diff --git a/Dockerfile b/Dockerfile index 52715d0..f0631df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,12 @@ COPY package.json pnpm-lock.yaml run/entrypoint.sh ./ COPY prisma ./prisma # 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 build ./ diff --git a/README.md b/README.md index 1f1a26e..cc42fac 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,34 @@ for the university hospital in Augsburg ## 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 -npm run dev +pnpm run dev # 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 -./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 # 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: ```bash -npm run build +pnpm run build ``` 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 @@ -42,3 +63,10 @@ To release a new version, tun the release script with the new version as a param ```bash ./release.sh 1.0.0 ``` + +### Building docker image + +```bash +pnpm run build +docker build -t thetadev256/visitenbuch . +``` diff --git a/eslint.config.js b/eslint.config.js index 9421a96..3d6696e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,7 +14,7 @@ import ts from "typescript-eslint"; export default [ js.configs.recommended, - ...ts.configs.recommended, + ...ts.configs.recommendedTypeChecked, ...svelte.configs["flat/recommended"], // TS-Svelte { @@ -585,10 +585,16 @@ export default [ }, ], "@typescript-eslint/return-await": "error", - "@typescript-eslint/no-shadow": ["error", { allow: ["i", "j"] }], "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/", "*.config.cjs", "vite.config.js.timestamp-*", - "vite.config.ts.timestamp-*", + "vitest.config.*.timestamp-*", ".tmp/", ], }, diff --git a/package.json b/package.json index 1f69a82..1bde82a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "visitenbuch", - "version": "0.3.1", + "version": "0.3.5", "private": true, "license": "AGPL-3.0", "scripts": { @@ -10,7 +10,7 @@ "test": "vitest --run && vitest --config vitest.config.integration.js --run", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "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", "test:unit": "vitest", "test:integration": "vitest --config vitest.config.integration.js", @@ -28,8 +28,7 @@ "qs": "^6.12.1", "set-cookie-parser": "^2.6.0", "svelte-floating-ui": "^1.5.8", - "zod": "^3.23.8", - "zod-form-data": "^2.0.2" + "zod": "^3.23.8" }, "devDependencies": { "@faker-js/faker": "^8.4.1", diff --git a/playwright.config.js b/playwright.config.js index 3d145d0..ed4ee46 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,10 +1,21 @@ import { defineConfig } from "@playwright/test"; +// eslint-disable-next-line no-undef +const CI = Boolean(process.env.CI); + export default defineConfig({ webServer: { - command: "npm run build && npm run preview", + command: "npm run preview -m test", 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/, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e977f6..c90e8bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,9 +41,6 @@ dependencies: zod: specifier: ^3.23.8 version: 3.23.8 - zod-form-data: - specifier: ^2.0.2 - version: 2.0.2(zod@3.23.8) devDependencies: '@faker-js/faker': @@ -5961,14 +5958,6 @@ packages: dev: 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): resolution: {integrity: sha512-az0uJ243PxsRIa2x1WmNE/pnuA05gUq/JB8Lwe1EDCCL/Fz9MgjYQ0fPlyc2Tcv6aF2ZA7WM5TWaRZVEFaAIag==} requiresBuild: true diff --git a/prisma/migrations/20240518143538_unique_cat_room_station/migration.sql b/prisma/migrations/20240518143538_unique_cat_room_station/migration.sql new file mode 100644 index 0000000..7686fa8 --- /dev/null +++ b/prisma/migrations/20240518143538_unique_cat_room_station/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 633705d..bfc2700 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,7 +50,7 @@ model User { // Hospital station model Station { id Int @id @default(autoincrement()) - name String + name String @unique Room Room[] hidden Boolean @default(false) @@ -60,7 +60,7 @@ model Station { // Hospital room model Room { id Int @id @default(autoincrement()) - name String + name String @unique station Station @relation(fields: [station_id], references: [id], onDelete: Restrict) station_id Int Patient Patient[] @@ -90,7 +90,7 @@ model Patient { // Entry category (e.g. Blood test, Exams, ...) model Category { id Int @id @default(autoincrement()) - name String + name String @unique color String? description String? EntryVersion EntryVersion[] diff --git a/run/entrypoint.sh b/run/entrypoint.sh index c749cb0..f7ebe95 100755 --- a/run/entrypoint.sh +++ b/run/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/dumb-init /bin/sh set -e # Migrate database before starting server npx prisma migrate deploy diff --git a/src/app.d.ts b/src/app.d.ts index 13a994c..b86d0aa 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -8,7 +8,9 @@ declare global { interface Locals { session: Session | null; } - // interface PageData {} + interface PageData { + session: Session | null; + } // interface Platform {} } diff --git a/src/app.html b/src/app.html index 5f0dd88..50038b5 100644 --- a/src/app.html +++ b/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/src/app.pcss b/src/app.pcss index 244e176..801af2d 100644 --- a/src/app.pcss +++ b/src/app.pcss @@ -43,6 +43,7 @@ button { text-align: initial; + user-select: text; } .heading { @@ -72,10 +73,6 @@ button { .row { @apply flex; - } - - .row, - .rowb { @apply p-2; @apply border-t border-solid border-base-content/30; } @@ -109,3 +106,77 @@ button { 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; + } +} diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 4aac5a8..4cc17f9 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -3,7 +3,7 @@ import { TRPCClientError } from "@trpc/client"; 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 // "Internal error" message. The most common errors should be mapped to a more // detailed description diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9a0c57d..87c81a0 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -31,7 +31,3 @@ export const handle = sequence( authorization, createTRPCHandle({ router, createContext }), ); - -// Allow server application to exit -process.on("SIGINT", process.exit); // Ctrl+C -process.on("SIGTERM", process.exit); // docker stop diff --git a/src/lib/components/entry/EntryBody.svelte b/src/lib/components/entry/EntryBody.svelte index 48b5383..d712f50 100644 --- a/src/lib/components/entry/EntryBody.svelte +++ b/src/lib/components/entry/EntryBody.svelte @@ -61,7 +61,7 @@
-
+
Zuletzt bearbeitet am {formatDate(entry.current_version.created_at, true)} von
diff --git a/src/lib/components/filter/Autocomplete.svelte b/src/lib/components/filter/Autocomplete.svelte index 9009450..7a20f6d 100644 --- a/src/lib/components/filter/Autocomplete.svelte +++ b/src/lib/components/filter/Autocomplete.svelte @@ -1,8 +1,9 @@ - + +
{#each activeFilters as fdata, i} @@ -260,4 +281,10 @@ height: 32px; } } + + @media print { + .filterbar-outer { + display: none; + } + } diff --git a/src/lib/components/filter/FilterChip.svelte b/src/lib/components/filter/FilterChip.svelte index 50bac86..4d869f2 100644 --- a/src/lib/components/filter/FilterChip.svelte +++ b/src/lib/components/filter/FilterChip.svelte @@ -41,6 +41,15 @@ } } : 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) { autocomplete.open(); } @@ -52,7 +61,7 @@ ? filter.name : filter.toggleOff?.name ?? filter.name + TOFF; $: 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;
+ + diff --git a/src/lib/components/filter/filters.ts b/src/lib/components/filter/filters.ts index 50dac8a..dfc6743 100644 --- a/src/lib/components/filter/filters.ts +++ b/src/lib/components/filter/filters.ts @@ -107,7 +107,7 @@ export const ENTRY_FILTERS: Record = { name: "Datum", icon: mdiCalendar, inputType: InputType.FilterList, - options: async () => weekFilterItems(), + options: () => weekFilterItems(), textToItem: (s) => { const parsed = DateRange.parseHuman(s); if (parsed) { diff --git a/src/lib/components/filter/types.ts b/src/lib/components/filter/types.ts index cc3eb6b..f3c64e1 100644 --- a/src/lib/components/filter/types.ts +++ b/src/lib/components/filter/types.ts @@ -1,3 +1,5 @@ +import type { MaybePromise } from "@sveltejs/kit"; + export enum InputType { None = 0, FreeText = 1, @@ -27,7 +29,7 @@ export type FilterDef = { name: string; icon?: string; }; - options?: () => Promise; + options?: () => MaybePromise; textToItem?: (s: string) => BaseItem | void; }; diff --git a/src/lib/components/table/EntryTable.svelte b/src/lib/components/table/EntryTable.svelte index 6ea8b98..4af0262 100644 --- a/src/lib/components/table/EntryTable.svelte +++ b/src/lib/components/table/EntryTable.svelte @@ -1,4 +1,6 @@
- +
@@ -39,10 +41,11 @@ class="transition-colors hover:bg-neutral-content/10" class:done={entry.execution?.done} class:priority={entry.current_version.priority} + on:dblclick={() => { void goto("/entry/" + entry.id); }} > diff --git a/src/lib/components/table/FilteredPatientTable.svelte b/src/lib/components/table/FilteredPatientTable.svelte index eff3843..afb602d 100644 --- a/src/lib/components/table/FilteredPatientTable.svelte +++ b/src/lib/components/table/FilteredPatientTable.svelte @@ -44,7 +44,7 @@ if (browser) { // Update page URL const url = getQueryUrl(q, baseUrl); - goto(url, { replaceState: true, keepFocus: true }); + void goto(url, { replaceState: true, keepFocus: true }); } } diff --git a/src/lib/components/table/PatientTable.svelte b/src/lib/components/table/PatientTable.svelte index 21a3568..c89ce9b 100644 --- a/src/lib/components/table/PatientTable.svelte +++ b/src/lib/components/table/PatientTable.svelte @@ -1,4 +1,6 @@
-
{entry.id}
+
@@ -34,10 +36,11 @@ { void goto("/patient/" + patient.id); }} > + import { humanDate } from "$lib/shared/util"; + + function dateIn(n: number): string { + const date = new Date(); + date.setDate(date.getDate() + n); + return humanDate(date); + } + +
{#each { length: 4 } as _, i} -
{patient.id}
+
diff --git a/src/routes/(app)/entry/[id]/+page.server.ts b/src/routes/(app)/entry/[id]/+page.server.ts index 49aa997..3f7e31c 100644 --- a/src/routes/(app)/entry/[id]/+page.server.ts +++ b/src/routes/(app)/entry/[id]/+page.server.ts @@ -5,7 +5,7 @@ import { superValidate, message } from "sveltekit-superforms"; import { ZUrlEntityId } from "$lib/shared/model/validation"; 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"; @@ -22,18 +22,21 @@ export const actions: Actions = { const done = todoDays === null; const nTodoDays = todoDays ? parseInt(todoDays.toString()) : 0; - await loadWrap(async () => { + if (form.data.text.length > 0) { await trpc(event).entry.newExecution.mutate({ id, old_execution_id: form.data.old_execution_id, 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`); } - return message(form, done ? "Eintrag erledigt" : "Eintrag mit Notiz versehen"); + 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 { form }; }), }; diff --git a/src/routes/(app)/entry/[id]/+page.svelte b/src/routes/(app)/entry/[id]/+page.svelte index 6828514..3616ca3 100644 --- a/src/routes/(app)/entry/[id]/+page.svelte +++ b/src/routes/(app)/entry/[id]/+page.svelte @@ -1,5 +1,4 @@ - -
- - -
diff --git a/src/routes/(app)/visit/+page.svelte b/src/routes/(app)/visit/+page.svelte index 3a3522f..23cbc5d 100644 --- a/src/routes/(app)/visit/+page.svelte +++ b/src/routes/(app)/visit/+page.svelte @@ -77,18 +77,21 @@ if (browser) { // Update page URL const url = getQueryUrl(q, URL_VISIT); - goto(url, { replaceState: true, keepFocus: true }); + void goto(url, { replaceState: true, keepFocus: true }); } } - Visite + Visite {dateRange.format()} -

Visite

+
+

Visite

+ +
-
+
@@ -118,7 +121,7 @@
{#each data.groups as group} {@const first = group.items[0] ?? group.prio[0]} -
+
{#if data.groupByStation} {#if first.patient.room} Station {first.patient.room?.station.name} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index bdc5805..6065cf5 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,5 +1,5 @@ import type { LayoutServerLoad } from "./$types"; -export const load: LayoutServerLoad = async (event) => ({ +export const load: LayoutServerLoad = (event) => ({ session: event.locals.session, }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a18fc7e..acdea48 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,5 @@ - +
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 0d37816..0e1376f 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -5,6 +5,6 @@

Visitenbuch

- +
diff --git a/tests/e2e/_test.ts b/tests/e2e/_test.ts new file mode 100644 index 0000000..d0ed5a2 --- /dev/null +++ b/tests/e2e/_test.ts @@ -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 { + const cookies = await page.context().cookies(); + return cookies.findIndex((c) => c.name === AUTH_COOKIE) !== -1; +} + +async function newSessionToken(user: Account): Promise { + 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({ + 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"; diff --git a/tests/e2e/login.test.ts b/tests/e2e/login.test.ts new file mode 100644 index 0000000..9d51f53 --- /dev/null +++ b/tests/e2e/login.test.ts @@ -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" } }); +}); diff --git a/tests/e2e/plan.test.ts b/tests/e2e/plan.test.ts new file mode 100644 index 0000000..62aa555 --- /dev/null +++ b/tests/e2e/plan.test.ts @@ -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"); +}); diff --git a/tests/helpers/generate-mockdata.ts b/tests/helpers/generate-mockdata.ts index 260f1f9..412d09f 100644 --- a/tests/helpers/generate-mockdata.ts +++ b/tests/helpers/generate-mockdata.ts @@ -1,6 +1,7 @@ /* eslint-disable no-await-in-loop */ import fs from "fs"; import path from "path"; +import { performance } from "perf_hooks"; import { fileURLToPath } from "url"; import { faker } from "@faker-js/faker"; @@ -29,9 +30,17 @@ function randomId(len: number): number { 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 () => { + const startTime = performance.now(); + // Reset database await prisma.$transaction([ + prisma.savedFilter.deleteMany(), prisma.entryExecution.deleteMany(), prisma.entryVersion.deleteMany(), prisma.entry.deleteMany(), @@ -58,9 +67,16 @@ export default async () => { const entryMockdata: MockEntry[] = file .trim() .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 lastName = faker.person.lastName(); @@ -109,7 +125,7 @@ export default async () => { await prisma.entryVersion.create({ data: { entry_id: entry.id, - author_id: randomId(N_USERS), + author_id: randomUserId(), category_id: CATEGORY_IDS[e.category], date: todo_date, priority, @@ -123,7 +139,7 @@ export default async () => { await prisma.entryVersion.create({ data: { entry_id: entry.id, - author_id: randomId(N_USERS), + author_id: randomUserId(), category_id: CATEGORY_IDS[e.category], date: todo_date, priority, @@ -137,7 +153,7 @@ export default async () => { await prisma.entryExecution.create({ data: { entry_id: entry.id, - author_id: randomId(N_USERS), + author_id: randomUserId(), text: e.result, created_at: faker.date.soon({ refDate: todo_date, days: 2 }), }, @@ -146,7 +162,7 @@ export default async () => { await prisma.$transaction([ prisma.$executeRawUnsafe( - `alter sequence users_id_seq restart with ${N_USERS + 1}`, + `alter sequence users_id_seq restart with ${N_USERS + 2}`, ), prisma.$executeRawUnsafe( `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`); }; diff --git a/tests/helpers/reset-db.ts b/tests/helpers/reset-db.ts index a7d12f8..54889b7 100644 --- a/tests/helpers/reset-db.ts +++ b/tests/helpers/reset-db.ts @@ -4,6 +4,7 @@ import { CATEGORIES, ROOMS, STATIONS, USERS, } from "./testdata"; +/** Reset database and create basic test data for integration tests */ export default async () => { await prisma.$transaction([ prisma.savedFilter.deleteMany(), diff --git a/tests/integration/query/category.ts b/tests/integration/query/category.ts index 2e17793..b5fb658 100644 --- a/tests/integration/query/category.ts +++ b/tests/integration/query/category.ts @@ -26,7 +26,7 @@ test("get categories", async () => { test("delete categories", async () => { await deleteCategory(6); - expect(getCategory(6)).rejects.toThrowError("No Category found"); + await expect(getCategory(6)).rejects.toThrowError("No Category found"); }); test("hide category", async () => { diff --git a/tests/integration/query/entry.ts b/tests/integration/query/entry.ts index 78c87d4..9c2422d 100644 --- a/tests/integration/query/entry.ts +++ b/tests/integration/query/entry.ts @@ -149,7 +149,7 @@ test("create entry version (wrong old vid)", async () => { }); const entry = await getEntry(eId); - expect(async () => { + await expect(async () => { await newEntryVersion( 1, eId, @@ -202,7 +202,7 @@ test("create entry execution (wrong old xid)", async () => { }); 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 () => { diff --git a/tests/integration/query/patient.ts b/tests/integration/query/patient.ts index f039870..42fec28 100644 --- a/tests/integration/query/patient.ts +++ b/tests/integration/query/patient.ts @@ -49,7 +49,7 @@ test("update patient", async () => { test("delete patient", async () => { 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 () => { @@ -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 () => { diff --git a/tests/integration/query/room.ts b/tests/integration/query/room.ts index b730a3f..5dd7695 100644 --- a/tests/integration/query/room.ts +++ b/tests/integration/query/room.ts @@ -37,7 +37,7 @@ test("update room", async () => { test("delete room", async () => { 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 () => { diff --git a/tests/integration/query/savedFilter.ts b/tests/integration/query/savedFilter.ts index c7c02cc..8c27a64 100644 --- a/tests/integration/query/savedFilter.ts +++ b/tests/integration/query/savedFilter.ts @@ -96,7 +96,7 @@ test("update filter", 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 () => { diff --git a/tests/integration/query/station.ts b/tests/integration/query/station.ts index 65a8db9..b6b2270 100644 --- a/tests/integration/query/station.ts +++ b/tests/integration/query/station.ts @@ -25,7 +25,7 @@ test("update station", async () => { test("delete station", async () => { 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 () => { diff --git a/tests/integration/trpc/entry.ts b/tests/integration/trpc/entry.ts index e609d21..c4bb8ab 100644 --- a/tests/integration/trpc/entry.ts +++ b/tests/integration/trpc/entry.ts @@ -16,7 +16,7 @@ test("create entry", async () => { }); 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.execution).toBeNull(); expect(entry.current_version.id).gt(0); diff --git a/vite.config.js b/vite.config.ts similarity index 56% rename from vite.config.js rename to vite.config.ts index 6539b16..195765a 100644 --- a/vite.config.js +++ b/vite.config.ts @@ -2,12 +2,12 @@ import { exec } from "child_process"; import { promisify } from "util"; 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"; // Get current tag/commit and last commit date from git const pexec = promisify(exec); -let [version, lastmod] = ( +const [version, lastmod] = ( await Promise.allSettled([ pexec("git describe --tags || git rev-parse --short HEAD"), pexec('git log -1 --format=%cd --date=format:"%Y-%m-%d %H:%M"'), @@ -23,7 +23,8 @@ export default defineConfig({ createViteLicensePlugin({ additionalFiles: { "oss-licenses.html": (packages) => { - let res = ` + let res = ` + Visitenbuch - Lizenzen @@ -31,13 +32,23 @@ export default defineConfig({

Open-Source-Lizenzen

JSON-formatted license list `; - for (const p of packages) { - // @ts-expect-error repo not present in type definition - let rp = p.repository; - // @ts-expect-error author not present in type definition - let aut = p.author; - if (typeof aut === "object") { - aut = aut.name; + const escapeHTML = (s: string | null) => s ? s.replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") : ""; + + 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; @@ -50,13 +61,13 @@ export default defineConfig({ } res += `
\n`; - res += `

${p.name}

\n`; + res += `

${escapeHTML(p.name)}

\n`; res += `
Name Beschreibung
\n`; - res += `\n`; - if (aut) res += `\n`; - res += `\n`; - if (repoUrl) res += `\n`; - else if (rp) res += `\n`; + res += `\n`; + if (aut) res += `\n`; + res += `\n`; + if (repoUrl) res += `\n`; + else if (rp) res += `\n`; res += `
Version:${p.version}
Author:${aut}
License:${p.license}
Repository:${repoUrl}
Repository:${rp}
Version:${escapeHTML(p.version)}
Author:${escapeHTML(aut)}
License:${escapeHTML(p.license)}
Repository:${escapeHTML(repoUrl)}
Repository:${escapeHTML(rp)}
\n`; res += "
"; } diff --git a/vitest.config.integration.js b/vitest.config.integration.js index 81c290e..0289eb3 100644 --- a/vitest.config.integration.js +++ b/vitest.config.integration.js @@ -4,6 +4,11 @@ import { sveltekit } from "@sveltejs/kit/vite"; 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({ plugins: [sveltekit()], test: { @@ -13,8 +18,7 @@ export default defineConfig({ maxConcurrency: 1, setupFiles: ["tests/helpers/setup.ts"], env: { - // eslint-disable-next-line no-undef - DATABASE_URL: process.env.TEST_DATABASE_URL ?? "postgresql://postgres:1234@localhost:5432/test?schema=public", + DATABASE_URL, }, }, });