Compare commits

..

No commits in common. "9a6b8094dae44b3c1b861d671d54fb6495e85c9f" and "02ffd4a0138030c5f2b8548fbd4dc2875adea87c" have entirely different histories.

21 changed files with 649 additions and 690 deletions

View file

@ -14,16 +14,10 @@ module.exports = {
parser: "svelte-eslint-parser", parser: "svelte-eslint-parser",
parserOptions: { parserOptions: {
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
svelteFeatures: {
experimentalGenerics: true,
},
}, },
}, },
], ],
settings: {}, settings: {},
globals: {
$$Generic: "readonly",
},
parserOptions: { parserOptions: {
sourceType: "module", sourceType: "module",
ecmaVersion: 2020, ecmaVersion: 2020,

2
.gitignore vendored
View file

@ -8,3 +8,5 @@ node_modules
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
sveltekit-zero-api.d.ts
/.vscode

View file

@ -1,3 +0,0 @@
{
"eslint.validate": ["javascript", "typescript", "svelte"]
}

View file

@ -17,13 +17,13 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@auth/core": "^0.28.2", "@auth/core": "^0.27.0",
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.6.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@prisma/client": "^5.12.1", "@prisma/client": "^5.10.2",
"diff": "^5.2.0", "diff": "^5.2.0",
"isomorphic-dompurify": "^2.6.0", "isomorphic-dompurify": "^2.4.0",
"marked": "^12.0.1", "marked": "^12.0.0",
"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.22.4", "zod": "^3.22.4",
@ -31,40 +31,40 @@
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@playwright/test": "^1.42.1", "@playwright/test": "^1.42.0",
"@sveltejs/adapter-node": "^5.0.1", "@sveltejs/adapter-node": "^4.0.1",
"@sveltejs/kit": "^2.5.5", "@sveltejs/kit": "^2.5.2",
"@sveltejs/vite-plugin-svelte": "^3.0.2", "@sveltejs/vite-plugin-svelte": "^3.0.2",
"@tailwindcss/typography": "^0.5.12", "@tailwindcss/typography": "^0.5.10",
"@trpc/client": "^10.45.2", "@trpc/client": "^10.45.1",
"@trpc/server": "^10.45.2", "@trpc/server": "^10.45.1",
"@types/diff": "^5.0.9", "@types/diff": "^5.0.9",
"@types/node": "^20.12.4", "@types/node": "^20.11.21",
"@types/set-cookie-parser": "^2.4.7", "@types/set-cookie-parser": "^2.4.7",
"@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.5.0", "@typescript-eslint/parser": "^7.1.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.17",
"daisyui": "^4.9.0", "daisyui": "^4.7.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-no-relative-import-paths": "^1.5.3", "eslint-plugin-no-relative-import-paths": "^1.5.3",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",
"postcss-import": "^16.1.0", "postcss-import": "^16.0.1",
"postcss-nesting": "^12.1.1", "postcss-nesting": "^12.0.4",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2", "prettier-plugin-svelte": "^3.2.2",
"prisma": "^5.12.1", "prisma": "^5.10.2",
"svelte": "^4.2.12", "svelte": "^4.2.12",
"svelte-check": "^3.6.9", "svelte-check": "^3.6.5",
"sveltekit-superforms": "^2.12.2", "sveltekit-superforms": "^2.6.2",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.1",
"trpc-sveltekit": "^3.6.1", "trpc-sveltekit": "^3.5.27",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"tsx": "^4.7.2", "tsx": "^4.7.1",
"typescript": "^5.4.3", "typescript": "^5.3.3",
"vite": "^5.2.8", "vite": "^5.1.4",
"vitest": "^1.4.0" "vitest": "^1.3.1"
}, },
"type": "module" "type": "module"
} }

File diff suppressed because it is too large Load diff

View file

