@protobase/ui 0.18.0 → 0.19.0
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.d.ts +1 -0
- package/dist/app/current-address.js +2 -1
- package/dist/app/gallery/gallery-items.js +37 -0
- package/dist/app/gallery/gallery-layout.js +17 -0
- package/dist/app/gallery/gallery-section.js +124 -0
- package/dist/app/gallery/lightbox.js +121 -0
- package/dist/app/gallery/photo-details.js +49 -0
- package/dist/app/gallery/use-gallery-photos.js +38 -0
- package/dist/app/record-lines.js +2 -2
- package/dist/app/record-mode/record-mode.js +31 -0
- package/dist/app/record-page.js +109 -28
- package/dist/app/use-live-save.js +4 -1
- package/dist/package.js +1 -1
- package/dist/record-view/spec-table.js +25 -0
- package/dist/shell/assets/app-BS7ufaBT.js +66 -0
- package/dist/shell/assets/index-BLnOMDD8.css +2 -0
- package/dist/shell/assets/{index-BXEkf4-O.js → index-CLXqLf4p.js} +1 -1
- package/dist/shell/assets/{maplibre-DCGyNbM2.js → maplibre-r-WEXdlV.js} +1 -1
- package/dist/shell/assets/{record-map-DEn8Ulev.js → record-map-Bvls7qdn.js} +1 -1
- package/dist/shell/assets/{records-map-DdLiVxEp.js → records-map-iNBfciYy.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-zMOavpbt.js → shared-protobase-ui-9PLdWR62.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-j50iAbLC.js +0 -66
- package/dist/shell/assets/index-D0AzKE47.css +0 -2
package/dist/app/record-page.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { inApp } from "../app-shell/breadcrumb.js";
|
|
1
3
|
import { useAuth } from "../auth/auth-provider.js";
|
|
2
4
|
import { formatRelativeTime } from "../format/date.js";
|
|
3
|
-
import { Button } from "../primitives/button.js";
|
|
5
|
+
import { Button, buttonClass } from "../primitives/button.js";
|
|
4
6
|
import { keys } from "../data/query-keys.js";
|
|
5
7
|
import { useClient } from "../data/api-provider.js";
|
|
6
8
|
import { useNavigationGuard, useRouter } from "./router.js";
|
|
@@ -10,7 +12,7 @@ import { humanize } from "../live/naming.js";
|
|
|
10
12
|
import { useProjectUi } from "./pages/project-ui.js";
|
|
11
13
|
import { displayValue } from "../live/display-value.js";
|
|
12
14
|
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
13
|
-
import { useAppAddress } from "./use-app-address.js";
|
|
15
|
+
import { AppLink, useAppAddress } from "./use-app-address.js";
|
|
14
16
|
import { ErrorBanner } from "./error-banner.js";
|
|
15
17
|
import { shownWidget } from "./field-types/shown-widget.js";
|
|
16
18
|
import { WidgetPart } from "./field-types/widget-part.js";
|
|
@@ -20,10 +22,12 @@ import { Field } from "../record-view/field.js";
|
|
|
20
22
|
import { Section } from "../record-view/section.js";
|
|
21
23
|
import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record-sidebar.js";
|
|
22
24
|
import { RecordHeader } from "../record-view/record-header.js";
|
|
25
|
+
import { SpecTable } from "../record-view/spec-table.js";
|
|
23
26
|
import { toneFor } from "../live/columns-from-model.js";
|
|
24
27
|
import { useDeleteFlow } from "./delete/use-delete-flow.js";
|
|
25
28
|
import { useRestore } from "./delete/use-restore.js";
|
|
26
29
|
import { ValueBadge } from "./value-badge.js";
|
|
30
|
+
import { FieldValue } from "./pages/field-value.js";
|
|
27
31
|
import { auditField } from "../live/audit-fields.js";
|
|
28
32
|
import { CustomSection } from "./custom-section.js";
|
|
29
33
|
import { MapSection } from "./map/map-section.js";
|
|
@@ -32,11 +36,13 @@ import { RecordLines, findLinesResource } from "./record-lines.js";
|
|
|
32
36
|
import { draftStorageKey } from "./draft-storage.js";
|
|
33
37
|
import { useLines } from "./use-lines.js";
|
|
34
38
|
import { useLinesDraft } from "./use-lines-draft.js";
|
|
39
|
+
import { GallerySection } from "./gallery/gallery-section.js";
|
|
40
|
+
import { fieldHelp, helpShown, recordShown } from "./record-mode/record-mode.js";
|
|
35
41
|
import { useRecordEditor } from "./use-record-editor.js";
|
|
36
42
|
import { useRelatedRecords } from "./use-related-records.js";
|
|
37
43
|
import { RelatedSection } from "./related-section.js";
|
|
38
44
|
import { Fragment, useMemo } from "react";
|
|
39
|
-
import { Check } from "lucide-react";
|
|
45
|
+
import { Check, Pencil } from "lucide-react";
|
|
40
46
|
import { isApiError } from "@protobase/client";
|
|
41
47
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
42
48
|
import { useQueryClient } from "@tanstack/react-query";
|
|
@@ -45,16 +51,17 @@ import { enumLabel } from "@protobase/schema";
|
|
|
45
51
|
const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
|
|
46
52
|
/**
|
|
47
53
|
* The view's layout, or one section with the editable fields when the view has none. A user who may only read the
|
|
48
|
-
* resource has every field read-only, so they get every field, locked, rather than an empty section
|
|
54
|
+
* resource has every field read-only, so they get every field, locked, rather than an empty section; so does the read
|
|
55
|
+
* view, which shows the record rather than what can be changed.
|
|
49
56
|
*/
|
|
50
|
-
const layoutFor = (model, view) => {
|
|
57
|
+
const layoutFor = (model, view, read) => {
|
|
51
58
|
if (view && view.layout.length > 0) return view.layout;
|
|
52
59
|
const shown = Object.values(model.fields).filter((field) => !hidden(model, field.name));
|
|
53
60
|
const editable = shown.filter((field) => !field.readOnly);
|
|
54
61
|
return [{
|
|
55
62
|
kind: "section",
|
|
56
63
|
title: "Details",
|
|
57
|
-
fields: (editable.length > 0 ? editable : shown).map((field) => field.name)
|
|
64
|
+
fields: (editable.length > 0 && !read ? editable : shown).map((field) => field.name)
|
|
58
65
|
}];
|
|
59
66
|
};
|
|
60
67
|
const RecordPage = ({ resource, recordKey }) => {
|
|
@@ -92,7 +99,7 @@ const RecordPage = ({ resource, recordKey }) => {
|
|
|
92
99
|
const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
93
100
|
const meta = useAdminMeta();
|
|
94
101
|
const { basePath } = useRouter();
|
|
95
|
-
const { go, open } = useAppAddress();
|
|
102
|
+
const { address, go, open } = useAppAddress();
|
|
96
103
|
const view = meta.views[model.name];
|
|
97
104
|
const singular = view?.names?.singular ?? humanize(model.name);
|
|
98
105
|
const title = recordTitle(model, view, stored.record, recordKey);
|
|
@@ -109,12 +116,23 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
109
116
|
draftKey: auth.kind === "signed-in" ? draftStorageKey(auth.user.id, model.name, recordKey) : void 0,
|
|
110
117
|
lines: linesDraft
|
|
111
118
|
});
|
|
112
|
-
useNavigationGuard(editor.dirty);
|
|
113
119
|
const queryClient = useQueryClient();
|
|
114
120
|
const client = useClient();
|
|
115
121
|
const permissions = usePermissions(model.name);
|
|
116
122
|
const restore = useRestore(model);
|
|
117
123
|
const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
|
|
124
|
+
const { shown, offersEdit, editing } = recordShown(view?.recordMode, canUpdate, address?.kind === "record" && address.edit === true);
|
|
125
|
+
useNavigationGuard(editor.dirty && shown === "form");
|
|
126
|
+
const readAddress = {
|
|
127
|
+
kind: "record",
|
|
128
|
+
resource: model.name,
|
|
129
|
+
key: recordKey
|
|
130
|
+
};
|
|
131
|
+
const editAddress = {
|
|
132
|
+
...readAddress,
|
|
133
|
+
edit: true
|
|
134
|
+
};
|
|
135
|
+
const leavesForm = shown === "form" && view?.recordMode !== void 0 && view.recordMode !== "edit";
|
|
118
136
|
const canDelete = can(permissions, "delete") && stored.permissions?.delete !== false;
|
|
119
137
|
const remove = useDeleteFlow({
|
|
120
138
|
model,
|
|
@@ -128,7 +146,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
128
146
|
});
|
|
129
147
|
const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
|
|
130
148
|
const { fields: fieldTypes } = useProjectUi();
|
|
131
|
-
const layout = layoutFor(model, view);
|
|
149
|
+
const layout = layoutFor(model, view, shown === "read");
|
|
132
150
|
const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
|
|
133
151
|
const status = model.fields.status?.type === "enum" ? String(stored.record.status) : void 0;
|
|
134
152
|
const updatedAt = auditField(model, "updated");
|
|
@@ -142,7 +160,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
142
160
|
const conflict = editor.conflicts[name];
|
|
143
161
|
return /* @__PURE__ */ jsx(Field, {
|
|
144
162
|
label: hint(name)?.label ?? humanize(name),
|
|
145
|
-
help: hint(name)
|
|
163
|
+
help: fieldHelp(hint(name), view, editing),
|
|
146
164
|
error: editor.errors[name],
|
|
147
165
|
wide: Boolean(hint(name)?.multiline),
|
|
148
166
|
conflict: conflict === void 0 ? void 0 : {
|
|
@@ -163,7 +181,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
163
181
|
stored: stored.record[name],
|
|
164
182
|
relationLabel: related.labels[name],
|
|
165
183
|
relationTo: related.addresses[name],
|
|
166
|
-
locked:
|
|
184
|
+
locked: shown !== "form" || access === "read",
|
|
167
185
|
invalid: Boolean(editor.errors[name]),
|
|
168
186
|
reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
|
|
169
187
|
resource: model.name,
|
|
@@ -171,6 +189,30 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
171
189
|
})
|
|
172
190
|
}, field.sensitive ? `${name}:${stored.etag}` : name);
|
|
173
191
|
};
|
|
192
|
+
const readRow = (name) => {
|
|
193
|
+
const field = model.fields[name];
|
|
194
|
+
const value = field.sensitive ? /* @__PURE__ */ jsx(FieldEditor, {
|
|
195
|
+
field,
|
|
196
|
+
hints: hint(name),
|
|
197
|
+
value: "",
|
|
198
|
+
stored: stored.record[name],
|
|
199
|
+
locked: true,
|
|
200
|
+
reveal: () => client.reveal(model.name, recordKey, name),
|
|
201
|
+
resource: model.name,
|
|
202
|
+
onChange: (next) => editor.change(name, next)
|
|
203
|
+
}) : /* @__PURE__ */ jsx(FieldValue, {
|
|
204
|
+
model,
|
|
205
|
+
field,
|
|
206
|
+
value: stored.record[name],
|
|
207
|
+
relationLabel: related.labels[name]
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
label: hint(name)?.label ?? humanize(name),
|
|
211
|
+
help: fieldHelp(hint(name), view, editing),
|
|
212
|
+
wide: Boolean(hint(name)?.multiline),
|
|
213
|
+
value
|
|
214
|
+
};
|
|
215
|
+
};
|
|
174
216
|
const firstSidebar = layout.findIndex((item) => item.kind === "sidebar");
|
|
175
217
|
const summary = /* @__PURE__ */ jsx(MetadataCard, {
|
|
176
218
|
title: "Summary",
|
|
@@ -206,34 +248,51 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
206
248
|
etag: stored.etag,
|
|
207
249
|
permissions: stored.permissions
|
|
208
250
|
};
|
|
209
|
-
const
|
|
251
|
+
const save = async () => {
|
|
252
|
+
if (await editor.save() && leavesForm) go(readAddress, { replace: true });
|
|
253
|
+
};
|
|
254
|
+
const deleteButton = canDelete && /* @__PURE__ */ jsx(Button, {
|
|
255
|
+
variant: "danger",
|
|
256
|
+
className: "max-md:min-h-11",
|
|
257
|
+
onClick: () => void remove.start({
|
|
258
|
+
key: recordKey,
|
|
259
|
+
title,
|
|
260
|
+
etag: stored.etag,
|
|
261
|
+
snapshot: stored.record
|
|
262
|
+
}),
|
|
263
|
+
children: "Delete"
|
|
264
|
+
});
|
|
265
|
+
const saveControls = offersEdit ? /* @__PURE__ */ jsxs(Fragment$1, { children: [deleteButton, /* @__PURE__ */ jsxs(AppLink, {
|
|
266
|
+
to: editAddress,
|
|
267
|
+
className: cn(buttonClass({ variant: "primary" }), "max-md:min-h-11 max-md:flex-1"),
|
|
268
|
+
children: [/* @__PURE__ */ jsx(Pencil, {
|
|
269
|
+
className: "size-3.5",
|
|
270
|
+
"aria-hidden": true
|
|
271
|
+
}), "Edit"]
|
|
272
|
+
})] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
210
273
|
editor.dirty && /* @__PURE__ */ jsx("span", {
|
|
211
274
|
className: "text-xs text-muted-foreground",
|
|
212
275
|
children: "Unsaved changes"
|
|
213
276
|
}),
|
|
214
|
-
editor.dirty && canUpdate && /* @__PURE__ */ jsx(Button, {
|
|
277
|
+
editor.dirty && canUpdate && !leavesForm && /* @__PURE__ */ jsx(Button, {
|
|
215
278
|
className: "max-md:min-h-11",
|
|
216
279
|
disabled: editor.saving,
|
|
217
280
|
onClick: editor.discard,
|
|
218
281
|
children: "Discard"
|
|
219
282
|
}),
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
className: "max-md:min-h-11",
|
|
223
|
-
onClick: () =>
|
|
224
|
-
|
|
225
|
-
title,
|
|
226
|
-
etag: stored.etag,
|
|
227
|
-
snapshot: stored.record
|
|
228
|
-
}),
|
|
229
|
-
children: "Delete"
|
|
283
|
+
leavesForm && /* @__PURE__ */ jsx(AppLink, {
|
|
284
|
+
to: readAddress,
|
|
285
|
+
className: cn(buttonClass(), "max-md:min-h-11"),
|
|
286
|
+
onClick: (event) => inApp(event) && editor.discard(),
|
|
287
|
+
children: "Cancel"
|
|
230
288
|
}),
|
|
289
|
+
deleteButton,
|
|
231
290
|
canUpdate && /* @__PURE__ */ jsxs(Button, {
|
|
232
291
|
variant: "primary",
|
|
233
292
|
className: "max-md:min-h-11 max-md:flex-1",
|
|
234
293
|
loading: editor.saving,
|
|
235
294
|
disabled: !editor.dirty && editor.phase !== "saved",
|
|
236
|
-
onClick: () => void
|
|
295
|
+
onClick: () => void save(),
|
|
237
296
|
children: [editor.phase === "saved" && /* @__PURE__ */ jsx(Check, { className: "size-4" }), editor.phase === "saved" ? "Saved" : "Save"]
|
|
238
297
|
})
|
|
239
298
|
] });
|
|
@@ -273,7 +332,23 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
273
332
|
error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it, or Discard to drop it.")
|
|
274
333
|
})
|
|
275
334
|
}),
|
|
276
|
-
editor.
|
|
335
|
+
shown !== "form" && offersEdit && editor.dirty && /* @__PURE__ */ jsxs("div", {
|
|
336
|
+
className: "mt-4 flex items-center justify-between gap-3 rounded-lg border bg-surface px-3 py-2 text-[13px]",
|
|
337
|
+
role: "status",
|
|
338
|
+
children: [/* @__PURE__ */ jsx("span", { children: "You have unsaved changes to this record." }), /* @__PURE__ */ jsxs("span", {
|
|
339
|
+
className: "flex gap-2",
|
|
340
|
+
children: [/* @__PURE__ */ jsx(AppLink, {
|
|
341
|
+
to: editAddress,
|
|
342
|
+
className: buttonClass({ size: "sm" }),
|
|
343
|
+
children: "Continue editing"
|
|
344
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
345
|
+
size: "sm",
|
|
346
|
+
onClick: editor.discard,
|
|
347
|
+
children: "Discard"
|
|
348
|
+
})]
|
|
349
|
+
})]
|
|
350
|
+
}),
|
|
351
|
+
shown === "form" && editor.restored && /* @__PURE__ */ jsxs("div", {
|
|
277
352
|
className: "mt-4 flex items-center justify-between gap-3 rounded-lg border bg-surface px-3 py-2 text-[13px]",
|
|
278
353
|
role: "status",
|
|
279
354
|
children: [/* @__PURE__ */ jsx("span", { children: "Your unsaved changes were restored." }), /* @__PURE__ */ jsxs("span", {
|
|
@@ -307,13 +382,18 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
307
382
|
item,
|
|
308
383
|
record: stored.record
|
|
309
384
|
}, `map:${index}`)];
|
|
385
|
+
if (item.kind === "gallery") return [/* @__PURE__ */ jsx(GallerySection, {
|
|
386
|
+
item,
|
|
387
|
+
record: stored.record,
|
|
388
|
+
recordKey
|
|
389
|
+
}, `gallery:${index}`)];
|
|
310
390
|
if (item.kind !== "section") return [];
|
|
311
391
|
const fields = item.fields.filter((name) => model.fields[name] && visible(name));
|
|
312
392
|
if (fields.length === 0) return [];
|
|
313
393
|
return [/* @__PURE__ */ jsx(Section, {
|
|
314
394
|
title: item.title,
|
|
315
|
-
help: item.help,
|
|
316
|
-
children: /* @__PURE__ */ jsx("div", {
|
|
395
|
+
help: helpShown(item.helpWhen, view, editing) ? item.help : void 0,
|
|
396
|
+
children: shown === "read" ? /* @__PURE__ */ jsx(SpecTable, { rows: fields.map(readRow) }) : /* @__PURE__ */ jsx("div", {
|
|
317
397
|
className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
|
|
318
398
|
children: fields.map(fieldView)
|
|
319
399
|
})
|
|
@@ -322,7 +402,8 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
322
402
|
lines: linesModel,
|
|
323
403
|
draft: linesDraft,
|
|
324
404
|
vatRate: Number(stored.record.vatRate ?? 21) / 100,
|
|
325
|
-
saving: editor.saving
|
|
405
|
+
saving: editor.saving,
|
|
406
|
+
locked: shown !== "form"
|
|
326
407
|
})]
|
|
327
408
|
}), /* @__PURE__ */ jsxs("aside", {
|
|
328
409
|
className: "space-y-4 md:pt-8",
|
|
@@ -5,7 +5,8 @@ import { isApiError } from "@protobase/client";
|
|
|
5
5
|
const savedForMs = 2e3;
|
|
6
6
|
/**
|
|
7
7
|
* Shows the outcome of a save the way the view asks: a toast (spinner, then check) or the state of
|
|
8
|
-
* the Save button. API errors are reported and not rethrown; anything else propagates.
|
|
8
|
+
* the Save button. API errors are reported and not rethrown; anything else propagates. `run` resolves to whether the
|
|
9
|
+
* save went through.
|
|
9
10
|
*/
|
|
10
11
|
const useLiveSave = (mode, subject) => {
|
|
11
12
|
const toast = useToast();
|
|
@@ -30,6 +31,7 @@ const useLiveSave = (mode, subject) => {
|
|
|
30
31
|
setPhase("saved");
|
|
31
32
|
timer.current = setTimeout(() => setPhase("idle"), savedForMs);
|
|
32
33
|
}
|
|
34
|
+
return true;
|
|
33
35
|
} catch (error) {
|
|
34
36
|
if (!isApiError(error)) throw error;
|
|
35
37
|
const title = error.status === 412 ? "Changed by someone else" : "Could not save";
|
|
@@ -42,6 +44,7 @@ const useLiveSave = (mode, subject) => {
|
|
|
42
44
|
setPhase("idle");
|
|
43
45
|
setMessage(`${title}: ${error.message}`);
|
|
44
46
|
}
|
|
47
|
+
return false;
|
|
45
48
|
}
|
|
46
49
|
};
|
|
47
50
|
return {
|
package/dist/package.js
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
//#region src/record-view/spec-table.tsx
|
|
4
|
+
/**
|
|
5
|
+
* Label/value rows, the label on the left and the value on the right, as a property's features table on a real estate
|
|
6
|
+
* site: what a record page's section shows on its read view. A wide row puts its value under the label.
|
|
7
|
+
*/
|
|
8
|
+
const SpecTable = ({ rows }) => /* @__PURE__ */ jsx("dl", {
|
|
9
|
+
className: "border-t border-border text-[13px]",
|
|
10
|
+
children: rows.map((row) => /* @__PURE__ */ jsxs("div", {
|
|
11
|
+
className: cn("grid gap-x-4 border-b border-border py-2.5", row.wide ? "grid-cols-1 gap-y-1.5" : "grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"),
|
|
12
|
+
children: [/* @__PURE__ */ jsxs("dt", {
|
|
13
|
+
className: "min-w-0 text-muted-foreground",
|
|
14
|
+
children: [row.label, row.help && /* @__PURE__ */ jsx("span", {
|
|
15
|
+
className: "mt-0.5 block text-xs text-faint-foreground",
|
|
16
|
+
children: row.help
|
|
17
|
+
})]
|
|
18
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
19
|
+
className: "min-w-0 break-words text-foreground",
|
|
20
|
+
children: row.value
|
|
21
|
+
})]
|
|
22
|
+
}, row.label))
|
|
23
|
+
});
|
|
24
|
+
//#endregion
|
|
25
|
+
export { SpecTable };
|