Compare commits

..

6 commits

Author SHA1 Message Date
9a6b8094da
fix: add generic item type to Autocomplete 2024-04-05 00:35:07 +02:00
9b7e6fdbd4
fix: fetch Autocomplete items on select
doc: add some documentation to Autocomplete
2024-04-04 17:52:43 +02:00
c459450abc
fix: cannot create execution 2024-04-04 17:13:53 +02:00
96e55c52a5
chore: update dependencies 2024-04-04 17:06:47 +02:00
b9b8f55c28
fix: search/filter error with slash/backslash/escaped quote in query 2024-03-18 03:08:17 +01:00
5afc7da40d
fix: search/filter error with slash/backslash/escaped quote in query
now replacing ^ -> ^^, / with ^1 and \ with ^2
2024-03-18 02:08:49 +01:00
21 changed files with 690 additions and 649 deletions

View file

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

2
.gitignore vendored
View file

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

3
.vscode/settings.json vendored Normal file
View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -10,33 +10,46 @@
import { createFloatingActions } from "svelte-floating-ui";
import { shift } from "svelte-floating-ui/dom";
import outclick from "$lib/actions/outclick";
import type { BaseItem, SelectionOrText } from "./types";
import type { BaseItem } from "./types";
import Icon from "$lib/components/ui/Icon.svelte";
import { onMount } from "svelte";
type T = $$Generic<BaseItem>;
type OnSelectResult = { newValue: string; close: boolean };
export let items: BaseItem[] | (() => Promise<BaseItem[]>);
export let selection: SelectionOrText | null = null;
/** List of items to choose from (or an async function fetching them) */
export let items: T[] | (() => Promise<T[]>);
/** 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 cache: { [key: string]: BaseItem[] } = {};
/** Object to cache fetched items in */
export let cache: { [key: string]: T[] } = {};
/** Key in cache object under which fetched items are stored */
export let cacheKey: string | undefined = undefined;
/** Input field placeholder */
export let placeholder: string | undefined = undefined;
/** Add horizontal padding to the input element */
export let padding = true;
/** CSS classes of the container */
export let cls = "";
/** CSS classes of the input element */
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 */
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 filterFn: (item: BaseItem) => boolean = () => true;
/** Function to filter shown items */
export let filterFn: (item: T) => boolean = () => true;
/** Selection callback. Returns the new input value after selection */
export let onSelect: (item: SelectionOrText, kb: boolean) => OnSelectResult = (
item,
kb
) => {
return { newValue: item.name ?? "", close: true };
};
export let onSelect: (item: T, kb: boolean) => OnSelectResult | void = () => {};
export let onUnselect = () => {};
export let onClose = (kb: boolean) => {};
export let onBackspace = () => {};
@ -44,10 +57,11 @@
let opened = false;
let highlightIndex = 0;
let isLoading = false;
let srcItems: BaseItem[];
let filteredItems: BaseItem[] = [];
let srcItems: T[];
let filteredItems: T[] = [];
$: if (hiddenIds || selection) updateSearch();
// Reload search items if hidden ids are changed
$: if (srcItems && hiddenIds) updateSearch();
// HTML elements
let inputElm: HTMLInputElement | undefined;
@ -92,7 +106,7 @@
if (i !== -1) {
highlightIndex = i;
}
if (asTextInput) setInputValue(selection.name ?? "");
if (!partOfFilterbar) setInputValue(selection.name ?? "");
} else {
highlightIndex = 0;
setInputValue(selection.name ?? "");
@ -142,30 +156,34 @@
if (inputElm) inputElm.focus();
}
export function close(kb: boolean) {
export function close(kb = true) {
if (opened) {
onClose(kb);
}
opened = false;
}
function selectListItem(item: BaseItem | undefined, kb: boolean) {
selection = { id: item?.id, name: item?.name };
const selRes = onSelect(item ?? { name: inputValue() }, kb);
setInputValue(selRes.newValue);
if (selRes.close) {
function selectListItem(item: T | undefined, kb: boolean) {
if (item) {
selection = item;
const selRes = onSelect(item, kb);
setInputValue(selRes ? selRes.newValue : item.name);
if (!selRes || selRes.close) {
close(kb);
} else {
updateSearch();
}
} else {
selection = null;
}
}
function onKeyDown(e: KeyboardEvent) {
let key = e.key;
if (key === "Tab" && e.shiftKey) key = "ShiftTab";
const fnmap = {
Tab: close,
ShiftTab: close,
const fnmap: { [key: string]: () => void } = {
Tab: () => close,
ShiftTab: () => close,
ArrowDown: () => {
open();
if (highlightIndex < filteredItems.length - 1) {
@ -184,7 +202,7 @@
e.stopPropagation();
if (opened) {
if (inputElm) inputElm.focus();
close(true);
close();
}
},
Backspace: () => {
@ -195,10 +213,9 @@
}
},
};
// @ts-expect-error unknown map type
const fn = fnmap[key];
if (typeof fn === "function") {
fn(e);
fn();
}
}
@ -250,6 +267,14 @@
placement: "bottom-start",
middleware: [shift()],
});
if (!partOfFilterbar) {
onMount(() => {
if (selection?.name) {
setInputValue(selection.name);
}
});
}
</script>
<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 */
export let search = false;
let autocomplete: Autocomplete | undefined;
let autocomplete: Autocomplete<BaseItem> | undefined;
let activeFilters: FilterData[] = [];
let cache: { [key: string]: BaseItem[] } = {};
let searchVal = "";
@ -216,6 +216,7 @@
activeFilters = activeFilters;
updateFilter();
}}
partOfFilterbar
/>
<button
class="btn btn-sm btn-circle btn-ghost absolute bottom-0 right-0"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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