@protobase/ui 0.22.5 → 0.22.7

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/dist/app/app.js CHANGED
@@ -29,6 +29,8 @@ import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
29
29
  import { breadcrumbFor } from "./breadcrumb-for.js";
30
30
  import { ComposedPage } from "./pages/composed-page.js";
31
31
  import { CreateRoute } from "./create/create-route.js";
32
+ import { userMenuFromMeta } from "./user-menu-from-meta.js";
33
+ import { navFromMeta, navItemId } from "./nav-from-meta.js";
32
34
  import { ListPage } from "./list-page.js";
33
35
  import { MembersPage } from "../organizations/members-page.js";
34
36
  import { InvitationsPage } from "../organizations/invitations-page.js";
@@ -37,8 +39,6 @@ import { OrganizationPage } from "../organizations/organization-page.js";
37
39
  import { OrganizationsPage } from "../organizations/organizations-page.js";
38
40
  import { actingUser } from "../roles/acting-user.js";
39
41
  import { useActiveRole } from "../roles/use-active-role.js";
40
- import { userMenuFromMeta } from "./user-menu-from-meta.js";
41
- import { navFromMeta } from "./nav-from-meta.js";
42
42
  import { ProjectLogo } from "./project-logo.js";
43
43
  import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
44
44
  import { PluginNotice } from "../plugins/plugin-notice.js";
@@ -50,8 +50,13 @@ import { StaffBanner } from "./staff-banner.js";
50
50
  import { useNavRecent } from "./use-nav-recent.js";
51
51
  import { useCallback, useEffect, useState } from "react";
52
52
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
53
+ import { viewOfList } from "@protobase/schema/internal";
53
54
  //#region src/app/app.tsx
54
- const placeOf = (address) => address?.kind === "page" ? address.page : address?.resource;
55
+ const placeOf = (address) => {
56
+ if (address?.kind === "page") return address.page;
57
+ if (address?.kind === "list") return navItemId(address.resource, address.list);
58
+ return address?.resource;
59
+ };
55
60
  const recordOf = (address) => address?.kind === "record" ? address : void 0;
56
61
  const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
57
62
  const meta = useAdminMeta();
@@ -67,7 +72,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
67
72
  const search = useGlobalSearch();
68
73
  const theme = useTheme();
69
74
  const recent = useNavRecent(meta, basePath, {
70
- resource: place,
75
+ resource: shown?.resource,
71
76
  key: shown?.key
72
77
  });
73
78
  const groups = navFromMeta(meta, basePath, recent);
@@ -92,7 +97,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
92
97
  ]);
93
98
  const resource = address?.kind === "page" ? void 0 : address?.resource;
94
99
  const drawnFrom = resource ? sourceStatusOf(meta, resource) : void 0;
95
- const view = resource ? meta.views[resource] : void 0;
100
+ const view = resource ? viewOfList(meta.views[resource], address?.kind === "list" ? address.list : void 0) : void 0;
96
101
  const group = groups.find((entry) => entry.items.some((item) => item.id === place));
97
102
  const model = resource ? meta.resources[resource] : void 0;
98
103
  const record = useOpenedRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
