@protobase/ui 0.19.10 → 0.19.12

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.
@@ -104,7 +104,6 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
104
104
  const { basePath } = useRouter();
105
105
  const { address, go, open } = useAppAddress();
106
106
  const view = meta.views[model.name];
107
- const singular = view?.names?.singular ?? humanize(model.name);
108
107
  const title = recordTitle(model, view, stored.record, recordKey);
109
108
  const { state: auth } = useAuth();
110
109
  const linesModel = useMemo(() => findLinesResource(meta.resources, model), [meta.resources, model]);
@@ -153,7 +152,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
153
152
  const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
154
153
  const hasStatus = model.fields.status?.type === "enum";
155
154
  const statusValue = stored.record.status;
156
- const status = hasStatus && statusValue !== null && statusValue !== void 0 && statusValue !== "" ? String(statusValue) : void 0;
155
+ const status = hasStatus && !sidebarFields.includes("status") && statusValue !== null && statusValue !== void 0 && statusValue !== "" ? String(statusValue) : void 0;
157
156
  const updatedAt = auditField(model, "updated");
158
157
  const hint = (name) => view?.fields[name];
159
158
  const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden";
@@ -264,29 +263,23 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
264
263
  snapshot: stored.record
265
264
  }) : void 0;
266
265
  const formControls = shown === "form" && /* @__PURE__ */ jsxs(Fragment$1, { children: [
267
- editor.dirty && /* @__PURE__ */ jsx("span", {
268
- className: "text-xs text-muted-foreground",
269
- children: "Unsaved changes"
270
- }),
271
- editor.dirty && !leavesForm && /* @__PURE__ */ jsx(Button, {
272
- className: "max-md:min-h-11",
273
- disabled: editor.saving,
274
- onClick: editor.discard,
275
- children: "Discard"
276
- }),
277
- leavesForm && /* @__PURE__ */ jsx(AppLink, {
278
- to: readAddress,
279
- className: cn(buttonClass(), "max-md:min-h-11"),
280
- onClick: (event) => inApp(event) && editor.discard(),
281
- children: "Cancel"
282
- }),
283
266
  /* @__PURE__ */ jsxs(Button, {
284
267
  variant: "primary",
285
- className: "max-md:min-h-11 max-md:flex-1",
268
+ className: "max-md:min-h-11 max-md:flex-1 md:w-full",
286
269
  loading: editor.saving,
287
270
  disabled: !editor.dirty && editor.phase !== "saved",
288
271
  onClick: () => void save(),
289
272
  children: [editor.phase === "saved" && /* @__PURE__ */ jsx(Check, { className: "size-4" }), editor.phase === "saved" ? "Saved" : "Save"]
273
+ }),
274
+ leavesForm && /* @__PURE__ */ jsx(AppLink, {
275
+ to: readAddress,
276
+ className: cn(buttonClass(), "max-md:min-h-11 md:w-full"),
277
+ onClick: (event) => inApp(event) && editor.discard(),
278
+ children: "Cancel"
279
+ }),
280
+ editor.dirty && /* @__PURE__ */ jsx("span", {
281
+ className: "text-xs text-muted-foreground max-md:order-first",
282
+ children: "Unsaved changes"
290
283
  })
291
284
  ] });
292
285
  return /* @__PURE__ */ jsxs("div", {
@@ -300,17 +293,11 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
300
293
  children: [
301
294
  /* @__PURE__ */ jsx(RecordHeader, {
302
295
  title,
296
+ titleHidden: true,
303
297
  status: status ? {
304
298
  label: enumLabel(status, hint("status")?.valueLabels),
305
299
  tone: toneFor(status)
306
- } : hasStatus ? void 0 : {
307
- label: singular,
308
- tone: "neutral"
309
- },
310
- actions: /* @__PURE__ */ jsx("div", {
311
- className: "hidden items-center gap-4 md:flex",
312
- children: formControls
313
- })
300
+ } : void 0
314
301
  }),
315
302
  editor.message && /* @__PURE__ */ jsx("div", {
316
303
  className: "mt-4",
@@ -323,7 +310,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
323
310
  className: "mt-4",
324
311
  children: /* @__PURE__ */ jsx(ErrorBanner, {
325
312
  title: "The lines were changed by someone else",
326
- error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it, or Discard to drop it.")
313
+ error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it.")
327
314
  })
328
315
  }),
329
316
  shown !== "form" && offersEdit && editor.dirty && /* @__PURE__ */ jsxs("div", {
@@ -361,7 +348,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
361
348
  /* @__PURE__ */ jsxs("div", {
362
349
  className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
363
350
  children: [/* @__PURE__ */ jsxs("main", {
364
- className: "divide-y divide-border",
351
+ className: cn("divide-y divide-border", !status && "*:first:pt-0!"),
365
352
  children: [layout.flatMap((item, index) => {
366
353
  if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
367
354
  item,
@@ -406,8 +393,12 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
406
393
  locked: shown !== "form"
407
394
  })]
408
395
  }), /* @__PURE__ */ jsxs("aside", {
409
- className: "space-y-4 md:pt-8",
396
+ className: cn("space-y-4", status && "md:pt-8"),
410
397
  children: [
398
+ formControls && /* @__PURE__ */ jsx("div", {
399
+ className: "hidden flex-col items-center gap-2 md:flex",
400
+ children: formControls
401
+ }),
411
402
  /* @__PURE__ */ jsx(RecordActions, {
412
403
  model,
413
404
  stored,
@@ -1,25 +1,49 @@
1
- import { formatDateTime } from "../../format/date.js";
1
+ import { formatAgo, formatDateTime } from "../../format/date.js";
2
2
  import { isApiError } from "@protobase/client";
3
3
  //#region src/app/runtime/runtime-status.ts
4
4
  /** The top bar shows the update button only when there is an update and the endpoint wants it shown. */
5
5
  const showsUpdateButton = (status) => Boolean(status?.updateAvailable && status.indicator);
6
+ /** How often the status is read again: rarely, and every few seconds while an update runs so its progress shows. */
7
+ const refreshMs = (updating) => updating ? 5e3 : 18e5;
6
8
  const policyLabels = {
7
9
  immediate: "Updates as soon as a release is out",
8
10
  weekly: "Updates at a set time every week",
9
11
  scheduled: "Updates at the time set for this release",
10
12
  manual: "Updates only when asked to"
11
13
  };
12
- /** When the latest version will be applied on its own, in words. */
13
- const nextUpdateText = (status) => {
14
- if (status.updating) return "Updating now";
14
+ const stageLabels = {
15
+ job: "Preparing the update",
16
+ build: "Building"
17
+ };
18
+ const runningText = (status, now) => {
19
+ if (!status.updateStage) return "Updating now";
20
+ const label = stageLabels[status.updateStage];
21
+ if (!status.updateStartedAt) return label;
22
+ const seconds = Math.max(0, Math.floor((now - Date.parse(status.updateStartedAt)) / 1e3));
23
+ return `${label}, started ${formatAgo(seconds)}`;
24
+ };
25
+ /** How far the running update is, or when the latest version will be applied on its own, in words; `now` in ms. */
26
+ const nextUpdateText = (status, now = Date.now()) => {
27
+ if (status.updating) return runningText(status, now);
15
28
  if (status.nextUpdateAt) return `${policyLabels[status.policy]}: next on ${formatDateTime(Date.parse(status.nextUpdateAt))} UTC`;
16
29
  return policyLabels[status.policy];
17
30
  };
18
- /** Why "Update now" failed, in words. */
31
+ /** How the last update ended, in words, with the endpoint's message for the owner when it has one. */
32
+ const lastUpdateText = (last) => {
33
+ const when = `${formatDateTime(Date.parse(last.finishedAt))} UTC`;
34
+ const headline = {
35
+ succeeded: `Updated to ${last.toVersion} on ${when}`,
36
+ unchanged: `The update to ${last.toVersion} on ${when} changed nothing`,
37
+ failed: `The update to ${last.toVersion} failed on ${when}`
38
+ }[last.outcome];
39
+ return last.message ? `${headline}: ${last.message}` : `${headline}.`;
40
+ };
41
+ /** Why "Update now" failed, in words: the endpoint's own message for a refusal, when it sends one. */
19
42
  const updateErrorText = (error) => {
43
+ if (isApiError(error) && error.status < 500 && error.problem.detail) return error.problem.detail;
20
44
  if (isApiError(error) && error.status === 409) return "Already on the latest version, or an update is running.";
21
45
  if (isApiError(error) && error.status === 403) return "Only an admin can update the runtime.";
22
46
  return "The update could not be started. Try again later.";
23
47
  };
24
48
  //#endregion
25
- export { nextUpdateText, showsUpdateButton, updateErrorText };
49
+ export { lastUpdateText, nextUpdateText, refreshMs, showsUpdateButton, updateErrorText };
@@ -0,0 +1,66 @@
1
+ import { Spinner } from "../../primitives/spinner.js";
2
+ import { Button } from "../../primitives/button.js";
3
+ import { Dialog } from "../../primitives/dialog.js";
4
+ import { lastUpdateText, nextUpdateText, updateErrorText } from "./runtime-status.js";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/app/runtime/runtime-update-dialog.tsx
7
+ const Row = ({ label, value }) => /* @__PURE__ */ jsxs("div", {
8
+ className: "flex justify-between gap-4 py-1",
9
+ children: [/* @__PURE__ */ jsx("span", {
10
+ className: "text-muted-foreground",
11
+ children: label
12
+ }), /* @__PURE__ */ jsx("span", {
13
+ className: "font-medium tabular-nums",
14
+ children: value
15
+ })]
16
+ });
17
+ /**
18
+ * The runtime update dialog: the versions, how far a running update is or when the policy applies the latest one, how
19
+ * the last update ended (while none runs), and "Update now".
20
+ */
21
+ const RuntimeUpdateDialog = ({ status, requesting, error, checkError, onUpdate, onClose }) => {
22
+ const last = status.updating ? void 0 : status.lastUpdate;
23
+ return /* @__PURE__ */ jsxs(Dialog, {
24
+ title: "Runtime update",
25
+ description: status.updating ? /* @__PURE__ */ jsxs("span", {
26
+ className: "inline-flex items-center gap-1.5",
27
+ children: [/* @__PURE__ */ jsx(Spinner, { className: "size-3.5" }), nextUpdateText(status)]
28
+ }) : nextUpdateText(status),
29
+ onClose,
30
+ actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
31
+ onClick: onClose,
32
+ children: "Close"
33
+ }), /* @__PURE__ */ jsx(Button, {
34
+ variant: "primary",
35
+ loading: requesting,
36
+ disabled: status.updating || !status.updateAvailable,
37
+ onClick: onUpdate,
38
+ children: status.updating ? "Updating…" : "Update now"
39
+ })] }),
40
+ children: [
41
+ /* @__PURE__ */ jsx(Row, {
42
+ label: "Current version",
43
+ value: status.version
44
+ }),
45
+ /* @__PURE__ */ jsx(Row, {
46
+ label: "Latest version",
47
+ value: status.latest
48
+ }),
49
+ last && /* @__PURE__ */ jsx("p", {
50
+ className: last.outcome === "failed" ? "mt-3 text-danger-text" : "mt-3 text-muted-foreground",
51
+ children: lastUpdateText(last)
52
+ }),
53
+ error !== void 0 && /* @__PURE__ */ jsx("p", {
54
+ role: "alert",
55
+ className: "mt-3 text-danger-text",
56
+ children: updateErrorText(error)
57
+ }),
58
+ checkError !== void 0 && /* @__PURE__ */ jsx("p", {
59
+ className: "mt-3 text-muted-foreground",
60
+ children: "The status could not be checked again; this is the last one read."
61
+ })
62
+ ]
63
+ });
64
+ };
65
+ //#endregion
66
+ export { RuntimeUpdateDialog };
@@ -1,24 +1,14 @@
1
1
  import { Button } from "../../primitives/button.js";
2
- import { Dialog } from "../../primitives/dialog.js";
3
- import { nextUpdateText, showsUpdateButton, updateErrorText } from "./runtime-status.js";
2
+ import { showsUpdateButton } from "./runtime-status.js";
3
+ import { RuntimeUpdateDialog } from "./runtime-update-dialog.js";
4
4
  import { useRuntimeClient, useRuntimeStatus } from "./use-runtime-status.js";
5
5
  import { useState } from "react";
6
6
  import { ArrowUpCircle } from "lucide-react";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/app/runtime/runtime-update.tsx
9
- const Row = ({ label, value }) => /* @__PURE__ */ jsxs("div", {
10
- className: "flex justify-between gap-4 py-1",
11
- children: [/* @__PURE__ */ jsx("span", {
12
- className: "text-muted-foreground",
13
- children: label
14
- }), /* @__PURE__ */ jsx("span", {
15
- className: "font-medium tabular-nums",
16
- children: value
17
- })]
18
- });
19
9
  /**
20
10
  * The top bar's runtime update button, shown while the endpoint reports an update and wants it shown, and the dialog
21
- * it opens: the versions, when the update policy applies the latest one, and "Update now". Below `md` only the icon shows.
11
+ * it opens. On mobile only the icon shows.
22
12
  */
23
13
  const RuntimeUpdate = ({ client }) => {
24
14
  const { status, update, requesting, error, checkError } = useRuntimeStatus(client);
@@ -35,39 +25,13 @@ const RuntimeUpdate = ({ client }) => {
35
25
  className: "max-md:sr-only",
36
26
  children: "Update"
37
27
  })]
38
- }), open && /* @__PURE__ */ jsxs(Dialog, {
39
- title: "Runtime update",
40
- description: nextUpdateText(status),
41
- onClose: () => setOpen(false),
42
- actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
43
- onClick: () => setOpen(false),
44
- children: "Close"
45
- }), /* @__PURE__ */ jsx(Button, {
46
- variant: "primary",
47
- loading: requesting,
48
- disabled: status.updating || !status.updateAvailable,
49
- onClick: () => void update(),
50
- children: status.updating ? "Updating…" : "Update now"
51
- })] }),
52
- children: [
53
- /* @__PURE__ */ jsx(Row, {
54
- label: "Current version",
55
- value: status.version
56
- }),
57
- /* @__PURE__ */ jsx(Row, {
58
- label: "Latest version",
59
- value: status.latest
60
- }),
61
- error !== void 0 && /* @__PURE__ */ jsx("p", {
62
- role: "alert",
63
- className: "mt-3 text-danger-text",
64
- children: updateErrorText(error)
65
- }),
66
- checkError !== void 0 && /* @__PURE__ */ jsx("p", {
67
- className: "mt-3 text-muted-foreground",
68
- children: "The status could not be checked again; this is the last one read."
69
- })
70
- ]
28
+ }), open && /* @__PURE__ */ jsx(RuntimeUpdateDialog, {
29
+ status,
30
+ requesting,
31
+ error,
32
+ checkError,
33
+ onUpdate: () => void update(),
34
+ onClose: () => setOpen(false)
71
35
  })] });
