@protobase/ui 0.12.13 → 0.12.14

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.
@@ -2,7 +2,7 @@ import { Badge } from "../../primitives/badge.js";
2
2
  import { Skeleton } from "../../primitives/skeleton.js";
3
3
  import { humanize } from "../../live/naming.js";
4
4
  import { useProjectUi } from "../pages/project-ui.js";
5
- import { cardFields } from "./list-displays.js";
5
+ import { cardFields, filledMeta } from "./list-displays.js";
6
6
  import { AppLink } from "../use-app-address.js";
7
7
  import { fileShown, isPreviewable } from "../../live/file-values.js";
8
8
  import { displayValue } from "../../live/display-value.js";
@@ -40,8 +40,8 @@ const MetaValue = ({ icon, children }) => {
40
40
  };
41
41
  /**
42
42
  * A card per record, opening its page: its image across the top, the title over the subtitle, the badge beside it,
43
- * small meta fields below and the labels of its flags that are true; or, with a `card` component, what it draws of the
44
- * row, the built-in content again when it is missing or throws.
43
+ * small meta fields below, those with a value, and the labels of its flags that are true; or, with a `card` component,
44
+ * what it draws of the row, the built-in content again when it is missing or throws.
45
45
  */
46
46
  const CardsDisplay = ({ model, view, display, list }) => {
47
47
  const { image, title, subtitle, badge, meta, flags } = cardFields(model, view, display);
@@ -108,7 +108,7 @@ const CardsDisplay = ({ model, view, display, list }) => {
108
108
  }),
