From 99495aaddb1ffa7f7fbc55a5fc36ac9b161f4c74 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Thu, 25 Apr 2024 21:28:03 +0200 Subject: [PATCH 1/2] fix: keep order of filters --- src/lib/shared/model/validation.test.ts | 16 ++++++++++++++- src/lib/shared/model/validation.ts | 27 ++++++++++++++++++++++--- src/lib/shared/util/diff.test.ts | 2 +- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/lib/shared/model/validation.test.ts b/src/lib/shared/model/validation.test.ts index a012136..3f76cdb 100644 --- a/src/lib/shared/model/validation.test.ts +++ b/src/lib/shared/model/validation.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; -import { fields } from "./validation"; +import { ZEntriesFilter, fields } from "./validation"; test("date string", () => { const DateString = fields.DateString(); @@ -14,3 +14,17 @@ test("date string", () => { const dsError = DateString.safeParse("2024-30-10"); 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" }], + }); +}); diff --git a/src/lib/shared/model/validation.ts b/src/lib/shared/model/validation.ts index 9b1d0e7..c2c233f 100644 --- a/src/lib/shared/model/validation.ts +++ b/src/lib/shared/model/validation.ts @@ -40,6 +40,26 @@ export const fields = { const coercedUint = z.coerce.number().int().nonnegative(); const coercedBool = z.string().toLowerCase().transform((v) => v === "true").or(z.boolean()); +function returnDataInSameOrderAsPassed>( + schema: Schema, +) { + return z.any().transform((value, ctx) => { + const parsed = schema.safeParse(value); + if (parsed.success) { + const res: z.infer = {}; + 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 ZUrlEntityId = coercedUint; export const ZUser = implement().with({ @@ -116,7 +136,7 @@ const paginatedQuery = (f: T) => z }) .partial(); -export const ZEntriesFilter = z +const zex = z .object({ author: ZFilterList, category: ZFilterList, @@ -129,10 +149,11 @@ export const ZEntriesFilter = z station: ZFilterList, }) .partial(); +export const ZEntriesFilter = returnDataInSameOrderAsPassed(zex); export const ZEntriesQuery = paginatedQuery(ZEntriesFilter); -export const ZPatientsFilter = z +export const ZPatientsFilter = returnDataInSameOrderAsPassed(z .object({ search: z.string(), room: ZFilterList, @@ -140,6 +161,6 @@ export const ZPatientsFilter = z hidden: coercedBool, includeHidden: z.coerce.boolean(), }) - .partial(); + .partial()); export const ZPatientsQuery = paginatedQuery(ZPatientsFilter); diff --git a/src/lib/shared/util/diff.test.ts b/src/lib/shared/util/diff.test.ts index 52f7902..8d018cb 100644 --- a/src/lib/shared/util/diff.test.ts +++ b/src/lib/shared/util/diff.test.ts @@ -50,7 +50,7 @@ test("versions diff", () => { id: 1, name: "Laborabnahme", description: "Blutabnahme zur Untersuchung im Labor", - color: "FF0000", + color: "#FF0000", }, created_at: new Date("2024-02-10T11:31:00.000Z"), date: "2024-01-12", From 152824f6c071b4f647be90d80c680cbd8ce736f3 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Mon, 29 Apr 2024 13:20:31 +0200 Subject: [PATCH 2/2] feat: add filter saving --- .../migration.sql | 13 +++ prisma/schema.prisma | 22 ++++- src/app.d.ts | 36 -------- src/lib/components/filter/FilterBar.svelte | 4 + .../components/filter/SavedFilterChip.svelte | 51 ++++++++++++ src/lib/components/filter/SavedFilters.svelte | 82 +++++++++++++++++++ .../table/FilteredEntryTable.svelte | 5 +- .../table/FilteredPatientTable.svelte | 1 + src/lib/components/ui/LoadingIcon.svelte | 17 ++++ src/lib/server/query/savedFilter.ts | 28 +++++++ src/lib/server/trpc/router.ts | 2 + src/lib/server/trpc/routes/category.ts | 4 +- src/lib/server/trpc/routes/entry.ts | 4 +- src/lib/server/trpc/routes/patient.ts | 4 +- src/lib/server/trpc/routes/room.ts | 4 +- src/lib/server/trpc/routes/savedFilter.ts | 24 ++++++ src/lib/server/trpc/routes/station.ts | 4 +- src/lib/server/trpc/routes/user.ts | 4 +- src/lib/shared/model/model.ts | 8 ++ src/lib/shared/model/validation.ts | 18 +++- src/routes/(app)/plan/+page.svelte | 2 +- 21 files changed, 274 insertions(+), 63 deletions(-) create mode 100644 prisma/migrations/20240425200550_saved_filters/migration.sql create mode 100644 src/lib/components/filter/SavedFilterChip.svelte create mode 100644 src/lib/components/filter/SavedFilters.svelte create mode 100644 src/lib/components/ui/LoadingIcon.svelte create mode 100644 src/lib/server/query/savedFilter.ts create mode 100644 src/lib/server/trpc/routes/savedFilter.ts diff --git a/prisma/migrations/20240425200550_saved_filters/migration.sql b/prisma/migrations/20240425200550_saved_filters/migration.sql new file mode 100644 index 0000000..621074c --- /dev/null +++ b/prisma/migrations/20240425200550_saved_filters/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c401ae1..38f77ff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,6 +40,7 @@ model User { accounts Account[] EntryVersion EntryVersion[] EntryExecution EntryExecution[] + SavedFilter SavedFilter[] @@map("users") } @@ -80,8 +81,8 @@ model Patient { 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) + @@map("patients") } // Entry category (e.g. Blood test, Exams, ...) @@ -107,8 +108,8 @@ model Entry { tsvec Unsupported("tsvector")? - @@map("entries") @@index([tsvec], type: Gin) + @@map("entries") } model EntryVersion { @@ -128,8 +129,8 @@ model EntryVersion { created_at DateTime @default(now()) - @@map("entry_versions") @@index([entry_id]) + @@map("entry_versions") } model EntryExecution { @@ -144,6 +145,19 @@ model EntryExecution { created_at DateTime @default(now()) - @@map("entry_executions") @@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") } diff --git a/src/app.d.ts b/src/app.d.ts index 4e30e3c..0b9d41b 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -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 {}; diff --git a/src/lib/components/filter/FilterBar.svelte b/src/lib/components/filter/FilterBar.svelte index 82c0688..daf6a16 100644 --- a/src/lib/components/filter/FilterBar.svelte +++ b/src/lib/components/filter/FilterBar.svelte @@ -8,6 +8,7 @@ import Autocomplete from "./Autocomplete.svelte"; import EntryFilterChip from "./FilterChip.svelte"; + import SavedFilters from "./SavedFilters.svelte"; import type { FilterDef, FilterQdata, @@ -27,6 +28,7 @@ export let hiddenFilters: string[] = []; /** True if a separate search field should be displayed */ export let search = false; + export let view: string; let autocomplete: Autocomplete | undefined; let activeFilters: FilterData[] = []; @@ -246,6 +248,8 @@ + + diff --git a/src/lib/components/filter/SavedFilters.svelte b/src/lib/components/filter/SavedFilters.svelte new file mode 100644 index 0000000..e0e17fb --- /dev/null +++ b/src/lib/components/filter/SavedFilters.svelte @@ -0,0 +1,82 @@ + + + +
+
+ Gespeicherte Filter: +
+ + {#if filters} + {#each filters as filter, i (filter.id)} + remove(i)} + onSave={() => update(i)} + > + {filter.name} + + {/each} + {:else} + + {/if} + + +
diff --git a/src/lib/components/table/FilteredEntryTable.svelte b/src/lib/components/table/FilteredEntryTable.svelte index bfdec34..75a11c6 100644 --- a/src/lib/components/table/FilteredEntryTable.svelte +++ b/src/lib/components/table/FilteredEntryTable.svelte @@ -57,10 +57,13 @@ filterData={query.filter} hiddenFilters={patientId !== null ? ["patient"] : []} onUpdate={filterUpdate} + view="plan" > - + + + diff --git a/src/lib/components/ui/LoadingIcon.svelte b/src/lib/components/ui/LoadingIcon.svelte new file mode 100644 index 0000000..246f41b --- /dev/null +++ b/src/lib/components/ui/LoadingIcon.svelte @@ -0,0 +1,17 @@ + + +{#if show} + +{/if} diff --git a/src/lib/server/query/savedFilter.ts b/src/lib/server/query/savedFilter.ts new file mode 100644 index 0000000..761a2c7 --- /dev/null +++ b/src/lib/server/query/savedFilter.ts @@ -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 { + 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 { + return prisma.savedFilter.findMany({ + select: { id: true, name: true, query: true }, + where: { user_id, view }, + }); +} diff --git a/src/lib/server/trpc/router.ts b/src/lib/server/trpc/router.ts index 85ad3b4..8301107 100644 --- a/src/lib/server/trpc/router.ts +++ b/src/lib/server/trpc/router.ts @@ -5,6 +5,7 @@ import { categoryRouter } from "./routes/category"; import { entryRouter } from "./routes/entry"; import { patientRouter } from "./routes/patient"; import { roomRouter } from "./routes/room"; +import { savedFilterRouter } from "./routes/savedFilter"; import { stationRouter } from "./routes/station"; import { userRouter } from "./routes/user"; @@ -21,6 +22,7 @@ export const router = t.router({ room: roomRouter, patient: patientRouter, user: userRouter, + savedFilter: savedFilterRouter, }); export type Router = typeof router; diff --git a/src/lib/server/trpc/routes/category.ts b/src/lib/server/trpc/routes/category.ts index b8f6f59..2333808 100644 --- a/src/lib/server/trpc/routes/category.ts +++ b/src/lib/server/trpc/routes/category.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { fields, ZCategoryNew } from "$lib/shared/model/validation"; +import { ZEntityId, ZCategoryNew } from "$lib/shared/model/validation"; import { deleteCategory, @@ -12,8 +12,6 @@ import { import { t, trpcWrap } from ".."; -const ZEntityId = fields.EntityId(); - export const categoryRouter = t.router({ list: t.procedure.query(async () => trpcWrap(getCategories)), get: t.procedure diff --git a/src/lib/server/trpc/routes/entry.ts b/src/lib/server/trpc/routes/entry.ts index 9c3427e..94d3ea3 100644 --- a/src/lib/server/trpc/routes/entry.ts +++ b/src/lib/server/trpc/routes/entry.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import { - fields, ZEntriesQuery, ZEntryExecutionNew, ZEntryNew, ZEntryVersionNew, + ZEntityId, } from "$lib/shared/model/validation"; import { executionsDiff, versionsDiff } from "$lib/shared/util/diff"; @@ -23,8 +23,6 @@ import { import { t, trpcWrap } from ".."; -const ZEntityId = fields.EntityId(); - export const entryRouter = t.router({ get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => { const [entry, n_versions, n_executions] = await Promise.all([ diff --git a/src/lib/server/trpc/routes/patient.ts b/src/lib/server/trpc/routes/patient.ts index 16fa97f..dfe77a3 100644 --- a/src/lib/server/trpc/routes/patient.ts +++ b/src/lib/server/trpc/routes/patient.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { fields, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation"; +import { ZEntityId, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation"; import { deletePatient, @@ -15,8 +15,6 @@ import { import { t, trpcWrap } from ".."; -const ZEntityId = fields.EntityId(); - export const patientRouter = t.router({ getNames: t.procedure.query(async () => trpcWrap(getPatientNames)), get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => { diff --git a/src/lib/server/trpc/routes/room.ts b/src/lib/server/trpc/routes/room.ts index 8d88e88..9872f2d 100644 --- a/src/lib/server/trpc/routes/room.ts +++ b/src/lib/server/trpc/routes/room.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { fields, ZRoomNew } from "$lib/shared/model/validation"; +import { ZEntityId, ZRoomNew } from "$lib/shared/model/validation"; import { deleteRoom, getRoom, getRooms, newRoom, updateRoom, @@ -8,8 +8,6 @@ import { import { t, trpcWrap } from ".."; -const ZEntityId = fields.EntityId(); - export const roomRouter = t.router({ list: t.procedure.query(async (opts) => trpcWrap(getRooms)), get: t.procedure diff --git a/src/lib/server/trpc/routes/savedFilter.ts b/src/lib/server/trpc/routes/savedFilter.ts new file mode 100644 index 0000000..78d9388 --- /dev/null +++ b/src/lib/server/trpc/routes/savedFilter.ts @@ -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), + )), +}); diff --git a/src/lib/server/trpc/routes/station.ts b/src/lib/server/trpc/routes/station.ts index 80e7944..d611f86 100644 --- a/src/lib/server/trpc/routes/station.ts +++ b/src/lib/server/trpc/routes/station.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { fields, ZStationNew } from "$lib/shared/model/validation"; +import { ZEntityId, ZStationNew } from "$lib/shared/model/validation"; import { deleteStation, @@ -12,8 +12,6 @@ import { import { t, trpcWrap } from ".."; -const ZEntityId = fields.EntityId(); - export const stationRouter = t.router({ list: t.procedure.query(getStations), get: t.procedure diff --git a/src/lib/server/trpc/routes/user.ts b/src/lib/server/trpc/routes/user.ts index 9fab4cd..0f25f65 100644 --- a/src/lib/server/trpc/routes/user.ts +++ b/src/lib/server/trpc/routes/user.ts @@ -1,13 +1,11 @@ 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 { t, trpcWrap } from ".."; -const ZEntityId = fields.EntityId(); - export const userRouter = t.router({ list: t.procedure .input(z.object({ pagination: ZPagination }).partial()) diff --git a/src/lib/shared/model/model.ts b/src/lib/shared/model/model.ts index 42239f6..e3c75de 100644 --- a/src/lib/shared/model/model.ts +++ b/src/lib/shared/model/model.ts @@ -127,3 +127,11 @@ export type EntryExecution = { export type EntryExecutionNew = { text: string; }; + +export type SavedFilter = { + id: number; + name: string; + query: string; +}; + +export type SavedFilterNew = Omit & { view: string }; diff --git a/src/lib/shared/model/validation.ts b/src/lib/shared/model/validation.ts index c2c233f..3c0cffd 100644 --- a/src/lib/shared/model/validation.ts +++ b/src/lib/shared/model/validation.ts @@ -11,6 +11,7 @@ import type { PaginationRequest, PatientNew, RoomNew, + SavedFilterNew, StationNew, User, } from "."; @@ -60,6 +61,7 @@ function returnDataInSameOrderAsPassed }); } +export const ZEntityId = fields.EntityId(); export const ZUrlEntityId = coercedUint; export const ZUser = implement().with({ @@ -136,7 +138,7 @@ const paginatedQuery = (f: T) => z }) .partial(); -const zex = z +export const ZEntriesFilter = returnDataInSameOrderAsPassed(z .object({ author: ZFilterList, category: ZFilterList, @@ -148,8 +150,7 @@ const zex = z search: z.string(), station: ZFilterList, }) - .partial(); -export const ZEntriesFilter = returnDataInSameOrderAsPassed(zex); + .partial()); export const ZEntriesQuery = paginatedQuery(ZEntriesFilter); @@ -164,3 +165,14 @@ export const ZPatientsFilter = returnDataInSameOrderAsPassed(z .partial()); export const ZPatientsQuery = paginatedQuery(ZPatientsFilter); + +export const ZSavedFilterNew = implement().with({ + view: fields.NameString(), + name: fields.NameString(), + query: z.string(), +}); + +export const ZSavedFilterUpdate = z.object({ + id: ZEntityId, + query: z.string(), +}); diff --git a/src/routes/(app)/plan/+page.svelte b/src/routes/(app)/plan/+page.svelte index 5d1d5b7..cbd54f1 100644 --- a/src/routes/(app)/plan/+page.svelte +++ b/src/routes/(app)/plan/+page.svelte @@ -13,5 +13,5 @@ - Neuer Eintrag + Neuer Eintrag