@gooddata/sdk-ui-ext 11.51.0-alpha.0 → 11.51.0-alpha.2

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 (40) hide show
  1. package/NOTICE +4 -4
  2. package/esm/index.d.ts +3 -2
  3. package/esm/index.d.ts.map +1 -1
  4. package/esm/index.js +3 -1
  5. package/esm/internal/translations/en-US.localization-bundle.d.ts +4 -0
  6. package/esm/internal/translations/en-US.localization-bundle.d.ts.map +1 -1
  7. package/esm/internal/translations/en-US.localization-bundle.js +4 -0
  8. package/esm/sdk-ui-ext.d.ts +56 -305
  9. package/esm/share/ObjectShareDialog.d.ts +19 -23
  10. package/esm/share/ObjectShareDialog.d.ts.map +1 -1
  11. package/esm/share/ObjectShareDialog.js +85 -171
  12. package/esm/share/accessSummary.d.ts +18 -8
  13. package/esm/share/accessSummary.d.ts.map +1 -1
  14. package/esm/share/accessSummary.js +25 -13
  15. package/esm/share/messages.d.ts +7 -0
  16. package/esm/share/messages.d.ts.map +1 -1
  17. package/esm/share/messages.js +5 -0
  18. package/esm/share/objectShareController.helpers.d.ts +83 -5
  19. package/esm/share/objectShareController.helpers.d.ts.map +1 -1
  20. package/esm/share/objectShareController.helpers.js +144 -10
  21. package/esm/share/objectShareController.types.d.ts +63 -64
  22. package/esm/share/objectShareController.types.d.ts.map +1 -1
  23. package/esm/share/types.d.ts +4 -5
  24. package/esm/share/types.d.ts.map +1 -1
  25. package/esm/share/useAccessList.d.ts +64 -63
  26. package/esm/share/useAccessList.d.ts.map +1 -1
  27. package/esm/share/useAccessList.js +174 -221
  28. package/esm/share/useLabelScope.d.ts +18 -13
  29. package/esm/share/useLabelScope.d.ts.map +1 -1
  30. package/esm/share/useLabelScope.js +112 -123
  31. package/esm/share/useObjectShareController.d.ts +14 -17
  32. package/esm/share/useObjectShareController.d.ts.map +1 -1
  33. package/esm/share/useObjectShareController.js +211 -224
  34. package/esm/share/useObjectShareDialog.d.ts +90 -0
  35. package/esm/share/useObjectShareDialog.d.ts.map +1 -0
  36. package/esm/share/useObjectShareDialog.js +82 -0
  37. package/package.json +21 -21
  38. package/src/notificationsPanel/NotificationsPanel/DefaultNotificationsPanelButton.scss +4 -1
  39. package/styles/css/main.css +1 -1
  40. package/styles/css/main.css.map +1 -1
@@ -1,8 +1,26 @@
1
1
  // (C) 2026 GoodData Corporation
2
- import { useCallback, useEffect, useMemo, useState } from "react";
3
- import { useBackendStrict, useWorkspaceStrict } from "@gooddata/sdk-ui";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { useBackendStrict, useCancelablePromise, useWorkspaceStrict } from "@gooddata/sdk-ui";
4
4
  import { isPermissionsNotAvailable } from "./accessErrors.js";
5
- import { buildLabelMutations, buildLabelMutationsForPrincipals, isGranteeGrantedIn, } from "./objectShareController.helpers.js";
5
+ import { buildLabelMutationsForPrincipals, isGranteeGrantedIn, } from "./objectShareController.helpers.js";
6
+ /** Pure: each grantee's in-scope label ids — primary always counts, others only where the grantee is granted. */
7
+ function resolveScopes(results, committedGranteeIds) {
8
+ const resolved = {};
9
+ for (const id of committedGranteeIds) {
10
+ resolved[id] = [];
11
+ }
12
+ for (const result of results) {
13
+ if ("transient" in result) {
14
+ continue;
15
+ }
16
+ for (const id of committedGranteeIds) {
17
+ if (result.label.isPrimary || isGranteeGrantedIn(result.list, id)) {
18
+ resolved[id].push(result.label.id);
19
+ }
20
+ }
21
+ }
22
+ return resolved;
23
+ }
6
24
  /**
7
25
  * Owns label-scope resolution + writes for {@link useObjectShareController}.
8
26
  * Resolves each grantee's scope by fetching every label's access list, tracks
@@ -11,144 +29,115 @@ import { buildLabelMutations, buildLabelMutationsForPrincipals, isGranteeGranted
11
29
  *
12
30
  * The resolved scope is local-authoritative: the probe seeds a scope only for
13
31
  * grantees it doesn't already know, so an optimistic scope written for a freshly
14
- * added grantee is never overwritten by the backend's lagging read. Local scopes
15
- * are dropped on a target switch so the next object re-resolves from scratch.
32
+ * added grantee is never overwritten by the backend's lagging read. Session-scoped
33
+ * like its owner the target is fixed for the mount (see {@link ObjectShareDialog}).
16
34
  *
17
35
  * @internal
18
36
  */
