@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 +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,EAEH,KAAK,sBAAsB,EAE3B,KAAK,MAAM,EAId,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,KAAK,kBAAkB,EAAE,KAAK,sBAAsB,EAAmB,MAAM,sBAAsB,CAAC;AAkB7G,OAAO,KAAK,EAER,2BAA2B,EAC3B,mBAAmB,EACnB,aAAa,EAChB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEvD;;;;GAIG;AACH,MAAM,WAAW,WAAW;IACxB,iFAAiF;IACjF,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,wFAAwF;IACxF,OAAO,EAAE,OAAO,CAAC;IACjB,iGAA+F;IAC/F,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,EAAE,OAAO,CAAC;IAC7B,6EAA6E;IAC7E,YAAY,EAAE,aAAa,GAAG,SAAS,CAAC;IACxC;;;;OAIG;IACH,oBAAoB,EAAE,OAAO,CAAC;IAC9B;;;;OAIG;IACH,aAAa,EAAE,kBAAkB,CAAC;IAClC,6FAA2F;IAC3F,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC;IACjC;;;;;;OAMG;IACH,uBAAuB,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;IACtD,iEAAiE;IACjE,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;IAC7B;;;;;OAKG;IACH,iBAAiB,EAAE,OAAO,CAAC;IAE3B,mFAAmF;IACnF,MAAM,EAAE,CAAC,MAAM,EAAE,sBAAsB,EAAE,EAAE,cAAc,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/F;;;OAGG;IACH,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,qGAAqG;IACrG,WAAW,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,mBAAmB,EAAE,CAAC,CAAC,CAAC;IACzE,8EAA8E;IAC9E,gBAAgB,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,kBAAkB,CAAC,CAAC,CAAC;IAC3E;;;;OAIG;IACH,iBAAiB,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;CAC7E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CACzB,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,OAAO,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,EACjC,UAAU,EAAE,OAAO,GACpB,WAAW,CAiUb"}
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,EAK9B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACH,KAAK,gBAAgB,EAKxB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,KAAK,kBAAkB,EAAE,KAAK,sBAAsB,EAAmB,MAAM,sBAAsB,CAAC;AAS7G,OAAO,EAEH,KAAK,SAAS,EASjB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EACR,2BAA2B,EAC3B,mBAAmB,EACnB,aAAa,EACb,0BAA0B,EAC7B,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEvD;;;;GAIG;AACH,MAAM,WAAW,WAAW;IACxB,oDAAoD;IACpD,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,sBAAsB,EAAE,OAAO,CAAC;IAChC,6EAA6E;IAC7E,YAAY,EAAE,aAAa,GAAG,SAAS,CAAC;IACxC;;;OAGG;IACH,oBAAoB,EAAE,OAAO,CAAC;IAC9B,8DAA8D;IAC9D,aAAa,EAAE,kBAAkB,CAAC;IAClC,oFAAkF;IAClF,cAAc,EAAE,0BAA0B,CAAC;IAC3C;;;OAGG;IACH,uBAAuB,EAAE,0BAA0B,GAAG,SAAS,CAAC;IAChE,wFAAwF;IACxF,oBAAoB,EAAE,OAAO,CAAC;IAC9B,iFAAiF;IACjF,OAAO,EAAE,oBAAoB,GAAG,SAAS,CAAC;IAC1C,qDAAqD;IACrD,MAAM,EAAE,2BAA2B,CAAC,QAAQ,CAAC,CAAC;IAC9C,mDAAmD;IACnD,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAExC,mFAAmF;IACnF,MAAM,EAAE,CAAC,MAAM,EAAE,sBAAsB,EAAE,EAAE,cAAc,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/F,+FAA6F;IAC7F,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAEjE;;;;OAIG;IACH,eAAe,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAC;IACxD;;;;OAIG;IACH,iBAAiB,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,0BAA0B,KAAK,IAAI,CAAC;IAC3E,gFAAgF;IAChF,kBAAkB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,iEAAiE;IACjE,iBAAiB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;OAGG;IACH,eAAe,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAEtC,oFAAoF;IACpF,aAAa,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,KAAK,IAAI,CAAC;IAC1D,6DAA6D;IAC7D,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,8FAA8F;IAC9F,YAAY,EAAE,MAAM,IAAI,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,wBAAwB,GAAG,SAAS,GAAG,WAAW,CAyRvF"}
@@ -1,140 +1,60 @@
1
1
  // (C) 2026 GoodData Corporation
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useCallback, useMemo, useState } from "react";
3
3
  import { idRef, objRefToString, serializeObjRef, } from "@gooddata/sdk-model";
4
- import { useBackendStrict, useCancelablePromise, useWorkspaceStrict } from "@gooddata/sdk-ui";
4
+ import { convertError, useBackendStrict, useCancelablePromise, useWorkspaceStrict, } from "@gooddata/sdk-ui";
5
5
  import { useToastMessage } from "@gooddata/sdk-ui-kit";
6
- import { isPermissionsNotAvailable } from "./accessErrors.js";
7
6
  import { composeEffectiveWorkspaceAccess, deriveGeneralAccess, deriveInheritedWorkspaceLevel, deriveWorkspacePermissionLevel, } from "./accessSummary.js";
8
7
  import { objectShareMessages } from "./messages.js";
9
- import { assigneeIdentityFacts, assigneeMatchesQuery, granteeId, granteesFromAccessList, userDisplayPair, userIdentityFacts, } from "./objectShareController.helpers.js";
8
+ import { assigneeIdentityFacts, assigneeMatchesQuery, effectivePermissionAbove, granteeId, granteesFromAccessList, mergeGrantees, userDisplayPair, userIdentityFacts, } from "./objectShareController.helpers.js";
10
9
  /**
11
- * Owns the backend access list. It is fetched once (per target) then seeded into
12
- * local state, which is authoritative while the dialog/summary is mounted:
13
- * mutations write through to it directly and roll back the one changed entry on
14
- * failure. There is no post-write refetch, so the grantee list never blanks and
15
- * never fights the backend's read-after-write lag. A target switch re-seeds from
16
- * the new fetch; the seed is gated on a per-target stamp so a late fetch for a
17
- * previous target can't clobber the current one.
10
+ * Owns the backend access list for ONE dialog session: the hook is mounted while
11
+ * the dialog is open, for a single target that must not change while mounted
12
+ * (remount for a new target see {@link ObjectShareDialog}). Unmounting discards
13
+ * every transient: the edit overlay, the profile, in-flight finalizers.
18
14
  *
19
- * Effective (inherited) permissions and display names come from that single
20
- * fetch: inherited access doesn't change as a result of editing a direct grant,
21
- * so the badge stays correct for the session without re-reading.
15
+ * The flow is one-directional: the list is fetched once, and the displayed state
16
+ * is *derived* from that fetch composed with a small local edit overlay
17
+ * ({@link mergeGrantees}). There is no mirrored state and no post-write refetch —
18
+ * a mutation writes its overlay entry (so the row updates immediately), commits to
19
+ * the backend, then settles the entry on success or reverts it on failure — to the
20
+ * committed entry it superseded (kept as `settled` on the pending edit), or to the
21
+ * fetched row when there was none. The grantee list therefore never blanks and
22
+ * never fights read-after-write lag.
22
23
  *
23
24
  * @internal
24
25
  */
