@protobase/ui 0.63.2 → 0.63.4

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.
Files changed (46) hide show
  1. package/dist/activity-chart/activity-chart.js +1 -1
  2. package/dist/app/lines-plan.js +116 -0
  3. package/dist/app/list-order.js +7 -1
  4. package/dist/app/record-lines-cell.js +47 -0
  5. package/dist/app/record-lines.js +102 -25
  6. package/dist/app/record-messages.js +8 -2
  7. package/dist/app/record-page.js +13 -15
  8. package/dist/app/use-lines-draft.js +94 -35
  9. package/dist/app/use-lines.js +21 -7
  10. package/dist/app/use-record-editor.js +15 -10
  11. package/dist/app/use-record-lines.js +47 -0
  12. package/dist/auth/auth-gate.js +4 -1
  13. package/dist/auth/auth-provider.js +4 -1
  14. package/dist/auth/first-run-page.js +49 -40
  15. package/dist/auth/messages.js +4 -0
  16. package/dist/data/use-list.d.ts +27 -1
  17. package/dist/data/use-list.js +10 -3
  18. package/dist/data-grid/cell-text.js +1 -1
  19. package/dist/data-grid/cells.js +1 -1
  20. package/dist/inline-child-table/columns.js +7 -2
  21. package/dist/inline-child-table/inline-child-table.js +29 -48
  22. package/dist/inline-child-table/line-row.js +16 -44
  23. package/dist/inline-child-table/messages.js +2 -0
  24. package/dist/package.js +1 -1
  25. package/dist/shell/assets/app-CBYlZ19-.js +9 -0
  26. package/dist/shell/assets/{calls-BjMBacTA.js → calls-BIXaKTs0.js} +2 -2
  27. package/dist/shell/assets/{index-DYpysp6_.js → index-BshKxX1Z.js} +2 -2
  28. package/dist/shell/assets/index-BvT0X9o1.css +2 -0
  29. package/dist/shell/assets/{records-map-BZPeBvM4.js → records-map-D1SJKyvj.js} +2 -2
  30. package/dist/shell/assets/{shared-protobase-ui-ByA8um6R.js → shared-protobase-ui-DibYt-TM.js} +2 -2
  31. package/dist/shell/index.html +4 -4
  32. package/dist/shell/protobase.shell.json +2 -2
  33. package/dist/shell/protobase.sw.js +2 -2
  34. package/dist/shell-source-maps/assets/app-CBYlZ19-.js.map +1 -0
  35. package/dist/shell-source-maps/assets/{calls-BjMBacTA.js.map → calls-BIXaKTs0.js.map} +1 -1
  36. package/dist/shell-source-maps/assets/{index-DYpysp6_.js.map → index-BshKxX1Z.js.map} +1 -1
  37. package/dist/shell-source-maps/assets/{records-map-BZPeBvM4.js.map → records-map-D1SJKyvj.js.map} +1 -1
  38. package/dist/shell-source-maps/assets/{shared-protobase-ui-ByA8um6R.js.map → shared-protobase-ui-DibYt-TM.js.map} +1 -1
  39. package/package.json +4 -4
  40. package/dist/inline-child-table/number-cell.js +0 -25
  41. package/dist/inline-child-table/totals-footer.js +0 -39
  42. package/dist/mocks/invoices.js +0 -13
  43. package/dist/mocks/products.js +0 -167
  44. package/dist/shell/assets/app-DqOuQJpX.js +0 -9
  45. package/dist/shell/assets/index-Be8nCyRW.css +0 -2
  46. package/dist/shell-source-maps/assets/app-DqOuQJpX.js.map +0 -1
@@ -1,8 +1,8 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { useMediaQuery } from "../lib/use-media-query.js";
3
- import { activitySeries } from "../mocks/activity.js";
4
3
  import { formatInt } from "../format/number.js";
5
4
  import { formatDayShort } from "../format/date.js";
5
+ import { activitySeries } from "../mocks/activity.js";
6
6
  import { Segmented } from "../primitives/segmented.js";
7
7
  import { halfOverHalfDelta, totalCount } from "./activity-math.js";
8
8
  import { ActivityTooltip } from "./activity-tooltip.js";
