@protobase/ui 0.12.12 → 0.12.14
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/displays/cards-display.js +4 -4
- package/dist/app/displays/display-controls.js +21 -9
- package/dist/app/displays/list-displays.js +3 -1
- package/dist/app/list-page.js +71 -53
- package/dist/app/list-search.js +4 -3
- package/dist/app-shell/page-header.js +14 -8
- package/dist/data-grid/cells.js +1 -1
- package/dist/filter-panel/filter-panel.js +1 -0
- package/dist/format/money.js +11 -5
- package/dist/live/columns-from-model.js +4 -2
- package/dist/live/filter-string.js +35 -15
- package/dist/live/use-filter-config.js +24 -8
- package/dist/live/view-fields.js +21 -8
- package/dist/package.js +1 -1
- package/dist/primitives/menu-keys.js +14 -0
- package/dist/primitives/menu-select.js +88 -0
- package/dist/shell/assets/app-5zDld3WT.js +65 -0
- package/dist/shell/assets/index-Crnlycqw.css +2 -0
- package/dist/shell/assets/{index-D1ngvDop.js → index-YyxLNNma.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-Cv4rF2fz.js → shared-protobase-ui-CRCsuQMn.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-BKltU-WD.js +0 -65
- package/dist/shell/assets/index-CP0s3cFI.css +0 -2
|
@@ -2,7 +2,7 @@ import { Badge } from "../../primitives/badge.js";
|
|
|
2
2
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
3
|
import { humanize } from "../../live/naming.js";
|
|
4
4
|
import { useProjectUi } from "../pages/project-ui.js";
|
|
5
|
-
import { cardFields } from "./list-displays.js";
|
|
5
|
+
import { cardFields, filledMeta } from "./list-displays.js";
|
|
6
6
|
import { AppLink } from "../use-app-address.js";
|
|
7
7
|
import { fileShown, isPreviewable } from "../../live/file-values.js";
|
|
8
8
|
import { displayValue } from "../../live/display-value.js";
|
|
@@ -40,8 +40,8 @@ const MetaValue = ({ icon, children }) => {
|
|
|
40
40
|
};
|
|
41
41
|
/**
|
|
42
42
|
* A card per record, opening its page: its image across the top, the title over the subtitle, the badge beside it,
|
|
43
|
-
* small meta fields below and the labels of its flags that are true; or, with a `card` component,
|
|
44
|
-
* row, the built-in content again when it is missing or throws.
|
|
43
|
+
* small meta fields below, those with a value, and the labels of its flags that are true; or, with a `card` component,
|
|
44
|
+
* what it draws of the row, the built-in content again when it is missing or throws.
|
|
45
45
|
*/
|
|
46
46
|
const CardsDisplay = ({ model, view, display, list }) => {
|
|
47
47
|
const { image, title, subtitle, badge, meta, flags } = cardFields(model, view, display);
|
|
@@ -108,7 +108,7 @@ const CardsDisplay = ({ model, view, display, list }) => {
|
|
|
108
108
|
}),
|
|
109
109
|
meta.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
110
110
|
className: "mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
|
|
111
|
-
children: meta.map((name) => /* @__PURE__ */ jsxs("div", {
|
|
111
|
+
children: filledMeta(row, meta).map((name) => /* @__PURE__ */ jsxs("div", {
|
|
112
112
|
className: "flex gap-1",
|
|
113
113
|
children: [/* @__PURE__ */ jsx("dt", {
|
|
114
114
|
className: "sr-only",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
1
2
|
import { humanize } from "../../live/naming.js";
|
|
2
3
|
import { displayChoices, sortFields } from "./list-displays.js";
|
|
3
4
|
import { parseSort } from "../list-order.js";
|
|
5
|
+
import { MenuSelect } from "../../primitives/menu-select.js";
|
|
4
6
|
import { Segmented } from "../../primitives/segmented.js";
|
|
5
7
|
import { Calendar, Columns3, LayoutGrid, List, SquareKanban, Table2 } from "lucide-react";
|
|
6
8
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -14,25 +16,35 @@ const icons = {
|
|
|
14
16
|
list: List,
|
|
15
17
|
calendar: Calendar
|
|
16
18
|
};
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The list page's switch between the table and the view's displays, as buttons or, on a phone, as a menu; nothing for a
|
|
21
|
+
* list with only its table.
|
|
22
|
+
*/
|
|
23
|
+
const DisplaySwitch = ({ view, value, onChange, menu = false }) => {
|
|
19
24
|
const choices = displayChoices(view);
|
|
20
25
|
if (choices.length < 2) return null;
|
|
26
|
+
const options = choices.map((choice) => ({
|
|
27
|
+
value: choice.name,
|
|
28
|
+
label: choice.label,
|
|
29
|
+
icon: choice.icon === void 0 ? void 0 : icons[choice.icon]
|
|
30
|
+
}));
|
|
31
|
+
if (menu) return /* @__PURE__ */ jsx(MenuSelect, {
|
|
32
|
+
label: "Display",
|
|
33
|
+
value,
|
|
34
|
+
onChange,
|
|
35
|
+
options
|
|
36
|
+
});
|
|
21
37
|
return /* @__PURE__ */ jsx(Segmented, {
|
|
22
38
|
label: "Display",
|
|
23
39
|
size: "md",
|
|
24
40
|
value,
|
|
25
41
|
onChange,
|
|
26
|
-
options
|
|
27
|
-
value: choice.name,
|
|
28
|
-
label: choice.label,
|
|
29
|
-
icon: choice.icon === void 0 ? void 0 : icons[choice.icon]
|
|
30
|
-
}))
|
|
42
|
+
options
|
|
31
43
|
});
|
|
32
44
|
};
|
|
33
45
|
const directions = [["asc", "ascending"], ["desc", "descending"]];
|
|
34
46
|
/** The order of a display without column headers, over the sortable fields it shows and the order the list has now. */
|
|
35
|
-
const SortMenu = ({ model, view, display, orderBy, onChange }) => {
|
|
47
|
+
const SortMenu = ({ model, view, display, orderBy, onChange, className }) => {
|
|
36
48
|
const current = parseSort(orderBy);
|
|
37
49
|
const fields = sortFields(model, view, display);
|
|
38
50
|
const names = fields.includes(current.columnId) ? fields : [current.columnId, ...fields];
|
|
@@ -41,7 +53,7 @@ const SortMenu = ({ model, view, display, orderBy, onChange }) => {
|
|
|
41
53
|
"aria-label": "Sort",
|
|
42
54
|
value: `${current.columnId} ${current.direction}`,
|
|
43
55
|
onChange: (event) => onChange(event.target.value),
|
|
44
|
-
className: "h-8 rounded-md border border-border-strong bg-background px-2.5 text-[13px] shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20",
|
|
56
|
+
className: cn("h-8 rounded-md border border-border-strong bg-background px-2.5 text-[13px] shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20", className),
|
|
45
57
|
children: names.flatMap((name) => directions.map(([direction, word]) => /* @__PURE__ */ jsxs("option", {
|
|
46
58
|
value: `${name} ${direction}`,
|
|
47
59
|
children: [
|
|
@@ -37,6 +37,8 @@ const cardFields = (model, view, display) => ({
|
|
|
37
37
|
meta: display.meta ?? [],
|
|
38
38
|
flags: display.flags ?? []
|
|
39
39
|
});
|
|
40
|
+
/** The meta fields a card shows for a row: those with a value, so a listing for rent only shows no purchase price. */
|
|
41
|
+
const filledMeta = (row, meta) => meta.filter((name) => row.record[name] !== null && row.record[name] !== void 0 && row.record[name] !== "");
|
|
40
42
|
/** Every field a display shows, the fields its rows are loaded and labelled for. */
|
|
41
43
|
const shownFields = (model, view, display) => {
|
|
42
44
|
if (display.kind === "custom") return display.fields;
|
|
@@ -73,4 +75,4 @@ const displayRow = (model, row) => {
|
|
|
73
75
|
};
|
|
74
76
|
};
|
|
75
77
|
//#endregion
|
|
76
|
-
export { cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, loadedFields, shownDisplay, shownFields, sortFields };
|
|
78
|
+
export { cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledMeta, loadedFields, shownDisplay, shownFields, sortFields };
|
package/dist/app/list-page.js
CHANGED
|
@@ -15,6 +15,7 @@ import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
|
|
|
15
15
|
import { DataGrid } from "../data-grid/data-grid.js";
|
|
16
16
|
import { ActiveFilterChips } from "../filter-panel/active-filter-chips.js";
|
|
17
17
|
import { activeChips, emptyFilterState, removeChip } from "../filter-panel/filter-state.js";
|
|
18
|
+
import { FilterSection } from "../filter-panel/filter-section.js";
|
|
18
19
|
import { ToolbarRow } from "../filter-panel/toolbar-row.js";
|
|
19
20
|
import { FilterPanel } from "../filter-panel/filter-panel.js";
|
|
20
21
|
import { columnsFromModel } from "../live/columns-from-model.js";
|
|
@@ -135,14 +136,15 @@ const LoadedList = ({ model, address }) => {
|
|
|
135
136
|
const searchBox = model.search?.length ? /* @__PURE__ */ jsx(ListSearch, {
|
|
136
137
|
value: search?.kind === "search" ? search.text : "",
|
|
137
138
|
onCommit: commitSearch,
|
|
138
|
-
placeholder: `Search ${plural.toLowerCase()}
|
|
139
|
+
placeholder: `Search ${plural.toLowerCase()}`,
|
|
140
|
+
className: desktop ? void 0 : "min-h-11"
|
|
139
141
|
}) : null;
|
|
140
142
|
const panel = /* @__PURE__ */ jsx(FilterPanel, {
|
|
141
143
|
layout,
|
|
142
144
|
config,
|
|
143
145
|
value: state,
|
|
144
146
|
onChange: setFilter,
|
|
145
|
-
leading: sortMenu,
|
|
147
|
+
leading: layout === "bar" ? sortMenu : void 0,
|
|
146
148
|
trailing: searchBox
|
|
147
149
|
});
|
|
148
150
|
const desktopToolbar = layout === "bar" ? panel : /* @__PURE__ */ jsx(ToolbarRow, {
|
|
@@ -150,40 +152,75 @@ const LoadedList = ({ model, address }) => {
|
|
|
150
152
|
trailing: searchBox
|
|
151
153
|
});
|
|
152
154
|
const chips = activeChips(config, state);
|
|
155
|
+
const chipsRow = /* @__PURE__ */ jsx(ActiveFilterChips, {
|
|
156
|
+
scroll: true,
|
|
157
|
+
chips,
|
|
158
|
+
onRemove: (key) => setFilter(removeChip(state, key)),
|
|
159
|
+
onClear: () => setFilter(emptyFilterState)
|
|
160
|
+
});
|
|
161
|
+
const phoneSort = display && /* @__PURE__ */ jsx(FilterSection, {
|
|
162
|
+
title: "Sort",
|
|
163
|
+
children: /* @__PURE__ */ jsx(SortMenu, {
|
|
164
|
+
model,
|
|
165
|
+
view,
|
|
166
|
+
display,
|
|
167
|
+
orderBy,
|
|
168
|
+
onChange: (next) => set({ orderBy: next }),
|
|
169
|
+
className: "min-h-11 w-full"
|
|
170
|
+
})
|
|
171
|
+
});
|
|
153
172
|
const phoneFilters = /* @__PURE__ */ jsxs("div", {
|
|
154
173
|
className: "flex flex-col gap-2",
|
|
155
|
-
children: [
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
174
|
+
children: [
|
|
175
|
+
/* @__PURE__ */ jsxs("div", {
|
|
176
|
+
className: "flex items-center gap-2",
|
|
177
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
178
|
+
className: "min-h-11",
|
|
179
|
+
onClick: () => setSheetOpen(true),
|
|
180
|
+
"aria-haspopup": "dialog",
|
|
181
|
+
children: [
|
|
182
|
+
/* @__PURE__ */ jsx(SlidersHorizontal, { className: "size-3.5" }),
|
|
183
|
+
"Filters",
|
|
184
|
+
chips.length > 0 && ` (${chips.length})`
|
|
185
|
+
]
|
|
186
|
+
}), searchBox ?? /* @__PURE__ */ jsx("div", {
|
|
187
|
+
className: "min-w-0 flex-1",
|
|
188
|
+
children: chipsRow
|
|
189
|
+
})]
|
|
190
|
+
}),
|
|
191
|
+
searchBox && chipsRow,
|
|
192
|
+
/* @__PURE__ */ jsx(Sheet, {
|
|
193
|
+
title: "Filters",
|
|
194
|
+
open: sheetOpen,
|
|
195
|
+
onClose: () => setSheetOpen(false),
|
|
196
|
+
children: /* @__PURE__ */ jsx(FilterPanel, {
|
|
197
|
+
layout: "panel",
|
|
198
|
+
fullWidth: true,
|
|
199
|
+
config,
|
|
200
|
+
value: state,
|
|
201
|
+
onChange: setFilter,
|
|
202
|
+
leading: phoneSort
|
|
173
203
|
})
|
|
174
|
-
})]
|
|
175
|
-
}), /* @__PURE__ */ jsx(Sheet, {
|
|
176
|
-
title: "Filters",
|
|
177
|
-
open: sheetOpen,
|
|
178
|
-
onClose: () => setSheetOpen(false),
|
|
179
|
-
children: /* @__PURE__ */ jsx(FilterPanel, {
|
|
180
|
-
layout: "panel",
|
|
181
|
-
fullWidth: true,
|
|
182
|
-
config,
|
|
183
|
-
value: state,
|
|
184
|
-
onChange: setFilter
|
|
185
204
|
})
|
|
186
|
-
|
|
205
|
+
]
|
|
206
|
+
});
|
|
207
|
+
const createButton = can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
|
|
208
|
+
variant: "primary",
|
|
209
|
+
onClick: () => go({
|
|
210
|
+
kind: "create",
|
|
211
|
+
resource
|
|
212
|
+
}),
|
|
213
|
+
children: [
|
|
214
|
+
/* @__PURE__ */ jsx(Plus, { className: "size-3.5" }),
|
|
215
|
+
"New ",
|
|
216
|
+
singular.toLowerCase()
|
|
217
|
+
]
|
|
218
|
+
});
|
|
219
|
+
const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
|
|
220
|
+
view,
|
|
221
|
+
value: display?.name ?? tableDisplay,
|
|
222
|
+
onChange: (name) => set({ display: name }),
|
|
223
|
+
menu
|
|
187
224
|
});
|
|
188
225
|
const openRecord = (row) => go({
|
|
189
226
|
kind: "record",
|
|
@@ -230,27 +267,8 @@ const LoadedList = ({ model, address }) => {
|
|
|
230
267
|
/* @__PURE__ */ jsx(PageHeader, {
|
|
231
268
|
title: plural,
|
|
232
269
|
subtitle: view?.help,
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
view,
|
|
236
|
-
value: display?.name ?? tableDisplay,
|
|
237
|
-
onChange: (name) => set({ display: name })
|
|
238
|
-
}),
|
|
239
|
-
!desktop && sortMenu,
|
|
240
|
-
!desktop && searchBox,
|
|
241
|
-
can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
|
|
242
|
-
variant: "primary",
|
|
243
|
-
onClick: () => go({
|
|
244
|
-
kind: "create",
|
|
245
|
-
resource
|
|
246
|
-
}),
|
|
247
|
-
children: [
|
|
248
|
-
/* @__PURE__ */ jsx(Plus, { className: "size-3.5" }),
|
|
249
|
-
"New ",
|
|
250
|
-
singular.toLowerCase()
|
|
251
|
-
]
|
|
252
|
-
})
|
|
253
|
-
] })
|
|
270
|
+
aside: !desktop && displaySwitch(true),
|
|
271
|
+
actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [displaySwitch(false), createButton] }) : createButton
|
|
254
272
|
}),
|
|
255
273
|
!parsed.ok && /* @__PURE__ */ jsx(ErrorBanner, {
|
|
256
274
|
title: "The filter in the address could not be read",
|
package/dist/app/list-search.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
1
2
|
import { Input } from "../primitives/input.js";
|
|
2
3
|
import { useEffect, useState } from "react";
|
|
3
4
|
import { Search } from "lucide-react";
|
|
4
5
|
import { jsx } from "react/jsx-runtime";
|
|
5
6
|
//#region src/app/list-search.tsx
|
|
6
|
-
/** A search box that commits after a short pause or on Enter. */
|
|
7
|
-
const ListSearch = ({ value, onCommit, placeholder }) => {
|
|
7
|
+
/** A search box that commits after a short pause or on Enter; `className` goes on its frame. */
|
|
8
|
+
const ListSearch = ({ value, onCommit, placeholder, className }) => {
|
|
8
9
|
const [text, setText] = useState(value);
|
|
9
10
|
useEffect(() => setText(value), [value]);
|
|
10
11
|
useEffect(() => {
|
|
@@ -17,7 +18,7 @@ const ListSearch = ({ value, onCommit, placeholder }) => {
|
|
|
17
18
|
onCommit
|
|
18
19
|
]);
|
|
19
20
|
return /* @__PURE__ */ jsx(Input, {
|
|
20
|
-
wrapperClassName: "min-w-0 flex-1 sm:w-72 sm:flex-initial",
|
|
21
|
+
wrapperClassName: cn("min-w-0 flex-1 sm:w-72 sm:flex-initial", className),
|
|
21
22
|
leading: /* @__PURE__ */ jsx(Search, { className: "size-4" }),
|
|
22
23
|
placeholder,
|
|
23
24
|
"aria-label": "Search",
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
//#region src/app-shell/page-header.tsx
|
|
3
|
-
const PageHeader = ({ title, subtitle, actions }) =>
|
|
4
|
-
|
|
5
|
-
children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
|
|
3
|
+
const PageHeader = ({ title, subtitle, aside, actions }) => {
|
|
4
|
+
const heading = /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
|
|
6
5
|
className: "text-xl font-semibold tracking-tight",
|
|
7
6
|
children: title
|
|
8
7
|
}), subtitle && /* @__PURE__ */ jsx("p", {
|
|
9
8
|
className: "text-[13px] text-muted-foreground",
|
|
10
9
|
children: subtitle
|
|
11
|
-
})] })
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
})] });
|
|
11
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
12
|
+
className: "flex flex-wrap items-end justify-between gap-x-4 gap-y-2",
|
|
13
|
+
children: [aside ? /* @__PURE__ */ jsxs("div", {
|
|
14
|
+
className: "flex min-w-0 flex-1 items-center justify-between gap-3",
|
|
15
|
+
children: [heading, aside]
|
|
16
|
+
}) : heading, actions && /* @__PURE__ */ jsx("div", {
|
|
17
|
+
className: "flex w-full flex-wrap items-center gap-2 sm:w-auto",
|
|
18
|
+
children: actions
|
|
19
|
+
})]
|
|
20
|
+
});
|
|
21
|
+
};
|
|
16
22
|
//#endregion
|
|
17
23
|
export { PageHeader };
|
package/dist/data-grid/cells.js
CHANGED
|
@@ -29,7 +29,7 @@ const renderCell = (spec, value) => {
|
|
|
29
29
|
});
|
|
30
30
|
case "money": return /* @__PURE__ */ jsxs("span", {
|
|
31
31
|
className: "tabular-nums",
|
|
32
|
-
children: [formatMoney(Number(value), spec.currency), spec.suffix]
|
|
32
|
+
children: [formatMoney(Number(value), spec.currency, spec.decimals), spec.suffix]
|
|
33
33
|
});
|
|
34
34
|
case "date": return /* @__PURE__ */ jsx("span", {
|
|
35
35
|
className: "tabular-nums text-muted-foreground",
|
|
@@ -68,6 +68,7 @@ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabe
|
|
|
68
68
|
className: "border-b pb-3",
|
|
69
69
|
children: chipsView
|
|
70
70
|
}),
|
|
71
|
+
leading,
|
|
71
72
|
config.facets.map((group) => /* @__PURE__ */ jsx(FilterSection, {
|
|
72
73
|
title: group.label,
|
|
73
74
|
children: facetView(group)
|
package/dist/format/money.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
//#region src/format/money.ts
|
|
2
2
|
const formatters = /* @__PURE__ */ new Map();
|
|
3
|
-
const formatterFor = (currency) => {
|
|
4
|
-
const
|
|
3
|
+
const formatterFor = (currency, decimals) => {
|
|
4
|
+
const key = `${currency}:${decimals ?? ""}`;
|
|
5
|
+
const cached = formatters.get(key);
|
|
5
6
|
if (cached) return cached;
|
|
6
7
|
const created = new Intl.NumberFormat("en-IE", {
|
|
7
8
|
style: "currency",
|
|
8
|
-
currency
|
|
9
|
+
currency,
|
|
10
|
+
...decimals !== void 0 && {
|
|
11
|
+
minimumFractionDigits: decimals,
|
|
12
|
+
maximumFractionDigits: decimals
|
|
13
|
+
}
|
|
9
14
|
});
|
|
10
|
-
formatters.set(
|
|
15
|
+
formatters.set(key, created);
|
|
11
16
|
return created;
|
|
12
17
|
};
|
|
13
|
-
|
|
18
|
+
/** An amount in cents in the currency, with its usual decimals or `decimals` of them. */
|
|
19
|
+
const formatMoney = (cents, currency = "EUR", decimals) => formatterFor(currency, decimals).format(cents / 100);
|
|
14
20
|
//#endregion
|
|
15
21
|
export { formatMoney };
|
|
@@ -69,13 +69,14 @@ const columnsFromModel = (resource, view, highlight = []) => {
|
|
|
69
69
|
const viewField = view?.fields[name];
|
|
70
70
|
const kind = kindOf(field, viewField, index);
|
|
71
71
|
const labels = Object.fromEntries((field.enumValues ?? []).map((value) => [value, enumLabel(value, viewField?.valueLabels)]));
|
|
72
|
+
const suffixWidth = viewField?.suffix ? Math.ceil(viewField.suffix.length * 7.2) : 0;
|
|
72
73
|
return [{
|
|
73
74
|
id: name,
|
|
74
75
|
header: viewField?.label ?? humanize(name),
|
|
75
76
|
kind,
|
|
76
|
-
width: name === "name" ? 260 : widths[kind],
|
|
77
|
+
width: (name === "name" ? 260 : widths[kind]) + suffixWidth,
|
|
77
78
|
sortable: field.sortable,
|
|
78
|
-
minWidth: Math.max(kind === "id" ? 150 : 0, headerWidth(viewField?.label ?? humanize(name), field.sortable), kind === "status" ? badgeWidth(Object.values(labels)) : 0),
|
|
79
|
+
minWidth: Math.max(kind === "id" ? 150 : 0, headerWidth(viewField?.label ?? humanize(name), field.sortable), kind === "status" ? badgeWidth(Object.values(labels)) : 0, suffixWidth > 0 ? widths[kind] + suffixWidth : 0),
|
|
79
80
|
...fixedKinds.has(kind) && { fixed: true },
|
|
80
81
|
...highlight.includes(name) && { highlight: true },
|
|
81
82
|
...field.type === "enum" && {
|
|
@@ -85,6 +86,7 @@ const columnsFromModel = (resource, view, highlight = []) => {
|
|
|
85
86
|
...field.type === "enum" && viewField?.valueColors && { colors: viewField.valueColors },
|
|
86
87
|
...field.type === "enum" && viewField?.badgeShape && { shape: viewField.badgeShape },
|
|
87
88
|
...viewField?.suffix !== void 0 && { suffix: viewField.suffix },
|
|
89
|
+
...kind === "money" && viewField?.decimals !== void 0 && { decimals: viewField.decimals },
|
|
88
90
|
...kind === "money" && {
|
|
89
91
|
currency: "EUR",
|
|
90
92
|
value: (row) => row[name] == null ? null : currencyCents(row[name])
|
|
@@ -60,6 +60,10 @@ const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
|
|
|
60
60
|
const facets = config.facets.flatMap((group) => {
|
|
61
61
|
const values = state.facets[group.id] ?? [];
|
|
62
62
|
if (values.length === 0) return [];
|
|
63
|
+
if (group.flags) {
|
|
64
|
+
const chosen = group.options.filter((option) => values.includes(option.value)).map((option) => w.eq(option.value, true));
|
|
65
|
+
return chosen.length === 0 ? [] : [chosen.length === 1 ? chosen[0] : w.or(...chosen)];
|
|
66
|
+
}
|
|
63
67
|
return [values.length === 1 ? w.eq(group.id, values[0]) : w.in(group.id, values)];
|
|
64
68
|
});
|
|
65
69
|
const ranges = (config.ranges ?? []).flatMap((range) => {
|
|
@@ -79,6 +83,17 @@ const clausesToText = (clauses) => clauses.length === 0 ? "" : printFilter(w.and
|
|
|
79
83
|
const stateToText = (config, state, extras = [], now = /* @__PURE__ */ new Date()) => clausesToText([...stateToClauses(config, state, now), ...extras]);
|
|
80
84
|
const flatten = (expr) => expr.kind === "and" ? expr.args.flatMap(flatten) : [expr];
|
|
81
85
|
const fieldName = (expr) => expr.field.path.join(".");
|
|
86
|
+
const isTrue = (expr) => expr.kind === "compare" && expr.op === "=" && expr.value.kind === "boolean" && expr.value.value;
|
|
87
|
+
const flagsChosen = (clause, groupOf) => {
|
|
88
|
+
const parts = clause.kind === "or" ? clause.args : [clause];
|
|
89
|
+
if (!parts.every(isTrue)) return void 0;
|
|
90
|
+
const fields = parts.map(fieldName);
|
|
91
|
+
const group = groupOf.get(fields[0]);
|
|
92
|
+
return group !== void 0 && fields.every((field) => groupOf.get(field) === group) ? {
|
|
93
|
+
group,
|
|
94
|
+
fields
|
|
95
|
+
} : void 0;
|
|
96
|
+
};
|
|
82
97
|
const literalText = (value) => value.kind === "number" ? String(value.raw) : value.kind === "string" ? String(value.value) : void 0;
|
|
83
98
|
const presetOf = (clause, now) => {
|
|
84
99
|
const v = clause.value;
|
|
@@ -104,26 +119,31 @@ const textToState = (config, text, now = /* @__PURE__ */ new Date()) => {
|
|
|
104
119
|
toggles: []
|
|
105
120
|
};
|
|
106
121
|
const extras = [];
|
|
107
|
-
const facetIds = new Set(config.facets.map((group) => group.id));
|
|
122
|
+
const facetIds = new Set(config.facets.filter((group) => !group.flags).map((group) => group.id));
|
|
123
|
+
const flagGroupOf = new Map(config.facets.filter((group) => group.flags).flatMap((group) => group.options.map((option) => [option.value, group.id])));
|
|
108
124
|
const toggleIds = new Set((config.toggles ?? []).map((toggle) => toggle.id));
|
|
109
125
|
const rangeIds = new Set((config.ranges ?? []).map((range) => range.id));
|
|
110
126
|
const bounds = /* @__PURE__ */ new Map();
|
|
111
127
|
let dateClause;
|
|
112
128
|
let todayEnd;
|
|
113
|
-
for (const clause of parsed.ast ? flatten(parsed.ast) : [])
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
state.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (clause.
|
|
123
|
-
else
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
for (const clause of parsed.ast ? flatten(parsed.ast) : []) {
|
|
130
|
+
const flags = flagsChosen(clause, flagGroupOf);
|
|
131
|
+
if (flags && !state.facets[flags.group]) state.facets[flags.group] = flags.fields;
|
|
132
|
+
else if (clause.kind === "compare" && facetIds.has(fieldName(clause)) && clause.op === "=" && literalText(clause.value) !== void 0) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], literalText(clause.value)];
|
|
133
|
+
else if (clause.kind === "in" && facetIds.has(fieldName(clause)) && clause.values.every((value) => literalText(value) !== void 0)) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], ...clause.values.map((value) => literalText(value))];
|
|
134
|
+
else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === ">=" && presetOf(clause, now) && !state.date) {
|
|
135
|
+
state.date = presetOf(clause, now);
|
|
136
|
+
dateClause = clause;
|
|
137
|
+
} else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === "<" && (clause.value.kind === "timestamp" || clause.value.kind === "string") && periodStart("today", now).startsWith(clause.value.value.slice(0, 10))) todayEnd = clause;
|
|
138
|
+
else if (clause.kind === "compare" && toggleIds.has(fieldName(clause)) && clause.op === "=" && clause.value.kind === "boolean" && clause.value.value) state.toggles.push(fieldName(clause));
|
|
139
|
+
else if (clause.kind === "compare" && rangeIds.has(fieldName(clause)) && (clause.op === ">=" || clause.op === "<=") && clause.value.kind === "number") {
|
|
140
|
+
const range = bounds.get(fieldName(clause)) ?? { clauses: [] };
|
|
141
|
+
if (clause.op === ">=") range.low = clause.value.value;
|
|
142
|
+
else range.high = clause.value.value;
|
|
143
|
+
range.clauses.push(clause);
|
|
144
|
+
bounds.set(fieldName(clause), range);
|
|
145
|
+
} else extras.push(clause);
|
|
146
|
+
}
|
|
127
147
|
if (state.date === "yesterday" && !todayEnd) {
|
|
128
148
|
extras.push(dateClause);
|
|
129
149
|
state.date = void 0;
|
|
@@ -25,25 +25,30 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
25
25
|
const field = model.fields[name];
|
|
26
26
|
return field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
|
|
27
27
|
};
|
|
28
|
-
const
|
|
28
|
+
const counted = skeleton.facets.flatMap((group) => (group.flags ? group.options.map((option) => option.value) : [group.id]).map((field) => ({
|
|
29
|
+
group: group.id,
|
|
30
|
+
field
|
|
31
|
+
})));
|
|
32
|
+
const facetQueries = useQueries({ queries: counted.map(({ group, field }) => {
|
|
29
33
|
const filter = stateToText(skeleton, {
|
|
30
34
|
...state,
|
|
31
35
|
facets: {
|
|
32
36
|
...state.facets,
|
|
33
|
-
[group
|
|
37
|
+
[group]: []
|
|
34
38
|
}
|
|
35
39
|
}, extras);
|
|
36
40
|
return {
|
|
37
|
-
queryKey: keys.facets(model.name,
|
|
38
|
-
queryFn: () => client.facets(model.name,
|
|
41
|
+
queryKey: keys.facets(model.name, field, filter),
|
|
42
|
+
queryFn: () => client.facets(model.name, field, {
|
|
39
43
|
filter,
|
|
40
44
|
limit: 50
|
|
41
45
|
})
|
|
42
46
|
};
|
|
43
47
|
}) });
|
|
44
|
-
const
|
|
48
|
+
const counts = (group, field) => facetQueries[counted.findIndex((entry) => entry.group === group && entry.field === field)]?.data;
|
|
49
|
+
const labelQueries = useQueries({ queries: skeleton.facets.map((group) => {
|
|
45
50
|
const target = targetOf(group.id);
|
|
46
|
-
const ids = labelKeys((
|
|
51
|
+
const ids = labelKeys((counts(group.id, group.id) ?? []).flatMap((facet) => facet.value === null ? [] : [String(facet.value)]), state.facets[group.id] ?? []);
|
|
47
52
|
return {
|
|
48
53
|
queryKey: keys.labels(`facet:${model.name}.${group.id}`, ids.join(",")),
|
|
49
54
|
enabled: Boolean(target) && ids.length > 0,
|
|
@@ -67,9 +72,20 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
67
72
|
};
|
|
68
73
|
}) });
|
|
69
74
|
const facetGroup = (group, index) => {
|
|
75
|
+
if (group.flags) {
|
|
76
|
+
const options = group.options.map(({ value }) => ({
|
|
77
|
+
value,
|
|
78
|
+
label: label(value),
|
|
79
|
+
count: counts(group.id, value)?.find((facet) => String(facet.value) === "true")?.count ?? 0
|
|
80
|
+
}));
|
|
81
|
+
return {
|
|
82
|
+
...group,
|
|
83
|
+
options
|
|
84
|
+
};
|
|
85
|
+
}
|
|
70
86
|
const field = model.fields[group.id];
|
|
71
87
|
const names = new Map(labelQueries[index]?.data ?? []);
|
|
72
|
-
const
|
|
88
|
+
const values = (counts(group.id, group.id) ?? []).flatMap((facet) => facet.value === null ? [] : [{
|
|
73
89
|
value: String(facet.value),
|
|
74
90
|
label: field?.type === "enum" ? enumLabel(String(facet.value), view?.fields[group.id]?.valueLabels) : names.get(String(facet.value)) ?? String(facet.value),
|
|
75
91
|
count: facet.count
|
|
@@ -78,7 +94,7 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
78
94
|
return {
|
|
79
95
|
...group,
|
|
80
96
|
label: label(group.id),
|
|
81
|
-
options: search.matches ? matchOptions(search.matches,
|
|
97
|
+
options: search.matches ? matchOptions(search.matches, values) : values,
|
|
82
98
|
...names.size > 0 && { labels: Object.fromEntries(names) },
|
|
83
99
|
...search.onSearch && { onSearch: search.onSearch }
|
|
84
100
|
};
|
package/dist/live/view-fields.js
CHANGED
|
@@ -27,14 +27,27 @@ const widgetFields = (view, model) => {
|
|
|
27
27
|
const widgets = view?.filters ?? [];
|
|
28
28
|
const date = widgets.find((widget) => widget.kind === "dateRange");
|
|
29
29
|
return {
|
|
30
|
-
facets: widgets.flatMap((widget) =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
facets: widgets.flatMap((widget) => {
|
|
31
|
+
if (widget.kind === "facets" && !rangeOf(widget, model)) return [{
|
|
32
|
+
id: widget.field,
|
|
33
|
+
label: widget.field,
|
|
34
|
+
options: [],
|
|
35
|
+
searchable: widget.search,
|
|
36
|
+
...model?.fields[widget.field]?.type === "relation" && { combobox: true }
|
|
37
|
+
}];
|
|
38
|
+
if (widget.kind === "flags") return [{
|
|
39
|
+
id: widget.fields.join("|"),
|
|
40
|
+
label: widget.label,
|
|
41
|
+
options: widget.fields.map((field) => ({
|
|
42
|
+
value: field,
|
|
43
|
+
label: field,
|
|
44
|
+
count: 0
|
|
45
|
+
})),
|
|
46
|
+
flags: true
|
|
47
|
+
}];
|
|
48
|
+
return [];
|
|
49
|
+
}),
|
|
50
|
+
ranges: widgets.flatMap((widget) => widget.kind !== "flags" && rangeOf(widget, model) ? [emptyRange(widget.field)] : []),
|
|
38
51
|
...date && {
|
|
39
52
|
dateField: date.field,
|
|
40
53
|
dateKind: model?.fields[date.field]?.type === "date" ? "date" : "timestamp",
|
package/dist/package.js
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/primitives/menu-keys.ts
|
|
2
|
+
/**
|
|
3
|
+
* The item of an open menu a key moves the focus to, from the item at `index` (-1 for none) of `count`: the arrows step
|
|
4
|
+
* and wrap, Home and End jump to the ends. `undefined` for a key that does not move it.
|
|
5
|
+
*/
|
|
6
|
+
const menuKeyTarget = (key, index, count) => {
|
|
7
|
+
if (count === 0) return void 0;
|
|
8
|
+
if (key === "Home") return 0;
|
|
9
|
+
if (key === "End") return count - 1;
|
|
10
|
+
if (key === "ArrowDown") return index < 0 ? 0 : (index + 1) % count;
|
|
11
|
+
if (key === "ArrowUp") return index < 0 ? count - 1 : (index - 1 + count) % count;
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
export { menuKeyTarget };
|