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