@@ -0,0 +1,116 @@
1
+ import { recordId, recordKey } from "../live/model-helpers.js";
2
+ import { differs, toPatchValue } from "../live/field-values.js";
3
+ import { encodeKey } from "@protobase/schema/internal";
4
+ //#region src/app/lines-plan.ts
5
+ const emptyLinesState = {
6
+ added: [],
7
+ edits: {},
8
+ removed: []
9
+ };
10
+ /** The ids on screen, in order: the stored lines that are kept, then the new ones, as far as `order` does not say otherwise. */
11
+ const shownIds = (storedIds, state) => {
12
+ const kept = [...storedIds.filter((id) => !state.removed.includes(id)), ...state.added];
13
+ if (!state.order) return kept;
14
+ return [...state.order.filter((id) => kept.includes(id)), ...kept.filter((id) => !state.order.includes(id))];
15
+ };
16
+ /** The edited fields that differ from the stored line, as the API takes them. */
17
+ const changedData = (model, edits, line) => Object.fromEntries(Object.entries(edits).flatMap(([name, draft]) => {
18
+ const field = model.fields[name];
19
+ return field && differs(field, draft, line[name]) ? [[name, toPatchValue(field, draft)]] : [];
20
+ }));
21
+ /** The fields filled in on a new line; the database's defaults fill the rest. */
22
+ const filledData = (model, edits) => Object.fromEntries(Object.entries(edits).flatMap(([name, draft]) => {
23
+ const field = model.fields[name];
24
+ if (!field || field.type !== "boolean" && String(draft ?? "").trim() === "") return [];
25
+ return [[name, toPatchValue(field, draft)]];
26
+ }));
27
+ /**
28
+ * The batch that writes the lines as they are on screen: removed lines are deleted, edited ones updated with the ETag
29
+ * they were read with, and new ones created with the parent's key and their place. New lines after the kept ones are
30
+ * numbered on from them; new lines between kept ones need the kept ones out of the way first, so those are moved above
31
+ * every position in use, the new lines created at their places, and the kept ones moved to theirs. A kept line the batch
32
+ * wrote before is moved with `*`, as its ETag was checked by that earlier operation of the same transaction.
33
+ */
34
+ const planLinesOps = ({ model, position, link, stored, state }) => {
35
+ const plan = {
36
+ ops: [],
37
+ rows: []
38
+ };
39
+ const push = (op, row) => {
40
+ plan.ops.push(op);
41
+ plan.rows.push(row);
42
+ };
43
+ const byId = new Map(stored.map((line) => [recordId(model, line), line]));
44
+ const storedIds = [...byId.keys()];
45
+ const final = shownIds(storedIds, state);
46
+ const keyOf = (id) => recordKey(model, byId.get(id));
47
+ const etagOf = (id) => {
48
+ const etag = byId.get(id).etag;
49
+ return typeof etag === "string" ? etag : void 0;
50
+ };
51
+ for (const id of state.removed) {
52
+ if (!byId.has(id)) continue;
53
+ const etag = etagOf(id);
54
+ push({
55
+ op: "delete",
56
+ resource: model.name,
57
+ key: keyOf(id),
58
+ ...etag && { etag }
59
+ }, id);
60
+ }
61
+ const written = /* @__PURE__ */ new Set();
62
+ for (const [id, edits] of Object.entries(state.edits)) {
63
+ if (!byId.has(id) || state.removed.includes(id)) continue;
64
+ const data = changedData(model, edits, byId.get(id));
65
+ if (Object.keys(data).length === 0) continue;
66
+ const etag = etagOf(id);
67
+ push({
68
+ op: "update",
69
+ resource: model.name,
70
+ key: keyOf(id),
71
+ data,
72
+ ...etag && { etag }
73
+ }, id);
74
+ written.add(id);
75
+ }
76
+ const kept = final.filter((id) => byId.has(id));
77
+ const added = final.filter((id) => !byId.has(id));
78
+ const etags = (ids, wildcard) => Object.fromEntries(ids.flatMap((id) => {
79
+ const etag = wildcard || written.has(id) ? "*" : etagOf(id);
80
+ return etag ? [[encodeKey(keyOf(id)), etag]] : [];
81
+ }));
82
+ const reorder = (keys, start, tags) => ({
83
+ op: "reorder",
84
+ resource: model.name,
85
+ field: position,
86
+ keys,
87
+ ...start !== 1 && { start },
88
+ etags: tags
89
+ });
90
+ const create = (id, place, ref) => ({
91
+ op: "create",
92
+ resource: model.name,
93
+ data: {
94
+ ...filledData(model, state.edits[id] ?? {}),
95
+ [link.field]: link.value,
96
+ [position]: place
97
+ },
98
+ ...ref && { ref }
99
+ });
100
+ const placeOf = (line) => Number(line[position]) || 0;
101
+ const moved = kept.some((id, index) => id !== storedIds.filter((each) => kept.includes(each))[index]);
102
+ if (added.every((id) => final.indexOf(id) >= kept.length)) {
103
+ if (moved) push(reorder(kept.map(keyOf), 1, etags(kept, false)));
104
+ const last = moved ? kept.length : Math.max(0, ...kept.map((id) => placeOf(byId.get(id))));
105
+ added.forEach((id, index) => push(create(id, last + 1 + index), id));
106
+ return plan;
107
+ }
108
+ const above = Math.max(final.length, ...stored.map(placeOf)) + 1;
109
+ if (kept.length > 0) push(reorder(kept.map(keyOf), above, etags(kept, false)));
110
+ const refOf = (id) => `line${added.indexOf(id) + 1}`;
111
+ for (const id of added) push(create(id, final.indexOf(id) + 1, refOf(id)), id);
112
+ push(reorder(final.map((id) => byId.has(id) ? keyOf(id) : { $ref: refOf(id) }), 1, etags(kept, true)));
113
+ return plan;
114
+ };
115
+ //#endregion
116
+ export { emptyLinesState, planLinesOps, shownIds };
@@ -1,7 +1,13 @@
1
1
  //#region src/app/list-order.ts