109
109
  meta.length > 0 && /* @__PURE__ */ jsx("dl", {
110
110
  className: "mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
111
- children: meta.map((name) => /* @__PURE__ */ jsxs("div", {
111
+ children: filledMeta(row, meta).map((name) => /* @__PURE__ */ jsxs("div", {
112
112
  className: "flex gap-1",
113
113
  children: [/* @__PURE__ */ jsx("dt", {
114
114
  className: "sr-only",
@@ -37,6 +37,8 @@ const cardFields = (model, view, display) => ({
37
37
  meta: display.meta ?? [],
38
38
  flags: display.flags ?? []
39
39
  });
40
+ /** The meta fields a card shows for a row: those with a value, so a listing for rent only shows no purchase price. */
41
+ const filledMeta = (row, meta) => meta.filter((name) => row.record[name] !== null && row.record[name] !== void 0 && row.record[name] !== "");
40
42
  /** Every field a display shows, the fields its rows are loaded and labelled for. */
41
43
  const shownFields = (model, view, display) => {
42
44
  if (display.kind === "custom") return display.fields;
@@ -73,4 +75,4 @@ const displayRow = (model, row) => {
73
75
  };
74
76
  };
75
77
  //#endregion
76
- export { cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, loadedFields, shownDisplay, shownFields, sortFields };
78
+ export { cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledMeta, loadedFields, shownDisplay, shownFields, sortFields };
@@ -29,7 +29,7 @@ const renderCell = (spec, value) => {
29
29
  });
30
30
  case "money": return /* @__PURE__ */ jsxs("span", {
31
31
  className: "tabular-nums",
32
- children: [formatMoney(Number(value), spec.currency), spec.suffix]
32
+ children: [formatMoney(Number(value), spec.currency, spec.decimals), spec.suffix]
33
33
  });
34
34
  case "date": return /* @__PURE__ */ jsx("span", {
35
35
  className: "tabular-nums text-muted-foreground",
@@ -1,15 +1,21 @@
1
1
  //#region src/format/money.ts
2
2
  const formatters = /* @__PURE__ */ new Map();
3
- const formatterFor = (currency) => {
4
- const cached = formatters.get(currency);
3
+ const formatterFor = (currency, decimals) => {
4
+ const key = `${currency}:${decimals ?? ""}`;
5
+ const cached = formatters.get(key);
5
6
  if (cached) return cached;
6
7
  const created = new Intl.NumberFormat("en-IE", {
7
8
  style: "currency",
8
- currency
9
+ currency,
10
+ ...decimals !== void 0 && {
11
+ minimumFractionDigits: decimals,
12
+ maximumFractionDigits: decimals
13
+ }
9
14
  });
10
- formatters.set(currency, created);
15
+ formatters.set(key, created);
11
16
  return created;
12
17
  };
13
- const formatMoney = (cents, currency = "EUR") => formatterFor(currency).format(cents / 100);
18
+ /** An amount in cents in the currency, with its usual decimals or `decimals` of them. */
19
+ const formatMoney = (cents, currency = "EUR", decimals) => formatterFor(currency, decimals).format(cents / 100);
14
20
  //#endregion
15
21
  export { formatMoney };
@@ -69,13 +69,14 @@ const columnsFromModel = (resource, view, highlight = []) => {
69
69
  const viewField = view?.fields[name];
70
70
  const kind = kindOf(field, viewField, index);
71
71
  const labels = Object.fromEntries((field.enumValues ?? []).map((value) => [value, enumLabel(value, viewField?.valueLabels)]));
72
+ const suffixWidth = viewField?.suffix ? Math.ceil(viewField.suffix.length * 7.2) : 0;
72
73
  return [{
73
74
  id: name,
74
75
  header: viewField?.label ?? humanize(name),
75
76
  kind,
76
- width: name === "name" ? 260 : widths[kind],
77
+ width: (name === "name" ? 260 : widths[kind]) + suffixWidth,
77
78
  sortable: field.sortable,
78
- minWidth: Math.max(kind === "id" ? 150 : 0, headerWidth(viewField?.label ?? humanize(name), field.sortable), kind === "status" ? badgeWidth(Object.values(labels)) : 0),
79
+ minWidth: Math.max(kind === "id" ? 150 : 0, headerWidth(viewField?.label ?? humanize(name), field.sortable), kind === "status" ? badgeWidth(Object.values(labels)) : 0, suffixWidth > 0 ? widths[kind] + suffixWidth : 0),
79
80
  ...fixedKinds.has(kind) && { fixed: true },
80
81
  ...highlight.includes(name) && { highlight: true },
81
82
  ...field.type === "enum" && {
@@ -85,6 +86,7 @@ const columnsFromModel = (resource, view, highlight = []) => {
85
86
  ...field.type === "enum" && viewField?.valueColors && { colors: viewField.valueColors },
86
87
  ...field.type === "enum" && viewField?.badgeShape && { shape: viewField.badgeShape },
87
88
  ...viewField?.suffix !== void 0 && { suffix: viewField.suffix },
89
+ ...kind === "money" && viewField?.decimals !== void 0 && { decimals: viewField.decimals },
88
90
  ...kind === "money" && {
89
91
  currency: "EUR",
90
92
  value: (row) => row[name] == null ? null : currencyCents(row[name])
@@ -60,6 +60,10 @@ const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
60
60
  const facets = config.facets.flatMap((group) => {
61
61
  const values = state.facets[group.id] ?? [];
62
62
  if (values.length === 0) return [];
63
+ if (group.flags) {
64
+ const chosen = group.options.filter((option) => values.includes(option.value)).map((option) => w.eq(option.value, true));
65
+ return chosen.length === 0 ? [] : [chosen.length === 1 ? chosen[0] : w.or(...chosen)];
66
+ }
63
67
  return [values.length === 1 ? w.eq(group.id, values[0]) : w.in(group.id, values)];
64
68
  });
65
69
  const ranges = (config.ranges ?? []).flatMap((range) => {
@@ -79,6 +83,17 @@ const clausesToText = (clauses) => clauses.length === 0 ? "" : printFilter(w.and
79
83
  const stateToText = (config, state, extras = [], now = /* @__PURE__ */ new Date()) => clausesToText([...stateToClauses(config, state, now), ...extras]);
80
84
  const flatten = (expr) => expr.kind === "and" ? expr.args.flatMap(flatten) : [expr];
81
85
  const fieldName = (expr) => expr.field.path.join(".");
86
+ const isTrue = (expr) => expr.kind === "compare" && expr.op === "=" && expr.value.kind === "boolean" && expr.value.value;
87
+ const flagsChosen = (clause, groupOf) => {
88
+ const parts = clause.kind === "or" ? clause.args : [clause];
89
+ if (!parts.every(isTrue)) return void 0;
90
+ const fields = parts.map(fieldName);
91
+ const group = groupOf.get(fields[0]);
92
+ return group !== void 0 && fields.every((field) => groupOf.get(field) === group) ? {
93
+ group,
94
+ fields
95
+ } : void 0;
96
+ };
82
97
  const literalText = (value) => value.kind === "number" ? String(value.raw) : value.kind === "string" ? String(value.value) : void 0;
83
98
  const presetOf = (clause, now) => {
84
99
  const v = clause.value;
@@ -104,26 +119,31 @@ const textToState = (config, text, now = /* @__PURE__ */ new Date()) => {
104
119
  toggles: []
105
120
  };
106
121
  const extras = [];
107
- const facetIds = new Set(config.facets.map((group) => group.id));
122
+ const facetIds = new Set(config.facets.filter((group) => !group.flags).map((group) => group.id));
123
+ const flagGroupOf = new Map(config.facets.filter((group) => group.flags).flatMap((group) => group.options.map((option) => [option.value, group.id])));
108
124
  const toggleIds = new Set((config.toggles ?? []).map((toggle) => toggle.id));
109
125
  const rangeIds = new Set((config.ranges ?? []).map((range) => range.id));
110
126
  const bounds = /* @__PURE__ */ new Map();
111
127
  let dateClause;
112
128
  let todayEnd;
113
- for (const clause of parsed.ast ? flatten(parsed.ast) : []) if (clause.kind === "compare" && facetIds.has(fieldName(clause)) && clause.op === "=" && literalText(clause.value) !== void 0) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], literalText(clause.value)];
114
- else if (clause.kind === "in" && facetIds.has(fieldName(clause)) && clause.values.every((value) => literalText(value) !== void 0)) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], ...clause.values.map((value) => literalText(value))];
115
- else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === ">=" && presetOf(clause, now) && !state.date) {
116
- state.date = presetOf(clause, now);
117
- dateClause = clause;
118
- } else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === "<" && (clause.value.kind === "timestamp" || clause.value.kind === "string") && periodStart("today", now).startsWith(clause.value.value.slice(0, 10))) todayEnd = clause;
119
- else if (clause.kind === "compare" && toggleIds.has(fieldName(clause)) && clause.op === "=" && clause.value.kind === "boolean" && clause.value.value) state.toggles.push(fieldName(clause));
120
- else if (clause.kind === "compare" && rangeIds.has(fieldName(clause)) && (clause.op === ">=" || clause.op === "<=") && clause.value.kind === "number") {
121
- const range = bounds.get(fieldName(clause)) ?? { clauses: [] };
122
- if (clause.op === ">=") range.low = clause.value.value;
123
- else range.high = clause.value.value;
124
- range.clauses.push(clause);
125
- bounds.set(fieldName(clause), range);
126
- } else extras.push(clause);
129
+ for (const clause of parsed.ast ? flatten(parsed.ast) : []) {
130
+ const flags = flagsChosen(clause, flagGroupOf);
131
+ if (flags && !state.facets[flags.group]) state.facets[flags.group] = flags.fields;
132
+ else if (clause.kind === "compare" && facetIds.has(fieldName(clause)) && clause.op === "=" && literalText(clause.value) !== void 0) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], literalText(clause.value)];
133
+ else if (clause.kind === "in" && facetIds.has(fieldName(clause)) && clause.values.every((value) => literalText(value) !== void 0)) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], ...clause.values.map((value) => literalText(value))];
134
+ else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === ">=" && presetOf(clause, now) && !state.date) {
135
+ state.date = presetOf(clause, now);
136
+ dateClause = clause;
137
+ } else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === "<" && (clause.value.kind === "timestamp" || clause.value.kind === "string") && periodStart("today", now).startsWith(clause.value.value.slice(0, 10))) todayEnd = clause;
138
+ else if (clause.kind === "compare" && toggleIds.has(fieldName(clause)) && clause.op === "=" && clause.value.kind === "boolean" && clause.value.value) state.toggles.push(fieldName(clause));
139
+ else if (clause.kind === "compare" && rangeIds.has(fieldName(clause)) && (clause.op === ">=" || clause.op === "<=") && clause.value.kind === "number") {
140
+ const range = bounds.get(fieldName(clause)) ?? { clauses: [] };
141
+ if (clause.op === ">=") range.low = clause.value.value;
142
+ else range.high = clause.value.value;
143
+ range.clauses.push(clause);
144
+ bounds.set(fieldName(clause), range);
145
+ } else extras.push(clause);
146
+ }
127
147
  if (state.date === "yesterday" && !todayEnd) {
128
148
  extras.push(dateClause);
129
149
  state.date = void 0;
@@ -25,25 +25,30 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
25
25
  const field = model.fields[name];
26
26
  return field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
27
27
  };
28
- const facetQueries = useQueries({ queries: skeleton.facets.map((group) => {
28
+ const counted = skeleton.facets.flatMap((group) => (group.flags ? group.options.map((option) => option.value) : [group.id]).map((field) => ({
29
+ group: group.id,
30
+ field
31
+ })));
32
+ const facetQueries = useQueries({ queries: counted.map(({ group, field }) => {
29
33
  const filter = stateToText(skeleton, {
30
34
  ...state,
31
35
  facets: {
32
36
  ...state.facets,
33
- [group.id]: []
37
+ [group]: []
34
38
  }
35
39
  }, extras);
36
40
  return {
37
- queryKey: keys.facets(model.name, group.id, filter),
38
- queryFn: () => client.facets(model.name, group.id, {
41
+ queryKey: keys.facets(model.name, field, filter),
42
+ queryFn: () => client.facets(model.name, field, {
39
43
  filter,
40
44
  limit: 50
41
45
  })
42
46
  };
43
47
  }) });
44
- const labelQueries = useQueries({ queries: skeleton.facets.map((group, index) => {
48
+ const counts = (group, field) => facetQueries[counted.findIndex((entry) => entry.group === group && entry.field === field)]?.data;
49
+ const labelQueries = useQueries({ queries: skeleton.facets.map((group) => {
45
50
  const target = targetOf(group.id);
46
- const ids = labelKeys((facetQueries[index]?.data ?? []).flatMap((facet) => facet.value === null ? [] : [String(facet.value)]), state.facets[group.id] ?? []);
51
+ const ids = labelKeys((counts(group.id, group.id) ?? []).flatMap((facet) => facet.value === null ? [] : [String(facet.value)]), state.facets[group.id] ?? []);
47
52
  return {
48
53
  queryKey: keys.labels(`facet:${model.name}.${group.id}`, ids.join(",")),
49
54
  enabled: Boolean(target) && ids.length > 0,
@@ -67,9 +72,20 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
67
72
  };
68
73
  }) });
69
74
  const facetGroup = (group, index) => {
75
+ if (group.flags) {
76
+ const options = group.options.map(({ value }) => ({
77
+ value,
78
+ label: label(value),
79
+ count: counts(group.id, value)?.find((facet) => String(facet.value) === "true")?.count ?? 0
80
+ }));
81
+ return {
82
+ ...group,
83
+ options
84
+ };
85
+ }
70
86
  const field = model.fields[group.id];
71
87
  const names = new Map(labelQueries[index]?.data ?? []);
72
- const counted = (facetQueries[index]?.data ?? []).flatMap((facet) => facet.value === null ? [] : [{
88
+ const values = (counts(group.id, group.id) ?? []).flatMap((facet) => facet.value === null ? [] : [{
73
89
  value: String(facet.value),
74
90
  label: field?.type === "enum" ? enumLabel(String(facet.value), view?.fields[group.id]?.valueLabels) : names.get(String(facet.value)) ?? String(facet.value),
75
91
  count: facet.count
@@ -78,7 +94,7 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
78
94
  return {
79
95
  ...group,
80
96
  label: label(group.id),
81
- options: search.matches ? matchOptions(search.matches, counted) : counted,
97
+ options: search.matches ? matchOptions(search.matches, values) : values,
82
98
  ...names.size > 0 && { labels: Object.fromEntries(names) },
83
99
  ...search.onSearch && { onSearch: search.onSearch }
84
100
  };
@@ -27,14 +27,27 @@ const widgetFields = (view, model) => {
27
27
  const widgets = view?.filters ?? [];
28
28
  const date = widgets.find((widget) => widget.kind === "dateRange");
29
29
  return {
30
- facets: widgets.flatMap((widget) => widget.kind === "facets" && !rangeOf(widget, model) ? [{
31
- id: widget.field,
32
- label: widget.field,
33
- options: [],
34
- searchable: widget.search,
35
- ...model?.fields[widget.field]?.type === "relation" && { combobox: true }
36
- }] : []),
37
- ranges: widgets.flatMap((widget) => rangeOf(widget, model) ? [emptyRange(widget.field)] : []),
30
+ facets: widgets.flatMap((widget) => {
31
+ if (widget.kind === "facets" && !rangeOf(widget, model)) return [{
32
+ id: widget.field,
33
+ label: widget.field,
34
+ options: [],
35
+ searchable: widget.search,
36
+ ...model?.fields[widget.field]?.type === "relation" && { combobox: true }
37
+ }];
38
+ if (widget.kind === "flags") return [{
39
+ id: widget.fields.join("|"),
40
+ label: widget.label,
41
+ options: widget.fields.map((field) => ({
42
+ value: field,
43
+ label: field,
44
+ count: 0
45
+ })),
46
+ flags: true
47
+ }];
48
+ return [];
49
+ }),
50
+ ranges: widgets.flatMap((widget) => widget.kind !== "flags" && rangeOf(widget, model) ? [emptyRange(widget.field)] : []),
38
51
  ...date && {
39
52
  dateField: date.field,
40
53
  dateKind: model?.fields[date.field]?.type === "date" ? "date" : "timestamp",
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.12.13";
2
+ var version = "0.12.14";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };