@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,84 +1,59 @@
1
1
  // (C) 2026 GoodData Corporation
2
- import { useCallback, useMemo, useRef, useState } from "react";
2
+ 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, effectivePermissionAbove, granularGranteeFor, toGranularGrantee, } from "./objectShareController.helpers.js";
6
+ import { EMPTY_IDS, NO_LABELS, 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
  /**
10
- * Manages the share dialog state and backend I/O for a single shareable object.
10
+ * Manages the share-dialog state and backend I/O for ONE dialog session: the hook
11
+ * is mounted while the dialog is open, for a single target that must not change
12
+ * while mounted (see {@link ObjectShareDialog}). Unmounting discards every
13
+ * transient — pending rows, staged confirms, optimistic overlays — so no explicit
14
+ * reset exists or is needed.
11
15
  *
12
- * The access list is fetched once and seeded into local state, which is the
13
- * source of truth while the dialog/summary is mounted. Each access change is
14
- * written through to local state immediately and sent to the backend; on failure
15
- * the one changed entry is rolled back. There is no post-write refetch, so the
16
- * grantee list never blanks and never fights the backend's read-after-write lag.
17
- * Top-level open/close is the consumer's concern (see {@link ObjectShareDialog}).
16
+ * The access list is fetched once, and the displayed state is *derived* from that
17
+ * fetch composed with a small local edit overlay (owned by `useAccessList`). Each
18
+ * access change writes its overlay entry (so the row updates immediately), commits
19
+ * to the backend, then settles the entry on success or reverts it on failure (to
20
+ * the last committed entry, or the fetched value). There is no post-write refetch,
21
+ * so the grantee list never blanks and never fights read-after-write lag.
18
22
  *
19
23
  * Mutations follow a **commit-on-interaction** model: each access change is sent
20
24
  * immediately; the general-access toggle goes through a confirm step because it
21
- * is high-impact. There is no batched Save. The list is fetched eagerly so
22
- * `state.summary` also drives an inline access row while closed.
23
- *
24
- * Most consumers do not call this directly — render {@link ObjectShareDialog} with
25
- * plain props and it owns its controller. Call this (exported as `useObjectShare`)
26
- * only to share a single access-list fetch between the dialog and an inline summary
27
- * row: call it once, read `state.summary` for the row, and pass the controller into
28
- * the dialog. Grantee rows carry identity facts only (`name`/`email` are undefined
29
- * when unknown); the display fallback order is not exposed, so {@link ObjectShareDialog}
30
- * is the only sanctioned row renderer.
25
+ * is high-impact. There is no batched Save. Grantee rows carry identity facts only
26
+ * (`name`/`email` are undefined when unknown); the display fallback order is not
27
+ * exposed, so {@link ObjectShareDialog} is the only sanctioned row renderer.
31
28
  *
32
29
  * @internal
33
30
  */
34
31
  export function useObjectShareController(target, options) {
35
- const { onSaved, labels = NO_LABELS, labelsError = false, labelsLoading = false, isOpen = false, } = options ?? {};
32
+ const { labels = NO_LABELS, labelsError = false, labelsLoading = false } = options ?? {};
36
33
  const toast = useToastMessage();
37
34
  // UI-local buffers — never backend data.
38
35
  const [subview, setSubview] = useState("main");
39
36
  const [pendingGrantees, setPendingGrantees] = useState([]);
40
37
  const [pendingGeneralAccess, setPendingGeneralAccess] = useState(undefined);
41
- // True while a workspace-level re-grade is committing. The workspace rule has no
42
- // grantee row to carry a `pending` flag (unlike named grantees), so this gates
43
- // re-entry and disables the dropdown to keep rapid VIEW↔SHARE toggles from issuing
44
- // overlapping writes that could settle out of order.
45
- const [workspaceLevelSaving, setWorkspaceLevelSaving] = useState(false);
46
- // The backend access list, seeded into local state (rows, general access,
47
- // summary, the commit/picker primitives) lives in its own hook.
48
- const { targetKey, hasList, grantees, seededWithoutGrants, selfIdentity, selfIdentityResolved, generalAccess, workspaceLevel, inheritedWorkspaceLevel, summary, status, loadError, accessUnavailable, commit, loadOptions, refForId, setGrantees, setGeneralAccess, setWorkspaceLevel, } = useAccessList(target, onSaved, isOpen);
49
- // Always-current target key, read inside async mutation finalizers that captured
50
- // an older one. A write started for object A resolves after the user navigated to
51
- // object B; because local state is authoritative (no refetch), applying A's
52
- // finalizer to B's seeded list would corrupt B (e.g. clobber a same-id row, or
53
- // revert B's general access). Each finalizer captures the target it started for
54
- // and bails if it no longer matches.
55
- const targetKeyRef = useRef(targetKey);
56
- targetKeyRef.current = targetKey;
57
- // The committed grantee ids the label-scope probe resolves against (a removing
58
- // row is excluded so its scope is dropped, not re-seeded).
59
- const committedGranteeIds = useMemo(() => grantees.filter((g) => g.pending !== "removing").map((g) => g.id), [grantees]);
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);
39
+ // Grantee ids the label-scope probe resolves against a removing row is excluded
40
+ // so its scope is dropped, not re-seeded.
41
+ const committedGranteeIds = useMemo(() => {
42
+ const ids = [];
43
+ for (const g of grantees) {
44
+ if (g.pending !== "removing") {
45
+ ids.push(g.id);
46
+ }
47
+ }
48
+ return ids;
49
+ }, [grantees]);
60
50
  // Per-label scope resolution + the single label-write path live in their own hook.
61
- const { effectiveLabels, labelsResolved, selectedLabelIdsByGrantee, setSelectedLabelIdsByGrantee, reconcileLabelScope, reconcileLabelScopeMany, } = useLabelScope(target, targetKey, labels, hasList, committedGranteeIds, labelsError, labelsLoading);
62
- // Drop the transient UI buffers when the permission target changes. The detail
63
- // view is reused across objects and closes the dialog by toggling `isOpen`
64
- // alone, so without this a staged add-grantee subview or general-access confirm
65
- // would survive navigation and reappear on (and apply to) the next object.
66
- // Reset during render — React's "adjust state on prop change" idiom — rather
67
- // than in an effect, so the stale buffers never reach a commit.
68
- const [lastTargetKey, setLastTargetKey] = useState(targetKey);
69
- if (lastTargetKey !== targetKey) {
70
- setLastTargetKey(targetKey);
71
- setSubview("main");
72
- setPendingGrantees([]);
73
- setPendingGeneralAccess(undefined);
74
- setWorkspaceLevelSaving(false);
75
- }
76
- const reset = useCallback(() => {
77
- setSubview("main");
78
- setPendingGrantees([]);
79
- setPendingGeneralAccess(undefined);
80
- setWorkspaceLevelSaving(false);
81
- }, []);
51
+ const { effectiveLabels, labelsResolved, labelsInitializing, selectedLabelIdsByGrantee, setSelectedLabelIdsByGrantee, reconcileLabelScope, } = useLabelScope(target, labels, hasList, committedGranteeIds, labelsError, labelsLoading);
52
+ // Effective access: the direct state composed with inherited rule access.
53
+ // Also pins the level to VIEW while restricted, so the (hidden) dropdown
54
+ // never shows a stale SHARE left over from a prior grant.
55
+ const effectiveWorkspace = useMemo(() => composeEffectiveWorkspaceAccess(generalAccess, workspaceLevel, workspaceInheritedLevel), [generalAccess, workspaceLevel, workspaceInheritedLevel]);
56
+ // ── Add-grantee subview ──────────────────────────────────────────────────────
82
57
  const openAddGrantee = useCallback(() => {
83
58
  setSubview("addGrantee");
84
59
  setPendingGrantees([]);
@@ -87,6 +62,7 @@ export function useObjectShareController(target, options) {
87
62
  setSubview("main");
88
63
  setPendingGrantees([]);
89
64
  }, []);
65
+ // ── Shared write path (object grant + label-scope mirror) ────────────────────
90
66
  // The single object+labels write path shared by remove and general access (the
91
67
  // two changes that must keep the object grant and its label mirror consistent).
92
68
  // Mirrors the labels first, then writes the object; if the object write fails it
@@ -95,47 +71,48 @@ export function useObjectShareController(target, options) {
95
71
  // are one logical change), remove proceeds (the object revoke is what matters; a
96
72
  // leftover label grant is only surfaced as a warning). Returns the object-write
97
73
  // result and whether the label mirror fully applied; the caller owns the local
98
- // write-through, the revert and any toast beyond `commit`'s own.
74
+ // overlay, the revert and any toast beyond `commit`'s own.
99
75
  const applyAccessChange = useCallback(async (params) => {
100
76
  const { principal, objectMutation, successMessage, labelDesired, labelCurrent } = params;
101
- const labelsOk = await reconcileLabelScope(principal, labelDesired, labelCurrent);
77
+ const { ok: labelsOk } = await reconcileLabelScope([principal], labelDesired, labelCurrent);
102
78
  if (!labelsOk && params.abortIfLabelsFail) {
103
79
  // Don't write the object on a half-applied label scope: undo whatever
104
80
  // mirrored and surface the failure to the caller.
105
- await reconcileLabelScope(principal, labelCurrent, labelDesired);
81
+ await reconcileLabelScope([principal], labelCurrent, labelDesired);
106
82
  return { ok: false, labelsOk: false };
107
83
  }
108
84
  const ok = await commit([objectMutation], successMessage);
109
85
  if (!ok) {
110
86
  // Object write failed — undo the label mirror so labels and object
111
87
  // don't drift.
112
- await reconcileLabelScope(principal, labelCurrent, labelDesired);
88
+ await reconcileLabelScope([principal], labelCurrent, labelDesired);
113
89
  }
114
90
  return { ok, labelsOk };
115
91
  }, [commit, reconcileLabelScope]);
92
+ // ── Grantee mutations (add / level / remove / labels) ────────────────────────
116
93
  const confirmAddGrantees = useCallback(async () => {
117
94
  if (pendingGrantees.length === 0) {
118
95
  return;
119
96
  }
120
- const startedFor = targetKey;
121
- // Insert the picked grantees as pending rows. Identity facts are not copied
122
- // from the picker option (its name/email are display fallbacks, not facts)
123
- // rows render from the facts cache the picker fetch already populated.
97
+ // Insert the picked grantees as pending overlay rows, carrying the picker
98
+ // option's ref (exact backend ref) and display name/email straight through
99
+ // there is no post-write refetch, so the row shows what the picker showed.
124
100
  const addedIds = pendingGrantees.map((g) => g.id);
125
101
  const addedRows = pendingGrantees.map((g) => ({
126
102
  id: g.id,
127
103
  kind: g.kind,
128
- granteeRef: refForId(g.id),
104
+ granteeRef: g.ref,
105
+ name: g.name,
106
+ email: g.email,
129
107
  level: g.permissionLevel,
130
- pending: "saving",
131
108
  }));
132
- const mutations = pendingGrantees.map((g) => toGranularGrantee(g.kind, refForId(g.id), g.permissionLevel));
109
+ const mutations = pendingGrantees.map((g) => toGranularGrantee(g.kind, g.ref, g.permissionLevel));
133
110
  // Default each new grantee to ALL labels (the picker's Add is gated until
134
111
  // labels have loaded, so the set is known here). Reflect the full scope
135
112
  // before the writes so the row shows it immediately.
136
113
  const allLabelIds = effectiveLabels.map((l) => l.id);
137
114
  const allLabelIdSet = new Set(allLabelIds);
138
- setGrantees((prev) => [...prev.filter((g) => !addedIds.includes(g.id)), ...addedRows]);
115
+ addedRows.forEach(applyGranteeAdd);
139
116
  setSelectedLabelIdsByGrantee((prev) => {
140
117
  const next = { ...prev };
141
118
  for (const id of addedIds) {
@@ -143,18 +120,11 @@ export function useObjectShareController(target, options) {
143
120
  }
144
121
  return next;
145
122
  });
146
- // Leave the add subview at once so the new rows show in the main list.
147
123
  closeAddGrantee();
148
124
  const ok = await commit(mutations, objectShareMessages.toastGranteeAdded);
149
- // Bail if the target switched mid-add: the new object's list was already
150
- // seeded, so rolling back / clearing markers / mirroring labels by these ids
151
- // would hit the wrong object.
152
- if (targetKeyRef.current !== startedFor) {
153
- return;
154
- }
155
125
  if (!ok) {
156
126
  // Roll back the failed adds.
157
- setGrantees((prev) => prev.filter((g) => !addedIds.includes(g.id)));
127
+ addedIds.forEach((id) => failGranteeEdit(id));
158
128
  setSelectedLabelIdsByGrantee((prev) => {
159
129
  const next = { ...prev };
160
130
  for (const id of addedIds) {
@@ -164,14 +134,9 @@ export function useObjectShareController(target, options) {
164
134
  });
165
135
  return;
166
136
  }
167
- // Object grant landed — clear the saving marker on the new rows.
168
- setGrantees((prev) => prev.map((g) => (addedIds.includes(g.id) ? { ...g, pending: undefined } : g)));
169
137
  // One label write per label carrying all added grantees, not one per grantee.
170
- const principals = pendingGrantees.map((g) => ({ kind: g.kind, granteeRef: refForId(g.id) }));
171
- const { ok: labelsOk, failedLabelIds } = await reconcileLabelScopeMany(principals, allLabelIdSet, EMPTY_IDS);
172
- if (targetKeyRef.current !== startedFor) {
173
- return;
174
- }
138
+ const principals = pendingGrantees.map((g) => ({ kind: g.kind, granteeRef: g.ref }));
139
+ const { ok: labelsOk, failedLabelIds } = await reconcileLabelScope(principals, allLabelIdSet, EMPTY_IDS);
175
140
  if (!labelsOk) {
176
141
  toast.addWarning(objectShareMessages.toastLabelScopePartial);
177
142
  // Drop only the failed labels, keeping those that landed — otherwise local
@@ -186,16 +151,21 @@ export function useObjectShareController(target, options) {
186
151
  return next;
187
152
  });
188
153
  }
154
+ // Settle only now — the add is one logical operation (object grant + label
155
+ // grants), and an unlocked row mid-way would let a remove or label edit race
156
+ // the in-flight label grants. The `added` entries persist (base never had
157
+ // these rows); only the saving marker clears.
158
+ addedIds.forEach((id) => settleGranteeEdit(id));
189
159
  }, [
190
160
  pendingGrantees,
191
- targetKey,
192
161
  commit,
193
162
  closeAddGrantee,
194
163
  effectiveLabels,
195
- reconcileLabelScopeMany,
196
- refForId,
164
+ reconcileLabelScope,
197
165
  toast,
198
- setGrantees,
166
+ applyGranteeAdd,
167
+ settleGranteeEdit,
168
+ failGranteeEdit,
199
169
  setSelectedLabelIdsByGrantee,
200
170
  ]);
201
171
  const changePermissionLevel = useCallback(async (granteeId, level) => {
@@ -203,67 +173,48 @@ export function useObjectShareController(target, options) {
203
173
  if (!grantee || grantee.level === level || grantee.pending) {
204
174
  return;
205
175
  }
206
- const startedFor = targetKey;
207
- const previousLevel = grantee.level;
208
- // Recompute the inherited-SHARE badge for the new direct level: raising a
209
- // VIEW grant to SHARE makes the inherited SHARE no longer "above" the
210
- // direct grant (badge hidden); lowering it back surfaces the warning again.
211
- const nextEffective = effectivePermissionAbove(level, grantee.inheritsShare ? ["SHARE"] : []);
212
- // Show the new level immediately (marked saving), then write it through.
213
- setGrantees((prev) => prev.map((g) => g.id === granteeId
214
- ? { ...g, level, effectivePermission: nextEffective, pending: "saving" }
215
- : g));
176
+ applyGranteeLevel(granteeId, level);
216
177
  const ok = await commit([toGranularGrantee(grantee.kind, grantee.granteeRef, level)], objectShareMessages.toastAccessUpdated);
217
- // The target may have changed while the write was in flight; applying this
218
- // finalizer to the newly seeded list would corrupt the other object's row.
219
- if (targetKeyRef.current !== startedFor) {
220
- return;
178
+ // The settled entry persists on success (base still holds the old level);
179
+ // failure restores the last committed entry, or the fetched row.
180
+ if (ok) {
181
+ settleGranteeEdit(granteeId);
221
182
  }
222
- setGrantees((prev) => prev.map((g) => g.id === granteeId
223
- ? {
224
- ...g,
225
- level: ok ? level : previousLevel,
226
- effectivePermission: ok
227
- ? nextEffective
228
- : effectivePermissionAbove(previousLevel, g.inheritsShare ? ["SHARE"] : []),
229
- pending: undefined,
230
- }
231
- : g));
232
- }, [grantees, targetKey, commit, setGrantees]);
183
+ else {
184
+ failGranteeEdit(granteeId);
185
+ }
186
+ }, [grantees, commit, applyGranteeLevel, settleGranteeEdit, failGranteeEdit]);
233
187
  const removeGrantee = useCallback(async (granteeId) => {
234
188
  const grantee = grantees.find((g) => g.id === granteeId);
235
189
  if (!grantee || grantee.pending) {
236
190
  return;
237
191
  }
238
- const startedFor = targetKey;
239
192
  // Mark the row removed but keep it visible (muted) until the write lands.
240
- setGrantees((prev) => prev.map((g) => (g.id === granteeId ? { ...g, pending: "removing" } : g)));
193
+ applyGranteeRemove(granteeId);
241
194
  // Revoke the grantee's per-label grants too — they are independent
242
- // access-list entries, so the object revoke alone would leave them behind
243
- // (current = all permissionable). The object revoke is what matters; a
244
- // label revoke that fails is non-fatal (surfaced as a warning), so don't
245
- // abort on it.
195
+ // access-list entries, so the object revoke alone would leave them behind.
196
+ // The revoke runs over the grantee's KNOWN scope: if the object revoke
197
+ // then fails, the compensation re-grants exactly that scope, never labels
198
+ // the grantee didn't hold. The object revoke is what matters; a label
199
+ // revoke that fails is non-fatal (surfaced as a warning), so don't abort
200
+ // on it.
246
201
  const { ok, labelsOk } = await applyAccessChange({
247
202
  principal: { kind: grantee.kind, granteeRef: grantee.granteeRef },
248
203
  objectMutation: toGranularGrantee(grantee.kind, grantee.granteeRef, "none"),
249
204
  successMessage: objectShareMessages.toastAccessUpdated,
250
205
  labelDesired: EMPTY_IDS,
251
- labelCurrent: new Set(effectiveLabels.map((l) => l.id)),
206
+ labelCurrent: new Set(selectedLabelIdsByGrantee[granteeId] ?? effectiveLabels.map((l) => l.id)),
252
207
  abortIfLabelsFail: false,
253
208
  });
254
- // Bail if the target switched mid-write: the row no longer belongs to the
255
- // seeded list, and dropping/restoring "it" would hit the other object.
256
- if (targetKeyRef.current !== startedFor) {
257
- return;
258
- }
259
209
  if (!ok) {
260
210
  // Object revoke failed (applyAccessChange already re-granted the
261
211
  // labels) — restore the row.
262
- setGrantees((prev) => prev.map((g) => (g.id === granteeId ? { ...g, pending: undefined } : g)));
212
+ failGranteeEdit(granteeId);
263
213
  return;
264
214
  }
265
- // Object revoke landed drop the row from local state.
266
- setGrantees((prev) => prev.filter((g) => g.id !== granteeId));
215
+ // Object revoke landed. For a fetched row the settled `removed` entry
216
+ // persists to keep it hidden; a revoked overlay-born row drops entirely.
217
+ settleGranteeEdit(granteeId);
267
218
  setSelectedLabelIdsByGrantee((prev) => {
268
219
  const { [granteeId]: _omit, ...rest } = prev;
269
220
  return rest;
@@ -275,16 +226,18 @@ export function useObjectShareController(target, options) {
275
226
  }
276
227
  }, [
277
228
  grantees,
278
- targetKey,
279
229
  applyAccessChange,
280
230
  effectiveLabels,
231
+ selectedLabelIdsByGrantee,
281
232
  toast,
282
- setGrantees,
233
+ applyGranteeRemove,
234
+ settleGranteeEdit,
235
+ failGranteeEdit,
283
236
  setSelectedLabelIdsByGrantee,
284
237
  ]);
285
238
  const changeGranteeLabels = useCallback(async (granteeId, requested) => {
286
239
  const grantee = grantees.find((g) => g.id === granteeId);
287
- if (!grantee || !target) {
240
+ if (!grantee || grantee.pending || !target) {
288
241
  return;
289
242
  }
290
243
  // Primary label is always in scope; never let it be dropped.
@@ -293,49 +246,65 @@ export function useObjectShareController(target, options) {
293
246
  const currentScope = selectedLabelIdsByGrantee[granteeId] ?? effectiveLabels.map((l) => l.id);
294
247
  const desired = new Set(nextScope);
295
248
  const current = new Set(currentScope);
296
- const startedFor = targetKey;
297
- // Reflect the new scope immediately; restore it on failure.
298
- const previousScope = currentScope;
299
- setSelectedLabelIdsByGrantee((prev) => ({ ...prev, [granteeId]: nextScope }));
300
- const ok = await reconcileLabelScope({ kind: grantee.kind, granteeRef: grantee.granteeRef }, desired, current);
301
- // Bail if the target switched mid-write — the scope map now belongs to the
302
- // other object, so neither the success no-op nor the rollback applies here.
303
- if (targetKeyRef.current !== startedFor) {
249
+ // Applying the checklist unchanged is a no-op — don't lock the row or
250
+ // toast success over zero writes.
251
+ if (desired.size === current.size && nextScope.every((id) => current.has(id))) {
304
252
  return;
305
253
  }
254
+ // 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);
259
+ setSelectedLabelIdsByGrantee((prev) => ({ ...prev, [granteeId]: nextScope }));
260
+ const { ok, failedLabelIds } = await reconcileLabelScope([{ kind: grantee.kind, granteeRef: grantee.granteeRef }], desired, current);
306
261
  if (ok) {
262
+ settleGranteeEdit(granteeId);
307
263
  toast.addSuccess(objectShareMessages.toastAccessUpdated);
308
- onSaved?.();
264
+ return;
265
+ }
266
+ failGranteeEdit(granteeId);
267
+ // Label writes settle independently: keep what landed and flip only the
268
+ // failed labels back, so the local scope matches the backend exactly —
269
+ // restoring the whole previous scope would un-track the writes that
270
+ // succeeded, and later edits would diff against a false state.
271
+ const failed = new Set(failedLabelIds);
272
+ const landedScope = Array.from(new Set([...nextScope, ...currentScope])).filter((id) => (failed.has(id) ? current : desired).has(id));
273
+ setSelectedLabelIdsByGrantee((prev) => ({ ...prev, [granteeId]: landedScope }));
274
+ const anyLanded = landedScope.length !== currentScope.length || landedScope.some((id) => !current.has(id));
275
+ if (anyLanded) {
276
+ toast.addWarning(objectShareMessages.toastLabelScopePartial);
309
277
  }
310
278
  else {
311
279
  toast.addError(objectShareMessages.toastError);
312
- setSelectedLabelIdsByGrantee((prev) => ({ ...prev, [granteeId]: previousScope }));
313
280
  }
314
281
  }, [
315
282
  grantees,
316
283
  target,
317
- targetKey,
318
284
  effectiveLabels,
319
285
  selectedLabelIdsByGrantee,
320
286
  reconcileLabelScope,
321
287
  toast,
322
- onSaved,
288
+ applyGranteeLevel,
289
+ settleGranteeEdit,
290
+ failGranteeEdit,
323
291
  setSelectedLabelIdsByGrantee,
324
292
  ]);
293
+ // ── Workspace-rule mutations (general access + rule level) ───────────────────
325
294
  const requestGeneralAccessChange = useCallback((next) => {
326
295
  // Inherited rule access can't be revoked from this workspace, so a
327
296
  // Restricted request can never be honored — refuse it (the radio row
328
297
  // is also disabled). Compare against the effective value the radio
329
298
  // shows: with inherited access it is WORKSPACE even when this
330
299
  // workspace holds no rule of its own.
331
- if (inheritedWorkspaceLevel && next === "RESTRICTED") {
300
+ if (workspaceInheritedLevel && next === "RESTRICTED") {
332
301
  return;
333
302
  }
334
- const effective = inheritedWorkspaceLevel ? "WORKSPACE" : generalAccess;
303
+ const effective = workspaceInheritedLevel ? "WORKSPACE" : generalAccess;
335
304
  if (next !== effective) {
336
305
  setPendingGeneralAccess(next);
337
306
  }
338
- }, [generalAccess, inheritedWorkspaceLevel]);
307
+ }, [generalAccess, workspaceInheritedLevel]);
339
308
  const confirmGeneralAccessChange = useCallback(async () => {
340
309
  const next = pendingGeneralAccess;
341
310
  if (!next || next === generalAccess) {
@@ -349,17 +318,16 @@ export function useObjectShareController(target, options) {
349
318
  if (workspaceLevelSaving) {
350
319
  return;
351
320
  }
352
- const startedFor = targetKey;
353
- const previous = generalAccess;
354
- const previousWorkspaceLevel = workspaceLevel;
355
321
  // Apply the new value and close the confirm at once — the radio + summary
356
322
  // reflect `next` immediately; the write commits in the background. The
357
323
  // workspace rule is always written as VIEW, so reflect VIEW now too —
358
324
  // otherwise the summary would keep a SHARE level left over from the fetch.
359
- setGeneralAccess(next);
360
- if (next === "WORKSPACE") {
361
- setWorkspaceLevel("VIEW");
362
- }
325
+ // PENDING until the whole labels+object save settles: the radio and the
326
+ // level menu must stay locked, or a re-grade started meanwhile would race
327
+ // this save on the same allWorkspaceUsers rule and could land first, letting
328
+ // the delayed VIEW write downgrade what the UI shows.
329
+ const nextLevel = next === "WORKSPACE" ? "VIEW" : workspaceLevel;
330
+ applyRuleEdit({ generalAccess: next, level: nextLevel, pending: true });
363
331
  setPendingGeneralAccess(undefined);
364
332
  const principal = { allWorkspaceUsers: true };
365
333
  const allIds = new Set(effectiveLabels.map((l) => l.id));
@@ -376,18 +344,15 @@ export function useObjectShareController(target, options) {
376
344
  labelCurrent: current,
377
345
  abortIfLabelsFail: true,
378
346
  });
379
- // Bail if the target switched mid-write — reverting now would change the
380
- // other object's general access.
381
- if (targetKeyRef.current !== startedFor) {
382
- return;
347
+ if (ok) {
348
+ settleRuleEdit();
383
349
  }
384
- if (!ok) {
350
+ else {
385
351
  // Labels or object write failed (applyAccessChange already undid the
386
- // label mirror) — revert the value (and the workspace level). The
387
- // label-abort path produced no toast, so surface the error here; a
352
+ // label mirror) — revert to the last committed rule state, or the fetch.
353
+ // The label-abort path produced no toast, so surface the error here; a
388
354
  // failed object write already toasted via commit, so don't double up.
389
- setGeneralAccess(previous);
390
- setWorkspaceLevel(previousWorkspaceLevel);
355
+ failRuleEdit();
391
356
  if (!labelsOk) {
392
357
  toast.addError(objectShareMessages.toastError);
393
358
  }
@@ -397,54 +362,52 @@ export function useObjectShareController(target, options) {
397
362
  generalAccess,
398
363
  workspaceLevel,
399
364
  workspaceLevelSaving,
400
- targetKey,
401
365
  applyAccessChange,
402
366
  effectiveLabels,
403
367
  toast,
404
- setGeneralAccess,
405
- setWorkspaceLevel,
368
+ applyRuleEdit,
369
+ settleRuleEdit,
370
+ failRuleEdit,
406
371
  ]);
407
372
  const changeWorkspaceLevel = useCallback(async (level) => {
373
+ // The dropdown displays the EFFECTIVE level (direct composed with
374
+ // inherited), so no-ops must be judged against it: with an inherited
375
+ // SHARE over a direct VIEW the menu shows SHARE checked, and clicking
376
+ // it must not silently escalate the persisted direct rule to SHARE.
377
+ const effectiveLevel = effectiveWorkspace.workspaceLevel;
408
378
  // Only re-grade an already-granted workspace rule; ignore no-ops, calls made
409
379
  // while this workspace grants no rule of its own (inherited-only access has
410
- // nothing to re-grade), calls while an inherited SHARE pins the effective
411
- // level (a direct downgrade couldn't change it), and re-entry while a
380
+ // nothing to re-grade), calls for levels below an inherited one (a direct
381
+ // downgrade couldn't lower the effective level), and re-entry while a
412
382
  // previous re-grade is still in flight (would overlap writes).
413
383
  if (generalAccess !== "WORKSPACE" ||
414
- inheritedWorkspaceLevel === "SHARE" ||
415
- level === workspaceLevel ||
384
+ levelsBelow(workspaceInheritedLevel ?? "VIEW").includes(level) ||
385
+ level === effectiveLevel ||
416
386
  workspaceLevelSaving) {
417
387
  return;
418
388
  }
419
- const startedFor = targetKey;
420
- const previousLevel = workspaceLevel;
421
- // Reflect the new level immediately and mark the write in flight, then commit
422
- // it. Labels are untouched — they were already mirrored when workspace access
423
- // was granted; this changes only the rule's permission grade.
424
- setWorkspaceLevel(level);
425
- setWorkspaceLevelSaving(true);
389
+ // Labels are untouched — already mirrored when workspace access was granted;
390
+ // this only re-grades the rule's permission level.
391
+ applyRuleEdit({ generalAccess: "WORKSPACE", level, pending: true });
426
392
  const ok = await commit([granularGranteeFor({ allWorkspaceUsers: true }, level)], objectShareMessages.toastGeneralAccessUpdated);
427
- // Bail if the target switched mid-write — the workspace level now belongs to
428
- // the other object, so neither keeping nor rolling back applies here. The
429
- // saving flag is reset on the new target's seed, so don't touch it.
430
- if (targetKeyRef.current !== startedFor) {
431
- return;
393
+ if (ok) {
394
+ settleRuleEdit();
432
395
  }
433
- if (!ok) {
434
- setWorkspaceLevel(previousLevel);
396
+ else {
397
+ failRuleEdit();
435
398
  }
436
- setWorkspaceLevelSaving(false);
437
399
  }, [
438
400
  generalAccess,
439
- inheritedWorkspaceLevel,
440
- workspaceLevel,
401
+ workspaceInheritedLevel,
402
+ effectiveWorkspace,
441
403
  workspaceLevelSaving,
442
- targetKey,
443
404
  commit,
444
- setWorkspaceLevel,
405
+ applyRuleEdit,
406
+ settleRuleEdit,
407
+ failRuleEdit,
445
408
  ]);
409
+ // ── Assembly: actions + state view exposed to consumers ──────────────────────
446
410
  const actions = useMemo(() => ({
447
- reset,
448
411
  openAddGrantee,
449
412
  closeAddGrantee,
450
413
  setPendingGrantees,
@@ -458,7 +421,6 @@ export function useObjectShareController(target, options) {
458
421
  confirmGeneralAccessChange,
459
422
  changeWorkspaceLevel,
460
423
  }), [
461
- reset,
462
424
  openAddGrantee,
463
425
  closeAddGrantee,
464
426
  loadOptions,
@@ -470,48 +432,73 @@ export function useObjectShareController(target, options) {
470
432
  confirmGeneralAccessChange,
471
433
  changeWorkspaceLevel,
472
434
  ]);
473
- const state = useMemo(() => ({
474
- subview,
475
- status,
476
- error: loadError,
477
- accessUnavailable,
478
- summary,
479
- targetKey,
480
- selfIdentity,
481
- selfIdentityResolved,
482
- seededWithoutGrants,
483
- grantees,
484
- // Effective access: the direct state composed with inherited rule
485
- // access. Also pins the level to VIEW while restricted, so the (hidden)
486
- // dropdown never shows a stale SHARE left over from a prior grant.
487
- ...composeEffectiveWorkspaceAccess(generalAccess, workspaceLevel, inheritedWorkspaceLevel),
488
- workspaceAccessInherited: inheritedWorkspaceLevel !== undefined,
489
- // No direct rule to re-grade (inherited-only access), or an inherited
490
- // SHARE pins the effective level — either way the dropdown is read-only.
491
- workspaceLevelLocked: generalAccess !== "WORKSPACE" || inheritedWorkspaceLevel === "SHARE",
492
- workspaceLevelSaving,
493
- labels: effectiveLabels,
494
- labelsResolved,
495
- selectedLabelIdsByGrantee,
496
- pendingGeneralAccess,
497
- pendingGrantees,
498
- }), [
435
+ // Sorted for display only; the overlay stays in write-through order, so
436
+ // add/rollback logic is unaffected. Memoized apart from the state assembly so
437
+ // the array identity changes only when the rows do, not on every state change.
438
+ const sortedGrantees = useMemo(() => sortGrantees(grantees), [grantees]);
439
+ const state = useMemo(() => {
440
+ // Row policy is classified here, not in the dialog, so a consumer
441
+ // injecting this controller into its own UI gets the same classification.
442
+ const selfManagedGranteeId = grantees.length === 1 && grantees[0].isSelf ? grantees[0].id : undefined;
443
+ const granteeControlsLocked = grantees.length === 1 && grantees[0].kind === "user" && !selfIdentityResolved;
444
+ // A workspace-wide share-capable rule (SHARE or EDIT) is the one way other
445
+ // than admin/manager rights through the manage gate — no Admin badge then.
446
+ const workspaceShareCapable = effectiveWorkspace.generalAccess === "WORKSPACE" && effectiveWorkspace.workspaceLevel !== "VIEW";
447
+ // The synthesized Admin row shows only while NO other permissions are set:
448
+ // the caller must have reached the list without a grant of their own
449
+ // (`seededWithoutSelfGrant` a non-admin who locally removes their own
450
+ // grant must not gain the badge) and the list must currently be empty, so
451
+ // adding a grantee hides it and removing the last one brings it back.
452
+ const adminSelfRow = status === "success" &&
453
+ seededWithoutSelfGrant &&
454
+ grantees.length === 0 &&
455
+ !workspaceShareCapable &&
456
+ selfIdentity
457
+ ? userDisplayPair(selfIdentity, selfIdentity.id)
458
+ : undefined;
459
+ return {
460
+ subview,
461
+ status,
462
+ error: loadError,
463
+ summary,
464
+ grantees: sortedGrantees,
465
+ selfManagedGranteeId,
466
+ // Policy, not just classification: the caller's own sole row can't be
467
+ // raised above itself, an inherited workspace level can't be undercut.
468
+ selfManagedDisabledLevels: selfManagedGranteeId ? levelsAbove(grantees[0].level) : undefined,
469
+ workspaceDisabledLevels: workspaceInheritedLevel
470
+ ? levelsBelow(workspaceInheritedLevel)
471
+ : undefined,
472
+ granteeControlsLocked,
473
+ adminSelfRow,
474
+ ...effectiveWorkspace,
475
+ workspaceInheritedLevel: workspaceInheritedLevel,
476
+ workspaceLevelLocked: generalAccess !== "WORKSPACE" || workspaceInheritedLevel === "EDIT",
477
+ workspaceLevelSaving,
478
+ labels: effectiveLabels,
479
+ labelsResolved,
480
+ labelsInitializing,
481
+ selectedLabelIdsByGrantee,
482
+ pendingGeneralAccess,
483
+ pendingGrantees,
484
+ };
485
+ }, [
499
486
  subview,
500
487
  status,
501
488
  loadError,
502
- accessUnavailable,
503
489
  summary,
504
- targetKey,
505
490
  selfIdentity,
506
491
  selfIdentityResolved,
507
- seededWithoutGrants,
492
+ seededWithoutSelfGrant,
508
493
  grantees,
494
+ sortedGrantees,
495
+ effectiveWorkspace,
509
496
  generalAccess,
510
- workspaceLevel,
511
- inheritedWorkspaceLevel,
497
+ workspaceInheritedLevel,
512
498
  workspaceLevelSaving,
513
499
  effectiveLabels,
514
500
  labelsResolved,
501
+ labelsInitializing,
515
502
  selectedLabelIdsByGrantee,
516
503
  pendingGeneralAccess,
517
504
  pendingGrantees,