2
+ /**
3
+ * The order a list opens in: the view's sort, else the primary key's when it is sortable. Otherwise none, as the API
4
+ * refuses a sort by a field that is not sortable and orders a list without one by its key anyway.
5
+ */
2
6
  const defaultOrderBy = (model, view) => {
3
7
  const sort = view?.list?.sort?.[0];
4
- return sort ? `${sort[0]} ${sort[1]}` : `${model.primaryKey[0]} asc`;
8
+ if (sort) return `${sort[0]} ${sort[1]}`;
9
+ const key = model.primaryKey[0];
10
+ return model.fields[key]?.sortable ? `${key} asc` : "";
5
11
  };
6
12
  const parseSort = (orderBy) => {
7
13
  const [columnId = "", direction] = orderBy.trim().split(/\s+/);
@@ -0,0 +1,47 @@
1
+ import { FieldControlContext } from "../primitives/field-control.js";
2
+ import { FieldEditor } from "./field-editor.js";
3
+ import { fieldMessage } from "./field-errors.js";
4
+ import { text } from "./record-messages.js";
5
+ import { useId } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/record-lines-cell.tsx
8
+ /** One field of a line, edited with the record page's own editor; its label is for screen readers, as the column heading shows it. */
9
+ const LineCell = ({ field, hints, label, lineNumber, resource, value, stored, relationLabel, relationTo, relationPicker, locked, problem, onChange }) => {
10
+ const id = useId();
11
+ const message = problem && fieldMessage(field, label, problem);
12
+ return /* @__PURE__ */ jsxs(FieldControlContext.Provider, {
13
+ value: {
14
+ id,
15
+ invalid: Boolean(problem),
16
+ required: false,
17
+ ...message && { describedBy: `${id}-error` }
18
+ },
19
+ children: [
20
+ /* @__PURE__ */ jsx("label", {
21
+ htmlFor: id,
22
+ className: "sr-only",
23
+ children: text("lineField", label, lineNumber)
24
+ }),
25
+ /* @__PURE__ */ jsx(FieldEditor, {
26
+ field,
27
+ hints,
28
+ value,
29
+ stored,
30
+ relationLabel,
31
+ relationTo,
32
+ relationPicker,
33
+ locked,
34
+ invalid: Boolean(problem),
35
+ resource,
36
+ onChange
37
+ }),
38
+ message && /* @__PURE__ */ jsx("p", {
39
+ id: `${id}-error`,
40
+ className: "px-1 pt-0.5 text-xs text-danger-text",
41
+ children: message
42
+ })
43
+ ]
44
+ });
45
+ };
46
+ //#endregion
47
+ export { LineCell };
@@ -1,40 +1,117 @@
1
- import { usePermissions } from "./meta-gate.js";
1
+ import { useAdminMeta, usePermissions } from "./meta-gate.js";
2
+ import { lineTableMinWidth } from "../inline-child-table/columns.js";
2
3
  import { InlineChildTable } from "../inline-child-table/inline-child-table.js";
4
+ import { formatInt } from "../format/number.js";
3
5
  import { Section } from "../record-view/section.js";
4
- import { can } from "./permissions.js";
6
+ import { humanize } from "../live/naming.js";
7
+ import { recordTitle } from "../live/model-helpers.js";
8
+ import { isChosen, keyValue } from "./create/create-values.js";
9
+ import { can, rowAllows } from "./permissions.js";
5
10
  import { text } from "./record-messages.js";
6
- import { jsx } from "react/jsx-runtime";
11
+ import { LineCell } from "./record-lines-cell.js";
12
+ import "./use-lines.js";
13
+ import { useState } from "react";
14
+ import { jsx, jsxs } from "react/jsx-runtime";
7
15
  //#region src/app/record-lines.tsx
8
- /** A resource that holds numbered lines of this one: it points at the parent and has `position` and `quantity`. */
9
- const findLinesResource = (resources, parent) => Object.values(resources).find((candidate) => candidate.name !== parent.name && candidate.fields.position && candidate.fields.quantity && Object.values(candidate.fields).some((field) => field.type === "relation" && field.relation?.resource === parent.name));
10
- const toLine = (record) => ({
11
- id: String(record.id),
12
- productId: String(record.productId ?? ""),
13
- description: String(record.description ?? ""),
14
- quantity: Number(record.quantity),
15
- unitPriceCents: Math.round(Number(record.unitPrice) * 100)
16
- });
17
- /** The lines of a record. Reordering only changes `draft`; the record's Save writes it, in one batch. */
18
- const RecordLines = ({ lines, draft, vatRate, saving, locked = false }) => {
19
- const canReorder = can(usePermissions(lines.name), "update");
20
- return /* @__PURE__ */ jsx(Section, {
16
+ /**
17
+ * The lines of a record as a table of their view's columns, edited in place: a line's fields, new lines, removed ones
18
+ * and their order only change the draft; the record's Save writes it all, in one batch. A line the server turns down
19
+ * says why on its row.
20
+ */
21
+ const RecordLines = ({ lines, editable, saving }) => {
22
+ const { resources, views } = useAdminMeta();
23
+ const { model, columns, data, draft } = lines;
24
+ const permissions = usePermissions(model.name);
25
+ const view = views[model.name];
26
+ const [picked, setPicked] = useState({});
27
+ const open = editable && data.complete && !data.loading && !saving;
28
+ const canUpdate = open && can(permissions, "update");
29
+ const canCreate = open && can(permissions, "create");
30
+ const canDelete = open && can(permissions, "delete");
31
+ const writable = (line) => line.stored ? canUpdate && rowAllows(line.stored, "update") : canCreate;
32
+ const placeOf = (line) => draft.shown.indexOf(line) + 1;
33
+ const rejected = (line) => draft.rejection?.id === line.id ? draft.rejection : void 0;
34
+ const tableColumns = columns.map((name) => {
35
+ const field = model.fields[name];
36
+ const hints = view?.fields[name];
37
+ const label = hints?.label ?? humanize(name);
38
+ const target = field.relation ? resources[field.relation.resource] : void 0;
39
+ const pickable = target && target.primaryKey.length === 1 ? target : void 0;
40
+ return {
41
+ key: name,
42
+ label,
43
+ ...[
44
+ "integer",
45
+ "bigint",
46
+ "decimal"
47
+ ].includes(field.type) && {
48
+ align: "right",
49
+ width: "minmax(6rem,0.6fr)"
50
+ },
51
+ cell: (line) => {
52
+ const value = draft.value(line.id, name);
53
+ const chosen = value === null || value === void 0 || value === "" ? void 0 : String(value);
54
+ return /* @__PURE__ */ jsx(LineCell, {
55
+ field,
56
+ hints,
57
+ label,
58
+ lineNumber: placeOf(line),
59
+ resource: model.name,
60
+ value,
61
+ stored: line.stored?.[name],
62
+ relationLabel: chosen === void 0 ? void 0 : picked[`${line.id}/${name}`] ?? data.labels[name]?.[chosen],
63
+ relationTo: pickable && chosen !== void 0 ? {
64
+ kind: "record",
65
+ resource: pickable.name,
66
+ key: chosen
67
+ } : void 0,
68
+ relationPicker: pickable && {
69
+ target: pickable,
70
+ view: views[pickable.name]
71
+ },
72
+ locked: !writable(line),
73
+ problem: rejected(line)?.fields[name],
74
+ onChange: (next) => {
75
+ if (!pickable || !isChosen(next)) return draft.change(line.id, name, next);
76
+ setPicked((current) => ({
77
+ ...current,
78
+ [`${line.id}/${name}`]: next.label
79
+ }));
80
+ draft.change(line.id, name, keyValue(pickable, next.key));
81
+ }
82
+ });
83
+ }
84
+ };
85
+ });
86
+ const lineError = (line) => {
87
+ const rejection = rejected(line);
88
+ if (!rejection || columns.some((name) => rejection.fields[name])) return void 0;
89
+ return rejection.message;
90
+ };
91
+ return /* @__PURE__ */ jsxs(Section, {
21
92
  title: text("lines"),
22
93
  help: text("linesHelp"),
23
- children: /* @__PURE__ */ jsx("div", {
94
+ children: [!data.complete && /* @__PURE__ */ jsx("p", {
95
+ className: "mb-2 text-muted-foreground",
96
+ children: text("tooManyLines", formatInt(1e3))
97
+ }), /* @__PURE__ */ jsx("div", {
24
98
  className: "-mx-4 overflow-x-auto px-4 [-webkit-overflow-scrolling:touch] md:mx-0 md:px-0",
25
99
  children: /* @__PURE__ */ jsx("div", {
26
- className: "min-w-[560px]",
100
+ style: { minWidth: lineTableMinWidth(tableColumns) },
27
101
  children: /* @__PURE__ */ jsx(InlineChildTable, {
28
- lines: draft.shown.map(toLine),
29
- onChange: () => void 0,
102
+ lines: draft.shown,
103
+ columns: tableColumns,
104
+ lineName: (line) => recordTitle(model, view, line.stored ?? {}, "") || text("lineNumber", placeOf(line)),
30
105
  onReorder: (next) => draft.setOrder(next.map((line) => line.id)),
31
- vatRate,
32
- readOnly: true,
33
- reorderable: canReorder && !saving && !locked
106
+ reorderable: canUpdate,
107
+ onAdd: canCreate ? () => void draft.add() : void 0,
108
+ onRemove: canCreate || canDelete ? (line) => draft.remove(line.id) : void 0,
109
+ removable: (line) => line.stored ? canDelete && rowAllows(line.stored, "delete") : canCreate,
110
+ lineError
34
111
  })
35
112
  })
36
- })
113
+ })]
37
114
  });
38
115
  };
39
116
  //#endregion
40
- export { RecordLines, findLinesResource };
117
+ export { RecordLines };
@@ -36,7 +36,10 @@ const text = defineMessages({
36
36
  couldNotLoadRelated: "Could not load",
37
37
  relatedRecords: "Related records",
38
38
  lines: "Lines",
39
- linesHelp: "What the document bills for. Drag the handle, or use the menu, to change the order; Save applies it."
39
+ linesHelp: "What the document bills for. Edit a line in place, add one, or drag the handle to change the order; Save applies it all.",
40
+ lineNumber: (place) => `Line ${place}`,
41
+ lineField: (label, place) => `${label}, line ${place}`,
42
+ tooManyLines: (limit) => `This record has more than ${limit} lines, so they show here read-only.`
40
43
  },
41
44
  nl: {
42
45
  details: "Details",
@@ -73,7 +76,10 @@ const text = defineMessages({
73
76
  couldNotLoadRelated: "Kon niet laden",
74
77
  relatedRecords: "Gerelateerde records",
75
78
  lines: "Regels",
76
- linesHelp: "Waar het document voor factureert. Sleep de greep, of gebruik het menu, om de volgorde te wijzigen; Bewaren past hem toe."
79
+ linesHelp: "Waar het document voor factureert. Wijzig een regel ter plekke, voeg er een toe, of sleep de greep om de volgorde te wijzigen; Bewaren past alles toe.",
80
+ lineNumber: (place) => `Regel ${place}`,
81
+ lineField: (label, place) => `${label}, regel ${place}`,
82
+ tooManyLines: (limit) => `Dit record heeft meer dan ${limit} regels, dus ze staan hier alleen ter inzage.`
77
83
  }
78
84
  });
79
85
  //#endregion
@@ -53,10 +53,9 @@ import { RecordChartSection } from "./record-chart/record-chart-section.js";
53
53
  import { RecordActions } from "./pages/record-actions.js";
54
54
  import { groupOpen, toggledOpen } from "./group-open.js";
55
55
  import { attentionTab, shownTab, splitTabs } from "./record-tabs.js";
56
- import { RecordLines, findLinesResource } from "./record-lines.js";
56
+ import { RecordLines } from "./record-lines.js";
57
57
  import { draftStorageKey } from "./draft-storage.js";
58
- import { useLines } from "./use-lines.js";
59
- import { useLinesDraft } from "./use-lines-draft.js";
58
+ import { useRecordLines } from "./use-record-lines.js";
60
59
  import { GallerySection } from "./gallery/gallery-section.js";
61
60
  import { HistorySection } from "./history/history-section.js";
62
61
  import { CommentsSection } from "./comments/comments-section.js";
@@ -67,7 +66,7 @@ import { useRelatedRecords } from "./use-related-records.js";
67
66
  import { IncomingCard } from "./incoming-card.js";
68
67
  import { RelatedSection } from "./related-section.js";
69
68
  import { relatedValue } from "./related-value.js";
70
- import { Fragment, useEffect, useMemo } from "react";
69
+ import { Fragment, useEffect } from "react";
71
70
  import { Check } from "lucide-react";
72
71
  import { isApiError } from "@protobase/client";
73
72
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -174,9 +173,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
174
173
  const view = meta.views[model.name];
175
174
  const title = useRecordTitle()(model, view, stored.record, recordKey);
176
175
  const { state: auth } = useAuth();
177
- const linesModel = useMemo(() => findLinesResource(meta.resources, model), [meta.resources, model]);
178
- const linesData = useLines(linesModel, model, recordKey);
179
- const linesDraft = useLinesDraft(linesModel, linesData.items);
176
+ const lines = useRecordLines(model, recordKey);
180
177
  const editor = useRecordEditor({
181
178
  model,
182
179
  stored,
@@ -184,7 +181,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
184
181
  mode: view?.saveFeedback ?? "toast",
185
182
  subject: title,
186
183
  draftKey: !preview && auth.kind === "signed-in" ? draftStorageKey(auth.user.id, model.name, recordKey) : void 0,
187
- lines: linesDraft
184
+ lines: lines.draft
188
185
  });
189
186
  const queryClient = useQueryClient();
190
187
  const client = useClient();
@@ -552,12 +549,13 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
552
549
  children: tab.items.flatMap(mainItem)
553
550
  }, `tab:${tab.title}`),
554
551
  after.flatMap(mainItem),
555
- linesModel && !preview && /* @__PURE__ */ jsx(RecordLines, {
556
- lines: linesModel,
557
- draft: linesDraft,
558
- vatRate: Number(stored.record.vatRate ?? 21) / 100,
559
- saving: editor.saving,
560
- locked: shown !== "form"
552
+ lines.model && !preview && /* @__PURE__ */ jsx(RecordLines, {
553
+ lines: {
554
+ ...lines,
555
+ model: lines.model
556
+ },
557
+ editable: shown === "form",
558
+ saving: editor.saving
561
559
  })
562
560
  ]
563
561
  }), /* @__PURE__ */ jsxs("aside", {
@@ -604,7 +602,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
604
602
  model,
605
603
  recordKey,
606
604
  layout,
607
- lines: linesModel
605
+ lines: lines.model
608
606
  })
609
607
  ]
610
608
  })]