@@ -183,7 +188,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
183
188
  }, address.resource) : address.kind === "record" ? /* @__PURE__ */ jsx(RecordPage, {
184
189
  resource: address.resource,
185
190
  recordKey: address.key
186
- }, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, address.resource);
191
+ }, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, navItemId(address.resource, address.list));
187
192
  const appShell = /* @__PURE__ */ jsx(AppShell, {
188
193
  pageScroll: true,
189
194
  sidebarMode: sidebarMode ?? "text-small",
@@ -15,7 +15,8 @@ const breadcrumbFor = ({ basePath, address, group, page, resourceLabel, recordTi
15
15
  label: resourceLabel,
16
16
  href: appPath({
17
17
  kind: "list",
18
- resource: address.resource
18
+ resource: address.resource,
19
+ ...address.kind === "list" && address.list && { list: address.list }
19
20
  }, basePath)
20
21
  }] : [];
21
22
  if (address.kind === "list") return [...groupCrumb, ...resource];
@@ -49,4 +49,4 @@ const measureText = (value, measure) => {
49
49
  return `${measure.hints?.prefix ?? ""}${text}${measure.hints?.suffix ?? ""}`;
50
50
  };
51
51
  //#endregion
52
- export { groupFilter, measureText, periodPoints, valueSlices };
52
+ export { colorOf, groupFilter, labelOf, measureText, periodPoints, valueSlices };
@@ -7,6 +7,7 @@ import { periodPoints, valueSlices } from "./chart-groups.js";
7
7
  import { GroupBars } from "./group-bars.js";
8
8
  import { GroupDonut } from "./group-donut.js";
9
9
  import { PeriodChart } from "./period-chart.js";
10
+ import { splitPoints } from "./split-points.js";
10
11
  import { jsx } from "react/jsx-runtime";
11
12
  //#region src/app/charts/chart-view.tsx
12
13
  const measureOf = (model, view, sum, average) => {
@@ -19,20 +20,31 @@ const measureOf = (model, view, sum, average) => {
19
20
  hints: view?.fields[name]
20
21
  };
21
22
  };
23
+ const labelOf = (field, resources, views) => {
24
+ const target = field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
25
+ return target && target.primaryKey.length === 1 ? labelFieldOf(target, views[target.name]) : void 0;
26
+ };
22
27
  /**
23
28
  * Loads a chart's groups through `:aggregate` and draws them: a date field's buckets as bars or a line, a field's
24
29
  * values as ranked bars or a donut, each value linking to its records. The server checked the chart at startup.
25
30
  */
26
- const ChartView = ({ model, view, groupBy, type = "bar", sum, average, filter = "", granularity = "day", range = "30d", limit = 10, empty = "Nothing to chart yet." }) => {
31
+ const ChartView = ({ model, view, groupBy, splitBy, type = "bar", sum, average, filter = "", granularity = "day", range = "30d", limit = 10, empty = "Nothing to chart yet." }) => {
27
32
  const { resources, views } = useAdminMeta();
28
33
  const field = model.fields[groupBy];
29
34
  const dated = field?.type === "date" || field?.type === "timestamp";
30
- const target = field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
31
- const label = target && target.primaryKey.length === 1 ? labelFieldOf(target, views[target.name]) : void 0;
35
+ const label = labelOf(field, resources, views);
36
+ const splitField = dated && splitBy !== void 0 ? model.fields[splitBy] : void 0;
37
+ const splitLabel = labelOf(splitField, resources, views);
38
+ const split = splitField ? {
39
+ splitBy: splitField.name,
40
+ splitLimit: 8,
41
+ ...splitLabel && { splitLabel }
42
+ } : {};
32
43
  const params = dated ? {
33
44
  granularity,
34
45
  range,
35
- timeZone: viewerZone()
46
+ timeZone: viewerZone(),
47
+ ...split
36
48
  } : {
37
49
  limit,
38
50
  ...label && { label }
@@ -56,11 +68,24 @@ const ChartView = ({ model, view, groupBy, type = "bar", sum, average, filter =
56
68
  className: "text-muted-foreground",
57
69
  children: empty
58
70
  });
71
+ const chartType = type === "line" ? "line" : "bar";
72
+ if (dated && splitField && result.data.splits) {
73
+ const { series, points } = splitPoints(groups, result.data.splits, splitField, view?.fields[splitField.name], measure, filter);
74
+ return /* @__PURE__ */ jsx(PeriodChart, {
75
+ points,
76
+ measure,
77
+ bucket: granularity,
78
+ type: chartType,
79
+ series,
80
+ resource: model.name,
81
+ view
82
+ });
83
+ }
59
84
  if (dated) return /* @__PURE__ */ jsx(PeriodChart, {
60
85
  points: periodPoints(groups, measure),
61
86
  measure,
62
87
  bucket: granularity,
63
- type: type === "line" ? "line" : "bar"
88
+ type: chartType
64
89
  });
65
90
  const slices = valueSlices(groups, field, view?.fields[groupBy], measure, filter);
66
91
  return type === "donut" ? /* @__PURE__ */ jsx(GroupDonut, {
@@ -1,6 +1,5 @@
1
- import { AppLink } from "../use-app-address.js";
2
1
  import { measureText } from "./chart-groups.js";
3
- import { filteredListAddress } from "../list-link.js";
2
+ import { LegendItem } from "./legend-item.js";
4
3
  import { jsx, jsxs } from "react/jsx-runtime";
5
4
  import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
6
5
  //#region src/app/charts/group-donut.tsx
@@ -51,29 +50,20 @@ const GroupDonut = ({ resource, view, slices, measure }) => {
51
50
  })
52
51
  }), /* @__PURE__ */ jsx("ul", {
53
52
  className: "flex w-full min-w-0 flex-col gap-0.5",
54
- children: slices.map((slice) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
55
- to: filteredListAddress(view, resource, slice.filter),
56
- className: "group flex items-center gap-2 rounded-md px-1 py-0.5 hover:bg-muted",
57
- children: [
58
- /* @__PURE__ */ jsx("span", {
59
- className: "size-2.5 shrink-0 rounded-full",
60
- style: { background: slice.color },
61
- "aria-hidden": true
62
- }),
63
- /* @__PURE__ */ jsx("span", {
64
- className: "min-w-0 flex-1 truncate group-hover:text-primary-text",
65
- children: slice.label
66
- }),
67
- /* @__PURE__ */ jsx("span", {
68
- className: "font-medium tabular-nums",
69
- children: measureText(slice.value, measure)
70
- }),
71
- /* @__PURE__ */ jsx("span", {
72
- className: "w-9 text-right text-xs text-muted-foreground tabular-nums",
73
- children: percent(slice.value ?? 0, whole)
74
- })
75
- ]
76
- }) }, slice.key))
53
+ children: slices.map((slice) => /* @__PURE__ */ jsxs(LegendItem, {
54
+ resource,
55
+ view,
56
+ filter: slice.filter,
57
+ color: slice.color,
58
+ label: slice.label,
59
+ children: [/* @__PURE__ */ jsx("span", {
60
+ className: "font-medium tabular-nums",
61
+ children: measureText(slice.value, measure)
62
+ }), /* @__PURE__ */ jsx("span", {
63
+ className: "w-9 text-right text-xs text-muted-foreground tabular-nums",
64
+ children: percent(slice.value ?? 0, whole)
65
+ })]
66
+ }, slice.key))
77
67
  })]
