@protobase/ui 0.21.9 → 0.21.11

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.
@@ -17,7 +17,8 @@ const takeLinkError = () => {
17
17
  };
18
18
  /** The signed-in person's passkeys, two-factor authentication and connected accounts, within what the sign-in policy allows. */
19
19
  const AccountSecurityPage = ({ email }) => {
20
- const { session, signInProviders, linkProvider } = useAuth();
20
+ const { session, signInProviders, socialProviders, linkProvider } = useAuth();
21
+ const linkable = signInProviders.filter((provider) => socialProviders.includes(provider.id));
21
22
  const { account } = session;
22
23
  const [loaded, setLoaded] = useState();
23
24
  const [error, setError] = useState();
@@ -61,11 +62,11 @@ const AccountSecurityPage = ({ email }) => {
61
62
  onNewBackupCodes: (password) => account.newBackupCodes(password),
62
63
  onChanged: reload
63
64
  }),
64
- signInProviders.length > 0 && /* @__PURE__ */ jsx(SignInProvidersSection, {
65
- providers: signInProviders,
65
+ linkable.length > 0 && /* @__PURE__ */ jsx(SignInProvidersSection, {
66
+ providers: linkable,
66
67
  linked: loaded.linked,
67
68
  onConnect: linkProvider,
68
- error: linkError && providerMessage(linkError, signInProviders.length === 1 ? signInProviders[0].name : "the provider")
69
+ error: linkError && providerMessage(linkError, linkable.length === 1 ? linkable[0].name : "the provider")
69
70
  })
70
71
  ] }) : error ? /* @__PURE__ */ jsx("p", {
71
72
  role: "alert",
@@ -1,5 +1,6 @@
1
1
  import { isEditable, toPatchValue } from "../../live/field-values.js";
2
2
  import { whenShown } from "../record-mode/record-mode.js";
3
+ import { isRecordCondition } from "@protobase/schema";
3
4
  //#region src/app/create/create-values.ts
4
5
  const isChosen = (value) => typeof value === "object" && value !== null && "key" in value;
5
6
  /** Fields the user can set on a new record: not read-only, not the tenant, and not structured data. */
@@ -13,7 +14,7 @@ const createSections = (model, view) => {
13
14
  const creatable = Object.values(model.fields).filter((field) => isCreatable(model, field)).map((field) => field.name);
14
15
  const placed = /* @__PURE__ */ new Set();
15
16
  const sections = (view?.layout ?? []).flatMap((item) => {
16
- if (item.kind !== "section" || !whenShown(item.when, true)) return [];
17
+ if (item.kind !== "section" || isRecordCondition(item.when) || !whenShown(item.when, true)) return [];
17
18
  const fields = item.fields.filter((name) => creatable.includes(name) && !placed.has(name) && whenShown(view?.fields[name]?.when, true));
18
19
  fields.forEach((name) => placed.add(name));
19
20
  return fields.length > 0 ? [{
@@ -1,43 +1,14 @@
1
- import { useClient } from "../../../data/api-provider.js";
2
- import { useAdminMeta } from "../../meta-gate.js";
3
1
  import { useLayoutRecord } from "../record-scope.js";
4
- import { countQuery } from "../use-layout-data.js";
5
2
  import { propsOf } from "./block-props.js";
6
- import { usePathValues } from "../use-path-value.js";
7
- import { useMemo } from "react";
8
- import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
9
- import { useQueries } from "@tanstack/react-query";
10
- import { countedResource, evaluateCondition, parseCondition } from "@protobase/layout";
3
+ import { useConditions } from "../use-conditions.js";
4
+ import { Fragment, jsx } from "react/jsx-runtime";
5
+ import "@protobase/layout";
11
6
  //#region src/app/pages/blocks/show.tsx
12
7
  /** Its children while `when` holds: fields of the record around it, and `<resource>.count` for any resource. */
13
8
  const ShowBlock = ({ node, children }) => {
14
9
  const { when } = propsOf(node);
15
- const { resources } = useAdminMeta();
16
- const client = useClient();
17
- const scope = useLayoutRecord();
18
- const condition = useMemo(() => parseCondition(when), [when]);
19
- const paths = condition.ok ? condition.paths : [];
20
- const counted = paths.map((path) => countedResource(path, (name) => Boolean(scope && Object.hasOwn(scope.model.fields, name))));
21
- const fieldPaths = paths.filter((_, index) => counted[index] === void 0);
22
- const fields = usePathValues(scope, fieldPaths);
23
- const counts = useQueries({ queries: counted.flatMap((resource) => {
24
- const model = resource === void 0 ? void 0 : resources[resource];
25
- if (!model) return [];
26
- return [countQuery(client, model)];
27
- }) });
28
- if (!condition.ok) return null;
29
- if (fields.some((value) => value.state === "loading") || counts.some((count) => count.isPending)) return null;
30
- const values = {};
31
- fieldPaths.forEach((path, index) => {
32
- const value = fields[index];
33
- if (value.state === "ready") values[path] = value.value;
34
- });
35
- let next = 0;
36
- paths.forEach((path, index) => {
37
- if (counted[index] === void 0) return;
38
- values[path] = resources[counted[index]] ? counts[next++]?.data ?? 0 : 0;
39
- });
40
- return evaluateCondition(condition, values) ? /* @__PURE__ */ jsx(Fragment$1, { children }) : null;
10
+ const [shown] = useConditions([when], useLayoutRecord());
11
+ return shown ? /* @__PURE__ */ jsx(Fragment, { children }) : null;
41
12
  };
42
13
  //#endregion
43
14
  export { ShowBlock };
@@ -0,0 +1,49 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ import { useAdminMeta } from "../meta-gate.js";
3
+ import { countQuery } from "./use-layout-data.js";
4
+ import { usePathValues } from "./use-path-value.js";
5
+ import { useMemo } from "react";
6
+ import { useQueries } from "@tanstack/react-query";
7
+ import { countedResource, evaluateCondition, parseCondition } from "@protobase/layout";
8
+ //#region src/app/pages/use-conditions.ts
9
+ /**
10
+ * Whether each condition holds for the record around it (`scope`): `undefined` while a value it reads loads, `false`
11
+ * when it does not parse. Fields come from the stored record, followed through relations; `<resource>.count` counts
12
+ * records. All conditions share one set of queries, so the hook count never changes with the conditions.
13
+ */
14
+ const useConditions = (whens, scope) => {
15
+ const { resources } = useAdminMeta();
16
+ const client = useClient();
17
+ const key = JSON.stringify(whens);
18
+ const parsed = useMemo(() => JSON.parse(key).map(parseCondition), [key]);
19
+ const isField = (name) => Boolean(scope && Object.hasOwn(scope.model.fields, name));
20
+ const paths = [...new Set(parsed.flatMap((condition) => condition.ok ? condition.paths : []))];
21
+ const counted = new Map(paths.flatMap((path) => {
22
+ const resource = countedResource(path, isField);
23
+ return resource === void 0 ? [] : [[path, resource]];
24
+ }));
25
+ const fieldPaths = paths.filter((path) => !counted.has(path));
26
+ const countedModels = [...new Set(counted.values())].flatMap((resource) => resources[resource] ? [resources[resource]] : []);
27
+ const fields = usePathValues(scope, fieldPaths);
28
+ const counts = useQueries({ queries: countedModels.map((model) => countQuery(client, model)) });
29
+ return parsed.map((condition) => {
30
+ if (!condition.ok) return false;
31
+ const values = {};
32
+ for (const path of condition.paths) {
33
+ const resource = counted.get(path);
34
+ if (resource === void 0) {
35
+ const value = fields[fieldPaths.indexOf(path)];
36
+ if (value.state === "loading") return void 0;
37
+ if (value.state === "ready") values[path] = value.value;
38
+ continue;
39
+ }
40
+ const index = countedModels.findIndex((model) => model.name === resource);
41
+ if (index === -1) values[path] = 0;
42
+ else if (counts[index].isPending) return void 0;
43
+ else values[path] = counts[index].data ?? 0;
44
+ }
45
+ return evaluateCondition(condition, values);
46
+ });
47
+ };
48
+ //#endregion
49
+ export { useConditions };
@@ -32,7 +32,10 @@ const recordShown = (mode, canUpdate, edit) => {
32
32
  const helpShown = (own, view, editing) => (own ?? view?.helpWhen ?? "always") === "always" || editing;
33
33
  /** A field's help line on the record page, when it shows. */
34
34
  const fieldHelp = (hints, view, editing) => hints?.help !== void 0 && helpShown(hints.helpWhen, view, editing) ? hints.help : void 0;
35
- /** Whether a field or layout item shows: always, unless it says only while editing or only while reading and the page is not. */
35
+ /**
36
+ * Whether a field or layout item shows: always, unless it says only while editing or only while reading and the page is not.
37
+ * A record condition is no mode and counts as always here; the record page evaluates it separately.
38
+ */
36
39
  const whenShown = (when, editing) => {
37
40
  if (when === "editing") return editing;
38
41
  if (when === "reading") return !editing;
@@ -0,0 +1,18 @@
1
+ import { whenShown } from "./record-mode.js";
2
+ import { isRecordCondition } from "@protobase/schema";
3
+ //#region src/app/record-mode/shown-layout.ts
4
+ /** The items with a record condition in `when`, whose conditions the page evaluates. */
5
+ const conditionalItems = (items) => items.filter((item) => isRecordCondition(item.when));
6
+ /**
7
+ * The items that show: those with a record condition only when it holds (`held`, one answer per conditional item in
8
+ * order, `undefined` while loading) and the page is no preview; the others by the form or read view.
9
+ */
10
+ const shownLayout = (items, held, editing, preview) => {
11
+ let next = 0;
12
+ return items.filter((item) => {
13
+ if (!isRecordCondition(item.when)) return whenShown(item.when, editing);
14
+ return !preview && held[next++] === true;
15
+ });
16
+ };
17
+ //#endregion
18
+ export { conditionalItems, shownLayout };
@@ -0,0 +1,14 @@
1
+ import { useConditions } from "../pages/use-conditions.js";
2
+ import { conditionalItems, shownLayout } from "./shown-layout.js";
3
+ //#region src/app/record-mode/use-shown-layout.ts
4
+ /**
5
+ * The layout items to draw: those meant for the form or the read view as the page is, and those with a record
6
+ * condition while it holds for the saved record. Conditional items stay out while their values load and on a preview.
7
+ */
8
+ const useShownLayout = (items, scope, editing, preview) => {
9
+ const conditions = preview ? [] : conditionalItems(items).map((item) => item.when);
10
+ const held = useConditions(conditions, scope);
11
+ return shownLayout(items, held, editing, preview);
12
+ };
13
+ //#endregion
14
+ export { useShownLayout };
@@ -52,6 +52,7 @@ import { useLinesDraft } from "./use-lines-draft.js";
52
52
  import { GallerySection } from "./gallery/gallery-section.js";
53
53
  import { HistorySection } from "./history/history-section.js";
54
54
  import { CommentsSection } from "./comments/comments-section.js";
55
+ import { useShownLayout } from "./record-mode/use-shown-layout.js";
55
56
  import { useRecordEditor } from "./use-record-editor.js";
56
57
  import { useRelatedRecords } from "./use-related-records.js";
57
58
  import { IncomingCard } from "./incoming-card.js";
@@ -190,7 +191,14 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
190
191
  editor.change(name, keyValue(target, next.key));
191
192
  };
192
193
  const { fields: fieldTypes } = useProjectUi();
193
- const layout = layoutFor(model, view, shown === "read").filter((item) => whenShown(item.when, editing));
194
+ const scope = {
195
+ model,
196
+ record: stored.record,
197
+ key: recordKey,
198
+ etag: stored.etag,
199
+ permissions: stored.permissions
200
+ };
201
+ const layout = useShownLayout(layoutFor(model, view, shown === "read"), scope, editing, preview);
194
202
  const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
195
203
  const hasStatus = model.fields.status?.type === "enum";
196
204
  const statusValue = stored.record.status;
@@ -307,13 +315,6 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
307
315
  }];
308
316
  })
309
317
  });
310
- const scope = {
311
- model,
312
- record: stored.record,
313
- key: recordKey,
314
- etag: stored.etag,
315
- permissions: stored.permissions
316
- };
317
318
  const save = async () => {
318
319
  if (await editor.save() && leavesForm) go(readAddress, { replace: true });
319
320
  };
@@ -5,6 +5,7 @@ import { Section } from "../record-view/section.js";
5
5
  import { ErrorBanner } from "./error-banner.js";
6
6
  import { listColumnNames } from "../live/columns-from-model.js";
7
7
  import { useAdminMeta, usePermissions } from "./meta-gate.js";
8
+ import { countQuery } from "./pages/use-layout-data.js";
8
9
  import { searchAll } from "../live/search-all.js";
9
10
  import { relatedOpens } from "./related-opens.js";
10
11
  import { EmbeddedDisplaySwitch, EmbeddedList } from "./embedded-list/embedded-list.js";
@@ -82,7 +83,15 @@ const RelatedSection = ({ item, recordKey, owner }) => {
82
83
  const [display, pick] = useEmbeddedDisplay(view, `related:${owner}:${item.title}`, item.display);
83
84
  const permissions = usePermissions(item.resource);
84
85
  const [sorting, setSorting] = useState(false);
86
+ const client = useClient();
87
+ const counting = Boolean(item.hideEmpty && model && match.filter);
88
+ const count = useQuery({
89
+ queryKey: keys.count(item.resource, match.filter ?? ""),
90
+ enabled: counting,
91
+ queryFn: () => countQuery(client, model, match.filter ?? "").queryFn()
92
+ });
85
93
  if (!model) return null;
94
+ if (item.hideEmpty && (match.isPending || match.filter === null || counting && (count.isPending || count.data === 0))) return null;
86
95
  const opens = relatedOpens(item, model, resources);
87
96
  const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
88
97
  const handOrder = relatedHandOrder(item, model, permissions);
@@ -49,7 +49,8 @@ const providerMessages = {
49
49
  account_not_linked: (name) => `Your ${name} account is not linked to your account here. Sign in another way, then connect ${name} on the Sign-in & security page.`,
50
50
  account_already_linked_to_different_user: (name) => `This ${name} account is linked to someone else here already.`,
51
51
  unable_to_link_account: (name) => `Your ${name} account could not be linked. Try again.`,
52
- access_denied: (name) => `${name} did not let you in, or you cancelled.`
52
+ access_denied: (name) => `${name} did not let you in, or you cancelled.`,
53
+ EMAIL_DOMAIN_NOT_ALLOWED: (name) => `${name} signed you in with an address this app does not accept from it. Sign in another way.`
53
54
  };
54
55
  /** Why signing in or linking with the provider `name` did not go through, from the code it came back with. */
55
56
  const providerMessage = (code, name) => providerMessages[code]?.(name) ?? `Signing in with ${name} did not go through. Try again.`;
@@ -18,6 +18,7 @@ const AuthProvider = ({ session: given, children }) => {
18
18
  const [passkeyDomains, setPasskeyDomains] = useState([]);
19
19
  const [socialProviders, setSocialProviders] = useState([]);
20
20
  const [platformSignIn, setPlatformSignIn] = useState();
21
+ const [samlProviders, setSamlProviders] = useState([]);
21
22
  const [staffSignIn, setStaffSignIn] = useState();
22
23
  const [organizations, setOrganizations] = useState();
23
24
  const [anonymous, setAnonymous] = useState(false);
@@ -52,6 +53,7 @@ const AuthProvider = ({ session: given, children }) => {
52
53
  setPasskeyDomains(status.passkeyDomains ?? []);
53
54
  setSocialProviders(status.socialProviders);
54
55
  setPlatformSignIn(status.platformSignIn);
56
+ setSamlProviders(status.samlProviders);
55
57
  setStaffSignIn(status.staffSignIn);
56
58
  setOrganizations(status.organizations);
57
59
  setAnonymous(Boolean(status.anonymous));
@@ -73,10 +75,10 @@ const AuthProvider = ({ session: given, children }) => {
73
75
  canSignUp,
74
76
  passkeyDomains,
75
77
  socialProviders,
76
- signInProviders: socialProviders.map((id) => ({
78
+ signInProviders: [...socialProviders.map((id) => ({
77
79
  id,
78
80
  name: providerName(id, platformSignIn)
79
- })),
81
+ })), ...samlProviders],
80
82
  staffSignIn,
81
83
  ...organizations && { organizations },
82
84
  markSignedOut,
@@ -100,7 +102,9 @@ const AuthProvider = ({ session: given, children }) => {
100
102
  signInWithPasskey: async (domain) => enter(await session.signInWithPasskey(domain)),
101
103
  verifyTwoFactor: async (input) => enter(await session.verifyTwoFactor(input)),
102
104
  signInSocial: async (provider) => {
103
- window.location.assign(await session.signInSocial(provider, new URL(withoutSignInLink(window.location.href), window.location.href).href));
105
+ const callbackURL = new URL(withoutSignInLink(window.location.href), window.location.href).href;
106
+ const saml = samlProviders.some((candidate) => candidate.id === provider);
107
+ window.location.assign(await (saml ? session.signInSaml(provider, callbackURL) : session.signInSocial(provider, callbackURL)));
104
108
  },
105
109
  linkProvider: async (provider) => {
106
110
  window.location.assign(await session.account.linkProvider(provider, new URL(withoutSignInLink(window.location.href), window.location.href).href));
@@ -122,6 +126,7 @@ const AuthProvider = ({ session: given, children }) => {
122
126
  passkeyDomains,
123
127
  socialProviders,
124
128
  platformSignIn,
129
+ samlProviders,
125
130
  staffSignIn,
126
131
  organizations,
127
132
  anonymous,
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.21.9";
2
+ var version = "0.21.11";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };