@gooddata/sdk-ui-ext 11.43.0-alpha.1 → 11.43.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 (41) hide show
  1. package/NOTICE +3 -3
  2. package/esm/index.d.ts +4 -0
  3. package/esm/index.d.ts.map +1 -1
  4. package/esm/index.js +2 -0
  5. package/esm/internal/components/configurationControls/customTooltip/CustomTooltipSection.d.ts.map +1 -1
  6. package/esm/internal/components/configurationControls/customTooltip/CustomTooltipSection.js +3 -2
  7. package/esm/internal/translations/en-US.localization-bundle.d.ts +44 -0
  8. package/esm/internal/translations/en-US.localization-bundle.d.ts.map +1 -1
  9. package/esm/internal/translations/en-US.localization-bundle.js +44 -0
  10. package/esm/sdk-ui-ext.d.ts +274 -0
  11. package/esm/share/ObjectShareDialog.d.ts +67 -0
  12. package/esm/share/ObjectShareDialog.d.ts.map +1 -0
  13. package/esm/share/ObjectShareDialog.js +102 -0
  14. package/esm/share/accessSummary.d.ts +20 -0
  15. package/esm/share/accessSummary.d.ts.map +1 -0
  16. package/esm/share/accessSummary.js +26 -0
  17. package/esm/share/messages.d.ts +44 -0
  18. package/esm/share/messages.d.ts.map +1 -0
  19. package/esm/share/messages.js +23 -0
  20. package/esm/share/objectShareController.helpers.d.ts +80 -0
  21. package/esm/share/objectShareController.helpers.d.ts.map +1 -0
  22. package/esm/share/objectShareController.helpers.js +152 -0
  23. package/esm/share/objectShareController.types.d.ts +110 -0
  24. package/esm/share/objectShareController.types.d.ts.map +1 -0
  25. package/esm/share/objectShareController.types.js +2 -0
  26. package/esm/share/types.d.ts +47 -0
  27. package/esm/share/types.d.ts.map +1 -0
  28. package/esm/share/types.js +2 -0
  29. package/esm/share/useAccessList.d.ts +51 -0
  30. package/esm/share/useAccessList.d.ts.map +1 -0
  31. package/esm/share/useAccessList.js +263 -0
  32. package/esm/share/useLabelScope.d.ts +54 -0
  33. package/esm/share/useLabelScope.d.ts.map +1 -0
  34. package/esm/share/useLabelScope.js +160 -0
  35. package/esm/share/useObjectShare.d.ts +50 -0
  36. package/esm/share/useObjectShare.d.ts.map +1 -0
  37. package/esm/share/useObjectShare.js +17 -0
  38. package/esm/share/useObjectShareController.d.ts +24 -0
  39. package/esm/share/useObjectShareController.d.ts.map +1 -0
  40. package/esm/share/useObjectShareController.js +347 -0
  41. package/package.json +21 -21