25
- export function useAccessList(target, onSaved, dialogOpen) {
26
+ export function useAccessList(target) {
26
27
  const backend = useBackendStrict();
27
28
  const workspace = useWorkspaceStrict();
28
29
  const toast = useToastMessage();
29
- // Authoritative local state, seeded from the fetch below.
30
- const [grantees, setGrantees] = useState([]);
31
- // Load-time fact, deliberately NOT derived from the live list: local mutations
32
- // must not change it (see the IAccessList doc).
33
- const [seededWithoutGrants, setSeededWithoutGrants] = useState(false);
34
- const [generalAccess, setGeneralAccess] = useState("RESTRICTED");
35
- const [workspaceLevel, setWorkspaceLevel] = useState("VIEW");
36
- const [inheritedWorkspaceLevel, setInheritedWorkspaceLevel] = useState(undefined);
37
- // The target the local state was seeded for; undefined until the first seed.
38
- // Reading state as belonging to the current target hinges on this matching
39
- // `targetKey` — a target switch makes the old state stale until the re-seed.
40
- const [seededTarget, setSeededTarget] = useState(undefined);
41
- const [loadError, setLoadError] = useState(undefined);
42
- // Assignee identities from the listing (picker + on-open resolve), keyed by
43
- // grantee id: de-collapsed facts + the original ObjRef (the serialized id
44
- // loses Uri-vs-Id). The signed-in user is absent from the listing; their
45
- // facts derive from the profile instead.
46
- const [knownAssignees, setKnownAssignees] = useState({});
47
- // Memoized current-user fetch — the profile doesn't change while mounted,
48
- // so resolve it at most once and share the promise across callers.
49
- const currentUserCache = useRef(undefined);
50
- // Kind-qualified: distinct permission targets can share an identifier (e.g. an
51
- // attribute and a metric), and every target-scoped guard — the seed stamp, the
52
- // async-finalizer bail-outs, the dialog's staged-confirm reset — compares this
53
- // key. serializeObjRef keeps the ref's own `type` qualifier too.
54
- const targetKey = target ? `${target.kind}:${serializeObjRef(target.ref)}` : undefined;
55
- // Written from both the picker (loadOptions) and the on-open resolve. Must not
56
- // depend on the cache it writes, or it would re-trigger loadOptions' fetch.
57
- const cacheAssignees = useCallback((assignees) => {
58
- setKnownAssignees((prev) => {
59
- const next = { ...prev };
60
- for (const assignee of assignees) {
61
- next[granteeId(assignee.type === "user" ? "user" : "group", assignee.ref)] = {
62
- facts: assigneeIdentityFacts(assignee),
63
- ref: assignee.ref,
64
- };
65
- }
66
- return next;
67
- });
68
- }, []);
69
- // Initial (and target-change) load. Seeds local state once per target; thereafter
70
- // local state is authoritative and mutations write through it.
30
+ // The only local state: the transient edit overlay. Everything else is derived.
31
+ const [granteeEdits, setGranteeEdits] = useState({});
32
+ const [ruleEdit, setRuleEditState] = useState(undefined);
33
+ // Serialized so an inline idRef(...) a new object each render — doesn't refetch forever.
34
+ const fetchKey = target ? `${workspace}:${target.kind}:${serializeObjRef(target.ref)}` : undefined;
71
35
  const { result: fetchedList, status: fetchStatus, error: fetchError, } = useCancelablePromise({
72
36
  promise: target
73
37
  ? () => backend.workspace(workspace).objectPermissions().getAccessList(target)
74
38
  : undefined,
75
39
  },
76
- // Key on the serialized ref, not the ObjRef object — an inline idRef(...) is a
77
- // new instance each render and would otherwise refetch forever.
78
- [backend, workspace, target?.kind, targetKey]);
79
- // Local state belongs to the current target only once its list has been seeded
80
- // AND no fetch is in flight for it. Requiring `fetchStatus === "success"` (not
81
- // just a matching `seededTarget`) matters when the same object is reopened after
82
- // navigating away: the deps change re-runs `useCancelablePromise`, which resets to
83
- // `loading` while it refetches. Without this, the still-matching previous
84
- // `seededTarget` would keep `hasList` true and surface the prior session's
85
- // grantees as `success` (mutations enabled) until the new list lands.
86
- const hasList = seededTarget === targetKey && targetKey !== undefined && fetchStatus === "success";
87
- useEffect(() => {
88
- if (fetchStatus === "success" && fetchedList) {
89
- // The cancelable promise is keyed on targetKey, so this list is for it.
90
- const seededGrantees = granteesFromAccessList(fetchedList);
91
- setGrantees(seededGrantees);
92
- setSeededWithoutGrants(seededGrantees.length === 0);
93
- setGeneralAccess(deriveGeneralAccess(fetchedList.grants));
94
- setWorkspaceLevel(deriveWorkspacePermissionLevel(fetchedList.grants));
95
- setInheritedWorkspaceLevel(deriveInheritedWorkspaceLevel(fetchedList.grants));
96
- setSeededTarget(targetKey);
97
- setLoadError(undefined);
98
- }
99
- else if (fetchStatus === "error") {
100
- setLoadError(fetchError instanceof Error ? fetchError : new Error(String(fetchError)));
101
- }
102
- else if (fetchStatus === "loading") {
103
- // A (re)fetch is in flight — including when the same object is reopened
104
- // after navigating away. Drop the seed stamp so the previous session's
105
- // list isn't surfaced as the current one until the fresh fetch is seeded.
106
- setSeededTarget(undefined);
107
- }
108
- }, [fetchStatus, fetchedList, fetchError, targetKey]);
109
- const getCurrentUser = useCallback(() => {
110
- if (!currentUserCache.current) {
111
- currentUserCache.current = backend
112
- .currentUser()
113
- .getUser()
114
- .catch((error) => {
115
- // Don't cache a rejected promise, or a transient profile-read
116
- // failure would stick for the rest of the session.
117
- currentUserCache.current = undefined;
118
- throw error;
119
- });
120
- }
121
- return currentUserCache.current;
122
- }, [backend]);
123
- // Resolve the current user only while the dialog is open — keeps the summary-only
124
- // path free of a profile request (the self-row facts it feeds aren't shown there).
40
+ // fetchKey already encodes workspace, kind and ref.
41
+ [backend, fetchKey]);
42
+ const hasList = fetchStatus === "success" && !!fetchedList;
43
+ const base = useMemo(() => (hasList ? granteesFromAccessList(fetchedList) : []), [hasList, fetchedList]);
44
+ // The profile identifies the caller's own row (self-managed classification, the
45
+ // admin empty-state row). The session exists only while the dialog is open, so
46
+ // fetch it once per session; the client caches it across sessions.
125
47
  const { result: currentUser, status: currentUserStatus } = useCancelablePromise({
126
- promise: dialogOpen && targetKey ? () => getCurrentUser() : undefined,
48
+ promise: () => backend.currentUser().getUser(),
127
49
  onError: () => { },
128
- },
129
- // Key on target presence, not identity the resolved user is
130
- // target-independent, so a target switch must not reset it.
131
- [getCurrentUser, targetKey !== undefined, dialogOpen]);
132
- // Success only: while pending OR after a (silently swallowed) failure the
133
- // rows' isSelf is just its unresolved default, and consumers must not treat
134
- // an unidentified row as safe to mutate. Retried naturally on dialog reopen
135
- // (the promise is keyed on `dialogOpen`; the failure isn't cached).
50
+ }, [backend]);
51
+ // Success only: while pending or after a swallowed failure a sole row can't be told
52
+ // apart from the caller's own grant, so it must not be treated as safe to mutate.
136
53
  const selfIdentityResolved = currentUserStatus === "success";
