@protobase/ui 0.22.2 → 0.22.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 (33) hide show
  1. package/dist/app/charts/chart-view.js +2 -0
  2. package/dist/app/charts/group-bars.js +3 -6
  3. package/dist/app/charts/group-donut.js +3 -6
  4. package/dist/app/current-address.js +4 -4
  5. package/dist/app/displays/board-allowed.js +26 -0
  6. package/dist/app/displays/board-display.js +2 -1
  7. package/dist/app/export/cells.js +4 -1
  8. package/dist/app/field-editor.js +1 -1
  9. package/dist/app/list-link.js +23 -0
  10. package/dist/app/pages/blocks/stat.js +3 -5
  11. package/dist/app/pages/field-value.js +1 -1
  12. package/dist/app/record-page.js +2 -2
  13. package/dist/data-grid/cell-text.js +2 -2
  14. package/dist/data-grid/cells.js +2 -1
  15. package/dist/filter-panel/filter-config.js +2 -0
  16. package/dist/format/date.js +5 -4
  17. package/dist/live/columns-from-model.js +1 -0
  18. package/dist/live/display-value.js +11 -6
  19. package/dist/live/filter-string.js +4 -0
  20. package/dist/package.js +1 -1
  21. package/dist/shell/assets/app-BM5iQ8yZ.js +69 -0
  22. package/dist/shell/assets/export-worker-C1djGgjD.js +2 -0
  23. package/dist/shell/assets/{index-Dkcn0zHH.js → index-DylcQP59.js} +1 -1
  24. package/dist/shell/assets/{maplibre-CAXiHBgj.js → maplibre-CY0vkBw4.js} +1 -1
  25. package/dist/shell/assets/{markdown-content-B7XDhlLn.js → markdown-content-D3r5sNwd.js} +1 -1
  26. package/dist/shell/assets/{record-map-BcLvshZy.js → record-map-C9XvJmY1.js} +1 -1
  27. package/dist/shell/assets/{records-map-BYeO0Ega.js → records-map-BtQTl7lD.js} +1 -1
  28. package/dist/shell/assets/{shared-protobase-ui-CVanihMA.js → shared-protobase-ui-BIRhZOyK.js} +1 -1
  29. package/dist/shell/index.html +3 -3
  30. package/dist/shell/protobase.shell.json +1 -1
  31. package/package.json +4 -4
  32. package/dist/shell/assets/app-ByxJVEUs.js +0 -69
  33. package/dist/shell/assets/export-worker-BoAr3vuF.js +0 -2
