@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.
- package/dist/activity-chart/activity-chart.js +1 -1
- package/dist/app/lines-plan.js +116 -0
- package/dist/app/list-order.js +7 -1
- package/dist/app/record-lines-cell.js +47 -0
- package/dist/app/record-lines.js +102 -25
- package/dist/app/record-messages.js +8 -2
- package/dist/app/record-page.js +13 -15
- package/dist/app/use-lines-draft.js +94 -35
- package/dist/app/use-lines.js +21 -7
- package/dist/app/use-record-editor.js +15 -10
- package/dist/app/use-record-lines.js +47 -0
- package/dist/auth/auth-gate.js +4 -1
- package/dist/auth/auth-provider.js +4 -1
- package/dist/auth/first-run-page.js +49 -40
- package/dist/auth/messages.js +4 -0
- package/dist/data/use-list.d.ts +27 -1
- package/dist/data/use-list.js +10 -3
- package/dist/data-grid/cell-text.js +1 -1
- package/dist/data-grid/cells.js +1 -1
- package/dist/inline-child-table/columns.js +7 -2
- package/dist/inline-child-table/inline-child-table.js +29 -48
- package/dist/inline-child-table/line-row.js +16 -44
- package/dist/inline-child-table/messages.js +2 -0
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-CBYlZ19-.js +9 -0
- package/dist/shell/assets/{calls-BjMBacTA.js → calls-BIXaKTs0.js} +2 -2
- package/dist/shell/assets/{index-DYpysp6_.js → index-BshKxX1Z.js} +2 -2
- package/dist/shell/assets/index-BvT0X9o1.css +2 -0
- package/dist/shell/assets/{records-map-BZPeBvM4.js → records-map-D1SJKyvj.js} +2 -2
- package/dist/shell/assets/{shared-protobase-ui-ByA8um6R.js → shared-protobase-ui-DibYt-TM.js} +2 -2
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +2 -2
- package/dist/shell/protobase.sw.js +2 -2
- package/dist/shell-source-maps/assets/app-CBYlZ19-.js.map +1 -0
- package/dist/shell-source-maps/assets/{calls-BjMBacTA.js.map → calls-BIXaKTs0.js.map} +1 -1
- package/dist/shell-source-maps/assets/{index-DYpysp6_.js.map → index-BshKxX1Z.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-BZPeBvM4.js.map → records-map-D1SJKyvj.js.map} +1 -1
- package/dist/shell-source-maps/assets/{shared-protobase-ui-ByA8um6R.js.map → shared-protobase-ui-DibYt-TM.js.map} +1 -1
- package/package.json +4 -4
- package/dist/inline-child-table/number-cell.js +0 -25
- package/dist/inline-child-table/totals-footer.js +0 -39
- package/dist/mocks/invoices.js +0 -13
- package/dist/mocks/products.js +0 -167
- package/dist/shell/assets/app-DqOuQJpX.js +0 -9
- package/dist/shell/assets/index-Be8nCyRW.css +0 -2
- 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 };
|
package/dist/app/list-order.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/app/record-lines.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
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("
|
|
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
|
-
|
|
100
|
+
style: { minWidth: lineTableMinWidth(tableColumns) },
|
|
27
101
|
children: /* @__PURE__ */ jsx(InlineChildTable, {
|
|
28
|
-
lines: draft.shown
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
package/dist/app/record-page.js
CHANGED
|
@@ -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
|
|
56
|
+
import { RecordLines } from "./record-lines.js";
|
|
57
57
|
import { draftStorageKey } from "./draft-storage.js";
|
|
58
|
-
import {
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
556
|
-
lines:
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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:
|
|
605
|
+
lines: lines.model
|
|
608
606
|
})
|
|
609
607
|
]
|
|
610
608
|
})]
|
|
@@ -1,44 +1,103 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
7
|
-
*
|
|
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 = (
|
|
10
|
-
const [
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
package/dist/app/use-lines.js
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
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:
|
|
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
|
|
15
|
-
|
|
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 };
|