@gooddata/sdk-ui-ext 11.52.0-alpha.6 → 11.52.0-alpha.7

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 (50) hide show
  1. package/esm/dataLoaders/InsightDataLoader.d.ts +6 -0
  2. package/esm/dataLoaders/InsightDataLoader.d.ts.map +1 -1
  3. package/esm/dataLoaders/InsightDataLoader.js +4 -1
  4. package/esm/dataLoaders/index.d.ts +13 -0
  5. package/esm/dataLoaders/index.d.ts.map +1 -1
  6. package/esm/dataLoaders/index.js +14 -0
  7. package/esm/index.d.ts +1 -1
  8. package/esm/index.d.ts.map +1 -1
  9. package/esm/index.js +1 -1
  10. package/esm/insightView/InsightRenderer.d.ts +2 -5
  11. package/esm/insightView/InsightRenderer.d.ts.map +1 -1
  12. package/esm/insightView/InsightRenderer.js +1 -2
  13. package/esm/internal/components/pluggableVisualizations/mekko/PluggableMekko.d.ts +6 -1
  14. package/esm/internal/components/pluggableVisualizations/mekko/PluggableMekko.d.ts.map +1 -1
  15. package/esm/internal/components/pluggableVisualizations/mekko/PluggableMekko.js +25 -4
  16. package/esm/internal/components/pluggableVisualizations/pivotTable/PluggablePivotTable.d.ts.map +1 -1
  17. package/esm/internal/components/pluggableVisualizations/pivotTable/PluggablePivotTable.js +0 -1
  18. package/esm/internal/translations/en-US.localization-bundle.d.ts +4 -0
  19. package/esm/internal/translations/en-US.localization-bundle.d.ts.map +1 -1
  20. package/esm/internal/translations/en-US.localization-bundle.js +4 -0
  21. package/esm/notificationsPanel/Notification/Notification.d.ts.map +1 -1
  22. package/esm/notificationsPanel/Notification/Notification.js +6 -3
  23. package/esm/notificationsPanel/NotificationFiltersDetail/NotificationFiltersDetail.d.ts +3 -3
  24. package/esm/notificationsPanel/NotificationFiltersDetail/NotificationFiltersDetail.d.ts.map +1 -1
  25. package/esm/notificationsPanel/NotificationFiltersDetail/NotificationFiltersDetail.js +4 -4
  26. package/esm/notificationsPanel/NotificationFiltersDetail/NotificationFiltersDetailDialog.d.ts +9 -3
  27. package/esm/notificationsPanel/NotificationFiltersDetail/NotificationFiltersDetailDialog.d.ts.map +1 -1
  28. package/esm/notificationsPanel/NotificationFiltersDetail/NotificationFiltersDetailDialog.js +4 -4
  29. package/esm/sdk-ui-ext.d.ts +13 -0
  30. package/esm/share/ObjectShareDialog.js +22 -6
  31. package/esm/share/messages.d.ts +7 -0
  32. package/esm/share/messages.d.ts.map +1 -1
  33. package/esm/share/messages.js +5 -0
  34. package/esm/share/objectShareController.helpers.d.ts +117 -17
  35. package/esm/share/objectShareController.helpers.d.ts.map +1 -1
  36. package/esm/share/objectShareController.helpers.js +181 -44
  37. package/esm/share/objectShareController.types.d.ts +34 -10
  38. package/esm/share/objectShareController.types.d.ts.map +1 -1
  39. package/esm/share/useAccessList.d.ts +6 -0
  40. package/esm/share/useAccessList.d.ts.map +1 -1
  41. package/esm/share/useAccessList.js +26 -6
  42. package/esm/share/useLabelScope.d.ts +46 -1
  43. package/esm/share/useLabelScope.d.ts.map +1 -1
  44. package/esm/share/useLabelScope.js +98 -6
  45. package/esm/share/useObjectShareController.d.ts.map +1 -1
  46. package/esm/share/useObjectShareController.js +213 -45
  47. package/esm/share/useObjectShareDialog.d.ts +7 -0
  48. package/esm/share/useObjectShareDialog.d.ts.map +1 -1
  49. package/esm/share/useObjectShareDialog.js +22 -2
  50. package/package.json +21 -21
@@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from "react";
3
3
  import { useToastMessage } from "@gooddata/sdk-ui-kit";
4
4
  import { composeEffectiveWorkspaceAccess } from "./accessSummary.js";
5
5
  import { objectShareMessages } from "./messages.js";
6
- import { EMPTY_IDS, NO_LABELS, granularGranteeFor, levelsAbove, levelsBelow, sortGrantees, toGranularGrantee, userDisplayPair, } from "./objectShareController.helpers.js";
6
+ import { EMPTY_IDS, NO_LABELS, changesEffectiveLevel, granularGranteeFor, levelsAbove, levelsBelow, sortGrantees, toGranularGrantee, userDisplayPair, } from "./objectShareController.helpers.js";
7
7
  import { useAccessList } from "./useAccessList.js";
8
8
  import { useLabelScope } from "./useLabelScope.js";
