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.
- package/constant/labelOf.ts +12 -3
- package/document/filterMeta.ts +0 -1
- package/document/types.ts +0 -1
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/types/constant/labelOf.d.ts +4 -3
- package/types/document/filterMeta.d.ts +0 -1
- package/types/document/types.d.ts +0 -1
- package/types/ui/Data/QueryMaker.d.ts +34 -1
- package/types/ui/Data/RefPicker.d.ts +16 -0
- package/types/ui/Data/index.d.ts +1 -0
- package/types/ui/Data/index_.d.ts +1 -0
- package/types/ui/Signal/Arg.d.ts +4 -1
- package/ui/Data/ListContainer.tsx +17 -5
- package/ui/Data/QueryMaker.tsx +112 -28
- package/ui/Data/RefPicker.tsx +203 -0
- package/ui/Data/index.ts +12 -1
- package/ui/Data/index_.tsx +1 -0
- package/ui/Signal/Arg.tsx +14 -10
package/constant/labelOf.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one line a human scans for
|
|
3
|
-
*
|
|
4
|
-
* `title
|
|
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"]) {
|
package/document/filterMeta.ts
CHANGED
|
@@ -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
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one line a human scans for
|
|
3
|
-
*
|
|
4
|
-
* `title
|
|
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
|
|
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 {};
|
package/types/ui/Data/index.d.ts
CHANGED
|
@@ -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;
|
package/types/ui/Signal/Arg.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
266
|
-
renderQueryMaker
|
|
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
|
-
{
|
|
405
|
+
{queryMakerArgs}
|
|
394
406
|
<RenderInsight />
|
|
395
407
|
{view === "card" ? (
|
|
396
408
|
<DataCardList
|
package/ui/Data/QueryMaker.tsx
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
73
|
+
applyRef.current(setting);
|
|
37
74
|
}, []);
|
|
38
75
|
const update = (setting: Required<QuerySetting>) => {
|
|
39
76
|
setSetting(setting);
|
|
40
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 {
|
|
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
|
};
|
package/ui/Data/index_.tsx
CHANGED
|
@@ -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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
203
|
+
renderLeaf(value, onChange)
|
|
200
204
|
)}
|
|
201
205
|
</div>
|
|
202
206
|
</div>
|