@@ -1,44 +1,103 @@
1
- import { recordKey } from "../live/model-helpers.js";
2
- import { useCallback, useMemo, useState } from "react";
3
- import { encodeKey } from "@protobase/schema/internal";
1
+ import { recordId } from "../live/model-helpers.js";
2
+ import { toFieldDraft } from "../live/field-values.js";
3
+ import { fieldErrors } from "./field-errors.js";
4
+ import { emptyLinesState, planLinesOps, shownIds } from "./lines-plan.js";
5
+ import { useMemo, useRef, useState } from "react";
4
6
  //#region src/app/use-lines-draft.ts
7
+ const without = (source, name) => Object.fromEntries(Object.entries(source).filter(([key]) => key !== name));
5
8
  /**
6
- * The order the person has given the lines of a record, kept in the form until Save writes it, together with the
7
- * record, in one batch. `shown` is the lines in that order; `op()` is the batch operation that makes it permanent.
9
+ * The changes the person made to a record's lines, kept in the form until Save writes them, together with the record,
10
+ * in one batch: the order, new lines, typed fields and removed lines. `shown` is the lines as they are on screen;
11
+ * `plan()` is the batch that makes them permanent.
8
12
  */
9
- const useLinesDraft = (model, stored) => {
10
- const [order, setOrder] = useState();
11
- const ids = stored.map((line) => String(line.id));
12
- const shown = useMemo(() => {
13
- if (!order) return stored;
14
- const known = order.flatMap((id) => stored.filter((line) => String(line.id) === id));
15
- const added = stored.filter((line) => !order.includes(String(line.id)));
16
- return [...known, ...added];
17
- }, [order, stored]);
18
- const shownIds = shown.map((line) => String(line.id));
19
- const dirty = order !== void 0 && shownIds.some((id, index) => id !== ids[index]);
20
- const op = useCallback(() => {
21
- if (!model || !dirty) return [];
22
- return [{
23
- op: "reorder",
24
- resource: model.name,
25
- field: "position",
26
- keys: shown.map((line) => recordKey(model, line)),
27
- etags: Object.fromEntries(shown.flatMap((line) => typeof line.etag === "string" ? [[encodeKey(recordKey(model, line)), line.etag]] : []))
28
- }];
29
- }, [
30
- model,
31
- dirty,
32
- shown
33
- ]);
13
+ const useLinesDraft = (target, stored) => {
14
+ const [state, setState] = useState(emptyLinesState);
15
+ const [rejection, setRejection] = useState();
16
+ const nextId = useRef(1);
17
+ const byId = useMemo(() => new Map(target ? stored.map((line) => [recordId(target.model, line), line]) : []), [target, stored]);
18
+ const shown = shownIds([...byId.keys()], state).map((id) => ({
19
+ id,
20
+ stored: byId.get(id)
21
+ }));
22
+ const plan = () => target ? planLinesOps({
23
+ ...target,
24
+ stored,
25
+ state
26
+ }) : {
27
+ ops: [],
28
+ rows: []
29
+ };
30
+ const dirty = plan().ops.length > 0;
31
+ const order = useMemo(() => {
32
+ const kept = shownIds([...byId.keys()], state).filter((id) => byId.has(id));
33
+ const before = [...byId.keys()].filter((id) => kept.includes(id));
34
+ return state.order !== void 0 && kept.some((id, index) => id !== before[index]) ? kept : void 0;
35
+ }, [byId, state]);
36
+ /** The value a line's field editor holds: what was typed, else the stored value as the editor takes it. */
37
+ const value = (id, name) => {
38
+ const typed = state.edits[id];
39
+ if (typed && name in typed) return typed[name];
40
+ const field = target?.model.fields[name];
41
+ const current = byId.get(id)?.[name];
42
+ if (!field) return current;
43
+ return ["boolean", "file"].includes(field.type) ? current : toFieldDraft(field, current);
44
+ };
45
+ const forget = (id) => setRejection((current) => current?.id === id ? void 0 : current);
34
46
  return {
35
47
  shown,
36
48
  dirty,
37
- /** The ids in the order on screen, for keeping a draft. */
38
- order: dirty ? shownIds : void 0,
39
- setOrder: (next) => setOrder(next),
40
- reset: () => setOrder(void 0),
41
- op
49
+ /** The stored lines' ids in the order on screen, once moved, for keeping a draft. */
50
+ order,
51
+ value,
52
+ change: (id, name, next) => {
53
+ forget(id);
54
+ setState((current) => ({
55
+ ...current,
56
+ edits: {
57
+ ...current.edits,
58
+ [id]: {
59
+ ...current.edits[id],
60
+ [name]: next
61
+ }
62
+ }
63
+ }));
64
+ },
65
+ /** Adds an empty line at the end; the database's defaults fill what is left empty when it is saved. */
66
+ add: () => {
67
+ const id = `new:${nextId.current++}`;
68
+ setState((current) => ({
69
+ ...current,
70
+ added: [...current.added, id]
71
+ }));
72
+ return id;
73
+ },
74
+ remove: (id) => {
75
+ forget(id);
76
+ setState((current) => current.added.includes(id) ? {
77
+ ...current,
78
+ added: current.added.filter((each) => each !== id),
79
+ edits: without(current.edits, id)
80
+ } : {
81
+ ...current,
82
+ removed: [...current.removed, id]
83
+ });
84
+ },
85
+ setOrder: (next) => setState((current) => ({
86
+ ...current,
87
+ order: next
88
+ })),
89
+ reset: () => {
90
+ setState(emptyLinesState);
91
+ setRejection(void 0);
92
+ },
93
+ /** The server turned down the write of line `id`: its fields and message show on that line until it changes. */
94
+ reject: (id, error) => setRejection({
95
+ id,
96
+ fields: fieldErrors(error),
97
+ message: error.message
98
+ }),
99
+ rejection,
100
+ plan
42
101
  };
43
102
  };
44
103
  //#endregion
@@ -1,19 +1,33 @@
1
1
  import { useList } from "../data/use-list.js";
2
+ import { useEffect, useMemo } from "react";
2
3
  //#region src/app/use-lines.ts
4
+ /** The most lines a record's line editor loads; a record with more shows them read-only, as it cannot save them as a whole. */
5
+ const linesLimit = 1e3;
3
6
  const byPosition = (a, b) => Number(a.position) - Number(b.position);
4
- /** The numbered lines of a record, in position order, as the server has them. Nothing is fetched without a lines resource. */
5
- const useLines = (lines, parent, parentKey) => {
6
- const link = lines && Object.values(lines.fields).find((field) => field.type === "relation" && field.relation?.resource === parent.name);
7
+ /**
8
+ * The numbered lines of a record, in position order, as the server has them: every page, up to `linesLimit`, each as
9
+ * large as the API gives. `labels` names the records their relations point at. Nothing is fetched without a lines resource.
10
+ */
11
+ const useLines = (lines, link, parentKey, labels) => {
7
12
  const list = useList(lines?.name ?? "", {
8
13
  enabled: Boolean(lines && link),
9
14
  filter: link ? `${link.name} = "${parentKey}"` : void 0,
10
- pageSize: 200,
15
+ pageSize: 500,
16
+ labels,
11
17
  ...lines?.fields.position?.sortable && { orderBy: "position asc" }
12
18
  });
19
+ const items = useMemo(() => [...list.items].sort(byPosition), [list.data]);
20
+ const more = list.hasNextPage && list.items.length < 1e3;
21
+ useEffect(() => {
22
+ if (more && !list.isFetchingNextPage) list.fetchNextPage();
23
+ }, [more, list.isFetchingNextPage]);
13
24
  return {
14
- items: [...list.items].sort(byPosition),
15
- loading: list.isPending && Boolean(lines)
25
+ items,
26
+ labels: list.labels,
27
+ loading: (list.isPending || more) && Boolean(lines),
28
+ /** Every line is loaded, so the lines can be saved as a whole. */
29
+ complete: !list.hasNextPage
16
30
  };
17
31
  };
18
32
  //#endregion
19
- export { useLines };
33
+ export { linesLimit, useLines };