72
36
  };
73
37
  const OwnRuntimeUpdate = ({ url }) => /* @__PURE__ */ jsx(RuntimeUpdate, { client: useRuntimeClient(url) });
@@ -1,10 +1,8 @@
1
1
  import { useApiToken } from "../../auth/use-api-token.js";
2
+ import { refreshMs } from "./runtime-status.js";
2
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
4
  import { createRuntimeClient } from "@protobase/client";
4
5
  //#region src/app/runtime/use-runtime-status.ts
5
- /** How often the status is read again: rarely, and more often while an update runs so the button goes away when it is done. */
6
- const refreshMs = 18e5;
7
- const updatingRefreshMs = 3e4;
8
6
  /** A client of the runtime endpoint at `url`, authenticated as the signed-in user; `undefined` until their token is there. */
9
7
  const useRuntimeClient = (url) => {
10
8
  const token = useApiToken();
@@ -17,7 +15,7 @@ const useRuntimeClient = (url) => {
17
15
  }) : void 0, [url, ready]);
18
16
  };
19
17
  /**
20
- * The runtime's status, read on load and every half hour after, and `update` to apply the latest version now.
18
+ * The runtime's status, read on load and every half hour after (every few seconds while an update runs), and `update` to apply the latest version now.
21
19
  * `status` keeps the last answer when a later read fails, which `checkError` then holds; `error` is why "Update now" failed.
22
20
  */
23
21
  const useRuntimeStatus = (client) => {
@@ -35,7 +33,7 @@ const useRuntimeStatus = (client) => {
35
33
  setCheckError(void 0);
36
34
  }, (reason) => active && setCheckError(reason));
37
35
  read();
38
- const timer = setInterval(() => void read(), updating ? updatingRefreshMs : refreshMs);
36
+ const timer = setInterval(() => void read(), refreshMs(updating));
39
37
  return () => {
40
38
  active = false;
41
39
  clearInterval(timer);
@@ -19,6 +19,12 @@ const time = new Intl.DateTimeFormat("en-GB", {
19
19
  const formatDate = (ms) => day.format(ms);
20
20
  const formatDayShort = (ms) => dayShort.format(ms);
21
21
  const formatDateTime = (ms) => `${day.format(ms)}, ${time.format(ms)}`;
22
+ const formatAgo = (seconds) => {
23
+ if (seconds < 60) return `${seconds} s ago`;
24
+ if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
25
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
26
+ return `${Math.floor(seconds / 86400)} d ago`;
27
+ };
22
28
  new Intl.RelativeTimeFormat("en", { numeric: "auto" });
23
29
  //#endregion
24
- export { formatDate, formatDateTime, formatDayShort };
30
+ export { formatAgo, formatDate, formatDateTime, formatDayShort };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.19.10";
2
+ var version = "0.19.12";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -2,49 +2,55 @@ import { AvatarStack } from "../primitives/avatar.js";
2
2
  import { Badge } from "../primitives/badge.js";
3
3
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/record-view/record-header.tsx
5
- const RecordHeader = ({ collection, title, status, lastEditedBy, presence = [], actions }) => /* @__PURE__ */ jsxs("header", {
6
- className: "flex flex-col gap-4 border-b border-border pb-6 md:flex-row md:items-end md:justify-between md:gap-6",
7
- children: [/* @__PURE__ */ jsxs("div", { children: [
8
- collection && /* @__PURE__ */ jsx("p", {
9
- className: "mb-1 text-xs text-muted-foreground",
10
- children: collection
11
- }),
12
- /* @__PURE__ */ jsxs("div", {
13
- className: "flex flex-wrap items-center gap-x-3 gap-y-1",
14
- children: [/* @__PURE__ */ jsx("h1", {
15
- className: "text-xl font-semibold tracking-tight text-foreground md:text-2xl",
16
- children: title
17
- }), status && /* @__PURE__ */ jsx(Badge, {
18
- tone: status.tone,
19
- children: status.label
20
- })]
21
- }),
22
- lastEditedBy && /* @__PURE__ */ jsx("p", {
23
- className: "mt-1.5 text-xs text-muted-foreground",
24
- children: lastEditedBy.name ? /* @__PURE__ */ jsxs(Fragment, { children: [
25
- "Last edited by ",
26
- /* @__PURE__ */ jsx("span", {
27
- className: "font-medium text-foreground",
28
- children: lastEditedBy.name
29
- }),
30
- " ",
31
- lastEditedBy.ago
32
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: ["Last updated ", lastEditedBy.ago] })
33
- })
34
- ] }), /* @__PURE__ */ jsxs("div", {
35
- className: "flex items-center gap-4",
36
- children: [presence.length > 0 && /* @__PURE__ */ jsxs("span", {
37
- className: "flex items-center gap-2 text-xs text-muted-foreground",
38
- children: [
39
- /* @__PURE__ */ jsx(AvatarStack, {
40
- people: presence,
41
- size: "md"
42
- }),
43
- presence.length,
44
- " viewing"
45
- ]
46
- }), actions]
47
- })]
48
- });
5
+ const RecordHeader = ({ collection, title, titleHidden, status, lastEditedBy, presence = [], actions }) => {
6
+ if (titleHidden && !collection && !status && !lastEditedBy && presence.length === 0 && !actions) return /* @__PURE__ */ jsx("h1", {
7
+ className: "sr-only",
8
+ children: title
9
+ });
10
+ return /* @__PURE__ */ jsxs("header", {
11
+ className: "flex flex-col gap-4 border-b border-border pb-6 md:flex-row md:items-end md:justify-between md:gap-6",
12
+ children: [/* @__PURE__ */ jsxs("div", { children: [
13
+ collection && /* @__PURE__ */ jsx("p", {
14
+ className: "mb-1 text-xs text-muted-foreground",
15
+ children: collection
16
+ }),
17
+ /* @__PURE__ */ jsxs("div", {
18
+ className: "flex flex-wrap items-center gap-x-3 gap-y-1",
19
+ children: [/* @__PURE__ */ jsx("h1", {
20
+ className: titleHidden ? "sr-only" : "text-xl font-semibold tracking-tight text-foreground md:text-2xl",
21
+ children: title
22
+ }), status && /* @__PURE__ */ jsx(Badge, {
23
+ tone: status.tone,
24
+ children: status.label
25
+ })]
26
+ }),
27
+ lastEditedBy && /* @__PURE__ */ jsx("p", {
28
+ className: "mt-1.5 text-xs text-muted-foreground",
29
+ children: lastEditedBy.name ? /* @__PURE__ */ jsxs(Fragment, { children: [
30
+ "Last edited by ",
31
+ /* @__PURE__ */ jsx("span", {
32
+ className: "font-medium text-foreground",
33
+ children: lastEditedBy.name
34
+ }),
35
+ " ",
36
+ lastEditedBy.ago
37
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: ["Last updated ", lastEditedBy.ago] })
38
+ })
39
+ ] }), /* @__PURE__ */ jsxs("div", {
40
+ className: "flex items-center gap-4",
41
+ children: [presence.length > 0 && /* @__PURE__ */ jsxs("span", {
42
+ className: "flex items-center gap-2 text-xs text-muted-foreground",
43
+ children: [
44
+ /* @__PURE__ */ jsx(AvatarStack, {
45
+ people: presence,
46
+ size: "md"
47
+ }),
48
+ presence.length,
49
+ " viewing"
50
+ ]
51
+ }), actions]
52
+ })]
53
+ });
54
+ };
49
55
  //#endregion
50
56
  export { RecordHeader };