akanjs 3.0.0-alpha.59 → 3.0.0-alpha.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/client/clientRuntime.ts +3 -0
  2. package/client/types.ts +3 -3
  3. package/dictionary/dictInfo.ts +3 -2
  4. package/document/documentQuery.ts +1 -0
  5. package/document/filterMeta.ts +77 -11
  6. package/fetch/client/fetchClient.ts +7 -5
  7. package/fetch/client/httpClient.ts +3 -1
  8. package/fetch/fetchType/appliedReturn.type.ts +6 -0
  9. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  10. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  11. package/package.json +1 -1
  12. package/service/predefinedAdaptor/database.adaptor.ts +23 -13
  13. package/signal/schema/JsonSchemaBuilder.ts +5 -1
  14. package/signal/serializer/fetch.serializer.ts +35 -3
  15. package/signal/signalContext.ts +13 -1
  16. package/signal/slice.ts +21 -17
  17. package/signal/types.ts +5 -0
  18. package/types/client/clientRuntime.d.ts +5 -0
  19. package/types/client/types.d.ts +5 -3
  20. package/types/dictionary/dictInfo.d.ts +1 -1
  21. package/types/document/filterMeta.d.ts +24 -3
  22. package/types/fetch/client/fetchClient.d.ts +3 -0
  23. package/types/fetch/fetchType/appliedReturn.type.d.ts +5 -0
  24. package/types/service/predefinedAdaptor/database.adaptor.d.ts +7 -1
  25. package/types/signal/slice.d.ts +9 -7
  26. package/types/signal/types.d.ts +5 -0
  27. package/types/ui/Data/Dashboard.d.ts +4 -2
  28. package/types/ui/Data/ListContainer.d.ts +8 -4
  29. package/types/ui/Data/QueryMaker.d.ts +3 -3
  30. package/types/ui/Data/index.d.ts +1 -0
  31. package/types/ui/Model/AdminPanel.d.ts +3 -3
  32. package/types/ui/Signal/Arg.d.ts +4 -3
  33. package/ui/Data/Dashboard.tsx +2 -2
  34. package/ui/Data/ListContainer.tsx +14 -5
  35. package/ui/Data/QueryMaker.tsx +62 -12
  36. package/ui/Data/index.ts +2 -1
  37. package/ui/Model/AdminPanel.tsx +4 -3
  38. package/ui/Signal/Arg.tsx +20 -7
  39. package/ui/Signal/makeExample.ts +1 -1
@@ -1,4 +1,5 @@
1
1
  import type { SliceMeta } from "akanjs/fetch";
2
+ import type { SerializedArg } from "akanjs/signal";
2
3
  import type { ReactNode } from "react";
3
4
  import type { TransMessageOption } from "./makePageProto";
4
5
 
@@ -49,6 +50,8 @@ type RuntimeFetch = typeof globalThis.fetch & {
49
50
  slice: Record<string, SliceMeta>;
50
51
  /** Sort keys per model refName, registered from each serialized signal's filter. */
51
52
  sortKeyMap?: Map<string, string[]>;
53
+ /** Filter queries per model refName, with the args each one takes — what the root slice picks from. */
54
+ filterQueryMap?: Map<string, { [queryKey: string]: SerializedArg[] }>;
52
55
  ws: RuntimeWs;
53
56
  [key: string]: unknown;
54
57
  };
package/client/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BaseInsight } from "akanjs/constant";
2
- import type { FetchInitForm, SliceMeta } from "akanjs/fetch";
2
+ import type { FetchInitForm, QuerySetting, SliceMeta } from "akanjs/fetch";
3
3
  import type { ReactElement, ReactNode } from "react";
4
4
 
5
5
  export type ReactFontStyle = "normal" | "italic" | "oblique";
@@ -109,7 +109,7 @@ const slugFontPart = (value: string) =>
109
109
  export interface ModelsProps<M extends { id: string }> {
110
110
  className?: string;
111
111
  slice?: SliceMeta;
112
- query?: Record<string, unknown>;
112
+ query?: QuerySetting;
113
113
  init?: FetchInitForm<any, any>;
114
114
  onClickItem?: (model: M) => unknown;
115
115
  }