@@ -0,0 +1,51 @@
1
+ import type { IObjectPermissionsObject } from "@gooddata/sdk-backend-spi";
2
+ import { type IGranularAccessGrantee, type IObjectAccessList, type ObjRef } from "@gooddata/sdk-model";
3
+ import { type GeneralAccessValue, type IUiGranteeAsyncOptions } from "@gooddata/sdk-ui-kit";
4
+ import { type IGranteeOverlayEntry } from "./objectShareController.helpers.js";
5
+ import type { IObjectShareControllerState, IObjectShareGrantee } from "./objectShareController.types.js";
6
+ import type { IObjectAccessSummary } from "./types.js";
7
+ /**
8
+ * The owned access list + optimistic overlay for {@link useObjectShareController}.
9
+ *
10
+ * @internal
11
+ */
12
+ export interface IAccessList {
13
+ /** Stable serialized key of the current target's ref, or undefined when none. */
14
+ targetKey: string | undefined;
15
+ /** The owned list as it applies to the *current* target (undefined while a switch is pending). */
16
+ currentAccessList: IObjectAccessList | undefined;
17
+ /** Committed grantee rows overlaid with optimistic intent (add/level/remove). */
18
+ grantees: IObjectShareGrantee[];
19
+ /** Sorted, comma-joined committed grantee ids — a stable key for resolution effects. */
20
+ granteeIdsKey: string;
21
+ /** Optimistic-aware general access (workspace vs restricted). */
22
+ generalAccess: GeneralAccessValue;
23
+ /** Inline access summary (optimistic-aware), or undefined before the first load. */
24
+ summary: IObjectAccessSummary | undefined;
25
+ /** Top-level load status surfaced as the controller status. */
26
+ status: IObjectShareControllerState["status"];
27
+ /** Error from the initial/target-change load. */
28
+ loadError: Error | undefined;
29
+ /** Commit a grant change, toast on success, then background-refresh. False on failure. */
30
+ commit: (mutate: IGranularAccessGrantee[], successMessage: {
31
+ id: string;
32
+ }) => Promise<boolean>;
33
+ /** Picker loader — available assignees minus already-granted, filtered by query. */
34
+ loadOptions: (search: string) => Promise<IUiGranteeAsyncOptions>;
35
+ /** Resolve a grantee id back to the picker's original ObjRef (preserves Uri vs Id ref). */
36
+ refForId: (id: string) => ObjRef;
37
+ setOverlay: React.Dispatch<React.SetStateAction<Record<string, IGranteeOverlayEntry>>>;
38
+ setKnownNames: React.Dispatch<React.SetStateAction<Record<string, string>>>;
39
+ setOptimisticGeneralAccess: React.Dispatch<React.SetStateAction<GeneralAccessValue | undefined>>;
40
+ }
41
+ /**
42
+ * Owns the backend access list and its optimistic overlay. The list is fetched
43
+ * via `useCancelablePromise` then owned locally: mutations patch the overlay and
44
+ * a background refresh reconciles it, so the grantee list never blanks between a
45
+ * change and the server response. A target switch is handled by stamping the
46
+ * fetched list with its target and reading through `currentAccessList`.
47
+ *
48
+ * @internal
49
+ */
50
+ export declare function useAccessList(target: IObjectPermissionsObject | undefined, onSaved: (() => void) | undefined): IAccessList;
51
+ //# sourceMappingURL=useAccessList.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useAccessList.d.ts","sourceRoot":"","sources":["../../src/share/useAccessList.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,OAAO,EACH,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,EACtB,KAAK,MAAM,EAEd,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,KAAK,kBAAkB,EAAE,KAAK,sBAAsB,EAAmB,MAAM,sBAAsB,CAAC;AAI7G,OAAO,EACH,KAAK,oBAAoB,EAM5B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EAAE,2BAA2B,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AACzG,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEvD;;;;GAIG;AACH,MAAM,WAAW,WAAW;IACxB,iFAAiF;IACjF,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,kGAAkG;IAClG,iBAAiB,EAAE,iBAAiB,GAAG,SAAS,CAAC;IACjD,iFAAiF;IACjF,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,0FAAwF;IACxF,aAAa,EAAE,MAAM,CAAC;IACtB,iEAAiE;IACjE,aAAa,EAAE,kBAAkB,CAAC;IAClC,oFAAoF;IACpF,OAAO,EAAE,oBAAoB,GAAG,SAAS,CAAC;IAC1C,+DAA+D;IAC/D,MAAM,EAAE,2BAA2B,CAAC,QAAQ,CAAC,CAAC;IAC9C,iDAAiD;IACjD,SAAS,EAAE,KAAK,GAAG,SAAS,CAAC;IAE7B,0FAA0F;IAC1F,MAAM,EAAE,CAAC,MAAM,EAAE,sBAAsB,EAAE,EAAE,cAAc,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/F,sFAAoF;IACpF,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACjE,2FAA2F;IAC3F,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;IAEjC,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC,CAAC,CAAC;IACvF,aAAa,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAC5E,0BAA0B,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC,CAAC;CACpG;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CACzB,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,OAAO,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,GAClC,WAAW,CAsRb"}
@@ -0,0 +1,263 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { objRefToString, } from "@gooddata/sdk-model";
4
+ import { useBackendStrict, useCancelablePromise, useWorkspaceStrict } from "@gooddata/sdk-ui";
5
+ import { useToastMessage } from "@gooddata/sdk-ui-kit";
6
+ import { deriveGeneralAccess, deriveWorkspacePermissionLevel } from "./accessSummary.js";
7
+ import { objectShareMessages } from "./messages.js";
8
+ import { assigneeMatchesQuery, granteeId, granteesFromAccessList, mergeOverlay, reconcileOverlay, } from "./objectShareController.helpers.js";
9
+ /**
10
+ * Owns the backend access list and its optimistic overlay. The list is fetched
11
+ * via `useCancelablePromise` then owned locally: mutations patch the overlay and
12
+ * a background refresh reconciles it, so the grantee list never blanks between a
13
+ * change and the server response. A target switch is handled by stamping the
14
+ * fetched list with its target and reading through `currentAccessList`.
15
+ *
16
+ * @internal
17
+ */
18
+ export function useAccessList(target, onSaved) {
19
+ const backend = useBackendStrict();
20
+ const workspace = useWorkspaceStrict();
21
+ const toast = useToastMessage();
22
+ const [accessList, setAccessList] = useState(undefined);
23
+ const [accessListTarget, setAccessListTarget] = useState(undefined);
24
+ const [loadStatus, setLoadStatus] = useState(target ? "loading" : "idle");
25
+ const [loadError, setLoadError] = useState(undefined);
26
+ // Optimistic overlay keyed by grantee id. Each entry holds the *intended* row
27
+ // (add/level-change) or a removal, plus a pending flag while the write is in
28
+ // flight. The backend has read-after-write lag, so we show the intended state
29
+ // and clear an entry only once a refresh confirms it (or the write fails).
30
+ const [overlay, setOverlay] = useState({});
31
+ // Display names learned from the picker; keep a human name on a row after the
32
+ // optimistic overlay reconciles away (the grant often returns only a raw id).
33
+ const [knownNames, setKnownNames] = useState({});
34
+ // Original ObjRef per grantee id, learned from the picker (the access-list id
35
+ // is a serialized `kind:identifier`, which loses Uri-vs-Id). Reused for writes.
36
+ const [knownRefs, setKnownRefs] = useState({});
37
+ // Optimistic general-access override — held from confirm until the fetched list
38
+ // reflects it, so the radio + summary update instantly.
39
+ const [optimisticGeneralAccess, setOptimisticGeneralAccess] = useState(undefined);
40
+ const targetKey = target ? objRefToString(target.ref) : undefined;
41
+ // The owned list as it applies to the *current* target. A target switch makes
42
+ // the previous fetch's list stale until the new one lands; gating on the stamp
43
+ // means everything derived ignores it immediately — no reset effect.
44
+ const currentAccessList = accessListTarget === targetKey ? accessList : undefined;
45
+ // Always-current target key, read inside async callbacks that captured an older
46
+ // one. A background refresh started for the previous target can resolve after a
47
+ // switch; without this it would stamp the list back to the stale target, leaving
48
+ // the new target's `currentAccessList` undefined (and, with the load-gated
49
+ // status, stuck "loading" since its own fetch already settled).
50
+ const targetKeyRef = useRef(targetKey);
51
+ targetKeyRef.current = targetKey;
52
+ // Initial (and target-change) load. Feeds setAccessList once; thereafter we own it.
53
+ const { result: fetchedList, status: fetchStatus, error: fetchError, } = useCancelablePromise({
54
+ promise: target
55
+ ? () => backend.workspace(workspace).objectPermissions().getAccessList(target)
56
+ : undefined,
57
+ },
58
+ // Key on the serialized ref, not the ObjRef object — an inline idRef(...) is a
59
+ // new instance each render and would otherwise refetch forever.
60
+ [backend, workspace, target?.kind, targetKey]);
61
+ useEffect(() => {
62
+ if (fetchStatus === "success" && fetchedList) {
63
+ // The cancelable promise is keyed on targetKey, so this list is for it.
64
+ setAccessList(fetchedList);
65
+ setAccessListTarget(targetKey);
66
+ setLoadStatus("idle");
67
+ setLoadError(undefined);
68
+ setOverlay({}); // a target-change load is authoritative; drop stale overlay
69
+ setOptimisticGeneralAccess(undefined);
70
+ }
71
+ else if (fetchStatus === "error") {
72
+ setLoadStatus("error");
73
+ setLoadError(fetchError instanceof Error ? fetchError : new Error(String(fetchError)));
74
+ }
75
+ else if (fetchStatus === "loading") {
76
+ setLoadStatus("loading");
77
+ }
78
+ }, [fetchStatus, fetchedList, fetchError, targetKey]);
79
+ // Background refresh — pulls server truth without nulling the current list,
80
+ // then reconciles the overlay (clears entries the server now confirms).
81
+ const refresh = useCallback(async () => {
82
+ if (!target) {
83
+ return;
84
+ }
85
+ try {
86
+ const fresh = await backend.workspace(workspace).objectPermissions().getAccessList(target);
87
+ // The target may have changed while this refresh was in flight. Stamping
88
+ // a stale target's list now would clobber the new target's stamp and
89
+ // strand it loading, so drop the result if we've since switched away.
90
+ if (targetKeyRef.current !== targetKey) {
91
+ return;
92
+ }
93
+ setAccessList(fresh);
94
+ setAccessListTarget(targetKey);
95
+ setOverlay((prev) => reconcileOverlay(granteesFromAccessList(fresh), prev));
96
+ // Drop the optimistic general-access override once the server confirms it.
97
+ const freshGeneralAccess = deriveGeneralAccess(fresh.grants);
98
+ setOptimisticGeneralAccess((prev) => (prev === freshGeneralAccess ? undefined : prev));
99
+ }
100
+ catch {
101
+ // The refresh failed, but the mutation that triggered it already
102
+ // succeeded. The overlay can't be reconciled against fresh data now, so
103
+ // settle it optimistically: keep each entry's intended state (a `set`
104
+ // value stays, a `remove` keeps hiding the row) but clear its `pending`
105
+ // flag. Otherwise rows would spin on "saving"/"removing" forever even
106
+ // though the change persisted and a success toast was shown.
107
+ setOverlay((prev) => {
108
+ let changed = false;
109
+ const next = {};
110
+ for (const [id, entry] of Object.entries(prev)) {
111
+ if (entry.pending) {
112
+ changed = true;
113
+ next[id] = { ...entry, pending: false };
114
+ }
115
+ else {
116
+ next[id] = entry;
117
+ }
118
+ }
119
+ return changed ? next : prev;
120
+ });
121
+ }
122
+ }, [backend, workspace, target, targetKey]);
123
+ // Committed rows overlaid with optimistic intent; backfill a human name from
124
+ // the picker cache where the grant only carried a raw id.
125
+ const grantees = useMemo(() => {
126
+ if (!currentAccessList) {
127
+ return [];
128
+ }
129
+ const committed = granteesFromAccessList(currentAccessList).map((g) => {
130
+ const known = knownNames[g.id];
131
+ return known && g.name === objRefToString(g.granteeRef) ? { ...g, name: known } : g;
132
+ });
133
+ return mergeOverlay(committed, overlay);
134
+ }, [currentAccessList, overlay, knownNames]);
135
+ const granteeIdsKey = granteesFromAccessList(currentAccessList)
136
+ .map((g) => g.id)
137
+ .sort()
138
+ .join(",");
139
+ const committedGeneralAccess = useMemo(() => (currentAccessList ? deriveGeneralAccess(currentAccessList.grants) : "RESTRICTED"), [currentAccessList]);
140
+ const generalAccess = optimisticGeneralAccess ?? committedGeneralAccess;
141
+ const summary = useMemo(() => {
142
+ if (!currentAccessList) {
143
+ return undefined;
144
+ }
145
+ return {
146
+ generalAccess,
147
+ // While the optimistic override is active we just wrote the workspace
148
+ // rule as VIEW, so report VIEW rather than the stale fetched grant
149
+ // (which can still read SHARE through read-after-write lag).
150
+ workspaceLevel: optimisticGeneralAccess
151
+ ? "VIEW"
152
+ : deriveWorkspacePermissionLevel(currentAccessList.grants),
153
+ granteeCount: grantees.length,
154
+ };
155
+ }, [currentAccessList, generalAccess, optimisticGeneralAccess, grantees]);
156
+ // "success" only once the *current* target's list has actually landed. After a
157
+ // target switch the stamp clears `currentAccessList` immediately, but
158
+ // `loadStatus` still reads "idle" from the previous target's completed load
159
+ // until the effect re-runs — so gating on loadStatus alone would briefly report
160
+ // "success" with no list, letting the dialog enable mutations and the catalog
161
+ // row hide both summary and skeleton. Treat "no list for this target yet" as
162
+ // loading.
163
+ const status = target
164
+ ? loadStatus === "error"
165
+ ? "error"
166
+ : loadStatus === "loading" || !currentAccessList
167
+ ? "loading"
168
+ : "success"
169
+ : "idle";
170
+ // Commit a single grant change against the backend, then reconcile via refresh.
171
+ // Optimistic state is applied by the caller; on failure the caller's rollback
172
+ // runs and a toast is shown.
173
+ const commit = useCallback(async (mutate, successMessage) => {
174
+ if (!target) {
175
+ return false;
176
+ }
177
+ try {
178
+ await backend
179
+ .workspace(workspace)
180
+ .objectPermissions()
181
+ .manageObjectPermissions(target, mutate);
182
+ toast.addSuccess(successMessage);
183
+ onSaved?.();
184
+ await refresh();
185
+ return true;
186
+ }
187
+ catch {
188
+ toast.addError(objectShareMessages.toastError);
189
+ return false;
190
+ }
191
+ }, [backend, workspace, target, toast, onSaved, refresh]);
192
+ // Picker loader — fetches available assignees on demand, filters by the typed
193
+ // query, excludes anything already granted. Depends only on the grant sources
194
+ // (accessList, overlay), so its identity changes only when the granted set
195
+ // actually changes. It must NOT write state that feeds its own deps.
196
+ const loadOptions = useCallback(async (search) => {
197
+ if (!target) {
198
+ return { groups: [], users: [] };
199
+ }
200
+ const assignees = await backend
201
+ .workspace(workspace)
202
+ .objectPermissions()
203
+ .getAvailableAssignees(target);
204
+ const query = search.trim().toLowerCase();
205
+ const withIds = assignees.map((a) => ({
206
+ assignee: a,
207
+ id: granteeId(a.type === "user" ? "user" : "group", a.ref),
208
+ }));
209
+ // Remember every assignee's real name + ref so granted rows can show them
210
+ // even when the access-list grant later returns only a raw id. Safe here:
211
+ // neither is a dependency of loadOptions, so this won't re-trigger it.
212
+ setKnownNames((prev) => {
213
+ const next = { ...prev };
214
+ for (const { assignee, id } of withIds) {
215
+ next[id] = assignee.name;
216
+ }
217
+ return next;
218
+ });
219
+ setKnownRefs((prev) => {
220
+ const next = { ...prev };
221
+ for (const { assignee, id } of withIds) {
222
+ next[id] = assignee.ref;
223
+ }
224
+ return next;
225
+ });
226
+ const excluded = new Set(mergeOverlay(granteesFromAccessList(currentAccessList), overlay).map((g) => g.id));
227
+ const selectable = withIds
228
+ .filter(({ id }) => !excluded.has(id)) // hide anyone already granted
229
+ .filter(({ assignee }) => assigneeMatchesQuery(assignee, query));
230
+ return {
231
+ users: selectable
232
+ .filter(({ assignee }) => assignee.type === "user")
233
+ .map(({ assignee, id }) => ({
234
+ id,
235
+ kind: "user",
236
+ name: assignee.name,
237
+ email: assignee.type === "user" ? assignee.email : undefined,
238
+ })),
239
+ groups: selectable
240
+ .filter(({ assignee }) => assignee.type !== "user")
241
+ .map(({ assignee, id }) => ({ id, kind: "group", name: assignee.name })),
242
+ };
243
+ }, [backend, workspace, target, currentAccessList, overlay]);
244
+ // Reuse the picker's original ref (preserves UriRef vs IdentifierRef);
245
+ // fall back to the serialized id only if it wasn't cached.
246
+ const refForId = useCallback((id) => knownRefs[id] ?? { identifier: id.split(":", 2)[1] }, [knownRefs]);
247
+ return {
248
+ targetKey,
249
+ currentAccessList,
250
+ grantees,
251
+ granteeIdsKey,
252
+ generalAccess,
253
+ summary,
254
+ status,
255
+ loadError,
256
+ commit,
257
+ loadOptions,
258
+ refForId,
259
+ setOverlay,
260
+ setKnownNames,
261
+ setOptimisticGeneralAccess,
262
+ };
263
+ }
@@ -0,0 +1,54 @@
1
+ import { type MutableRefObject } from "react";
2
+ import { type IObjectPermissionsObject } from "@gooddata/sdk-backend-spi";
3
+ import type { IObjectAccessList } from "@gooddata/sdk-model";
4
+ import { type LabelScopePrincipal } from "./objectShareController.helpers.js";
5
+ import type { IObjectShareLabel } from "./types.js";
6
+ /**
7
+ * Per-label access scope for the share dialog: which labels each grantee can
8
+ * reach, which labels are independently permissionable, and the single write
9
+ * path that grants/revokes label access.
10
+ *
11
+ * @internal
12
+ */
13
+ export interface ILabelScope {
14
+ /**
15
+ * Labels that can actually take a per-label grant (a display form whose
16
+ * permissions endpoint responded). Until resolution completes, this is the
17
+ * full `labels` list.
18
+ */
19
+ effectiveLabels: IObjectShareLabel[];
20
+ /**
21
+ * Whether per-label resolution has finished (the permissionable set + each
22
+ * grantee's scope are known). False while labels are still loading or the probe
23
+ * is in flight, and false when label metadata failed to load (scope is then
24
+ * unknowable, so acting on it would silently orphan real label grants). True
25
+ * only for a genuinely label-free object. Callers gate every access-changing
26
+ * control (label edits, Add, remove, general access) on this so they don't act
27
+ * on the "assume all labels" placeholder.
28
+ */
29
+ labelsResolved: boolean;
30
+ /** Per-grantee label scope: grantee id → label ids in scope (primary always in). */
31
+ selectedLabelIdsByGrantee: Record<string, string[]>;
32
+ setSelectedLabelIdsByGrantee: React.Dispatch<React.SetStateAction<Record<string, string[]>>>;
33
+ /**
34
+ * Grantee ids whose scope was just written optimistically; the resolution
35
+ * effect keeps these instead of overwriting them through read-after-write lag.
36
+ */
37
+ optimisticScopeRef: MutableRefObject<Set<string>>;
38
+ /**
39
+ * The single per-label write path. Diffs `desired` vs `current` over the
40
+ * permissionable labels and grants/revokes for one principal. Returns false
41
+ * if any write fails (callers surface the error and roll back).
42
+ */
43
+ reconcileLabelScope: (principal: LabelScopePrincipal, desiredLabelIds: ReadonlySet<string>, currentLabelIds: ReadonlySet<string>) => Promise<boolean>;
44
+ }
45
+ /**
46
+ * Owns label-scope resolution + writes for {@link useObjectShareController}.
47
+ * Resolves each grantee's scope by fetching every label's access list, tracks
48
+ * which labels are permissionable, and exposes one reconcile primitive shared by
49
+ * add / remove / general-access / the labels picker so their behavior can't drift.
50
+ *
51
+ * @internal
52
+ */
53
+ export declare function useLabelScope(target: IObjectPermissionsObject | undefined, targetKey: string | undefined, labels: IObjectShareLabel[], currentAccessList: IObjectAccessList | undefined, granteeIdsKey: string, labelsError: boolean, labelsLoading: boolean): ILabelScope;
54
+ //# sourceMappingURL=useLabelScope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLabelScope.d.ts","sourceRoot":"","sources":["../../src/share/useLabelScope.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,gBAAgB,EAAqD,MAAM,OAAO,CAAC;AAEjG,OAAO,EAAE,KAAK,wBAAwB,EAA6B,MAAM,2BAA2B,CAAC;AACrG,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAG7D,OAAO,EACH,KAAK,mBAAmB,EAI3B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAWpD;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IACxB;;;;OAIG;IACH,eAAe,EAAE,iBAAiB,EAAE,CAAC;IACrC;;;;;;;;OAQG;IACH,cAAc,EAAE,OAAO,CAAC;IACxB,sFAAoF;IACpF,yBAAyB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACpD,4BAA4B,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC;IAC7F;;;OAGG;IACH,kBAAkB,EAAE,gBAAgB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;IAClD;;;;OAIG;IACH,mBAAmB,EAAE,CACjB,SAAS,EAAE,mBAAmB,EAC9B,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,EACpC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,KACnC,OAAO,CAAC,OAAO,CAAC,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CACzB,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,iBAAiB,EAAE,EAC3B,iBAAiB,EAAE,iBAAiB,GAAG,SAAS,EAChD,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,OAAO,EACpB,aAAa,EAAE,OAAO,GACvB,WAAW,CAoKb"}
@@ -0,0 +1,160 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { isUnexpectedResponseError } from "@gooddata/sdk-backend-spi";
4
+ import { useBackendStrict, useWorkspaceStrict } from "@gooddata/sdk-ui";
5
+ import { buildLabelMutations, granteesFromAccessList, isGranteeGrantedIn, } from "./objectShareController.helpers.js";
6
+ /**
7
+ * Whether a label's access-list error means the label is genuinely not
8
+ * independently permissionable (a definitive 404 / 501), as opposed to a
9
+ * transient failure (5xx / 403 / network) that should NOT drop a real label.
10
+ */
11
+ function isNotPermissionable(error) {
12
+ return isUnexpectedResponseError(error) && (error.httpStatus === 404 || error.httpStatus === 501);
13
+ }
14
+ /**
15
+ * Owns label-scope resolution + writes for {@link useObjectShareController}.
16
+ * Resolves each grantee's scope by fetching every label's access list, tracks
17
+ * which labels are permissionable, and exposes one reconcile primitive shared by
18
+ * add / remove / general-access / the labels picker so their behavior can't drift.
19
+ *
20
+ * @internal
21
+ */
22
+ export function useLabelScope(target, targetKey, labels, currentAccessList, granteeIdsKey, labelsError, labelsLoading) {
23
+ const backend = useBackendStrict();
24
+ const workspace = useWorkspaceStrict();
25
+ const [selectedLabelIdsByGrantee, setSelectedLabelIdsByGrantee] = useState({});
26
+ const optimisticScopeRef = useRef(new Set());
27
+ // Label ids whose permissions endpoint responded — not every display form is
28
+ // independently permissionable (some 404). `undefined` means "not resolved
29
+ // yet" (assume all).
30
+ const [permissionableLabelIds, setPermissionableLabelIds] = useState(undefined);
31
+ const labelsKey = labels.map((l) => l.id).join(",");
32
+ // A target switch OR a label-set change invalidates the previous probe's
33
+ // permissionable set; until the resolution effect re-derives it,
34
+ // `effectiveLabels` must fall back to all labels rather than filter against
35
+ // stale ids. Resetting on `labelsKey` too matters when the label set changes
36
+ // under the same target (e.g. labels finish loading): otherwise the old
37
+ // permissionable set would briefly mark scope resolved and filter the new
38
+ // labels against stale ids, so add/share could skip expected per-label grants.
39
+ useEffect(() => {
40
+ setPermissionableLabelIds(undefined);
41
+ }, [targetKey, labelsKey]);
42
+ // Resolve each grantee's label scope: fetch every label's access list once and
43
+ // record, per grantee, which labels they appear in (primary label always counts).
44
+ // Keyed on the committed grantee ids + labels so it re-resolves after add/remove.
45
+ // `hasAccessList` is a dep too: a list that loads with no named grantees keeps
46
+ // `granteeIdsKey` empty, so without it the effect would never run and the
47
+ // permissionable set (404 filtering) would never resolve.
48
+ const hasAccessList = currentAccessList !== undefined;
49
+ useEffect(() => {
50
+ if (!target || labels.length === 0 || !currentAccessList) {
51
+ return;
52
+ }
53
+ let cancelled = false;
54
+ const committedGranteeIds = granteesFromAccessList(currentAccessList).map((g) => g.id);
55
+ Promise.all(labels.map((label) => backend
56
+ .workspace(workspace)
57
+ .objectPermissions()
58
+ .getAccessList({ kind: "label", ref: label.ref })
59
+ .then((list) => ({ label, list }))
60
+ // Only a definitive 404/501 means the label can't take a per-label
61
+ // grant. A transient failure (5xx / 403 / network) must NOT drop a
62
+ // real label — return it without grant info so it stays grantable.
63
+ .catch((error) => ({ label, transient: !isNotPermissionable(error) })))).then((results) => {
64
+ if (cancelled) {
65
+ return;
66
+ }
67
+ const resolved = {};
68
+ for (const id of committedGranteeIds) {
69
+ resolved[id] = [];
70
+ }
71
+ const permissionable = new Set();
72
+ for (const result of results) {
73
+ if ("transient" in result) {
74
+ // Keep transiently-failed labels permissionable (don't hide a real
75
+ // label); skip definitively-not-permissionable ones (404/501).
76
+ if (result.transient) {
77
+ permissionable.add(result.label.id);
78
+ }
79
+ continue;
80
+ }
81
+ const { label, list } = result;
82
+ permissionable.add(label.id);
83
+ for (const id of committedGranteeIds) {
84
+ // Primary label is always part of the scope; others are scoped
85
+ // only when the grantee actually holds a grant on that label.
86
+ if (label.isPrimary || isGranteeGrantedIn(list, id)) {
87
+ resolved[id].push(label.id);
88
+ }
89
+ }
90
+ }
91
+ setSelectedLabelIdsByGrantee((prev) => {
92
+ // Keep a just-written optimistic scope through read-after-write lag;
93
+ // take the freshly-resolved scope for everyone else.
94
+ const next = { ...resolved };
95
+ for (const id of optimisticScopeRef.current) {
96
+ if (prev[id]) {
97
+ next[id] = prev[id];
98
+ }
99
+ }
100
+ return next;
101
+ });
102
+ setPermissionableLabelIds(permissionable);
103
+ });
104
+ return () => {
105
+ cancelled = true;
106
+ };
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [backend, workspace, targetKey, labelsKey, granteeIdsKey, hasAccessList]);
109
+ // A label-set change OR a target switch re-resolves scopes; drop the optimistic-
110
+ // scope guard so the freshly-resolved scopes aren't held stale. Keying on
111
+ // labelsKey alone leaked across objects that happen to share a labelsKey (e.g.
112
+ // during navigation, before the new object's labels load): the prior object's
113
+ // optimistic entries would then merge over the new object's resolved scopes and
114
+ // show/save the wrong label grants. targetKey makes the guard per-object.
115
+ useEffect(() => {
116
+ optimisticScopeRef.current = new Set();
117
+ }, [labelsKey, targetKey]);
118
+ // Only labels whose permissions endpoint responded are scope-controllable; until
119
+ // resolution completes (permissionableLabelIds undefined) assume all are usable.
120
+ // While the current target's list isn't loaded yet, ignore a permissionable set
121
+ // left over from a previous object so we never mis-filter the new labels.
122
+ const effectiveLabels = useMemo(() => currentAccessList && permissionableLabelIds
123
+ ? labels.filter((l) => permissionableLabelIds.has(l.id))
124
+ : labels, [labels, permissionableLabelIds, currentAccessList]);
125
+ // Resolution is done when the probe has produced a permissionable set, or when
126
+ // there are genuinely no labels to probe (a label-free object). Until then
127
+ // callers must treat the scope as unknown (not "all selected").
128
+ //
129
+ // Crucially, an EMPTY `labels` list only means "resolved" when labels aren't
130
+ // still loading and didn't error: while they load, the consumer hasn't passed
131
+ // them yet (labels === [] with loading true), and on error they can't be known.
132
+ // Treating either as resolved would let row controls reconcile against an empty
133
+ // label set and silently orphan real per-label grants. So stay unresolved while
134
+ // labels are pending, regardless of the current (possibly empty) list.
135
+ const labelsPending = labelsError || labelsLoading;
136
+ const labelsResolved = !labelsPending && (labels.length === 0 || permissionableLabelIds !== undefined);
137
+ // The single per-label write path. Diffs `desired` vs `current` over the
138
+ // permissionable labels and applies the grants/revokes for one principal
139
+ // (a grantee, or the all-workspace-users rule). Returns false if ANY write
140
+ // fails — callers surface the error and roll back, so a partial write never
141
+ // looks like success (no silent .catch). Used by add, remove, general access
142
+ // and the labels picker alike, so their label behavior can't drift.
143
+ const reconcileLabelScope = useCallback(async (principal, desiredLabelIds, currentLabelIds) => {
144
+ const writes = buildLabelMutations(principal, desiredLabelIds, currentLabelIds, effectiveLabels);
145
+ if (writes.length === 0) {
146
+ return true;
147
+ }
148
+ const svc = backend.workspace(workspace).objectPermissions();
149
+ const results = await Promise.allSettled(writes.map((w) => svc.manageObjectPermissions({ kind: "label", ref: w.ref }, [w.grantee])));
150
+ return results.every((r) => r.status === "fulfilled");
151
+ }, [effectiveLabels, backend, workspace]);
152
+ return {
153
+ effectiveLabels,
154
+ labelsResolved,
155
+ selectedLabelIdsByGrantee,
156
+ setSelectedLabelIdsByGrantee,
157
+ optimisticScopeRef,
158
+ reconcileLabelScope,
159
+ };
160
+ }
@@ -0,0 +1,50 @@
1
+ import type { IObjectPermissionsObject } from "@gooddata/sdk-backend-spi";
2
+ import type { IObjectShareController } from "./objectShareController.types.js";
3
+ import type { IObjectShareLabel } from "./types.js";
4
+ /**
5
+ * Options for {@link useObjectShare}.
6
+ *
7
+ * @internal
8
+ */
9
+ export interface IUseObjectShareOptions {
10
+ /**
11
+ * Fires after each successful access mutation (add grantee, change level,
12
+ * remove, general access toggle). Use it to keep UI outside the dialog in
13
+ * sync with edits made inside it (e.g. refresh an inline access row).
14
+ */
15
+ onSaved?: () => void;
16
+ /**
17
+ * Labels (display forms) of the shared attribute, enabling the per-grantee
18
+ * label-scope picker. Omit for objects without labels (e.g. facts).
19
+ */
20
+ labels?: IObjectShareLabel[];
21
+ /**
22
+ * Whether loading the object's labels failed. While true the controller stays
23
+ * label-unresolved so every access-changing control is disabled: with the
24
+ * label set unknown, reconciling access would diff against an empty set and
25
+ * silently orphan any real per-label grants. Distinct from an object that
26
+ * genuinely has no labels (omit `labels`), where editing is safe.
27
+ */
28
+ labelsError?: boolean;
29
+ /**
30
+ * Whether the object's labels are still loading. While true the controller stays
31
+ * label-unresolved (same gating as `labelsError`): the labels aren't passed yet,
32
+ * so an empty list must not be mistaken for a label-free object — otherwise row
33
+ * controls would reconcile against an empty set and orphan real per-label grants.
34
+ */
35
+ labelsLoading?: boolean;
36
+ }
37
+ /**
38
+ * Connected controller hook backing {@link ObjectShareDialog}. Most consumers
39
+ * do not need this — render `ObjectShareDialog` with plain props and it owns
40
+ * its controller. Use this hook directly only to share a single access-list
41
+ * fetch between the dialog and an inline summary row: call it once, read
42
+ * `state.summary` for the row, and pass the controller into the dialog.
43
+ *
44
+ * The hook eagerly fetches the access list on mount so `state.summary` is
45
+ * usable while the dialog is closed.
46
+ *
47
+ * @internal
48
+ */
49
+ export declare function useObjectShare(target: IObjectPermissionsObject | undefined, options?: IUseObjectShareOptions): IObjectShareController;
50
+ //# sourceMappingURL=useObjectShare.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useObjectShare.d.ts","sourceRoot":"","sources":["../../src/share/useObjectShare.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAE1E,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAC/E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAGpD;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;;OAGG;IACH,MAAM,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC7B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAC1B,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,OAAO,CAAC,EAAE,sBAAsB,GACjC,sBAAsB,CAQxB"}
@@ -0,0 +1,17 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { useObjectShareController } from "./useObjectShareController.js";
3
+ /**
4
+ * Connected controller hook backing {@link ObjectShareDialog}. Most consumers
5
+ * do not need this — render `ObjectShareDialog` with plain props and it owns
6
+ * its controller. Use this hook directly only to share a single access-list
7
+ * fetch between the dialog and an inline summary row: call it once, read
8
+ * `state.summary` for the row, and pass the controller into the dialog.
9
+ *
10
+ * The hook eagerly fetches the access list on mount so `state.summary` is
11
+ * usable while the dialog is closed.
12
+ *
13
+ * @internal
14
+ */
15
+ export function useObjectShare(target, options) {
16
+ return useObjectShareController(target, options?.onSaved, options?.labels, options?.labelsError, options?.labelsLoading);
17
+ }
@@ -0,0 +1,24 @@
1
+ import type { IObjectPermissionsObject } from "@gooddata/sdk-backend-spi";
2
+ import { type IObjectShareController } from "./objectShareController.types.js";
3
+ import type { IObjectShareLabel } from "./types.js";
4
+ /**
5
+ * Manages the share dialog state and backend I/O for a single shareable
6
+ * object.
7
+ *
8
+ * The access list is fetched (and re-fetched after every save) via
9
+ * `useCancelablePromise`; everything that reflects the backend — the grantee
10
+ * rows, general-access value, summary — is derived from it on render, never
11
+ * copied into local state. Local state is only the dialog's own transient UI:
12
+ * the active subview, the add-grantee buffer, the pending general-access
13
+ * confirm, and an in-flight save flag. Top-level open/close is the consumer's
14
+ * concern (see {@link ObjectShareDialog}).
15
+ *
16
+ * Mutations follow a **commit-on-interaction** model: each access change is
17
+ * sent immediately; the general-access toggle goes through a confirm step
18
+ * because it is high-impact. There is no batched Save. The list is fetched
19
+ * eagerly so `state.summary` also drives an inline access row while closed.
20
+ *
21
+ * @internal
22
+ */
23
+ export declare function useObjectShareController(target: IObjectPermissionsObject | undefined, onSaved?: () => void, labels?: IObjectShareLabel[], labelsError?: boolean, labelsLoading?: boolean): IObjectShareController;
24
+ //# sourceMappingURL=useObjectShareController.d.ts.map
@@ -0,0 +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;AAW1E,OAAO,EACH,KAAK,sBAAsB,EAK9B,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAIpD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,wBAAwB,CACpC,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,OAAO,CAAC,EAAE,MAAM,IAAI,EACpB,MAAM,GAAE,iBAAiB,EAAc,EACvC,WAAW,UAAQ,EACnB,aAAa,UAAQ,GACtB,sBAAsB,CAyZxB"}