@ -10,46 +10,33 @@
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";
import outclick from "$lib/actions/outclick"; import outclick from "$lib/actions/outclick";
import type { BaseItem } from "./types"; import type { BaseItem, SelectionOrText } from "./types";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { onMount } from "svelte";
type T = $$Generic<BaseItem>;
type OnSelectResult = { newValue: string; close: boolean }; type OnSelectResult = { newValue: string; close: boolean };
/** List of items to choose from (or an async function fetching them) */ export let items: BaseItem[] | (() => Promise<BaseItem[]>);
export let items: T[] | (() => Promise<T[]>); export let selection: SelectionOrText | null = null;
/** Current selection of the autocomplete field */
export let selection: T | null = null;
/** Set of item IDs that should be hidden from the list */
export let hiddenIds: Set<string | number> = new Set(); export let hiddenIds: Set<string | number> = new Set();
/** Object to cache fetched items in */ export let cache: { [key: string]: BaseItem[] } = {};
export let cache: { [key: string]: T[] } = {};
/** Key in cache object under which fetched items are stored */
export let cacheKey: string | undefined = undefined; export let cacheKey: string | undefined = undefined;
/** Input field placeholder */
export let placeholder: string | undefined = undefined; export let placeholder: string | undefined = undefined;
/** Add horizontal padding to the input element */
export let padding = true; export let padding = true;
/** CSS classes of the container */
export let cls = ""; export let cls = "";
/** CSS classes of the input element */
export let inputCls = "w-full bg-transparent outline-none"; export let inputCls = "w-full bg-transparent outline-none";
/** export let asTextInput = false;
* Enable filter bar optimizations:
* - Clear input on mount
*/
export let partOfFilterbar = false;
/** Dont select item automatically on close if only 1 item is shown */ /** Dont select item automatically on close if only 1 item is shown */
export let noAutoselect1 = false; export let noAutoselect1 = false;
/** Name of the form input. This sets a hidden field to the ID of the selected element
* (to allow Svelte Superforms to work) */
export let idInputName: string | undefined = undefined; export let idInputName: string | undefined = undefined;
/** Function to filter shown items */ export let filterFn: (item: BaseItem) => boolean = () => true;
export let filterFn: (item: T) => boolean = () => true;
/** Selection callback. Returns the new input value after selection */ /** Selection callback. Returns the new input value after selection */
export let onSelect: (item: T, kb: boolean) => OnSelectResult | void = () => {}; export let onSelect: (item: SelectionOrText, kb: boolean) => OnSelectResult = (
item,
kb
) => {
return { newValue: item.name ?? "", close: true };
};
export let onUnselect = () => {}; export let onUnselect = () => {};
export let onClose = (kb: boolean) => {}; export let onClose = (kb: boolean) => {};
export let onBackspace = () => {}; export let onBackspace = () => {};
@ -57,11 +44,10 @@
let opened = false; let opened = false;
let highlightIndex = 0; let highlightIndex = 0;
let isLoading = false; let isLoading = false;
let srcItems: T[]; let srcItems: BaseItem[];
let filteredItems: T[] = []; let filteredItems: BaseItem[] = [];
// Reload search items if hidden ids are changed $: if (hiddenIds || selection) updateSearch();
$: if (srcItems && hiddenIds) updateSearch();
// HTML elements // HTML elements
let inputElm: HTMLInputElement | undefined; let inputElm: HTMLInputElement | undefined;
@ -106,7 +92,7 @@
if (i !== -1) { if (i !== -1) {
highlightIndex = i; highlightIndex = i;
} }
if (!partOfFilterbar) setInputValue(selection.name ?? ""); if (asTextInput) setInputValue(selection.name ?? "");
} else { } else {
highlightIndex = 0; highlightIndex = 0;
setInputValue(selection.name ?? ""); setInputValue(selection.name ?? "");
@ -156,34 +142,30 @@
if (inputElm) inputElm.focus(); if (inputElm) inputElm.focus();
} }
export function close(kb = true) { export function close(kb: boolean) {
if (opened) { if (opened) {
onClose(kb); onClose(kb);
} }
opened = false; opened = false;
} }
function selectListItem(item: T | undefined, kb: boolean) { function selectListItem(item: BaseItem | undefined, kb: boolean) {
if (item) { selection = { id: item?.id, name: item?.name };
selection = item; const selRes = onSelect(item ?? { name: inputValue() }, kb);
const selRes = onSelect(item, kb); setInputValue(selRes.newValue);
setInputValue(selRes ? selRes.newValue : item.name); if (selRes.close) {
if (!selRes || selRes.close) { close(kb);
close(kb);
} else {
updateSearch();
}
} else { } else {
selection = null; updateSearch();
} }
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
let key = e.key; let key = e.key;
if (key === "Tab" && e.shiftKey) key = "ShiftTab"; if (key === "Tab" && e.shiftKey) key = "ShiftTab";
const fnmap: { [key: string]: () => void } = { const fnmap = {
Tab: () => close, Tab: close,
ShiftTab: () => close, ShiftTab: close,
ArrowDown: () => { ArrowDown: () => {
open(); open();
if (highlightIndex < filteredItems.length - 1) { if (highlightIndex < filteredItems.length - 1) {
@ -202,7 +184,7 @@
e.stopPropagation(); e.stopPropagation();
if (opened) { if (opened) {
if (inputElm) inputElm.focus(); if (inputElm) inputElm.focus();
close(); close(true);
} }
}, },
Backspace: () => { Backspace: () => {
@ -213,9 +195,10 @@
} }
}, },
}; };
// @ts-expect-error unknown map type
const fn = fnmap[key]; const fn = fnmap[key];
if (typeof fn === "function") { if (typeof fn === "function") {
fn(); fn(e);
} }
} }
@ -267,14 +250,6 @@
placement: "bottom-start", placement: "bottom-start",
middleware: [shift()], middleware: [shift()],
}); });
if (!partOfFilterbar) {
onMount(() => {
if (selection?.name) {
setInputValue(selection.name);
}
});
}
</script> </script>
<div class="flex-grow {cls}" use:outclick on:outclick={close}> <div class="flex-grow {cls}" use:outclick on:outclick={close}>

