akanjs 3.0.0-alpha.61 → 3.0.0-alpha.63

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.
@@ -0,0 +1,35 @@
1
+ import { FIELD_META } from "akanjs/base";
2
+ import { ConstantRegistry } from "./constantRegistry";
3
+ import type { FieldObject } from "./fieldInfo";
4
+
5
+ /**
6
+ * The query a field's value stands for, declared on the field itself with `.meta(...)`. A summary counter is the
7
+ * case this exists for: `hau` is however many rows one filter returns, so the tile showing it can open that exact
8
+ * listing without the page restating the filter beside it.
9
+ *
10
+ * The shape is read structurally rather than by class, so an app declares it with whatever builder it already has.
11
+ */
12
+ export interface FieldQueryMeta {
13
+ /** The model the query runs against. */
14
+ refName: string;
15
+ /** One of that model's declared filter keys. */
16
+ queryKey: string | null;
17
+ /** Read when the query is applied, so an arg relative to now — `() => [dayjs().subtract(1, "hour")]` — is current. */
18
+ queryArgs?: unknown[] | (() => unknown[]);
19
+ }
20
+
21
+ const isFieldQueryMeta = (meta: unknown): meta is FieldQueryMeta => {
22
+ if (!meta || typeof meta !== "object") return false;
23
+ const { refName, queryKey, queryArgs } = meta as Record<string, unknown>;
24
+ if (typeof refName !== "string" || !refName) return false;
25
+ if (queryKey !== null && typeof queryKey !== "string") return false;
26
+ return queryArgs === undefined || Array.isArray(queryArgs) || typeof queryArgs === "function";
27
+ };
28
+
29
+ /** The query one field of `refName` names, or nothing when the model, the field, or the declaration is absent. */
30
+ export const fieldQueryMetaOf = (refName: string, field: string): FieldQueryMeta | undefined => {
31
+ const cnst = ConstantRegistry.getDatabase(refName, { allowEmpty: true });
32
+ const fieldMap = cnst?.full[FIELD_META] as FieldObject | undefined;
33
+ const meta = fieldMap?.[field]?.meta;
34
+ return isFieldQueryMeta(meta) ? meta : undefined;
35
+ };
package/constant/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./constantRegistry";
3
3
  export * from "./crystalize";
4
4
  export * from "./deserialize";
5
5
  export * from "./fieldInfo";
6
+ export * from "./fieldQueryMeta";
6
7
  export * from "./getDefault";
7
8
  export * from "./immerify";
8
9
  export * from "./labelOf";
@@ -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") searchParams.set(arg.name, JSON.stringify(argValue));
119
- else if (arg.arrDepth && Array.isArray(argValue))
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
- args?: unknown[];
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<
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.61",
3
+ "version": "3.0.0-alpha.63",
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
  },
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The query a field's value stands for, declared on the field itself with `.meta(...)`. A summary counter is the
3
+ * case this exists for: `hau` is however many rows one filter returns, so the tile showing it can open that exact
4
+ * listing without the page restating the filter beside it.
5
+ *
6
+ * The shape is read structurally rather than by class, so an app declares it with whatever builder it already has.
7
+ */
8
+ export interface FieldQueryMeta {
9
+ /** The model the query runs against. */
10
+ refName: string;
11
+ /** One of that model's declared filter keys. */
12
+ queryKey: string | null;
13
+ /** Read when the query is applied, so an arg relative to now — `() => [dayjs().subtract(1, "hour")]` — is current. */
14
+ queryArgs?: unknown[] | (() => unknown[]);
15
+ }
16
+ /** The query one field of `refName` names, or nothing when the model, the field, or the declaration is absent. */
17
+ export declare const fieldQueryMetaOf: (refName: string, field: string) => FieldQueryMeta | undefined;
@@ -3,6 +3,7 @@ export * from "./constantRegistry.d.ts";
3
3
  export * from "./crystalize.d.ts";
4
4
  export * from "./deserialize.d.ts";
5
5
  export * from "./fieldInfo.d.ts";
6
+ export * from "./fieldQueryMeta.d.ts";
6
7
  export * from "./getDefault.d.ts";
7
8
  export * from "./immerify.d.ts";
8
9
  export * from "./labelOf.d.ts";
@@ -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
- args?: unknown[];
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,23 @@ 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
- /** Columns that narrow the listing when clicked. A column absent from the map renders as a plain tile. */
7
+ /**
8
+ * Overrides the filter a column applies. A column left out still narrows the listing when its own field
9
+ * declares a query with `.meta(...)`; one with neither renders as a plain tile.
10
+ */
7
11
  queryMap?: {
8
12
  [column: string]: QuerySetting;
9
13
  };
