akanjs 3.0.0-alpha.61 → 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/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/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 +11 -2
- package/types/ui/DatePicker.d.ts +15 -11
- package/ui/Data/Dashboard.tsx +37 -19
- package/ui/Data/ListContainer.tsx +26 -11
- package/ui/Data/QueryMaker.tsx +24 -9
- package/ui/Data/RefPicker.tsx +3 -4
- package/ui/DatePicker.tsx +110 -128
- package/ui/Model/AdminPanel.tsx +3 -1
- package/ui/styles.css +4 -0
|
@@ -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
|
},
|
|
@@ -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;
|
|
@@ -5,14 +5,21 @@ interface QueryMakerProps {
|
|
|
5
5
|
slice: SliceMeta;
|
|
6
6
|
query?: QuerySetting;
|
|
7
7
|
/** Where a completed filter goes. Defaults to the slice's own store, which is where a listing reads it. */
|
|
8
|
-
onApply?: (setting:
|
|
8
|
+
onApply?: (setting: ResolvedQuerySetting) => void;
|
|
9
9
|
}
|
|
10
10
|
export interface QueryMakerState {
|
|
11
11
|
queryKeys: string[];
|
|
12
12
|
args: SerializedArg[];
|
|
13
|
-
setting:
|
|
13
|
+
setting: ResolvedQuerySetting;
|
|
14
14
|
selectKey: (queryKey: string) => void;
|
|
15
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[];
|
|
16
23
|
}
|
|
17
24
|
interface QueryMakerKeyProps {
|
|
18
25
|
className?: string;
|
|
@@ -25,6 +32,8 @@ interface QueryMakerArgsProps {
|
|
|
25
32
|
slice: SliceMeta;
|
|
26
33
|
state: QueryMakerState;
|
|
27
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;
|
|
28
37
|
/** A filter the server will accept: every arg it declared as required has been given a value. */
|
|
29
38
|
export declare const isReadyQuery: (args: SerializedArg[], values: unknown[]) => boolean;
|
|
30
39
|
/**
|
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/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 { QueryMakerArgs, QueryMakerKey, useQueryMaker } from "./QueryMaker";
|
|
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;
|
|
@@ -178,7 +184,10 @@ export default function ListContainer<
|
|
|
178
184
|
useEffect(() => {
|
|
179
185
|
|
|
180
186
|
const queryArgs = new Array(slice.argLength).fill(null) as unknown[];
|
|
181
|
-
if (initQuery)
|
|
187
|
+
if (initQuery) {
|
|
188
|
+
const { queryKey, args } = resolveQuerySetting(initQuery);
|
|
189
|
+
[queryArgs[0], queryArgs[1]] = [queryKey, args];
|
|
190
|
+
}
|
|
182
191
|
void storeDo[namesOfSlice.initModel](...queryArgs, { sort, ...init });
|
|
183
192
|
}, []);
|
|
184
193
|
|
|
@@ -252,16 +261,22 @@ export default function ListContainer<
|
|
|
252
261
|
|
|
253
262
|
const modelLabel = dictLabel(l._, `${sliceName}.modelName`, refName);
|
|
254
263
|
const RenderTitle = renderTitle ?? ((model: Full) => `${modelLabel} - ${model.id ? model.id : "New"}`);
|
|
255
|
-
const ModelDashboard = (): ReactNode => {
|
|
256
|
-
const Stat = renderDashboard;
|
|
257
264
|
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
})
|
|
260
279
|
);
|
|
261
|
-
const summaryLoading = storeSel<boolean>((state) => !!(state as { summaryLoading?: boolean }).summaryLoading);
|
|
262
|
-
if (!Stat || !summary) return null;
|
|
263
|
-
return summaryLoading ? <Loading.Skeleton className="mb-4" active /> : <Stat summary={summary} hidePresents />;
|
|
264
|
-
};
|
|
265
280
|
|
|
266
281
|
const queryMakerArgs = renderQueryMaker ? (
|
|
267
282
|
renderQueryMaker()
|
|
@@ -401,7 +416,7 @@ export default function ListContainer<
|
|
|
401
416
|
) : null}
|
|
402
417
|
</div>
|
|
403
418
|
</div>
|
|
404
|
-
{query ? null :
|
|
419
|
+
{query ? null : modelDashboard}
|
|
405
420
|
{queryMakerArgs}
|
|
406
421
|
<RenderInsight />
|
|
407
422
|
{view === "card" ? (
|
package/ui/Data/QueryMaker.tsx
CHANGED
|
@@ -16,14 +16,21 @@ interface QueryMakerProps {
|
|
|
16
16
|
slice: SliceMeta;
|
|
17
17
|
query?: QuerySetting;
|
|
18
18
|
/** Where a completed filter goes. Defaults to the slice's own store, which is where a listing reads it. */
|
|
19
|
-
onApply?: (setting:
|
|
19
|
+
onApply?: (setting: ResolvedQuerySetting) => void;
|
|
20
20
|
}
|
|
21
21
|
export interface QueryMakerState {
|
|
22
22
|
queryKeys: string[];
|
|
23
23
|
args: SerializedArg[];
|
|
24
|
-
setting:
|
|
24
|
+
setting: ResolvedQuerySetting;
|
|
25
25
|
selectKey: (queryKey: string) => void;
|
|
26
26
|
setArg: (idx: number, value: unknown) => void;
|
|
27
|
+
/** Applies a whole filter at once, for a control that stands for one — a dashboard tile, a saved view. */
|
|
28
|
+
applySetting: (setting: QuerySetting) => void;
|
|
29
|
+
}
|
|
30
|
+
/** A setting whose thunk has been read, which is the only form the store and the wire accept. */
|
|
31
|
+
export interface ResolvedQuerySetting {
|
|
32
|
+
queryKey: string;
|
|
33
|
+
args: unknown[];
|
|
27
34
|
}
|
|
28
35
|
interface QueryMakerKeyProps {
|
|
29
36
|
className?: string;
|
|
@@ -37,6 +44,12 @@ interface QueryMakerArgsProps {
|
|
|
37
44
|
state: QueryMakerState;
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
/** A filter's args may be written as a thunk, so that an arg relative to now is read when the filter is applied. */
|
|
48
|
+
export const resolveQuerySetting = (setting: QuerySetting): ResolvedQuerySetting => ({
|
|
49
|
+
queryKey: setting.queryKey,
|
|
50
|
+
args: typeof setting.args === "function" ? setting.args() : (setting.args ?? []),
|
|
51
|
+
});
|
|
52
|
+
|
|
40
53
|
const isFillableArg = (arg: SerializedArg) => !arg.modelType;
|
|
41
54
|
const defaultArg = (arg: SerializedArg) => ((arg.arrDepth ?? 0) > 0 ? [] : null);
|
|
42
55
|
|
|
@@ -58,21 +71,20 @@ export const useQueryMaker = ({ slice, query, onApply }: QueryMakerProps): Query
|
|
|
58
71
|
const queryKeys = Object.entries(filterQuery)
|
|
59
72
|
.filter(([, args]) => args.every(isFillableArg))
|
|
60
73
|
.map(([queryKey]) => queryKey);
|
|
61
|
-
const [setting, setSetting] = useState<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
74
|
+
const [setting, setSetting] = useState<ResolvedQuerySetting>(
|
|
75
|
+
query ? resolveQuerySetting(query) : { queryKey: queryKeys[0] ?? "any", args: [] },
|
|
76
|
+
);
|
|
65
77
|
|
|
66
|
-
const applyRef = useRef<(setting:
|
|
78
|
+
const applyRef = useRef<(setting: ResolvedQuerySetting) => void>(() => undefined);
|
|
67
79
|
applyRef.current = (setting) => {
|
|
68
80
|
if (onApply) onApply(setting);
|
|
69
81
|
else void storeDo[`setQueryArgsOf${capitalize(sliceName)}`](setting.queryKey, setting.args);
|
|
70
82
|
};
|
|
71
83
|
|
|
72
|
-
const applyQuery = useDebounce((setting:
|
|
84
|
+
const applyQuery = useDebounce((setting: ResolvedQuerySetting) => {
|
|
73
85
|
applyRef.current(setting);
|
|
74
86
|
}, []);
|
|
75
|
-
const update = (setting:
|
|
87
|
+
const update = (setting: ResolvedQuerySetting) => {
|
|
76
88
|
setSetting(setting);
|
|
77
89
|
|
|
78
90
|
if (isReadyQuery(filterQuery[setting.queryKey] ?? [], setting.args)) applyQuery(setting);
|
|
@@ -88,6 +100,9 @@ export const useQueryMaker = ({ slice, query, onApply }: QueryMakerProps): Query
|
|
|
88
100
|
setArg: (idx, value) => {
|
|
89
101
|
update({ ...setting, args: args.map((arg, i) => (i === idx ? value : (setting.args[i] ?? defaultArg(arg)))) });
|
|
90
102
|
},
|
|
103
|
+
applySetting: (setting) => {
|
|
104
|
+
update(resolveQuerySetting(setting));
|
|
105
|
+
},
|
|
91
106
|
};
|
|
92
107
|
};
|
|
93
108
|
|
package/ui/Data/RefPicker.tsx
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { cn, fetch, usePage } from "akanjs/client";
|
|
3
3
|
import { capitalize } from "akanjs/common";
|
|
4
4
|
import { ConstantRegistry, labelOf } from "akanjs/constant";
|
|
5
|
-
import type { QuerySetting } from "akanjs/fetch";
|
|
6
5
|
import { useEffect, useState } from "react";
|
|
7
6
|
import { AiOutlineClose, AiOutlineSearch } from "react-icons/ai";
|
|
8
7
|
import { buttonRecipe } from "../Button";
|
|
@@ -10,7 +9,7 @@ import { Empty } from "../Empty";
|
|
|
10
9
|
import { Loading } from "../Loading";
|
|
11
10
|
import { Modal } from "../Modal";
|
|
12
11
|
import { dictLabel } from "./dataText";
|
|
13
|
-
import { isReadyQuery, QueryMakerArgs, QueryMakerKey, useQueryMaker } from "./QueryMaker";
|
|
12
|
+
import { isReadyQuery, QueryMakerArgs, QueryMakerKey, type ResolvedQuerySetting, useQueryMaker } from "./QueryMaker";
|
|
14
13
|
|
|
15
14
|
interface RefPickerProps {
|
|
16
15
|
className?: string;
|
|
@@ -39,10 +38,10 @@ export default function RefPicker({ className, refName, value, onChange }: RefPi
|
|
|
39
38
|
const filterQuery = fetch.filterQueryMap?.get(refName) ?? {};
|
|
40
39
|
|
|
41
40
|
const initialKey = filterQuery.bySearch ? "bySearch" : "any";
|
|
42
|
-
const initialQuery:
|
|
41
|
+
const initialQuery: ResolvedQuerySetting = { queryKey: initialKey, args: [] };
|
|
43
42
|
|
|
44
43
|
const [open, setOpen] = useState(false);
|
|
45
|
-
const [applied, setApplied] = useState<
|
|
44
|
+
const [applied, setApplied] = useState<ResolvedQuerySetting>(initialQuery);
|
|
46
45
|
const [page, setPage] = useState(1);
|
|
47
46
|
const [rows, setRows] = useState<RefRow[]>([]);
|
|
48
47
|
const [hasNext, setHasNext] = useState(false);
|
package/ui/DatePicker.tsx
CHANGED
|
@@ -1,189 +1,168 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { type Dayjs, dayjs } from "akanjs/base";
|
|
3
3
|
import { cn, msg } from "akanjs/client";
|
|
4
|
-
import {
|
|
5
|
-
import { type FocusEvent, useEffect, useRef } from "react";
|
|
4
|
+
import { type ChangeEvent, useEffect } from "react";
|
|
6
5
|
import { AiOutlineSwapRight } from "react-icons/ai";
|
|
7
6
|
|
|
8
7
|
import { inputRecipe } from "./recipe";
|
|
9
8
|
import { createOverridable } from "./UiOverride";
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
type NativeDateType = "date" | "datetime-local" | "time";
|
|
11
|
+
|
|
12
|
+
const valueFormat = { date: "YYYY-MM-DD", "datetime-local": "YYYY-MM-DDTHH:mm", time: "HH:mm" } as const;
|
|
13
|
+
|
|
14
|
+
const toInputValue = (value: Dayjs | null | undefined, type: NativeDateType) =>
|
|
15
|
+
value?.isValid() ? value.format(valueFormat[type]) : "";
|
|
16
|
+
|
|
17
|
+
/** `type="time"` reports a clock reading only, so the day comes from whatever the field already holds. */
|
|
18
|
+
const fromInputValue = (raw: string, type: NativeDateType, base: Dayjs) => {
|
|
19
|
+
if (!raw) return null;
|
|
20
|
+
const picked = type === "time" ? dayjs(`${base.format(valueFormat.date)}T${raw}`) : dayjs(raw);
|
|
21
|
+
return picked.isValid() ? picked : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface NativeDateInputProps {
|
|
25
|
+
className?: string;
|
|
26
|
+
type: NativeDateType;
|
|
27
|
+
value?: Dayjs | null;
|
|
28
|
+
min?: Dayjs | null;
|
|
29
|
+
max?: Dayjs | null;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
32
|
+
onChange: (value: Dayjs) => void;
|
|
33
|
+
}
|
|
34
|
+
const NativeDateInput = ({
|
|
35
|
+
className,
|
|
36
|
+
type,
|
|
37
|
+
value,
|
|
38
|
+
min,
|
|
39
|
+
max,
|
|
40
|
+
disabled,
|
|
41
|
+
disabledDate,
|
|
42
|
+
onChange,
|
|
43
|
+
}: NativeDateInputProps) => {
|
|
44
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
45
|
+
const picked = fromInputValue(event.target.value, type, value ?? dayjs());
|
|
46
|
+
if (!picked || disabledDate?.(picked)) {
|
|
47
|
+
msg.warning("base.selectDateError");
|
|
48
|
+
|
|
49
|
+
event.target.value = toInputValue(value, type);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
onChange(picked);
|
|
53
|
+
};
|
|
54
|
+
return (
|
|
55
|
+
<input
|
|
56
|
+
className={className}
|
|
57
|
+
type={type}
|
|
58
|
+
value={toInputValue(value, type)}
|
|
59
|
+
min={min ? toInputValue(min, type) : undefined}
|
|
60
|
+
max={max ? toInputValue(max, type) : undefined}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
onChange={handleChange}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
13
66
|
|
|
14
|
-
const ReactDatePicker = lazy(
|
|
15
|
-
async () => {
|
|
16
|
-
await import(reactDatePickerStylePath);
|
|
17
|
-
return import(reactDatePickerPackage);
|
|
18
|
-
},
|
|
19
|
-
{ ssr: false },
|
|
20
|
-
);
|
|
21
67
|
export interface DatePickerProps {
|
|
68
|
+
className?: string;
|
|
22
69
|
value?: Dayjs | null;
|
|
23
70
|
onChange: (value: Dayjs | null) => void;
|
|
24
71
|
showTime?: boolean;
|
|
25
|
-
|
|
26
|
-
|
|
72
|
+
/** Earliest selectable value. The browser enforces it. */
|
|
73
|
+
min?: Dayjs | null;
|
|
74
|
+
/** Latest selectable value. The browser enforces it. */
|
|
75
|
+
max?: Dayjs | null;
|
|
76
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
27
77
|
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
28
|
-
className?: string;
|
|
29
|
-
placement?: "top" | "bottom" | "left" | "right";
|
|
30
78
|
defaultValue?: Dayjs;
|
|
31
79
|
}
|
|
32
80
|
|
|
33
81
|
const DefaultDatePicker = ({
|
|
82
|
+
className = "",
|
|
34
83
|
value,
|
|
35
84
|
onChange,
|
|
36
85
|
showTime,
|
|
37
|
-
|
|
38
|
-
|
|
86
|
+
min,
|
|
87
|
+
max,
|
|
39
88
|
disabledDate,
|
|
40
|
-
placement,
|
|
41
|
-
className = "",
|
|
42
89
|
defaultValue,
|
|
43
90
|
}: DatePickerProps) => {
|
|
44
|
-
const clickNum = useRef(0);
|
|
45
|
-
|
|
46
91
|
useEffect(() => {
|
|
47
|
-
if (defaultValue)
|
|
48
|
-
onChange(defaultValue);
|
|
49
|
-
}
|
|
92
|
+
if (defaultValue) onChange(defaultValue);
|
|
50
93
|
}, [defaultValue]);
|
|
51
94
|
|
|
52
|
-
const handleDateChange = (date?: Date | null) => {
|
|
53
|
-
if (!date) {
|
|
54
|
-
msg.warning("base.selectDateError");
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
onChange(dayjs(date));
|
|
58
|
-
};
|
|
59
|
-
|
|
60
95
|
return (
|
|
61
|
-
<
|
|
96
|
+
<NativeDateInput
|
|
62
97
|
className={inputRecipe({}, ["text-center", className])}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}}
|
|
70
|
-
onChange={handleDateChange}
|
|
71
|
-
showTimeSelect={showTime}
|
|
72
|
-
popperPlacement={placement}
|
|
73
|
-
timeIntervals={timeIntervals}
|
|
74
|
-
filterDate={(date: Date) => (!disabledDate?.(dayjs(date)) ? true : false)}
|
|
75
|
-
dateFormat={format}
|
|
98
|
+
type={showTime ? "datetime-local" : "date"}
|
|
99
|
+
value={value}
|
|
100
|
+
min={min}
|
|
101
|
+
max={max}
|
|
102
|
+
disabledDate={disabledDate}
|
|
103
|
+
onChange={onChange}
|
|
76
104
|
/>
|
|
77
105
|
);
|
|
78
106
|
};
|
|
79
107
|
|
|
80
108
|
export interface RangePickerProps {
|
|
109
|
+
className?: string;
|
|
81
110
|
value: [Dayjs | null, Dayjs | null];
|
|
82
111
|
onChange: (value: [Dayjs | null, Dayjs | null]) => void;
|
|
83
|
-
format?: string;
|
|
84
112
|
showTime?: boolean;
|
|
85
|
-
|
|
113
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
86
114
|
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
87
|
-
className?: string;
|
|
88
115
|
}
|
|
89
116
|
|
|
90
|
-
const DefaultRangePicker = ({
|
|
91
|
-
|
|
92
|
-
onChange,
|
|
93
|
-
format = "yyyy-MM-dd",
|
|
94
|
-
showTime,
|
|
95
|
-
timeIntervals = 10,
|
|
96
|
-
disabledDate,
|
|
97
|
-
className = "",
|
|
98
|
-
}: RangePickerProps) => {
|
|
99
|
-
const handleStartDateChange = (date?: Date | null) => {
|
|
100
|
-
if (!date) {
|
|
101
|
-
msg.warning("base.selectDateError");
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
onChange([dayjs(date), value[1] ?? dayjs()]);
|
|
105
|
-
};
|
|
106
|
-
const handleEndDateChange = (date?: Date | null) => {
|
|
107
|
-
if (!date) {
|
|
108
|
-
msg.warning("base.selectDateError");
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
onChange([value[0] ?? dayjs(), dayjs(date)]);
|
|
112
|
-
};
|
|
117
|
+
const DefaultRangePicker = ({ className = "", value, onChange, showTime, disabledDate }: RangePickerProps) => {
|
|
118
|
+
const type = showTime ? "datetime-local" : "date";
|
|
113
119
|
|
|
114
|
-
const
|
|
120
|
+
const inputClassName = "m-0 h-full w-full border-none bg-transparent p-3 text-center focus:outline-hidden";
|
|
115
121
|
return (
|
|
116
122
|
<div className={inputRecipe({}, ["flex h-full w-fit items-center gap-2 p-0", className])}>
|
|
117
|
-
<
|
|
118
|
-
className={
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
onChange={
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
filterDate={(date: Date) => (!disabledDate?.(dayjs(date)) ? true : false)}
|
|
127
|
-
dateFormat={format}
|
|
123
|
+
<NativeDateInput
|
|
124
|
+
className={inputClassName}
|
|
125
|
+
type={type}
|
|
126
|
+
value={value[0]}
|
|
127
|
+
max={value[1]}
|
|
128
|
+
disabledDate={disabledDate}
|
|
129
|
+
onChange={(start) => {
|
|
130
|
+
onChange([start, value[1] ?? dayjs()]);
|
|
131
|
+
}}
|
|
128
132
|
/>
|
|
129
133
|
<AiOutlineSwapRight className="text-3xl text-muted-foreground" />
|
|
130
|
-
<
|
|
131
|
-
className={
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
onChange={
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
filterDate={(date: Date) =>
|
|
140
|
-
!disabledDate?.(dayjs(date)) && value[0] && !dayjs(date).add(1, "day").isBefore(value[0]) ? true : false
|
|
141
|
-
}
|
|
142
|
-
filterTime={(time: Date) => !!value[0] && dayjs(time).isAfter(value[0])}
|
|
143
|
-
dateFormat={format}
|
|
134
|
+
<NativeDateInput
|
|
135
|
+
className={inputClassName}
|
|
136
|
+
type={type}
|
|
137
|
+
value={value[1]}
|
|
138
|
+
min={value[0]}
|
|
139
|
+
disabledDate={disabledDate}
|
|
140
|
+
onChange={(end) => {
|
|
141
|
+
onChange([value[0] ?? dayjs(), end]);
|
|
142
|
+
}}
|
|
144
143
|
/>
|
|
145
144
|
</div>
|
|
146
145
|
);
|
|
147
146
|
};
|
|
148
147
|
|
|
149
148
|
export interface TimePickerProps {
|
|
149
|
+
className?: string;
|
|
150
150
|
value: Dayjs | null;
|
|
151
151
|
onChange: (value: Dayjs) => void;
|
|
152
|
-
format?: string;
|
|
153
|
-
timeIntervals?: number;
|
|
154
|
-
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
155
|
-
className?: string;
|
|
156
152
|
disabled?: boolean;
|
|
153
|
+
/** Rejected on selection rather than greyed out — a native field constrains only through `min` / `max`. */
|
|
154
|
+
disabledDate?: (date: Dayjs) => boolean | null | undefined;
|
|
157
155
|
}
|
|
158
156
|
|
|
159
|
-
const DefaultTimePicker = ({
|
|
160
|
-
disabled,
|
|
161
|
-
className,
|
|
162
|
-
value,
|
|
163
|
-
format = "HH:mm",
|
|
164
|
-
onChange,
|
|
165
|
-
timeIntervals = 10,
|
|
166
|
-
}: TimePickerProps) => {
|
|
167
|
-
const handleDateChange = (date?: Date | null) => {
|
|
168
|
-
if (!date) {
|
|
169
|
-
msg.warning("base.selectDateError");
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
onChange(dayjs(date));
|
|
173
|
-
};
|
|
174
|
-
|
|
157
|
+
const DefaultTimePicker = ({ className, value, onChange, disabled, disabledDate }: TimePickerProps) => {
|
|
175
158
|
return (
|
|
176
|
-
<
|
|
177
|
-
wrapperClassName="inline-block"
|
|
159
|
+
<NativeDateInput
|
|
178
160
|
className={cn("inline-block w-auto", className)}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
showTimeSelect
|
|
182
|
-
showTimeSelectOnly
|
|
183
|
-
timeIntervals={timeIntervals}
|
|
184
|
-
timeCaption="Time"
|
|
185
|
-
dateFormat={format}
|
|
161
|
+
type="time"
|
|
162
|
+
value={value}
|
|
186
163
|
disabled={disabled}
|
|
164
|
+
disabledDate={disabledDate}
|
|
165
|
+
onChange={onChange}
|
|
187
166
|
/>
|
|
188
167
|
);
|
|
189
168
|
};
|
|
@@ -194,9 +173,12 @@ const DatePickerBase = createOverridable("DatePicker", DefaultDatePicker);
|
|
|
194
173
|
* Date picker. `DatePicker`, `DatePicker.RangePicker`, and `DatePicker.TimePicker` each resolve to a
|
|
195
174
|
* route-scoped override when a `page/**\/_overrides.tsx` in the route's ancestry declares one (slots
|
|
196
175
|
* `DatePicker`, `DatePickerRangePicker`, `DatePickerTimePicker`).
|
|
176
|
+
*
|
|
177
|
+
* Each renders one native `<input type="date" | "datetime-local" | "time">`, so the calendar is the browser's
|
|
178
|
+
* own: an OS wheel on mobile, keyboard entry everywhere, nothing shipped in the bundle. What that costs is a
|
|
179
|
+
* themed popup, a chosen display format, and per-day disabling — an app needing any of those overrides the slot.
|
|
197
180
|
*/
|
|
198
181
|
export const DatePicker = Object.assign(DatePickerBase, {
|
|
199
182
|
RangePicker: createOverridable("DatePickerRangePicker", DefaultRangePicker),
|
|
200
183
|
TimePicker: createOverridable("DatePickerTimePicker", DefaultTimePicker),
|
|
201
184
|
});
|
|
202
|
-
|
package/ui/Model/AdminPanel.tsx
CHANGED
|
@@ -45,12 +45,14 @@ export default function AdminPanel<
|
|
|
45
45
|
),
|
|
46
46
|
|
|
47
47
|
renderDashboard = summaryColumns?.length
|
|
48
|
-
? ({ summary, hidePresents }) => (
|
|
48
|
+
? ({ summary, hidePresents, onSelect, queryKey }) => (
|
|
49
49
|
<Data.Dashboard
|
|
50
50
|
summary={summary}
|
|
51
51
|
slice={slice}
|
|
52
52
|
columns={summaryColumns}
|
|
53
53
|
queryMap={queryMap}
|
|
54
|
+
onSelect={onSelect}
|
|
55
|
+
queryKey={queryKey}
|
|
54
56
|
hidePresents={hidePresents}
|
|
55
57
|
/>
|
|
56
58
|
)
|
package/ui/styles.css
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
기본 theme="css"는 data-theme 미부착 → :root 가 기본(dark). [data-theme="light"] override. */
|
|
15
15
|
:root,
|
|
16
16
|
[data-theme="dark"] {
|
|
17
|
+
/* Native controls the app does not paint — the date/time field's calendar popup, scrollbars, the caret —
|
|
18
|
+
take their colours from this and nothing else. */
|
|
19
|
+
color-scheme: dark;
|
|
17
20
|
--background: #0a0a0a;
|
|
18
21
|
--foreground: #fafafa;
|
|
19
22
|
--primary: #fafafa;
|
|
@@ -46,6 +49,7 @@
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
[data-theme="light"] {
|
|
52
|
+
color-scheme: light;
|
|
49
53
|
--background: #ffffff;
|
|
50
54
|
--foreground: #0a0a0a;
|
|
51
55
|
--primary: #171717;
|