19
- export function useLabelScope(target, targetKey, labels, hasList, committedGranteeIds, labelsError, labelsLoading) {
37
+ export function useLabelScope(target, labels, hasList, committedGranteeIds, labelsError, labelsLoading) {
20
38
  const backend = useBackendStrict();
21
39
  const workspace = useWorkspaceStrict();
40
+ // The optimistic label-scope overlay — kept authoritative: a scope written for
41
+ // a fresh add / labels edit must survive the backend's lagging re-read, so the
42
+ // probe below only seeds grantees it doesn't already know.
22
43
  const [selectedLabelIdsByGrantee, setSelectedLabelIdsByGrantee] = useState({});
23
- // Label ids whose permissions endpoint responded not every display form is
24
- // independently permissionable (some 404). `undefined` means "not resolved
25
- // yet" (assume all).
26
- const [permissionableLabelIds, setPermissionableLabelIds] = useState(undefined);
44
+ // Whether the session's first probe has settled (see `labelsInitializing`)
45
+ // a fact about session history, so it is state, flipped once in onSuccess.
46
+ const [everResolved, setEverResolved] = useState(false);
27
47
  const labelsKey = labels.map((l) => l.id).join(",");
28
- // A target switch OR a label-set change invalidates the previous probe's
29
- // permissionable set; until the resolution effect re-derives it,
30
- // `effectiveLabels` must fall back to all labels rather than filter against
31
- // stale ids. Resetting on `labelsKey` too matters when the label set changes
32
- // under the same target (e.g. labels finish loading): otherwise the old
33
- // permissionable set would briefly mark scope resolved and filter the new
34
- // labels against stale ids, so add/share could skip expected per-label grants.
35
- useEffect(() => {
36
- setPermissionableLabelIds(undefined);
37
- }, [targetKey, labelsKey]);
38
- // A target switch — or a label-set change under the same target (labels finish
39
- // loading, a label added/removed) — drops every resolved scope so the seeding
40
- // effect below re-resolves from the current label set's per-label lists rather
41
- // than preserving scopes computed against the old labels. Without the labelsKey
42
- // reset, an existing grantee would keep a scope missing a newly-added label even
43
- // when the backend grants them access to it. (An add under the SAME labels only
44
- // changes granteeIdsKey, not labelsKey, so an optimistic scope still survives.)
45
- useEffect(() => {
48
+ // Drop every resolved scope when the label SET changes (labels finish loading, a
49
+ // label added/removed) the probe must re-resolve, or a grantee keeps a scope
50
+ // missing a newly-granted label. Render-time adjust-on-change.
51
+ const [seenLabelsKey, setSeenLabelsKey] = useState(labelsKey);
52
+ if (seenLabelsKey !== labelsKey) {
53
+ setSeenLabelsKey(labelsKey);
46
54
  setSelectedLabelIdsByGrantee({});
47
- }, [targetKey, labelsKey]);
48
- // Stable string key of the committed grantee ids the array is rebuilt each
49
- // render, so the effect keys on this instead to re-resolve only on a real change.
50
- const granteeIdsKey = committedGranteeIds.slice().sort().join(",");
51
- // Resolve each grantee's label scope: fetch every label's access list once and
52
- // record, per grantee, which labels they appear in (primary label always counts).
53
- // Keyed on the committed grantee ids + labels so it re-resolves after add/remove.
54
- // `hasList` is a dep too: a list that loads with no named grantees keeps
55
- // `granteeIdsKey` empty, so without it the effect would never run and the
56
- // permissionable set (404 filtering) would never resolve.
57
- useEffect(() => {
58
- if (!target || labels.length === 0 || !hasList) {
59
- return;
60
- }
61
- let cancelled = false;
62
- Promise.all(labels.map((label) => backend
63
- .workspace(workspace)
64
- .objectPermissions()
65
- .getAccessList({ kind: "label", ref: label.ref })
66
- .then((list) => ({ label, list }))
67
- // Only a definitive 404 means the label can't take a per-label
68
- // grant. A transient failure (5xx / 403 / network) must NOT drop a
69
- // real label return it without grant info so it stays grantable.
70
- .catch((error) => ({ label, transient: !isPermissionsNotAvailable(error) })))).then((results) => {
71
- if (cancelled) {
72
- return;
73
- }
74
- const resolved = {};
75
- for (const id of committedGranteeIds) {
76
- resolved[id] = [];
77
- }
78
- const permissionable = new Set();
79
- for (const result of results) {
80
- if ("transient" in result) {
81
- // Keep transiently-failed labels permissionable (don't hide a real
82
- // label); skip definitively-not-permissionable ones (404).
83
- if (result.transient) {
84
- permissionable.add(result.label.id);
85
- }
86
- continue;
87
- }
88
- const { label, list } = result;
89
- permissionable.add(label.id);
90
- for (const id of committedGranteeIds) {
91
- // Primary label is always part of the scope; others are scoped
92
- // only when the grantee actually holds a grant on that label.
93
- if (label.isPrimary || isGranteeGrantedIn(list, id)) {
94
- resolved[id].push(label.id);
95
- }
96
- }
97
- }
55
+ }
56
+ // Probe each label's access list to learn which are permissionable (some 404) and
57
+ // which each grantee holds. `hasList` gates it: a list with no named grantees keeps
58
+ // `granteeIdsKey` empty, but the permissionable set (404 filtering) must still resolve.
59
+ const { result: labelLists } = useCancelablePromise({
60
+ promise: target && labels.length > 0 && hasList
61
+ ? () => Promise.all(labels.map((label) => backend
62
+ .workspace(workspace)
63
+ .objectPermissions()
64
+ .getAccessList({ kind: "label", ref: label.ref })
65
+ .then((list) => ({ label, list }))
66
+ // Only a definitive 404 means the label can't take a
67
+ // grant; a transient failure must NOT drop a real label.
68
+ .catch((error) => ({
69
+ label,
70
+ transient: !isPermissionsNotAvailable(error),
71
+ }))))
72
+ : undefined,
73
+ onSuccess: (results) => {
74
+ setEverResolved(true);
75
+ const resolved = resolveScopes(results, committedGranteeIds);
76
+ // Seed only grantees we don't already have a scope for an optimistic
77
+ // scope is authoritative and must survive this re-resolution. Fired once
78
+ // per resolution (onSuccess), so reading `prev` is safe.
98
79
  setSelectedLabelIdsByGrantee((prev) => {
99
- // Seed a scope only for grantees we don't already have one for. A
100
- // scope written optimistically (a fresh add, a labels edit) is
101
- // local-authoritative and must survive the re-resolution that the
102
- // grantee-set change triggers — the backend's lagging read would
103
- // otherwise reset it. Grantees gone from the list are dropped.
104
80
  const next = {};
105
81
  for (const id of committedGranteeIds) {
106
82
  next[id] = prev[id] ?? resolved[id];
107
83
  }
108
84
  return next;
109
85
  });
110
- setPermissionableLabelIds(permissionable);
111
- });
112
- return () => {
113
- cancelled = true;
114
- };
115
- // eslint-disable-next-line react-hooks/exhaustive-deps
116
- }, [backend, workspace, targetKey, labelsKey, granteeIdsKey, hasList]);
117
- // Only labels whose permissions endpoint responded are scope-controllable; until
118
- // resolution completes (permissionableLabelIds undefined) assume all are usable.
119
- // While the current target's list isn't loaded yet, ignore a permissionable set
120
- // left over from a previous object so we never mis-filter the new labels.
86
+ },
87
+ },
88
+ // Keyed on the label set's stable string hash, not the array (rebuilt every
89
+ // render — listing it would refetch each render, an infinite loop). NOT keyed
90
+ // on the grantee set: adds and removes maintain their scope optimistically,
91
+ // so a per-grantee-change re-probe would only discard its own results while
92
+ // flipping labelsResolved false and disabling controls for the round trip.
93
+ [backend, workspace, labelsKey, hasList]);
94
+ // Permissionable ids derived straight from the probe result — `undefined` (assume
95
+ // all) until it resolves, which also invalidates automatically on a dep change
96
+ // (useCancelablePromise resets to loading), so no separate reset is needed.
97
+ const permissionableLabelIds = useMemo(() => {
98
+ if (!labelLists) {
99
+ return undefined;
100
+ }
101
+ const permissionable = new Set();
102
+ for (const result of labelLists) {
103
+ // Keep transiently-failed labels permissionable; skip definitive 404s.
104
+ if ("transient" in result) {
105
+ if (result.transient) {
106
+ permissionable.add(result.label.id);
107
+ }
108
+ }
109
+ else {
110
+ permissionable.add(result.label.id);
111
+ }
112
+ }
113
+ return permissionable;
114
+ }, [labelLists]);
115
+ // Only permissionable labels are scope-controllable; assume all until resolved.
121
116
  const effectiveLabels = useMemo(() => hasList && permissionableLabelIds
122
117
  ? labels.filter((l) => permissionableLabelIds.has(l.id))
123
118
  : labels, [labels, permissionableLabelIds, hasList]);
124
- // Resolution is done when the probe has produced a permissionable set, or when
125
- // there are genuinely no labels to probe (a label-free object). Until then
126
- // callers must treat the scope as unknown (not "all selected").
127
- //
128
- // Crucially, an EMPTY `labels` list only means "resolved" when labels aren't
129
- // still loading and didn't error: while they load, the consumer hasn't passed
130
- // them yet (labels === [] with loading true), and on error they can't be known.
131
- // Treating either as resolved would let row controls reconcile against an empty
132
- // label set and silently orphan real per-label grants. So stay unresolved while
133
- // labels are pending, regardless of the current (possibly empty) list.
119
+ // A transiently-failed probe means that label's per-grantee grants are UNKNOWN
120
+ // the label stays visible (permissionable), but the scope must not count as
121
+ // resolved: edits would diff against an invented current and could orphan or
122
+ // skip that label's real grants. Same philosophy as the `labelsError` gate.
123
+ const hasTransientProbe = labelLists?.some((r) => "transient" in r && r.transient) ?? false;
124
+ // Resolved once the probe produced a permissionable set with no unknowns, or for
125
+ // a genuinely label-free object. An EMPTY `labels` list counts as resolved only
126
+ // when labels aren't still loading and didn't error otherwise row controls
127
+ // would reconcile against an empty set and silently orphan real per-label grants.
134
128
  const labelsPending = labelsError || labelsLoading;
135
- const labelsResolved = !labelsPending && (labels.length === 0 || permissionableLabelIds !== undefined);
136
- // The single per-label write path. Diffs `desired` vs `current` over the
137
- // permissionable labels and applies the grants/revokes for one principal
138
- // (a grantee, or the all-workspace-users rule). Returns false if ANY write
139
- // fails — callers surface the error and roll back, so a partial write never
140
- // looks like success (no silent .catch). Used by add, remove, general access
141
- // and the labels picker alike, so their label behavior can't drift.
142
- const reconcileLabelScope = useCallback(async (principal, desiredLabelIds, currentLabelIds) => {
143
- const writes = buildLabelMutations(principal, desiredLabelIds, currentLabelIds, effectiveLabels);
144
- if (writes.length === 0) {
145
- return true;
146
- }
147
- const svc = backend.workspace(workspace).objectPermissions();
148
- const results = await Promise.allSettled(writes.map((w) => svc.manageObjectPermissions({ kind: "label", ref: w.ref }, [w.grantee])));
149
- return results.every((r) => r.status === "fulfilled");
150
- }, [effectiveLabels, backend, workspace]);
151
- const reconcileLabelScopeMany = useCallback(async (principals, desiredLabelIds, currentLabelIds) => {
129
+ const labelsResolved = !labelsPending &&
130
+ (labels.length === 0 || (permissionableLabelIds !== undefined && !hasTransientProbe));
131
+ // First-resolution only: metadata still loading, or a probe will run and hasn't
132
+ // settled once. A metadata ERROR is not initializing (nothing more will load —
133
+ // the dialog reveals with disabled controls instead of holding placeholders).
134
+ const labelsInitializing = labelsLoading || (labels.length > 0 && !everResolved);
135
+ // The single per-label write path (see the interface doc) used by add, remove,
136
+ // general access and the labels picker alike, so their label behavior can't
137
+ // drift. Writes settle independently (allSettled), and the failed labels are
138
+ // reported by id — never collapsed into one boolean, or a partial failure would
139
+ // roll callers back past writes that actually landed.
140
+ const reconcileLabelScope = useCallback(async (principals, desiredLabelIds, currentLabelIds) => {
152
141
  const writes = buildLabelMutationsForPrincipals(principals, desiredLabelIds, currentLabelIds, effectiveLabels);
153
142
  if (writes.length === 0) {
154
143
  return { ok: true, failedLabelIds: [] };
@@ -163,9 +152,9 @@ export function useLabelScope(target, targetKey, labels, hasList, committedGrant
163
152
  return {
164
153
  effectiveLabels,
165
154
  labelsResolved,
155
+ labelsInitializing,
166
156
  selectedLabelIdsByGrantee,
167
157
  setSelectedLabelIdsByGrantee,
168
158
  reconcileLabelScope,
169
- reconcileLabelScopeMany,
170
159
  };
171
160
  }
@@ -1,27 +1,24 @@
1
1
  import type { IObjectPermissionsObject } from "@gooddata/sdk-backend-spi";
2
2
  import { type IObjectShareController, type IUseObjectShareOptions } from "./objectShareController.types.js";
3
3
  /**
4
- * Manages the share dialog state and backend I/O for a single shareable object.
4
+ * Manages the share-dialog state and backend I/O for ONE dialog session: the hook
5
+ * is mounted while the dialog is open, for a single target that must not change
6
+ * while mounted (see {@link ObjectShareDialog}). Unmounting discards every
7
+ * transient — pending rows, staged confirms, optimistic overlays — so no explicit
8
+ * reset exists or is needed.
5
9
  *
6
- * The access list is fetched once and seeded into local state, which is the
7
- * source of truth while the dialog/summary is mounted. Each access change is
8
- * written through to local state immediately and sent to the backend; on failure
9
- * the one changed entry is rolled back. There is no post-write refetch, so the
10
- * grantee list never blanks and never fights the backend's read-after-write lag.
11
- * Top-level open/close is the consumer's concern (see {@link ObjectShareDialog}).
10
+ * The access list is fetched once, and the displayed state is *derived* from that
11
+ * fetch composed with a small local edit overlay (owned by `useAccessList`). Each
12
+ * access change writes its overlay entry (so the row updates immediately), commits
13
+ * to the backend, then settles the entry on success or reverts it on failure (to
14
+ * the last committed entry, or the fetched value). There is no post-write refetch,
15
+ * so the grantee list never blanks and never fights read-after-write lag.
12
16
  *
13
17
  * Mutations follow a **commit-on-interaction** model: each access change is sent
14
18
  * immediately; the general-access toggle goes through a confirm step because it
15
- * is high-impact. There is no batched Save. The list is fetched eagerly so
16
- * `state.summary` also drives an inline access row while closed.
17
- *
18
- * Most consumers do not call this directly — render {@link ObjectShareDialog} with
19
- * plain props and it owns its controller. Call this (exported as `useObjectShare`)
20
- * only to share a single access-list fetch between the dialog and an inline summary
21
- * row: call it once, read `state.summary` for the row, and pass the controller into
22
- * the dialog. Grantee rows carry identity facts only (`name`/`email` are undefined
23
- * when unknown); the display fallback order is not exposed, so {@link ObjectShareDialog}
24
- * is the only sanctioned row renderer.
19
+ * is high-impact. There is no batched Save. Grantee rows carry identity facts only
20
+ * (`name`/`email` are undefined when unknown); the display fallback order is not
21
+ * exposed, so {@link ObjectShareDialog} is the only sanctioned row renderer.
25
22
  *
26
23
  * @internal
27
24
  */
@@ -1 +1 @@
1
- {"version":3,"file":"useObjectShareController.d.ts","sourceRoot":"","sources":["../../src/share/useObjectShareController.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAc1E,OAAO,EACH,KAAK,sBAAsB,EAI3B,KAAK,sBAAsB,EAE9B,MAAM,kCAAkC,CAAC;AAI1C;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,wBAAwB,CACpC,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,OAAO,CAAC,EAAE,sBAAsB,GACjC,sBAAsB,CAmmBxB"}
1
+ {"version":3,"file":"useObjectShareController.d.ts","sourceRoot":"","sources":["../../src/share/useObjectShareController.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAiB1E,OAAO,EACH,KAAK,sBAAsB,EAI3B,KAAK,sBAAsB,EAE9B,MAAM,kCAAkC,CAAC;AAI1C;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,wBAAwB,CACpC,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,OAAO,CAAC,EAAE,sBAAsB,GACjC,sBAAsB,CA+kBxB"}