@@ -65,10 +65,12 @@ const ChartView = ({ model, view, groupBy, type = "bar", sum, average, filter =
65
65
  const slices = valueSlices(groups, field, view?.fields[groupBy], measure, filter);
66
66
  return type === "donut" ? /* @__PURE__ */ jsx(GroupDonut, {
67
67
  resource: model.name,
68
+ view,
68
69
  slices,
69
70
  measure
70
71
  }) : /* @__PURE__ */ jsx(GroupBars, {
71
72
  resource: model.name,
73
+ view,
72
74
  slices,
73
75
  measure
74
76
  });
@@ -1,23 +1,20 @@
1
1
  import { AppLink } from "../use-app-address.js";
2
2
  import { measureText } from "./chart-groups.js";
3
+ import { filteredListAddress } from "../list-link.js";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
4
5
  //#region src/app/charts/group-bars.tsx
5
6
  /**
6
7
  * A ranked list of a field's values, each a bar as long as its number and a link to its records. Bars in rows read on
7
8
  * a phone as well as on a desk, and carry their label and value as text, so no colour has to say what a bar is.
8
9
  */
9
- const GroupBars = ({ resource, slices, measure }) => {
10
+ const GroupBars = ({ resource, view, slices, measure }) => {
10
11
  const largest = Math.max(0, ...slices.map((slice) => slice.value ?? 0));
11
12
  return /* @__PURE__ */ jsx("ul", {
12
13
  className: "flex flex-col gap-1",
13
14
  children: slices.map((slice) => {
14
15
  const share = largest > 0 && slice.value !== null ? Math.max(0, slice.value) / largest : 0;
15
16
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
16
- to: {
17
- kind: "list",
18
- resource,
19
- filter: slice.filter
20
- },
17
+ to: filteredListAddress(view, resource, slice.filter),
21
18
  className: "group grid grid-cols-[minmax(6rem,35%)_1fr_auto] items-center gap-3 rounded-md px-1 py-1 hover:bg-muted",
22
19
  title: measure.kind === "count" ? void 0 : `${measureText(slice.count, { kind: "count" })} records`,
23
20
  children: [
@@ -1,5 +1,6 @@
1
1
  import { AppLink } from "../use-app-address.js";
2
2
  import { measureText } from "./chart-groups.js";
3
+ import { filteredListAddress } from "../list-link.js";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
4
5
  import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
5
6
  //#region src/app/charts/group-donut.tsx
@@ -22,7 +23,7 @@ const SliceTooltip = ({ active, payload, measure }) => {
22
23
  * A donut of how a total shares out over a field's values, with a legend that names each, gives its number and share,
23
24
  * and links to its records: the legend, not the colour, says which slice is which.
24
25
  */
25
- const GroupDonut = ({ resource, slices, measure }) => {
26
+ const GroupDonut = ({ resource, view, slices, measure }) => {
26
27
  const drawn = slices.filter((slice) => slice.value !== null && slice.value > 0);
27
28
  const whole = drawn.reduce((sum, slice) => sum + slice.value, 0);
28
29
  return /* @__PURE__ */ jsxs("div", {
@@ -51,11 +52,7 @@ const GroupDonut = ({ resource, slices, measure }) => {
51
52
  }), /* @__PURE__ */ jsx("ul", {
52
53
  className: "flex w-full min-w-0 flex-col gap-0.5",
53
54
  children: slices.map((slice) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
54
- to: {
55
- kind: "list",
56
- resource,
57
- filter: slice.filter
58
- },
55
+ to: filteredListAddress(view, resource, slice.filter),
59
56
  className: "group flex items-center gap-2 rounded-md px-1 py-0.5 hover:bg-muted",
60
57
  children: [
61
58
  /* @__PURE__ */ jsx("span", {
@@ -24,7 +24,8 @@ const listedResource = (resources, views, resource) => {
24
24
  const listDefaults = (model, view, xs = false) => ({
25
25
  display: model ? listDefaultDisplay(model, view, xs) : defaultDisplay(view, xs),
26
26
  orderBy: model ? defaultOrderBy(model, view) : "",
27
- filterLayout: "bar"
27
+ filterLayout: "bar",
28
+ filter: view?.list?.filter ?? ""
28
29
  });
29
30
  /** The address as read from the location, with a list's defaults filled in and a record's key as text. */
30
31
  const currentAddress = (read, defaults) => {
@@ -38,11 +39,10 @@ const currentAddress = (read, defaults) => {
38
39
  if (read?.kind !== "list") return read;
39
40
  return {
40
41
  ...defaults(read.resource),
41
- filter: "",
42
42
  ...read
43
43
  };
44
44
  };
45
- /** The list address after `change`, leaving out every value that is empty or its default, so `/<resource>` stays clean. */
45
+ /** The list address after `change`, leaving out every value that is empty or its default (a cleared filter is written as an empty `filter=` when the default is not empty), so `/<resource>` stays clean. */
46
46
  const changedList = (read, change, defaults) => {
47
47
  const next = {
48
48
  ...read,
@@ -52,7 +52,7 @@ const changedList = (read, change, defaults) => {
52
52
  return {
53
53
  kind: "list",
54
54
  resource: read.resource,
55
- ...next.filter && { filter: next.filter },
55
+ ...differs("filter") && { filter: next.filter },
56
56
  ...differs("display") && { display: next.display },
57
57
  ...differs("orderBy") && { orderBy: next.orderBy },
58
58
  ...differs("filterLayout") && { filterLayout: next.filterLayout },
@@ -0,0 +1,26 @@
1
+ import { parseFilter } from "@protobase/schema";
2
+ //#region src/app/displays/board-allowed.ts
3
+ const flatten = (expr) => expr.kind === "and" ? expr.args.flatMap(flatten) : [expr];
4
+ const text = (value) => value.kind === "string" ? String(value.value) : void 0;
5
+ const allowedBy = (clause, by) => {
6
+ if (clause.kind === "compare" && clause.op === "=" && clause.field.path.join(".") === by) {
7
+ const value = text(clause.value);
8
+ return value === void 0 ? void 0 : [value];
9
+ }
10
+ if (clause.kind === "in" && clause.field.path.join(".") === by) {
11
+ const values = clause.values.map(text);
12
+ return values.every((value) => value !== void 0) ? values : void 0;
13
+ }
14
+ };
15
+ /**
16
+ * The columns of a board `filter` does not rule out: those its top-level `by = "v"` and `in(by, ...)` clauses allow. Any
17
+ * other clause leaves every column, so a column is left out only when no record in it can match.
18
+ */
19
+ const columnsAllowed = (filter, by, columns) => {
20
+ const parsed = parseFilter(filter);
21
+ if (!parsed.ok || !parsed.ast) return columns;
22
+ const clauses = flatten(parsed.ast).map((clause) => allowedBy(clause, by)).filter((values) => values !== void 0);
23
+ return columns.filter((column) => clauses.every((values) => column.value !== null && values.includes(column.value)));
24
+ };
25
+ //#endregion
26
+ export { columnsAllowed };
@@ -4,6 +4,7 @@ import { can } from "../permissions.js";
4
4
  import { RecordCard } from "./record-card.js";
5
5
  import { collision, columnCoordinates, columnId } from "./board-dnd.js";
6
6
  import { BoardColumnView } from "./board-column.js";
7
+ import { columnsAllowed } from "./board-allowed.js";
7
8
  import { useBoardMove } from "./use-board-move.js";
8
9
  import { useRef, useState } from "react";
9
10
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -14,7 +15,7 @@ import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, useS
14
15
  * a card dragged, or moved with the keyboard, to another column saves the record with that column's value.
15
16
  */
16
17
  const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit }) => {
17
- const columns = boardColumns(model, view, display);
18
+ const columns = columnsAllowed(filter, display.by, boardColumns(model, view, display));
18
19
  const card = boardCard(model, view, display);
19
20
  const permissions = usePermissions(model.name);
20
21
  const movable = can(permissions, "update") && !model.fields[display.by]?.readOnly;
@@ -19,7 +19,10 @@ const csvText = (column, value, labels, mode) => {
19
19
  const { field, hints } = column;
20
20
  if (field.type === "file") return fileText(value, mode);
21
21
  if (field.type === "relation") return mode === "shown" ? relationLabel(column, value, labels) : String(value);
22
- if (mode === "shown") return displayValue(field, value, hints);
22
+ if (mode === "shown") return displayValue(field, value, {
23
+ ...hints,
24
+ format: void 0
25
+ });
23
26
  if (typeof value === "object") return JSON.stringify(value);
24
27
  return String(value);
25
28
  };
@@ -106,7 +106,7 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
106
106
  })
107
107
  });
108
108
  if (!isEditable(field) || locked) return /* @__PURE__ */ jsx("div", {
109
- title: valueTitle(field, stored),
109
+ title: valueTitle(field, stored, void 0, hints),
110
110
  className: cn("py-1.5 tabular-nums", field.type === "decimal" || field.type === "integer" ? "font-medium" : "text-muted-foreground", multiline && "whitespace-pre-line break-words"),
111
111
  children: displayValue(field, stored, hints)
112
112
  });
@@ -0,0 +1,23 @@
1
+ //#region src/app/list-link.ts
2
+ /**
3
+ * The list of `resource` opened on exactly the records `filter` counted or showed: with no filter, an empty one when
4
+ * the view's list opens on a default filter, so the link does not narrow what it counted; otherwise a clean path.
5
+ */
6
+ const filteredListAddress = (view, resource, filter) => {
7
+ if (filter) return {
8
+ kind: "list",
9
+ resource,
10
+ filter
11
+ };
12
+ if (view?.list?.filter) return {
13
+ kind: "list",
14
+ resource,
15
+ filter: ""
16
+ };
17
+ return {
18
+ kind: "list",
19
+ resource
20
+ };
21
+ };
22
+ //#endregion
23
+ export { filteredListAddress };
@@ -5,12 +5,14 @@ import { useLayoutRecord } from "../record-scope.js";
5
5
  import { useCount } from "../use-layout-data.js";
6
6
  import { propsOf } from "./block-props.js";
7
7
  import { CardFrame } from "./card-frame.js";
8
+ import { filteredListAddress } from "../../list-link.js";
8
9
  import { FieldValue } from "../field-value.js";
9
10
  import { usePathValue } from "../use-path-value.js";
10
11
  import { jsx, jsxs } from "react/jsx-runtime";
11
12
  //#region src/app/pages/blocks/stat.tsx
12
13
  const big = "text-2xl font-semibold tracking-tight tabular-nums";
13
14
  const Count = ({ model, filter }) => {
15
+ const { views } = useAdminMeta();
14
16
  const count = useCount(model, filter);
15
17
  if (count.isPending) return /* @__PURE__ */ jsx(Skeleton, { className: "h-8 w-16" });
16
18
  if (count.error) return /* @__PURE__ */ jsx("span", {
@@ -18,11 +20,7 @@ const Count = ({ model, filter }) => {
18
20
  children: "Could not count"
19
21
  });
20
22
  return /* @__PURE__ */ jsx(AppLink, {
21
- to: {
22
- kind: "list",
23
- resource: model.name,
24
- filter
25
- },
23
+ to: filteredListAddress(views[model.name], model.name, filter),
26
24
  className: `${big} hover:text-primary-text after:absolute after:inset-0 after:content-['']`,
27
25
  children: count.data.toLocaleString("en-IE")
28
26
  });
@@ -87,7 +87,7 @@ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
87
87
  children: text
88
88
  });
89
89
  return /* @__PURE__ */ jsx("span", {
90
- title: valueTitle(field, value),
90
+ title: valueTitle(field, value, void 0, hints),
91
91
  className: cn(hints.format === "code" && "font-mono text-xs", (field.type === "decimal" || field.type === "integer") && "tabular-nums", hints.multiline && "whitespace-pre-line break-words"),
92
92
  children: text
93
93
  });
@@ -303,7 +303,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
303
303
  format: contact,
304
304
  value: text
305
305
  }) : /* @__PURE__ */ jsx("strong", {
306
- title: valueTitle(field, value),
306
+ title: valueTitle(field, value, void 0, hint(name)),
307
307
  className: hint(name)?.multiline ? "whitespace-pre-line break-words" : void 0,
308
308
  children: text
309
309
  });
@@ -524,7 +524,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
524
524
  return [{
525
525
  label: hint(name)?.label ?? humanize(name),
526
526
  value: known(name) ? /* @__PURE__ */ jsx("span", {
527
- title: valueTitle(field, stored.record[name]),
527
+ title: valueTitle(field, stored.record[name], void 0, hint(name)),
528
528
  children: displayValue(field, stored.record[name], hint(name))
529
529
  }) : /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" })
530
530
  }];
@@ -1,6 +1,6 @@
1
1
  import { formatMoney } from "../format/money.js";
2
2
  import { formatInt, formatPercent, formatSigned } from "../format/number.js";
3
- import { formatDate, formatDateTime } from "../format/date.js";
3
+ import { formatDate, formatDateTime, formatRelativeTime } from "../format/date.js";
4
4
  import { userById } from "../mocks/users.js";
5
5
  import { fileShown } from "../live/file-values.js";
6
6
  import { enumLabel } from "@protobase/schema";
@@ -15,7 +15,7 @@ const cellText = (spec, value) => {
15
15
  case "id": return String(value);
16
16
  case "money": return `${formatMoney(Number(value), spec.currency, spec.decimals)}${spec.suffix ?? ""}`;
17
17
  case "date": return formatDate(Number(value));
18
- case "datetime": return formatDateTime(Number(value));
18
+ case "datetime": return spec.relative ? formatRelativeTime(Math.round((Date.now() - Number(value)) / 1e3)) : formatDateTime(Number(value));
19
19
  case "number": return `${formatInt(Number(value))}${spec.suffix ?? ""}`;
20
20
  case "signed": return `${formatSigned(Number(value))}${spec.suffix ?? ""}`;
21
21
  case "percent": return Number(value) === 0 ? "—" : formatPercent(Number(value));
@@ -2,6 +2,7 @@ import { cn } from "../lib/cn.js";
2
2
  import { Avatar } from "../primitives/avatar.js";
3
3
  import { Badge } from "../primitives/badge.js";
4
4
  import { viewerZone, zoneLabel } from "../format/zone.js";
5
+ import { formatDateTime } from "../format/date.js";
5
6
  import { userById } from "../mocks/users.js";
6
7
  import { fileShown, shownImage } from "../live/file-values.js";
7
8
  import { cellText } from "./cell-text.js";
@@ -39,7 +40,7 @@ const renderCell = (spec, value) => {
39
40
  });
40
41
  case "datetime": return /* @__PURE__ */ jsx("span", {
41
42
  className: "tabular-nums text-muted-foreground",
42
- title: `Shown in ${zoneLabel(Number(value), viewerZone())}`,
43
+ title: spec.relative ? formatDateTime(Number(value)) : `Shown in ${zoneLabel(Number(value), viewerZone())}`,
43
44
  children: text
44
45
  });
45
46
  case "number": return /* @__PURE__ */ jsx("span", {
@@ -1,5 +1,6 @@
1
1
  //#region src/filter-panel/filter-config.ts
2
2
  const defaultDatePresets = [
3
+ "today",
3
4
  "7d",
4
5
  "30d",
5
6
  "year"
@@ -7,6 +8,7 @@ const defaultDatePresets = [
7
8
  const datePresetLabels = {
8
9
  today: "Today",
9
10
  yesterday: "Yesterday",
11
+ "24h": "Last 24 hours",
10
12
  "7d": "Last 7 days",
11
13
  "30d": "Last 30 days",
12
14
  "90d": "Last 90 days",
@@ -79,11 +79,12 @@ const units = [
79
79
  ["hour", 3600],
80
80
  ["minute", 60]
81
81
  ];
82
- /** "5 minutes ago", "yesterday", "3 years ago"; `seconds` is how long ago it was. */
82
+ /** "5 minutes ago", "yesterday", "in 3 hours"; `seconds` is how long ago it was, negative for a time to come. */
83
83
  const formatRelativeTime = (seconds) => {
84
- const [unit, size] = units.find(([, size]) => seconds >= size) ?? ["second", 1];
85
- if (unit === "second" && seconds < 45) return "just now";
86
- return relative.format(-Math.floor(seconds / size), unit);
84
+ const away = Math.abs(seconds);
85
+ const [unit, size] = units.find(([, size]) => away >= size) ?? ["second", 1];
86
+ if (unit === "second" && away < 45) return "just now";
87
+ return relative.format(-Math.sign(seconds) * Math.floor(away / size), unit);
87
88
  };
88
89
  //#endregion
89
90
  export { formatAgo, formatDate, formatDateTime, formatDayRange, formatDayShort, formatMonth, formatRelativeTime, formatTime, formatWeekday, formatWeekdayDate };
@@ -92,6 +92,7 @@ const columnsFromModel = (resource, view, highlight = [], names = listColumnName
92
92
  ...field.type === "enum" && viewField?.valueColors && { colors: viewField.valueColors },
93
93
  ...field.type === "enum" && viewField?.badgeShape && { shape: viewField.badgeShape },
94
94
  ...viewField?.suffix !== void 0 && { suffix: viewField.suffix },
95
+ ...kind === "datetime" && viewField?.format === "relative" && { relative: true },
95
96
  ...kind === "money" && viewField?.decimals !== void 0 && { decimals: viewField.decimals },
96
97
  ...kind === "money" && {
97
98
  currency: "EUR",
@@ -1,5 +1,5 @@
1
1
  import { viewerZone, zoneLabel } from "../format/zone.js";
2
- import { formatDate, formatDateTime } from "../format/date.js";
2
+ import { formatDate, formatDateTime, formatRelativeTime } from "../format/date.js";
3
3
  import { fileShown } from "./file-values.js";
4
4
  import { enumLabel } from "@protobase/schema";
5
5
  //#region src/live/display-value.ts
@@ -8,13 +8,16 @@ const affixed = (text, hints) => `${hints.prefix ?? ""}${text}${hints.suffix ??
8
8
  * A read-only rendering of a stored value, following the view's prefix, suffix, decimals, format and value label hints; a
9
9
  * timestamp on the clock of `zone`, the viewer's unless given.
10
10
  */
11
- const displayValue = (field, value, hints = {}, zone = viewerZone()) => {
11
+ const displayValue = (field, value, hints = {}, zone = viewerZone(), now = Date.now()) => {
12
12
  if (value === null || value === void 0 || value === "") return "—";
13
13
  switch (field.type) {
14
14
  case "boolean": return value ? "Yes" : "No";
15
15
  case "file": return fileShown(value)?.name ?? "—";
16
16
  case "enum": return enumLabel(String(value), hints.valueLabels);
17
- case "timestamp": return formatDateTime(Date.parse(String(value)), zone);
17
+ case "timestamp": {
18
+ const ms = Date.parse(String(value));
19
+ return hints.format === "relative" ? formatRelativeTime(Math.round((now - ms) / 1e3)) : formatDateTime(ms, zone);
20
+ }
18
21
  case "date": return formatDate(Date.parse(String(value)));
19
22
  case "decimal": {
20
23
  const text = hints.decimals === void 0 ? String(value) : Number(value).toFixed(hints.decimals);
@@ -29,11 +32,13 @@ const displayValue = (field, value, hints = {}, zone = viewerZone()) => {
29
32
  default: return typeof value === "object" ? JSON.stringify(value) : String(value);
30
33
  }
31
34
  };
32
- /** The hover label of a shown value: the time zone a timestamp is shown in, nothing for other values. */
33
- const valueTitle = (field, value, zone = viewerZone()) => {
35
+ /** The hover label of a shown value: the time zone a timestamp is shown in, or its exact time when shown relative; nothing for other values. */
36
+ const valueTitle = (field, value, zone = viewerZone(), hints = {}) => {
34
37
  if (field.type !== "timestamp" || typeof value !== "string") return void 0;
35
38
  const ms = Date.parse(value);
36
- return Number.isNaN(ms) ? void 0 : `Shown in ${zoneLabel(ms, zone)}`;
39
+ if (Number.isNaN(ms)) return void 0;
40
+ if (hints.format === "relative") return formatDateTime(ms, zone);
41
+ return `Shown in ${zoneLabel(ms, zone)}`;
37
42
  };
38
43
  //#endregion
39
44
  export { displayValue, valueTitle };
@@ -7,6 +7,10 @@ const span = {
7
7
  end: 0
8
8
  };
9
9
  const relative = {
10
+ "24h": {
11
+ amount: 24,
12
+ unit: "h"
13
+ },
10
14
  "7d": {
11
15
  amount: 7,
12
16
  unit: "d"
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.22.2";
2
+ var version = "0.22.4";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };