@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.
@@ -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)?.help,
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: !canUpdate || access === "read",
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 saveControls = /* @__PURE__ */ jsxs(Fragment$1, { children: [
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
- canDelete && /* @__PURE__ */ jsx(Button, {
221
- variant: "danger",
222
- className: "max-md:min-h-11",
223
- onClick: () => void remove.start({
224
- key: recordKey,
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 editor.save(),
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.restored && /* @__PURE__ */ jsxs("div", {
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
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.18.0";
2
+ var version = "0.19.0";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -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 };