@@ -127,7 +127,7 @@ export type ModelProps<T extends string, L extends { id: string }> = { [key in T
127
127
  export interface ModelDashboardProps<Summary> {
128
128
  className?: string;
129
129
  summary: Summary;
130
- queryMap?: Record<string, unknown>;
130
+ queryMap?: { [column: string]: QuerySetting };
131
131
  columns?: (keyof Summary)[];
132
132
  hidePresents?: boolean;
133
133
  slice?: SliceMeta;
@@ -206,12 +206,13 @@ export class ModelDictInfo<
206
206
  } as unknown as BaseModelCrudGetSignalTranslation<T, [string, string]>;
207
207
  }
208
208
  static baseSliceDictionary: {
209
- [key in ""]: FunctionTranslation<[string, string], "query">;
209
+ [key in ""]: FunctionTranslation<[string, string], "queryKey" | "args">;
210
210
  } = {
211
211
  "": fn(["Universal", "유니버설"])
212
212
  .desc(["Universal Slice", "유니버설 슬라이스"])
213
213
  .arg((t) => ({
214
- query: t(["Query", "쿼리"]).desc(["Query Description", "쿼리 설명"]),
214
+ queryKey: t(["Query", "쿼리"]).desc(["Filter query to run", "실행할 필터 쿼리"]),
215
+ args: t(["Arguments", "인자"]).desc(["Arguments of the filter query", "필터 쿼리의 인자"]),
215
216
  })),
216
217
  };
217
218
 
@@ -157,6 +157,7 @@ export const createDocumentQueryHelper = () => ({
157
157
  lte: (value: unknown) => op("lte", value),
158
158
  between: (from: unknown, to: unknown) => op("between", [from, to]),
159
159
  exists: (path: string) => ({ [path]: op("exists") }),
160
+
160
161
  missing: (path: string) => ({ [path]: op("missing") }),
161
162
  empty: (path: string) => ({ [path]: op("empty") }),
162
163
  has: (value: unknown) => op("has", value),
@@ -1,16 +1,18 @@
1
- import type { Cls, MergeAllDoubleKeyOfObjects, MergeAllKeyOfTypes } from "akanjs/base";
2
- import { FILTER_DICT_SHAPE, FILTER_META } from "akanjs/base";
3
- import type {
4
- BaseObject,
5
- ConstantFieldTypeInput,
6
- DocumentModel,
7
- FieldToValue,
8
- PlainTypeToFieldType,
9
- QueryOf,
10
- Serialized,
1
+ import type { Cls, EnumInstance, MergeAllDoubleKeyOfObjects, MergeAllKeyOfTypes } from "akanjs/base";
2
+ import { FILTER_DICT_SHAPE, FILTER_META, getNonArrayModel, isEnum } from "akanjs/base";
3
+ import {
4
+ type BaseObject,
5
+ type ConstantFieldType,
6
+ type ConstantFieldTypeInput,
7
+ type DocumentModel,
8
+ deserialize,
9
+ type FieldToValue,
10
+ type PlainTypeToFieldType,
11
+ type QueryOf,
12
+ type Serialized,
11
13
  } from "akanjs/constant";
12
14
 
13
- import type { DocumentQuery, DocumentQueryHelper } from "./documentQuery";
15
+ import { type DocumentQuery, type DocumentQueryHelper, documentQueryHelper } from "./documentQuery";
14
16
  import type { ConstantFilterMeta } from "./types";
15
17
 
16
18
  const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
@@ -98,6 +100,70 @@ export const fillMissingFilterArgs = (filterInfo: FilterInfo, args: unknown[]) =
98
100
  return [...args, ...Array(filterInfo.args.length - args.length).fill(undefined)];
99
101
  };
100
102
 
103
+ export interface FilterArgInfo {
104
+ name: string;
105
+ argRef: ConstantFieldType;
106
+ arrDepth: number;
107
+ enum?: EnumInstance;
108
+ nullable: boolean;
109
+ ref?: string;
110
+ }
111
+ /** Unwraps a filter arg's declaration the way `EndpointInfo.getArgInfo` unwraps an endpoint's. */
112
+ export const getFilterArgInfo = (arg: FilterInfo["args"][number]): FilterArgInfo => {
113
+ const [singleArg, arrDepth] = getNonArrayModel(arg.argRef as Cls);
114
+ const argIsEnum = isEnum(singleArg);
115
+ return {
116
+ name: arg.name,
117
+ argRef: (argIsEnum ? (singleArg as EnumInstance).type : singleArg) as ConstantFieldType,
118
+ arrDepth,
119
+ enum: argIsEnum ? (singleArg as EnumInstance) : undefined,
120
+ nullable: !!arg.option?.nullable,
121
+ ref: arg.option?.ref,
122
+ };
123
+ };
124
+ export const getFilterArgInfos = (filterInfo: FilterInfo): FilterArgInfo[] => filterInfo.args.map(getFilterArgInfo);
125
+
126
+ /** A caller named a filter that does not exist, or gave it arguments it cannot take. Its callers answer 400. */
127
+ export class FilterQueryError extends Error {}
128
+
129
+ const tryDeserializeFilterArg = (arg: FilterArgInfo, value: unknown, key: string) => {
130
+ try {
131
+ return deserialize(arg.argRef, arg.arrDepth, value, { key: arg.name, nullable: arg.nullable });
132
+ } catch (error) {
133
+ throw new FilterQueryError(
134
+ `Invalid filter argument "${arg.name}" for key: ${key}: ${error instanceof Error ? error.message : String(error)}`,
135
+ );
136
+ }
137
+ };
138
+
139
+ /**
140
+ * Compiles a `(queryKey, args)` pair into the query its filter declares — the root slice's whole contract.
141
+ * Every arg is parsed by the type the filter declared for it, so a Date arg reaches the query as a Dayjs and
142
+ * an id that is not one is refused here rather than becoming a query that matches nothing. Args past the
143
+ * declared ones are dropped: the caller names a filter, never a query.
144
+ */
145
+ export const resolveFilterQuery = (
146
+ filterRef: FilterCls,
147
+ queryKey?: string | null,
148
+ args?: unknown[] | null,
149
+ ): QueryOf<any> => {
150
+ const key = queryKey || "any";
151
+ const filterInfo = getFilterMeta(filterRef).query[key];
152
+ const queryFn = filterInfo?.queryFn;
153
+ if (!queryFn) throw new FilterQueryError(`No filter query for key: ${key}`);
154
+ const given = Array.isArray(args) ? args : [];
155
+ const queryArgs = getFilterArgInfos(filterInfo).map((arg, idx) => {
156
+ const value = given[idx];
157
+ if (!arg.nullable && (value === null || value === undefined))
158
+ throw new FilterQueryError(`Missing filter argument "${arg.name}" for key: ${key}`);
159
+ const parsed = tryDeserializeFilterArg(arg, value, key);
160
+
161
+ return parsed === null ? undefined : parsed;
162
+ });
163
+
164
+ return queryFn(...queryArgs, documentQueryHelper) as QueryOf<any>;
165
+ };
166
+
101
167
  export const assertFilterFitsCrud = (refName: string, queryKey: string, className: string) => {
102
168
  if (queryKey.toLowerCase() !== refName.toLowerCase()) return;
103
169
  throw new Error(
@@ -81,6 +81,7 @@ export class FetchClient {
81
81
  readonly handler: Record<string, FetchHandler>;
82
82
  readonly slice: Record<string, SliceMeta> = {};
83
83
  readonly sortKeyMap = new Map<string, string[]>();
84
+ readonly filterQueryMap = new Map<string, { [queryKey: string]: SerializedArg[] }>();
84
85
  readonly #originWs = new Map<string, WsClient>();
85
86
  readonly #handlerStore: Record<string, FetchHandler> = {};
86
87
  readonly #handlerFactory = new Map<string, FetchHandlerFactory>();
@@ -178,10 +179,10 @@ export class FetchClient {
178
179
  this.#registerSlice(refName, suffix, slice, signal.prefix);
179
180
  }
180
181
  if (signal.filter) {
181
- this.#registerFilterSortKey(refName, signal.filter.sortKeys);
182
- for (const [suffix, args] of Object.entries(signal.filter.filter)) {
183
- this.#registerFilterQuery(suffix, args);
184
- }
182
+
183
+ const filter = this.serializedSignal[refName]?.filter ?? signal.filter;
184
+ this.#registerFilterSortKey(refName, filter.sortKeys);
185
+ this.#registerFilterQuery(refName, filter.filter);
185
186
  }
186
187
  }
187
188
  return this;
@@ -279,7 +280,8 @@ export class FetchClient {
279
280
  #registerFilterSortKey(refName: string, sortKeys: string[]) {
280
281
  this.sortKeyMap.set(refName, sortKeys);
281
282
  }
282
- #registerFilterQuery(suffix: string, args: SerializedArg[]) {
283
+ #registerFilterQuery(refName: string, filter: { [queryKey: string]: SerializedArg[] }) {
284
+ this.filterQueryMap.set(refName, filter);
283
285
  }
284
286
  #makeHttpFn(key: string, endpoint: SerializedEndpoint, prefix?: string) {
285
287
  const argLength = endpoint.args.length;
@@ -114,7 +114,9 @@ export class HttpClient {
114
114
  searchArgs.forEach((arg) => {
115
115
  const argValue = argMap.get(arg.name);
116
116
  if (argValue === null || argValue === undefined) return;
117
- if (arg.arrDepth && Array.isArray(argValue))
117
+
118
+ if (arg.refName === "Any") searchParams.set(arg.name, JSON.stringify(argValue));
119
+ else if (arg.arrDepth && Array.isArray(argValue))
118
120
  argValue.forEach((value) => {
119
121
  searchParams.append(arg.name, String(value));
120
122
  });
@@ -8,6 +8,12 @@ export type SliceMeta = {
8
8
  argLength: number;
9
9
  };
10
10
 
11
+ /** What the root slice takes: one of the model's declared filter queries, and the args that filter asks for. */
12
+ export interface QuerySetting {
13
+ queryKey: string;
14
+ args?: unknown[];
15
+ }
16
+
11
17
  export type ServerInit<
12
18
  RefName extends string,
13
19
  Light,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.59",
3
+ "version": "3.0.0-alpha.60",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -385,6 +385,8 @@ interface SqlDialect {
385
385
  docColumn(): string;
386
386
  docValuePlaceholder(): string;
387
387
  extract(path: string): string;
388
+ projectExpr(path: string): string;
389
+ decodeProjected(value: unknown): unknown;
388
390
  eq(path: string, value: unknown): SqlFrag;
389
391
  ne(path: string, value: unknown): SqlFrag;
390
392
  compare(path: string, op: "gt" | "gte" | "lt" | "lte", value: unknown): SqlFrag;
@@ -423,6 +425,13 @@ export class SqliteDialect implements SqlDialect {
423
425
  extract(path: string) {
424
426
  return `json_extract(${this.docColumn()}, ${this.#path(path)})`;
425
427
  }
428
+
429
+ projectExpr(path: string) {
430
+ return `${this.docColumn()} -> ${this.#path(path)}`;
431
+ }
432
+ decodeProjected(value: unknown) {
433
+ return typeof value === "string" ? JSON.parse(value) : value;
434
+ }
426
435
  eq(path: string, value: unknown): SqlFrag {
427
436
  return value === null
428
437
  ? { sql: `${this.extract(path)} IS NULL`, params: [] }
@@ -554,6 +563,13 @@ export class PostgresDialect implements SqlDialect {
554
563
  extract(path: string) {
555
564
  return this.#jsonb(path);
556
565
  }
566
+
567
+ projectExpr(path: string) {
568
+ return this.#jsonb(path);
569
+ }
570
+ decodeProjected(value: unknown) {
571
+ return value;
572
+ }
557
573
  eq(path: string, value: unknown): SqlFrag {
558
574
  return value === null
559
575
  ? { sql: `${this.#jsonb(path)} IS NULL`, params: [] }
@@ -747,6 +763,11 @@ class QueryCompiler {
747
763
  return BASE_COLUMNS.has(path) ? quoteIdent(path) : this.dialect.extract(path);
748
764
  }
749
765
 
766
+ projectExpr(path: string) {
767
+ this.assertPath(path);
768
+ return BASE_COLUMNS.has(path) ? quoteIdent(path) : this.dialect.projectExpr(path);
769
+ }
770
+
750
771
  private compileNode(query: DocumentQuery, ctx: CompileContext): { sql: string; params: unknown[] } {
751
772
  if (this.isQueryNode(query)) {
752
773
  if (query.kind === "search") {
@@ -1416,7 +1437,7 @@ export class SqlDocumentStore {
1416
1437
  const jsonFields = fields.filter((field) => !BASE_COLUMNS.has(field));
1417
1438
  const baseColumns = [...BASE_COLUMNS].map((field) => quoteIdent(field));
1418
1439
  const jsonColumns = jsonFields.map(
1419
- (field, idx) => `${this.compiler.fieldExpr(field)} AS ${quoteIdent(this.projectionAlias(idx))}`,
1440
+ (field, idx) => `${this.compiler.projectExpr(field)} AS ${quoteIdent(this.projectionAlias(idx))}`,
1420
1441
  );
1421
1442
  return [...baseColumns, ...jsonColumns].join(", ");
1422
1443
  }
@@ -1434,7 +1455,7 @@ export class SqlDocumentStore {
1434
1455
  };
1435
1456
  const jsonFields = fields.filter((field) => !BASE_COLUMNS.has(field));
1436
1457
  for (const [idx, field] of jsonFields.entries()) {
1437
- const value = this.parseProjectedValue(row[this.projectionAlias(idx)]);
1458
+ const value = this.dialect.decodeProjected(row[this.projectionAlias(idx)]);
1438
1459
  const props = (this.database.doc[FIELD_META] as unknown as FieldMap)[field]?.getProps?.();
1439
1460
  if (value === null && !props?.nullable) {
1440
1461
  if (props?.default != null) {
@@ -1514,17 +1535,6 @@ export class SqlDocumentStore {
1514
1535
  );
1515
1536
  }
1516
1537
 
1517
- private parseProjectedValue(value: unknown) {
1518
- if (typeof value !== "string") return value;
1519
- const trimmed = value.trim();
1520
- if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return value;
1521
- try {
1522
- return JSON.parse(trimmed);
1523
- } catch {
1524
- return value;
1525
- }
1526
- }
1527
-
1528
1538
  private decodeDocumentPayload(payload: Record<string, unknown>) {
1529
1539
  const fields = this.database.doc[FIELD_META] as unknown as FieldMap;
1530
1540
  const result: Record<string, unknown> = {};
@@ -38,7 +38,11 @@ export class JsonSchemaBuilder {
38
38
  }
39
39
 
40
40
  arg(arg: SerializedArg): JsonSchema {
41
- const schema = arg.enum ? this.#enum(arg.enum) : this.#ref(arg.refName, arg.modelType);
41
+ const schema = arg.oneOf
42
+ ? JsonSchemaBuilder.#inlineEnum(arg.oneOf)
43
+ : arg.enum
44
+ ? this.#enum(arg.enum)
45
+ : this.#ref(arg.refName, arg.modelType);
42
46
  return JsonSchemaBuilder.#nullable(JsonSchemaBuilder.#arrayed(schema, arg.arrDepth ?? 0), !!arg.nullable);
43
47
  }
44
48
 
@@ -1,7 +1,7 @@
1
1
  import { type Cls, ENDPOINT_META, PrimitiveRegistry, type PrimitiveScalar, SLICE_META } from "akanjs/base";
2
2
  import { Logger } from "akanjs/common";
3
3
  import { ConstantRegistry, type ConstantType } from "akanjs/constant";
4
- import { getFilterMeta } from "akanjs/document";
4
+ import { type FilterArgInfo, getFilterArgInfos, getFilterMeta } from "akanjs/document";
5
5
  import type { LiveRegistry } from "akanjs/service";
6
6
  import type {
7
7
  ArgInfo,
@@ -10,6 +10,7 @@ import type {
10
10
  EndpointInfo,
11
11
  SerializedArg,
12
12
  SerializedEndpoint,
13
+ SerializedFilter,
13
14
  SerializedReturns,
14
15
  SerializedSignal,
15
16
  SerializedSlice,
@@ -71,6 +72,35 @@ export class FetchSerializer {
71
72
  };
72
73
  }
73
74
 
75
+ static #serializeFilterArg(argInfo: FilterArgInfo): SerializedArg {
76
+ const { refName, modelType } = FetchSerializer.#resolveRefInfo(argInfo.argRef as Cls);
77
+ return {
78
+ type: "search",
79
+ refName,
80
+ name: argInfo.name,
81
+ ...(modelType ? { modelType: modelType as SerializedArg["modelType"] } : {}),
82
+ ...(argInfo.arrDepth ? { arrDepth: argInfo.arrDepth } : {}),
83
+ ...(argInfo.nullable ? { nullable: true } : {}),
84
+ ...(argInfo.enum ? { enum: argInfo.enum.refName } : {}),
85
+ ...(argInfo.ref ? { ref: argInfo.ref } : {}),
86
+ };
87
+ }
88
+ /**
89
+ * The model's filter surface, which is what the root slice takes instead of a raw query. A client cannot
90
+ * offer a filter it cannot name, nor fill args it cannot type — so both travel, unlike the query map that
91
+ * used to stay server-side.
92
+ */
93
+ static #serializeFilter(sliceCls: SliceCls): SerializedFilter | undefined {
94
+ const filterMeta = getFilterMeta(sliceCls.srv.db.filter, { allowEmpty: true });
95
+ if (!filterMeta) return undefined;
96
+ const filter = Object.fromEntries(
97
+ Object.entries(filterMeta.query).map(([key, filterInfo]) => [
98
+ key,
99
+ getFilterArgInfos(filterInfo).map(FetchSerializer.#serializeFilterArg),
100
+ ]),
101
+ );
102
+ return { filter, sortKeys: Object.keys(filterMeta.sort) };
103
+ }
74
104
  static #serializeSlice(sliceInfo: SliceInfo): SerializedSlice {
75
105
  const guards = sliceInfo.signalOption.guards?.map((g) => g.name);
76
106
  return {
@@ -92,12 +122,14 @@ export class FetchSerializer {
92
122
  for (const [key, endpointInfo] of Object.entries(endpointMeta)) {
93
123
  endpoint[key] = FetchSerializer.#serializeEndpoint(endpointInfo);
94
124
  }
125
+ const filter = FetchSerializer.#serializeFilter(sliceCls);
95
126
 
96
- const sortKeys = Object.keys(getFilterMeta(sliceCls.srv.db.filter, { allowEmpty: true })?.sort ?? {});
127
+ const queryKeyArg = slice[""]?.args.find((arg) => arg.name === "queryKey");
128
+ if (queryKeyArg && filter) queryKeyArg.oneOf = Object.keys(filter.filter);
97
129
  return {
98
130
  ...(prefix ? { prefix } : {}),
99
131
  ...(Object.keys(slice).length ? { slice } : {}),
100
- ...(sortKeys.length ? { filter: { filter: {}, sortKeys } } : {}),
132
+ ...(filter ? { filter } : {}),
101
133
  ...(sliceCls.getGuards.filter((g) => g.name !== "None").length
102
134
  ? { getGuards: sliceCls.getGuards.map((g) => g.name) }
103
135
  : {}),
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Any,
2
3
  type BackendEnv,
3
4
  type Cls,
4
5
  FIELD_META,
@@ -457,6 +458,16 @@ export class HttpExecutionContext<Appended = unknown> {
457
458
  if (!this.#url) this.#url = new URL(this.req.url);
458
459
  return this.#url;
459
460
  }
461
+ /** The read side of `HttpClient.makeUrl`'s `Any` rule: the query string carries the value JSON-encoded. */
462
+ static #parseAny(name: string, raw: string | string[] | null): unknown {
463
+ if (raw === null) return null;
464
+ if (Array.isArray(raw)) return raw.map((value) => HttpExecutionContext.#parseAny(name, value));
465
+ try {
466
+ return JSON.parse(raw);
467
+ } catch {
468
+ throw new Exception.BadRequest(`Invalid JSON in "${name}"`);
469
+ }
470
+ }
460
471
  async getArgs(endpointInfo: EndpointInfo): Promise<unknown[]> {
461
472
  if (endpointInfo.args.length === 0) return [];
462
473
  this.params = this.req.params;
@@ -496,7 +507,8 @@ export class HttpExecutionContext<Appended = unknown> {
496
507
  nullable: arg.option?.nullable,
497
508
  });
498
509
  case "search": {
499
- const value = arg.arrDepth ? this.url.searchParams.getAll(arg.name) : this.url.searchParams.get(arg.name);
510
+ const raw = arg.arrDepth ? this.url.searchParams.getAll(arg.name) : this.url.searchParams.get(arg.name);
511
+ const value = arg.argRef === Any ? HttpExecutionContext.#parseAny(arg.name, raw) : raw;
500
512
  const result = deserialize(arg.argRef, arg.arrDepth, value, {
501
513
  key: arg.name,
502
514
  nullable: arg.option?.nullable,
package/signal/slice.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { Any, type Assign, type MergeAllKeyOfObjects, SLICE_DICT_SHAPE, SLICE_META } from "akanjs/base";
2
2
  import { applyMixins } from "akanjs/common";
3
- import type { DocumentModel, QueryOf } from "akanjs/constant";
4
- import type { FilterInstance } from "akanjs/document";
3
+ import { type FilterInstance, FilterQueryError, resolveFilterQuery } from "akanjs/document";
5
4
  import { type Adaptor, type AdaptorCls, dangerouslyAdapt, type ServiceModel } from "akanjs/service";
5
+ import { Exception } from "./exception";
6
6
  import type { GuardCls } from "./guard";
7
7
  import {
8
8
  buildSlice,
@@ -52,7 +52,7 @@ interface RootSliceOption {
52
52
  prefix?: string;
53
53
  }
54
54
 
55
- type RootSliceQuery<SrvModule extends ServiceModel, Full = CnstFull<SrvModule>> = QueryOf<DocumentModel<Full>>;
55
+ type RootSliceQueryKey<Filter extends FilterInstance> = Extract<keyof Filter["query"], string>;
56
56
 
57
57
  type ExtendSliceInfoObj<
58
58
  SrvModule extends ServiceModel,
@@ -91,7 +91,7 @@ export function slice<
91
91
  _Light = CnstLight<SrvModule>,
92
92
  _Insight = CnstInsight<SrvModule>,
93
93
  _Filter extends FilterInstance = DbFilter<SrvModule>,
94
- _Query = RootSliceQuery<SrvModule, _Full>,
94
+ _QueryKey extends string = RootSliceQueryKey<_Filter>,
95
95
  >(
96
96
  srv: SrvModule,
97
97
  option: RootSliceOption,
@@ -111,24 +111,18 @@ export function slice<
111
111
  _Insight,
112
112
  _Filter,
113
113
  SrvMap<SrvModule>,
114
- ["query"],
115
- [query?: _Query | null],
114
+ ["queryKey", "args"],
115
+ [queryKey?: _QueryKey | null, args?: unknown[] | null],
116
116
  [],
117
- [_Query]
117
+ [_QueryKey | undefined, unknown[] | undefined]
118
118
  >;
119
119
  }
120
120
  : ExtendSliceInfoObj<SrvModule, LibSlices>
121
121
  >
122
122
  > {
123
123
  if (!srv.cnst || !srv.db) throw new Error("cnst and db are required");
124
- const init = buildSlice(
125
- srv.srv.refName,
126
- srv.cnst.input,
127
- srv.cnst.full,
128
- srv.cnst.light,
129
- srv.cnst.insight,
130
- srv.db.filter,
131
- );
124
+ const filterRef = srv.db.filter;
125
+ const init = buildSlice(srv.srv.refName, srv.cnst.input, srv.cnst.full, srv.cnst.light, srv.cnst.insight, filterRef);
132
126
  const toGuards = (guard?: GuardCls | GuardCls[]) => (guard ? (Array.isArray(guard) ? guard : [guard]) : []);
133
127
  const rootGuards = toGuards(option.guards?.root);
134
128
  const getGuards = toGuards(option.guards?.get);
@@ -152,9 +146,19 @@ export function slice<
152
146
  static removeGuards = removeGuards;
153
147
  static [SLICE_META] = Object.assign(
154
148
  {
149
+
155
150
  [""]: init({ guards: rootGuards })
156
- .search<"query", object>("query", Any)
157
- .exec((query) => query ?? {}),
151
+ .search<"queryKey", string>("queryKey", String)
152
+ .search<"args", unknown[]>("args", Any)
153
+ .exec((queryKey, args) => {
154
+ try {
155
+ return resolveFilterQuery(filterRef, queryKey, args);
156
+ } catch (error) {
157
+
158
+ if (error instanceof FilterQueryError) throw new Exception.BadRequest(error.message);
159
+ throw error;
160
+ }
161
+ }),
158
162
  },
159
163
  sliceBuilder(init as Parameters<BuildSlice>[0]),
160
164
  );
package/signal/types.ts CHANGED
@@ -120,12 +120,17 @@ export interface SerializedArg {
120
120
  nullable?: boolean;
121
121
  example?: string | number | boolean | Date;
122
122
  enum?: string;
123
+ /** The values this arg accepts, when they are a fixed list the caller has to pick from. */
124
+ oneOf?: (string | number)[];
125
+ /** For an id a filter declared against a model: the model it points at, so a UI can offer a picker. */
126
+ ref?: string;
123
127
  }
124
128
  export interface SerializedEndpoint extends SerializedSignalOption {
125
129
  type: "query" | "mutation" | "pubsub" | "message" | "prompt";
126
130
  returns: SerializedReturns;
127
131
  }
128
132
  export interface SerializedFilter {
133
+ /** Every filter query the model declares, by key, with the args each one takes. */
129
134
  filter: { [key: string]: SerializedArg[] };
130
135
  sortKeys: string[];
131
136
  }
@@ -1,4 +1,5 @@
1
1
  import type { SliceMeta } from "akanjs/fetch";
2
+ import type { SerializedArg } from "akanjs/signal";
2
3
  import type { ReactNode } from "react";
3
4
  import type { TransMessageOption } from "./makePageProto.d.ts";
4
5
  type RuntimeTranslate = ((key: string, param?: Record<string, string | number>) => string) & {
@@ -41,6 +42,10 @@ type RuntimeFetch = typeof globalThis.fetch & {
41
42
  slice: Record<string, SliceMeta>;
42
43
  /** Sort keys per model refName, registered from each serialized signal's filter. */
43
44
  sortKeyMap?: Map<string, string[]>;
45
+ /** Filter queries per model refName, with the args each one takes — what the root slice picks from. */
46
+ filterQueryMap?: Map<string, {
47
+ [queryKey: string]: SerializedArg[];
48
+ }>;
44
49
  ws: RuntimeWs;
45
50
  [key: string]: unknown;
46
51
  };
@@ -1,5 +1,5 @@
1
1
  import type { BaseInsight } from "akanjs/constant";
2
- import type { FetchInitForm, SliceMeta } from "akanjs/fetch";
2
+ import type { FetchInitForm, QuerySetting, SliceMeta } from "akanjs/fetch";
3
3
  import type { ReactElement, ReactNode } from "react";
4
4
  export type ReactFontStyle = "normal" | "italic" | "oblique";
5
5
  export type ReactFontDisplay = "auto" | "block" | "swap" | "fallback" | "optional";
@@ -37,7 +37,7 @@ export interface ModelsProps<M extends {
37
37
  }> {
38
38
  className?: string;
39
39
  slice?: SliceMeta;
40
- query?: Record<string, unknown>;
40
+ query?: QuerySetting;
41
41
  init?: FetchInitForm<any, any>;
42
42
  onClickItem?: (model: M) => unknown;
43
43
  }
@@ -57,7 +57,9 @@ export type ModelProps<T extends string, L extends {
57
57
  export interface ModelDashboardProps<Summary> {
58
58
  className?: string;
59
59
  summary: Summary;
60
- queryMap?: Record<string, unknown>;
60
+ queryMap?: {
61
+ [column: string]: QuerySetting;
62
+ };
61
63
  columns?: (keyof Summary)[];
62
64
  hidePresents?: boolean;
63
65
  slice?: SliceMeta;
@@ -73,7 +73,7 @@ export declare class ModelDictInfo<Languages extends [string, ...string[]] = [st
73
73
  };
74
74
  static getBaseSignalDictionary<T extends string>(refName: T): BaseModelCrudGetSignalTranslation<T, [string, string]>;
75
75
  static baseSliceDictionary: {
76
- [key in ""]: FunctionTranslation<[string, string], "query">;
76
+ [key in ""]: FunctionTranslation<[string, string], "queryKey" | "args">;
77
77
  };
78
78
  languages: Languages;
79
79
  modelTranslation?: FieldTranslation<Languages>;
@@ -1,7 +1,7 @@
1
- import type { Cls, MergeAllDoubleKeyOfObjects, MergeAllKeyOfTypes } from "akanjs/base";
1
+ import type { Cls, EnumInstance, MergeAllDoubleKeyOfObjects, MergeAllKeyOfTypes } from "akanjs/base";
2
2
  import { FILTER_DICT_SHAPE, FILTER_META } from "akanjs/base";
3
- import type { BaseObject, ConstantFieldTypeInput, DocumentModel, FieldToValue, PlainTypeToFieldType, QueryOf, Serialized } from "akanjs/constant";
4
- import type { DocumentQuery, DocumentQueryHelper } from "./documentQuery.d.ts";
3
+ import { type BaseObject, type ConstantFieldType, type ConstantFieldTypeInput, type DocumentModel, type FieldToValue, type PlainTypeToFieldType, type QueryOf, type Serialized } from "akanjs/constant";
4
+ import { type DocumentQuery, type DocumentQueryHelper } from "./documentQuery.d.ts";
5
5
  import type { ConstantFilterMeta } from "./types.d.ts";
6
6
  export declare const isFilterModel: (filterRef: Cls<unknown, {
7
7
  [FILTER_META]?: ConstantFilterMeta;
@@ -19,6 +19,27 @@ export declare const getFilterInfoByKey: <ArgNames extends string[] = [], Args e
19
19
  export declare const setFilterInfoByKey: <ArgNames extends string[] = [], Args extends any[] = any[], Model = any>(modelRef: Cls<Model>, key: string, filterInfo: FilterInfo<ArgNames, Args, Model>) => void;
20
20
  export declare const getFilterSortByKey: (modelRef: FilterCls, key: string) => unknown;
21
21
  export declare const fillMissingFilterArgs: (filterInfo: FilterInfo, args: unknown[]) => any[];
22
+ export interface FilterArgInfo {
23
+ name: string;
24
+ argRef: ConstantFieldType;
25
+ arrDepth: number;
26
+ enum?: EnumInstance;
27
+ nullable: boolean;
28
+ ref?: string;
29
+ }
30
+ /** Unwraps a filter arg's declaration the way `EndpointInfo.getArgInfo` unwraps an endpoint's. */
31
+ export declare const getFilterArgInfo: (arg: FilterInfo["args"][number]) => FilterArgInfo;
32
+ export declare const getFilterArgInfos: (filterInfo: FilterInfo) => FilterArgInfo[];
33
+ /** A caller named a filter that does not exist, or gave it arguments it cannot take. Its callers answer 400. */
34
+ export declare class FilterQueryError extends Error {
35
+ }
36
+ /**
37
+ * Compiles a `(queryKey, args)` pair into the query its filter declares — the root slice's whole contract.
38
+ * Every arg is parsed by the type the filter declared for it, so a Date arg reaches the query as a Dayjs and
39
+ * an id that is not one is refused here rather than becoming a query that matches nothing. Args past the
40
+ * declared ones are dropped: the caller names a filter, never a query.
41
+ */
42
+ export declare const resolveFilterQuery: (filterRef: FilterCls, queryKey?: string | null, args?: unknown[] | null) => QueryOf<any>;
22
43
  export declare const assertFilterFitsCrud: (refName: string, queryKey: string, className: string) => void;
23
44
  export type BaseFilterSortKey = "latest" | "oldest" | "relevance";
24
45
  export type BaseFilterQueryKey = "any";
@@ -27,6 +27,9 @@ export declare class FetchClient {
27
27
  readonly handler: Record<string, FetchHandler>;
28
28
  readonly slice: Record<string, SliceMeta>;
29
29
  readonly sortKeyMap: Map<string, string[]>;
30
+ readonly filterQueryMap: Map<string, {
31
+ [queryKey: string]: SerializedArg[];
32
+ }>;
30
33
  serializedSignal: {
31
34
  [key: string]: SerializedSignal;
32
35
  };
@@ -6,6 +6,11 @@ export type SliceMeta = {
6
6
  sliceName: string;
7
7
  argLength: number;
8
8
  };
9
+ /** What the root slice takes: one of the model's declared filter queries, and the args that filter asks for. */
10
+ export interface QuerySetting {
11
+ queryKey: string;
12
+ args?: unknown[];
13
+ }
9
14
  export type ServerInit<RefName extends string, Light, Insight = any, QueryArgs = any, Filter extends FilterInstance = any, _CapitalizedRefName extends string = Capitalize<RefName>, _LightObj = GetStateObject<Light>, _InsightObj = GetStateObject<Insight>, _Sort = ExtractSort<Filter>> = SliceMeta & {
10
15
  [K in `${RefName}ObjList`]: _LightObj[];
11
16
  } & {
@@ -201,6 +201,8 @@ interface SqlDialect {
201
201
  docColumn(): string;
202
202
  docValuePlaceholder(): string;
203
203
  extract(path: string): string;
204
+ projectExpr(path: string): string;
205
+ decodeProjected(value: unknown): unknown;
204
206
  eq(path: string, value: unknown): SqlFrag;
205
207
  ne(path: string, value: unknown): SqlFrag;
206
208
  compare(path: string, op: "gt" | "gte" | "lt" | "lte", value: unknown): SqlFrag;
@@ -224,6 +226,8 @@ export declare class SqliteDialect implements SqlDialect {
224
226
  docColumn(): string;
225
227
  docValuePlaceholder(): string;
226
228
  extract(path: string): string;
229
+ projectExpr(path: string): string;
230
+ decodeProjected(value: unknown): any;
227
231
  eq(path: string, value: unknown): SqlFrag;
228
232
  ne(path: string, value: unknown): SqlFrag;
229
233
  compare(path: string, op: "gt" | "gte" | "lt" | "lte", value: unknown): SqlFrag;
@@ -247,6 +251,8 @@ export declare class PostgresDialect implements SqlDialect {
247
251
  docColumn(): string;
248
252
  docValuePlaceholder(): string;
249
253
  extract(path: string): string;
254
+ projectExpr(path: string): string;
255
+ decodeProjected(value: unknown): unknown;
250
256
  eq(path: string, value: unknown): SqlFrag;
251
257
  ne(path: string, value: unknown): SqlFrag;
252
258
  compare(path: string, op: "gt" | "gte" | "lt" | "lte", value: unknown): SqlFrag;
@@ -272,6 +278,7 @@ declare class QueryCompiler {
272
278
  compile(query?: DocumentQuery): CompiledQuery;
273
279
  orderBy(sort?: Record<string, 1 | -1>): string;
274
280
  fieldExpr(path: string): string;
281
+ projectExpr(path: string): string;
275
282
  private compileNode;
276
283
  private compileField;
277
284
  private assertPath;
@@ -379,7 +386,6 @@ export declare class SqlDocumentStore {
379
386
  private findOneForWrite;
380
387
  private pickByIdForWrite;
381
388
  private writeUpdatedDocument;
382
- private parseProjectedValue;
383
389
  private decodeDocumentPayload;
384
390
  private decodeFieldValue;
385
391
  private decodeMapValue;
@@ -1,6 +1,5 @@
1
1
  import { type Assign, type MergeAllKeyOfObjects, SLICE_DICT_SHAPE, SLICE_META } from "akanjs/base";
2
- import type { DocumentModel, QueryOf } from "akanjs/constant";
3
- import type { FilterInstance } from "akanjs/document";
2
+ import { type FilterInstance } from "akanjs/document";
4
3
  import { type Adaptor, type AdaptorCls, type ServiceModel } from "akanjs/service";
5
4
  import type { GuardCls } from "./guard.d.ts";
6
5
  import { type SliceBuilder, type SliceInfo, type SliceInfoArgNames, type SliceInfoArgs, type SliceInfoInternalArgs, type SliceInfoServerArgs, type SliceInfoSrvs } from "./sliceInfo.d.ts";
@@ -42,19 +41,22 @@ interface RootSliceOption {
42
41
  };
43
42
  prefix?: string;
44
43
  }
45
- type RootSliceQuery<SrvModule extends ServiceModel, Full = CnstFull<SrvModule>> = QueryOf<DocumentModel<Full>>;
44
+ type RootSliceQueryKey<Filter extends FilterInstance> = Extract<keyof Filter["query"], string>;
46
45
  type ExtendSliceInfoObj<SrvModule extends ServiceModel, LibSlices extends SliceCls[], _Input = CnstInput<SrvModule>, _Full = CnstFull<SrvModule>, _Light = CnstLight<SrvModule>, _Insight = CnstInsight<SrvModule>, _Filter extends FilterInstance = DbFilter<SrvModule>, _Merged = MergeAllKeyOfObjects<LibSlices, typeof SLICE_META>> = {
47
46
  [K in keyof _Merged]: _Merged[K] extends SliceInfo ? SliceInfo<SrvRefName<SrvModule>, _Input, _Full, _Light, _Insight, _Filter, SliceInfoSrvs<_Merged[K]>, SliceInfoArgNames<_Merged[K]>, SliceInfoArgs<_Merged[K]>, SliceInfoInternalArgs<_Merged[K]>, SliceInfoServerArgs<_Merged[K]>> : never;
48
47
  };
49
48
  /** Builds database-backed slice APIs for list, insight, init, view, edit, create, update, and remove flows. */
50
- export declare function slice<SrvModule extends ServiceModel, BuildSlice extends SliceBuilder<SrvModule>, LibSlices extends SliceCls[], _Input = CnstInput<SrvModule>, _Full = CnstFull<SrvModule>, _Light = CnstLight<SrvModule>, _Insight = CnstInsight<SrvModule>, _Filter extends FilterInstance = DbFilter<SrvModule>, _Query = RootSliceQuery<SrvModule, _Full>>(srv: SrvModule, option: RootSliceOption, sliceBuilder: BuildSlice, ...libSlices: LibSlices): SliceCls<SrvModule, Assign<ReturnType<BuildSlice>, LibSlices extends [] ? {
49
+ export declare function slice<SrvModule extends ServiceModel, BuildSlice extends SliceBuilder<SrvModule>, LibSlices extends SliceCls[], _Input = CnstInput<SrvModule>, _Full = CnstFull<SrvModule>, _Light = CnstLight<SrvModule>, _Insight = CnstInsight<SrvModule>, _Filter extends FilterInstance = DbFilter<SrvModule>, _QueryKey extends string = RootSliceQueryKey<_Filter>>(srv: SrvModule, option: RootSliceOption, sliceBuilder: BuildSlice, ...libSlices: LibSlices): SliceCls<SrvModule, Assign<ReturnType<BuildSlice>, LibSlices extends [] ? {
51
50
  [""]: SliceInfo<SrvRefName<SrvModule>, _Input, _Full, _Light, _Insight, _Filter, SrvMap<SrvModule>, [
52
- "query"
51
+ "queryKey",
52
+ "args"
53
53
  ], [
54
- query?: _Query | null
54
+ queryKey?: _QueryKey | null,
55
+ args?: unknown[] | null
55
56
  ], [
56
57
  ], [
57
- _Query
58
+ _QueryKey | undefined,
59
+ unknown[] | undefined
58
60
  ]>;
59
61
  } : ExtendSliceInfoObj<SrvModule, LibSlices>>>;
60
62
  export {};
@@ -107,12 +107,17 @@ export interface SerializedArg {
107
107
  nullable?: boolean;
108
108
  example?: string | number | boolean | Date;
109
109
  enum?: string;
110
+ /** The values this arg accepts, when they are a fixed list the caller has to pick from. */
111
+ oneOf?: (string | number)[];
112
+ /** For an id a filter declared against a model: the model it points at, so a UI can offer a picker. */
113
+ ref?: string;
110
114
  }
111
115
  export interface SerializedEndpoint extends SerializedSignalOption {
112
116
  type: "query" | "mutation" | "pubsub" | "message" | "prompt";
113
117
  returns: SerializedReturns;
114
118
  }
115
119
  export interface SerializedFilter {
120
+ /** Every filter query the model declares, by key, with the args each one takes. */
116
121
  filter: {
117
122
  [key: string]: SerializedArg[];
118
123
  };
@@ -1,10 +1,12 @@
1
- import type { SliceMeta } from "akanjs/fetch";
1
+ import type { QuerySetting, SliceMeta } from "akanjs/fetch";
2
2
  export interface DashboardProps<T extends string, State> {
3
3
  className?: string;
4
4
  summary: Record<string, unknown>;
5
5
  slice: SliceMeta;
6
6
  /** Columns that narrow the listing when clicked. A column absent from the map renders as a plain tile. */
7
- queryMap?: Record<string, unknown>;
7
+ queryMap?: {
8
+ [column: string]: QuerySetting;
9
+ };
8
10
  columns?: string[];
9
11
  presents?: string[];
10
12
  hidePresents?: boolean;
@@ -1,5 +1,5 @@
1
1
  import { type DataAction, type DataColumn, type DataTool, type ModelInsightProps, type ModelProps } from "akanjs/client";
2
- import type { FetchInitForm, SliceMeta } from "akanjs/fetch";
2
+ import type { FetchInitForm, QuerySetting, SliceMeta } from "akanjs/fetch";
3
3
  import { type ReactNode } from "react";
4
4
  export interface ListContainerProps<T extends string, State, Input, Full extends {
5
5
  id: string;
@@ -12,8 +12,12 @@ export interface ListContainerProps<T extends string, State, Input, Full extends
12
12
  cardListClassName?: string;
13
13
  /** Initial rendering mode. The toolbar toggle switches it from here. */
14
14
  type?: "card" | "list";
15
- /** Static query object passed as the first argument of the generated init action. */
16
- query?: Record<string, unknown>;
15
+ /** Fixed filter query for this listing. Given one, the panel is scoped and offers no query maker. */
16
+ query?: QuerySetting;
17
+ /** Summary column to filter query. A `?filter=<column>` link opens the listing on the query it names. */
18
+ queryMap?: {
19
+ [column: string]: QuerySetting;
20
+ };
17
21
  /** Initial fetch form: page, limit, sort, and the default values a new model starts from. */
18
22
  init?: FetchInitForm<Input, any>;
19
23
  /** Generated slice metadata for the target model. */
@@ -46,4 +50,4 @@ export default function ListContainer<T extends string, State, Input, Full exten
46
50
  id: string;
47
51
  }, Light extends {
48
52
  id: string;
49
- }>({ className, cardListClassName, type, query, init, create, slice, title, sort, columns, actions, tools, renderDashboard, renderItem, renderTemplate, renderTitle, renderView, renderQueryMaker, renderInsight, renderLoading, }: ListContainerProps<T, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
53
+ }>({ className, cardListClassName, type, query, queryMap, init, create, slice, title, sort, columns, actions, tools, renderDashboard, renderItem, renderTemplate, renderTitle, renderView, renderQueryMaker, renderInsight, renderLoading, }: ListContainerProps<T, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
@@ -1,8 +1,8 @@
1
- import type { SliceMeta } from "akanjs/fetch";
1
+ import type { QuerySetting, SliceMeta } from "akanjs/fetch";
2
2
  interface QueryMakerProps {
3
3
  className?: string;
4
4
  slice: SliceMeta;
5
- query?: Record<string, unknown>;
5
+ query?: QuerySetting;
6
6
  }
7
- export default function QueryMaker({ className, slice, query }: QueryMakerProps): null;
7
+ export default function QueryMaker({ className, slice, query }: QueryMakerProps): import("react/jsx-runtime").JSX.Element | null;
8
8
  export {};
@@ -5,5 +5,6 @@ export declare const Data: {
5
5
  Item: typeof import("./Item.d.ts").default;
6
6
  ListContainer: typeof import("./ListContainer.d.ts").default;
7
7
  Pagination: typeof import("./Pagination.d.ts").default;
8
+ QueryMaker: typeof import("./QueryMaker.d.ts").default;
8
9
  TableList: typeof import("./TableList.d.ts").default;
9
10
  };
@@ -1,4 +1,4 @@
1
- import type { SliceMeta } from "akanjs/fetch";
1
+ import type { QuerySetting, SliceMeta } from "akanjs/fetch";
2
2
  import type { ListContainerProps } from "../Data/ListContainer.d.ts";
3
3
  interface AdminPanelProps<T extends string, State, Input, Full extends {
4
4
  id: string;
@@ -21,9 +21,9 @@ interface AdminPanelProps<T extends string, State, Input, Full extends {
21
21
  template?: any;
22
22
  unit?: any;
23
23
  view?: any;
24
- /** Summary column to query descriptor. Only a column this map names becomes a link to its filtered listing. */
24
+ /** Summary column to filter query. Only a column this map names becomes a link to its filtered listing. */
25
25
  queryMap?: {
26
- [key: string]: unknown;
26
+ [column: string]: QuerySetting;
27
27
  };
28
28
  /** Keys of the app's `summary` state shown as dashboard tiles above the list. */
29
29
  summaryColumns?: string[];
@@ -9,7 +9,7 @@ declare function Arg({ argType, value, onChange }: ArgProps): import("react/jsx-
9
9
  declare namespace Arg {
10
10
  var Table: ({ refName, endpointKey, args }: ArgTableProps) => import("react/jsx-runtime").JSX.Element;
11
11
  var Param: ({ endpointKey, arg, value, onChange }: ArgParamProps) => import("react/jsx-runtime").JSX.Element;
12
- var Query: ({ endpointKey, arg, value, onChange }: ArgQueryProps) => import("react/jsx-runtime").JSX.Element;
12
+ var Query: ({ endpointKey, arg, label, value, onChange }: ArgQueryProps) => import("react/jsx-runtime").JSX.Element;
13
13
  var FormData: ({ endpointKey, arg, value, onChange }: ArgFormDataProps) => import("react/jsx-runtime").JSX.Element;
14
14
  var ID: ({ value, onChange }: ArgIDProps) => import("react/jsx-runtime").JSX.Element;
15
15
  var Int: ({ value, onChange }: ArgIntProps) => import("react/jsx-runtime").JSX.Element;
@@ -35,6 +35,7 @@ interface ArgParamProps {
35
35
  interface ArgQueryProps {
36
36
  endpointKey: string;
37
37
  arg: SerializedArg;
38
+ label?: string;
38
39
  value: any;
39
40
  onChange: (value: any) => void;
40
41
  }
@@ -45,7 +46,7 @@ interface ArgFormDataProps {
45
46
  onChange: (value: any) => void;
46
47
  }
47
48
  interface ArgIDProps {
48
- value: string;
49
+ value: string | null;
49
50
  onChange: (value: string) => void;
50
51
  }
51
52
  interface ArgIntProps {
@@ -57,7 +58,7 @@ interface ArgFloatProps {
57
58
  onChange: (value: number) => void;
58
59
  }
59
60
  interface ArgStringProps {
60
- value: string;
61
+ value: string | null;
61
62
  onChange: (value: string) => void;
62
63
  }
63
64
  interface ArgBooleanProps {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { cn, usePage } from "akanjs/client";
3
- import type { SliceMeta } from "akanjs/fetch";
3
+ import type { QuerySetting, SliceMeta } from "akanjs/fetch";
4
4
  import { st } from "akanjs/store";
5
5
 
6
6
  import { Link } from "../Link";
@@ -11,7 +11,7 @@ export interface DashboardProps<T extends string, State> {
11
11
  summary: Record<string, unknown>;
12
12
  slice: SliceMeta;
13
13
  /** Columns that narrow the listing when clicked. A column absent from the map renders as a plain tile. */
14
- queryMap?: Record<string, unknown>;
14
+ queryMap?: { [column: string]: QuerySetting };
15
15
  columns?: string[];
16
16
  presents?: string[];
17
17
  hidePresents?: boolean;
@@ -12,7 +12,7 @@ import {
12
12
  } from "akanjs/client";
13
13
  import { capitalize } from "akanjs/common";
14
14
  import { type BaseInsight, ConstantRegistry, labelOf } from "akanjs/constant";
15
- import type { FetchInitForm, SliceMeta } from "akanjs/fetch";
15
+ import type { FetchInitForm, QuerySetting, SliceMeta } from "akanjs/fetch";
16
16
  import { st } from "akanjs/store";
17
17
  import { useScreenScope } from "akanjs/webkit";
18
18
  import { type ReactNode, useEffect, useState } from "react";
@@ -36,6 +36,7 @@ import { Select } from "../Select";
36
36
  import DataCardList from "./CardList";
37
37
  import { columnKey, downloadBlob, toCsvBlob, toJsonBlob } from "./dataExport";
38
38
  import { dictLabel } from "./dataText";
39
+ import DataQueryMaker from "./QueryMaker";
39
40
  import DataTableList from "./TableList";
40
41
 
41
42
  const controlClassName = "h-9";
@@ -55,8 +56,10 @@ export interface ListContainerProps<
55
56
  cardListClassName?: string;
56
57
  /** Initial rendering mode. The toolbar toggle switches it from here. */
57
58
  type?: "card" | "list";
58
- /** Static query object passed as the first argument of the generated init action. */
59
- query?: Record<string, unknown>;
59
+ /** Fixed filter query for this listing. Given one, the panel is scoped and offers no query maker. */
60
+ query?: QuerySetting;
61
+ /** Summary column to filter query. A `?filter=<column>` link opens the listing on the query it names. */
62
+ queryMap?: { [column: string]: QuerySetting };
60
63
  /** Initial fetch form: page, limit, sort, and the default values a new model starts from. */
61
64
  init?: FetchInitForm<Input, any>;
62
65
  /** Generated slice metadata for the target model. */
@@ -100,6 +103,7 @@ export default function ListContainer<
100
103
  cardListClassName,
101
104
  type = "card",
102
105
  query,
106
+ queryMap,
103
107
  init,
104
108
  create = true,
105
109
  slice,
@@ -167,10 +171,13 @@ export default function ListContainer<
167
171
  const sortOfModel = storeUse[namesOfSlice.sortOfModel]() as string;
168
172
  const modelInsight = storeUse[namesOfSlice.modelInsight]() as BaseInsight;
169
173
  const modelListLoading = storeUse[namesOfSlice.modelListLoading]() as string | boolean;
174
+ const searchParams = st.use.searchParams({ agent: false });
175
+ const filter = Array.isArray(searchParams.filter) ? searchParams.filter[0] : searchParams.filter;
176
+ const initQuery = query ?? (filter ? queryMap?.[filter] : undefined);
170
177
  useEffect(() => {
171
178
 
172
179
  const queryArgs = new Array(slice.argLength).fill(null) as unknown[];
173
- if (query) queryArgs[0] = query;
180
+ if (initQuery) [queryArgs[0], queryArgs[1]] = [initQuery.queryKey, initQuery.args ?? []];
174
181
  void storeDo[namesOfSlice.initModel](...queryArgs, { sort, ...init });
175
182
  }, []);
176
183
 
@@ -254,7 +261,9 @@ export default function ListContainer<
254
261
  if (!Stat || !summary) return null;
255
262
  return summaryLoading ? <Loading.Skeleton className="mb-4" active /> : <Stat summary={summary} hidePresents />;
256
263
  };
257
- const RenderQueryMaker = renderQueryMaker;
264
+
265
+ const RenderQueryMaker =
266
+ renderQueryMaker ?? (query ? undefined : () => <DataQueryMaker slice={slice} query={initQuery} />);
258
267
  const RenderInsight = (): ReactNode => (renderInsight ? renderInsight({ insight: modelInsight }) : null);
259
268
  const RenderTemplate = renderTemplate;
260
269
  const RenderTools = (): ReactNode => {
@@ -1,21 +1,71 @@
1
1
  "use client";
2
-
3
- import type { SliceMeta } from "akanjs/fetch";
2
+ import { cn, fetch, usePage } from "akanjs/client";
3
+ import { capitalize } from "akanjs/common";
4
+ import type { QuerySetting, SliceMeta } from "akanjs/fetch";
5
+ import type { SerializedArg } from "akanjs/signal";
6
+ import { st } from "akanjs/store";
7
+ import { useDebounce } from "akanjs/webkit";
8
+ import { useState } from "react";
9
+ import { Select } from "../Select";
10
+ import Arg from "../Signal/Arg";
11
+ import { dictLabel } from "./dataText";
4
12
 
5
13
  interface QueryMakerProps {
6
14
  className?: string;
7
15
  slice: SliceMeta;
8
- query?: Record<string, unknown>;
9
- }
10
- interface QuerySetting {
11
- queryKey: string;
12
- arg: Record<string, unknown>;
16
+ query?: QuerySetting;
13
17
  }
14
- const searchQuerySetting: QuerySetting = { queryKey: "search", arg: { $search: undefined as string | undefined } };
15
- const byStatusQuerySetting: QuerySetting = { queryKey: "byStatuses", arg: { statuses: null } };
18
+
19
+ const isFillableArg = (arg: SerializedArg) => !arg.modelType;
20
+ const defaultArg = (arg: SerializedArg) => ((arg.arrDepth ?? 0) > 0 ? [] : null);
16
21
 
17
22
  export default function QueryMaker({ className, slice, query }: QueryMakerProps) {
18
- const { sliceName } = slice;
19
- return null;
23
+ const { l } = usePage();
24
+ const { refName, sliceName } = slice;
25
+ const storeDo = st.do as unknown as { [key: string]: (...args: unknown[]) => Promise<void> };
26
+ const filterQuery = fetch.filterQueryMap?.get(refName) ?? {};
27
+ const queryKeys = Object.entries(filterQuery)
28
+ .filter(([, args]) => args.every(isFillableArg))
29
+ .map(([queryKey]) => queryKey);
30
+ const [setting, setSetting] = useState<Required<QuerySetting>>({
31
+ queryKey: query?.queryKey ?? queryKeys[0] ?? "any",
32
+ args: query?.args ?? [],
33
+ });
34
+
35
+ const applyQuery = useDebounce((setting: Required<QuerySetting>) => {
36
+ void storeDo[`setQueryArgsOf${capitalize(sliceName)}`](setting.queryKey, setting.args);
37
+ }, []);
38
+ const update = (setting: Required<QuerySetting>) => {
39
+ setSetting(setting);
40
+ applyQuery(setting);
41
+ };
42
+ const args = filterQuery[setting.queryKey] ?? [];
43
+ if (queryKeys.length < 2) return null;
44
+ return (
45
+ <div className={cn("mb-4 flex w-full flex-col gap-1 rounded-box border border-border p-3", className)}>
46
+ <Select<string>
47
+ className="w-full md:w-72"
48
+ value={setting.queryKey}
49
+ options={queryKeys.map((queryKey) => ({
50
+ label: dictLabel(l._, `${refName}.query.${queryKey}`, queryKey),
51
+ value: queryKey,
52
+ }))}
53
+ onChange={(queryKey) => {
54
+ update({ queryKey, args: (filterQuery[queryKey] ?? []).map(defaultArg) });
55
+ }}
56
+ />
57
+ {args.map((arg, idx) => (
58
+ <Arg.Query
59
+ key={arg.name}
60
+ endpointKey={setting.queryKey}
61
+ arg={arg}
62
+ label={dictLabel(l._, `${refName}.query.${setting.queryKey}.arg.${arg.name}`, arg.name)}
63
+ value={setting.args[idx] ?? defaultArg(arg)}
64
+ onChange={(value: unknown) => {
65
+ update({ ...setting, args: args.map((a, i) => (i === idx ? value : (setting.args[i] ?? defaultArg(a)))) });
66
+ }}
67
+ />
68
+ ))}
69
+ </div>
70
+ );
20
71
  }
21
-
package/ui/Data/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CardList, Dashboard, Insight, Item, ListContainer, Pagination, TableList } from "./index_";
1
+ import { CardList, Dashboard, Insight, Item, ListContainer, Pagination, QueryMaker, TableList } from "./index_";
2
2
 
3
3
  export const Data = {
4
4
  CardList,
@@ -7,5 +7,6 @@ export const Data = {
7
7
  Item,
8
8
  ListContainer,
9
9
  Pagination,
10
+ QueryMaker,
10
11
  TableList,
11
12
  };
@@ -1,6 +1,6 @@
1
1
  import type { ModelProps } from "akanjs/client";
2
2
  import type { BaseInsight } from "akanjs/constant";
3
- import type { SliceMeta } from "akanjs/fetch";
3
+ import type { QuerySetting, SliceMeta } from "akanjs/fetch";
4
4
  import type { ComponentType, ReactNode } from "react";
5
5
  import { Data } from "../Data";
6
6
  import type { ListContainerProps } from "../Data/ListContainer";
@@ -17,8 +17,8 @@ interface AdminPanelProps<T extends string, State, Input, Full extends { id: str
17
17
  template?: any;
18
18
  unit?: any;
19
19
  view?: any;
20
- /** Summary column to query descriptor. Only a column this map names becomes a link to its filtered listing. */
21
- queryMap?: { [key: string]: unknown };
20
+ /** Summary column to filter query. Only a column this map names becomes a link to its filtered listing. */
21
+ queryMap?: { [column: string]: QuerySetting };
22
22
  /** Keys of the app's `summary` state shown as dashboard tiles above the list. */
23
23
  summaryColumns?: string[];
24
24
  /** Model insight keys shown above the list. The header already carries the total count. */
@@ -70,6 +70,7 @@ export default function AdminPanel<
70
70
  return (
71
71
  <Data.ListContainer
72
72
  slice={slice}
73
+ queryMap={queryMap}
73
74
  renderItem={Unit}
74
75
  renderInsight={renderInsight}
75
76
  renderDashboard={renderDashboard}
package/ui/Signal/Arg.tsx CHANGED
@@ -10,6 +10,7 @@ import { buttonRecipe } from "../Button";
10
10
  import { DatePicker } from "../DatePicker";
11
11
  import { Input } from "../Input";
12
12
  import { dictText, docDash, docUi } from "../Reference";
13
+ import { Select } from "../Select";
13
14
  import { Tooltip } from "../Tooltip";
14
15
  import UiObject from "./Object";
15
16
  import { signalUi } from "./style";
@@ -137,20 +138,32 @@ Arg.Param = ArgParam;
137
138
  interface ArgQueryProps {
138
139
  endpointKey: string;
139
140
  arg: SerializedArg;
141
+ label?: string;
140
142
  value: any;
141
143
  onChange: (value: any) => void;
142
144
  }
143
- const ArgQuery = ({ endpointKey, arg, value, onChange }: ArgQueryProps) => {
145
+ const ArgQuery = ({ endpointKey, arg, label, value, onChange }: ArgQueryProps) => {
144
146
  const argRef = ConstantRegistry.getModelRef(arg.refName, arg.modelType);
145
147
  if (!PrimitiveRegistry.has(argRef)) throw new Error(`Query arg - ${endpointKey}/${arg.name} must be scalar`);
146
148
  else if ((arg.arrDepth ?? 0) > 1)
147
149
  throw new Error(`Query arg - ${endpointKey}/${arg.name} must not be more than 2D array`);
148
150
  const argType = PrimitiveRegistry.getName(argRef as typeof PrimitiveScalar) as DefaultPrimitiveName;
151
+ const enumRef = arg.enum ? ConstantRegistry.enum.get(arg.enum) : undefined;
152
+ const options: (string | number)[] = arg.oneOf ?? (enumRef ? [...enumRef.values] : []);
153
+ const multiple = (arg.arrDepth ?? 0) > 0;
149
154
  return (
150
155
  <div className={signalUi.inputRow}>
151
- <div className={signalUi.inputLabel}>{arg.name}</div>
156
+ <div className={signalUi.inputLabel}>{label ?? arg.name}</div>
152
157
  <div className="w-full">
153
- {(arg.arrDepth ?? 0) > 0 && Array.isArray(value) ? (
158
+ {options.length ? (
159
+ <Select<string | number, boolean>
160
+ options={options.map((option) => ({ label: option, value: option }))}
161
+ multiple={multiple}
162
+ nullable={arg.nullable}
163
+ value={multiple ? ((value as (string | number)[] | null) ?? []) : (value as string | number)}
164
+ onChange={onChange}
165
+ />
166
+ ) : multiple && Array.isArray(value) ? (
154
167
  <div className="flex flex-col gap-2">
155
168
  {value.map((val, idx) => (
156
169
  <div className="flex items-center gap-2" key={idx}>
@@ -214,14 +227,14 @@ const ArgFormData = ({ endpointKey, arg, value, onChange }: ArgFormDataProps) =>
214
227
  Arg.FormData = ArgFormData;
215
228
 
216
229
  interface ArgIDProps {
217
- value: string;
230
+ value: string | null;
218
231
  onChange: (value: string) => void;
219
232
  }
220
233
  const ArgID = ({ value, onChange }: ArgIDProps) => {
221
234
  return (
222
235
  <Input
223
236
  inputClassName="w-full font-mono"
224
- value={value}
237
+ value={value ?? ""}
225
238
  onChange={(value) => {
226
239
  onChange(value);
227
240
  }}
@@ -268,14 +281,14 @@ const ArgFloat = ({ value, onChange }: ArgFloatProps) => {
268
281
  Arg.Float = ArgFloat;
269
282
 
270
283
  interface ArgStringProps {
271
- value: string;
284
+ value: string | null;
272
285
  onChange: (value: string) => void;
273
286
  }
274
287
  const ArgString = ({ value, onChange }: ArgStringProps) => {
275
288
  return (
276
289
  <Input
277
290
  inputClassName="w-full font-mono"
278
- value={value}
291
+ value={value ?? ""}
279
292
  onChange={(value) => {
280
293
  onChange(value);
281
294
  }}
@@ -44,7 +44,7 @@ const getRequestExample = (modelRef: Cls) => {
44
44
  else
45
45
  example[key] = (
46
46
  (field.example ?? field.enum)
47
- ? arraiedModel(field.example ?? (field.enum?.values as string[])[0], field.optArrDepth)
47
+ ? arraiedModel(field.example ?? field.enum?.values[0], field.optArrDepth)
48
48
  : arraiedModel(getRequestExample(field.modelRef), field.arrDepth)
49
49
  ) as unknown;
50
50
  });