9
9
  /**
@@ -35,7 +35,7 @@ export function useObjectShareController(target, options) {
35
35
  const [subview, setSubview] = useState("main");
36
36
  const [pendingGrantees, setPendingGrantees] = useState([]);
37
37
  const [pendingGeneralAccess, setPendingGeneralAccess] = useState(undefined);
38
- const { hasList, grantees, seededWithoutSelfGrant, selfIdentity, selfIdentityResolved, generalAccess, workspaceLevel, workspaceInheritedLevel, workspaceLevelSaving, summary, status, loadError, commit, loadOptions, applyGranteeAdd, applyGranteeLevel, applyGranteeRemove, settleGranteeEdit, failGranteeEdit, applyRuleEdit, settleRuleEdit, failRuleEdit, } = useAccessList(target);
38
+ const { hasList, grantees, seededWithoutSelfGrant, selfIdentity, selfIdentityResolved, generalAccess, workspaceLevel, workspaceInheritedLevel, workspaceLevelSaving, summary, status, loadError, commit, loadOptions, applyGranteeAdd, applyGranteeLevel, applyGranteeLock, applyGranteeRemove, settleGranteeEdit, failGranteeEdit, applyRuleEdit, settleRuleEdit, failRuleEdit, } = useAccessList(target);
39
39
  // Grantee ids the label-scope probe resolves against — a removing row is excluded
40
40
  // so its scope is dropped, not re-seeded.
41
41
  const committedGranteeIds = useMemo(() => {
@@ -48,7 +48,16 @@ export function useObjectShareController(target, options) {
48
48
  return ids;
49
49
  }, [grantees]);
50
50
  // Per-label scope resolution + the single label-write path live in their own hook.
51
- const { effectiveLabels, labelsResolved, labelsInitializing, selectedLabelIdsByGrantee, setSelectedLabelIdsByGrantee, reconcileLabelScope, } = useLabelScope(target, labels, hasList, committedGranteeIds, labelsError, labelsLoading);
51
+ const { effectiveLabels, labelsResolved, labelsInitializing, selectedLabelIdsByGrantee, setSelectedLabelIdsByGrantee, inheritedLabelIdsByGrantee, directLabelIdsByGrantee, recordDirectLabelWrites, forgetGranteeLabels, reconcileLabelScope, reconcileLabelScopes, regradeLabelScope, } = useLabelScope(target, labels, hasList, committedGranteeIds, labelsError, labelsLoading);
52
+ // The labels THIS workspace granted — the only ones a write may touch. Read straight
53
+ // from the tracked direct set rather than subtracting "inherited" from the scope: the
54
+ // subtraction relied on the probe still describing our own grants, and after a revoke it
55
+ // did not, so a later re-grade re-created access that had just been removed. A label
56
+ // held by inheritance ALONE is absent here, so no write can reach it: revoking one is a
57
+ // no-op whose compensation could mint a real grant, and re-grading one would invent one.
58
+ // A dual-granted label IS here — its local grant is ours to revoke and re-grade, or it
59
+ // would outlive the inherited grant hiding it.
60
+ const directLabelScopeOf = useCallback((granteeId) => new Set(directLabelIdsByGrantee[granteeId] ?? effectiveLabels.map((l) => l.id)), [directLabelIdsByGrantee, effectiveLabels]);
52
61
  // Effective access: the direct state composed with inherited rule access.
53
62
  // Also pins the level to VIEW while restricted, so the (hidden) dropdown
54
63
  // never shows a stale SHARE left over from a prior grant.
@@ -74,12 +83,12 @@ export function useObjectShareController(target, options) {
74
83
  // overlay, the revert and any toast beyond `commit`'s own.
75
84
  const applyAccessChange = useCallback(async (params) => {
76
85
  const { principal, objectMutation, successMessage, labelDesired, labelCurrent } = params;
77
- const { ok: labelsOk } = await reconcileLabelScope([principal], labelDesired, labelCurrent);
86
+ const { ok: labelsOk, failedLabelIds } = await reconcileLabelScope([principal], labelDesired, labelCurrent);
78
87
  if (!labelsOk && params.abortIfLabelsFail) {
79
88
  // Don't write the object on a half-applied label scope: undo whatever
80
89
  // mirrored and surface the failure to the caller.
81
90
  await reconcileLabelScope([principal], labelCurrent, labelDesired);
82
- return { ok: false, labelsOk: false };
91
+ return { ok: false, labelsOk: false, failedLabelIds };
83
92
  }
84
93
  const ok = await commit([objectMutation], successMessage);
85
94
  if (!ok) {
@@ -87,7 +96,10 @@ export function useObjectShareController(target, options) {
87
96
  // don't drift.
88
97
  await reconcileLabelScope([principal], labelCurrent, labelDesired);
89
98
  }
90
- return { ok, labelsOk };
99
+ // The failed ids are reported, not just counted: a caller that keeps the row
100
+ // must keep those labels in its scope, or the local state would claim access
101
+ // the backend still grants and a later edit would diff against a lie.
102
+ return { ok, labelsOk, failedLabelIds };
91
103
  }, [commit, reconcileLabelScope]);
92
104
  // ── Grantee mutations (add / level / remove / labels) ────────────────────────
93
105
  const confirmAddGrantees = useCallback(async () => {
@@ -105,18 +117,30 @@ export function useObjectShareController(target, options) {
105
117
  name: g.name,
106
118
  email: g.email,
107
119
  level: g.permissionLevel,
120
+ // The add writes a grant in THIS workspace, so the new row holds a
121
+ // direct grant at the picked level — without it the row would read as
122
+ // inherited-only and its Remove would be (correctly) refused.
123
+ directLevel: g.permissionLevel,
108
124
  }));
109
125
  const mutations = pendingGrantees.map((g) => toGranularGrantee(g.kind, g.ref, g.permissionLevel));
110
- // Default each new grantee to ALL labels (the picker's Add is gated until
111
- // labels have loaded, so the set is known here). Reflect the full scope
112
- // before the writes so the row shows it immediately.
126
+ // Each grantee is granted the label scope picked in the add dialog; an
127
+ // untouched picker leaves it undefined, which means ALL labels (the picker's
128
+ // Add is gated until labels have loaded, so the set is known here). The
129
+ // primary label is always in scope, exactly as `changeGranteeLabels` keeps it.
130
+ // Reflect each scope before the writes so the rows show them immediately.
113
131
  const allLabelIds = effectiveLabels.map((l) => l.id);
114
- const allLabelIdSet = new Set(allLabelIds);
132
+ const primaryLabelIds = effectiveLabels.filter((l) => l.isPrimary).map((l) => l.id);
133
+ const scopeById = new Map(pendingGrantees.map((g) => [
134
+ g.id,
135
+ g.labelIds === undefined
136
+ ? allLabelIds
137
+ : Array.from(new Set([...g.labelIds, ...primaryLabelIds])),
138
+ ]));
115
139
  addedRows.forEach(applyGranteeAdd);
116
140
  setSelectedLabelIdsByGrantee((prev) => {
117
141
  const next = { ...prev };
118
142
  for (const id of addedIds) {
119
- next[id] = allLabelIds;
143
+ next[id] = scopeById.get(id);
120
144
  }
121
145
  return next;
122
146
  });
@@ -134,19 +158,30 @@ export function useObjectShareController(target, options) {
134
158
  });
135
159
  return;
136
160
  }
137
- // One label write per label carrying all added grantees, not one per grantee.
138
- const principals = pendingGrantees.map((g) => ({ kind: g.kind, granteeRef: g.ref }));
139
- const { ok: labelsOk, failedLabelIds } = await reconcileLabelScope(principals, allLabelIdSet, EMPTY_IDS);
161
+ // One label write per label carrying all added grantees, not one per grantee
162
+ // even when their scopes differ, so a shared label stays a single call. Each
163
+ // label grant carries that grantee's picked level, so the label mirrors the
164
+ // object grant instead of landing on VIEW.
165
+ const { ok: labelsOk, failedLabelIds } = await reconcileLabelScopes(pendingGrantees.map((g) => ({
166
+ principal: { kind: g.kind, granteeRef: g.ref, level: g.permissionLevel },
167
+ desiredLabelIds: new Set(scopeById.get(g.id)),
168
+ currentLabelIds: EMPTY_IDS,
169
+ })));
170
+ const failed = new Set(failedLabelIds);
171
+ // Every label grant that landed is now a grant of OURS — record it, or a later
172
+ // removal would not know to revoke it.
173
+ for (const id of addedIds) {
174
+ recordDirectLabelWrites(id, scopeById.get(id).filter((labelId) => !failed.has(labelId)), []);
175
+ }
140
176
  if (!labelsOk) {
141
177
  toast.addWarning(objectShareMessages.toastLabelScopePartial);
142
- // Drop only the failed labels, keeping those that landed otherwise local
143
- // scope under-reports and changeGranteeLabels later skips their revokes.
144
- const failed = new Set(failedLabelIds);
145
- const survived = allLabelIds.filter((id) => !failed.has(id));
178
+ // Drop only the failed labels from each grantee's own scope, keeping those
179
+ // that landed — otherwise local scope under-reports and changeGranteeLabels
180
+ // later skips their revokes.
146
181
  setSelectedLabelIdsByGrantee((prev) => {
147
182
  const next = { ...prev };
148
183
  for (const id of addedIds) {
149
- next[id] = survived;
184
+ next[id] = scopeById.get(id).filter((labelId) => !failed.has(labelId));
150
185
  }
151
186
  return next;
152
187
  });
@@ -161,7 +196,8 @@ export function useObjectShareController(target, options) {
161
196
  commit,
162
197
  closeAddGrantee,
163
198
  effectiveLabels,
164
- reconcileLabelScope,
199
+ reconcileLabelScopes,
200
+ recordDirectLabelWrites,
165
201
  toast,
166
202
  applyGranteeAdd,
167
203
  settleGranteeEdit,
@@ -170,23 +206,83 @@ export function useObjectShareController(target, options) {
170
206
  ]);
171
207
  const changePermissionLevel = useCallback(async (granteeId, level) => {
172
208
  const grantee = grantees.find((g) => g.id === granteeId);
173
- if (!grantee || grantee.level === level || grantee.pending) {
209
+ // A pick that cannot move the effective level is refused rather than quietly
210
+ // strengthening the grant made here — the rule lives in `changesEffectiveLevel`
211
+ // so every reaction to a pick agrees, and no confirm is staged for a no-op.
212
+ // Also refused before the label scope resolves: the re-grade below would run
213
+ // over the assumed-all fallback and mint grants the grantee never held. The
214
+ // UI gates on the same fact (isMutable); the controller stays authoritative.
215
+ if (!grantee || grantee.pending || !labelsResolved || !changesEffectiveLevel(grantee, level)) {
174
216
  return;
175
217
  }
176
218
  applyGranteeLevel(granteeId, level);
219
+ // A label grant is an independent access-list entry carrying its own
220
+ // level, so re-grading the object alone would leave the grantee's labels
221
+ // at the level they were granted at.
222
+ //
223
+ // Order so a label grant is never left above the object grant: lower the
224
+ // labels BEFORE lowering the object, and raise the object BEFORE raising
225
+ // the labels. Either way a failure leaves labels no broader than the
226
+ // object, and a failed object write puts the labels back.
227
+ const previousLevel = grantee.directLevel ?? grantee.level;
228
+ const lowering = levelsBelow(previousLevel).includes(level);
229
+ const scope = directLabelScopeOf(granteeId);
230
+ const regradeLabels = (to) => regradeLabelScope([{ kind: grantee.kind, granteeRef: grantee.granteeRef, level: to }], scope);
231
+ let labelsOk = true;
232
+ if (lowering) {
233
+ labelsOk = (await regradeLabels(level)).ok;
234
+ if (!labelsOk) {
235
+ // A label that kept the old, HIGHER level while the object dropped
236
+ // would leave label access above object access — the one state this
237
+ // ordering exists to prevent. So abort: put every in-scope label back
238
+ // and leave the object alone. Same all-or-nothing policy the
239
+ // general-access change applies to its label mirror.
240
+ await regradeLabels(previousLevel);
241
+ failGranteeEdit(granteeId);
242
+ toast.addError(objectShareMessages.toastError);
243
+ return;
244
+ }
245
+ }
177
246
  const ok = await commit([toGranularGrantee(grantee.kind, grantee.granteeRef, level)], objectShareMessages.toastAccessUpdated);
178
247
  // The settled entry persists on success (base still holds the old level);
179
248
  // failure restores the last committed entry, or the fetched row.
180
- if (ok) {
181
- settleGranteeEdit(granteeId);
182
- }
183
- else {
249
+ if (!ok) {
250
+ if (lowering) {
251
+ await regradeLabels(previousLevel);
252
+ }
184
253
  failGranteeEdit(granteeId);
254
+ return;
255
+ }
256
+ if (!lowering) {
257
+ labelsOk = (await regradeLabels(level)).ok;
258
+ }
259
+ settleGranteeEdit(granteeId);
260
+ if (!labelsOk) {
261
+ // The object level changed but some labels kept the old one — warn so
262
+ // the partial state isn't mistaken for success.
263
+ toast.addWarning(objectShareMessages.toastLabelScopePartial);
185
264
  }
186
- }, [grantees, commit, applyGranteeLevel, settleGranteeEdit, failGranteeEdit]);
265
+ }, [
266
+ grantees,
267
+ labelsResolved,
268
+ commit,
269
+ directLabelScopeOf,
270
+ regradeLabelScope,
271
+ toast,
272
+ applyGranteeLevel,
273
+ settleGranteeEdit,
274
+ failGranteeEdit,
275
+ ]);
187
276
  const removeGrantee = useCallback(async (granteeId) => {
188
277
  const grantee = grantees.find((g) => g.id === granteeId);
189
- if (!grantee || grantee.pending) {
278
+ // Only a grant made in THIS workspace can be revoked here. With access
279
+ // inherited-only (a group, or a parent workspace) there is nothing local
280
+ // to revoke: the write would be an empty-permissions no-op that the row
281
+ // then reported as removed. The menu also disables Remove in that case;
282
+ // this keeps the controller — not the UI — authoritative. Same for an
283
+ // unresolved label scope: the revoke below would run over the assumed-all
284
+ // fallback and revoke (then compensate with) grants that never existed.
285
+ if (!grantee || grantee.pending || !labelsResolved || grantee.directLevel === undefined) {
190
286
  return;
191
287
  }
192
288
  // Mark the row removed but keep it visible (muted) until the write lands.
@@ -198,12 +294,20 @@ export function useObjectShareController(target, options) {
198
294
  // the grantee didn't hold. The object revoke is what matters; a label
199
295
  // revoke that fails is non-fatal (surfaced as a warning), so don't abort
200
296
  // on it.
201
- const { ok, labelsOk } = await applyAccessChange({
202
- principal: { kind: grantee.kind, granteeRef: grantee.granteeRef },
297
+ const { ok, labelsOk, failedLabelIds } = await applyAccessChange({
298
+ // The level matters only for the compensating re-grant if the object
299
+ // revoke fails: without it the labels would come back at the default
300
+ // VIEW, quietly downgrading an EDIT/SHARE grantee we just failed to
301
+ // remove.
302
+ principal: {
303
+ kind: grantee.kind,
304
+ granteeRef: grantee.granteeRef,
305
+ level: grantee.directLevel,
306
+ },
203
307
  objectMutation: toGranularGrantee(grantee.kind, grantee.granteeRef, "none"),
204
308
  successMessage: objectShareMessages.toastAccessUpdated,
205
309
  labelDesired: EMPTY_IDS,
206
- labelCurrent: new Set(selectedLabelIdsByGrantee[granteeId] ?? effectiveLabels.map((l) => l.id)),
310
+ labelCurrent: directLabelScopeOf(granteeId),
207
311
  abortIfLabelsFail: false,
208
312
  });
209
313
  if (!ok) {
@@ -215,10 +319,37 @@ export function useObjectShareController(target, options) {
215
319
  // Object revoke landed. For a fetched row the settled `removed` entry
216
320
  // persists to keep it hidden; a revoked overlay-born row drops entirely.
217
321
  settleGranteeEdit(granteeId);
218
- setSelectedLabelIdsByGrantee((prev) => {
219
- const { [granteeId]: _omit, ...rest } = prev;
220
- return rest;
221
- });
322
+ // Every local label grant is gone except the ones whose revoke failed — record
323
+ // that, so a later write on a surviving row cannot act on grants we removed.
324
+ const revokedIds = [...directLabelScopeOf(granteeId)].filter((id) => !failedLabelIds.includes(id));
325
+ recordDirectLabelWrites(granteeId, [], revokedIds);
326
+ if (grantee.inheritedLevel === undefined) {
327
+ // Row is gone for good; drop its bookkeeping — unless a label revoke
328
+ // failed. The direct set is then the ONLY record that the label is still
329
+ // granted (the probe never re-runs), and forgetting it would let the
330
+ // grant outlive a same-session re-add: reseeded from the new writes
331
+ // alone, no later diff would ever revoke it.
332
+ if (failedLabelIds.length === 0) {
333
+ forgetGranteeLabels(granteeId);
334
+ }
335
+ }
336
+ else {
337
+ // A grantee who also inherits survives as an inherited-only row, so its
338
+ // scope must survive too — narrowed to what the removal could not take away.
339
+ // Dropping the entry would fall back to "all labels" (the probe does not
340
+ // re-run for grantee changes) and overstate what they can reach. Any label
341
+ // whose revoke FAILED stays in: the backend still grants it.
342
+ setSelectedLabelIdsByGrantee((prev) => {
343
+ const keep = new Set([
344
+ ...(inheritedLabelIdsByGrantee[granteeId] ?? []),
345
+ ...failedLabelIds,
346
+ ]);
347
+ const survivingIds = effectiveLabels
348
+ .filter((l) => l.isPrimary || keep.has(l.id))
349
+ .map((l) => l.id);
350
+ return { ...prev, [granteeId]: survivingIds };
351
+ });
352
+ }
222
353
  if (!labelsOk) {
223
354
  // Object access is gone but some per-label grants couldn't be
224
355
  // revoked — warn so the leftover scope isn't mistaken for success.
@@ -226,9 +357,13 @@ export function useObjectShareController(target, options) {
226
357
  }
227
358
  }, [
228
359
  grantees,
360
+ labelsResolved,
229
361
  applyAccessChange,
362
+ directLabelScopeOf,
363
+ recordDirectLabelWrites,
364
+ forgetGranteeLabels,
230
365
  effectiveLabels,
231
- selectedLabelIdsByGrantee,
366
+ inheritedLabelIdsByGrantee,
232
367
  toast,
233
368
  applyGranteeRemove,
234
369
  settleGranteeEdit,
@@ -237,27 +372,54 @@ export function useObjectShareController(target, options) {
237
372
  ]);
238
373
  const changeGranteeLabels = useCallback(async (granteeId, requested) => {
239
374
  const grantee = grantees.find((g) => g.id === granteeId);
240
- if (!grantee || grantee.pending || !target) {
375
+ // Refused before the label scope resolves — the diff below would otherwise
376
+ // run against the assumed-all fallback (the UI gates on the same fact via
377
+ // `isMutable`; the controller stays authoritative).
378
+ if (!grantee || grantee.pending || !labelsResolved || !target) {
241
379
  return;
242
380
  }
243
- // Primary label is always in scope; never let it be dropped.
381
+ // The primary label is always in scope, and so is a label the grantee only
382
+ // inherits: there is no local grant to revoke, so dropping it would report
383
+ // access removed that this workspace cannot reach. The checklist locks both;
384
+ // this keeps the controller — not the UI — authoritative.
244
385
  const primaryIds = effectiveLabels.filter((l) => l.isPrimary).map((l) => l.id);
245
- const nextScope = Array.from(new Set([...requested, ...primaryIds]));
386
+ const inheritedIds = inheritedLabelIdsByGrantee[granteeId] ?? [];
387
+ const nextScope = Array.from(new Set([...requested, ...primaryIds, ...inheritedIds]));
246
388
  const currentScope = selectedLabelIdsByGrantee[granteeId] ?? effectiveLabels.map((l) => l.id);
247
389
  const desired = new Set(nextScope);
248
- const current = new Set(currentScope);
390
+ // Inherited labels join the CURRENT side too: an add-path scope is seeded
391
+ // before inheritance is known for the new row, so without this an untouched
392
+ // Apply would diff an inherited label as newly checked and grant it locally.
393
+ const current = new Set([...currentScope, ...inheritedIds]);
249
394
  // Applying the checklist unchanged is a no-op — don't lock the row or
250
395
  // toast success over zero writes.
251
396
  if (desired.size === current.size && nextScope.every((id) => current.has(id))) {
252
397
  return;
253
398
  }
254
399
  // Reflect the new scope immediately and lock the row until the writes
255
- // settle — an overlapping label edit or removal would race these writes
256
- // on the same labels. The lock reuses the level overlay at the unchanged
257
- // level; pending is the only fact it adds.
258
- applyGranteeLevel(granteeId, grantee.level);
400
+ // settle — an overlapping label edit or removal would race these writes on
401
+ // the same labels. A lock-only overlay, because this changes no object
402
+ // access: re-applying the displayed level would persist the EFFECTIVE level
403
+ // as this row's direct grant, so an inherited EDIT over a direct VIEW would
404
+ // become a local EDIT after any label edit.
405
+ applyGranteeLock(granteeId);
259
406
  setSelectedLabelIdsByGrantee((prev) => ({ ...prev, [granteeId]: nextScope }));
260
- const { ok, failedLabelIds } = await reconcileLabelScope([{ kind: grantee.kind, granteeRef: grantee.granteeRef }], desired, current);
407
+ // Labels newly brought into scope are granted at the level the grantee
408
+ // holds on the object — with no grant of their own here (inherited-only
409
+ // access) there is no level to mirror, so VIEW stands.
410
+ const { ok, failedLabelIds } = await reconcileLabelScope([
411
+ {
412
+ kind: grantee.kind,
413
+ granteeRef: grantee.granteeRef,
414
+ level: grantee.directLevel ?? "VIEW",
415
+ },
416
+ ], desired, current);
417
+ // Record what the writes actually did to OUR grants: a label brought into scope
418
+ // gains one, a label dropped loses one. Only over the labels this workspace could
419
+ // write — an inherited-only label is absent from the diff either way.
420
+ const failedIds = new Set(failedLabelIds);
421
+ const writable = directLabelScopeOf(granteeId);
422
+ recordDirectLabelWrites(granteeId, nextScope.filter((id) => !current.has(id) && !failedIds.has(id)), currentScope.filter((id) => writable.has(id) && !desired.has(id) && !failedIds.has(id)));
261
423
  if (ok) {
262
424
  settleGranteeEdit(granteeId);
263
425
  toast.addSuccess(objectShareMessages.toastAccessUpdated);
@@ -281,11 +443,15 @@ export function useObjectShareController(target, options) {
281
443
  }, [
282
444
  grantees,
283
445
  target,
446
+ labelsResolved,
284
447
  effectiveLabels,
285
448
  selectedLabelIdsByGrantee,
449
+ inheritedLabelIdsByGrantee,
450
+ directLabelScopeOf,
451
+ recordDirectLabelWrites,
286
452
  reconcileLabelScope,
287
453
  toast,
288
- applyGranteeLevel,
454
+ applyGranteeLock,
289
455
  settleGranteeEdit,
290
456
  failGranteeEdit,
291
457
  setSelectedLabelIdsByGrantee,
@@ -479,6 +645,7 @@ export function useObjectShareController(target, options) {
479
645
  labelsResolved,
480
646
  labelsInitializing,
481
647
  selectedLabelIdsByGrantee,
648
+ inheritedLabelIdsByGrantee,
482
649
  pendingGeneralAccess,
483
650
  pendingGrantees,
484
651
  };
@@ -500,6 +667,7 @@ export function useObjectShareController(target, options) {
500
667
  labelsResolved,
501
668
  labelsInitializing,
502
669
  selectedLabelIdsByGrantee,
670
+ inheritedLabelIdsByGrantee,
503
671
  pendingGeneralAccess,
504
672
  pendingGrantees,
505
673
  ]);
@@ -44,6 +44,13 @@ export interface IObjectShareDialogViewModel {
44
44
  * for the signed-in user's own sole grant, undefined for every other row.
45
45
  */