View file

@ -24,7 +24,7 @@
/** True if a separate search field should be displayed */ /** True if a separate search field should be displayed */
export let search = false; export let search = false;
let autocomplete: Autocomplete<BaseItem> | undefined; let autocomplete: Autocomplete | undefined;
let activeFilters: FilterData[] = []; let activeFilters: FilterData[] = [];
let cache: { [key: string]: BaseItem[] } = {}; let cache: { [key: string]: BaseItem[] } = {};
let searchVal = ""; let searchVal = "";
@ -216,7 +216,6 @@
activeFilters = activeFilters; activeFilters = activeFilters;
updateFilter(); updateFilter();
}} }}
partOfFilterbar
/> />
<button <button
class="btn btn-sm btn-circle btn-ghost absolute bottom-0 right-0" class="btn btn-sm btn-circle btn-ghost absolute bottom-0 right-0"

View file

@ -12,7 +12,7 @@
export let onRemove = () => {}; export let onRemove = () => {};
export let onSelection = (selection: SelectionOrText, kb: boolean) => {}; export let onSelection = (selection: SelectionOrText, kb: boolean) => {};
let autocomplete: Autocomplete<BaseItem> | undefined; let autocomplete: Autocomplete | undefined;
function startEditing() { function startEditing() {
fdata.editing = true; fdata.editing = true;
@ -76,9 +76,7 @@ gap-1 pl-1"
hiddenIds={hids} hiddenIds={hids}
{cache} {cache}
cacheKey={filter.id} cacheKey={filter.id}
selection={fdata.selection?.id selection={fdata.selection}
? { id: fdata.selection?.id, name: fdata.selection?.name ?? "" }
: null}
padding={false} padding={false}
onSelect={(item) => { onSelect={(item) => {
// Accept the selection if this is a free text field or the user selected a variant // Accept the selection if this is a free text field or the user selected a variant
@ -91,7 +89,6 @@ gap-1 pl-1"
}} }}
{onClose} {onClose}
onBackspace={onRemove} onBackspace={onRemove}
partOfFilterbar
/> />
{:else} {:else}
<!-- svelte-ignore a11y-autofocus --> <!-- svelte-ignore a11y-autofocus -->

View file

@ -68,12 +68,16 @@
}} }}
selection={patient?.room} selection={patient?.room}
onSelect={(item) => { onSelect={(item) => {
// @ts-expect-error room items have station attr
station = item.station; station = item.station;
// @ts-expect-error ids are always numeric
$form.room_id = item.id; $form.room_id = item.id;
return { newValue: item.name ?? "", close: true };
}} }}
onUnselect={() => { onUnselect={() => {
$form.room_id = null; $form.room_id = null;
}} }}
asTextInput
idInputName="room_id" idInputName="room_id"
/> />
</FormField> </FormField>

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import FormField from "./FormField.svelte";
import Markdown from "./Markdown.svelte"; import Markdown from "./Markdown.svelte";
import type { InputConstraint } from "sveltekit-superforms"; import type { InputConstraint } from "sveltekit-superforms";

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation";
import { mdiChevronLeft, mdiChevronRight, mdiPageFirst, mdiPageLast } from "@mdi/js"; import { mdiChevronLeft, mdiChevronRight, mdiPageFirst, mdiPageLast } from "@mdi/js";
import type { Pagination, PaginationRequest } from "$lib/shared/model"; import type { Pagination, PaginationRequest } from "$lib/shared/model";

