@protobase/ui 0.21.1 → 0.21.3
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/current-address.js +2 -2
- package/dist/app/displays/display-controls.js +3 -4
- package/dist/app/displays/list-displays.js +58 -4
- package/dist/app/displays/record-card.js +2 -2
- package/dist/app/embedded-list/embedded-columns.js +1 -1
- package/dist/app/embedded-list/embedded-display.js +6 -2
- package/dist/app/embedded-list/embedded-list.js +1 -1
- package/dist/app/field-editor.js +1 -1
- package/dist/app/hand-order/hand-order.js +21 -0
- package/dist/app/hand-order/hand-sorted-list.js +98 -0
- package/dist/app/hand-order/sort-button.js +20 -0
- package/dist/app/hand-order/use-hand-order.js +105 -0
- package/dist/app/list-page.js +39 -12
- package/dist/app/pages/field-value.js +1 -1
- package/dist/app/record-page.js +2 -2
- package/dist/app/related-dots/related-dots-columns.js +2 -1
- package/dist/app/related-dots/related-dots.js +1 -1
- package/dist/app/related-section.js +37 -8
- package/dist/data-grid/cell-text.js +30 -0
- package/dist/data-grid/cells.js +27 -35
- package/dist/data-grid/content-widths.js +81 -0
- package/dist/data-grid/data-grid.js +58 -40
- package/dist/data-grid/default-pins.js +13 -0
- package/dist/data-grid/grid-row.js +16 -4
- package/dist/data-grid/pinned-storage.js +2 -1
- package/dist/data-grid/row-reorder.js +68 -0
- package/dist/data-grid/truncated-text.js +16 -0
- package/dist/data-grid/use-fitted-columns.js +28 -0
- package/dist/data-grid/use-pinned-columns.js +6 -5
- package/dist/live/columns-from-model.js +6 -6
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-wZRscg1o.js +69 -0
- package/dist/shell/assets/index-C1fzTv_X.css +2 -0
- package/dist/shell/assets/{index-CW037BBI.js → index-RHKnIOOP.js} +1 -1
- package/dist/shell/assets/{maplibre-Ck9abcyu.js → maplibre-DN_v_fcJ.js} +1 -1
- package/dist/shell/assets/{markdown-content-BDjSKrC7.js → markdown-content-Be4vIiWm.js} +1 -1
- package/dist/shell/assets/{record-map-CKPZ2KJs.js → record-map-CVeshts3.js} +1 -1
- package/dist/shell/assets/{records-map-DadV3GNO.js → records-map-ClnWq-RH.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-tJCYxj7E.js → shared-protobase-ui-DWCBNg3c.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-DGVgRtZo.js +0 -69
- package/dist/shell/assets/index-Dc6xgHsz.css +0 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defaultDisplay } from "./displays/list-displays.js";
|
|
1
|
+
import { defaultDisplay, listDefaultDisplay } from "./displays/list-displays.js";
|
|
2
2
|
import { defaultOrderBy } from "./list-order.js";
|
|
3
3
|
import { encodeKey } from "@protobase/schema/internal";
|
|
4
4
|
//#region src/app/current-address.ts
|
|
@@ -22,7 +22,7 @@ const listedResource = (resources, views, resource) => {
|
|
|
22
22
|
};
|
|
23
23
|
/** What a list shows when its address names nothing: its default display for the screen, the list's sort, the filters in a bar. */
|
|
24
24
|
const listDefaults = (model, view, xs = false) => ({
|
|
25
|
-
display: defaultDisplay(view, xs),
|
|
25
|
+
display: model ? listDefaultDisplay(model, view, xs) : defaultDisplay(view, xs),
|
|
26
26
|
orderBy: model ? defaultOrderBy(model, view) : "",
|
|
27
27
|
filterLayout: "bar"
|
|
28
28
|
});
|
|
@@ -18,11 +18,10 @@ const icons = {
|
|
|
18
18
|
map: Map
|
|
19
19
|
};
|
|
20
20
|
/**
|
|
21
|
-
* The list page's switch between the table and the view's displays, as buttons or, on a phone, as a menu;
|
|
22
|
-
* list with only its table.
|
|
21
|
+
* The list page's switch between the table and the view's displays, or `choices`, as buttons or, on a phone, as a menu;
|
|
22
|
+
* nothing for a list with only its table.
|
|
23
23
|
*/
|
|
24
|
-
const DisplaySwitch = ({ view, value, onChange, menu = false }) => {
|
|
25
|
-
const choices = displayChoices(view);
|
|
24
|
+
const DisplaySwitch = ({ view, value, onChange, menu = false, choices = displayChoices(view) }) => {
|
|
26
25
|
if (choices.length < 2) return null;
|
|
27
26
|
const options = choices.map((choice) => ({
|
|
28
27
|
value: choice.name,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { recordKey, recordTitleField } from "../../live/model-helpers.js";
|
|
2
2
|
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { listColumnNames } from "../../live/columns-from-model.js";
|
|
3
4
|
import { displayValue } from "../../live/display-value.js";
|
|
4
5
|
import { photosKey } from "../../live/row-photos.js";
|
|
5
6
|
import { filledFields } from "../filled-fields.js";
|
|
@@ -18,6 +19,40 @@ const defaultDisplay = (view, xs, preferred) => {
|
|
|
18
19
|
const cards = displaysOf(view).filter((display) => display.kind === "cards");
|
|
19
20
|
return cards.find((display) => display.name === fallback)?.name ?? cards[0]?.name ?? fallback;
|
|
20
21
|
};
|
|
22
|
+
/** The name of the list a phone shows for a list without a cards display. */
|
|
23
|
+
const rowsDisplay = "rows";
|
|
24
|
+
/**
|
|
25
|
+
* The list a phone shows for a list without a cards display, as a cards display of a row per record: the title, the
|
|
26
|
+
* first enum column as a badge on the right, and the next two columns, booleans left out, on a muted line below.
|
|
27
|
+
* `undefined` for a view with a cards display, or a display of its own named `rows`.
|
|
28
|
+
*/
|
|
29
|
+
const phoneRows = (model, view) => {
|
|
30
|
+
if (displaysOf(view).some((display) => display.kind === "cards" || display.name === "rows")) return void 0;
|
|
31
|
+
const title = recordTitleField(model, view);
|
|
32
|
+
const candidates = listColumnNames(model, view).filter((name) => name !== title && model.fields[name] !== void 0);
|
|
33
|
+
const badge = candidates.find((name) => model.fields[name].type === "enum");
|
|
34
|
+
const footer = candidates.filter((name) => name !== badge && model.fields[name].type !== "boolean").slice(0, 2);
|
|
35
|
+
return {
|
|
36
|
+
kind: "cards",
|
|
37
|
+
name: rowsDisplay,
|
|
38
|
+
label: "List",
|
|
39
|
+
icon: "list",
|
|
40
|
+
title,
|
|
41
|
+
...badge !== void 0 && { badge },
|
|
42
|
+
footer
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const isPhoneRows = (view, display) => display.name === "rows" && !displaysOf(view).some((other) => other.name === "rows");
|
|
46
|
+
/**
|
|
47
|
+
* The display a list page opens in when its address names none: `defaultDisplay`, but below the `sm` breakpoint the
|
|
48
|
+
* phone's rows (`phoneRows`) in place of the table.
|
|
49
|
+
*/
|
|
50
|
+
const listDefaultDisplay = (model, view, xs) => {
|
|
51
|
+
const display = defaultDisplay(view, xs);
|
|
52
|
+
return xs && display === tableDisplay && phoneRows(model, view) ? rowsDisplay : display;
|
|
53
|
+
};
|
|
54
|
+
/** The display a list page shows for the address's `name`: `shownDisplay`, or the phone's rows when it names them, on any screen. */
|
|
55
|
+
const shownListDisplay = (model, view, name) => (name === "rows" ? phoneRows(model, view) : void 0) ?? shownDisplay(view, name);
|
|
21
56
|
/** The display a list shows for the address's `name`: `undefined` for the table, and the view's default for a name it does not have. */
|
|
22
57
|
const shownDisplay = (view, name) => {
|
|
23
58
|
if (name === tableDisplay) return void 0;
|
|
@@ -38,6 +73,15 @@ const displayChoices = (view) => [{
|
|
|
38
73
|
label: display.label ?? humanize(display.name),
|
|
39
74
|
icon: display.icon ?? defaultIcons[display.kind]
|
|
40
75
|
}))];
|
|
76
|
+
/** The list page's display switch: `displayChoices`, and below the `sm` breakpoint the phone's rows when it has them. */
|
|
77
|
+
const listDisplayChoices = (model, view, xs) => {
|
|
78
|
+
const choices = displayChoices(view);
|
|
79
|
+
return xs && phoneRows(model, view) ? [...choices, {
|
|
80
|
+
name: rowsDisplay,
|
|
81
|
+
label: "List",
|
|
82
|
+
icon: "list"
|
|
83
|
+
}] : choices;
|
|
84
|
+
};
|
|
41
85
|
/** The fields a cards display shows, its title the view's title field when it names none. */
|
|
42
86
|
const cardFields = (model, view, display) => ({
|
|
43
87
|
image: display.image,
|
|
@@ -101,9 +145,19 @@ const shownFields = (model, view, display) => {
|
|
|
101
145
|
].filter((name) => name !== void 0);
|
|
102
146
|
};
|
|
103
147
|
/** The fields to label the list's rows for: the table's columns and every display's, so one load serves them all. */
|
|
104
|
-
const loadedFields = (model, view, columns) =>
|
|
105
|
-
|
|
106
|
-
const
|
|
148
|
+
const loadedFields = (model, view, columns) => {
|
|
149
|
+
const rows = phoneRows(model, view);
|
|
150
|
+
const displays = rows ? [...displaysOf(view), rows] : displaysOf(view);
|
|
151
|
+
return [.../* @__PURE__ */ new Set([...columns, ...displays.flatMap((display) => shownFields(model, view, display))])];
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* The sortable fields a display shows, for the sort menu that stands in for column headers; for the phone's rows, those
|
|
155
|
+
* of the table's columns and the title, as the table's headers sort by.
|
|
156
|
+
*/
|
|
157
|
+
const sortFields = (model, view, display) => {
|
|
158
|
+
const shown = isPhoneRows(view, display) ? [recordTitleField(model, view), ...listColumnNames(model, view)] : shownFields(model, view, display);
|
|
159
|
+
return [...new Set(shown)].filter((name) => model.fields[name]?.sortable);
|
|
160
|
+
};
|
|
107
161
|
const isLabelled = (value) => typeof value === "object" && value !== null && "id" in value && "name" in value;
|
|
108
162
|
/**
|
|
109
163
|
* A loaded row, its relations as `{ id, name }`, split into the record as the API gives it, the relations' names and
|
|
@@ -128,4 +182,4 @@ const displayRow = (model, row) => {
|
|
|
128
182
|
};
|
|
129
183
|
};
|
|
130
184
|
//#endregion
|
|
131
|
-
export { amountParts, cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledAmount, filledMeta, loadedFields, mapCards, shownDisplay, shownFields, sortFields };
|
|
185
|
+
export { amountParts, cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledAmount, filledMeta, listDefaultDisplay, listDisplayChoices, loadedFields, mapCards, phoneRows, rowsDisplay, shownDisplay, shownFields, shownListDisplay, sortFields };
|
|
@@ -3,14 +3,14 @@ import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
|
3
3
|
import { recordKey } from "../../live/model-helpers.js";
|
|
4
4
|
import { humanize } from "../../live/naming.js";
|
|
5
5
|
import { useProjectUi } from "../pages/project-ui.js";
|
|
6
|
+
import { markdownExcerpt } from "../../markdown/markdown-excerpt.js";
|
|
7
|
+
import { isMarkdown } from "../../markdown/markdown-hint.js";
|
|
6
8
|
import { displayValue } from "../../live/display-value.js";
|
|
7
9
|
import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
|
|
8
10
|
import { AppLink } from "../use-app-address.js";
|
|
9
11
|
import { shownWidget } from "../field-types/shown-widget.js";
|
|
10
12
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
11
13
|
import { Markdown } from "../../markdown/markdown.js";
|
|
12
|
-
import { isMarkdown } from "../../markdown/markdown-hint.js";
|
|
13
|
-
import { markdownExcerpt } from "../../markdown/markdown-excerpt.js";
|
|
14
14
|
import { ValueBadge } from "../value-badge.js";
|
|
15
15
|
import { useSeedRecord } from "../record-preview.js";
|
|
16
16
|
import { namedIcon } from "../named-icon.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { humanize } from "../../live/naming.js";
|
|
2
|
+
import { columnsFromModel } from "../../live/columns-from-model.js";
|
|
2
3
|
import { displayRow } from "../displays/list-displays.js";
|
|
3
4
|
import { scopeOf } from "../pages/use-layout-data.js";
|
|
4
|
-
import { columnsFromModel } from "../../live/columns-from-model.js";
|
|
5
5
|
import { FieldValue } from "../pages/field-value.js";
|
|
6
6
|
import { usePathValue } from "../pages/use-path-value.js";
|
|
7
7
|
import { withFieldTypeCells } from "../field-types/field-type-cells.js";
|
|
@@ -18,13 +18,17 @@ const embeddedDisplay = (view, xs, { picked, preferred }) => {
|
|
|
18
18
|
return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
|
|
19
19
|
};
|
|
20
20
|
/**
|
|
21
|
-
* The resource's list with the embedded list's filter, order and display, leaving out what the list does by default
|
|
21
|
+
* The resource's list with the embedded list's filter, order and display, leaving out what the list does by default (an
|
|
22
|
+
* embedded list's own default display among it, so a phone opens the list in its rows rather than the table), and
|
|
22
23
|
* the saved search whose results it shows, if any. `resource` names another list of the same records, such as the
|
|
23
24
|
* favorites that list a user's saved listings.
|
|
24
25
|
*/
|
|
25
26
|
const viewAllAddress = (model, view, xs, { resource = model.name, ...list }) => changedList({
|
|
26
27
|
kind: "list",
|
|
27
28
|
resource
|
|
28
|
-
}, list,
|
|
29
|
+
}, list, {
|
|
30
|
+
...listDefaults(model, view, xs),
|
|
31
|
+
display: defaultDisplay(view, xs)
|
|
32
|
+
});
|
|
29
33
|
//#endregion
|
|
30
34
|
export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
|
|
@@ -5,10 +5,10 @@ import { humanize } from "../../live/naming.js";
|
|
|
5
5
|
import { useProjectUi } from "../pages/project-ui.js";
|
|
6
6
|
import { useAdminMeta } from "../meta-gate.js";
|
|
7
7
|
import { ErrorBanner } from "../error-banner.js";
|
|
8
|
+
import { listColumnNames } from "../../live/columns-from-model.js";
|
|
8
9
|
import { displayRow, loadedFields, shownDisplay } from "../displays/list-displays.js";
|
|
9
10
|
import { defaultOrderBy, parseSort, sortToOrderBy } from "../list-order.js";
|
|
10
11
|
import { AppLink, useAppAddress } from "../use-app-address.js";
|
|
11
|
-
import { listColumnNames } from "../../live/columns-from-model.js";
|
|
12
12
|
import { DataGrid } from "../../data-grid/data-grid.js";
|
|
13
13
|
import { viewAllAddress } from "./embedded-display.js";
|
|
14
14
|
import { BoardDisplay } from "../displays/board-display.js";
|
package/dist/app/field-editor.js
CHANGED
|
@@ -6,6 +6,7 @@ import { humanize } from "../live/naming.js";
|
|
|
6
6
|
import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
|
|
7
7
|
import { Switch } from "../primitives/switch.js";
|
|
8
8
|
import { Textarea } from "../primitives/textarea.js";
|
|
9
|
+
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
9
10
|
import { displayValue } from "../live/display-value.js";
|
|
10
11
|
import { AppLink } from "./use-app-address.js";
|
|
11
12
|
import { RelationPicker } from "./relation-picker.js";
|
|
@@ -15,7 +16,6 @@ import { useShownWidget } from "./field-types/shown-widget.js";
|
|
|
15
16
|
import { WidgetPart } from "./field-types/widget-part.js";
|
|
16
17
|
import { Markdown } from "../markdown/markdown.js";
|
|
17
18
|
import { MarkdownEditor } from "../markdown/markdown-editor.js";
|
|
18
|
-
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
19
19
|
import { ArrowUpRight } from "lucide-react";
|
|
20
20
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
21
21
|
import { enumLabel } from "@protobase/schema";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { can } from "../permissions.js";
|
|
2
|
+
//#region src/app/hand-order/hand-order.ts
|
|
3
|
+
/** The field a user may sort `model`'s records by hand in: its `position`, when they may update it. */
|
|
4
|
+
const writablePosition = (model, permissions) => {
|
|
5
|
+
const position = model.position;
|
|
6
|
+
if (!position || !can(permissions, "update")) return void 0;
|
|
7
|
+
const field = model.fields[position.field];
|
|
8
|
+
return field && !field.readOnly ? position.field : void 0;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The field the list page sorts all of `model`'s records by hand in. A resource whose records are ordered within a
|
|
12
|
+
* parent is sorted in the parent's related section instead, since its list mixes the parents.
|
|
13
|
+
*/
|
|
14
|
+
const listHandOrder = (model, permissions) => model.position?.within === void 0 ? writablePosition(model, permissions) : void 0;
|
|
15
|
+
/**
|
|
16
|
+
* The field a related section sorts its records by hand in: those of one parent, when the section lists every record
|
|
17
|
+
* whose `within` relation points at the page's record, no filter or join resource leaving some out.
|
|
18
|
+
*/
|
|
19
|
+
const relatedHandOrder = (item, model, permissions) => model.position?.within !== void 0 && item.field === model.position.within && !item.through && !item.filter ? writablePosition(model, permissions) : void 0;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { listHandOrder, relatedHandOrder };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { recordKey, recordTitle } from "../../live/model-helpers.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
4
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
5
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
7
|
+
import { loadedFields } from "../displays/list-displays.js";
|
|
8
|
+
import { useAppAddress } from "../use-app-address.js";
|
|
9
|
+
import { DataGrid } from "../../data-grid/data-grid.js";
|
|
10
|
+
import { embeddedColumns } from "../embedded-list/embedded-columns.js";
|
|
11
|
+
import { useHandOrder } from "./use-hand-order.js";
|
|
12
|
+
import { useMemo } from "react";
|
|
13
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
//#region src/app/hand-order/hand-sorted-list.tsx
|
|
15
|
+
/**
|
|
16
|
+
* The records in their order by hand, in the list's table with a drag handle in each row's checkbox place: dragging a
|
|
17
|
+
* row, or moving it with the keyboard from its handle, renumbers them all. The page scrolls them, and a row still opens
|
|
18
|
+
* its record.
|
|
19
|
+
*/
|
|
20
|
+
const HandSortedList = ({ model, view, field, filter, columns }) => {
|
|
21
|
+
const { resources, views } = useAdminMeta();
|
|
22
|
+
const { fields: fieldTypes } = useProjectUi();
|
|
23
|
+
const { go } = useAppAddress();
|
|
24
|
+
const specs = useMemo(() => embeddedColumns(model, view, columns, {
|
|
25
|
+
resources,
|
|
26
|
+
views,
|
|
27
|
+
fieldTypes
|
|
28
|
+
}).map((spec) => ({
|
|
29
|
+
...spec,
|
|
30
|
+
sortable: false
|
|
31
|
+
})), [
|
|
32
|
+
model,
|
|
33
|
+
view,
|
|
34
|
+
columns.join(","),
|
|
35
|
+
resources,
|
|
36
|
+
views,
|
|
37
|
+
fieldTypes
|
|
38
|
+
]);
|
|
39
|
+
const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
|
|
40
|
+
model,
|
|
41
|
+
view,
|
|
42
|
+
specs
|
|
43
|
+
]);
|
|
44
|
+
const order = useHandOrder({
|
|
45
|
+
model,
|
|
46
|
+
field,
|
|
47
|
+
filter,
|
|
48
|
+
fields
|
|
49
|
+
});
|
|
50
|
+
const rows = order.rows;
|
|
51
|
+
const source = useMemo(() => rows && {
|
|
52
|
+
total: rows.length,
|
|
53
|
+
rowAt: (index) => rows[index]
|
|
54
|
+
}, [rows]);
|
|
55
|
+
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
56
|
+
if (order.loading) return /* @__PURE__ */ jsx("div", {
|
|
57
|
+
className: "flex flex-col gap-2",
|
|
58
|
+
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
59
|
+
});
|
|
60
|
+
if (order.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
61
|
+
title: "Could not load",
|
|
62
|
+
error: order.error
|
|
63
|
+
});
|
|
64
|
+
if (order.more) return /* @__PURE__ */ jsxs("p", {
|
|
65
|
+
className: "text-muted-foreground",
|
|
66
|
+
children: [
|
|
67
|
+
"There are more than ",
|
|
68
|
+
500 .toLocaleString("en-IE"),
|
|
69
|
+
" ",
|
|
70
|
+
plural,
|
|
71
|
+
", too many to sort by hand."
|
|
72
|
+
]
|
|
73
|
+
});
|
|
74
|
+
if (!source || source.total === 0) return /* @__PURE__ */ jsx("p", {
|
|
75
|
+
className: "text-muted-foreground",
|
|
76
|
+
children: "Nothing here yet."
|
|
77
|
+
});
|
|
78
|
+
const openRecord = (row) => go({
|
|
79
|
+
kind: "record",
|
|
80
|
+
resource: model.name,
|
|
81
|
+
key: recordKey(model, row)
|
|
82
|
+
});
|
|
83
|
+
return /* @__PURE__ */ jsx(DataGrid, {
|
|
84
|
+
fit: true,
|
|
85
|
+
scroll: "parent",
|
|
86
|
+
columns: specs,
|
|
87
|
+
source,
|
|
88
|
+
latencyMs: 0,
|
|
89
|
+
onOpenRow: openRecord,
|
|
90
|
+
rowNumbers: false,
|
|
91
|
+
reorder: {
|
|
92
|
+
label: (row) => recordTitle(model, view, row, row.id),
|
|
93
|
+
onMove: order.move
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
//#endregion
|
|
98
|
+
export { HandSortedList };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { ArrowUpDown, Check } from "lucide-react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/app/hand-order/sort-button.tsx
|
|
5
|
+
/** The button that turns sorting by hand on, as **Sort**, and off again, as **Done**; with `iconOnly` its icon alone. */
|
|
6
|
+
const SortButton = ({ sorting, onChange, size, iconOnly = false }) => {
|
|
7
|
+
const label = sorting ? "Done" : "Sort";
|
|
8
|
+
return /* @__PURE__ */ jsxs(Button, {
|
|
9
|
+
size,
|
|
10
|
+
variant: sorting ? "primary" : "secondary",
|
|
11
|
+
onClick: () => onChange(!sorting),
|
|
12
|
+
...iconOnly && {
|
|
13
|
+
"aria-label": label,
|
|
14
|
+
title: label
|
|
15
|
+
},
|
|
16
|
+
children: [sorting ? /* @__PURE__ */ jsx(Check, { className: "size-3.5" }) : /* @__PURE__ */ jsx(ArrowUpDown, { className: "size-3.5" }), !iconOnly && label]
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
export { SortButton };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { recordId, recordKey } from "../../live/model-helpers.js";
|
|
4
|
+
import { useToast } from "../../toasts/toast-provider.js";
|
|
5
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
+
import { refusalReason } from "../displays/board-columns.js";
|
|
7
|
+
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
8
|
+
import { listLabels, withLabels } from "../../live/relation-labels.js";
|
|
9
|
+
import { useRef, useState } from "react";
|
|
10
|
+
import { useQuery } from "@tanstack/react-query";
|
|
11
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
12
|
+
import { arrayMove } from "@dnd-kit/sortable";
|
|
13
|
+
//#region src/app/hand-order/use-hand-order.ts
|
|
14
|
+
/**
|
|
15
|
+
* The records of `filter` in the order of `field`, all of them up to `handOrderLimit`, with `more` when there are
|
|
16
|
+
* others, and `move` to put one elsewhere. A move shows at once and renumbers them all 1, 2, 3, ... in one batch
|
|
17
|
+
* `reorder` with their ETags; moves made while one saves are saved after it, with the ETags it gave back. A refusal
|
|
18
|
+
* puts them back as the server has them and says why; either way every list of the resource reloads.
|
|
19
|
+
*/
|
|
20
|
+
const useHandOrder = ({ model, field, filter, fields }) => {
|
|
21
|
+
const client = useClient();
|
|
22
|
+
const toast = useToast();
|
|
23
|
+
const refresh = useRefreshResource();
|
|
24
|
+
const { resources, views } = useAdminMeta();
|
|
25
|
+
const orderBy = `${field} asc`;
|
|
26
|
+
const loaded = useQuery({
|
|
27
|
+
queryKey: [...keys.list(model.name, filter, orderBy, 500, "hand-order"), fields.join(",")],
|
|
28
|
+
queryFn: async () => {
|
|
29
|
+
const labels = listLabels(resources, views, model, fields);
|
|
30
|
+
const page = await client.search(model.name, {
|
|
31
|
+
filter: filter || void 0,
|
|
32
|
+
orderBy,
|
|
33
|
+
pageSize: 500,
|
|
34
|
+
labels
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
rows: withLabels(resources, model, fields, page.items, page.labels).map((row, index) => ({
|
|
38
|
+
...row,
|
|
39
|
+
id: recordId(model, page.items[index])
|
|
40
|
+
})),
|
|
41
|
+
more: Boolean(page.nextPageToken)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const [moved, setMoved] = useState();
|
|
46
|
+
const latest = useRef(void 0);
|
|
47
|
+
const saving = useRef(Promise.resolve());
|
|
48
|
+
const waiting = useRef(0);
|
|
49
|
+
const save = async () => {
|
|
50
|
+
const rows = latest.current;
|
|
51
|
+
if (!rows) return;
|
|
52
|
+
const etags = Object.fromEntries(rows.flatMap((row) => typeof row.etag === "string" ? [[encodeKey(recordKey(model, row)), row.etag]] : []));
|
|
53
|
+
const written = (await client.batchWrite([{
|
|
54
|
+
op: "reorder",
|
|
55
|
+
resource: model.name,
|
|
56
|
+
field,
|
|
57
|
+
keys: rows.map((row) => recordKey(model, row)),
|
|
58
|
+
etags
|
|
59
|
+
}])).results[0];
|
|
60
|
+
if (written?.op !== "reorder" || !latest.current) return;
|
|
61
|
+
const stored = new Map(written.records.map((entry) => [recordId(model, entry.record), entry]));
|
|
62
|
+
latest.current = latest.current.map((row) => {
|
|
63
|
+
const entry = stored.get(row.id);
|
|
64
|
+
return entry ? {
|
|
65
|
+
...row,
|
|
66
|
+
[field]: entry.record[field],
|
|
67
|
+
etag: entry.etag
|
|
68
|
+
} : row;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const refused = (error) => {
|
|
72
|
+
latest.current = void 0;
|
|
73
|
+
setMoved(void 0);
|
|
74
|
+
toast.show({
|
|
75
|
+
state: "error",
|
|
76
|
+
title: "Could not change the order",
|
|
77
|
+
description: refusalReason(error)
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
const settle = async () => {
|
|
81
|
+
waiting.current -= 1;
|
|
82
|
+
if (waiting.current > 0) return;
|
|
83
|
+
await refresh(model.name);
|
|
84
|
+
if (waiting.current > 0) return;
|
|
85
|
+
latest.current = void 0;
|
|
86
|
+
setMoved(void 0);
|
|
87
|
+
};
|
|
88
|
+
const move = (from, to) => {
|
|
89
|
+
const rows = latest.current ?? loaded.data?.rows;
|
|
90
|
+
if (!rows || from === to) return;
|
|
91
|
+
latest.current = arrayMove(rows, from, to);
|
|
92
|
+
setMoved(latest.current);
|
|
93
|
+
waiting.current += 1;
|
|
94
|
+
saving.current = saving.current.then(save).catch(refused).then(settle);
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
rows: moved ?? loaded.data?.rows,
|
|
98
|
+
more: loaded.data?.more ?? false,
|
|
99
|
+
loading: loaded.isPending,
|
|
100
|
+
error: loaded.error,
|
|
101
|
+
move
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
export { useHandOrder };
|
package/dist/app/list-page.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useIsDesktop } from "../lib/use-media-query.js";
|
|
1
|
+
import { useIsDesktop, useIsXs } from "../lib/use-media-query.js";
|
|
2
2
|
import { usePageScroll } from "../app-shell/page-scroll.js";
|
|
3
3
|
import { PageHeader } from "../app-shell/page-header.js";
|
|
4
4
|
import { useAuth } from "../auth/auth-provider.js";
|
|
@@ -9,12 +9,12 @@ import { useProjectUi } from "./pages/project-ui.js";
|
|
|
9
9
|
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
10
10
|
import { Sheet } from "../primitives/sheet.js";
|
|
11
11
|
import { ErrorBanner } from "./error-banner.js";
|
|
12
|
-
import {
|
|
12
|
+
import { columnsFromModel, listColumnNames } from "../live/columns-from-model.js";
|
|
13
|
+
import { displayRow, listDisplayChoices, loadedFields, shownListDisplay } from "./displays/list-displays.js";
|
|
13
14
|
import { parseSort, sortToOrderBy } from "./list-order.js";
|
|
14
15
|
import { listedResource } from "./current-address.js";
|
|
15
16
|
import { AppLink, useAppAddress } from "./use-app-address.js";
|
|
16
17
|
import { can, rowAllows } from "./permissions.js";
|
|
17
|
-
import { columnsFromModel } from "../live/columns-from-model.js";
|
|
18
18
|
import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
|
|
19
19
|
import { DataGrid } from "../data-grid/data-grid.js";
|
|
20
20
|
import { useLiveRows } from "../live/use-live-rows.js";
|
|
@@ -52,6 +52,9 @@ import { SavedList } from "./saved/saved-list.js";
|
|
|
52
52
|
import { SaveSearchButton } from "./searches/save-search-button.js";
|
|
53
53
|
import { SavedSearches } from "./searches/saved-searches.js";
|
|
54
54
|
import { useListCellEditor } from "./list-edit/use-list-cell-editor.js";
|
|
55
|
+
import { listHandOrder } from "./hand-order/hand-order.js";
|
|
56
|
+
import { HandSortedList } from "./hand-order/hand-sorted-list.js";
|
|
57
|
+
import { SortButton } from "./hand-order/sort-button.js";
|
|
55
58
|
import { useMemo, useState } from "react";
|
|
56
59
|
import { Download, Plus, SlidersHorizontal } from "lucide-react";
|
|
57
60
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -128,6 +131,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
128
131
|
const extras = parsed.ok ? parsed.extras : [];
|
|
129
132
|
const { orderBy, filterLayout: layout } = address;
|
|
130
133
|
const desktop = useIsDesktop();
|
|
134
|
+
const xs = useIsXs();
|
|
131
135
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
132
136
|
const scoped = scope ? [...extras, scope] : extras;
|
|
133
137
|
const config = useFilterConfig({
|
|
@@ -159,8 +163,12 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
159
163
|
orderBy,
|
|
160
164
|
related
|
|
161
165
|
});
|
|
162
|
-
const display =
|
|
163
|
-
const
|
|
166
|
+
const display = shownListDisplay(model, view, address.display);
|
|
167
|
+
const permissions = usePermissions(resource);
|
|
168
|
+
const handOrder = scope ? void 0 : listHandOrder(model, permissions);
|
|
169
|
+
const [sorting, setSorting] = useState(false);
|
|
170
|
+
const handSorting = sorting && handOrder !== void 0;
|
|
171
|
+
const pageScroll = handSorting || display?.kind !== "map";
|
|
164
172
|
usePageScroll(pageScroll);
|
|
165
173
|
const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
|
|
166
174
|
const scroll = useListScroll();
|
|
@@ -172,7 +180,6 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
172
180
|
snapshot: row
|
|
173
181
|
});
|
|
174
182
|
const singular = view?.names?.singular ?? humanize(resource);
|
|
175
|
-
const permissions = usePermissions(resource);
|
|
176
183
|
const cellEditing = useListCellEditor(model, view, permissions);
|
|
177
184
|
const { state: auth } = useAuth();
|
|
178
185
|
const restore = useRestore(model);
|
|
@@ -308,8 +315,14 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
308
315
|
},
|
|
309
316
|
children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), desktop && "Export"]
|
|
310
317
|
});
|
|
311
|
-
const
|
|
318
|
+
const sortButton = handOrder && /* @__PURE__ */ jsx(SortButton, {
|
|
319
|
+
sorting: handSorting,
|
|
320
|
+
onChange: setSorting,
|
|
321
|
+
iconOnly: !desktop
|
|
322
|
+
});
|
|
323
|
+
const displaySwitch = (menu) => !handSorting && /* @__PURE__ */ jsx(DisplaySwitch, {
|
|
312
324
|
view,
|
|
325
|
+
choices: listDisplayChoices(model, view, xs),
|
|
313
326
|
value: display?.name ?? tableDisplay,
|
|
314
327
|
onChange: (name) => set({ display: name }),
|
|
315
328
|
menu
|
|
@@ -392,7 +405,19 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
392
405
|
list: displayRows
|
|
393
406
|
});
|
|
394
407
|
};
|
|
395
|
-
const shown =
|
|
408
|
+
const shown = handSorting ? /* @__PURE__ */ jsxs("div", {
|
|
409
|
+
className: "flex flex-col gap-3",
|
|
410
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
411
|
+
className: "text-muted-foreground",
|
|
412
|
+
children: "Drag a row by its handle to move it. Every move is saved as you drop it."
|
|
413
|
+
}), /* @__PURE__ */ jsx(HandSortedList, {
|
|
414
|
+
model,
|
|
415
|
+
view,
|
|
416
|
+
field: handOrder,
|
|
417
|
+
filter: "",
|
|
418
|
+
columns: listColumnNames(model, view)
|
|
419
|
+
})]
|
|
420
|
+
}) : drawDisplay();
|
|
396
421
|
return /* @__PURE__ */ jsxs("div", {
|
|
397
422
|
className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
|
|
398
423
|
children: [
|
|
@@ -404,11 +429,13 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
404
429
|
aside: !desktop && displaySwitch(true),
|
|
405
430
|
actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
406
431
|
displaySwitch(false),
|
|
432
|
+
sortButton,
|
|
407
433
|
saveSearch,
|
|
408
434
|
importButton,
|
|
409
435
|
exportButton,
|
|
410
436
|
createButton
|
|
411
437
|
] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
438
|
+
sortButton,
|
|
412
439
|
saveSearch,
|
|
413
440
|
importButton,
|
|
414
441
|
exportButton,
|
|
@@ -419,11 +446,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
419
446
|
title: "The filter in the address could not be read",
|
|
420
447
|
error: new Error(parsed.errors.map((error) => error.message).join("; "))
|
|
421
448
|
}),
|
|
422
|
-
rows.error && !display && /* @__PURE__ */ jsx(ErrorBanner, {
|
|
449
|
+
rows.error && !display && !handSorting && /* @__PURE__ */ jsx(ErrorBanner, {
|
|
423
450
|
title: "Could not load rows",
|
|
424
451
|
error: rows.error
|
|
425
452
|
}),
|
|
426
|
-
view?.chart && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
|
|
453
|
+
view?.chart && !handSorting && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
|
|
427
454
|
model,
|
|
428
455
|
view,
|
|
429
456
|
chart: view.chart,
|
|
@@ -436,8 +463,8 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
436
463
|
noun: plural.toLowerCase(),
|
|
437
464
|
filter: loadFilter
|
|
438
465
|
})),
|
|
439
|
-
desktop ? desktopToolbar : phoneFilters,
|
|
440
|
-
desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
|
|
466
|
+
!handSorting && (desktop ? desktopToolbar : phoneFilters),
|
|
467
|
+
desktop && layout === "panel" && !handSorting ? /* @__PURE__ */ jsxs("div", {
|
|
441
468
|
className: "flex min-h-0 flex-1 gap-4",
|
|
442
469
|
children: [/* @__PURE__ */ jsx("div", {
|
|
443
470
|
className: pageScroll ? "sticky top-[calc(var(--pb-sticky-top,0px)+1rem)] flex max-h-[calc(100dvh-var(--pb-sticky-top,0px)-var(--pb-sticky-bottom,0px)-2rem)] shrink-0 self-start" : "flex shrink-0",
|
|
@@ -3,13 +3,13 @@ import { keys } from "../../data/query-keys.js";
|
|
|
3
3
|
import { useClient } from "../../data/api-provider.js";
|
|
4
4
|
import { useAdminMeta } from "../meta-gate.js";
|
|
5
5
|
import { fileShown, shownImage } from "../../live/file-values.js";
|
|
6
|
+
import { isMarkdown } from "../../markdown/markdown-hint.js";
|
|
6
7
|
import { displayValue } from "../../live/display-value.js";
|
|
7
8
|
import { AppLink } from "../use-app-address.js";
|
|
8
9
|
import { FileImage } from "../../live/file-image.js";
|
|
9
10
|
import { useShownWidget } from "../field-types/shown-widget.js";
|
|
10
11
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
11
12
|
import { Markdown } from "../../markdown/markdown.js";
|
|
12
|
-
import { isMarkdown } from "../../markdown/markdown-hint.js";
|
|
13
13
|
import { fetchLabels } from "../../live/relation-labels.js";
|
|
14
14
|
import { ValueBadge } from "../value-badge.js";
|
|
15
15
|
import { jsx, jsxs } from "react/jsx-runtime";
|
package/dist/app/record-page.js
CHANGED
|
@@ -22,16 +22,16 @@ import { fromDraft, isRequiredOnEdit } from "../live/field-values.js";
|
|
|
22
22
|
import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/record-mode.js";
|
|
23
23
|
import { isChosen, keyValue } from "./create/create-values.js";
|
|
24
24
|
import { ErrorBanner } from "./error-banner.js";
|
|
25
|
+
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
26
|
+
import { toneFor } from "../live/columns-from-model.js";
|
|
25
27
|
import { displayValue } from "../live/display-value.js";
|
|
26
28
|
import { filledFields } from "./filled-fields.js";
|
|
27
29
|
import { AppLink, useAppAddress } from "./use-app-address.js";
|
|
28
30
|
import { shownWidget } from "./field-types/shown-widget.js";
|
|
29
31
|
import { WidgetPart } from "./field-types/widget-part.js";
|
|
30
32
|
import { Markdown } from "../markdown/markdown.js";
|
|
31
|
-
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
32
33
|
import { FieldEditor } from "./field-editor.js";
|
|
33
34
|
import { can, fieldAccess } from "./permissions.js";
|
|
34
|
-
import { toneFor } from "../live/columns-from-model.js";
|
|
35
35
|
import { ValueBadge } from "./value-badge.js";
|
|
36
36
|
import { FieldValue } from "./pages/field-value.js";
|
|
37
37
|
import { fieldMessage } from "./field-errors.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { humanize } from "../../live/naming.js";
|
|
2
|
-
import { headerWidth
|
|
2
|
+
import { headerWidth } from "../../data-grid/content-widths.js";
|
|
3
|
+
import { listColumnNames } from "../../live/columns-from-model.js";
|
|
3
4
|
import { RelatedDots } from "./related-dots.js";
|
|
4
5
|
import { RecordCard } from "../displays/record-card.js";
|
|
5
6
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -2,10 +2,10 @@ import { cn } from "../../lib/cn.js";
|
|
|
2
2
|
import { toneDots } from "../../primitives/badge.js";
|
|
3
3
|
import { recordTitleField } from "../../live/model-helpers.js";
|
|
4
4
|
import { humanize } from "../../live/naming.js";
|
|
5
|
+
import { toneFor } from "../../live/columns-from-model.js";
|
|
5
6
|
import { displayValue } from "../../live/display-value.js";
|
|
6
7
|
import { displayRow } from "../displays/list-displays.js";
|
|
7
8
|
import { AppLink } from "../use-app-address.js";
|
|
8
|
-
import { toneFor } from "../../live/columns-from-model.js";
|
|
9
9
|
import { valueColor } from "../../live/value-color.js";
|
|
10
10
|
import { placeCard } from "./card-place.js";
|
|
11
11
|
import { dotCard } from "./dot-card.js";
|