137
54
  const selfId = currentUser ? granteeId("user", idRef(currentUser.login)) : undefined;
55
+ // Derived from the immutable SEED (see the interface doc): a caller whose own
56
+ // grant was the way in must not read as grant-independent after removing it.
57
+ const seededWithoutSelfGrant = hasList && selfIdentityResolved && !base.some((g) => g.id === selfId);
138
58
  // De-collapsed like every listing fact — on tiger the user id is often the email.
139
59
  const selfIdentity = useMemo(() => currentUser
140
60
  ? {
@@ -142,75 +62,38 @@ export function useAccessList(target, onSaved, dialogOpen) {
142
62
  id: currentUser.login,
143
63
  }
144
64
  : undefined, [currentUser]);
145
- // Rows with identity facts backfilled from the assignee cache or, for the
146
- // signed-in user's own row (absent from the listing by design), from the
147
- // profile. Empty until the current target's list is seeded; a derivation, so
148
- // it re-applies as the caches grow.
149
- const namedGrantees = useMemo(() => {
150
- if (!hasList) {
151
- return [];
152
- }
153
- return grantees.map((g) => {
154
- const known = knownAssignees[g.id]?.facts ?? (g.id === selfId ? selfIdentity : undefined);
155
- const isSelf = g.id === selfId;
156
- return known
157
- ? { ...g, isSelf, name: g.name ?? known.name, email: g.email ?? known.email }
158
- : { ...g, isSelf };
159
- });
160
- }, [hasList, grantees, knownAssignees, selfIdentity, selfId]);
161
- // Resolve identity facts on dialog open — grants often carry only raw ids
162
- // (the post-reload state). Unconditional: no missing-facts gate to keep in
163
- // sync with the cache, and summary-only consumers never fetch. Keyed on the
164
- // serialized targetKey so an inline-ref consumer re-rendering mid-fetch
165
- // doesn't cancel it.
166
- useCancelablePromise({
167
- promise: target && dialogOpen
168
- ? () => backend.workspace(workspace).objectPermissions().getAvailableAssignees(target)
169
- : undefined,
170
- onSuccess: cacheAssignees,
171
- // Best-effort backfill: on error the raw id stays (pre-fix behavior, no
172
- // regression) and the picker can still resolve it on demand. No toast.
173
- onError: () => { },
174
- }, [backend, workspace, targetKey, dialogOpen]);
175
- // Stable sorted key of the currently-granted ids — drives the picker's
176
- // "exclude already-granted" filter. Keyed on ids only (not names), so the
177
- // picker's own name-cache writes can't change loadOptions' identity and
178
- // re-trigger its fetch.
179
- const excludedIdsKey = useMemo(() => grantees
180
- .filter((g) => g.pending !== "removing")
181
- .map((g) => g.id)
182
- .sort()
183
- .join(","), [grantees]);
65
+ const grantees = useMemo(() => mergeGrantees(base, granteeEdits, selfId, selfIdentity), [base, granteeEdits, selfId, selfIdentity]);
66
+ const generalAccess = ruleEdit?.generalAccess ?? deriveGeneralAccess(fetchedList?.grants ?? []);
67
+ const workspaceLevel = ruleEdit?.level ?? deriveWorkspacePermissionLevel(fetchedList?.grants ?? []);
68
+ const workspaceLevelSaving = ruleEdit?.pending ?? false;
69
+ const workspaceInheritedLevel = useMemo(() => deriveInheritedWorkspaceLevel(fetchedList?.grants ?? []), [fetchedList]);
70
+ // Keyed on plain values so the summary's identity changes exactly when its
71
+ // content does — consumers (the dialog's onSummaryChange) rely on that.
72
+ const granteeCount = grantees.filter((g) => g.pending !== "removing").length;
184
73
  const summary = useMemo(() => {
185
74
  if (!hasList) {
186
75
  return undefined;
187
76
  }
188
- // The summary shows effective access: an inherited rule counts as
189
- // workspace access even when this workspace holds no rule of its own.
77
+ // Effective access: an inherited rule counts as WORKSPACE even without a local rule.
190
78
  return {
191
- ...composeEffectiveWorkspaceAccess(generalAccess, workspaceLevel, inheritedWorkspaceLevel),
192
- granteeCount: namedGrantees.filter((g) => g.pending !== "removing").length,
79
+ ...composeEffectiveWorkspaceAccess(generalAccess, workspaceLevel, workspaceInheritedLevel),
80
+ granteeCount,
193
81
  };
194
- }, [hasList, generalAccess, workspaceLevel, inheritedWorkspaceLevel, namedGrantees]);
195
- // "success" only once the *current* target's list has been seeded. After a
196
- // target switch the previous seed is stale (seededTarget !== targetKey), so
197
- // gating on hasList keeps the status at "loading" until the new list lands —
198
- // never "success" with no list, which would let the dialog enable mutations
199
- // and the catalog row hide both summary and skeleton.
200
- const status = target
201
- ? fetchStatus === "error"
202
- ? "error"
203
- : hasList
204
- ? "success"
205
- : "loading"
206
- : "idle";
207
- // Derived from the live fetch (status + error travel together), not the
208
- // persisted loadError so a target switch can't briefly flag the new target
209
- // with the previous target's 404 before the load effect updates loadError.
210
- const accessUnavailable = fetchStatus === "error" && isPermissionsNotAvailable(fetchError);
211
- // Write a single grant change to the backend, then toast. The caller applies
212
- // the optimistic local write-through and rolls it back on failure; there is no
213
- // refetch — local state stays authoritative.
82
+ }, [hasList, generalAccess, workspaceLevel, workspaceInheritedLevel, granteeCount]);
83
+ // "success" waits for `hasList` so consumers never see success with nothing to show.
84
+ const deriveStatus = () => {
85
+ if (!target) {
86
+ return "idle";
87
+ }
88
+ if (fetchStatus === "error") {
89
+ return "error";
90
+ }
91
+ return hasList ? "success" : "loading";
92
+ };
93
+ const status = deriveStatus();
94
+ // Typed SDK error, the house pattern (see InsightView) — not a hand-rolled Error.
95
+ const loadError = useMemo(() => (fetchStatus === "error" ? convertError(fetchError) : undefined), [fetchStatus, fetchError]);
96
+ // The caller owns the optimistic overlay write and its rollback; there is no refetch.
214
97
  const commit = useCallback(async (mutate, successMessage) => {
215
98
  if (!target) {
216
99
  return false;
@@ -221,18 +104,20 @@ export function useAccessList(target, onSaved, dialogOpen) {
221
104
  .objectPermissions()
222
105
  .manageObjectPermissions(target, mutate);
223
106
  toast.addSuccess(successMessage);
224
- onSaved?.();
225
107
  return true;
226
108
  }
227
109
  catch {
228
110
  toast.addError(objectShareMessages.toastError);
229
111
  return false;
230
112
  }
231
- }, [backend, workspace, target, toast, onSaved]);
232
- // Picker loader fetches available assignees on demand, filters by the typed
233
- // query, and excludes anything already granted. Depends only on the
234
- // granted set (grantees), so its identity changes only when that set actually
235
- // changes. It must NOT write state that feeds its own deps.
113
+ }, [backend, workspace, target, toast]);
114
+ // Ids the picker excludes as already-granted INCLUDING rows whose removal is
115
+ // still in flight: offering those would let a re-add overlap the pending revoke
116
+ // on one id, and the settle/fail finalizers act on the id's current overlay
117
+ // entry, not the write that started them. A settled removal drops out of the
118
+ // merged view, so the id becomes offerable exactly when the revoke has landed.
119
+ const excludedIds = useMemo(() => new Set(grantees.map((g) => g.id)), [grantees]);
120
+ // Each option carries its backend `ref` so the add flow grants against the exact ref.
236
121
  const loadOptions = useCallback(async (search) => {
237
122
  if (!target) {
238
123
  return { groups: [], users: [] };
@@ -242,54 +127,122 @@ export function useAccessList(target, onSaved, dialogOpen) {
242
127
  .objectPermissions()
243
128
  .getAvailableAssignees(target);
244
129
  const query = search.trim().toLowerCase();
245
- const withIds = assignees.map((a) => ({
246
- assignee: a,
247
- id: granteeId(a.type === "user" ? "user" : "group", a.ref),
130
+ const users = [];
131
+ const groups = [];
132
+ for (const assignee of assignees) {
133
+ const kind = assignee.type === "user" ? "user" : "group";
134
+ const id = granteeId(kind, assignee.ref);
135
+ if (excludedIds.has(id) || !assigneeMatchesQuery(assignee, query)) {
136
+ continue;
137
+ }
138
+ if (assignee.type === "user") {
139
+ users.push({
140
+ id,
141
+ ref: assignee.ref,
142
+ kind: "user",
143
+ // Same name → email → id fallback the grantee rows use.
144
+ ...userDisplayPair(assigneeIdentityFacts(assignee), objRefToString(assignee.ref)),
145
+ });
146
+ }
147
+ else {
148
+ groups.push({ id, ref: assignee.ref, kind: "group", name: assignee.name });
149
+ }
150
+ }
151
+ return { users, groups };
152
+ }, [backend, workspace, target, excludedIds]);
153
+ const applyGranteeAdd = useCallback((grantee) => {
154
+ setGranteeEdits((prev) => {
155
+ const current = prev[grantee.id];
156
+ // Re-adding a removed base row supersedes the committed removal — carry
157
+ // it, so a failed add restores the removal instead of dropping the entry
158
+ // and resurrecting the base row at its pre-removal level.
159
+ const settled = current ? { ...current, settled: undefined } : undefined;
160
+ return { ...prev, [grantee.id]: { kind: "added", grantee, pending: true, settled } };
161
+ });
162
+ }, []);
163
+ const applyGranteeLevel = useCallback((id, level) => {
164
+ setGranteeEdits((prev) => {
165
+ const current = prev[id];
166
+ // Keep one level of history: the committed entry this edit supersedes.
167
+ const settled = current ? { ...current, settled: undefined } : undefined;
168
+ if (current?.kind === "added") {
169
+ // Overlay-born row: stay an `added` entry, or the merge would drop it.
170
+ const grantee = {
171
+ ...current.grantee,
172
+ level,
173
+ effectivePermission: effectivePermissionAbove(level, current.grantee.inheritedLevel),
174
+ };
175
+ return { ...prev, [id]: { kind: "added", grantee, pending: true, settled } };
176
+ }
177
+ return { ...prev, [id]: { kind: "level", level, pending: true, settled } };
178
+ });
179
+ }, []);
180
+ const applyGranteeRemove = useCallback((id) => {
181
+ setGranteeEdits((prev) => {
182
+ const current = prev[id];
183
+ const settled = current ? { ...current, settled: undefined } : undefined;
184
+ return { ...prev, [id]: { kind: "removed", pending: true, settled } };
185
+ });
186
+ }, []);
187
+ const settleGranteeEdit = useCallback((id) => {
188
+ setGranteeEdits((prev) => {
189
+ const current = prev[id];
190
+ if (!current) {
191
+ return prev;
192
+ }
193
+ // A settled removal always keeps its entry: for a base row (including a
194
+ // removed re-added one) it is what keeps the row hidden; for an
195
+ // overlay-born row it renders nothing and is harmless.
196
+ return { ...prev, [id]: { ...current, pending: false, settled: undefined } };
197
+ });
198
+ }, []);
199
+ const failGranteeEdit = useCallback((id) => {
200
+ setGranteeEdits((prev) => {
201
+ const current = prev[id];
202
+ if (!current) {
203
+ return prev;
204
+ }
205
+ if (current.settled) {
206
+ return { ...prev, [id]: current.settled };
207
+ }
208
+ const { [id]: _omit, ...rest } = prev;
209
+ return rest;
210
+ });
211
+ }, []);
212
+ const applyRuleEdit = useCallback((edit) => {
213
+ setRuleEditState((current) => ({
214
+ ...edit,
215
+ settled: current ? { ...current, settled: undefined } : undefined,
248
216
  }));
249
- // Remember every assignee's real name + ref so granted rows can show them
250
- // even when the access-list grant later returns only a raw id. Safe here:
251
- // the caches aren't dependencies of loadOptions, so this won't re-trigger it.
252
- cacheAssignees(assignees);
253
- const excluded = new Set(excludedIdsKey ? excludedIdsKey.split(",") : []);
254
- const selectable = withIds
255
- .filter(({ id }) => !excluded.has(id)) // hide anyone already granted
256
- .filter(({ assignee }) => assigneeMatchesQuery(assignee, query));
257
- return {
258
- users: selectable
259
- .filter(({ assignee }) => assignee.type === "user")
260
- .map(({ assignee, id }) => ({
261
- id,
262
- kind: "user",
263
- // Same fallback pairs as the grantee rows.
264
- ...userDisplayPair(assigneeIdentityFacts(assignee), objRefToString(assignee.ref)),
265
- })),
266
- groups: selectable
267
- .filter(({ assignee }) => assignee.type !== "user")
268
- .map(({ assignee, id }) => ({ id, kind: "group", name: assignee.name })),
269
- };
270
- }, [backend, workspace, target, excludedIdsKey, cacheAssignees]);
271
- // Reuse the picker's original ref (preserves UriRef vs IdentifierRef);
272
- // fall back to the serialized id only if it wasn't cached.
273
- const refForId = useCallback((id) => knownAssignees[id]?.ref ?? { identifier: id.split(":", 2)[1] }, [knownAssignees]);
217
+ }, []);
218
+ const settleRuleEdit = useCallback(() => {
219
+ setRuleEditState((current) => current ? { ...current, pending: false, settled: undefined } : current);
220
+ }, []);
221
+ const failRuleEdit = useCallback(() => {
222
+ setRuleEditState((current) => current?.settled);
223
+ }, []);
274
224
  return {
275
- targetKey,
276
225
  hasList,
277
- grantees: namedGrantees,
278
- seededWithoutGrants,
226
+ grantees,
227
+ seededWithoutSelfGrant,
279
228
  selfIdentity,
280
229
  selfIdentityResolved,
281
230
  generalAccess,
282
231
  workspaceLevel,
283
- inheritedWorkspaceLevel,
232
+ workspaceInheritedLevel,
233
+ workspaceLevelSaving,
284
234
  summary,
285
235
  status,
286
236
  loadError,
287
- accessUnavailable,
288
237
  commit,
289
238
  loadOptions,
290
- refForId,
291
- setGrantees,
292
- setGeneralAccess,
293
- setWorkspaceLevel,
239
+ applyGranteeAdd,
240
+ applyGranteeLevel,
241
+ applyGranteeRemove,
242
+ settleGranteeEdit,
243
+ failGranteeEdit,
244
+ applyRuleEdit,
245
+ settleRuleEdit,
246
+ failRuleEdit,
294
247
  };
295
248
  }
@@ -25,22 +25,27 @@ export interface ILabelScope {
25
25
  * on the "assume all labels" placeholder.
26
26
  */
27
27
  labelsResolved: boolean;
28
+ /**
29
+ * True until the session's FIRST scope resolution settles (labels metadata +
30
+ * the initial per-label probe). Unlike {@link labelsResolved} it never turns
31
+ * back on: a later re-probe (a grantee or label-set change) only re-disables
32
+ * controls. Lets the dialog hold its loading placeholders until controls are
33
+ * actionable without flashing back to them mid-session; a resolution that
34
+ * settles with failures counts too, so an error can't hold placeholders
35
+ * forever.
36
+ */
37
+ labelsInitializing: boolean;
28
38
  /** Per-grantee label scope: grantee id → label ids in scope (primary always in). */
29
39
  selectedLabelIdsByGrantee: Record<string, string[]>;
30
40
  setSelectedLabelIdsByGrantee: React.Dispatch<React.SetStateAction<Record<string, string[]>>>;
31
41
  /**
32
42
  * The single per-label write path. Diffs `desired` vs `current` over the
33
- * permissionable labels and grants/revokes for one principal. Returns false
34
- * if any write fails (callers surface the error and roll back).
35
- */
36
- reconcileLabelScope: (principal: LabelScopePrincipal, desiredLabelIds: ReadonlySet<string>, currentLabelIds: ReadonlySet<string>) => Promise<boolean>;
37
- /**
38
- * Multi-principal {@link reconcileLabelScope}: one `manageObjectPermissions`
39
- * per label carrying all principals that change on it (used by Add). Reports
40
- * `failedLabelIds` so the caller can keep the labels that landed rather than
41
- * discard the whole scope on a partial failure.
43
+ * permissionable labels and issues one `manageObjectPermissions` per changed
44
+ * label, carrying every principal that changes on it. Reports `failedLabelIds`
45
+ * so callers can keep the labels that landed rather than treat a partial
46
+ * failure as all-or-nothing (each label write settles independently).
42
47
  */
43
- reconcileLabelScopeMany: (principals: LabelScopePrincipal[], desiredLabelIds: ReadonlySet<string>, currentLabelIds: ReadonlySet<string>) => Promise<{
48
+ reconcileLabelScope: (principals: LabelScopePrincipal[], desiredLabelIds: ReadonlySet<string>, currentLabelIds: ReadonlySet<string>) => Promise<{
44
49
  ok: boolean;
45
50
  failedLabelIds: string[];
46
51
  }>;
@@ -53,10 +58,10 @@ export interface ILabelScope {
53
58
  *
54
59
  * The resolved scope is local-authoritative: the probe seeds a scope only for
55
60
  * grantees it doesn't already know, so an optimistic scope written for a freshly
56
- * added grantee is never overwritten by the backend's lagging read. Local scopes
57
- * are dropped on a target switch so the next object re-resolves from scratch.
61
+ * added grantee is never overwritten by the backend's lagging read. Session-scoped
62
+ * like its owner the target is fixed for the mount (see {@link ObjectShareDialog}).
58
63
  *
59
64
  * @internal
60
65
  */
61
- export declare function useLabelScope(target: IObjectPermissionsObject | undefined, targetKey: string | undefined, labels: IObjectShareLabel[], hasList: boolean, committedGranteeIds: string[], labelsError: boolean, labelsLoading: boolean): ILabelScope;
66
+ export declare function useLabelScope(target: IObjectPermissionsObject | undefined, labels: IObjectShareLabel[], hasList: boolean, committedGranteeIds: string[], labelsError: boolean, labelsLoading: boolean): ILabelScope;
62
67
  //# sourceMappingURL=useLabelScope.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useLabelScope.d.ts","sourceRoot":"","sources":["../../src/share/useLabelScope.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAI1E,OAAO,EACH,KAAK,mBAAmB,EAI3B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD;;;;;;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;;;;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;IACtB;;;;;OAKG;IACH,uBAAuB,EAAE,CACrB,UAAU,EAAE,mBAAmB,EAAE,EACjC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,EACpC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,KACnC,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAC3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CACzB,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,iBAAiB,EAAE,EAC3B,OAAO,EAAE,OAAO,EAChB,mBAAmB,EAAE,MAAM,EAAE,EAC7B,WAAW,EAAE,OAAO,EACpB,aAAa,EAAE,OAAO,GACvB,WAAW,CAqMb"}
1
+ {"version":3,"file":"useLabelScope.d.ts","sourceRoot":"","sources":["../../src/share/useLabelScope.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAK1E,OAAO,EACH,KAAK,mBAAmB,EAG3B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AA6BpD;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IACxB;;;;OAIG;IACH,eAAe,EAAE,iBAAiB,EAAE,CAAC;IACrC;;;;;;;;OAQG;IACH,cAAc,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B,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;;;;;;OAMG;IACH,mBAAmB,EAAE,CACjB,UAAU,EAAE,mBAAmB,EAAE,EACjC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,EACpC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,KACnC,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAC3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CACzB,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,MAAM,EAAE,iBAAiB,EAAE,EAC3B,OAAO,EAAE,OAAO,EAChB,mBAAmB,EAAE,MAAM,EAAE,EAC7B,WAAW,EAAE,OAAO,EACpB,aAAa,EAAE,OAAO,GACvB,WAAW,CAmKb"}