akanjs 3.0.0-alpha.60 → 3.0.0-alpha.61

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.
@@ -1,11 +1,20 @@
1
1
  /**
2
- * The one line a human scans for, derived from what the model already declared: the `text: "title"` search role
3
- * names exactly that field, so an agent-facing label costs no new declaration. Falls back to the conventional
4
- * `title`/`name` keys; the id is the caller's floor.
2
+ * The one line a human scans for. A model that writes its own `Light<Model>.label()` owns the answer, since
3
+ * display logic is the Light class's job; otherwise it is derived from what the model already declared — the
4
+ * `text: "title"` search role names exactly that field, so an agent-facing label costs no new declaration.
5
+ * Falls back to the conventional `title`/`name` keys; the id is the caller's floor.
5
6
  */
6
7
  export const labelOf = (model: unknown, value: unknown): string | undefined => {
7
8
  if (!value || typeof value !== "object") return undefined;
8
9
  const source = value as Record<string, unknown>;
10
+ const written = source.label;
11
+ if (typeof written === "function") {
12
+ try {
13
+ const label = (written as () => unknown).call(source);
14
+ if (typeof label === "string" && label) return label;
15
+ } catch {
16
+ }
17
+ }
9
18
  const paths = (model as { text?: { title?: Iterable<string> } } | null)?.text?.title;
10
19
  const titlePath = [...(paths ?? [])].find((path) => !path.includes(".") && !path.includes("["));
11
20
  for (const key of [titlePath, "title", "name"]) {
@@ -275,7 +275,6 @@ interface ArgProps<Value = unknown> {
275
275
  nullable?: boolean;
276
276
  ref?: string;
277
277
  default?: Value;
278
- renderOption?: (arg: never) => string;
279
278
  }
280
279
  export class FilterInfo<ArgNames extends string[] = any, Args extends any[] = any, Model = any> {
281
280
  readonly argNames: ArgNames = [] as unknown as ArgNames;
package/document/types.ts CHANGED
@@ -21,7 +21,6 @@ export interface FilterArgProps {
21
21
  nullable?: boolean;
22
22
  ref?: string;
23
23
  default?: string | number | boolean | object | null | (() => string | number | boolean | object | null);
24
- renderOption?: (value: never) => string;
25
24
  enum?: EnumInstance;
26
25
  }
27
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.60",
3
+ "version": "3.0.0-alpha.61",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The one line a human scans for, derived from what the model already declared: the `text: "title"` search role
3
- * names exactly that field, so an agent-facing label costs no new declaration. Falls back to the conventional
4
- * `title`/`name` keys; the id is the caller's floor.
2
+ * The one line a human scans for. A model that writes its own `Light<Model>.label()` owns the answer, since
3
+ * display logic is the Light class's job; otherwise it is derived from what the model already declared — the
4
+ * `text: "title"` search role names exactly that field, so an agent-facing label costs no new declaration.
5
+ * Falls back to the conventional `title`/`name` keys; the id is the caller's floor.
5
6
  */
6
7
  export declare const labelOf: (model: unknown, value: unknown) => string | undefined;
@@ -117,7 +117,6 @@ interface ArgProps<Value = unknown> {
117
117
  nullable?: boolean;
118
118
  ref?: string;
119
119
  default?: Value;
120
- renderOption?: (arg: never) => string;
121
120
  }
122
121
  export declare class FilterInfo<ArgNames extends string[] = any, Args extends any[] = any, Model = any> {
123
122
  readonly argNames: ArgNames;
@@ -17,7 +17,6 @@ export interface FilterArgProps {
17
17
  nullable?: boolean;
18
18
  ref?: string;
19
19
  default?: string | number | boolean | object | null | (() => string | number | boolean | object | null);
20
- renderOption?: (value: never) => string;
21
20
  enum?: EnumInstance;
22
21
  }
23
22
  export type DocumentProjection<T> = Partial<Record<keyof T, boolean>>;
@@ -1,8 +1,41 @@
1
1
  import type { QuerySetting, SliceMeta } from "akanjs/fetch";
2
+ import type { SerializedArg } from "akanjs/signal";
2
3
  interface QueryMakerProps {
3
4
  className?: string;
4
5
  slice: SliceMeta;
5
6
  query?: QuerySetting;
7
+ /** Where a completed filter goes. Defaults to the slice's own store, which is where a listing reads it. */
8
+ onApply?: (setting: Required<QuerySetting>) => void;
6
9
  }
7
- export default function QueryMaker({ className, slice, query }: QueryMakerProps): import("react/jsx-runtime").JSX.Element | null;
10
+ export interface QueryMakerState {
11
+ queryKeys: string[];
12
+ args: SerializedArg[];
13
+ setting: Required<QuerySetting>;
14
+ selectKey: (queryKey: string) => void;
15
+ setArg: (idx: number, value: unknown) => void;
16
+ }
17
+ interface QueryMakerKeyProps {
18
+ className?: string;
19
+ selectClassName?: string;
20
+ slice: SliceMeta;
21
+ state: QueryMakerState;
22
+ }
23
+ interface QueryMakerArgsProps {
24
+ className?: string;
25
+ slice: SliceMeta;
26
+ state: QueryMakerState;
27
+ }
28
+ /** A filter the server will accept: every arg it declared as required has been given a value. */
29
+ export declare const isReadyQuery: (args: SerializedArg[], values: unknown[]) => boolean;
30
+ /**
31
+ * The filter a listing is showing, and the one write that applies it. Held in a hook rather than a component
32
+ * because the key select rides the toolbar while the args that key takes render under it — two places in the
33
+ * tree, one selection.
34
+ */
35
+ export declare const useQueryMaker: ({ slice, query, onApply }: QueryMakerProps) => QueryMakerState;
36
+ /** The filter picker. Sized for a toolbar, beside the sort and page-size selects. */
37
+ export declare const QueryMakerKey: ({ className, selectClassName, slice, state }: QueryMakerKeyProps) => import("react/jsx-runtime").JSX.Element | null;
38
+ /** What the picked filter asks for. Renders nothing for a filter that takes no arguments. */
39
+ export declare const QueryMakerArgs: ({ className, slice, state }: QueryMakerArgsProps) => import("react/jsx-runtime").JSX.Element | null;
40
+ export default function QueryMaker({ className, slice, query, onApply }: QueryMakerProps): import("react/jsx-runtime").JSX.Element;
8
41
  export {};
@@ -0,0 +1,16 @@
1
+ interface RefPickerProps {
2
+ className?: string;
3
+ /** The model this id points at, named by the filter arg's `ref`. */
4
+ refName: string;
5
+ value: string | null;
6
+ onChange: (id: string | null) => void;
7
+ }
8
+ /**
9
+ * Picks one row of a referenced model instead of asking for its hex id. Rows are held here rather than in the
10
+ * ref model's store: that store is a singleton, so a picker loading into it would overwrite whatever listing of
11
+ * the same model is already on the screen — and a picker opened from inside a picker would overwrite itself.
12
+ *
13
+ * The search is the ref model's own root slice, which is Admin-guarded, so this belongs to admin surfaces.
14
+ */
15
+ export default function RefPicker({ className, refName, value, onChange }: RefPickerProps): import("react/jsx-runtime").JSX.Element;
16
+ export {};
@@ -6,5 +6,6 @@ export declare const Data: {
6
6
  ListContainer: typeof import("./ListContainer.d.ts").default;
7
7
  Pagination: typeof import("./Pagination.d.ts").default;
8
8
  QueryMaker: typeof import("./QueryMaker.d.ts").default;
9
+ RefPicker: typeof import("./RefPicker.d.ts").default;
9
10
  TableList: typeof import("./TableList.d.ts").default;
10
11
  };
@@ -6,3 +6,4 @@ export declare const ListContainer: typeof import("./ListContainer.d.ts").defaul
6
6
  export declare const Pagination: typeof import("./Pagination.d.ts").default;
7
7
  export declare const TableList: typeof import("./TableList.d.ts").default;
8
8
  export declare const QueryMaker: typeof import("./QueryMaker.d.ts").default;
9
+ export declare const RefPicker: typeof import("./RefPicker.d.ts").default;
@@ -1,5 +1,6 @@
1
1
  import { type Dayjs, type DefaultPrimitiveName } from "akanjs/base";
2
2
  import type { SerializedArg } from "akanjs/signal";
3
+ import type { ReactNode } from "react";
3
4
  interface ArgProps {
4
5
  argType: DefaultPrimitiveName;
5
6
  value: any;
@@ -9,7 +10,7 @@ declare function Arg({ argType, value, onChange }: ArgProps): import("react/jsx-
9
10
  declare namespace Arg {
10
11
  var Table: ({ refName, endpointKey, args }: ArgTableProps) => import("react/jsx-runtime").JSX.Element;
11
12
  var Param: ({ endpointKey, arg, value, onChange }: ArgParamProps) => import("react/jsx-runtime").JSX.Element;
12
- var Query: ({ endpointKey, arg, label, value, onChange }: ArgQueryProps) => import("react/jsx-runtime").JSX.Element;
13
+ var Query: ({ endpointKey, arg, label, value, onChange, renderScalar }: ArgQueryProps) => import("react/jsx-runtime").JSX.Element;
13
14
  var FormData: ({ endpointKey, arg, value, onChange }: ArgFormDataProps) => import("react/jsx-runtime").JSX.Element;
14
15
  var ID: ({ value, onChange }: ArgIDProps) => import("react/jsx-runtime").JSX.Element;
15
16
  var Int: ({ value, onChange }: ArgIntProps) => import("react/jsx-runtime").JSX.Element;
@@ -38,6 +39,8 @@ interface ArgQueryProps {
38
39
  label?: string;
39
40
  value: any;
40
41
  onChange: (value: any) => void;
42
+ /** Replaces the scalar input this arg would otherwise get, once per element for an array arg. */
43
+ renderScalar?: (value: any, onChange: (value: any) => void) => ReactNode;
41
44
  }
42
45
  interface ArgFormDataProps {
43
46
  endpointKey: string;
@@ -36,7 +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
+ import { QueryMakerArgs, QueryMakerKey, useQueryMaker } from "./QueryMaker";
40
40
  import DataTableList from "./TableList";
41
41
 
42
42
  const controlClassName = "h-9";
@@ -174,6 +174,7 @@ export default function ListContainer<
174
174
  const searchParams = st.use.searchParams({ agent: false });
175
175
  const filter = Array.isArray(searchParams.filter) ? searchParams.filter[0] : searchParams.filter;
176
176
  const initQuery = query ?? (filter ? queryMap?.[filter] : undefined);
177
+ const queryState = useQueryMaker({ slice, query: initQuery });
177
178
  useEffect(() => {
178
179
 
179
180
  const queryArgs = new Array(slice.argLength).fill(null) as unknown[];
@@ -261,9 +262,12 @@ export default function ListContainer<
261
262
  if (!Stat || !summary) return null;
262
263
  return summaryLoading ? <Loading.Skeleton className="mb-4" active /> : <Stat summary={summary} hidePresents />;
263
264
  };
264
-
265
- const RenderQueryMaker =
266
- renderQueryMaker ?? (query ? undefined : () => <DataQueryMaker slice={slice} query={initQuery} />);
265
+
266
+ const queryMakerArgs = renderQueryMaker ? (
267
+ renderQueryMaker()
268
+ ) : query ? null : (
269
+ <QueryMakerArgs slice={slice} state={queryState} />
270
+ );
267
271
  const RenderInsight = (): ReactNode => (renderInsight ? renderInsight({ insight: modelInsight }) : null);
268
272
  const RenderTemplate = renderTemplate;
269
273
  const RenderTools = (): ReactNode => {
@@ -356,6 +360,14 @@ export default function ListContainer<
356
360
  </button>
357
361
  ))}
358
362
  </div>
363
+ {query ? null : (
364
+ <QueryMakerKey
365
+ className="w-44 min-w-0"
366
+ selectClassName={cn("min-h-0", controlClassName)}
367
+ slice={slice}
368
+ state={queryState}
369
+ />
370
+ )}
359
371
  <RenderSort />
360
372
  <Select<number>
361
373
  className="w-36 min-w-0"
@@ -390,7 +402,7 @@ export default function ListContainer<
390
402
  </div>
391
403
  </div>
392
404
  {query ? null : <ModelDashboard />}
393
- {RenderQueryMaker ? <RenderQueryMaker /> : null}
405
+ {queryMakerArgs}
394
406
  <RenderInsight />
395
407
  {view === "card" ? (
396
408
  <DataCardList
@@ -5,22 +5,53 @@ import type { QuerySetting, SliceMeta } from "akanjs/fetch";
5
5
  import type { SerializedArg } from "akanjs/signal";
6
6
  import { st } from "akanjs/store";
7
7
  import { useDebounce } from "akanjs/webkit";
8
- import { useState } from "react";
8
+ import { useRef, useState } from "react";
9
9
  import { Select } from "../Select";
10
10
  import Arg from "../Signal/Arg";
11
11
  import { dictLabel } from "./dataText";
12
+ import RefPicker from "./RefPicker";
12
13
 
13
14
  interface QueryMakerProps {
14
15
  className?: string;
15
16
  slice: SliceMeta;
16
17
  query?: QuerySetting;
18
+ /** Where a completed filter goes. Defaults to the slice's own store, which is where a listing reads it. */
19
+ onApply?: (setting: Required<QuerySetting>) => void;
20
+ }
21
+ export interface QueryMakerState {
22
+ queryKeys: string[];
23
+ args: SerializedArg[];
24
+ setting: Required<QuerySetting>;
25
+ selectKey: (queryKey: string) => void;
26
+ setArg: (idx: number, value: unknown) => void;
27
+ }
28
+ interface QueryMakerKeyProps {
29
+ className?: string;
30
+ selectClassName?: string;
31
+ slice: SliceMeta;
32
+ state: QueryMakerState;
33
+ }
34
+ interface QueryMakerArgsProps {
35
+ className?: string;
36
+ slice: SliceMeta;
37
+ state: QueryMakerState;
17
38
  }
18
39
 
19
40
  const isFillableArg = (arg: SerializedArg) => !arg.modelType;
20
41
  const defaultArg = (arg: SerializedArg) => ((arg.arrDepth ?? 0) > 0 ? [] : null);
21
42
 
22
- export default function QueryMaker({ className, slice, query }: QueryMakerProps) {
23
- const { l } = usePage();
43
+ const isFilledArg = (value: unknown) =>
44
+ value !== null && value !== undefined && value !== "" && !(Array.isArray(value) && !value.length);
45
+ /** A filter the server will accept: every arg it declared as required has been given a value. */
46
+ export const isReadyQuery = (args: SerializedArg[], values: unknown[]) =>
47
+ args.every((arg, idx) => arg.nullable || isFilledArg(values[idx]));
48
+
49
+ /**
50
+ * The filter a listing is showing, and the one write that applies it. Held in a hook rather than a component
51
+ * because the key select rides the toolbar while the args that key takes render under it — two places in the
52
+ * tree, one selection.
53
+ */
54
+ export const useQueryMaker = ({ slice, query, onApply }: QueryMakerProps): QueryMakerState => {
24
55
  const { refName, sliceName } = slice;
25
56
  const storeDo = st.do as unknown as { [key: string]: (...args: unknown[]) => Promise<void> };
26
57
  const filterQuery = fetch.filterQueryMap?.get(refName) ?? {};
@@ -31,41 +62,94 @@ export default function QueryMaker({ className, slice, query }: QueryMakerProps)
31
62
  queryKey: query?.queryKey ?? queryKeys[0] ?? "any",
32
63
  args: query?.args ?? [],
33
64
  });
65
+
66
+ const applyRef = useRef<(setting: Required<QuerySetting>) => void>(() => undefined);
67
+ applyRef.current = (setting) => {
68
+ if (onApply) onApply(setting);
69
+ else void storeDo[`setQueryArgsOf${capitalize(sliceName)}`](setting.queryKey, setting.args);
70
+ };
34
71
 
35
72
  const applyQuery = useDebounce((setting: Required<QuerySetting>) => {
36
- void storeDo[`setQueryArgsOf${capitalize(sliceName)}`](setting.queryKey, setting.args);
73
+ applyRef.current(setting);
37
74
  }, []);
38
75
  const update = (setting: Required<QuerySetting>) => {
39
76
  setSetting(setting);
40
- applyQuery(setting);
77
+
78
+ if (isReadyQuery(filterQuery[setting.queryKey] ?? [], setting.args)) applyQuery(setting);
41
79
  };
42
80
  const args = filterQuery[setting.queryKey] ?? [];
81
+ return {
82
+ queryKeys,
83
+ args,
84
+ setting,
85
+ selectKey: (queryKey) => {
86
+ update({ queryKey, args: (filterQuery[queryKey] ?? []).map(defaultArg) });
87
+ },
88
+ setArg: (idx, value) => {
89
+ update({ ...setting, args: args.map((arg, i) => (i === idx ? value : (setting.args[i] ?? defaultArg(arg)))) });
90
+ },
91
+ };
92
+ };
93
+
94
+ /** The filter picker. Sized for a toolbar, beside the sort and page-size selects. */
95
+ export const QueryMakerKey = ({ className, selectClassName, slice, state }: QueryMakerKeyProps) => {
96
+ const { l } = usePage();
97
+ const { queryKeys, setting, selectKey } = state;
43
98
  if (queryKeys.length < 2) return null;
99
+ return (
100
+ <Select<string>
101
+ className={className}
102
+ selectClassName={selectClassName}
103
+ value={setting.queryKey}
104
+ options={queryKeys.map((queryKey) => ({
105
+ label: dictLabel(l._, `${slice.refName}.query.${queryKey}`, queryKey),
106
+ value: queryKey,
107
+ }))}
108
+ onChange={selectKey}
109
+ />
110
+ );
111
+ };
112
+
113
+ /** What the picked filter asks for. Renders nothing for a filter that takes no arguments. */
114
+ export const QueryMakerArgs = ({ className, slice, state }: QueryMakerArgsProps) => {
115
+ const { l } = usePage();
116
+ const { args, setting, setArg } = state;
117
+ if (!args.length) return null;
44
118
  return (
45
119
  <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
- ))}
120
+ {args.map((arg, idx) => {
121
+
122
+ const ref = arg.ref && fetch.slice[arg.ref] ? arg.ref : undefined;
123
+ return (
124
+ <Arg.Query
125
+ key={arg.name}
126
+ endpointKey={setting.queryKey}
127
+ arg={arg}
128
+ label={dictLabel(l._, `${slice.refName}.query.${setting.queryKey}.arg.${arg.name}`, arg.name)}
129
+ value={setting.args[idx] ?? defaultArg(arg)}
130
+ onChange={(value: unknown) => {
131
+ setArg(idx, value);
132
+ }}
133
+ renderScalar={
134
+ ref
135
+ ? (value: string | null, onChange: (value: string | null) => void) => (
136
+ <RefPicker refName={ref} value={value} onChange={onChange} />
137
+ )
138
+ : undefined
139
+ }
140
+ />
141
+ );
142
+ })}
143
+ </div>
144
+ );
145
+ };
146
+
147
+ export default function QueryMaker({ className, slice, query, onApply }: QueryMakerProps) {
148
+ const state = useQueryMaker({ slice, query, onApply });
149
+ return (
150
+ <div className={cn("flex w-full flex-col gap-1", className)}>
151
+ <QueryMakerKey className="w-full md:w-72" slice={slice} state={state} />
152
+ <QueryMakerArgs slice={slice} state={state} />
69
153
  </div>
70
154
  );
71
155
  }
@@ -0,0 +1,203 @@
1
+ "use client";
2
+ import { cn, fetch, usePage } from "akanjs/client";
3
+ import { capitalize } from "akanjs/common";
4
+ import { ConstantRegistry, labelOf } from "akanjs/constant";
5
+ import type { QuerySetting } from "akanjs/fetch";
6
+ import { useEffect, useState } from "react";
7
+ import { AiOutlineClose, AiOutlineSearch } from "react-icons/ai";
8
+ import { buttonRecipe } from "../Button";
9
+ import { Empty } from "../Empty";
10
+ import { Loading } from "../Loading";
11
+ import { Modal } from "../Modal";
12
+ import { dictLabel } from "./dataText";
13
+ import { isReadyQuery, QueryMakerArgs, QueryMakerKey, useQueryMaker } from "./QueryMaker";
14
+
15
+ interface RefPickerProps {
16
+ className?: string;
17
+ /** The model this id points at, named by the filter arg's `ref`. */
18
+ refName: string;
19
+ value: string | null;
20
+ onChange: (id: string | null) => void;
21
+ }
22
+ interface RefRow {
23
+ id: string;
24
+ label?: string;
25
+ }
26
+
27
+ const pageSize = 20;
28
+
29
+ /**
30
+ * Picks one row of a referenced model instead of asking for its hex id. Rows are held here rather than in the
31
+ * ref model's store: that store is a singleton, so a picker loading into it would overwrite whatever listing of
32
+ * the same model is already on the screen — and a picker opened from inside a picker would overwrite itself.
33
+ *
34
+ * The search is the ref model's own root slice, which is Admin-guarded, so this belongs to admin surfaces.
35
+ */
36
+ export default function RefPicker({ className, refName, value, onChange }: RefPickerProps) {
37
+ const { l } = usePage();
38
+ const slice = fetch.slice[refName];
39
+ const filterQuery = fetch.filterQueryMap?.get(refName) ?? {};
40
+
41
+ const initialKey = filterQuery.bySearch ? "bySearch" : "any";
42
+ const initialQuery: Required<QuerySetting> = { queryKey: initialKey, args: [] };
43
+
44
+ const [open, setOpen] = useState(false);
45
+ const [applied, setApplied] = useState<Required<QuerySetting>>(initialQuery);
46
+ const [page, setPage] = useState(1);
47
+ const [rows, setRows] = useState<RefRow[]>([]);
48
+ const [hasNext, setHasNext] = useState(false);
49
+ const [loading, setLoading] = useState(false);
50
+ const [error, setError] = useState<string | null>(null);
51
+ const [picked, setPicked] = useState<RefRow | null>(null);
52
+
53
+ const state = useQueryMaker({
54
+ slice,
55
+ query: initialQuery,
56
+ onApply: (setting) => {
57
+ setPage(1);
58
+ setApplied(setting);
59
+ },
60
+ });
61
+
62
+ useEffect(() => {
63
+ if (!open) return;
64
+ if (!isReadyQuery(filterQuery[applied.queryKey] ?? [], applied.args)) {
65
+ setRows([]);
66
+ return;
67
+ }
68
+ const listFn = fetch[`${refName}List`] as (...args: unknown[]) => Promise<Record<string, unknown>[]>;
69
+ const cnst = ConstantRegistry.getDatabase(refName);
70
+ let cancelled = false;
71
+ setLoading(true);
72
+ setError(null);
73
+
74
+ void listFn(applied.queryKey, applied.args, (page - 1) * pageSize, pageSize + 1, "latest")
75
+ .then((list) => {
76
+ if (cancelled) return;
77
+ setHasNext(list.length > pageSize);
78
+ setRows(list.slice(0, pageSize).map((row) => ({ id: String(row.id), label: labelOf(cnst.full, row) })));
79
+ })
80
+ .catch((reason: unknown) => {
81
+ if (cancelled) return;
82
+ setRows([]);
83
+ setError(reason instanceof Error ? reason.message : String(reason));
84
+ })
85
+ .finally(() => {
86
+ if (!cancelled) setLoading(false);
87
+ });
88
+ return () => {
89
+ cancelled = true;
90
+ };
91
+ }, [open, applied, page, refName]);
92
+
93
+ const modelLabel = dictLabel(l._, `${refName}.modelName`, refName);
94
+ const selectLabel = l.trans({ en: `Select ${modelLabel}`, ko: `${modelLabel} 선택` });
95
+ const selected = picked?.id === value ? picked.label : undefined;
96
+ const unlabeled = !!rows.length && rows.every((row) => !row.label);
97
+
98
+ return (
99
+ <div className={cn("flex w-full items-center gap-2", className)}>
100
+ <button
101
+ className={buttonRecipe({ variant: "outline", size: "sm" }, "min-w-0 flex-1 justify-start")}
102
+ onClick={() => {
103
+ setOpen(true);
104
+ }}
105
+ type="button"
106
+ >
107
+ <AiOutlineSearch className="shrink-0 text-foreground/50" />
108
+ <span className={cn("truncate", value ? (selected ? "" : "font-mono") : "text-foreground/45")}>
109
+ {selected ?? value ?? selectLabel}
110
+ </span>
111
+ </button>
112
+ {value ? (
113
+ <button
114
+ className={buttonRecipe({ variant: "ghost", size: "icon" }, "size-8 shrink-0 text-foreground/50")}
115
+ onClick={() => {
116
+ setPicked(null);
117
+ onChange(null);
118
+ }}
119
+ type="button"
120
+ >
121
+ <AiOutlineClose />
122
+ </button>
123
+ ) : null}
124
+ <Modal
125
+ className="max-w-xl"
126
+ open={open}
127
+ onCancel={() => {
128
+ setOpen(false);
129
+ }}
130
+ title={selectLabel}
131
+ >
132
+ <div className="flex flex-col gap-2">
133
+ <QueryMakerKey slice={slice} state={state} />
134
+ <QueryMakerArgs className="mb-0" slice={slice} state={state} />
135
+ {error ? (
136
+ <div className="rounded-box border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-xs">
137
+ {error}
138
+ </div>
139
+ ) : null}
140
+ {unlabeled ? (
141
+ <div className="rounded-box border border-warning/30 bg-warning/10 px-3 py-2 text-foreground/70 text-xs">
142
+ {l.trans({
143
+ en: `These rows show their id because "${refName}" has no label. Add a label() method to Light${capitalize(refName)}, or mark one field with { text: "title" }.`,
144
+ ko: `"${refName}"에 라벨이 없어 id를 표시합니다. Light${capitalize(refName)}에 label() 메서드를 추가하거나 필드 하나에 { text: "title" }을 지정하세요.`,
145
+ })}
146
+ </div>
147
+ ) : null}
148
+ {loading ? (
149
+ <Loading.Skeleton active />
150
+ ) : rows.length ? (
151
+ <div className="flex flex-col divide-y divide-border">
152
+ {rows.map((row) => (
153
+ <button
154
+ className={cn(
155
+ "flex items-center justify-between gap-3 px-2 py-2.5 text-left hover:bg-muted",
156
+ row.id === value && "bg-primary/10",
157
+ )}
158
+ key={row.id}
159
+ onClick={() => {
160
+ setPicked(row);
161
+ onChange(row.id);
162
+ setOpen(false);
163
+ }}
164
+ type="button"
165
+ >
166
+ <span className={cn("min-w-0 truncate text-sm", row.label ? "" : "font-mono")}>
167
+ {row.label ?? row.id}
168
+ </span>
169
+ {row.label ? <span className="shrink-0 font-mono text-foreground/40 text-xs">{row.id}</span> : null}
170
+ </button>
171
+ ))}
172
+ </div>
173
+ ) : (
174
+ <Empty minHeight={160} />
175
+ )}
176
+ <div className="flex items-center justify-between gap-2 pt-1">
177
+ <button
178
+ className={buttonRecipe({ variant: "ghost", size: "sm" })}
179
+ disabled={page <= 1}
180
+ onClick={() => {
181
+ setPage(page - 1);
182
+ }}
183
+ type="button"
184
+ >
185
+ {l.trans({ en: "Prev", ko: "이전" })}
186
+ </button>
187
+ <span className="text-foreground/45 text-xs">{page}</span>
188
+ <button
189
+ className={buttonRecipe({ variant: "ghost", size: "sm" })}
190
+ disabled={!hasNext}
191
+ onClick={() => {
192
+ setPage(page + 1);
193
+ }}
194
+ type="button"
195
+ >
196
+ {l.trans({ en: "Next", ko: "다음" })}
197
+ </button>
198
+ </div>
199
+ </div>
200
+ </Modal>
201
+ </div>
202
+ );
203
+ }
package/ui/Data/index.ts CHANGED
@@ -1,4 +1,14 @@
1
- import { CardList, Dashboard, Insight, Item, ListContainer, Pagination, QueryMaker, TableList } from "./index_";
1
+ import {
2
+ CardList,
3
+ Dashboard,
4
+ Insight,
5
+ Item,
6
+ ListContainer,
7
+ Pagination,
8
+ QueryMaker,
9
+ RefPicker,
10
+ TableList,
11
+ } from "./index_";
2
12
 
3
13
  export const Data = {
4
14
  CardList,
@@ -8,5 +18,6 @@ export const Data = {
8
18
  ListContainer,
9
19
  Pagination,
10
20
  QueryMaker,
21
+ RefPicker,
11
22
  TableList,
12
23
  };
@@ -9,3 +9,4 @@ export const ListContainer = lazy(() => import("./ListContainer"));
9
9
  export const Pagination = lazy(() => import("./Pagination"));
10
10
  export const TableList = lazy(() => import("./TableList"));
11
11
  export const QueryMaker = lazy(() => import("./QueryMaker"));
12
+ export const RefPicker = lazy(() => import("./RefPicker"));
package/ui/Signal/Arg.tsx CHANGED
@@ -4,7 +4,7 @@ import { usePage } from "akanjs/client";
4
4
  import { type ConstantCls, ConstantRegistry } from "akanjs/constant";
5
5
  import type { SerializedArg } from "akanjs/signal";
6
6
  import { st } from "akanjs/store";
7
- import type { ChangeEvent } from "react";
7
+ import type { ChangeEvent, ReactNode } from "react";
8
8
  import { AiOutlineDelete, AiOutlinePlus } from "react-icons/ai";
9
9
  import { buttonRecipe } from "../Button";
10
10
  import { DatePicker } from "../DatePicker";
@@ -141,8 +141,10 @@ interface ArgQueryProps {
141
141
  label?: string;
142
142
  value: any;
143
143
  onChange: (value: any) => void;
144
+ /** Replaces the scalar input this arg would otherwise get, once per element for an array arg. */
145
+ renderScalar?: (value: any, onChange: (value: any) => void) => ReactNode;
144
146
  }
145
- const ArgQuery = ({ endpointKey, arg, label, value, onChange }: ArgQueryProps) => {
147
+ const ArgQuery = ({ endpointKey, arg, label, value, onChange, renderScalar }: ArgQueryProps) => {
146
148
  const argRef = ConstantRegistry.getModelRef(arg.refName, arg.modelType);
147
149
  if (!PrimitiveRegistry.has(argRef)) throw new Error(`Query arg - ${endpointKey}/${arg.name} must be scalar`);
148
150
  else if ((arg.arrDepth ?? 0) > 1)
@@ -151,6 +153,12 @@ const ArgQuery = ({ endpointKey, arg, label, value, onChange }: ArgQueryProps) =
151
153
  const enumRef = arg.enum ? ConstantRegistry.enum.get(arg.enum) : undefined;
152
154
  const options: (string | number)[] = arg.oneOf ?? (enumRef ? [...enumRef.values] : []);
153
155
  const multiple = (arg.arrDepth ?? 0) > 0;
156
+ const renderLeaf = (leafValue: unknown, onLeafChange: (value: unknown) => void) =>
157
+ renderScalar ? (
158
+ renderScalar(leafValue, onLeafChange)
159
+ ) : (
160
+ <Arg argType={argType} value={leafValue as string} onChange={onLeafChange} />
161
+ );
154
162
  return (
155
163
  <div className={signalUi.inputRow}>
156
164
  <div className={signalUi.inputLabel}>{label ?? arg.name}</div>
@@ -167,13 +175,9 @@ const ArgQuery = ({ endpointKey, arg, label, value, onChange }: ArgQueryProps) =
167
175
  <div className="flex flex-col gap-2">
168
176
  {value.map((val, idx) => (
169
177
  <div className="flex items-center gap-2" key={idx}>
170
- <Arg
171
- argType={argType}
172
- value={val as string}
173
- onChange={(val) => {
174
- onChange([...(value.slice(0, idx) as string[]), val, ...(value.slice(idx + 1) as string[])]);
175
- }}
176
- />
178
+ {renderLeaf(val, (val) => {
179
+ onChange([...(value.slice(0, idx) as string[]), val, ...(value.slice(idx + 1) as string[])]);
180
+ })}
177
181
  <button
178
182
  className={buttonRecipe({ variant: "ghost", size: "icon" }, "size-8 shrink-0 text-foreground/50")}
179
183
  onClick={() => {
@@ -196,7 +200,7 @@ const ArgQuery = ({ endpointKey, arg, label, value, onChange }: ArgQueryProps) =
196
200
  </button>
197
201
  </div>
198
202
  ) : (
199
- <Arg argType={argType} value={value as string} onChange={onChange} />
203
+ renderLeaf(value, onChange)
200
204
  )}
201
205
  </div>
202
206
  </div>