@protobase/ui 0.25.5 → 0.25.7

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.
@@ -6,9 +6,11 @@ import { propsOf } from "./block-props.js";
6
6
  import { useState } from "react";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/app/pages/blocks/action.tsx
9
- /** A button for a named action of the resource's view, asking first when the action has `confirm` or `inputs`. */
10
- const ActionBlock = ({ node }) => {
11
- const { name, resource, label, variant } = propsOf(node);
9
+ /**
10
+ * A button for a named action of `resource`'s view, by default of the record around it, on that record, asking first
11
+ * when the action has `confirm` or `inputs`; nothing when the view has no such action for the user.
12
+ */
13
+ const ActionButton = ({ name, resource, label, variant }) => {
12
14
  const scope = useLayoutRecord();
13
15
  const action = useRunAction(resource ?? scope?.model.name ?? "", name, resource === void 0 || resource === scope?.model.name ? scope : void 0);
14
16
  const [asking, setAsking] = useState(false);
@@ -38,5 +40,7 @@ const ActionBlock = ({ node }) => {
38
40
  }
39
41
  })] });
40
42
  };
43
+ /** An Action block: its button. */
44
+ const ActionBlock = ({ node }) => /* @__PURE__ */ jsx(ActionButton, { ...propsOf(node) });
41
45
  //#endregion
