akanjs 3.0.0-alpha.60 → 3.0.0-alpha.62
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/fetch/client/httpClient.ts +5 -2
- package/fetch/fetchType/appliedReturn.type.ts +5 -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 -5
- 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/fetch/fetchType/appliedReturn.type.d.ts +5 -1
- package/types/ui/Data/Dashboard.d.ts +6 -1
- package/types/ui/Data/ListContainer.d.ts +5 -1
- package/types/ui/Data/QueryMaker.d.ts +43 -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/DatePicker.d.ts +15 -11
- package/types/ui/Signal/Arg.d.ts +4 -1
- package/ui/Data/Dashboard.tsx +37 -19
- package/ui/Data/ListContainer.tsx +42 -15
- package/ui/Data/QueryMaker.tsx +133 -34
- package/ui/Data/RefPicker.tsx +202 -0
- package/ui/Data/index.ts +12 -1
- package/ui/Data/index_.tsx +1 -0
- package/ui/DatePicker.tsx +110 -128
- package/ui/Model/AdminPanel.tsx +3 -1
- package/ui/Signal/Arg.tsx +14 -10
- package/ui/styles.css +4 -0
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
|
@@ -115,8 +115,11 @@ export class HttpClient {
|
|
|
115
115
|
const argValue = argMap.get(arg.name);
|
|
116
116
|
if (argValue === null || argValue === undefined) return;
|
|
117
117
|
|
|
118
|
-
if (arg.refName === "Any")
|
|
119
|
-
|
|
118
|
+
if (arg.refName === "Any") {
|
|
119
|
+
|
|
120
|
+
const encoded = JSON.stringify(argValue);
|
|
121
|
+
if (encoded !== undefined) searchParams.set(arg.name, encoded);
|
|
122
|
+
} else if (arg.arrDepth && Array.isArray(argValue))
|
|
120
123
|
argValue.forEach((value) => {
|
|
121
124
|
searchParams.append(arg.name, String(value));
|
|
122
125
|
});
|
|
@@ -11,7 +11,11 @@ export type SliceMeta = {
|
|
|
11
11
|
/** What the root slice takes: one of the model's declared filter queries, and the args that filter asks for. */
|
|
12
12
|
export interface QuerySetting {
|
|
13
13
|
queryKey: string;
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Read when the query is applied rather than when the setting is written, so a thunk keeps an arg relative to
|
|
16
|
+
* now — `() => [dayjs().subtract(1, "hour")]` — current at the moment the user asks for it.
|
|
17
|
+
*/
|
|
18
|
+
args?: unknown[] | (() => unknown[]);
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export type ServerInit<
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akanjs",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.62",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -216,7 +216,6 @@
|
|
|
216
216
|
"postgres": "^3.4.9",
|
|
217
217
|
"protobufjs": "^8.4.0",
|
|
218
218
|
"react": "19.2.7",
|
|
219
|
-
"react-datepicker": "^9.1.0",
|
|
220
219
|
"react-dom": "19.2.7",
|
|
221
220
|
"react-icons": "^5.6.0",
|
|
222
221
|
"react-refresh": "^0.18.0",
|
|
@@ -324,9 +323,6 @@
|
|
|
324
323
|
"react": {
|
|
325
324
|
"optional": true
|
|
326
325
|
},
|
|
327
|
-
"react-datepicker": {
|
|
328
|
-
"optional": true
|
|
329
|
-
},
|
|
330
326
|
"react-dom": {
|
|
331
327
|
"optional": true
|
|
332
328
|
},
|
|
@@ -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>>;
|
|
@@ -9,7 +9,11 @@ export type SliceMeta = {
|
|
|
9
9
|
/** What the root slice takes: one of the model's declared filter queries, and the args that filter asks for. */
|
|
10
10
|
export interface QuerySetting {
|
|
11
11
|
queryKey: string;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Read when the query is applied rather than when the setting is written, so a thunk keeps an arg relative to
|
|
14
|
+
* now — `() => [dayjs().subtract(1, "hour")]` — current at the moment the user asks for it.
|
|
15
|
+
*/
|
|
16
|
+
args?: unknown[] | (() => unknown[]);
|
|
13
17
|
}
|
|
14
18
|
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 & {
|
|
15
19
|
[K in `${RefName}ObjList`]: _LightObj[];
|
|
@@ -2,13 +2,18 @@ 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
|
+
/** The listing these tiles belong to. Kept so a caller names its target; the labels are app-level keys. */
|
|
5
6
|
slice: SliceMeta;
|
|
6
7
|
/** Columns that narrow the listing when clicked. A column absent from the map renders as a plain tile. */
|
|
7
8
|
queryMap?: {
|
|
8
9
|
[column: string]: QuerySetting;
|
|
9
10
|
};
|
|
11
|
+
/** Applies one column's filter. Without it a mapped column still renders, but as a plain tile. */
|
|
12
|
+
onSelect?: (setting: QuerySetting, column: string) => void;
|
|
13
|
+
/** The filter key the listing is showing, so a tile stops looking active once the toolbar moves off it. */
|
|
14
|
+
queryKey?: string;
|
|
10
15
|
columns?: string[];
|
|
11
16
|
presents?: string[];
|
|
12
17
|
hidePresents?: boolean;
|
|
13
18
|
}
|
|
14
|
-
export default function Dashboard<T extends string, State>({ className, summary,
|
|
19
|
+
export default function Dashboard<T extends string, State>({ className, summary, queryMap, onSelect, queryKey, columns, presents, hidePresents, }: DashboardProps<T, State>): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -34,9 +34,13 @@ export interface ListContainerProps<T extends string, State, Input, Full extends
|
|
|
34
34
|
tools?: DataTool[] | ((modelList: Light[]) => DataTool[]);
|
|
35
35
|
/** Per-row actions or action factory. */
|
|
36
36
|
actions?: DataAction[] | ((item: Light, idx: number) => DataAction[]);
|
|
37
|
-
renderDashboard?: ({ summary, hidePresents, }: {
|
|
37
|
+
renderDashboard?: ({ summary, hidePresents, onSelect, queryKey, }: {
|
|
38
38
|
summary: Record<string, unknown>;
|
|
39
39
|
hidePresents?: boolean;
|
|
40
|
+
/** Applies one summary column's filter to this listing, in place. */
|
|
41
|
+
onSelect: (setting: QuerySetting) => void;
|
|
42
|
+
/** The filter key the listing is showing right now. */
|
|
43
|
+
queryKey: string;
|
|
40
44
|
}) => ReactNode;
|
|
41
45
|
renderItem?: (props: ModelProps<any, any>) => ReactNode;
|
|
42
46
|
renderTemplate?: (props: any) => ReactNode | null;
|
|
@@ -1,8 +1,50 @@
|
|
|
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: ResolvedQuerySetting) => void;
|
|
6
9
|
}
|
|
7
|
-
export
|
|
10
|
+
export interface QueryMakerState {
|
|
11
|
+
queryKeys: string[];
|
|
12
|
+
args: SerializedArg[];
|
|
13
|
+
setting: ResolvedQuerySetting;
|
|
14
|
+
selectKey: (queryKey: string) => void;
|
|
15
|
+
setArg: (idx: number, value: unknown) => void;
|
|
16
|
+
/** Applies a whole filter at once, for a control that stands for one — a dashboard tile, a saved view. */
|
|
17
|
+
applySetting: (setting: QuerySetting) => void;
|
|
18
|
+
}
|
|
19
|
+
/** A setting whose thunk has been read, which is the only form the store and the wire accept. */
|
|
20
|
+
export interface ResolvedQuerySetting {
|
|
21
|
+
queryKey: string;
|
|
22
|
+
args: unknown[];
|
|
23
|
+
}
|
|
24
|
+
interface QueryMakerKeyProps {
|
|
25
|
+
className?: string;
|
|
26
|
+
selectClassName?: string;
|
|
27
|
+
slice: SliceMeta;
|
|
28
|
+
state: QueryMakerState;
|
|
29
|
+
}
|
|
30
|
+
interface QueryMakerArgsProps {
|
|
31
|
+
className?: string;
|
|
32
|
+
slice: SliceMeta;
|
|
33
|
+
state: QueryMakerState;
|
|
34
|
+
}
|
|
35
|
+
/** A filter's args may be written as a thunk, so that an arg relative to now is read when the filter is applied. */
|
|
36
|
+
export declare const resolveQuerySetting: (setting: QuerySetting) => ResolvedQuerySetting;
|
|
37
|
+
/** A filter the server will accept: every arg it declared as required has been given a value. */
|
|
38
|
+
export declare const isReadyQuery: (args: SerializedArg[], values: unknown[]) => boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The filter a listing is showing, and the one write that applies it. Held in a hook rather than a component
|
|
41
|
+
* because the key select rides the toolbar while the args that key takes render under it — two places in the
|
|
42
|
+
* tree, one selection.
|
|
43
|
+
*/
|
|
44
|
+
export declare const useQueryMaker: ({ slice, query, onApply }: QueryMakerProps) => QueryMakerState;
|
|
45
|
+
/** The filter picker. Sized for a toolbar, beside the sort and page-size selects. */
|
|
46
|
+
export declare const QueryMakerKey: ({ className, selectClassName, slice, state }: QueryMakerKeyProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
47
|
+
/** What the picked filter asks for. Renders nothing for a filter that takes no arguments. */
|
|
48
|
+
export declare const QueryMakerArgs: ({ className, slice, state }: QueryMakerArgsProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
49
|
+
export default function QueryMaker({ className, slice, query, onApply }: QueryMakerProps): import("react/jsx-runtime").JSX.Element;
|
|
8
50
|
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/DatePicker.d.ts
CHANGED
|
@@ -1,37 +1,41 @@
|
|
|
1
1
|
import { type Dayjs } from "akanjs/base";
|
|
2
2
|
export interface DatePickerProps {
|
|
3
|
+
className?: string;
|
|
3
4
|
value?: Dayjs | null;
|
|
4
5
|
onChange: (value: Dayjs | null) => void;
|
|
5
6
|
showTime?: boolean;
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
/** Earliest selectable value. The browser enforces it. */
|
|
8
|
+
min?: Dayjs | null;
|
|
9
|
+
/** Latest selectable value. The browser enforces it. */
|
|
10
|
+
max?: Dayjs | null;
|
|
11
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
8
12
|
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
9
|
-
className?: string;
|
|
10
|
-
placement?: "top" | "bottom" | "left" | "right";
|
|
11
13
|
defaultValue?: Dayjs;
|
|
12
14
|
}
|
|
13
15
|
export interface RangePickerProps {
|
|
16
|
+
className?: string;
|
|
14
17
|
value: [Dayjs | null, Dayjs | null];
|
|
15
18
|
onChange: (value: [Dayjs | null, Dayjs | null]) => void;
|
|
16
|
-
format?: string;
|
|
17
19
|
showTime?: boolean;
|
|
18
|
-
|
|
20
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
19
21
|
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
20
|
-
className?: string;
|
|
21
22
|
}
|
|
22
23
|
export interface TimePickerProps {
|
|
24
|
+
className?: string;
|
|
23
25
|
value: Dayjs | null;
|
|
24
26
|
onChange: (value: Dayjs) => void;
|
|
25
|
-
format?: string;
|
|
26
|
-
timeIntervals?: number;
|
|
27
|
-
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
28
|
-
className?: string;
|
|
29
27
|
disabled?: boolean;
|
|
28
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
29
|
+
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
32
|
* Date picker. `DatePicker`, `DatePicker.RangePicker`, and `DatePicker.TimePicker` each resolve to a
|
|
33
33
|
* route-scoped override when a `page/**\/_overrides.tsx` in the route's ancestry declares one (slots
|
|
34
34
|
* `DatePicker`, `DatePickerRangePicker`, `DatePickerTimePicker`).
|
|
35
|
+
*
|
|
36
|
+
* Each renders one native `<input type="date" | "datetime-local" | "time">`, so the calendar is the browser's
|
|
37
|
+
* own: an OS wheel on mobile, keyboard entry everywhere, nothing shipped in the bundle. What that costs is a
|
|
38
|
+
* themed popup, a chosen display format, and per-day disabling — an app needing any of those overrides the slot.
|
|
35
39
|
*/
|
|
36
40
|
export declare const DatePicker: import("react").ComponentType<DatePickerProps> & {
|
|
37
41
|
RangePicker: import("react").ComponentType<RangePickerProps>;
|
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;
|
package/ui/Data/Dashboard.tsx
CHANGED
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
import { cn, usePage } from "akanjs/client";
|
|
3
3
|
import type { QuerySetting, SliceMeta } from "akanjs/fetch";
|
|
4
4
|
import { st } from "akanjs/store";
|
|
5
|
+
import { useState } from "react";
|
|
5
6
|
|
|
6
|
-
import { Link } from "../Link";
|
|
7
7
|
import { dictLabel, formatStat } from "./dataText";
|
|
8
8
|
|
|
9
9
|
export interface DashboardProps<T extends string, State> {
|
|
10
10
|
className?: string;
|
|
11
11
|
summary: Record<string, unknown>;
|
|
12
|
+
/** The listing these tiles belong to. Kept so a caller names its target; the labels are app-level keys. */
|
|
12
13
|
slice: SliceMeta;
|
|
13
14
|
/** Columns that narrow the listing when clicked. A column absent from the map renders as a plain tile. */
|
|
14
15
|
queryMap?: { [column: string]: QuerySetting };
|
|
16
|
+
/** Applies one column's filter. Without it a mapped column still renders, but as a plain tile. */
|
|
17
|
+
onSelect?: (setting: QuerySetting, column: string) => void;
|
|
18
|
+
/** The filter key the listing is showing, so a tile stops looking active once the toolbar moves off it. */
|
|
19
|
+
queryKey?: string;
|
|
15
20
|
columns?: string[];
|
|
16
21
|
presents?: string[];
|
|
17
22
|
hidePresents?: boolean;
|
|
@@ -22,42 +27,55 @@ const tileClassName = "flex min-w-40 flex-1 flex-col gap-1 rounded-box border px
|
|
|
22
27
|
export default function Dashboard<T extends string, State>({
|
|
23
28
|
className,
|
|
24
29
|
summary,
|
|
25
|
-
slice,
|
|
26
30
|
queryMap,
|
|
31
|
+
onSelect,
|
|
32
|
+
queryKey,
|
|
27
33
|
columns,
|
|
28
34
|
presents,
|
|
29
35
|
hidePresents,
|
|
30
36
|
}: DashboardProps<T, State>) {
|
|
31
|
-
const { refName } = slice;
|
|
32
37
|
const { l } = usePage();
|
|
33
38
|
const searchParams = st.use.searchParams({ agent: false });
|
|
34
39
|
const filter = Array.isArray(searchParams.filter) ? searchParams.filter[0] : searchParams.filter;
|
|
40
|
+
|
|
41
|
+
const [selected, setSelected] = useState(typeof filter === "string" ? filter : undefined);
|
|
35
42
|
const shownColumns = (columns ?? []).filter((column) => summary[column] !== undefined);
|
|
36
43
|
const shownPresents = hidePresents ? [] : (presents ?? []).filter((column) => summary[column] !== undefined);
|
|
37
44
|
if (!shownColumns.length && !shownPresents.length) return null;
|
|
45
|
+
const activeColumn = selected && queryMap?.[selected]?.queryKey === queryKey ? selected : undefined;
|
|
38
46
|
return (
|
|
39
47
|
<div className={cn("mb-4 flex flex-wrap gap-2", className)}>
|
|
40
48
|
{[...shownColumns, ...shownPresents].map((column) => {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
const setting = queryMap?.[column];
|
|
50
|
+
const label = dictLabel(l._, `summary.${column}`, column);
|
|
51
|
+
const body = (
|
|
52
|
+
<>
|
|
53
|
+
<span className="truncate text-muted-foreground text-xs">{label}</span>
|
|
54
|
+
<span className="truncate font-semibold text-2xl text-primary tabular-nums">
|
|
55
|
+
{formatStat(summary[column])}
|
|
56
|
+
</span>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
return setting && onSelect ? (
|
|
60
|
+
<button
|
|
47
61
|
className={cn(
|
|
48
62
|
tileClassName,
|
|
49
|
-
"bg-card",
|
|
50
|
-
|
|
51
|
-
filter === column && linkable ? "border-primary" : "border-border",
|
|
63
|
+
"bg-card transition hover:border-primary/50",
|
|
64
|
+
activeColumn === column ? "border-primary" : "border-border",
|
|
52
65
|
)}
|
|
66
|
+
key={column}
|
|
67
|
+
onClick={() => {
|
|
68
|
+
setSelected(column);
|
|
69
|
+
onSelect(setting, column);
|
|
70
|
+
}}
|
|
71
|
+
type="button"
|
|
53
72
|
>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
</Link>
|
|
73
|
+
{body}
|
|
74
|
+
</button>
|
|
75
|
+
) : (
|
|
76
|
+
<div className={cn(tileClassName, "border-border bg-card")} key={column}>
|
|
77
|
+
{body}
|
|
78
|
+
</div>
|
|
61
79
|
);
|
|
62
80
|
})}
|
|
63
81
|
</div>
|
|
@@ -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, resolveQuerySetting, useQueryMaker } from "./QueryMaker";
|
|
40
40
|
import DataTableList from "./TableList";
|
|
41
41
|
|
|
42
42
|
const controlClassName = "h-9";
|
|
@@ -79,9 +79,15 @@ export interface ListContainerProps<
|
|
|
79
79
|
renderDashboard?: ({
|
|
80
80
|
summary,
|
|
81
81
|
hidePresents,
|
|
82
|
+
onSelect,
|
|
83
|
+
queryKey,
|
|
82
84
|
}: {
|
|
83
85
|
summary: Record<string, unknown>;
|
|
84
86
|
hidePresents?: boolean;
|
|
87
|
+
/** Applies one summary column's filter to this listing, in place. */
|
|
88
|
+
onSelect: (setting: QuerySetting) => void;
|
|
89
|
+
/** The filter key the listing is showing right now. */
|
|
90
|
+
queryKey: string;
|
|
85
91
|
}) => ReactNode;
|
|
86
92
|
renderItem?: (props: ModelProps<any, any>) => ReactNode;
|
|
87
93
|
renderTemplate?: (props: any) => ReactNode | null;
|
|
@@ -174,10 +180,14 @@ export default function ListContainer<
|
|
|
174
180
|
const searchParams = st.use.searchParams({ agent: false });
|
|
175
181
|
const filter = Array.isArray(searchParams.filter) ? searchParams.filter[0] : searchParams.filter;
|
|
176
182
|
const initQuery = query ?? (filter ? queryMap?.[filter] : undefined);
|
|
183
|
+
const queryState = useQueryMaker({ slice, query: initQuery });
|
|
177
184
|
useEffect(() => {
|
|
178
185
|
|
|
179
186
|
const queryArgs = new Array(slice.argLength).fill(null) as unknown[];
|
|
180
|
-
if (initQuery)
|
|
187
|
+
if (initQuery) {
|
|
188
|
+
const { queryKey, args } = resolveQuerySetting(initQuery);
|
|
189
|
+
[queryArgs[0], queryArgs[1]] = [queryKey, args];
|
|
190
|
+
}
|
|
181
191
|
void storeDo[namesOfSlice.initModel](...queryArgs, { sort, ...init });
|
|
182
192
|
}, []);
|
|
183
193
|
|
|
@@ -251,19 +261,28 @@ export default function ListContainer<
|
|
|
251
261
|
|
|
252
262
|
const modelLabel = dictLabel(l._, `${sliceName}.modelName`, refName);
|
|
253
263
|
const RenderTitle = renderTitle ?? ((model: Full) => `${modelLabel} - ${model.id ? model.id : "New"}`);
|
|
254
|
-
const ModelDashboard = (): ReactNode => {
|
|
255
|
-
const Stat = renderDashboard;
|
|
256
264
|
|
|
257
|
-
|
|
258
|
-
|
|
265
|
+
const summary = storeSel<Record<string, unknown> | undefined>(
|
|
266
|
+
(state) => (state as { summary?: Record<string, unknown> }).summary,
|
|
267
|
+
);
|
|
268
|
+
const summaryLoading = storeSel<boolean>((state) => !!(state as { summaryLoading?: boolean }).summaryLoading);
|
|
269
|
+
const modelDashboard =
|
|
270
|
+
!renderDashboard || !summary ? null : summaryLoading ? (
|
|
271
|
+
<Loading.Skeleton className="mb-4" active />
|
|
272
|
+
) : (
|
|
273
|
+
renderDashboard({
|
|
274
|
+
summary,
|
|
275
|
+
hidePresents: true,
|
|
276
|
+
onSelect: queryState.applySetting,
|
|
277
|
+
queryKey: queryState.setting.queryKey,
|
|
278
|
+
})
|
|
259
279
|
);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
renderQueryMaker ?? (query ? undefined : () => <DataQueryMaker slice={slice} query={initQuery} />);
|
|
280
|
+
|
|
281
|
+
const queryMakerArgs = renderQueryMaker ? (
|
|
282
|
+
renderQueryMaker()
|
|
283
|
+
) : query ? null : (
|
|
284
|
+
<QueryMakerArgs slice={slice} state={queryState} />
|
|
285
|
+
);
|
|
267
286
|
const RenderInsight = (): ReactNode => (renderInsight ? renderInsight({ insight: modelInsight }) : null);
|
|
268
287
|
const RenderTemplate = renderTemplate;
|
|
269
288
|
const RenderTools = (): ReactNode => {
|
|
@@ -356,6 +375,14 @@ export default function ListContainer<
|
|
|
356
375
|
</button>
|
|
357
376
|
))}
|
|
358
377
|
</div>
|
|
378
|
+
{query ? null : (
|
|
379
|
+
<QueryMakerKey
|
|
380
|
+
className="w-44 min-w-0"
|
|
381
|
+
selectClassName={cn("min-h-0", controlClassName)}
|
|
382
|
+
slice={slice}
|
|
383
|
+
state={queryState}
|
|
384
|
+
/>
|
|
385
|
+
)}
|
|
359
386
|
<RenderSort />
|
|
360
387
|
<Select<number>
|
|
361
388
|
className="w-36 min-w-0"
|
|
@@ -389,8 +416,8 @@ export default function ListContainer<
|
|
|
389
416
|
) : null}
|
|
390
417
|
</div>
|
|
391
418
|
</div>
|
|
392
|
-
{query ? null :
|
|
393
|
-
{
|
|
419
|
+
{query ? null : modelDashboard}
|
|
420
|
+
{queryMakerArgs}
|
|
394
421
|
<RenderInsight />
|
|
395
422
|
{view === "card" ? (
|
|
396
423
|
<DataCardList
|