78
68
  });
79
69
  };
@@ -0,0 +1,33 @@
1
+ import { AppLink } from "../use-app-address.js";
2
+ import { filteredListAddress } from "../list-link.js";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app/charts/legend-item.tsx
5
+ const row = "flex items-center gap-2 rounded-md px-1 py-0.5";
6
+ /**
7
+ * One row of a chart legend: a colour dot and the value's name, with `children` after it. With a `filter` the row links to
8
+ * that value's records; without, as for the rest of a split, it is plain text.
9
+ */
10
+ const LegendItem = ({ resource, view, filter, color, label, children }) => {
11
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
12
+ /* @__PURE__ */ jsx("span", {
13
+ className: "size-2.5 shrink-0 rounded-full",
14
+ style: { background: color },
15
+ "aria-hidden": true
16
+ }),
17
+ /* @__PURE__ */ jsx("span", {
18
+ className: "min-w-0 flex-1 truncate group-hover:text-primary-text",
19
+ children: label
20
+ }),
21
+ children
22
+ ] });
23
+ return /* @__PURE__ */ jsx("li", { children: filter === void 0 ? /* @__PURE__ */ jsx("div", {
24
+ className: row,
25
+ children: content
26
+ }) : /* @__PURE__ */ jsx(AppLink, {
27
+ to: filteredListAddress(view, resource, filter),
28
+ className: `group ${row} hover:bg-muted`,
29
+ children: content
30
+ }) });
31
+ };
32
+ //#endregion
33
+ export { LegendItem };
@@ -1,5 +1,6 @@
1
1
  import { useMediaQuery } from "../../lib/use-media-query.js";
2
2
  import { measureText } from "./chart-groups.js";
3
+ import { LegendItem } from "./legend-item.js";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
4
5
  import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
5
6
  //#region src/app/charts/period-chart.tsx
@@ -58,22 +59,47 @@ const axisTick = {
58
59
  fontSize: 11,
59
60
  fill: "var(--muted-foreground)"
60
61
  };
61
- const PointTooltip = ({ active, payload, measure, bucket }) => {
62
+ const PointTooltip = ({ active, payload, measure, bucket, series }) => {
62
63
  const point = payload?.[0]?.payload;
63
64
  if (!active || !point) return null;
64
65
  return /* @__PURE__ */ jsxs("div", {
65
66
  className: "rounded-md border bg-background px-2.5 py-1.5 text-xs shadow-pop",
66
- children: [/* @__PURE__ */ jsx("div", {
67
- className: "text-muted-foreground",
68
- children: (bucket === "week" ? "Week of " : "") + formatterOf(tooltipFormats[bucket])(point.at)
69
- }), /* @__PURE__ */ jsx("div", {
70
- className: "font-semibold tabular-nums",
71
- children: measureText(point.value, measure)
72
- })]
67
+ children: [
68
+ /* @__PURE__ */ jsx("div", {
69
+ className: "text-muted-foreground",
70
+ children: (bucket === "week" ? "Week of " : "") + formatterOf(tooltipFormats[bucket])(point.at)
71
+ }),
72
+ series?.map((part) => /* @__PURE__ */ jsxs("div", {
73
+ className: "flex items-center gap-2",
74
+ children: [
75
+ /* @__PURE__ */ jsx("span", {
76
+ className: "size-2 shrink-0 rounded-full",
77
+ style: { background: part.color },
78
+ "aria-hidden": true
79
+ }),
80
+ /* @__PURE__ */ jsx("span", {
81
+ className: "flex-1",
82
+ children: part.label
83
+ }),
84
+ /* @__PURE__ */ jsx("span", {
85
+ className: "font-semibold tabular-nums",
86
+ children: measureText(point[part.key] ?? null, measure)
87
+ })
88
+ ]
89
+ }, part.key)),
90
+ series && measure.kind !== "average" && /* @__PURE__ */ jsx("div", {
91
+ className: "mt-0.5 border-t pt-0.5 font-semibold tabular-nums",
92
+ children: measureText(point.value, measure)
93
+ }),
94
+ !series && /* @__PURE__ */ jsx("div", {
95
+ className: "font-semibold tabular-nums",
96
+ children: measureText(point.value, measure)
97
+ })
98
+ ]
73
99
  });
74
100
  };
75
101
  /** A date field's buckets over time, as bars or a line; a bucket without a value to add up is a gap. */
76
- const PeriodChart = ({ points, measure, bucket, type }) => {
102
+ const PeriodChart = ({ points, measure, bucket, type, series, resource, view }) => {
77
103
  const wide = useMediaQuery("(min-width: 640px)");
78
104
  const tick = formatterOf(formats[bucket]);
79
105
  const grid = /* @__PURE__ */ jsx(CartesianGrid, {
@@ -101,11 +127,17 @@ const PeriodChart = ({ points, measure, bucket, type }) => {
101
127
  cursor: type === "bar" ? { fill: "var(--muted)" } : { stroke: "var(--border-strong)" },
102
128
  content: /* @__PURE__ */ jsx(PointTooltip, {
103
129
  measure,
104
- bucket
130
+ bucket,
131
+ ...series && { series }
105
132
  }),
106
133
  isAnimationActive: false
107
134
  });
108
- return /* @__PURE__ */ jsx("div", {
135
+ const parts = series ?? [{
136
+ key: "value",
137
+ label: "",
138
+ color: "var(--chart-1)"
139
+ }];
140
+ const chart = /* @__PURE__ */ jsx("div", {
109
141
  className: "h-40",
110
142
  children: /* @__PURE__ */ jsx(ResponsiveContainer, {
111
143
  width: "100%",
@@ -123,13 +155,13 @@ const PeriodChart = ({ points, measure, bucket, type }) => {
123
155
  x,
124
156
  y,
125
157
  tooltip,
126
- /* @__PURE__ */ jsx(Line, {
127
- dataKey: "value",
128
- stroke: "var(--chart-1)",
158
+ parts.map((part) => /* @__PURE__ */ jsx(Line, {
159
+ dataKey: part.key,
160
+ stroke: part.color,
129
161
  strokeWidth: 2,
130
162
  dot: points.length <= 31 ? {
131
163
  r: 3,
132
- fill: "var(--chart-1)",
164
+ fill: part.color,
133
165
  strokeWidth: 0
134
166
  } : false,
135
167
  activeDot: {
@@ -138,7 +170,7 @@ const PeriodChart = ({ points, measure, bucket, type }) => {
138
170
  strokeWidth: 2
139
171
  },
140
172
  isAnimationActive: false
141
- })
173
+ }, part.key))
142
174
  ]
143
175
  }) : /* @__PURE__ */ jsxs(BarChart, {
144
176
  data: points,
@@ -154,21 +186,36 @@ const PeriodChart = ({ points, measure, bucket, type }) => {
154
186
  x,
155
187
  y,
156
188
  tooltip,
157
- /* @__PURE__ */ jsx(Bar, {
158
- dataKey: "value",
159
- fill: "var(--chart-1)",
160
- radius: [
189
+ parts.map((part, index) => /* @__PURE__ */ jsx(Bar, {
190
+ dataKey: part.key,
191
+ fill: part.color,
192
+ ...series && { stackId: "split" },
193
+ radius: index === parts.length - 1 ? [
161
194
  4,
162
195
  4,
163
196
  0,
164
197
  0
165
- ],
198
+ ] : 0,
166
199
  isAnimationActive: false
167
- })
200
+ }, part.key))
168
201
  ]
169
202
  })
170
203
  })
171
204
  });
205
+ if (!series || resource === void 0) return chart;
206
+ return /* @__PURE__ */ jsxs("div", {
207
+ className: "flex flex-col gap-2",
208
+ children: [chart, /* @__PURE__ */ jsx("ul", {
209
+ className: "flex flex-wrap gap-x-3 gap-y-0.5 text-sm",
210
+ children: series.map((part) => /* @__PURE__ */ jsx(LegendItem, {
211
+ resource,
212
+ view,
213
+ color: part.color,
214
+ label: part.label,
215
+ ...part.filter !== void 0 && { filter: part.filter }
216
+ }, part.key))
217
+ })]
218
+ });
172
219
  };
173
220
  //#endregion
174
221
  export { PeriodChart };
@@ -0,0 +1,52 @@
1
+ import { colorOf, groupFilter, labelOf, periodPoints } from "./chart-groups.js";
2
+ //#region src/app/charts/split-points.ts
3
+ const noise = 1e-6;
4
+ /**
5
+ * A date field's buckets with each split into one part per value of another field, in the order the API ranks them.
6
+ * A bucket where a value has no records holds 0 of a count and no value of a sum or average. For a count or a sum, what
7
+ * the kept values leave of the bucket's total is "Other", a series of its own when some bucket has any; an average
8
+ * has no remainder.
9
+ */
10
+ const splitPoints = (groups, splits, field, hints, measure, filter = "") => {
11
+ const series = splits.map((split, index) => ({
12
+ key: `s${index}`,
13
+ label: labelOf(field, hints, split),
14
+ color: colorOf(field, hints, split.value, index),
15
+ filter: groupFilter(field, split.value, filter)
16
+ }));
17
+ const missing = measure.kind === "count" ? 0 : null;
18
+ const partOf = (group, value) => {
19
+ const part = group.splits?.find((candidate) => candidate.value === value);
20
+ if (!part) return missing;
21
+ return measure.kind === "count" ? part.count : part[measure.kind] ?? null;
22
+ };
23
+ const points = periodPoints(groups, measure).map((point, at) => {
24
+ const group = groups[at];
25
+ const parts = splits.map((split) => partOf(group, split.value));
26
+ const entries = Object.fromEntries(parts.map((part, index) => [`s${index}`, part]));
27
+ if (measure.kind === "average") return {
28
+ ...point,
29
+ ...entries
30
+ };
31
+ const rest = point.value === null ? null : point.value - parts.reduce((all, part) => all + (part ?? 0), 0);
32
+ return {
33
+ ...point,
34
+ ...entries,
35
+ other: rest === null ? null : rest < noise ? 0 : rest
36
+ };
37
+ });
38
+ if (!(measure.kind !== "average" && points.some((point) => (point.other ?? 0) > 0))) return {
39
+ series,
40
+ points: points.map(({ other, ...point }) => point)
41
+ };
42
+ return {
43
+ series: [...series, {
44
+ key: "other",
45
+ label: "Other",
46
+ color: "var(--border-strong)"
47
+ }],
48
+ points
49
+ };
50
+ };
51
+ //#endregion
52
+ export { splitPoints };
@@ -7,6 +7,7 @@ import { AppAddress, CalendarView, FilterLayout, MapView, ResourceModel, ViewMod
7
7
  export type ListAddress = {
8
8
  kind: 'list';
9
9
  resource: string;
10
+ list?: string;
10
11
  display: string;
11
12
  filter: string;
12
13
  orderBy: string;
@@ -31,5 +32,5 @@ export type CurrentAddress = ListAddress | {
31
32
  page: string;
32
33
  };
33
34
  /** What can change about the list the app shows; `undefined` returns a value to its default. */
34
- export type ListChange = Partial<Omit<ListAddress, 'kind' | 'resource'>>;
35
+ export type ListChange = Partial<Omit<ListAddress, 'kind' | 'resource' | 'list'>>;
35
36
  //#endregion
@@ -1,14 +1,15 @@
1
1
  import { defaultDisplay, listDefaultDisplay } from "./displays/list-displays.js";
2
2
  import { defaultOrderBy } from "./list-order.js";
3
- import { encodeKey } from "@protobase/schema/internal";
3
+ import { encodeKey, viewOfList } from "@protobase/schema/internal";
4
4
  //#region src/app/current-address.ts
5
5
  /**
6
6
  * The resource whose records a list shows, with its view: the list's own, or, when its view has `listOf`, the resource
7
7
  * that relation points at, if the caller can see it.
8
8
  */
9
- const listedResource = (resources, views, resource) => {
9
+ const listedResource = (resources, views, resource, list) => {
10
10
  const model = resources[resource];
11
- const listOf = views[resource]?.listOf;
11
+ const own = viewOfList(views[resource], list);
12
+ const listOf = own?.listOf;
12
13
  const target = listOf === void 0 ? void 0 : model?.fields[listOf]?.relation?.resource;
13
14
  return target !== void 0 && resources[target] ? {
14
15
  model: resources[target],
@@ -16,7 +17,7 @@ const listedResource = (resources, views, resource) => {
16
17
  listOf: true
17
18
  } : {
18
19
  model,
19
- view: views[resource],
20
+ view: own,
20
21
  listOf: false
21
22
  };
22
23
  };
@@ -38,7 +39,7 @@ const currentAddress = (read, defaults) => {
38
39
  };
39
40
  if (read?.kind !== "list") return read;
40
41
  return {
41
- ...defaults(read.resource),
42
+ ...defaults(read.resource, read.list),
42
43
  ...read
43
44
  };
44
45
  };
@@ -52,6 +53,7 @@ const changedList = (read, change, defaults) => {
52
53
  return {
53
54
  kind: "list",
54
55
  resource: read.resource,
56
+ ...read.list && { list: read.list },
55
57
  ...differs("filter") && { filter: next.filter },
56
58
  ...differs("display") && { display: next.display },
57
59
  ...differs("orderBy") && { orderBy: next.orderBy },
@@ -33,6 +33,7 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
33
33
  const [fitting, setFitting] = useState(at === void 0);
34
34
  const records = useMapRecords({
35
35
  model,
36
+ view,
36
37
  display,
37
38
  fields,
38
39
  filter,
@@ -12,7 +12,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
12
12
  * many. A new area or filter keeps the previous answer until its own arrives, and an answer for an area the map has
13
13
  * left is never shown.
14
14
  */
15
- const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
15
+ const useMapRecords = ({ model, view, display, fields, filter, orderBy, area }) => {
16
16
  const client = useClient();
17
17
  const { resources, views } = useAdminMeta();
18
18
  const position = {
@@ -32,7 +32,7 @@ const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
32
32
  filter: inside,
33
33
  orderBy,
34
34
  labels: listLabels(resources, views, model, fields),
35
- photos: listPhotos(views[model.name])
35
+ photos: listPhotos(view)
36
36
  });
37
37
  return {
38
38
  rows: withLabels(resources, model, fields, withPhotos(model, result.items, result.photos), result.labels).map((record) => displayRow(model, record)),
@@ -50,6 +50,7 @@ import { useListScroll } from "./list-scroll.js";
50
50
  import { ListChart } from "./list-chart.js";
51
51
  import { ListGroupChart } from "./list-group-chart.js";
52
52
  import { ListSearch } from "./list-search.js";
53
+ import { navItemId } from "./nav-from-meta.js";
53
54
  import { withRelatedDotsColumns } from "./related-dots/related-dots-columns.js";
54
55
  import { SavedList } from "./saved/saved-list.js";
55
56
  import { SaveSearchButton } from "./searches/save-search-button.js";
@@ -62,7 +63,7 @@ import { useMemo, useState } from "react";
62
63
  import { Download, Plus, SlidersHorizontal } from "lucide-react";
63
64
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
64
65
  import { createWhere } from "@protobase/schema";
65
- import { isGroupChart, tableDisplay } from "@protobase/schema/internal";
66
+ import { isGroupChart, tableDisplay, viewOfList } from "@protobase/schema/internal";
66
67
  //#region src/app/list-page.tsx
67
68
  const where$1 = createWhere();
68
69
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
@@ -86,12 +87,20 @@ const ListPage = ({ address }) => {
86
87
  "."
87
88
  ]
88
89
  });
89
- const view = views[address.resource];
90
+ const view = viewOfList(views[address.resource], address.list);
91
+ if (address.list !== void 0 && !view) return /* @__PURE__ */ jsxs(Notice, {
92
+ title: "Not found",
93
+ children: [
94
+ "There is no list called ",
95
+ address.list,
96
+ "."
97
+ ]
98
+ });
90
99
  if (view?.searchesOf) return /* @__PURE__ */ jsx(SavedSearches, {
91
100
  model,
92
101
  view
93
102
  });
94
- const listed = listedResource(resources, views, address.resource);
103
+ const listed = listedResource(resources, views, address.resource, address.list);
95
104
  if (view && listed.listOf) {
96
105
  const target = listed.model;
97
106
  return /* @__PURE__ */ jsx(SavedList, {
@@ -118,7 +127,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
118
127
  const meta = useAdminMeta();
119
128
  const { set, listTo, go, href } = useAppAddress();
120
129
  const resource = model.name;
121
- const view = meta.views[resource];
130
+ const view = viewOfList(meta.views[resource], address.list);
122
131
  const fields = useMemo(() => widgetFields(view, model), [view, model]);
123
132
  const { fields: fieldTypes } = useProjectUi();
124
133
  const columns = useMemo(() => withRelatedDotsColumns(withFieldTypeCells(columnsFromModel(model, view), model, view, fieldTypes), model, view, meta.resources, meta.views), [
@@ -377,7 +386,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
377
386
  onRun: (rows) => remove.startBulk(rows.map(target))
378
387
  }] : []],
379
388
  rowNumbers: false,
380
- pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
389
+ pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, navItemId(resource, address.list)) : void 0,
381
390
  scroll: "parent",
382
391
  className: "flex-1"
383
392
  }) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
@@ -5,6 +5,8 @@ import { userMenuResources } from "./user-menu-from-meta.js";
5
5
  import { LayoutDashboard } from "lucide-react";
6
6
  import { appPath } from "@protobase/schema";
7
7
  //#region src/app/nav-from-meta.ts
8
+ /** The sidebar item of a resource's list, or of one of its named lists. */
9
+ const navItemId = (resource, list) => list === void 0 ? resource : `${resource}/${list}`;
8
10
  const groupLabels = {
9
11
  sales: "Sales",
10
12
  crm: "CRM",
@@ -18,7 +20,8 @@ const groupLabels = {
18
20
  * the user menu leave an entry out, `nav.order` sorts within a group, and `recent` (from `useNavRecent`) makes an entry
19
21
  * collapsible. Groups with pages come first, so a dashboard leads the sidebar. The resources of a source that cannot be
20
22
  * read are listed under its label, each with the problem. A visitor also gets the resources whose views ask them to sign
21
- * in, where those views place them, under "Account" without a group.
23
+ * in, where those views place them, under "Account" without a group. Each named list of a view is an entry of its
24
+ * own, placed by its own `nav`.
22
25
  */
23
26
  const navFromMeta = (meta, basePath = "", recent = {}) => {
24
27
  const groups = /* @__PURE__ */ new Map();
@@ -45,15 +48,20 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
45
48
  }
46
49
  for (const model of Object.values(meta.resources)) {
47
50
  const view = meta.views[model.name];
48
- if (!view || view.nav?.hidden || inMenu.has(model.name)) continue;
51
+ if (!view || inMenu.has(model.name)) continue;
49
52
  const schema = model.table.schema ?? "public";
50
- const groupName = view.nav?.group ?? groupLabels[schema] ?? humanize(schema);
51
- const group = groups.get(groupName) ?? {
52
- label: groupName,
53
- items: []
53
+ const defaultGroup = view.nav?.group ?? groupLabels[schema] ?? humanize(schema);
54
+ const add = (groupName, item, order) => {
55
+ const group = groups.get(groupName) ?? {
56
+ label: groupName,
57
+ items: []
58
+ };
59
+ group.items.push(item);
60
+ groups.set(groupName, group);
61
+ orders.set(item.id, order ?? Number.MAX_SAFE_INTEGER);
54
62
  };
55
- group.items.push({
56
- id: model.name,
63
+ if (!view.nav?.hidden) add(defaultGroup, {
64
+ id: navItemId(model.name),
57
65
  label: view.names?.plural ?? humanize(model.name),
58
66
  icon: iconFor(model.name),
59
67
  href: appPath({
@@ -61,9 +69,20 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
61
69
  resource: model.name
62
70
  }, basePath),
63
71
  ...recent[model.name] && { recent: recent[model.name] }
64
- });
65
- groups.set(groupName, group);
66
- orders.set(model.name, view.nav?.order ?? Number.MAX_SAFE_INTEGER);
72
+ }, view.nav?.order);
73
+ for (const named of view.lists ?? []) {
74
+ if (named.nav?.hidden) continue;
75
+ add(named.nav?.group ?? defaultGroup, {
76
+ id: navItemId(model.name, named.name),
77
+ label: named.names.plural,
78
+ icon: iconFor(model.name),
79
+ href: appPath({
80
+ kind: "list",
81
+ resource: model.name,
82
+ list: named.name
83
+ }, basePath)
84
+ }, named.nav?.order);
85
+ }
67
86
  }
68
87
  for (const entry of Object.values(meta.signIn ?? {})) {
69
88
  if (entry.nav?.hidden || inMenu.has(entry.resource)) continue;
@@ -108,4 +127,4 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
108
127
  }));
109
128
  };
110
129
  //#endregion
111
- export { navFromMeta };
130
+ export { navFromMeta, navItemId };