42
- export { ActionBlock };
46
+ export { ActionBlock, ActionButton };
@@ -0,0 +1,43 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { Button } from "../../primitives/button.js";
3
+ import { text } from "./messages.js";
4
+ import { useLayoutEffect, useRef, useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/app/related-cards/clamped.tsx
7
+ /**
8
+ * Its content cut off at a few lines' height with "Show more" under it when it is taller, which shows it all; the cut
9
+ * hides the rest rather than scrolling it, so the page keeps its one scrollbar.
10
+ */
11
+ const Clamped = ({ children }) => {
12
+ const content = useRef(null);
13
+ const [open, setOpen] = useState(false);
14
+ const [taller, setTaller] = useState(false);
15
+ useLayoutEffect(() => {
16
+ const box = content.current;
17
+ if (!box) return;
18
+ const measure = () => setTaller(box.scrollHeight > box.parentElement.clientHeight + 1);
19
+ measure();
20
+ const observer = new ResizeObserver(measure);
21
+ observer.observe(box);
22
+ return () => observer.disconnect();
23
+ }, [open]);
24
+ return /* @__PURE__ */ jsxs("div", {
25
+ className: "flex flex-col items-start gap-1",
26
+ children: [/* @__PURE__ */ jsx("div", {
27
+ className: cn("w-full min-w-0", !open && "max-h-40 overflow-hidden"),
28
+ children: /* @__PURE__ */ jsx("div", {
29
+ ref: content,
30
+ children
31
+ })
32
+ }), (taller || open) && /* @__PURE__ */ jsx(Button, {
33
+ size: "sm",
34
+ variant: "ghost",
35
+ className: "-ml-2",
36
+ "aria-expanded": open,
37
+ onClick: () => setOpen(!open),
38
+ children: open ? text("showLess") : text("showMore")
39
+ })]
40
+ });
41
+ };
42
+ //#endregion
43
+ export { Clamped };
@@ -0,0 +1,18 @@
1
+ import { defineMessages } from "../../locale/messages.js";
2
+ //#region src/app/related-cards/messages.ts
3
+ const text = defineMessages({
4
+ en: {
5
+ showMore: "Show more",
6
+ showLess: "Show less",
7
+ couldNotLoad: "Could not load",
8
+ nothingYet: "Nothing here yet."
9
+ },
10
+ nl: {
11
+ showMore: "Meer tonen",
12
+ showLess: "Minder tonen",
13
+ couldNotLoad: "Kon niet laden",
14
+ nothingYet: "Hier staat nog niets."
15
+ }
16
+ });
17
+ //#endregion
18
+ export { text };
@@ -0,0 +1,66 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { recordKey, recordTitle } from "../../live/model-helpers.js";
3
+ import { useAdminMeta } from "../meta-gate.js";
4
+ import { AppLink } from "../use-app-address.js";
5
+ import { RecordScope } from "../pages/record-scope.js";
6
+ import { ActionButton } from "../pages/blocks/action.js";
7
+ import { FieldValue } from "../pages/field-value.js";
8
+ import { usePathValue } from "../pages/use-path-value.js";
9
+ import { fieldLabel } from "../pages/blocks/field.js";
10
+ import { Clamped } from "./clamped.js";
11
+ import { jsx, jsxs } from "react/jsx-runtime";
12
+ //#region src/app/related-cards/related-card.tsx
13
+ const CardField = ({ scope, path }) => {
14
+ const { views } = useAdminMeta();
15
+ const value = usePathValue(scope, path);
16
+ if (value.state === "missing") return null;
17
+ const label = fieldLabel(path, value.state === "ready" ? views[value.model.name]?.fields[value.field.name]?.label : void 0);
18
+ return /* @__PURE__ */ jsxs("div", {
19
+ className: "min-w-0",
20
+ children: [/* @__PURE__ */ jsx("dt", {
21
+ className: "text-body text-muted-foreground",
22
+ children: label
23
+ }), /* @__PURE__ */ jsx("dd", {
24
+ className: "mt-0.5",
25
+ children: value.state === "loading" ? /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" }) : /* @__PURE__ */ jsx(Clamped, { children: /* @__PURE__ */ jsx(FieldValue, {
26
+ model: value.model,
27
+ field: value.field,
28
+ value: value.value
29
+ }) })
30
+ })]
31
+ });
32
+ };
33
+ /**
34
+ * A related record as a card: its title linking to its page, its `fields` under their labels and the buttons of its
35
+ * view's `actions`, run on it as everywhere else.
36
+ */
37
+ const RelatedCard = ({ scope, view, fields, actions }) => /* @__PURE__ */ jsxs("li", {
38
+ className: "flex min-w-0 flex-col gap-3 rounded-lg border border-border bg-surface p-4",
39
+ children: [
40
+ /* @__PURE__ */ jsx(AppLink, {
41
+ to: {
42
+ kind: "record",
43
+ resource: scope.model.name,
44
+ key: recordKey(scope.model, scope.record)
45
+ },
46
+ className: "self-start font-medium text-primary-text hover:underline",
47
+ children: recordTitle(scope.model, view, scope.record, scope.key)
48
+ }),
49
+ fields.length > 0 && /* @__PURE__ */ jsx("dl", {
50
+ className: "flex flex-col gap-3",
51
+ children: fields.map((path) => /* @__PURE__ */ jsx(CardField, {
52
+ scope,
53
+ path
54
+ }, path))
55
+ }),
56
+ actions.length > 0 && /* @__PURE__ */ jsx(RecordScope, {
57
+ value: scope,
58
+ children: /* @__PURE__ */ jsx("div", {
59
+ className: "flex flex-wrap items-center gap-2",
60
+ children: actions.map((name) => /* @__PURE__ */ jsx(ActionButton, { name }, name))
61
+ })
62
+ })
63
+ ]
64
+ });
65
+ //#endregion
66
+ export { RelatedCard };
@@ -0,0 +1,85 @@
1
+ import { useIsXs } from "../../lib/use-media-query.js";
2
+ import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { humanize } from "../../live/naming.js";
4
+ import { defaultOrderBy } from "../list-order.js";
5
+ import { AppLink } from "../use-app-address.js";
6
+ import { ErrorBanner } from "../error-banner.js";
7
+ import { scopeOf, useCount, useListPage } from "../pages/use-layout-data.js";
8
+ import { embeddedDisplay, viewAllAddress } from "../embedded-list/embedded-display.js";
9
+ import { text } from "../embedded-list/messages.js";
10
+ import { cutOffNote } from "../embedded-list/cut-off-note.js";
11
+ import { text as text$1 } from "./messages.js";
12
+ import { RelatedCard } from "./related-card.js";
13
+ import { ArrowRight } from "lucide-react";
14
+ import { jsx, jsxs } from "react/jsx-runtime";
15
+ //#region src/app/related-cards/related-cards.tsx
16
+ /**
17
+ * The first `limit` records of a list as cards stacked full width, each with its fields and its actions' buttons, and a
18
+ * link to the whole list beside a note such as "The first 10 of 37." when there are more.
19
+ */
20
+ const RelatedCards = ({ model, view, filter, sort, fields, actions, limit, display, empty }) => {
21
+ const xs = useIsXs();
22
+ const orderBy = sort || defaultOrderBy(model, view);
23
+ const page = useListPage(model, {
24
+ filter,
25
+ sort: orderBy,
26
+ pageSize: limit
27
+ });
28
+ const count = useCount(model, filter);
29
+ if (page.isPending) return /* @__PURE__ */ jsx("div", {
30
+ className: "flex flex-col gap-3",
31
+ children: Array.from({ length: 2 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24 w-full" }, index))
32
+ });
33
+ if (page.error) return /* @__PURE__ */ jsx(ErrorBanner, {
34
+ title: text$1("couldNotLoad"),
35
+ error: page.error
36
+ });
37
+ const rows = page.data.items;
38
+ if (rows.length === 0) return /* @__PURE__ */ jsx("p", {
39
+ className: "text-muted-foreground",
40
+ children: empty ?? text$1("nothingYet")
41
+ });
42
+ const note = count.data === void 0 ? void 0 : cutOffNote(rows.length, count.data, false);
43
+ return /* @__PURE__ */ jsxs("div", {
44
+ className: "flex flex-col gap-3",
45
+ children: [/* @__PURE__ */ jsx("ul", {
46
+ className: "flex flex-col gap-3",
47
+ children: rows.map((row) => {
48
+ const scope = scopeOf(model, row);
49
+ return /* @__PURE__ */ jsx(RelatedCard, {
50
+ scope,
51
+ view,
52
+ fields,
53
+ actions
54
+ }, scope.key);
55
+ })
56
+ }), /* @__PURE__ */ jsxs("div", {
57
+ className: "flex flex-wrap items-center justify-between gap-2",
58
+ children: [/* @__PURE__ */ jsx("p", {
59
+ className: "text-xs text-muted-foreground",
60
+ children: note
61
+ }), /* @__PURE__ */ jsxs(AppLink, {
62
+ to: viewAllAddress(model, view, xs, {
63
+ filter,
64
+ orderBy,
65
+ display: embeddedDisplay(view, xs, { preferred: display })
66
+ }),
67
+ className: "flex items-center gap-1 font-medium text-primary-text hover:underline",
68
+ children: [
69
+ text("viewAll"),
70
+ " ",
71
+ /* @__PURE__ */ jsx("span", {
72
+ className: "sr-only",
73
+ children: (view?.names?.plural ?? humanize(model.name)).toLowerCase()
74
+ }),
75
+ /* @__PURE__ */ jsx(ArrowRight, {
76
+ className: "size-3.5",
77
+ "aria-hidden": true
78
+ })
79
+ ]
80
+ })]
81
+ })]
82
+ });
83
+ };
84
+ //#endregion
85
+ export { RelatedCards };
@@ -14,6 +14,7 @@ import { useEmbeddedDisplay } from "./embedded-list/use-embedded-display.js";
14
14
  import { text } from "./record-messages.js";
15
15
  import { HandSortedList } from "./hand-order/hand-sorted-list.js";
16
16
  import { ReorderButton } from "./hand-order/reorder-button.js";
17
+ import { RelatedCards } from "./related-cards/related-cards.js";
17
18
  import { useState } from "react";
18
19
  import { jsx, jsxs } from "react/jsx-runtime";
19
20
  import { useQuery } from "@tanstack/react-query";
@@ -78,7 +79,7 @@ const Empty = ({ empty }) => /* @__PURE__ */ jsx("p", {
78
79
  });
79
80
  /**
80
81
  * A record page section listing the first records of another resource that point at this record, directly or through
81
- * a join resource, in the list page's table or display, with a link to them all in the list. When they are ordered by
82
+ * a join resource, in the list page's table or display, or as cards with their actions, with a link to them all in the list. When they are ordered by
82
83
  * hand within this record, **Reorder** shows them all in their order, to be moved by their drag handles.
83
84
  */
84
85
  const RelatedSection = ({ item, value, owner }) => {
@@ -100,6 +101,25 @@ const RelatedSection = ({ item, value, owner }) => {
100
101
  if (item.hideEmpty && (match.isPending || match.filter === null || counting && (count.isPending || count.data === 0))) return null;
101
102
  const opens = relatedOpens(item, model, resources);
102
103
  const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
104
+ const listed = (list) => match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
105
+ title: text("couldNotLoadRelated"),
106
+ error: match.error
107
+ }) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { empty: item.empty }) : list(match.filter);
108
+ if (item.presentation === "cards") return /* @__PURE__ */ jsx(Section, {
109
+ title: item.title,
110
+ help: item.help,
111
+ children: listed((filter) => /* @__PURE__ */ jsx(RelatedCards, {
112
+ model,
113
+ view,
114
+ filter,
115
+ sort: item.sort ?? "",
116
+ fields: columns,
117
+ actions: item.actions ?? [],
118
+ limit: item.limit ?? 10,
119
+ display: item.display,
120
+ empty: item.empty
121
+ }))
122
+ });
103
123
  const handOrder = relatedHandOrder(item, model, permissions);
104
124
  const displaySwitch = /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
105
125
  view,
@@ -130,20 +150,17 @@ const RelatedSection = ({ item, value, owner }) => {
130
150
  title: item.title,
131
151
  help: item.help,
132
152
  actions,
133
- children: match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
134
- title: text("couldNotLoadRelated"),
135
- error: match.error
136
- }) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { empty: item.empty }) : /* @__PURE__ */ jsx(EmbeddedList, {
153
+ children: listed((filter) => /* @__PURE__ */ jsx(EmbeddedList, {
137
154
  model,
138
155
  view,
139
- filter: match.filter,
156
+ filter,
140
157
  sort: item.sort ?? "",
141
158
  columns,
142
159
  limit: item.limit ?? 10,
143
160
  display,
144
161
  empty: item.empty,
145
162
  opens
146
- }, match.filter)
163
+ }, filter))
147
164
  });
148
165
  };
149
166
  //#endregion
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.25.5";
2
+ var version = "0.25.7";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };