Compare commits

..

5 commits

Author SHA1 Message Date
04d9883c96
feat: make page printable
Some checks failed
Visitenbuch CI / test (push) Failing after 4m26s
Visitenbuch CI / release (push) Has been skipped
2024-05-14 02:13:44 +02:00
8d9b75c5fd
feat: add E2E testing 2024-05-14 00:40:10 +02:00
cc1ebaff1a
fix: FilterList selection hides items from other FilterLists 2024-05-14 00:29:38 +02:00
009729b877
fix: update ESLint config and fix lints 2024-05-13 23:33:35 +02:00
74742ae61b
ci: fix tag fetching 2024-05-13 14:18:21 +02:00
55 changed files with 406 additions and 104 deletions

View file

@ -4,6 +4,8 @@ node_modules
/package /package
.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-*

View file

@ -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
@ -36,6 +46,11 @@ jobs:
run: | run: |
npx prisma migrate reset --force npx prisma migrate reset --force
npm run test:integration npm run test:integration
- name: 👨‍🔬 E2E test
run: |
npx playwright install --with-deps
npm run build
npm run test:e2e
release: release:
runs-on: cimaster-latest runs-on: cimaster-latest
@ -45,6 +60,8 @@ jobs:
steps: steps:
- name: 👁️ Checkout repository - name: 👁️ Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 📦 pnpm install - name: 📦 pnpm install
run: pnpm install run: pnpm install
@ -65,7 +82,7 @@ jobs:
run: | run: |
{ {
echo 'CHANGELOG<<END_OF_FILE' echo 'CHANGELOG<<END_OF_FILE'
git show -s --format=%N "${{ github.ref_name }}" | tail -n +4 | awk 'BEGIN{RS="-----BEGIN PGP SIGNATURE-----"} NR==1{printf $0}' git show -s --format=%N "${{ github.ref }}" | tail -n +4 | awk 'BEGIN{RS="-----BEGIN PGP SIGNATURE-----"} NR==1{printf $0}'
echo END_OF_FILE echo END_OF_FILE
} >> "$GITHUB_ENV" } >> "$GITHUB_ENV"
- name: 🎉 Publish release - name: 🎉 Publish release

3
.gitignore vendored
View file

@ -4,5 +4,8 @@ node_modules
/.svelte-kit /.svelte-kit
/package /package
.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-*

View file

@ -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/",
], ],
}, },

View file

@ -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",

View file

@ -2,9 +2,12 @@ import { defineConfig } from "@playwright/test";
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: true,
}, },
testDir: "tests", testDir: "tests/e2e",
testMatch: /(.+\.)?(test|spec)\.[jt]s/, testMatch: /\.[jt]s$/,
globalSetup: "tests/helpers/generate-mockdata.ts",
outputDir: ".svelte-kit/test-results",
}); });

4
src/app.d.ts vendored
View file

@ -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 {}
} }

View file

@ -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;
}
}

View file

@ -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

View file

@ -33,5 +33,5 @@ export const handle = sequence(
); );
// Allow server application to exit // Allow server application to exit
process.on("SIGINT", process.exit); // Ctrl+C process.on("SIGINT", () => process.exit()); // Ctrl+C
process.on("SIGTERM", process.exit); // docker stop process.on("SIGTERM", () => process.exit()); // docker stop

View file

@ -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>

View file

@ -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;

View file

@ -55,7 +55,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 +96,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 +135,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 +158,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 };
} }
@ -260,4 +265,10 @@
height: 32px; height: 32px;
} }
} }
@media print {
.filterbar-outer {
display: none;
}
}
</style> </style>

View file

@ -52,7 +52,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 +61,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;

View file

@ -77,7 +77,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 +97,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>

View file

@ -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) {

View file

@ -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;
}; };

View file

@ -16,7 +16,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" />
@ -42,7 +42,7 @@
> >
<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

View file

@ -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>

View file

@ -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>

View file

@ -17,7 +17,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" />

View file

@ -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);
} }

View file

@ -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;
} }

View file

@ -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) => {

View file

@ -107,7 +107,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);
} }

View file