14
+ /** Model whose fields the columns name. Its `.meta(...)` declarations are where a tile's filter comes from. */
15
+ summaryRefName?: string;
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;
10
20
  columns?: string[];
11
21
  presents?: string[];
12
22
  hidePresents?: boolean;
13
23
  }
14
- export default function Dashboard<T extends string, State>({ className, summary, slice, queryMap, columns, presents, hidePresents, }: DashboardProps<T, State>): import("react/jsx-runtime").JSX.Element | null;
24
+ export default function Dashboard<T extends string, State>({ className, summary, slice, queryMap, summaryRefName, 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: Required<QuerySetting>) => void;
8
+ onApply?: (setting: ResolvedQuerySetting) => void;
9
9
  }
10
10
  export interface QueryMakerState {
11
11
  queryKeys: string[];
12
12
  args: SerializedArg[];
13
- setting: Required<QuerySetting>;
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
  /**
@@ -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
- format?: string;
7
- timeIntervals?: number;
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
- timeIntervals?: number;
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>;
@@ -21,10 +21,15 @@ interface AdminPanelProps<T extends string, State, Input, Full extends {
21
21
  template?: any;
22
22
  unit?: any;
23
23
  view?: any;
24
- /** Summary column to filter query. Only a column this map names becomes a link to its filtered listing. */
24
+ /**
25
+ * Overrides the filter a summary column applies. A column left out still narrows the listing when its own
26
+ * field declares one with `.meta(...)`.
27
+ */
25
28
  queryMap?: {
26
29
  [column: string]: QuerySetting;
27
30
  };
31
+ /** Model whose fields `summaryColumns` name. Defaults to the `summary` model the state key already implies. */
32
+ summaryRefName?: string;
28
33
  /** Keys of the app's `summary` state shown as dashboard tiles above the list. */
29
34
  summaryColumns?: string[];
30
35
  /** Model insight keys shown above the list. The header already carries the total count. */
@@ -34,5 +39,5 @@ export default function AdminPanel<RefName extends string, State, Input, Full ex
34
39
  id: string;
35
40
  }, Light extends {
36
41
  id: string;
37
- }>({ slice, components, queryMap, template, unit, view, summaryColumns, insightColumns, renderInsight, renderDashboard, ...props }: AdminPanelProps<RefName, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
42
+ }>({ slice, components, queryMap, summaryRefName, template, unit, view, summaryColumns, insightColumns, renderInsight, renderDashboard, ...props }: AdminPanelProps<RefName, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
38
43
  export {};
@@ -1,17 +1,28 @@
1
1
  "use client";
2
2
  import { cn, usePage } from "akanjs/client";
3
+ import { fieldQueryMetaOf } from "akanjs/constant";
3
4
  import type { QuerySetting, SliceMeta } from "akanjs/fetch";
4
5
  import { st } from "akanjs/store";
6
+ import { useState } from "react";
5
7
 
6
- import { Link } from "../Link";
7
8
  import { dictLabel, formatStat } from "./dataText";
8
9
 
9
10
  export interface DashboardProps<T extends string, State> {
10
11
  className?: string;
11
12
  summary: Record<string, unknown>;
13
+ /** The listing these tiles belong to. Kept so a caller names its target; the labels are app-level keys. */
12
14
  slice: SliceMeta;
13
- /** Columns that narrow the listing when clicked. A column absent from the map renders as a plain tile. */
15
+ /**
16
+ * Overrides the filter a column applies. A column left out still narrows the listing when its own field
17
+ * declares a query with `.meta(...)`; one with neither renders as a plain tile.
18
+ */
14
19
  queryMap?: { [column: string]: QuerySetting };
20
+ /** Model whose fields the columns name. Its `.meta(...)` declarations are where a tile's filter comes from. */
21
+ summaryRefName?: string;
22
+ /** Applies one column's filter. Without it a mapped column still renders, but as a plain tile. */
23
+ onSelect?: (setting: QuerySetting, column: string) => void;
24
+ /** The filter key the listing is showing, so a tile stops looking active once the toolbar moves off it. */
25
+ queryKey?: string;
15
26
  columns?: string[];
16
27
  presents?: string[];
17
28
  hidePresents?: boolean;
@@ -24,40 +35,62 @@ export default function Dashboard<T extends string, State>({
24
35
  summary,
25
36
  slice,
26
37
  queryMap,
38
+ summaryRefName = "summary",
39
+ onSelect,
40
+ queryKey,
27
41
  columns,
28
42
  presents,
29
43
  hidePresents,
30
44
  }: DashboardProps<T, State>) {
31
- const { refName } = slice;
32
45
  const { l } = usePage();
33
46
  const searchParams = st.use.searchParams({ agent: false });
34
47
  const filter = Array.isArray(searchParams.filter) ? searchParams.filter[0] : searchParams.filter;
48
+
49
+ const [selected, setSelected] = useState(typeof filter === "string" ? filter : undefined);
50
+
51
+ const settingOf = (column: string): QuerySetting | undefined => {
52
+ if (queryMap?.[column]) return queryMap[column];
53
+ const meta = fieldQueryMetaOf(summaryRefName, column);
54
+ if (!meta?.queryKey || meta.refName !== slice.refName) return undefined;
55
+ return { queryKey: meta.queryKey, args: meta.queryArgs };
56
+ };
35
57
  const shownColumns = (columns ?? []).filter((column) => summary[column] !== undefined);
36
58
  const shownPresents = hidePresents ? [] : (presents ?? []).filter((column) => summary[column] !== undefined);
37
59
  if (!shownColumns.length && !shownPresents.length) return null;
60
+ const activeColumn = selected && settingOf(selected)?.queryKey === queryKey ? selected : undefined;
38
61
  return (
39
62
  <div className={cn("mb-4 flex flex-wrap gap-2", className)}>
40
63
  {[...shownColumns, ...shownPresents].map((column) => {
41
- const linkable = queryMap?.[column] !== undefined;
42
- return (
43
- <Link
44
- key={column}
45
- disabled={!linkable}
46
- href={`/admin?topMenu=data&subMenu=${refName}&filter=${column}`}
64
+ const setting = settingOf(column);
65
+ const label = dictLabel(l._, `summary.${column}`, column);
66
+ const body = (
67
+ <>
68
+ <span className="truncate text-muted-foreground text-xs">{label}</span>
69
+ <span className="truncate font-semibold text-2xl text-primary tabular-nums">
70
+ {formatStat(summary[column])}
71
+ </span>
72
+ </>
73
+ );
74
+ return setting && onSelect ? (
75
+ <button
47
76
  className={cn(
48
77
  tileClassName,
49
- "bg-card",
50
- linkable && "transition hover:border-primary/50",
51
- filter === column && linkable ? "border-primary" : "border-border",
78
+ "bg-card transition hover:border-primary/50",
79
+ activeColumn === column ? "border-primary" : "border-border",
52
80
  )}
81
+ key={column}
82
+ onClick={() => {
83
+ setSelected(column);
84
+ onSelect(setting, column);
85
+ }}
86
+ type="button"
53
87
  >
54
- <span className="truncate text-muted-foreground text-xs">
55
- {dictLabel(l._, `summary.${column}`, column)}
56
- </span>
57
- <span className="truncate font-semibold text-2xl text-primary tabular-nums">
58
- {formatStat(summary[column])}
59
- </span>
60
- </Link>
88
+ {body}
89
+ </button>
90
+ ) : (
91
+ <div className={cn(tileClassName, "border-border bg-card")} key={column}>
92
+ {body}
93
+ </div>
61
94
  );
62
95
  })}
63
96
  </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) [queryArgs[0], queryArgs[1]] = [initQuery.queryKey, initQuery.args ?? []];
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
- const summary = storeSel<Record<string, unknown> | undefined>(
259
- (state) => (state as { summary?: Record<string, unknown> }).summary,
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 : <ModelDashboard />}
419
+ {query ? null : modelDashboard}
405
420
  {queryMakerArgs}
406
421
  <RenderInsight />
407
422
  {view === "card" ? (
@@ -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: Required<QuerySetting>) => void;
19
+ onApply?: (setting: ResolvedQuerySetting) => void;
20
20
  }
21
21
  export interface QueryMakerState {
22
22
  queryKeys: string[];
23
23
  args: SerializedArg[];
24
- setting: Required<QuerySetting>;
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<Required<QuerySetting>>({
62
- queryKey: query?.queryKey ?? queryKeys[0] ?? "any",
63
- args: query?.args ?? [],
64
- });
74
+ const [setting, setSetting] = useState<ResolvedQuerySetting>(
75
+ query ? resolveQuerySetting(query) : { queryKey: queryKeys[0] ?? "any", args: [] },
76
+ );
65
77
 
66
- const applyRef = useRef<(setting: Required<QuerySetting>) => void>(() => undefined);
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: Required<QuerySetting>) => {
84
+ const applyQuery = useDebounce((setting: ResolvedQuerySetting) => {
73
85
  applyRef.current(setting);
74
86
  }, []);
75
- const update = (setting: Required<QuerySetting>) => {
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
 
@@ -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: Required<QuerySetting> = { queryKey: initialKey, args: [] };
41
+ const initialQuery: ResolvedQuerySetting = { queryKey: initialKey, args: [] };
43
42
 
44
43
  const [open, setOpen] = useState(false);
45
- const [applied, setApplied] = useState<Required<QuerySetting>>(initialQuery);
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 { lazy } from "akanjs/webkit";
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
- const reactDatePickerPackage = "react-datepicker";
12
- const reactDatePickerStylePath = "react-datepicker/dist/react-datepicker.css";
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
- format?: string;
26
- timeIntervals?: number;
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
- format = "yyyy-MM-dd",
38
- timeIntervals = 10,
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
- <ReactDatePicker
96
+ <NativeDateInput
62
97
  className={inputRecipe({}, ["text-center", className])}
63
- selected={value ? value.toDate() : new Date()}
64
- disabledKeyboardNavigation
65
- onFocus={(e: FocusEvent<HTMLInputElement>) => {
66
-
67
- if (clickNum.current % 2 === 0) e.target.blur();
68
- clickNum.current++;
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
- timeIntervals?: number;
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
- value,
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 pickerClassName = "m-0 h-full w-full border-none bg-transparent p-3 text-center focus:outline-hidden z-50";
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
- <ReactDatePicker
118
- className={pickerClassName}
119
- selected={value[0] ? value[0].toDate() : undefined}
120
- selectsStart
121
- startDate={value[0] ? value[0].toDate() : undefined}
122
- endDate={value[1] ? value[1].toDate() : undefined}
123
- onChange={handleStartDateChange}
124
- showTimeSelect={showTime}
125
- timeIntervals={timeIntervals}
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
- <ReactDatePicker
131
- className={pickerClassName}
132
- selected={value[1] ? value[1].toDate() : undefined}
133
- selectsEnd
134
- startDate={value[0] ? value[0].toDate() : undefined}
135
- endDate={value[1] ? value[1].toDate() : undefined}
136
- onChange={handleEndDateChange}
137
- showTimeSelect={showTime}
138
- timeIntervals={timeIntervals}
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
- <ReactDatePicker
177
- wrapperClassName="inline-block"
159
+ <NativeDateInput
178
160
  className={cn("inline-block w-auto", className)}
179
- selected={value ? value.toDate() : new Date()}
180
- onChange={handleDateChange}
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
-
@@ -17,8 +17,13 @@ interface AdminPanelProps<T extends string, State, Input, Full extends { id: str
17
17
  template?: any;
18
18
  unit?: any;
19
19
  view?: any;
20
- /** Summary column to filter query. Only a column this map names becomes a link to its filtered listing. */
20
+ /**
21
+ * Overrides the filter a summary column applies. A column left out still narrows the listing when its own
22
+ * field declares one with `.meta(...)`.
23
+ */
21
24
  queryMap?: { [column: string]: QuerySetting };
25
+ /** Model whose fields `summaryColumns` name. Defaults to the `summary` model the state key already implies. */
26
+ summaryRefName?: string;
22
27
  /** Keys of the app's `summary` state shown as dashboard tiles above the list. */
23
28
  summaryColumns?: string[];
24
29
  /** Model insight keys shown above the list. The header already carries the total count. */
@@ -35,6 +40,7 @@ export default function AdminPanel<
35
40
  slice,
36
41
  components,
37
42
  queryMap,
43
+ summaryRefName,
38
44
  template,
39
45
  unit,
40
46
  view,
@@ -45,12 +51,15 @@ export default function AdminPanel<
45
51
  ),
46
52
 
47
53
  renderDashboard = summaryColumns?.length
48
- ? ({ summary, hidePresents }) => (
54
+ ? ({ summary, hidePresents, onSelect, queryKey }) => (
49
55
  <Data.Dashboard
50
56
  summary={summary}
51
57
  slice={slice}
52
58
  columns={summaryColumns}
53
59
  queryMap={queryMap}
60
+ summaryRefName={summaryRefName}
61
+ onSelect={onSelect}
62
+ queryKey={queryKey}
54
63
  hidePresents={hidePresents}
55
64
  />
56
65
  )
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;