@protobase/ui 0.15.2 → 0.15.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.
@@ -5,8 +5,8 @@ import { mapCards } from "./list-displays.js";
5
5
  import { useAdminMeta } from "../meta-gate.js";
6
6
  import { ErrorBanner } from "../error-banner.js";
7
7
  import { Problem } from "../pages/problem.js";
8
- import { RecordCard } from "./record-card.js";
9
8
  import { useDebounced } from "../../lib/use-debounced.js";
9
+ import { RecordCard } from "./record-card.js";
10
10
  import { boundsAround, worldArea } from "../map/map-area.js";
11
11
  import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
12
12
  import { useMapRecords } from "./use-map-records.js";
@@ -1,4 +1,4 @@
1
- import { Bed, Bell, Bookmark, FileText, Heart, RulerDimensionLine, Send, Wallet } from "lucide-react";
1
+ import { Bed, Bell, Bookmark, ExternalLink, FileText, Heart, RulerDimensionLine, Send, Wallet } from "lucide-react";
2
2
  //#region src/app/named-icon.ts
3
3
  /** The icons an action's, a field's or a sign-in prompt's `icon` may name. */
4
4
  const byName = {
@@ -9,7 +9,8 @@ const byName = {
9
9
  bed: Bed,
10
10
  heart: Heart,
11
11
  bell: Bell,
12
- bookmark: Bookmark
12
+ bookmark: Bookmark,
13
+ "external-link": ExternalLink
13
14
  };
14
15
  /** The icon a name stands for, or nothing for a name the app does not know. */
15
16
  const namedIcon = (name) => name !== void 0 && Object.hasOwn(byName, name) ? byName[name] : void 0;
@@ -1,5 +1,43 @@
1
+ import { hrefField } from "@protobase/schema/internal";
1
2
  //#region src/app/pages/action-href.ts
2
3
  /** `href` with every `{field}` replaced by the record's value, percent-encoded; a missing value becomes empty. */
3
4
  const fillHref = (href, record = {}) => href.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => encodeURIComponent(record[name] === null || record[name] === void 0 ? "" : String(record[name])));
5
+ const webUrl = (value) => {
6
+ if (typeof value !== "string" || !URL.canParse(value)) return void 0;
7
+ const url = new URL(value);
8
+ return url.protocol === "https:" || url.protocol === "http:" ? url.href : void 0;
9
+ };
10
+ /**
11
+ * Where a link action's `href` goes from `record`: an href that is only `{field}` is the record's value as it is, when
12
+ * that is an `https:` or `http:` URL, and nothing otherwise; any other href is filled by `fillHref`.
13
+ */
14
+ const linkTarget = (href, record = {}) => {
15
+ const field = hrefField(href);
16
+ return field === void 0 ? fillHref(href, record) : webUrl(record[field]);
17
+ };
18
+ /**
19
+ * Opens a link's target: an `https:` or `http:` page in a new tab that gets neither the app's window nor its address,
20
+ * anything else (`mailto:`) in place. A link without a target does nothing.
21
+ */
22
+ const openLink = (target) => {
23
+ if (target === void 0) return;
24
+ if (/^https?:/i.test(target)) window.open(target, "_blank", "noopener,noreferrer");
25
+ else window.location.assign(target);
26
+ };
27
+ const hostOf = (target) => URL.canParse(target) ? new URL(target).hostname.replace(/^www\./, "") : "";
28
+ /**
29
+ * `action` as it shows for `record`: a link whose `href` has no target there is left out, and `{host}` in a link's label
30
+ * becomes the host its target opens, without `www.`.
31
+ */
32
+ const actionFor = (action, record) => {
33
+ const run = action.run;
34
+ if (run?.kind !== "link" || !("href" in run)) return action;
35
+ const target = linkTarget(run.href, record);
36
+ if (target === void 0) return void 0;
37
+ return action.label.includes("{host}") ? {
38
+ ...action,
39
+ label: action.label.replaceAll("{host}", hostOf(target))
40
+ } : action;
41
+ };
4
42
  //#endregion
5
- export { fillHref };
43
+ export { actionFor, fillHref, linkTarget, openLink };
@@ -5,15 +5,16 @@ import { useAdminMeta, usePermissions } from "../meta-gate.js";
5
5
  import { useAppAddress } from "../use-app-address.js";
6
6
  import { can } from "../permissions.js";
7
7
  import { useRefreshResource } from "./use-layout-data.js";
8
- import { fillHref } from "./action-href.js";
8
+ import { actionFor, linkTarget, openLink } from "./action-href.js";
9
9
  import { useState } from "react";
10
10
  import { fillAddress } from "@protobase/schema/internal";
11
11
  //#region src/app/pages/use-run-action.ts
12
12
  const message = (error) => error instanceof Error ? error.message : "Something went wrong";
13
13
  /**
14
14
  * The named actions of `resource`'s view, ready to run on `scope` (the record they act on): built-in behaviour first
15
- * (`update`, `delete`, `link`), else the handler the project registered under the action's name. `blocked` says why one
16
- * cannot run; `afterRun` hears about every action that succeeded.
15
+ * (`update`, `delete`, `link`), else the handler the project registered under the action's name. A link to a URL the
16
+ * record holds is left out while it holds none. `blocked` says why one cannot run; `afterRun` hears about every action
17
+ * that succeeded.
17
18
  */
18
19
  const useActionRunner = (resource, scope, afterRun) => {
19
20
  const { views } = useAdminMeta();
@@ -25,7 +26,7 @@ const useActionRunner = (resource, scope, afterRun) => {
25
26
  const refresh = useRefreshResource();
26
27
  const { go } = useAppAddress();
27
28
  const [running, setRunning] = useState();
28
- const actions = views[resource]?.actions ?? [];
29
+ const actions = (views[resource]?.actions ?? []).flatMap((action) => actionFor(action, scope?.record) ?? []);
29
30
  const blocked = (action) => {
30
31
  const kind = action.run?.kind;
31
32
  if ((kind === "update" || kind === "delete") && !scope) return "Needs a record";
@@ -37,7 +38,7 @@ const useActionRunner = (resource, scope, afterRun) => {
37
38
  const { run } = action;
38
39
  if (run?.kind === "link") {
39
40
  if ("to" in run) go(fillAddress(run.to, scope?.record));
40
- else window.location.assign(fillHref(run.href, scope?.record));
41
+ else openLink(linkTarget(run.href, scope?.record));
41
42
  return;
42
43
  }
43
44
  if (run?.kind === "update") await client.update(resource, scope.key, run.values, scope.etag ?? "*");
@@ -1,13 +1,26 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { useDebounced } from "../lib/use-debounced.js";
2
3
  import { barInRange, fraction, moveThumb } from "./range-math.js";
4
+ import { useEffect, useState } from "react";
3
5
  import { jsx, jsxs } from "react/jsx-runtime";
4
6
  import "./range-slider.css";
5
7
  //#region src/filter-panel/range-filter.tsx
8
+ /** How long a thumb rests, mid-drag or between arrow keys, before the list filters on it. */
9
+ const rangeSettleMs = 1e3;
6
10
  const RangeFilter = ({ config, value, onChange }) => {
7
11
  const { min, max, step, histogram, format } = config;
8
- const selected = value ?? [min, max];
12
+ const [draft, setDraft] = useState();
13
+ const selected = draft ?? value ?? [min, max];
9
14
  const peak = Math.max(...histogram);
10
- const commit = (next) => onChange(next[0] === min && next[1] === max ? void 0 : next);
15
+ const commit = () => {
16
+ if (!draft) return;
17
+ setDraft(void 0);
18
+ onChange(draft[0] === min && draft[1] === max ? void 0 : draft);
19
+ };
20
+ const rested = useDebounced(draft, rangeSettleMs);
21
+ useEffect(() => {
22
+ if (rested) commit();
23
+ }, [rested]);
11
24
  return /* @__PURE__ */ jsxs("div", {
12
25
  className: "w-full",
13
26
  children: [
@@ -40,7 +53,10 @@ const RangeFilter = ({ config, value, onChange }) => {
40
53
  max,
41
54
  step,
42
55
  value: selected[thumb],
43
- onChange: (event) => commit(moveThumb(selected, thumb, Number(event.target.value)))
56
+ onChange: (event) => setDraft(moveThumb(selected, thumb, Number(event.target.value))),
57
+ onPointerUp: commit,
58
+ onTouchEnd: commit,
59
+ onBlur: commit
44
60
  }, thumb))
45
61
  }),
46
62
  /* @__PURE__ */ jsxs("div", {
@@ -51,4 +67,4 @@ const RangeFilter = ({ config, value, onChange }) => {
51
67
  });
52
68
  };
53
69
  //#endregion
54
- export { RangeFilter };
70
+ export { RangeFilter, rangeSettleMs };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.15.2";
2
+ var version = "0.15.4";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };