Compare commits

...

2 commits

Author SHA1 Message Date
152824f6c0
feat: add filter saving 2024-04-29 13:20:31 +02:00
99495aaddb
fix: keep order of filters 2024-04-25 21:28:03 +02:00
23 changed files with 312 additions and 66 deletions

View file

@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "saved_filter" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"view" TEXT NOT NULL,
"name" TEXT NOT NULL,
"query" TEXT NOT NULL,
CONSTRAINT "saved_filter_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "saved_filter" ADD CONSTRAINT "saved_filter_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -40,6 +40,7 @@ model User {
accounts Account[] accounts Account[]
EntryVersion EntryVersion[] EntryVersion EntryVersion[]
EntryExecution EntryExecution[] EntryExecution EntryExecution[]
SavedFilter SavedFilter[]
@@map("users") @@map("users")
} }
@ -80,8 +81,8 @@ model Patient {
full_name String? @default(dbgenerated("((first_name || ' '::text) || last_name)")) @ignore full_name String? @default(dbgenerated("((first_name || ' '::text) || last_name)")) @ignore
@@map("patients")
@@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin) @@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin)
@@map("patients")
} }
// Entry category (e.g. Blood test, Exams, ...) // Entry category (e.g. Blood test, Exams, ...)
@ -107,8 +108,8 @@ model Entry {
tsvec Unsupported("tsvector")? tsvec Unsupported("tsvector")?
@@map("entries")
@@index([tsvec], type: Gin) @@index([tsvec], type: Gin)
@@map("entries")
} }
model EntryVersion { model EntryVersion {
@ -128,8 +129,8 @@ model EntryVersion {
created_at DateTime @default(now()) created_at DateTime @default(now())
@@map("entry_versions")
@@index([entry_id]) @@index([entry_id])
@@map("entry_versions")
} }
model EntryExecution { model EntryExecution {
@ -144,6 +145,19 @@ model EntryExecution {
created_at DateTime @default(now()) created_at DateTime @default(now())
@@map("entry_executions")
@@index([entry_id]) @@index([entry_id])
@@map("entry_executions")
}
model SavedFilter {
id Int @id @default(autoincrement())
user User @relation(fields: [user_id], references: [id])
user_id Int
view String
name String
query String
@@map("saved_filter")
} }

36
src/app.d.ts vendored
View file

@ -20,40 +20,4 @@ declare global {
} }
} }
declare module "@gramex/url/encode" {
export default function encode(obj: unknown, settings?: {
listBracket?: boolean;
listIndex?: boolean;
objBracket?: boolean;
sortKeys?: boolean;
drop?: unknown[];
}): string;
}
declare module "@gramex/url/index" {
export { default as encode } from "@gramex/url/encode";
import { default as update } from "@gramex/url/update";
export { update };
export const decode: (url: string, settings?: {
convert?: boolean;
forceList?: boolean;
pruneString?: boolean;
}) => unknown;
}
declare module "@gramex/url/update" {
export default function update(obj: unknown, url: string, settings?: {
convert?: boolean;
forceList?: boolean;
pruneString?: boolean;
pruneObject?: boolean;
pruneArray?: boolean;
}): unknown;
}
declare module "@gramex/url" {
import main = require("@gramex/url/index");
export = main;
}
export {}; export {};

View file

@ -8,6 +8,7 @@
import Autocomplete from "./Autocomplete.svelte"; import Autocomplete from "./Autocomplete.svelte";
import EntryFilterChip from "./FilterChip.svelte"; import EntryFilterChip from "./FilterChip.svelte";
import SavedFilters from "./SavedFilters.svelte";
import type { import type {
FilterDef, FilterDef,
FilterQdata, FilterQdata,
@ -27,6 +28,7 @@
export let hiddenFilters: string[] = []; export let hiddenFilters: string[] = [];
/** 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;
export let view: string;
let autocomplete: Autocomplete<BaseItem> | undefined; let autocomplete: Autocomplete<BaseItem> | undefined;
let activeFilters: FilterData[] = []; let activeFilters: FilterData[] = [];
@ -246,6 +248,8 @@
<slot /> <slot />
</div> </div>
<SavedFilters {view} />
<style lang="postcss"> <style lang="postcss">
.filterbar-outer { .filterbar-outer {
@apply flex flex-wrap w-full items-start justify-center gap-2 relative; @apply flex flex-wrap w-full items-start justify-center gap-2 relative;

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { mdiClose, mdiFloppy } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte";
export let href = "";
export let onSave = () => {};
export let onRemove = () => {};
function onSaveInt(e: MouseEvent) {
e.preventDefault();
onSave();
}
function onRemoveInt(e: MouseEvent) {
e.preventDefault();
onRemove();
}
</script>
<a class="chip btn-transition" {href}>
<span class="mx-2">
<slot />
</span>
<button class="save" on:click={onSaveInt}>
<Icon path={mdiFloppy} size={1} />
</button>
<button class="remove" on:click={onRemoveInt}>
<Icon path={mdiClose} size={1} />
</button>
</a>
<style lang="postcss">
.chip {
@apply flex items-center overflow-hidden rounded-md bg-primary text-primary-content h-8;
& > button {
@apply border-l border-primary-content/10 px-1 flex h-full items-center;
}
}
.save {
@apply hover:bg-success/80 active:bg-success focus:bg-success/80;
}
.remove {
@apply hover:bg-error/80 active:bg-error focus:bg-error/80;
}
</style>

View file

@ -0,0 +1,82 @@
<!-- Bar of saved filter chips -->
<script lang="ts">
import { mdiPlus } from "@mdi/js";
import { onMount } from "svelte";
import type { SavedFilter } from "$lib/shared/model";
import { trpc } from "$lib/shared/trpc";
import Icon from "$lib/components/ui/Icon.svelte";
import LoadingIcon from "$lib/components/ui/LoadingIcon.svelte";
import Chip from "./SavedFilterChip.svelte";
export let view: string;
let filters: SavedFilter[] | null = null;
onMount(() => {
trpc().savedFilter.get.query(view).then((res) => {
filters = res;
});
});
function getQuery(): string {
return window.location.search.substring(1);
}
function create() {
const query = getQuery();
if (query.length === 0) return;
const name = prompt("Name");
if (!name) return;
trpc().savedFilter.create.mutate({ name, query, view }).then((id) => {
filters?.push({ id, name, query });
filters = filters; // force reactive update
});
}
function update(ix: number) {
const f = filters![ix];
const query = getQuery();
if (query.length === 0) return;
trpc().savedFilter.update.mutate({ id: f.id, query });
f.query = query;
filters = filters; // force reactive update
}
function remove(ix: number) {
const f = filters![ix];
trpc().savedFilter.delete.mutate(f.id);
filters!.splice(ix, 1);
filters = filters;
}
</script>
<div class="flex flex-row flex-wrap items-center gap-2">
<div class="text-sm h-8 flex items-center">
Gespeicherte Filter:
</div>
{#if filters}
{#each filters as filter, i (filter.id)}
<Chip
href={"?" + filter.query}
onRemove={() => remove(i)}
onSave={() => update(i)}
>
{filter.name}
</Chip>
{/each}
{:else}
<LoadingIcon />
{/if}
<button class="btn btn-sm btn-primary pl-1" on:click={create}>
<Icon path={mdiPlus} />
Neu
</button>
</div>

View file

@ -57,10 +57,13 @@
filterData={query.filter} filterData={query.filter}
hiddenFilters={patientId !== null ? ["patient"] : []} hiddenFilters={patientId !== null ? ["patient"] : []}
onUpdate={filterUpdate} onUpdate={filterUpdate}
view="plan"
> >
<slot /> <slot name="filterbar" />
</FilterBar> </FilterBar>
<slot />
<EntryTable <EntryTable
{baseUrl} {baseUrl}
{entries} {entries}

View file

@ -54,6 +54,7 @@
filterData={query.filter} filterData={query.filter}
onUpdate={filterUpdate} onUpdate={filterUpdate}
search search
view="patients"
> >
<slot /> <slot />
</FilterBar> </FilterBar>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { mdiLoading } from "@mdi/js";
import { onMount } from "svelte";
import Icon from "./Icon.svelte";
let show = false;
onMount(() => {
show = false;
setTimeout(() => (show = true), 300);
});
</script>
{#if show}
<Icon path={mdiLoading} spin={1} />
{/if}

View file

@ -0,0 +1,28 @@
import type { SavedFilter, SavedFilterNew } from "$lib/shared/model";
import { prisma } from "$lib/server/prisma";
export async function newSavedFilter(filter: SavedFilterNew, user_id: number): Promise<number> {
const created = await prisma.savedFilter.create({
data: {
user_id,
...filter,
},
});
return created.id;
}
export async function updateSavedFilter(id: number, query: string, user_id: number) {
await prisma.savedFilter.update({ where: { id, user_id }, data: { query } });
}
export async function deleteSavedFilter(id: number, user_id: number) {
await prisma.savedFilter.delete({ where: { id, user_id } });
}
export async function getSavedFilters(user_id: number, view: string): Promise<SavedFilter[]> {
return prisma.savedFilter.findMany({
select: { id: true, name: true, query: true },
where: { user_id, view },
});
}

View file

@ -5,6 +5,7 @@ import { categoryRouter } from "./routes/category";
import { entryRouter } from "./routes/entry"; import { entryRouter } from "./routes/entry";
import { patientRouter } from "./routes/patient"; import { patientRouter } from "./routes/patient";
import { roomRouter } from "./routes/room"; import { roomRouter } from "./routes/room";
import { savedFilterRouter } from "./routes/savedFilter";
import { stationRouter } from "./routes/station"; import { stationRouter } from "./routes/station";
import { userRouter } from "./routes/user"; import { userRouter } from "./routes/user";
@ -21,6 +22,7 @@ export const router = t.router({
room: roomRouter, room: roomRouter,
patient: patientRouter, patient: patientRouter,
user: userRouter, user: userRouter,
savedFilter: savedFilterRouter,
}); });
export type Router = typeof router; export type Router = typeof router;

View file

@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { fields, ZCategoryNew } from "$lib/shared/model/validation"; import { ZEntityId, ZCategoryNew } from "$lib/shared/model/validation";
import { import {
deleteCategory, deleteCategory,
@ -12,8 +12,6 @@ import {
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId();
export const categoryRouter = t.router({ export const categoryRouter = t.router({
list: t.procedure.query(async () => trpcWrap(getCategories)), list: t.procedure.query(async () => trpcWrap(getCategories)),
get: t.procedure get: t.procedure

View file

@ -1,11 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { import {
fields,
ZEntriesQuery, ZEntriesQuery,
ZEntryExecutionNew, ZEntryExecutionNew,
ZEntryNew, ZEntryNew,
ZEntryVersionNew, ZEntryVersionNew,
ZEntityId,
} from "$lib/shared/model/validation"; } from "$lib/shared/model/validation";
import { executionsDiff, versionsDiff } from "$lib/shared/util/diff"; import { executionsDiff, versionsDiff } from "$lib/shared/util/diff";
@ -23,8 +23,6 @@ import {
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId();
export const entryRouter = t.router({ export const entryRouter = t.router({
get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => { get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {
const [entry, n_versions, n_executions] = await Promise.all([ const [entry, n_versions, n_executions] = await Promise.all([

View file

@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { fields, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation"; import { ZEntityId, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation";
import { import {
deletePatient, deletePatient,
@ -15,8 +15,6 @@ import {
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId();
export const patientRouter = t.router({ export const patientRouter = t.router({
getNames: t.procedure.query(async () => trpcWrap(getPatientNames)), getNames: t.procedure.query(async () => trpcWrap(getPatientNames)),
get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => { get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {

View file

@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { fields, ZRoomNew } from "$lib/shared/model/validation"; import { ZEntityId, ZRoomNew } from "$lib/shared/model/validation";
import { import {
deleteRoom, getRoom, getRooms, newRoom, updateRoom, deleteRoom, getRoom, getRooms, newRoom, updateRoom,
@ -8,8 +8,6 @@ import {
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId();
export const roomRouter = t.router({ export const roomRouter = t.router({
list: t.procedure.query(async (opts) => trpcWrap(getRooms)), list: t.procedure.query(async (opts) => trpcWrap(getRooms)),
get: t.procedure get: t.procedure

View file

@ -0,0 +1,24 @@
import {
ZEntityId, ZSavedFilterNew, ZSavedFilterUpdate, fields,
} from "$lib/shared/model/validation";
import {
deleteSavedFilter, getSavedFilters, newSavedFilter, updateSavedFilter,
} from "$lib/server/query/savedFilter";
import { t, trpcWrap } from "..";
export const savedFilterRouter = t.router({
get: t.procedure.input(fields.NameString()).query(async (opts) => trpcWrap(
async () => getSavedFilters(opts.ctx.user.id, opts.input),
)),
create: t.procedure.input(ZSavedFilterNew).mutation(async (opts) => trpcWrap(
async () => newSavedFilter(opts.input, opts.ctx.user.id),
)),
update: t.procedure.input(ZSavedFilterUpdate).mutation(async (opts) => trpcWrap(
async () => updateSavedFilter(opts.input.id, opts.input.query, opts.ctx.user.id),
)),
delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(
async () => deleteSavedFilter(opts.input, opts.ctx.user.id),
)),
});

View file

@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { fields, ZStationNew } from "$lib/shared/model/validation"; import { ZEntityId, ZStationNew } from "$lib/shared/model/validation";
import { import {
deleteStation, deleteStation,
@ -12,8 +12,6 @@ import {
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId();
export const stationRouter = t.router({ export const stationRouter = t.router({
list: t.procedure.query(getStations), list: t.procedure.query(getStations),
get: t.procedure get: t.procedure

View file

@ -1,13 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { fields, ZPagination } from "$lib/shared/model/validation"; import { ZEntityId, ZPagination } from "$lib/shared/model/validation";
import { getUser, getUserNames, getUsers } from "$lib/server/query"; import { getUser, getUserNames, getUsers } from "$lib/server/query";
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId();
export const userRouter = t.router({ export const userRouter = t.router({
list: t.procedure list: t.procedure
.input(z.object({ pagination: ZPagination }).partial()) .input(z.object({ pagination: ZPagination }).partial())

View file

@ -127,3 +127,11 @@ export type EntryExecution = {
export type EntryExecutionNew = { export type EntryExecutionNew = {
text: string; text: string;
}; };
export type SavedFilter = {
id: number;
name: string;
query: string;
};
export type SavedFilterNew = Omit<SavedFilter, "id"> & { view: string };

View file

@ -1,6 +1,6 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { fields } from "./validation"; import { ZEntriesFilter, fields } from "./validation";
test("date string", () => { test("date string", () => {
const DateString = fields.DateString(); const DateString = fields.DateString();
@ -14,3 +14,17 @@ test("date string", () => {
const dsError = DateString.safeParse("2024-30-10"); const dsError = DateString.safeParse("2024-30-10");
expect(dsError.success).toBe(false); expect(dsError.success).toBe(false);
}); });
test("filter data", () => {
// Strings from query params should be converted and order kept
const entriesFilter = ZEntriesFilter.parse({
done: "true",
category: [{ id: "1", name: "CA" }],
author: [{ id: "1", name: "Max Mustermann" }],
});
expect(entriesFilter).toStrictEqual({
done: true,
category: [{ id: 1, name: "CA" }],
author: [{ id: 1, name: "Max Mustermann" }],
});
});

View file

@ -11,6 +11,7 @@ import type {
PaginationRequest, PaginationRequest,
PatientNew, PatientNew,
RoomNew, RoomNew,
SavedFilterNew,
StationNew, StationNew,
User, User,
} from "."; } from ".";
@ -40,6 +41,27 @@ export const fields = {
const coercedUint = z.coerce.number().int().nonnegative(); const coercedUint = z.coerce.number().int().nonnegative();
const coercedBool = z.string().toLowerCase().transform((v) => v === "true").or(z.boolean()); const coercedBool = z.string().toLowerCase().transform((v) => v === "true").or(z.boolean());
function returnDataInSameOrderAsPassed<Schema extends z.ZodObject<z.ZodRawShape>>(
schema: Schema,
) {
return z.any().transform((value, ctx) => {
const parsed = schema.safeParse(value);
if (parsed.success) {
const res: z.infer<Schema> = {};
for (const key of Object.keys(value)) {
if (key in parsed.data) {
// @ts-expect-error keys are safe
res[key] = parsed.data[key];
}
}
return res;
} else {
parsed.error.issues.forEach((iss) => ctx.addIssue(iss));
}
});
}
export const ZEntityId = fields.EntityId();
export const ZUrlEntityId = coercedUint; export const ZUrlEntityId = coercedUint;
export const ZUser = implement<User>().with({ export const ZUser = implement<User>().with({
@ -116,7 +138,7 @@ const paginatedQuery = <T extends z.ZodTypeAny>(f: T) => z
}) })
.partial(); .partial();
export const ZEntriesFilter = z export const ZEntriesFilter = returnDataInSameOrderAsPassed(z
.object({ .object({
author: ZFilterList, author: ZFilterList,
category: ZFilterList, category: ZFilterList,
@ -128,11 +150,11 @@ export const ZEntriesFilter = z
search: z.string(), search: z.string(),
station: ZFilterList, station: ZFilterList,
}) })
.partial(); .partial());
export const ZEntriesQuery = paginatedQuery(ZEntriesFilter); export const ZEntriesQuery = paginatedQuery(ZEntriesFilter);
export const ZPatientsFilter = z export const ZPatientsFilter = returnDataInSameOrderAsPassed(z
.object({ .object({
search: z.string(), search: z.string(),
room: ZFilterList, room: ZFilterList,
@ -140,6 +162,17 @@ export const ZPatientsFilter = z
hidden: coercedBool, hidden: coercedBool,
includeHidden: z.coerce.boolean(), includeHidden: z.coerce.boolean(),
}) })
.partial(); .partial());
export const ZPatientsQuery = paginatedQuery(ZPatientsFilter); export const ZPatientsQuery = paginatedQuery(ZPatientsFilter);
export const ZSavedFilterNew = implement<SavedFilterNew>().with({
view: fields.NameString(),
name: fields.NameString(),
query: z.string(),
});
export const ZSavedFilterUpdate = z.object({
id: ZEntityId,
query: z.string(),
});

View file

@ -50,7 +50,7 @@ test("versions diff", () => {
id: 1, id: 1,
name: "Laborabnahme", name: "Laborabnahme",
description: "Blutabnahme zur Untersuchung im Labor", description: "Blutabnahme zur Untersuchung im Labor",
color: "FF0000", color: "#FF0000",
}, },
created_at: new Date("2024-02-10T11:31:00.000Z"), created_at: new Date("2024-02-10T11:31:00.000Z"),
date: "2024-01-12", date: "2024-01-12",

View file

@ -13,5 +13,5 @@
</svelte:head> </svelte:head>
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query}> <FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query}>
<a class="btn btn-sm btn-primary" href="/entry/new">Neuer Eintrag</a> <a slot="filterbar" class="btn btn-sm btn-primary" href="/entry/new">Neuer Eintrag</a>
</FilteredEntryTable> </FilteredEntryTable>