46
46
  rowDisabledLevels: (grantee: IObjectShareGrantee) => ObjectSharePermissionLevel[] | undefined;
47
+ /**
48
+ * Whether Remove access must render disabled: the grantee holds no grant in this
49
+ * workspace, so there is nothing here to revoke (their access is inherited).
50
+ */
51
+ isRowRemoveDisabled: (grantee: IObjectShareGrantee) => boolean;
52
+ /** Tooltip explaining why Remove access is disabled on this row. */
53
+ rowRemoveDisabledTooltip: string;
47
54
  /** Close the whole dialog, discarding any staged self-restriction. */
48
55
  onClose: () => void;
49
56
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"useObjectShareDialog.d.ts","sourceRoot":"","sources":["../../src/share/useObjectShareDialog.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAE1E,OAAO,EACH,KAAK,6BAA6B,EAClC,KAAK,2BAA2B,EAChC,KAAK,mBAAmB,EACxB,KAAK,0BAA0B,EAClC,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAY1E;;;;;;;GAOG;AACH,MAAM,WAAW,2BAA2B;IACxC,yFAAyF;IACzF,KAAK,EAAE,2BAA2B,CAAC;IACnC,yEAAyE;IACzE,OAAO,EAAE,6BAA6B,CAAC;IAEvC,8EAA8E;IAC9E,eAAe,EAAE,OAAO,CAAC;IACzB,qDAAqD;IACrD,gBAAgB,EAAE,OAAO,CAAC;IAC1B,4EAA0E;IAC1E,0BAA0B,EAAE,OAAO,CAAC;IACpC,+EAA+E;IAC/E,yBAAyB,EAAE,OAAO,CAAC;IAEnC;;;;;OAKG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,SAAS,EAAE,OAAO,CAAC;IAEnB,kGAAkG;IAClG,uBAAuB,EAAE,0BAA0B,EAAE,GAAG,SAAS,CAAC;IAClE;;;OAGG;IACH,iBAAiB,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,0BAA0B,EAAE,GAAG,SAAS,CAAC;IAE9F,sEAAsE;IACtE,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB;;;OAGG;IACH,qBAAqB,EAAE,CAAC,OAAO,EAAE,mBAAmB,EAAE,KAAK,EAAE,0BAA0B,KAAK,IAAI,CAAC;IACjG,qGAAqG;IACrG,WAAW,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAC;IACpD,qFAAqF;IACrF,oBAAoB,EAAE,MAAM,IAAI,CAAC;IACjC,+FAA6F;IAC7F,qBAAqB,EAAE,MAAM,IAAI,CAAC;CACrC;AAED;;;;GAIG;AACH,UAAU,2BAA2B;IACjC,wFAAsF;IACtF,MAAM,EAAE,wBAAwB,GAAG,SAAS,CAAC;IAC7C,oGAAoG;IACpG,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,mFAAmF;IACnF,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAC1D,2FAA2F;IAC3F,MAAM,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC7B,oGAAoG;IACpG,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iGAAiG;IACjG,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,EACjC,MAAM,EACN,OAAO,EACP,eAAe,EACf,MAAM,EACN,aAAa,EACb,WAAW,EACd,EAAE,2BAA2B,GAAG,2BAA2B,CAwF3D"}
1
+ {"version":3,"file":"useObjectShareDialog.d.ts","sourceRoot":"","sources":["../../src/share/useObjectShareDialog.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAI1E,OAAO,EACH,KAAK,6BAA6B,EAClC,KAAK,2BAA2B,EAChC,KAAK,mBAAmB,EACxB,KAAK,0BAA0B,EAClC,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAY1E;;;;;;;GAOG;AACH,MAAM,WAAW,2BAA2B;IACxC,yFAAyF;IACzF,KAAK,EAAE,2BAA2B,CAAC;IACnC,yEAAyE;IACzE,OAAO,EAAE,6BAA6B,CAAC;IAEvC,8EAA8E;IAC9E,eAAe,EAAE,OAAO,CAAC;IACzB,qDAAqD;IACrD,gBAAgB,EAAE,OAAO,CAAC;IAC1B,4EAA0E;IAC1E,0BAA0B,EAAE,OAAO,CAAC;IACpC,+EAA+E;IAC/E,yBAAyB,EAAE,OAAO,CAAC;IAEnC;;;;;OAKG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,SAAS,EAAE,OAAO,CAAC;IAEnB,kGAAkG;IAClG,uBAAuB,EAAE,0BAA0B,EAAE,GAAG,SAAS,CAAC;IAClE;;;OAGG;IACH,iBAAiB,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,0BAA0B,EAAE,GAAG,SAAS,CAAC;IAC9F;;;OAGG;IACH,mBAAmB,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,OAAO,CAAC;IAC/D,oEAAoE;IACpE,wBAAwB,EAAE,MAAM,CAAC;IAEjC,sEAAsE;IACtE,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB;;;OAGG;IACH,qBAAqB,EAAE,CAAC,OAAO,EAAE,mBAAmB,EAAE,KAAK,EAAE,0BAA0B,KAAK,IAAI,CAAC;IACjG,qGAAqG;IACrG,WAAW,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAC;IACpD,qFAAqF;IACrF,oBAAoB,EAAE,MAAM,IAAI,CAAC;IACjC,+FAA6F;IAC7F,qBAAqB,EAAE,MAAM,IAAI,CAAC;CACrC;AAED;;;;GAIG;AACH,UAAU,2BAA2B;IACjC,wFAAsF;IACtF,MAAM,EAAE,wBAAwB,GAAG,SAAS,CAAC;IAC7C,oGAAoG;IACpG,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,mFAAmF;IACnF,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAC1D,2FAA2F;IAC3F,MAAM,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC7B,oGAAoG;IACpG,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iGAAiG;IACjG,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,EACjC,MAAM,EACN,OAAO,EACP,eAAe,EACf,MAAM,EACN,aAAa,EACb,WAAW,EACd,EAAE,2BAA2B,GAAG,2BAA2B,CA6G3D"}
@@ -1,5 +1,8 @@
1
1
  // (C) 2026 GoodData Corporation
2
2
  import { useCallback, useEffect, useState } from "react";
3
+ import { useIntl } from "react-intl";
4
+ import { objectShareMessages } from "./messages.js";
5
+ import { changesEffectiveLevel, removalChangesEffectiveLevel } from "./objectShareController.helpers.js";
3
6
  import { useObjectShareController } from "./useObjectShareController.js";
4
7
  /**
5
8
  * Owns everything {@link ObjectShareDialog} needs to render for one dialog session:
@@ -10,6 +13,7 @@ import { useObjectShareController } from "./useObjectShareController.js";
10
13
  * @internal
11
14
  */
12
15
  export function useObjectShareDialog({ target, onClose, onSummaryChange, labels, labelsLoading, labelsError, }) {
16
+ const intl = useIntl();
13
17
  const { state, actions } = useObjectShareController(target, { labels, labelsError, labelsLoading });
14
18
  const [pendingSelfChange, setPendingSelfChange] = useState(undefined);
15
19
  // Synchronize the displayed access summary out to the consumer — the legitimate
@@ -31,7 +35,12 @@ export function useObjectShareDialog({ target, onClose, onSummaryChange, labels,
31
35
  // classified it as self-managed — those changes route through the confirm.
32
36
  const onRowPermissionChange = useCallback((grantee, level) => {
33
37
  if (state.selfManagedGranteeId === grantee.id) {
34
- if (level !== grantee.level) {
38
+ // Stage the confirm only for a pick that would really move the level the
39
+ // user effectively holds. Comparing against the displayed level instead
40
+ // opened "Restrict your access?" for a pick under an inherited floor, which
41
+ // the controller then refuses — the user would confirm a restriction that
42
+ // never happens. Same predicate the controller applies.
43
+ if (changesEffectiveLevel(grantee, level)) {
35
44
  setPendingSelfChange({ granteeId: grantee.id, level });
36
45
  }
37
46
  return;
@@ -39,7 +48,11 @@ export function useObjectShareDialog({ target, onClose, onSummaryChange, labels,
39
48
  void actions.changePermissionLevel(grantee.id, level);
40
49
  }, [state.selfManagedGranteeId, actions]);
41
50
  const onRowRemove = useCallback((grantee) => {
42
- if (state.selfManagedGranteeId === grantee.id) {
51
+ // Stage the confirm only when the removal can lower the level the user
52
+ // effectively holds — under an inherited floor the revoke removes the local
53
+ // grant but restricts nothing, so the "Restrict your access?" warning would
54
+ // promise a restriction that never happens. Same policy as the picks above.
55
+ if (state.selfManagedGranteeId === grantee.id && removalChangesEffectiveLevel(grantee)) {
43
56
  setPendingSelfChange({ granteeId: grantee.id, level: "none" });
44
57
  return;
45
58
  }
@@ -60,6 +73,11 @@ export function useObjectShareDialog({ target, onClose, onSummaryChange, labels,
60
73
  }, [pendingSelfChange, actions]);
61
74
  const onSelfRestrictCancel = useCallback(() => setPendingSelfChange(undefined), []);
62
75
  const rowDisabledLevels = useCallback((grantee) => state.selfManagedGranteeId === grantee.id ? state.selfManagedDisabledLevels : undefined, [state.selfManagedGranteeId, state.selfManagedDisabledLevels]);
76
+ // Levels below an inherited one are deliberately NOT disabled: lowering the
77
+ // direct grant still reduces the effective level whenever it sits above what is
78
+ // inherited (direct EDIT under an inherited SHARE → picking VIEW yields SHARE).
79
+ // The row's warning badge is what explains the inherited floor.
80
+ const isRowRemoveDisabled = useCallback((grantee) => grantee.directLevel === undefined, []);
63
81
  const isMutable = state.status === "success" && state.labelsResolved;
64
82
  const isLoading = state.status === "loading" || state.labelsInitializing;
65
83
  return {
@@ -73,6 +91,8 @@ export function useObjectShareDialog({ target, onClose, onSummaryChange, labels,
73
91
  isLoading,
74
92
  workspaceDisabledLevels: state.workspaceDisabledLevels,
75
93
  rowDisabledLevels,
94
+ isRowRemoveDisabled,
95
+ rowRemoveDisabledTooltip: intl.formatMessage(objectShareMessages.granteeRemoveInherited),
76
96
  onClose,
77
97
  onRowPermissionChange,
78
98
  onRowRemove,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gooddata/sdk-ui-ext",
3
- "version": "11.52.0-alpha.6",
3
+ "version": "11.52.0-alpha.7",
4
4
  "description": "GoodData.UI SDK - Extensions",
5
5
  "license": "LicenseRef-LICENSE",
6
6
  "author": "GoodData Corporation",
@@ -63,20 +63,20 @@
63
63
  "ts-invariant": "0.10.3",
64
64
  "tslib": "2.8.1",
65
65
  "uuid": "11.1.1",
66
- "@gooddata/sdk-backend-base": "11.52.0-alpha.6",
67
- "@gooddata/sdk-backend-spi": "11.52.0-alpha.6",
68
- "@gooddata/sdk-embedding": "11.52.0-alpha.6",
69
- "@gooddata/sdk-model": "11.52.0-alpha.6",
70
- "@gooddata/sdk-ui": "11.52.0-alpha.6",
71
- "@gooddata/sdk-ui-charts": "11.52.0-alpha.6",
72
- "@gooddata/sdk-ui-geo": "11.52.0-alpha.6",
73
- "@gooddata/sdk-ui-filters": "11.52.0-alpha.6",
74
- "@gooddata/sdk-ui-semantic-search": "11.52.0-alpha.6",
75
- "@gooddata/sdk-ui-kit": "11.52.0-alpha.6",
76
- "@gooddata/sdk-ui-theme-provider": "11.52.0-alpha.6",
77
- "@gooddata/sdk-ui-vis-commons": "11.52.0-alpha.6",
78
- "@gooddata/sdk-ui-pivot": "11.52.0-alpha.6",
79
- "@gooddata/util": "11.52.0-alpha.6"
66
+ "@gooddata/sdk-backend-base": "11.52.0-alpha.7",
67
+ "@gooddata/sdk-backend-spi": "11.52.0-alpha.7",
68
+ "@gooddata/sdk-embedding": "11.52.0-alpha.7",
69
+ "@gooddata/sdk-model": "11.52.0-alpha.7",
70
+ "@gooddata/sdk-ui": "11.52.0-alpha.7",
71
+ "@gooddata/sdk-ui-charts": "11.52.0-alpha.7",
72
+ "@gooddata/sdk-ui-geo": "11.52.0-alpha.7",
73
+ "@gooddata/sdk-ui-filters": "11.52.0-alpha.7",
74
+ "@gooddata/sdk-ui-kit": "11.52.0-alpha.7",
75
+ "@gooddata/sdk-ui-semantic-search": "11.52.0-alpha.7",
76
+ "@gooddata/sdk-ui-pivot": "11.52.0-alpha.7",
77
+ "@gooddata/sdk-ui-vis-commons": "11.52.0-alpha.7",
78
+ "@gooddata/util": "11.52.0-alpha.7",
79
+ "@gooddata/sdk-ui-theme-provider": "11.52.0-alpha.7"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@microsoft/api-documenter": "^7.17.0",
@@ -124,12 +124,12 @@
124
124
  "typescript": "5.9.3",
125
125
  "vitest": "4.1.8",
126
126
  "vitest-dom": "0.1.1",
127
- "@gooddata/eslint-config": "11.52.0-alpha.6",
128
- "@gooddata/i18n-toolkit": "11.52.0-alpha.6",
129
- "@gooddata/oxlint-config": "11.52.0-alpha.6",
130
- "@gooddata/reference-workspace": "11.52.0-alpha.6",
131
- "@gooddata/sdk-backend-mockingbird": "11.52.0-alpha.6",
132
- "@gooddata/stylelint-config": "11.52.0-alpha.6"
127
+ "@gooddata/eslint-config": "11.52.0-alpha.7",
128
+ "@gooddata/oxlint-config": "11.52.0-alpha.7",
129
+ "@gooddata/i18n-toolkit": "11.52.0-alpha.7",
130
+ "@gooddata/reference-workspace": "11.52.0-alpha.7",
131
+ "@gooddata/sdk-backend-mockingbird": "11.52.0-alpha.7",
132
+ "@gooddata/stylelint-config": "11.52.0-alpha.7"
133
133
  },
134
134
  "peerDependencies": {
135
135
  "react": "^18.0.0 || ^19.0.0",