View file

@ -117,10 +117,10 @@ export async function newEntryVersion(
// Check if there are any updates // Check if there are any updates
if ( if (
cver?.text === updatedVersion.text && cver.text === updatedVersion.text &&
cver?.date.getTime() === updatedVersion.date.getTime() && cver.date.getTime() === updatedVersion.date.getTime() &&
cver?.category_id === updatedVersion.category_id && cver.category_id === updatedVersion.category_id &&
cver?.priority === updatedVersion.priority cver.priority === updatedVersion.priority
) { ) {
return cver.id; return cver.id;
} }
@ -160,7 +160,7 @@ export async function newEntryExecution(
} }
// Check if there are any updates // Check if there are any updates
if (execution.text === cex?.text) { if (execution.text === cex.text) {
return cex.id; return cex.id;
} }

View file

@ -87,18 +87,15 @@ class SearchQueryComponents {
*/ */
export function parseSearchQuery(q: string): SearchQueryComponents { export function parseSearchQuery(q: string): SearchQueryComponents {
const regexpParts = /(-)?(?:"([^"]*)"|([^"\s]+))(?:\s|$)/g; const regexpParts = /(-)?(?:"([^"]*)"|([^"\s]+))(?:\s|$)/g;
const components = Array.from( const components = Array.from(q.replaceAll("'", '"').matchAll(regexpParts), (m) => {
q.replaceAll("'", '"').replaceAll("\\", "\\\\").matchAll(regexpParts), const negative = m[1] === "-";
(m) => { // Exact
const negative = m[1] === "-"; if (m[2]) {
// Exact return new SearchQueryComponent(m[2], QueryComponentType.Exact, negative);
if (m[2]) { } else {
return new SearchQueryComponent(m[2], QueryComponentType.Exact, negative); return new SearchQueryComponent(m[3], QueryComponentType.Normal, negative);
} else {
return new SearchQueryComponent(m[3], QueryComponentType.Normal, negative);
}
} }
); });
if ( if (
components.length > 0 && components.length > 0 &&

View file

@ -74,7 +74,7 @@ export function formatBool(val: boolean): string {
export function getQueryUrl(q: EntityQuery, basePath: string): string { export function getQueryUrl(q: EntityQuery, basePath: string): string {
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath; if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath;
return encodeURI(basePath + "/" + encodeURIComponent(JSON.stringify(q))); return basePath + "/" + JSON.stringify(q);
} }
export function gotoEntityQuery(q: EntityQuery, basePath: string) { export function gotoEntityQuery(q: EntityQuery, basePath: string) {

View file

@ -51,12 +51,14 @@
}} }}
selection={data.entry.current_version.category} selection={data.entry.current_version.category}
onSelect={(item) => { onSelect={(item) => {
// @ts-expect-error ids are always numeric
$form.category_id = item.id; $form.category_id = item.id;
return { newValue: item.name ?? "", close: true }; return { newValue: item.name ?? "", close: true };
}} }}
onUnselect={() => { onUnselect={() => {
$form.category_id = null; $form.category_id = null;
}} }}
asTextInput
idInputName="category_id" idInputName="category_id"
/> />
</FormField> </FormField>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import CategoryField from "$lib/components/table/CategoryField.svelte";
import UserField from "$lib/components/table/UserField.svelte"; import UserField from "$lib/components/table/UserField.svelte";
import { formatDate } from "$lib/shared/util"; import { formatBool, formatDate } from "$lib/shared/util";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import Header from "$lib/components/ui/Header.svelte"; import Header from "$lib/components/ui/Header.svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";

View file

@ -27,12 +27,14 @@
return await trpc().room.list.query(); return await trpc().room.list.query();
}} }}
onSelect={(item) => { onSelect={(item) => {
// @ts-expect-error ids are always numeric
$form.room_id = item.id; $form.room_id = item.id;
return { newValue: item.name ?? "", close: true }; return { newValue: item.name ?? "", close: true };
}} }}
onUnselect={() => { onUnselect={() => {
$form.room_id = null; $form.room_id = null;
}} }}
asTextInput
idInputName="room_id" idInputName="room_id"
/> />
</FormField> </FormField>
@ -44,8 +46,10 @@
return await trpc().patient.getNames.query(); return await trpc().patient.getNames.query();
}} }}
onSelect={(item) => { onSelect={(item) => {
// @ts-expect-error patient id
$form.patient_id = item.id; $form.patient_id = item.id;
trpc() trpc()
// @ts-expect-error patient id
.patient.get.query(item.id) .patient.get.query(item.id)
.then((p) => { .then((p) => {
$form.patient_first_name = p.first_name; $form.patient_first_name = p.first_name;
@ -60,9 +64,11 @@
$form.patient_last_name = null; $form.patient_last_name = null;
$form.patient_age = null; $form.patient_age = null;
}} }}
asTextInput
noAutoselect1 noAutoselect1
idInputName="patient_id" idInputName="patient_id"
filterFn={(itm) => { filterFn={(itm) => {
// @ts-expect-error patient items have room attr
return $form.room_id === null || itm.room_id === $form.room_id; return $form.room_id === null || itm.room_id === $form.room_id;
}} }}
placeholder="Neuer Patient" placeholder="Neuer Patient"
@ -113,12 +119,14 @@
return await trpc().category.list.query(); return await trpc().category.list.query();
}} }}
onSelect={(item) => { onSelect={(item) => {
// @ts-expect-error ids are always numeric
$form.category_id = item.id; $form.category_id = item.id;
return { newValue: item.name ?? "", close: true }; return { newValue: item.name ?? "", close: true };
}} }}
onUnselect={() => { onUnselect={() => {
$form.category_id = null; $form.category_id = null;
}} }}
asTextInput
idInputName="category_id" idInputName="category_id"
/> />
</FormField> </FormField>

View file

@ -1,6 +1,4 @@
import { z } from "zod"; import { ZPatientNew, ZUrlEntityId } from "$lib/shared/model/validation";
import { ZEntriesQuery, ZPatientNew, ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util"; import { loadWrap } from "$lib/shared/util";
import { superValidate } from "sveltekit-superforms"; import { superValidate } from "sveltekit-superforms";
@ -10,9 +8,10 @@ import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
const id = ZUrlEntityId.parse(event.params.id); const id = ZUrlEntityId.parse(event.params.id);
let query: z.infer<typeof ZEntriesQuery> = {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any
let query: any = {};
if (event.params.query) { if (event.params.query) {
query = ZEntriesQuery.parse(JSON.parse(decodeURIComponent(event.params.query))); query = JSON.parse(event.params.query);
} }
if (!query.filter) query.filter = {}; if (!query.filter) query.filter = {};
query.filter.patient = [{ id }]; query.filter.patient = [{ id }];

View file

@ -9,7 +9,7 @@ export const load: PageLoad = async (event) => {
let query: z.infer<typeof ZPatientsQuery> = {}; let query: z.infer<typeof ZPatientsQuery> = {};
if (event.params.query) { if (event.params.query) {
query = ZPatientsQuery.parse(JSON.parse(decodeURIComponent(event.params.query))); query = ZPatientsQuery.parse(JSON.parse(event.params.query));
} }
const patients = await loadWrap(async () => trpc(event).patient.list.query(query)); const patients = await loadWrap(async () => trpc(event).patient.list.query(query));

View file

@ -1,15 +1,12 @@
import { z } from "zod";
import { ZEntriesQuery } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util"; import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
let query: z.infer<typeof ZEntriesQuery> = {}; let query = {};
if (event.params.query) { if (event.params.query) {
query = ZEntriesQuery.parse(JSON.parse(decodeURIComponent(event.params.query))); query = JSON.parse(event.params.query);
} }
const entries = await loadWrap(() => trpc(event).entry.list.query(query)); const entries = await loadWrap(() => trpc(event).entry.list.query(query));

View file

@ -9,7 +9,9 @@ const config = {
kit: { kit: {
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(), adapter: adapter({
precompress: true,
}),
alias: { alias: {
$api: "./src/api", $api: "./src/api",