@ -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
@ -200,7 +201,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 +215,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()})`);

View file

@ -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" });

View file

@ -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,

View file

@ -227,7 +227,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;
} }

View file

@ -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

View file

@ -15,10 +15,7 @@
$: savedFilters.set(data.savedFilters); $: savedFilters.set(data.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 +62,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>

View file

@ -35,7 +35,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>

View file

@ -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>

View file

@ -25,7 +25,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)}

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>
<h1 class="heading">Visite</h1> <div class="flex gap-4 items-baseline">
<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}

View file

@ -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,
}); });

View file

@ -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}>

View file

@ -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>

49
tests/e2e/login.test.ts Normal file
View file

@ -0,0 +1,49 @@
import { test, expect } from "@playwright/test";
import {
isLoggedIn, loginIfNecessary, loginWithToken, USERNAME, OIDC_BASE_URL,
} from "$tests/helpers/login";
test.describe.configure({ mode: "serial" }); // Parallel account creation may cause issues
test("login", async ({ page }) => {
await page.goto("/");
await loginIfNecessary(page);
await expect(page).toHaveTitle("Visitenbuch");
await expect(page.locator("h1.heading")).toHaveText("Hallo, Lucy Login");
// Test cases may create more entries
expect(parseInt(await page.getByTestId("n-entries-todo").innerText()))
.toBeGreaterThanOrEqual(193);
});
test("loginWithToken", async ({ page }) => {
await loginWithToken(page);
await page.goto("/");
await expect(page).toHaveTitle("Visitenbuch");
await expect(page.locator("h1.heading")).toHaveText("Hallo, " + USERNAME);
// Test cases may create more entries
expect(parseInt(await page.getByTestId("n-entries-todo").innerText()))
.toBeGreaterThanOrEqual(193);
});
test("logout", async ({ page, baseURL }) => {
await page.goto("/");
await loginIfNecessary(page);
await page.goto("/logout");
await page.getByTestId("btn-logout").click();
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" } });
});

28
tests/e2e/plan.test.ts Normal file
View file

@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { loginWithToken } from "$tests/helpers/login";
test("filter", async ({ page }) => {
await loginWithToken(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");
});

View file

@ -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`);
}; };

58
tests/helpers/login.ts Normal file
View file

@ -0,0 +1,58 @@
import { encode } from "@auth/core/jwt";
import { expect, type Page } from "@playwright/test";
import { prisma } from "$lib/server/prisma";
const AUTH_COOKIE = "authjs.session-token";
export const OIDC_BASE_URL = "http://localhost:9090/interaction/";
export const USERNAME = "Tico Testboy";
export const USER_EMAIL = "t.testboy@example.org";
export async function loginIfNecessary(page: Page) {
if (page.url().startsWith(OIDC_BASE_URL)) {
await page.locator('input[name="login"]').fill("Lucy Login");
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);
}
}
export async function isLoggedIn(page: Page): Promise<boolean> {
const cookies = await page.context().cookies();
return cookies.findIndex((c) => c.name === AUTH_COOKIE) !== -1;
}
/**
* Create a session token (Cookie: authjs.session-token) to use for E2E tests
* so we dont have to step through the login system every time
*/
export async function newSessionToken(user_id: number): Promise<string> {
const user = await prisma.user.findUniqueOrThrow({
select: { email: true, name: true },
where: { id: user_id },
});
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(),
},
});
}
export async function loginWithToken(page: Page, user_id = 1) {
const token = await newSessionToken(user_id);
await page.context().addCookies([{
name: AUTH_COOKIE,
value: token,
domain: "localhost",
path: "/",
httpOnly: true,
sameSite: "Lax",
}]);
}

View file

@ -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(),

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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);

View file

@ -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"'),
@ -31,13 +31,17 @@ 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) { for (const _p of packages) {
// @ts-expect-error repo not present in type definition type LicenseMetaExt = LicenseMeta & {
let rp = p.repository; repository: string | null,
// @ts-expect-error author not present in type definition author: string | { name: string } | null
let aut = p.author; };
if (typeof aut === "object") { const p = _p as LicenseMetaExt;
aut = aut.name; 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;