diff --git a/apps/jetstream-canvas/src/app/AppRoutes.tsx b/apps/jetstream-canvas/src/app/AppRoutes.tsx index 74b517468..45d587765 100644 --- a/apps/jetstream-canvas/src/app/AppRoutes.tsx +++ b/apps/jetstream-canvas/src/app/AppRoutes.tsx @@ -36,6 +36,23 @@ const ManagePermissionsSelection = lazy(() => const ManagePermissionsEditor = lazy(() => import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.ManagePermissionsEditor })), ); +const PermissionAnalysis = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysis })), +); +const PermissionAnalysisSelection = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisSelection })), +); +const PermissionAnalysisView = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisView })), +); + +const DataAnalysis = lazy(() => import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysis }))); +const DataAnalysisSelection = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysisSelection })), +); +const FieldUsageAnalysisView = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.FieldUsageAnalysisView })), +); const DeployMetadata = lazy(() => import('@jetstream/feature/deploy').then((module) => ({ default: module.DeployMetadata }))); const DeployMetadataSelection = lazy(() => @@ -141,6 +158,8 @@ export const AppRoutes = () => { AutomationControlEditor.preload(); } else if (location.pathname.includes('/permissions-manager')) { ManagePermissionsEditor.preload(); + } else if (location.pathname.includes('/data-analysis')) { + FieldUsageAnalysisView.preload(); } else if (location.pathname.includes('/deploy-metadata')) { DeployMetadataDeployment.preload(); } else if (location.pathname.includes('/create-fields')) { @@ -214,6 +233,30 @@ export const AppRoutes = () => { } /> } /> + + + + } + > + } /> + } /> + } /> + + + + + } + > + } /> + } /> + } /> + const ManagePermissionsEditor = lazy(() => import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.ManagePermissionsEditor })), ); +const PermissionAnalysis = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysis })), +); +const PermissionAnalysisSelection = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisSelection })), +); +const PermissionAnalysisView = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisView })), +); + +const DataAnalysis = lazy(() => import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysis }))); +const DataAnalysisSelection = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysisSelection })), +); +const FieldUsageAnalysisView = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.FieldUsageAnalysisView })), +); const DeployMetadata = lazy(() => import('@jetstream/feature/deploy').then((module) => ({ default: module.DeployMetadata }))); const DeployMetadataSelection = lazy(() => @@ -121,6 +138,8 @@ export const AppRoutes = () => { AutomationControlEditor.preload(); } else if (location.pathname.includes('/permissions-manager')) { ManagePermissionsEditor.preload(); + } else if (location.pathname.includes('/data-analysis')) { + FieldUsageAnalysisView.preload(); } else if (location.pathname.includes('/deploy-metadata')) { DeployMetadataDeployment.preload(); } else if (location.pathname.includes('/create-fields')) { @@ -197,6 +216,30 @@ export const AppRoutes = () => { } /> } /> + + + + } + > + } /> + } /> + } /> + + + + + } + > + } /> + } /> + } /> + { - logger.error('[DB] Error initializing db', ex); - }); + initDexieDb({ recordSyncEnabled }) + .then(() => pruneAnalysisJobHistory()) + .catch((ex) => { + logger.error('[DB] Error initializing db', ex); + }); }, [appInfo.serverUrl, authInfo.accessToken, authInfo.deviceId, recordSyncEnabled]); useEffect(() => { diff --git a/apps/jetstream-e2e/src/tests/app/routing.spec.ts b/apps/jetstream-e2e/src/tests/app/routing.spec.ts index c877f7a67..238c066f3 100644 --- a/apps/jetstream-e2e/src/tests/app/routing.spec.ts +++ b/apps/jetstream-e2e/src/tests/app/routing.spec.ts @@ -19,6 +19,14 @@ const testCases = [ }, { cardTitle: 'AUTOMATION', menu: 'Automation Control', items: [{ link: 'Automation Control', path: '/automation-control' }] }, { cardTitle: 'PERMISSIONS', menu: 'Manage Permissions', items: [{ link: 'Manage Permissions', path: '/permissions-manager' }] }, + { + cardTitle: 'Analysis', + menu: 'Analysis Tools', + items: [ + { link: 'Permission Analysis', path: '/permission-analysis' }, + { link: 'Data Analysis', path: '/data-analysis' }, + ], + }, { cardTitle: 'DEPLOY', menu: 'Deploy Metadata', diff --git a/apps/jetstream/src/app/AppRoutes.tsx b/apps/jetstream/src/app/AppRoutes.tsx index 65b65fd63..13fe0e99b 100644 --- a/apps/jetstream/src/app/AppRoutes.tsx +++ b/apps/jetstream/src/app/AppRoutes.tsx @@ -39,6 +39,23 @@ const ManagePermissionsSelection = lazy(() => const ManagePermissionsEditor = lazy(() => import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.ManagePermissionsEditor })), ); +const PermissionAnalysis = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysis })), +); +const PermissionAnalysisSelection = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisSelection })), +); +const PermissionAnalysisView = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisView })), +); + +const DataAnalysis = lazy(() => import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysis }))); +const DataAnalysisSelection = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysisSelection })), +); +const FieldUsageAnalysisView = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.FieldUsageAnalysisView })), +); const DeployMetadata = lazy(() => import('@jetstream/feature/deploy').then((module) => ({ default: module.DeployMetadata }))); const DeployMetadataSelection = lazy(() => @@ -109,6 +126,8 @@ export const AppRoutes = () => { AutomationControlEditor.preload(); } else if (location.pathname.includes('/permissions-manager')) { ManagePermissionsEditor.preload(); + } else if (location.pathname.includes('/permission-analysis')) { + PermissionAnalysisView.preload(); } else if (location.pathname.includes('/deploy-metadata')) { DeployMetadataDeployment.preload(); } else if (location.pathname.includes('/create-fields')) { @@ -189,6 +208,30 @@ export const AppRoutes = () => { } /> } /> + + + + } + > + } /> + } /> + } /> + + + + + } + > + } /> + } /> + } /> + { - logger.error('[DB] Error initializing db', ex); - }); + initDexieDb({ recordSyncEnabled }) + .then(() => pruneAnalysisJobHistory()) + .catch((ex) => { + logger.error('[DB] Error initializing db', ex); + }); }, [appInfo.serverUrl, recordSyncEnabled]); useEffect(() => { diff --git a/libs/features/data-analysis/eslint.config.js b/libs/features/data-analysis/eslint.config.js new file mode 100644 index 000000000..6e5180a27 --- /dev/null +++ b/libs/features/data-analysis/eslint.config.js @@ -0,0 +1,17 @@ +const baseConfig = require('../../../eslint.config.js'); + +module.exports = [ + ...baseConfig, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: {}, + }, + { + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + }, + { + files: ['**/*.js', '**/*.jsx'], + rules: {}, + }, +]; diff --git a/libs/features/data-analysis/project.json b/libs/features/data-analysis/project.json new file mode 100644 index 000000000..72b784059 --- /dev/null +++ b/libs/features/data-analysis/project.json @@ -0,0 +1,16 @@ +{ + "name": "features-data-analysis", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/features/data-analysis/src", + "projectType": "library", + "tags": ["scope:browser"], + "targets": { + "test": { + "executor": "@nx/vitest:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "{projectRoot}/../../../coverage/libs/features/data-analysis" + } + } + } +} diff --git a/libs/features/data-analysis/src/DataAnalysis.tsx b/libs/features/data-analysis/src/DataAnalysis.tsx new file mode 100644 index 000000000..ba931e24c --- /dev/null +++ b/libs/features/data-analysis/src/DataAnalysis.tsx @@ -0,0 +1,12 @@ +import { TITLES } from '@jetstream/shared/constants'; +import { useTitle } from '@jetstream/shared/ui-utils'; +import { FunctionComponent } from 'react'; +import { Outlet } from 'react-router-dom'; + +/** Route shell for **Data analysis** (field usage). */ +export const DataAnalysis: FunctionComponent = () => { + useTitle(TITLES.DATA_ANALYSIS); + return ; +}; + +export default DataAnalysis; diff --git a/libs/features/data-analysis/src/DataAnalysisSelection.tsx b/libs/features/data-analysis/src/DataAnalysisSelection.tsx new file mode 100644 index 000000000..2c8664e9e --- /dev/null +++ b/libs/features/data-analysis/src/DataAnalysisSelection.tsx @@ -0,0 +1,136 @@ +import { css } from '@emotion/react'; +import { PermissionAnalysisHistoryModal, filterPermissionsSobjects } from '@jetstream/feature/manage-permissions'; +import { AsyncJobNew, DescribeGlobalSObjectResult, FieldUsageAnalysisJob } from '@jetstream/types'; +import { fromJetstreamEvents, jobsState } from '@jetstream/ui-core'; +import { + AutoFullHeightContainer, + ConnectedSobjectListMultiSelect, + fireToast, + Icon, + Page, + PageHeader, + PageHeaderActions, + PageHeaderRow, + PageHeaderTitle, + Tooltip, +} from '@jetstream/ui'; +import { selectedOrgState } from '@jetstream/ui/app-state'; +import { atom, useAtom, useAtomValue } from 'jotai'; +import { FunctionComponent, useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { isAnalysisJobActive } from './shared/analysis-job-runtime-state'; + +const selectedSObjectsAtom = atom([]); +const sobjectsAtom = atom(null); + +/** + * Object selection for field-usage style analysis; enqueues a browser-side job via the JobWorker pattern + * and routes the user to the results view keyed by the Dexie row that will receive the final blob. + */ +export const DataAnalysisSelection: FunctionComponent = () => { + const navigate = useNavigate(); + const selectedOrg = useAtomValue(selectedOrgState); + const jobs = useAtomValue(jobsState); + const [sobjects, setSobjects] = useAtom(sobjectsAtom); + const [selectedSObjects, setSelectedSObjects] = useAtom(selectedSObjectsAtom); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + + const handleStartJob = useCallback(() => { + if (!selectedOrg || !selectedSObjects.length) { + fireToast({ message: 'Select at least one Object.', type: 'error' }); + return; + } + if (isAnalysisJobActive(jobs, selectedOrg.uniqueId, 'field_usage')) { + fireToast({ + message: 'A Field Usage job is already running for this org. Wait for it to finish before starting another.', + type: 'warning', + }); + return; + } + + const jobHistoryKey = `aj_${crypto.randomUUID()}`; + const meta: FieldUsageAnalysisJob = { + jobHistoryKey, + orgUniqueId: selectedOrg.uniqueId, + objectApiNames: selectedSObjects, + loadFullScan: false, + }; + const asyncJobNew: AsyncJobNew = { + type: 'FieldUsageAnalysis', + title: `Field Usage Analysis (${selectedSObjects.length} Object${selectedSObjects.length === 1 ? '' : 's'})`, + org: selectedOrg, + meta, + viewUrl: `/analysis?job=${encodeURIComponent(jobHistoryKey)}`, + }; + fromJetstreamEvents.emit({ type: 'newJob', payload: [asyncJobNew] }); + fireToast({ message: 'Field Usage job started. Loading results…', type: 'success' }); + navigate({ pathname: 'analysis', search: new URLSearchParams({ job: jobHistoryKey }).toString() }); + }, [jobs, navigate, selectedOrg, selectedSObjects]); + + return ( + + + + + + + + + + + + + + Select Salesforce objects, then start a job. Results are computed in your browser and persisted locally per org. + + + + {isHistoryOpen && selectedOrg && ( + setIsHistoryOpen(false)} + onSelectJob={(nextJobId) => { + setIsHistoryOpen(false); + navigate({ pathname: 'analysis', search: new URLSearchParams({ job: nextJobId }).toString() }); + }} + /> + )} + +
+ +
+
+
+ ); +}; + +export default DataAnalysisSelection; diff --git a/libs/features/data-analysis/src/FieldUsageAnalysisView.tsx b/libs/features/data-analysis/src/FieldUsageAnalysisView.tsx new file mode 100644 index 000000000..030b13f65 --- /dev/null +++ b/libs/features/data-analysis/src/FieldUsageAnalysisView.tsx @@ -0,0 +1,1567 @@ +import { css } from '@emotion/react'; +import { DeleteMetadataModal } from '@jetstream/feature/deploy'; +import { PermissionAnalysisHistoryModal } from '@jetstream/feature/manage-permissions'; +import { logger } from '@jetstream/shared/client-logger'; +import { APP_ROUTES } from '@jetstream/shared/ui-router'; +import { convertDateToLocale, formatNumber, setItemInLocalStorage } from '@jetstream/shared/ui-utils'; +import { getErrorMessage, gzipDecode, isCustomFieldApiName } from '@jetstream/shared/utils'; +import { + AutoFullHeightContainer, + ColumnWithFilter, + DataTable, + DataTableSelectedContext, + DataTree, + DropDown, + Grid, + GridCol, + Icon, + KeyboardShortcut, + Modal, + Popover, + ProgressIndicator, + ReadOnlyFormElement, + SalesforceLogin, + ScopedNotification, + SelectFormatter, + setColumnFromType, + Tabs, + Toast, + Toolbar, + ToolbarItemActions, + ToolbarItemGroup, + Tooltip, + fireToast, + salesforceLoginAndRedirect, +} from '@jetstream/ui'; +import { RequireMetadataApiBanner, fromJetstreamEvents, jobsState } from '@jetstream/ui-core'; +import { applicationCookieState, selectSkipFrontdoorAuth, selectedOrgState } from '@jetstream/ui/app-state'; +import { dexieDb } from '@jetstream/ui/db'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { isValid } from 'date-fns/isValid'; +import { parseISO } from 'date-fns/parseISO'; +import groupBy from 'lodash/groupBy'; +import { useAtom, useAtomValue } from 'jotai'; +import type { AsyncJob, AsyncJobNew, FieldUsageAnalysisJob, FieldUsageFullResult, SalesforceOrgUi } from '@jetstream/types'; +import { Fragment, FunctionComponent, type Key, MouseEvent, type ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; +import { SELECT_COLUMN_KEY, SelectColumn, type RenderGroupCellProps, type SortColumn } from 'react-data-grid'; +import { Link, useHref, useSearchParams } from 'react-router-dom'; +import { fieldUsageRowsToCustomFieldDeleteMetadata, fieldUsageRowEligibleForDestructiveDelete } from './field-usage-destructive-delete'; +import { isAnalysisJobActive } from './shared/analysis-job-runtime-state'; +import { + countWhereUsedByUiCategory, + fieldHasWhereUsedDeps, + getFieldUsageTypeLabel, + getWhereUsedDepsForFieldKey, + parseFieldUsageJobResult, + type FieldUsageJobResultParsed, + type WhereUsedDependencyRowParsed, +} from './field-usage-result-parse'; +import { getWhereUsedOpenInSalesforcePath } from './where-used-open-in-salesforce'; + +const FIELD_USAGE_TABLE_ACTION_DELETE_METADATA = 'field-usage-delete-metadata'; + +const HEIGHT_BUFFER = 170; + +/** True for local Vite dev; false in production builds — used to avoid exposing raw job payloads in prod. */ +const SHOW_RAW_JOB_JSON_UI = import.meta.env.DEV; + +/** Custom fields at or below this fill rate appear on the **Low usage** tab. */ +const LOW_USAGE_PCT_THRESHOLD = 5; + +/** One-line explainer for the Low usage tab (threshold lives in {@link LOW_USAGE_PCT_THRESHOLD}). */ +function getFieldUsageLowUsageIntroCopy(pctThreshold: number): string { + return `Unmanaged Custom Fields at or below ${pctThreshold}% population in the rows scanned for this job. Packaged Custom Fields with a namespace prefix are not listed here.`; +} + +const TREE_GROUP_BY = ['objectApiName'] as const; + +/** Tall enough for two-line Object/Field cell + padded "Where Used" control (react-data-grid clips overflow). */ +const TREE_ROW_HEIGHT_LEAF_PX = 56; +/** Single-line truncated object summary (see {@link renderFieldUsageObjectGroupCell}). */ +const TREE_ROW_HEIGHT_GROUP_PX = 48; + +/** Same reference every render — new arrays/functions here break TreeDataGrid measurement and can cause an update loop. */ +const FIELD_USAGE_DATA_TREE_GROUP_BY: readonly string[] = TREE_GROUP_BY; + +const FIELD_USAGE_TREE_INITIAL_SORT_BY_FIELD: SortColumn[] = [{ columnKey: 'fieldApiName', direction: 'ASC' }]; + +const FIELD_USAGE_TREE_INITIAL_SORT_BY_PCT: SortColumn[] = [{ columnKey: 'pct', direction: 'ASC' }]; + +function fieldUsageDataTreeRowHeight({ type }: { type: string }): number { + if (type === 'GROUP') { + return TREE_ROW_HEIGHT_GROUP_PX; + } + return TREE_ROW_HEIGHT_LEAF_PX; +} + +/** Salesforce ISO timestamps shown in the browser locale/time zone (same as {@link convertDateToLocale} elsewhere). */ +function formatFieldUsageLatestModifiedCell(raw: string | null): string { + if (raw == null || raw === '') { + return '—'; + } + const parsed = parseISO(raw); + if (!isValid(parsed)) { + return raw; + } + return convertDateToLocale(raw) ?? raw; +} + +/** Leaf rows for {@link DataTree}, grouped by {@link FieldUsageTreeRow.objectApiName} (same pattern as field permissions editor). */ +interface FieldUsageTreeRow { + _key: string; + objectApiName: string; + objectLabel: string; + objectTotalRecords: number; + objectQueryTruncated: boolean; + objectCustomizable: boolean; + objectError?: string; + fieldApiName: string; + fieldLabel: string; + /** Describe-style type label ({@link getFieldUsageTypeLabel}). */ + type: string; + custom: boolean; + filled: number; + pct: number; + latestModified: string | null; + /** Synthetic row when the object payload has `error` and no field stats. */ + isObjectErrorPlaceholder?: boolean; + /** Unmanaged custom field (no namespace prefix) eligible for destructive delete; same rules as `isUnmanagedCustomFieldApiName` in shared utils. */ + destructiveDeleteEligible?: boolean; + /** Where Used row counts by Kind: Layout, Automation, Apex ({@link countWhereUsedByUiCategory}). */ + whereUsedOnLayout: number; + whereUsedInAutomation: number; + whereUsedInApex: number; +} + +function whereUsedUiCountsForField( + whereUsed: FieldUsageJobResultParsed['whereUsed'] | undefined, + objectApiName: string, + fieldApiName: string, +): { whereUsedOnLayout: number; whereUsedInAutomation: number; whereUsedInApex: number } { + if (!whereUsed || !isCustomFieldApiName(fieldApiName)) { + return { whereUsedOnLayout: 0, whereUsedInAutomation: 0, whereUsedInApex: 0 }; + } + const { onLayout, inAutomation, inApex } = countWhereUsedByUiCategory( + getWhereUsedDepsForFieldKey(whereUsed, `${objectApiName}.${fieldApiName}`), + ); + return { whereUsedOnLayout: onLayout, whereUsedInAutomation: inAutomation, whereUsedInApex: inApex }; +} + +/** Lightning Setup → Object Manager deep link (same pattern as permission analysis export). */ +function fieldUsageObjectManagerReturnUrl(objectApiName: string, view: 'details' | 'fields'): string { + const enc = encodeURIComponent(objectApiName); + if (view === 'fields') { + return `/lightning/setup/ObjectManager/${enc}/FieldsAndRelationships/view`; + } + return `/lightning/setup/ObjectManager/${enc}/Details/view`; +} + +/** SOQL for Query Records: analyzed fields plus Id / LastModifiedDate, no WHERE (same shape as field usage scan). */ +function buildFieldUsageObjectQuerySoql(objectApiName: string, childRows: readonly FieldUsageTreeRow[]): string { + const analyzedFields = childRows + .filter((row) => !row.isObjectErrorPlaceholder && row.objectApiName === objectApiName && row.fieldApiName && row.fieldApiName !== '—') + .map((row) => row.fieldApiName); + const orderedUnique = [...new Set(analyzedFields)].sort((a, b) => a.localeCompare(b)); + const selectList: string[] = ['Id', 'LastModifiedDate']; + for (const fieldName of orderedUnique) { + if (fieldName !== 'Id' && fieldName !== 'LastModifiedDate') { + selectList.push(fieldName); + } + } + return `SELECT ${selectList.join(', ')} FROM ${objectApiName}`; +} + +/** + * Opens Query Results in a new tab. Writes initial SOQL to `localStorage` under key `query` because `location.state` + * is not passed through `window.open`, and `sessionStorage` is not visible in the new tab (each top-level tab has its + * own session storage). {@link QueryResults} reads this handoff and clears `localStorage` after applying. + * `queryResultsHref` must come from {@link useHref} so the path includes the app router basename (e.g. `/app`). + */ +function openFieldUsageObjectQueryInNewTab( + objectApiName: string, + objectLabel: string, + childRows: readonly FieldUsageTreeRow[], + queryResultsHref: string, +): void { + const soql = buildFieldUsageObjectQuerySoql(objectApiName, childRows); + setItemInLocalStorage( + 'query', + JSON.stringify({ + soql, + isTooling: false, + sobject: { name: objectApiName, label: objectLabel }, + }), + ); + window.open(queryResultsHref, '_blank', 'noopener,noreferrer'); +} + +const FIELD_USAGE_POPOVER_PANEL_PROPS = { + onDoubleClick: (event: MouseEvent) => { + event.stopPropagation(); + }, +}; + +interface FieldUsageOrgLoginProps { + serverUrl: string | undefined; + org: SalesforceOrgUi | null | undefined; + skipFrontDoorAuth: boolean; +} + +function FieldUsageObjectGroupCell( + props: RenderGroupCellProps & + FieldUsageOrgLoginProps & { + /** From parent {@link useHref}; one value for all groups (avoids N identical hook calls). */ + queryResultsHref: string; + }, +): ReactElement { + const { groupKey, childRows, serverUrl, org, skipFrontDoorAuth, queryResultsHref } = props; + const api = String(groupKey); + const sample = childRows[0]; + const label = sample?.objectLabel?.trim() ? sample.objectLabel.trim() : api; + const analyzedFieldCount = childRows.filter((row) => !row.isObjectErrorPlaceholder).length; + const rowCount = sample?.objectTotalRecords ?? 0; + const slug = api.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const returnUrl = fieldUsageObjectManagerReturnUrl(api, 'details'); + const canDeepLink = Boolean(org?.uniqueId && serverUrl); + + const handleOpenQueryResults = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + openFieldUsageObjectQueryInNewTab(api, label, childRows, queryResultsHref); + }, + [api, label, childRows, queryResultsHref], + ); + + return ( + + + + } + content={ +
+ {canDeepLink && org && serverUrl ? ( + + View in Salesforce + + ) : null} + + + + + + + + + + + + + + + + + {canDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+
+ } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + width: '100%', + height: '100%', + alignItems: 'center', + display: 'flex', + lineHeight: 1.25, + padding: '0.25rem 0.5rem 0.25rem 0.25rem', + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink || !org || !serverUrl) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl, + org, + returnUrl, + skipFrontDoorAuth, + }); + } + }} + > + {label} {api} + + {' '} + · {formatNumber(analyzedFieldCount)} field{analyzedFieldCount === 1 ? '' : 's'} + {' · '} + {formatNumber(rowCount)} rows scanned + {sample?.objectQueryTruncated ? ' · truncated' : ''} + {sample?.objectCustomizable ? '' : ' · not customizable'} + + {sample?.objectError ? · {sample.objectError} : null} + +
+ ); +} + +function FieldUsageFieldNameCell({ + row, + serverUrl, + org, + skipFrontDoorAuth, +}: { + row: FieldUsageTreeRow; +} & FieldUsageOrgLoginProps): ReactElement { + const slug = `${row.objectApiName}-${row.fieldApiName}`.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const returnUrl = fieldUsageObjectManagerReturnUrl(row.objectApiName, 'fields'); + const canDeepLink = Boolean(org?.uniqueId && serverUrl); + + return ( + + {canDeepLink && org && serverUrl ? ( + + View in Salesforce + + ) : null} + + + + + + + + + + + {canDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+ + } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ width: '100%', height: '100%', padding: 0 }} + > +
) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink || !org || !serverUrl) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl, + org, + returnUrl, + skipFrontDoorAuth, + }); + } + }} + > +
+ {row.fieldApiName} +
+
{row.fieldLabel}
+
+
+ ); +} + +interface WhereUsedTableRow { + _key: string; + componentType: string; + componentName: string; + kindLabel: string; + /** Flow `VersionNumber` when known; em dash otherwise. */ + flowVersionLabel: string; + /** Relative path for Salesforce login link when opening the dependency in the org. */ + openInSalesforcePath: string | null; +} + +function formatJobResultJson(result: unknown): string { + try { + return JSON.stringify(result, null, 2); + } catch { + return String(result); + } +} + +/** + * Lazily stringifies the result when the Raw JSON tab actually renders. Inlining the stringify in the + * `resultTabs` useMemo would re-run on every memo dep change even if the tab isn't active — for very + * large blobs this is noticeable jank when toggling filters. + */ +const RawJsonTabContent: FunctionComponent<{ result: unknown }> = ({ result }) => { + const formatted = useMemo(() => formatJobResultJson(result), [result]); + return ( +
+
+        {formatted}
+      
+
+ ); +}; + +/** + * Field usage results workspace. Subscribes to the in-flight job entry (jotai jobsState) for progress + * and to Dexie `analysis_job_history` for the terminal row; no HTTP polling. Result decoding happens + * once per Dexie row (gzip decompress) and feeds the existing field-usage parser. + */ +export const FieldUsageAnalysisView: FunctionComponent = () => { + const selectedOrg = useAtomValue(selectedOrgState); + const [{ serverUrl }] = useAtom(applicationCookieState); + const skipFrontDoorAuth = useAtomValue(selectSkipFrontdoorAuth); + const jobs = useAtomValue(jobsState); + const [searchParams, setSearchParams] = useSearchParams(); + const jobId = searchParams.get('job'); + + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [loadAllRecordsModalOpen, setLoadAllRecordsModalOpen] = useState(false); + const [whereUsedForKey, setWhereUsedForKey] = useState(null); + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + const [fieldUsageSelectedRowKeys, setFieldUsageSelectedRowKeys] = useState(() => new Set()); + const [deleteFieldMetadataModalOpen, setDeleteFieldMetadataModalOpen] = useState(false); + const [decodedFullResult, setDecodedFullResult] = useState(null); + const [decodeError, setDecodeError] = useState(null); + + const fieldUsageQueryResultsHref = useHref({ + pathname: `${APP_ROUTES.QUERY.ROUTE}/results`, + ...(APP_ROUTES.QUERY.SEARCH_PARAM ? { search: `?${APP_ROUTES.QUERY.SEARCH_PARAM}` } : {}), + }); + + /** + * Live in-flight AsyncJob for this jobHistoryKey, when present. Drives the progress UI before the + * Dexie terminal row lands; the Jobs popover shows the same entry. + */ + const inFlightJob: AsyncJob | null = useMemo(() => { + if (!jobId) { + return null; + } + for (const candidate of Object.values(jobs)) { + if (candidate.type !== 'FieldUsageAnalysis') { + continue; + } + const meta = candidate.meta as FieldUsageAnalysisJob | undefined; + if (meta?.jobHistoryKey === jobId) { + return candidate as AsyncJob; + } + } + return null; + }, [jobs, jobId]); + + const inFlightStatus = inFlightJob?.status; + const isJobRunning = inFlightStatus === 'pending' || inFlightStatus === 'in-progress'; + + /** + * Terminal Dexie row for this jobHistoryKey, kept reactive via useLiveQuery so the view updates + * the moment the JobWorker writes the row. + */ + const historyRow = useLiveQuery(() => (jobId ? dexieDb.analysis_job_history.get(jobId) : undefined), [jobId]); + + useEffect(() => { + // Eagerly drop the prior decoded payload so switching between large completed runs doesn't keep both + // the old and new uncompressed blobs in memory while the new gunzip resolves. + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale decoded payload when job/row changes + setDecodedFullResult(null); + setDecodeError(null); + if (!historyRow || historyRow.status !== 'completed' || !historyRow.resultBlob) { + return; + } + let cancelled = false; + gzipDecode(historyRow.resultBlob) + .then((decoded) => { + if (!cancelled) { + setDecodedFullResult(decoded); + } + }) + .catch((ex) => { + if (!cancelled) { + logger.error('Failed to decode field_usage history blob', ex); + setDecodeError(getErrorMessage(ex)); + } + }); + return () => { + cancelled = true; + }; + }, [historyRow]); + + // Derived status / errors used by the existing render branches. + const jobStatusNormalized = useMemo(() => { + if (historyRow?.status === 'completed' || historyRow?.status === 'failed') { + return historyRow.status; + } + if (isJobRunning) { + return 'running'; + } + if (inFlightStatus === 'failed' || inFlightStatus === 'aborted') { + return 'failed'; + } + return ''; + }, [historyRow?.status, isJobRunning, inFlightStatus]); + const isTerminal = jobStatusNormalized === 'completed' || jobStatusNormalized === 'failed'; + const fetchError = decodeError; + const terminalErrorMessage = historyRow?.errorMessage ?? inFlightJob?.statusMessage ?? null; + const liveProgress = inFlightJob?.progress; + const isFieldUsageJobActiveForOrg = selectedOrg ? isAnalysisJobActive(jobs, selectedOrg.uniqueId, 'field_usage') : false; + + /** Must be memoized: a fresh object every render makes `[parsedResult]` effects run forever. */ + const parsedResult: FieldUsageJobResultParsed | null = useMemo(() => { + if (jobStatusNormalized !== 'completed' || !decodedFullResult) { + return null; + } + return parseFieldUsageJobResult(decodedFullResult); + }, [jobStatusNormalized, decodedFullResult]); + + useEffect(() => { + if (!parsedResult) { + return; + } + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset expansion when a new job result loads so object groups start expanded + setExpandedGroupIds(new Set(Object.keys(parsedResult.objects))); + }, [parsedResult]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- new job result invalidates row selection + setFieldUsageSelectedRowKeys(new Set()); + }, [parsedResult]); + + const treeFieldRows: FieldUsageTreeRow[] = useMemo(() => { + if (!parsedResult) { + return []; + } + const rows: FieldUsageTreeRow[] = []; + for (const objectApiName of Object.keys(parsedResult.objects).sort((a, b) => a.localeCompare(b))) { + const payload = parsedResult.objects[objectApiName]; + const base = { + objectApiName, + objectLabel: payload.label, + objectTotalRecords: payload.totalRecords, + objectQueryTruncated: payload.queryTruncated, + objectCustomizable: payload.customizable, + ...(payload.error ? { objectError: payload.error } : {}), + }; + if (payload.error) { + rows.push({ + _key: `${objectApiName}::__error__`, + ...base, + fieldApiName: '—', + fieldLabel: payload.error, + type: '', + custom: false, + filled: 0, + pct: 0, + latestModified: null, + isObjectErrorPlaceholder: true, + destructiveDeleteEligible: false, + whereUsedOnLayout: 0, + whereUsedInAutomation: 0, + whereUsedInApex: 0, + }); + continue; + } + for (const fieldApiName of Object.keys(payload.fieldUsage).sort((a, b) => a.localeCompare(b))) { + const stat = payload.fieldUsage[fieldApiName]; + const meta = payload.fieldMeta[fieldApiName]; + const destructiveDeleteEligible = fieldUsageRowEligibleForDestructiveDelete({ + isObjectErrorPlaceholder: false, + fieldApiName, + meta, + }); + rows.push({ + _key: `${objectApiName}.${fieldApiName}`, + ...base, + fieldApiName, + fieldLabel: meta?.label ?? fieldApiName, + type: getFieldUsageTypeLabel(meta), + custom: meta?.custom ?? false, + filled: stat.filled, + pct: stat.pct, + latestModified: stat.latestFilledRowModified, + destructiveDeleteEligible, + ...whereUsedUiCountsForField(parsedResult.whereUsed, objectApiName, fieldApiName), + }); + } + } + return rows; + }, [parsedResult]); + + const getTreeRowKey = useCallback((row: FieldUsageTreeRow) => row._key, []); + + const objectsTabTotals = useMemo(() => { + const objectCount = parsedResult ? Object.keys(parsedResult.objects).length : 0; + const analyzedFieldCount = treeFieldRows.filter((row) => !row.isObjectErrorPlaceholder).length; + return { objectCount, analyzedFieldCount }; + }, [parsedResult, treeFieldRows]); + + const fieldUsageReloadObjectApiNames = useMemo(() => { + if (!parsedResult) { + return []; + } + return Object.keys(parsedResult.objects).sort((a, b) => a.localeCompare(b)); + }, [parsedResult]); + + const canLoadAllRecords = parsedResult?.truncated === true && fieldUsageReloadObjectApiNames.length > 0 && Boolean(selectedOrg?.uniqueId); + + const handleConfirmLoadAllRecords = useCallback(() => { + if (!selectedOrg || fieldUsageReloadObjectApiNames.length === 0) { + return; + } + if (isAnalysisJobActive(jobs, selectedOrg.uniqueId, 'field_usage')) { + fireToast({ + message: 'A Field Usage job is already running for this org. Wait for it to finish before starting another.', + type: 'warning', + }); + return; + } + + const newJobHistoryKey = `aj_${crypto.randomUUID()}`; + const meta: FieldUsageAnalysisJob = { + jobHistoryKey: newJobHistoryKey, + orgUniqueId: selectedOrg.uniqueId, + objectApiNames: fieldUsageReloadObjectApiNames, + loadFullScan: true, + }; + const asyncJobNew: AsyncJobNew = { + type: 'FieldUsageAnalysis', + title: `Field Usage Full Scan (${fieldUsageReloadObjectApiNames.length} Object${fieldUsageReloadObjectApiNames.length === 1 ? '' : 's'})`, + org: selectedOrg, + meta, + viewUrl: `/analysis?job=${encodeURIComponent(newJobHistoryKey)}`, + }; + fromJetstreamEvents.emit({ type: 'newJob', payload: [asyncJobNew] }); + setLoadAllRecordsModalOpen(false); + fireToast({ message: 'Full scan job started. Loading results…', type: 'success' }); + setSearchParams({ job: newJobHistoryKey }, { replace: true }); + }, [jobs, selectedOrg, fieldUsageReloadObjectApiNames, setSearchParams]); + + const lowUsageTreeRows: FieldUsageTreeRow[] = useMemo(() => { + if (!parsedResult) { + return []; + } + const rows: FieldUsageTreeRow[] = []; + for (const objectApiName of Object.keys(parsedResult.objects).sort()) { + const payload = parsedResult.objects[objectApiName]; + if (!payload || payload.error) { + continue; + } + for (const fieldApiName of Object.keys(payload.fieldUsage)) { + const stat = payload.fieldUsage[fieldApiName]; + const meta = payload.fieldMeta[fieldApiName]; + if (!meta?.custom) { + continue; + } + if (stat.pct > LOW_USAGE_PCT_THRESHOLD) { + continue; + } + rows.push({ + _key: `${objectApiName}.${fieldApiName}`, + objectApiName, + objectLabel: payload.label, + objectTotalRecords: payload.totalRecords, + objectQueryTruncated: payload.queryTruncated, + objectCustomizable: payload.customizable, + fieldApiName, + fieldLabel: meta.label ?? fieldApiName, + type: getFieldUsageTypeLabel(meta), + custom: true, + filled: stat.filled, + pct: stat.pct, + latestModified: stat.latestFilledRowModified, + destructiveDeleteEligible: fieldUsageRowEligibleForDestructiveDelete({ + isObjectErrorPlaceholder: false, + fieldApiName, + meta, + }), + ...whereUsedUiCountsForField(parsedResult.whereUsed, objectApiName, fieldApiName), + }); + } + } + rows.sort((a, b) => a.pct - b.pct || a.objectApiName.localeCompare(b.objectApiName)); + return rows; + }, [parsedResult]); + + const fieldUsageRowByKey = useMemo(() => { + const map = new Map(); + for (const row of treeFieldRows) { + map.set(row._key, row); + } + for (const row of lowUsageTreeRows) { + map.set(row._key, row); + } + return map; + }, [treeFieldRows, lowUsageTreeRows]); + + const fieldUsageSelectedDestructiveDeleteCount = useMemo(() => { + let count = 0; + for (const key of fieldUsageSelectedRowKeys) { + if (fieldUsageRowByKey.get(key)?.destructiveDeleteEligible) { + count += 1; + } + } + return count; + }, [fieldUsageRowByKey, fieldUsageSelectedRowKeys]); + + const fieldUsageDeleteSelectedMetadata = useMemo(() => { + const rows = Array.from(fieldUsageSelectedRowKeys) + .map((key) => fieldUsageRowByKey.get(key)) + .filter((row): row is FieldUsageTreeRow => Boolean(row)); + return fieldUsageRowsToCustomFieldDeleteMetadata(rows); + }, [fieldUsageRowByKey, fieldUsageSelectedRowKeys]); + + const handleFieldUsageToolbarDropdown = useCallback( + (actionId: string) => { + if (actionId === FIELD_USAGE_TABLE_ACTION_DELETE_METADATA) { + if (fieldUsageSelectedDestructiveDeleteCount === 0) { + return; + } + setDeleteFieldMetadataModalOpen(true); + } + }, + [fieldUsageSelectedDestructiveDeleteCount], + ); + + const handleFieldUsageSelectedRowsChange = useCallback((next: Set) => { + setFieldUsageSelectedRowKeys(new Set(Array.from(next, (key) => String(key)))); + }, []); + + const whereUsedRows: WhereUsedTableRow[] = useMemo(() => { + if (!parsedResult || !whereUsedForKey) { + return []; + } + const deps: WhereUsedDependencyRowParsed[] = getWhereUsedDepsForFieldKey(parsedResult.whereUsed, whereUsedForKey); + return deps.map((row, index) => { + const fv = row.flowVersionNumber; + const flowVersionLabel = row.type.trim() === 'Flow' && fv != null && Number.isFinite(Number(fv)) ? String(fv) : '—'; + return { + _key: `${row.type}:${row.name}:${String(index)}`, + componentType: row.type, + componentName: row.name, + kindLabel: row.kind === 'automation' ? 'Automation' : row.kind === 'apex' ? 'Apex' : row.kind === 'layout' ? 'Layout' : 'Other', + flowVersionLabel, + openInSalesforcePath: getWhereUsedOpenInSalesforcePath(row), + }; + }); + }, [parsedResult, whereUsedForKey]); + + const treeColumns: ColumnWithFilter[] = useMemo( + () => [ + { + ...SelectColumn, + key: SELECT_COLUMN_KEY, + resizable: false, + sortable: false, + minWidth: 36, + width: 40, + maxWidth: 44, + renderCell: (args) => { + if (!args.row.destructiveDeleteEligible) { + return null; + } + return SelectColumn.renderCell?.(args) || ; + }, + renderGroupCell: () => null, + }, + { + ...setColumnFromType('objectApiName', 'text'), + name: '', + key: 'objectApiName', + width: 40, + minWidth: 36, + maxWidth: 44, + resizable: false, + sortable: false, + renderGroupCell: ({ isExpanded, toggleGroup }) => ( + + + + ), + renderCell: () => null, + }, + { + ...setColumnFromType('fieldApiName', 'text'), + name: 'Object / Field', + key: 'fieldApiName', + width: 340, + minWidth: 200, + renderGroupCell: (groupProps) => ( + + ), + renderCell: (p) => + p.row.isObjectErrorPlaceholder ? ( + {p.row.fieldLabel} + ) : ( + + ), + getValue: ({ row }) => `${row.fieldApiName} ${row.fieldLabel}`, + }, + { + ...setColumnFromType('type', 'text'), + name: 'Type', + key: 'type', + width: 200, + minWidth: 160, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : p.row.type}, + getValue: ({ row }) => (row.isObjectErrorPlaceholder ? '' : row.type), + }, + { + ...setColumnFromType('custom', 'text'), + name: 'Custom Field', + key: 'custom', + width: 100, + minWidth: 80, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : p.row.custom ? 'Yes' : 'No'}, + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return row.custom ? 'Yes' : 'No'; + }, + }, + { + ...setColumnFromType('filled', 'number'), + name: 'Filled', + key: 'filled', + width: 80, + minWidth: 80, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : formatNumber(p.row.filled)}, + getValue: ({ row }) => (row.isObjectErrorPlaceholder ? '' : formatNumber(row.filled)), + }, + { + ...setColumnFromType('pct', 'number'), + name: '% Filled', + key: 'pct', + width: 100, + minWidth: 100, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : `${p.row.pct.toFixed(1)}%`}, + getValue: ({ row }) => (row.isObjectErrorPlaceholder ? '' : `${row.pct.toFixed(1)}%`), + }, + { + ...setColumnFromType('latestModified', 'text'), + name: 'Latest Value Row Modified', + key: 'latestModified', + width: 220, + minWidth: 100, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : formatFieldUsageLatestModifiedCell(p.row.latestModified)}, + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return formatFieldUsageLatestModifiedCell(row.latestModified); + }, + }, + { + ...setColumnFromType('whereUsedOnLayout', 'number'), + name: 'On Layout', + key: 'whereUsedOnLayout', + width: 120, + minWidth: 100, + renderGroupCell: () => null, + renderCell: (p) => ( + {p.row.isObjectErrorPlaceholder ? '' : p.row.whereUsedOnLayout > 0 ? formatNumber(p.row.whereUsedOnLayout) : '—'} + ), + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return row.whereUsedOnLayout > 0 ? String(row.whereUsedOnLayout) : ''; + }, + }, + { + ...setColumnFromType('whereUsedInAutomation', 'number'), + name: 'In Automation', + key: 'whereUsedInAutomation', + width: 140, + minWidth: 140, + renderGroupCell: () => null, + renderCell: (p) => ( + + {p.row.isObjectErrorPlaceholder ? '' : p.row.whereUsedInAutomation > 0 ? formatNumber(p.row.whereUsedInAutomation) : '—'} + + ), + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return row.whereUsedInAutomation > 0 ? String(row.whereUsedInAutomation) : ''; + }, + }, + { + ...setColumnFromType('whereUsedInApex', 'number'), + name: 'In Apex', + key: 'whereUsedInApex', + width: 100, + minWidth: 100, + renderGroupCell: () => null, + renderCell: (p) => ( + {p.row.isObjectErrorPlaceholder ? '' : p.row.whereUsedInApex > 0 ? formatNumber(p.row.whereUsedInApex) : '—'} + ), + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return row.whereUsedInApex > 0 ? String(row.whereUsedInApex) : ''; + }, + }, + { + ...setColumnFromType('whereUsed', 'text'), + name: '', + key: 'whereUsed', + width: 188, + minWidth: 160, + sortable: false, + renderGroupCell: () => null, + renderCell: (p) => { + if (p.row.isObjectErrorPlaceholder || !isCustomFieldApiName(p.row.fieldApiName)) { + return ; + } + const fieldKey = `${p.row.objectApiName}.${p.row.fieldApiName}`; + if (!parsedResult || !fieldHasWhereUsedDeps(parsedResult.whereUsed, fieldKey)) { + return ; + } + return ( +
+ +
+ ); + }, + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder || !isCustomFieldApiName(row.fieldApiName)) { + return '—'; + } + const fieldKey = `${row.objectApiName}.${row.fieldApiName}`; + if (!parsedResult || !fieldHasWhereUsedDeps(parsedResult.whereUsed, fieldKey)) { + return '—'; + } + return 'Where Used'; + }, + }, + ], + [parsedResult, serverUrl, selectedOrg, skipFrontDoorAuth, fieldUsageQueryResultsHref], + ); + + const whereUsedColumns: ColumnWithFilter[] = useMemo( + () => [ + { + ...setColumnFromType('componentType', 'text'), + name: 'Metadata Type', + key: 'componentType', + width: 160, + maxWidth: 220, + }, + { + ...setColumnFromType('componentName', 'text'), + name: 'Name', + key: 'componentName', + width: 480, + minWidth: 280, + }, + { + ...setColumnFromType('flowVersionLabel', 'text'), + name: 'Flow Ver.', + key: 'flowVersionLabel', + width: 88, + minWidth: 72, + maxWidth: 100, + }, + { + ...setColumnFromType('kindLabel', 'text'), + name: 'Kind', + key: 'kindLabel', + width: 120, + maxWidth: 140, + }, + { + ...setColumnFromType('openInSalesforcePath', 'text'), + name: 'Open', + key: 'openInSalesforcePath', + width: 108, + minWidth: 96, + sortable: false, + renderCell: (p) => { + const returnUrl = p.row.openInSalesforcePath; + if (!returnUrl || !selectedOrg?.uniqueId || !serverUrl) { + return ; + } + return ( + + Open + + ); + }, + getValue: ({ row }) => (row.openInSalesforcePath ? 'Open' : ''), + }, + ], + [selectedOrg, serverUrl, skipFrontDoorAuth], + ); + + const resultTabs = useMemo(() => { + if (!parsedResult) { + return null; + } + return [ + { + id: 'objects', + title: ( + + + + + Objects & Fields + + ), + titleText: 'Objects & Fields', + content: ( +
+ {parsedResult.truncated && ( +
+ + At least one Object hit the row scan cap; percentages reflect scanned rows only. + +
+ )} + {treeFieldRows.length === 0 ? ( + No Object rows in this result. + ) : ( + +

+ {formatNumber(objectsTabTotals.analyzedFieldCount)} analyzed field + {objectsTabTotals.analyzedFieldCount === 1 ? '' : 's'} across {formatNumber(objectsTabTotals.objectCount)} Object + {objectsTabTotals.objectCount === 1 ? '' : 's'}. +

+ +
+ )} +
+ ), + }, + { + id: 'low-usage', + title: ( + + + + + Low Usage (≤{LOW_USAGE_PCT_THRESHOLD}%) + + ), + titleText: 'Low Usage Custom Fields', + content: ( +
+

+ {getFieldUsageLowUsageIntroCopy(LOW_USAGE_PCT_THRESHOLD)} +

+ {lowUsageTreeRows.length === 0 ? ( + + No unmanaged Custom Fields at or below {LOW_USAGE_PCT_THRESHOLD}% population for the objects in this scan. + + ) : ( + + + + )} +
+ ), + }, + ...(SHOW_RAW_JOB_JSON_UI + ? [ + { + id: 'raw-json', + title: ( + + + + + Raw JSON + + ), + titleText: 'Raw JSON', + content: , + }, + ] + : []), + ]; + }, [ + parsedResult, + treeFieldRows, + treeColumns, + lowUsageTreeRows, + expandedGroupIds, + getTreeRowKey, + objectsTabTotals, + decodedFullResult, + fieldUsageSelectedRowKeys, + handleFieldUsageSelectedRowsChange, + selectedOrg, + serverUrl, + skipFrontDoorAuth, + ]); + + return ( +
+ + +
+
+ + + + Go Back + + +
+
+
+ + {parsedResult && treeFieldRows.some((row) => !row.isObjectErrorPlaceholder) && ( + + } + position="right" + actionText="Field Actions" + items={[ + { + id: FIELD_USAGE_TABLE_ACTION_DELETE_METADATA, + subheader: 'Deploy Actions', + icon: { type: 'utility', icon: 'delete', description: 'Delete Selected Custom Fields' }, + value: 'Delete Selected Metadata', + disabled: fieldUsageSelectedDestructiveDeleteCount === 0, + title: + fieldUsageSelectedDestructiveDeleteCount === 0 + ? 'Select unmanaged Custom Fields (no namespace prefix) on the Objects & Fields or Low Usage tab' + : 'Same destructive deploy flow as Deploy Metadata (validate or delete from this org)', + }, + ]} + onSelected={handleFieldUsageToolbarDropdown} + /> + )} + + + + + + + +
+
+ + {deleteFieldMetadataModalOpen && selectedOrg && Object.keys(fieldUsageDeleteSelectedMetadata).length > 0 && ( + setDeleteFieldMetadataModalOpen(false)} + /> + )} + {loadAllRecordsModalOpen && ( + setLoadAllRecordsModalOpen(false)} + footer={ + + + + + } + > +
+ + This runs a full row scan for each Object in this job. It can take a long time and use many Salesforce API calls (REST query + and queryMore), counting against your org's daily limits. + +

+ A new analysis job will start. When it completes, this page will show that job's results. +

+
+
+ )} + {isHistoryOpen && selectedOrg && ( + setIsHistoryOpen(false)} + onSelectJob={(nextJobId) => { + setSearchParams({ job: nextJobId }, { replace: true }); + }} + /> + )} + {whereUsedForKey && ( + setWhereUsedForKey(null)} + footer={ + + } + > + {whereUsedRows.length === 0 ? ( +

+ No dependency rows were returned for this field (or Where Used could not be computed for this org). +

+ ) : ( +
+
+ + row._key} + includeQuickFilter + rowHeight={34} + /> + +
+
+ )} +
+ )} + + {!jobId && ( +
+ + No analysis job is linked to this page. Start a Field Usage job from Data Analysis, then you will be redirected here + automatically. + +
+ )} + {jobId && fetchError && {fetchError}} + {jobId && !fetchError && jobStatusNormalized === 'failed' && terminalErrorMessage != null && ( +
+ {terminalErrorMessage} +
+ )} + {jobId && !fetchError && !isTerminal && ( +
+

Field usage analysis in progress…

+

+ {isJobRunning && liveProgress?.label ? liveProgress.label : 'Preparing'} + {isJobRunning && liveProgress && liveProgress.total > 0 + ? ` — object ${formatNumber(liveProgress.current)} of ${formatNumber(liveProgress.total)}` + : ''} +

+ +

+ You can leave this page — the job will keep running and you'll find it in the Jobs popover. +

+
+ )} + {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && !parsedResult && ( +
+ + This job completed but the result payload is not a recognized Field Usage envelope (missing `phase: field_usage_v1`). + +
+ )} + {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && parsedResult && resultTabs && ( + + +
+

+ Summary + {parsedResult.summary} +

+
+ {parsedResult.failedObjects.length > 0 && ( +
+ Objects with errors: {parsedResult.failedObjects.join(', ')}. +
+ )} + tab.id).join('|')} initialActiveId="objects" tabs={resultTabs} /> +
+
+ )} +
+
+ ); +}; + +export default FieldUsageAnalysisView; diff --git a/libs/features/data-analysis/src/__tests__/field-usage-destructive-delete.spec.ts b/libs/features/data-analysis/src/__tests__/field-usage-destructive-delete.spec.ts new file mode 100644 index 000000000..d1cd86042 --- /dev/null +++ b/libs/features/data-analysis/src/__tests__/field-usage-destructive-delete.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { fieldUsageRowEligibleForDestructiveDelete, fieldUsageRowsToCustomFieldDeleteMetadata } from '../field-usage-destructive-delete'; +import type { FieldUsageFieldMetaParsed } from '../field-usage-result-parse'; + +const customMeta = (overrides: Partial = {}): FieldUsageFieldMetaParsed => ({ + label: 'Test', + calculated: false, + type: 'string', + custom: true, + ...overrides, +}); + +describe('fieldUsageRowEligibleForDestructiveDelete', () => { + it('rejects placeholders, non-custom, name fields, non-__c, and namespaced custom fields', () => { + expect( + fieldUsageRowEligibleForDestructiveDelete({ + isObjectErrorPlaceholder: true, + fieldApiName: 'X__c', + meta: customMeta(), + }), + ).toBe(false); + + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'Name', + meta: { ...customMeta(), custom: false }, + }), + ).toBe(false); + + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'My_Field__c', + meta: customMeta({ nameField: true }), + }), + ).toBe(false); + + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'Industry', + meta: customMeta({ custom: false }), + }), + ).toBe(false); + + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'ns__F__c', + meta: customMeta(), + }), + ).toBe(false); + }); + + it('accepts unmanaged custom fields', () => { + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'Unused_Field__c', + meta: customMeta(), + }), + ).toBe(true); + }); +}); + +describe('fieldUsageRowsToCustomFieldDeleteMetadata', () => { + it('builds CustomField members with Object.Field fullName', () => { + const map = fieldUsageRowsToCustomFieldDeleteMetadata([ + { + objectApiName: 'Account', + fieldApiName: 'Jetstream_Test_Field__c', + destructiveDeleteEligible: true, + }, + { + objectApiName: 'Account', + fieldApiName: 'Industry', + destructiveDeleteEligible: false, + }, + ]); + expect(Object.keys(map)).toEqual(['CustomField']); + expect(map.CustomField).toHaveLength(1); + expect(map.CustomField[0].fullName).toBe('Account.Jetstream_Test_Field__c'); + expect(map.CustomField[0].type).toBe('CustomField'); + }); +}); diff --git a/libs/features/data-analysis/src/__tests__/where-used-open-in-salesforce.spec.ts b/libs/features/data-analysis/src/__tests__/where-used-open-in-salesforce.spec.ts new file mode 100644 index 000000000..551429967 --- /dev/null +++ b/libs/features/data-analysis/src/__tests__/where-used-open-in-salesforce.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { getWhereUsedOpenInSalesforcePath } from '../where-used-open-in-salesforce'; + +describe('getWhereUsedOpenInSalesforcePath', () => { + it('uses stored path from job when present', () => { + expect( + getWhereUsedOpenInSalesforcePath({ + type: 'Flow', + name: 'X', + kind: 'automation', + openInSalesforcePath: '/builder_platform_interaction/flowBuilder.app?flowId=abc', + }), + ).toBe('/builder_platform_interaction/flowBuilder.app?flowId=abc'); + }); + + it('builds Flow builder URL from component id when path omitted', () => { + expect( + getWhereUsedOpenInSalesforcePath({ + type: 'Flow', + name: 'My_Flow', + kind: 'automation', + componentId: '301xx000000abcd', + }), + ).toBe('/builder_platform_interaction/flowBuilder.app?flowId=301xx000000abcd'); + }); + + it('returns Process Builder home without component id', () => { + expect( + getWhereUsedOpenInSalesforcePath({ + type: 'ProcessDefinition', + name: 'P', + kind: 'automation', + }), + ).toBe('/lightning/setup/ProcessAutomation/home'); + }); +}); diff --git a/libs/features/data-analysis/src/data-analysis.spec.ts b/libs/features/data-analysis/src/data-analysis.spec.ts new file mode 100644 index 000000000..20c24ec19 --- /dev/null +++ b/libs/features/data-analysis/src/data-analysis.spec.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest'; +import { + countWhereUsedByUiCategory, + fieldHasWhereUsedDeps, + getFieldUsageTypeLabel, + getWhereUsedDepsForFieldKey, + parseFieldUsageJobResult, +} from './field-usage-result-parse'; + +describe('@jetstream/feature/data-analysis', () => { + it('parseFieldUsageJobResult returns null for wrong phase', () => { + expect(parseFieldUsageJobResult({ phase: 'permission_export_v1', objects: {} })).toBeNull(); + expect(parseFieldUsageJobResult(null)).toBeNull(); + }); + + it('parseFieldUsageJobResult parses field_usage_v1 envelope', () => { + const parsed = parseFieldUsageJobResult({ + phase: 'field_usage_v1', + summary: 'ok', + truncated: false, + failedObjects: [], + whereUsed: { + 'Custom__c.Field__c': [{ type: 'Flow', name: 'My_Flow', kind: 'automation' }], + }, + objects: { + Custom__c: { + label: 'Custom', + customizable: true, + totalRecords: 10, + queryTruncated: false, + fieldUsage: { + Field__c: { filled: 2, pct: 20, latestFilledRowModified: null }, + }, + fieldMeta: { + Field__c: { label: 'Field', calculated: false, type: 'string', custom: true, length: 255 }, + }, + }, + }, + }); + expect(parsed).not.toBeNull(); + expect(parsed?.phase).toBe('field_usage_v1'); + expect(parsed?.objects.Custom__c.fieldUsage.Field__c.pct).toBe(20); + expect(parsed?.whereUsed['Custom__c.Field__c']).toHaveLength(1); + expect(parsed?.whereUsed['Custom__c.Field__c'][0].kind).toBe('automation'); + }); + + it('parseFieldUsageJobResult infers apex/automation from metadata type when kind is other (legacy payloads)', () => { + const parsed = parseFieldUsageJobResult({ + phase: 'field_usage_v1', + summary: 'ok', + truncated: false, + failedObjects: [], + whereUsed: { + 'O__c.F__c': [ + { type: 'ApexClass', name: 'Foo', kind: 'other' }, + { type: 'Flow', name: 'Bar', kind: 'other' }, + ], + }, + objects: { + O__c: { + label: 'O', + customizable: true, + totalRecords: 0, + queryTruncated: false, + fieldUsage: { F__c: { filled: 0, pct: 0, latestFilledRowModified: null } }, + fieldMeta: { + F__c: { label: 'F', calculated: false, type: 'string', custom: true, length: 10 }, + }, + }, + }, + }); + const legacyRows = parsed?.whereUsed['O__c.F__c'] ?? []; + expect(legacyRows.find((row) => row.type === 'ApexClass')?.kind).toBe('apex'); + expect(legacyRows.find((row) => row.type === 'Flow')?.kind).toBe('automation'); + }); + + it('parseFieldUsageJobResult infers layout kind from Layout / FlexiPage / FieldSet when kind is other (legacy payloads)', () => { + const parsed = parseFieldUsageJobResult({ + phase: 'field_usage_v1', + summary: 'ok', + truncated: false, + failedObjects: [], + whereUsed: { + 'O__c.F__c': [ + { type: 'Layout', name: 'Account Layout', kind: 'other' }, + { type: 'FlexiPage', name: 'My_App_Page', kind: 'other' }, + { type: 'FieldSet', name: 'Task_Fields', kind: 'other' }, + ], + }, + objects: { + O__c: { + label: 'O', + customizable: true, + totalRecords: 0, + queryTruncated: false, + fieldUsage: { F__c: { filled: 0, pct: 0, latestFilledRowModified: null } }, + fieldMeta: { + F__c: { label: 'F', calculated: false, type: 'string', custom: true, length: 10 }, + }, + }, + }, + }); + const layoutRows = parsed?.whereUsed['O__c.F__c'] ?? []; + expect(layoutRows.find((row) => row.type === 'Layout')?.kind).toBe('layout'); + expect(layoutRows.find((row) => row.type === 'FlexiPage')?.kind).toBe('layout'); + expect(layoutRows.find((row) => row.type === 'FieldSet')?.kind).toBe('layout'); + }); + + it('getWhereUsedDepsForFieldKey matches exact key and falls back to case-insensitive map keys', () => { + const map = { + 'ns__Obj__c.Field__c': [{ type: 'Flow', name: 'F', kind: 'other' as const }], + }; + expect(getWhereUsedDepsForFieldKey(map, 'ns__Obj__c.Field__c')).toHaveLength(1); + expect(getWhereUsedDepsForFieldKey(map, 'NS__Obj__c.Field__c')).toHaveLength(1); + expect(getWhereUsedDepsForFieldKey(map, ' missing.Key__c ')).toEqual([]); + }); + + it('fieldHasWhereUsedDeps is true only when the map has non-empty rows for that field key', () => { + const map = { + 'A__c.F__c': [{ type: 'Flow', name: 'X', kind: 'other' as const }], + 'B__c.G__c': [] as { type: string; name: string; kind: 'automation' | 'apex' | 'layout' | 'other' }[], + }; + expect(fieldHasWhereUsedDeps(map, 'A__c.F__c')).toBe(true); + expect(fieldHasWhereUsedDeps(map, 'B__c.G__c')).toBe(false); + expect(fieldHasWhereUsedDeps(map, 'Unknown__c.X__c')).toBe(false); + }); + + it('countWhereUsedByUiCategory buckets by kind (matches Kind column)', () => { + expect( + countWhereUsedByUiCategory([ + { type: 'Layout', name: 'L1', kind: 'layout' }, + { type: 'FlexiPage', name: 'FP1', kind: 'layout' }, + { type: 'FieldSet', name: 'FS1', kind: 'layout' }, + { type: 'Flow', name: 'Fl1', kind: 'automation' }, + { type: 'WorkflowRule', name: 'W1', kind: 'automation' }, + { type: 'ProcessDefinition', name: 'P1', kind: 'automation' }, + { type: 'ApexTrigger', name: 'T1', kind: 'automation' }, + { type: 'ApexClass', name: 'C1', kind: 'apex' }, + { type: 'CustomLabel', name: 'X', kind: 'other' }, + ]), + ).toEqual({ onLayout: 3, inAutomation: 4, inApex: 1 }); + }); + + it('countWhereUsedByUiCategory uses kind only (not Tooling type)', () => { + expect(countWhereUsedByUiCategory([{ type: 'Layout', name: 'L1', kind: 'other' }])).toEqual({ + onLayout: 0, + inAutomation: 0, + inApex: 0, + }); + }); + + it('parseFieldUsageJobResult dedupes Flow + FlowDefinition so automation count is per logical flow', () => { + const parsed = parseFieldUsageJobResult({ + phase: 'field_usage_v1', + summary: 'ok', + truncated: false, + failedObjects: [], + whereUsed: { + 'O__c.F__c': [ + { type: 'Flow', name: 'Dup_Flow-2', kind: 'automation' }, + { type: 'FlowDefinition', name: 'Dup_Flow', kind: 'automation' }, + ], + }, + objects: { + O__c: { + label: 'O', + customizable: true, + totalRecords: 0, + queryTruncated: false, + fieldUsage: { F__c: { filled: 0, pct: 0, latestFilledRowModified: null } }, + fieldMeta: { + F__c: { label: 'F', calculated: false, type: 'string', custom: true, length: 10 }, + }, + }, + }, + }); + const deps = parsed?.whereUsed['O__c.F__c'] ?? []; + expect(deps).toHaveLength(1); + expect(countWhereUsedByUiCategory(deps).inAutomation).toBe(1); + }); + + it('getFieldUsageTypeLabel capitalizes API type when describe metadata is absent (legacy jobs)', () => { + expect( + getFieldUsageTypeLabel({ + label: 'Status', + calculated: false, + type: 'picklist', + custom: true, + }), + ).toBe('Picklist'); + expect( + getFieldUsageTypeLabel({ + label: 'Qty', + calculated: false, + type: 'int', + custom: true, + }), + ).toBe('Number'); + expect( + getFieldUsageTypeLabel({ + label: 'Amt', + calculated: false, + type: 'double', + custom: true, + }), + ).toBe('Number'); + }); + + it('getFieldUsageTypeLabel uses polyfill-style labels when describe metadata is present', () => { + expect( + getFieldUsageTypeLabel({ + label: 'Amount', + calculated: false, + type: 'currency', + custom: true, + precision: 18, + scale: 2, + }), + ).toBe('Currency (18, 2)'); + expect( + getFieldUsageTypeLabel({ + label: 'Acct', + calculated: false, + type: 'reference', + custom: false, + referenceTo: ['Account'], + relationshipName: 'Account__r', + }), + ).toBe('Reference (Account)'); + expect( + getFieldUsageTypeLabel({ + label: 'Notes', + calculated: false, + type: 'textarea', + custom: true, + length: 32768, + }), + ).toBe('Long Text Area (32768)'); + expect( + getFieldUsageTypeLabel({ + label: 'Case Number', + calculated: false, + type: 'string', + custom: false, + autoNumber: true, + length: 30, + displayFormat: 'CS-{000000}', + }), + ).toBe('Auto Number (CS-{000000})'); + expect( + getFieldUsageTypeLabel({ + label: 'Seq', + calculated: false, + type: 'string', + custom: true, + autoNumber: true, + length: 10, + digits: 9, + }), + ).toBe('Auto Number (9 digits max)'); + }); +}); diff --git a/libs/features/data-analysis/src/field-usage-destructive-delete.ts b/libs/features/data-analysis/src/field-usage-destructive-delete.ts new file mode 100644 index 000000000..bb2b9773e --- /dev/null +++ b/libs/features/data-analysis/src/field-usage-destructive-delete.ts @@ -0,0 +1,68 @@ +import type { ListMetadataResult } from '@jetstream/types'; +import { isUnmanagedCustomFieldApiName } from '@jetstream/shared/utils'; +import type { FieldUsageFieldMetaParsed } from './field-usage-result-parse'; + +/** + * Whether a field usage row may be included in a destructive CustomField deploy (delete from org). + * Standard fields, packaged custom fields, and the object name field are excluded. + * + * Uses {@link isUnmanagedCustomFieldApiName} (same parse rules as Tooling `CustomField` and field usage where-used). + */ +export function fieldUsageRowEligibleForDestructiveDelete(args: { + isObjectErrorPlaceholder?: boolean; + fieldApiName: string; + meta?: FieldUsageFieldMetaParsed | null; +}): boolean { + const { isObjectErrorPlaceholder, fieldApiName, meta } = args; + if (isObjectErrorPlaceholder) { + return false; + } + if (!meta?.custom) { + return false; + } + if (meta.nameField) { + return false; + } + if (!isUnmanagedCustomFieldApiName(fieldApiName)) { + return false; + } + return true; +} + +export interface FieldUsageDestructiveDeleteRow { + objectApiName: string; + fieldApiName: string; + destructiveDeleteEligible?: boolean; +} + +/** + * Builds `selectedMetadata` for {@link DeleteMetadataModal} / destructive deploy (CustomField only). + */ +export function fieldUsageRowsToCustomFieldDeleteMetadata( + rows: readonly FieldUsageDestructiveDeleteRow[], +): Record { + const members: ListMetadataResult[] = []; + for (const row of rows) { + if (!row.destructiveDeleteEligible) { + continue; + } + const fullName = `${row.objectApiName}.${row.fieldApiName}`; + members.push({ + createdById: null, + createdByName: null, + createdDate: null, + fileName: `objects/${row.objectApiName}/fields/${row.fieldApiName}.field-meta.xml`, + fullName, + id: null, + lastModifiedById: null, + lastModifiedByName: null, + lastModifiedDate: null, + manageableState: 'unmanaged', + type: 'CustomField', + }); + } + if (members.length === 0) { + return {}; + } + return { CustomField: members }; +} diff --git a/libs/features/data-analysis/src/field-usage-result-parse.ts b/libs/features/data-analysis/src/field-usage-result-parse.ts new file mode 100644 index 000000000..aa68325cf --- /dev/null +++ b/libs/features/data-analysis/src/field-usage-result-parse.ts @@ -0,0 +1,449 @@ +import type { Field, FieldType } from '@jetstream/types'; +import { dedupeFieldUsageWhereUsedRows, sortFieldUsageWhereUsedRows } from '@jetstream/shared/utils'; +import { polyfillFieldDefinition } from '@jetstream/shared/ui-utils'; + +export interface FieldUsageStatParsed { + filled: number; + pct: number; + latestFilledRowModified: string | null; +} + +export interface FieldUsageFieldMetaParsed { + label: string; + calculated: boolean; + /** Salesforce describe API `type` (e.g. `string`, `reference`). */ + type: string; + custom: boolean; + /** Present on newer job results; used with {@link getFieldUsageTypeLabel}. */ + autoNumber?: boolean; + calculatedFormula?: string | null; + externalId?: boolean; + nameField?: boolean; + extraTypeInfo?: string | null; + length?: number; + precision?: number | null; + scale?: number; + referenceTo?: string[] | null; + relationshipName?: string | null; + digits?: number | null; + /** Auto-number display pattern when describe provides it. */ + displayFormat?: string | null; +} + +export interface FieldUsageObjectPayloadParsed { + label: string; + customizable: boolean; + totalRecords: number; + queryTruncated: boolean; + fieldUsage: Record; + fieldMeta: Record; + error?: string; +} + +export interface WhereUsedDependencyRowParsed { + type: string; + name: string; + kind: 'automation' | 'apex' | 'layout' | 'other'; + /** Tooling `MetadataComponentDependency.MetadataComponentId` when present on the job payload. */ + componentId?: string; + /** For Flow dependencies: Tooling `Flow.VersionNumber` when the API resolved the row. */ + flowVersionNumber?: number | null; + /** Relative path in the org to open this component; clients may compute a fallback from `componentId` + `type`. */ + openInSalesforcePath?: string | null; +} + +export type WhereUsedMapParsed = Record; + +/** + * Resolves tooling dependency rows for `ObjectApi.FieldApi`, tolerating stray whitespace or key casing drift in stored JSON. + */ +export function getWhereUsedDepsForFieldKey(whereUsed: WhereUsedMapParsed, objectDotField: string): WhereUsedDependencyRowParsed[] { + const trimmed = objectDotField.trim(); + const direct = whereUsed[trimmed]; + if (direct) { + return direct; + } + const normalized = trimmed.toLowerCase(); + for (const [storedKey, rows] of Object.entries(whereUsed)) { + if (storedKey.trim().toLowerCase() === normalized) { + return rows; + } + } + return []; +} + +/** True when the job includes at least one Tooling dependency row for this `Object.Field__c` key. */ +export function fieldHasWhereUsedDeps(whereUsed: WhereUsedMapParsed, objectDotField: string): boolean { + return getWhereUsedDepsForFieldKey(whereUsed, objectDotField).length > 0; +} + +/** Tooling `MetadataComponentType` values rolled into the **On layout** column and Kind Layout. */ +const WHERE_USED_UI_LAYOUT_TYPES = new Set(['Layout', 'FlexiPage', 'FieldSet']); + +/** + * Workflow, Process Builder, Flow, and Apex triggers — **In automation** column. + * Matches common `MetadataComponentDependency.MetadataComponentType` strings. + */ +const WHERE_USED_UI_AUTOMATION_TYPES = new Set([ + 'WorkflowRule', + 'WorkflowFieldUpdate', + 'ProcessDefinition', + 'Flow', + 'FlowDefinition', + 'ApexTrigger', +]); + +/** **In Apex** column: ApexClass, ApexPage, ApexComponent (same set as API `kind: apex`; triggers stay automation). */ +const WHERE_USED_UI_APEX_TYPES = new Set(['ApexClass', 'ApexPage', 'ApexComponent']); + +function inferWhereUsedKindFromMetadataType(metadataType: string): WhereUsedDependencyRowParsed['kind'] | undefined { + const trimmed = metadataType.trim(); + if (!trimmed) { + return undefined; + } + if (WHERE_USED_UI_LAYOUT_TYPES.has(trimmed)) { + return 'layout'; + } + if (WHERE_USED_UI_AUTOMATION_TYPES.has(trimmed)) { + return 'automation'; + } + if (WHERE_USED_UI_APEX_TYPES.has(trimmed)) { + return 'apex'; + } + return undefined; +} + +export interface WhereUsedUiCategoryCounts { + onLayout: number; + inAutomation: number; + inApex: number; +} + +/** + * Counts dependency rows per UI bucket using each row’s {@link WhereUsedDependencyRowParsed.kind} + * (same basis as the Where Used **Kind** column: layout / automation / apex). `other` is excluded from these three totals. + */ +export function countWhereUsedByUiCategory(deps: WhereUsedDependencyRowParsed[]): WhereUsedUiCategoryCounts { + let onLayout = 0; + let inAutomation = 0; + let inApex = 0; + for (const dep of deps) { + if (dep.kind === 'layout') { + onLayout++; + } else if (dep.kind === 'automation') { + inAutomation++; + } else if (dep.kind === 'apex') { + inApex++; + } + } + return { onLayout, inAutomation, inApex }; +} + +export interface FieldUsageJobResultParsed { + phase: 'field_usage_v1'; + summary: string; + truncated: boolean; + objects: Record; + whereUsed: WhereUsedMapParsed; + failedObjects: string[]; +} + +function asRecord(value: unknown): Record | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +function parseFieldUsageStat(value: unknown): FieldUsageStatParsed | null { + const rec = asRecord(value); + if (!rec) { + return null; + } + const filled = rec.filled; + const pct = rec.pct; + const latest = rec.latestFilledRowModified; + if (typeof filled !== 'number' || typeof pct !== 'number') { + return null; + } + return { + filled, + pct, + latestFilledRowModified: latest == null || latest === '' ? null : String(latest), + }; +} + +function parseOptionalNumber(value: unknown): number | undefined { + if (typeof value === 'number' && !Number.isNaN(value)) { + return value; + } + return undefined; +} + +function parseOptionalNumberOrNull(value: unknown): number | null | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + if (typeof value === 'number' && !Number.isNaN(value)) { + return value; + } + return undefined; +} + +function parseReferenceTo(value: unknown): string[] | null | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +function parseFieldMetaEntry(value: unknown): FieldUsageFieldMetaParsed | null { + const rec = asRecord(value); + if (!rec) { + return null; + } + const label = rec.label != null ? String(rec.label) : ''; + const type = rec.type != null ? String(rec.type) : ''; + return { + label, + calculated: rec.calculated === true, + type, + custom: rec.custom === true, + ...(rec.autoNumber === true || rec.autoNumber === false ? { autoNumber: rec.autoNumber === true } : {}), + ...(rec.calculatedFormula !== undefined + ? { calculatedFormula: rec.calculatedFormula == null ? null : String(rec.calculatedFormula) } + : {}), + ...(rec.externalId === true || rec.externalId === false ? { externalId: rec.externalId === true } : {}), + ...(rec.nameField === true || rec.nameField === false ? { nameField: rec.nameField === true } : {}), + ...(rec.extraTypeInfo !== undefined ? { extraTypeInfo: rec.extraTypeInfo == null ? null : String(rec.extraTypeInfo) } : {}), + ...(parseOptionalNumber(rec.length) !== undefined ? { length: parseOptionalNumber(rec.length) } : {}), + ...(parseOptionalNumberOrNull(rec.precision) !== undefined ? { precision: parseOptionalNumberOrNull(rec.precision) } : {}), + ...(parseOptionalNumber(rec.scale) !== undefined ? { scale: parseOptionalNumber(rec.scale) } : {}), + ...(parseReferenceTo(rec.referenceTo) !== undefined ? { referenceTo: parseReferenceTo(rec.referenceTo) } : {}), + ...(rec.relationshipName !== undefined ? { relationshipName: rec.relationshipName == null ? null : String(rec.relationshipName) } : {}), + ...(parseOptionalNumberOrNull(rec.digits) !== undefined ? { digits: parseOptionalNumberOrNull(rec.digits) } : {}), + ...(rec.displayFormat !== undefined ? { displayFormat: rec.displayFormat == null ? null : String(rec.displayFormat) } : {}), + }; +} + +function parseObjectPayload(value: unknown): FieldUsageObjectPayloadParsed | null { + const rec = asRecord(value); + if (!rec) { + return null; + } + const fieldUsageRaw = asRecord(rec.fieldUsage); + const fieldMetaRaw = asRecord(rec.fieldMeta); + if (!fieldUsageRaw || !fieldMetaRaw) { + return null; + } + const fieldUsage: Record = {}; + for (const [fieldName, stat] of Object.entries(fieldUsageRaw)) { + const parsed = parseFieldUsageStat(stat); + if (parsed) { + fieldUsage[fieldName] = parsed; + } + } + const fieldMeta: Record = {}; + for (const [fieldName, meta] of Object.entries(fieldMetaRaw)) { + const parsed = parseFieldMetaEntry(meta); + if (parsed) { + fieldMeta[fieldName] = parsed; + } + } + const totalRecords = rec.totalRecords; + return { + label: rec.label != null ? String(rec.label) : '', + customizable: rec.customizable === true, + totalRecords: typeof totalRecords === 'number' ? totalRecords : 0, + queryTruncated: rec.queryTruncated === true, + fieldUsage, + fieldMeta, + ...(typeof rec.error === 'string' && rec.error.trim() ? { error: rec.error } : {}), + }; +} + +function parseWhereUsedMap(value: unknown): WhereUsedMapParsed { + const rec = asRecord(value); + if (!rec) { + return {}; + } + const out: WhereUsedMapParsed = {}; + for (const [fieldKey, rowsUnknown] of Object.entries(rec)) { + if (!Array.isArray(rowsUnknown)) { + continue; + } + const rows: WhereUsedDependencyRowParsed[] = []; + for (const rowUnknown of rowsUnknown) { + const row = asRecord(rowUnknown); + if (!row) { + continue; + } + const rawKind = + row.kind === 'automation' || row.kind === 'apex' || row.kind === 'layout' || row.kind === 'other' ? row.kind : 'other'; + const typeStr = row.type != null ? String(row.type) : ''; + const inferredKind = inferWhereUsedKindFromMetadataType(typeStr); + const kind = rawKind === 'automation' || rawKind === 'apex' || rawKind === 'layout' ? rawKind : (inferredKind ?? rawKind); + const componentIdRaw = row.componentId; + const componentId = typeof componentIdRaw === 'string' && componentIdRaw.trim() ? componentIdRaw.trim() : undefined; + const fv = row.flowVersionNumber; + let flowVersionNumberParsed: number | undefined; + if (typeof fv === 'number' && Number.isFinite(fv)) { + flowVersionNumberParsed = fv; + } else if (fv != null && String(fv).trim() !== '') { + const coerced = Number(fv); + if (Number.isFinite(coerced)) { + flowVersionNumberParsed = coerced; + } + } + const pathRaw = row.openInSalesforcePath; + const openInSalesforcePathParsed = + typeof pathRaw === 'string' ? pathRaw : pathRaw != null && String(pathRaw).trim() ? String(pathRaw) : undefined; + rows.push({ + type: typeStr, + name: row.name != null ? String(row.name) : '', + kind, + ...(componentId ? { componentId } : {}), + ...(flowVersionNumberParsed !== undefined && Number.isFinite(flowVersionNumberParsed) + ? { flowVersionNumber: flowVersionNumberParsed } + : {}), + ...(openInSalesforcePathParsed !== undefined ? { openInSalesforcePath: openInSalesforcePathParsed } : {}), + }); + } + out[fieldKey] = sortFieldUsageWhereUsedRows(dedupeFieldUsageWhereUsedRows(rows)); + } + return out; +} + +function hasExtendedDescribeMeta(meta: FieldUsageFieldMetaParsed): boolean { + return ( + meta.autoNumber !== undefined || + meta.calculatedFormula !== undefined || + meta.externalId !== undefined || + meta.nameField !== undefined || + meta.extraTypeInfo !== undefined || + meta.length !== undefined || + meta.precision !== undefined || + meta.scale !== undefined || + meta.referenceTo !== undefined || + meta.relationshipName !== undefined || + meta.digits !== undefined || + meta.displayFormat !== undefined + ); +} + +function legacyApiTypeLabel(apiType: string): string { + const normalized = apiType.toLowerCase(); + if (normalized === 'int' || normalized === 'double') { + return 'Number'; + } + if (!apiType) { + return ''; + } + return `${apiType[0].toUpperCase()}${apiType.slice(1)}`; +} + +function isUsageReferenceLike(meta: FieldUsageFieldMetaParsed): boolean { + const refs = meta.referenceTo?.filter((target) => target.trim().length > 0) ?? []; + return (meta.type === 'reference' || meta.type === 'string') && !!meta.relationshipName && refs.length > 0; +} + +function rewriteLookupLabelToReference(label: string): string { + const lookupMatch = /^Lookup\s*\((.*)\)$/.exec(label.trim()); + if (lookupMatch) { + return `Reference (${lookupMatch[1]})`; + } + return label; +} + +/** + * Human-readable field type for the field usage grid (based on {@link polyfillFieldDefinition}, with usage-specific wording). + * References show as `Reference (Account, …)`; integers read as Number; auto-number includes format when available. + */ +export function getFieldUsageTypeLabel(meta: FieldUsageFieldMetaParsed | undefined): string { + if (!meta?.type) { + return ''; + } + if (!hasExtendedDescribeMeta(meta)) { + return legacyApiTypeLabel(meta.type); + } + + if (meta.autoNumber) { + const formatPattern = meta.displayFormat?.trim(); + if (formatPattern) { + return `Auto Number (${formatPattern})`; + } + if (meta.digits != null && meta.digits > 0) { + return `Auto Number (${meta.digits} digit${meta.digits === 1 ? '' : 's'} max)`; + } + return 'Auto Number'; + } + + if (isUsageReferenceLike(meta)) { + const targets = (meta.referenceTo ?? []).join(', '); + return `Reference (${targets})`; + } + + const textareaDefaultPlain = + meta.type === 'textarea' && (meta.extraTypeInfo == null || meta.extraTypeInfo === '') ? ('plaintextarea' as const) : meta.extraTypeInfo; + + const partial = { + autoNumber: false, + type: meta.type as FieldType, + calculated: meta.calculated, + calculatedFormula: meta.calculatedFormula ?? null, + externalId: meta.externalId ?? false, + nameField: meta.nameField ?? false, + extraTypeInfo: textareaDefaultPlain ?? null, + length: meta.length ?? 0, + precision: meta.precision ?? null, + scale: meta.scale ?? 0, + referenceTo: meta.referenceTo ?? null, + relationshipName: meta.relationshipName ?? null, + } as Field; + + return rewriteLookupLabelToReference(polyfillFieldDefinition(partial)); +} + +/** + * Parses `analysis_job.result` for completed **field_usage** jobs (`phase: field_usage_v1`). + * + * @param result JSON value stored on the analysis job row. + * @returns Parsed envelope or `null` when shape does not match. + */ +export function parseFieldUsageJobResult(result: unknown): FieldUsageJobResultParsed | null { + const rec = asRecord(result); + if (!rec || rec.phase !== 'field_usage_v1') { + return null; + } + const objectsRaw = asRecord(rec.objects); + if (!objectsRaw) { + return null; + } + const objects: Record = {}; + for (const [apiName, payloadUnknown] of Object.entries(objectsRaw)) { + const parsed = parseObjectPayload(payloadUnknown); + if (parsed) { + objects[apiName] = parsed; + } + } + const failedRaw = rec.failedObjects; + const failedObjects = Array.isArray(failedRaw) ? failedRaw.filter((entry): entry is string => typeof entry === 'string') : []; + + return { + phase: 'field_usage_v1', + summary: rec.summary != null ? String(rec.summary) : '', + truncated: rec.truncated === true, + objects, + whereUsed: parseWhereUsedMap(rec.whereUsed), + failedObjects, + }; +} diff --git a/libs/features/data-analysis/src/field-usage/__tests__/run-field-usage.spec.ts b/libs/features/data-analysis/src/field-usage/__tests__/run-field-usage.spec.ts new file mode 100644 index 000000000..72f11fdf6 --- /dev/null +++ b/libs/features/data-analysis/src/field-usage/__tests__/run-field-usage.spec.ts @@ -0,0 +1,210 @@ +import type { SalesforceOrgUi } from '@jetstream/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runFieldUsageQueryForObjects } from '../run-field-usage'; + +const { describeMock, queryMock, queryMoreMock } = vi.hoisted(() => ({ + describeMock: vi.fn(), + queryMock: vi.fn(), + queryMoreMock: vi.fn(), +})); + +vi.mock('@jetstream/shared/data', () => { + async function queryWithRecordBudget( + org: unknown, + soql: string, + isTooling: boolean, + budget: { remaining: number }, + onPage: (records: Record[]) => void, + ): Promise<{ truncated: boolean }> { + let response = await queryMock(org, soql, isTooling); + while (true) { + const records = response.queryResults.records as Record[]; + if (budget.remaining <= 0) { + return { truncated: true }; + } + if (records.length > budget.remaining) { + onPage(records.slice(0, budget.remaining)); + budget.remaining = 0; + return { truncated: true }; + } + onPage(records); + budget.remaining -= records.length; + if (response.queryResults.done) { + break; + } + const nextUrl = response.queryResults.nextRecordsUrl; + if (!nextUrl) { + break; + } + response = await queryMoreMock(org, nextUrl, isTooling); + } + return { truncated: false }; + } + return { + describeSObject: describeMock, + query: queryMock, + queryMore: queryMoreMock, + queryWithRecordBudget, + }; +}); + +const fakeOrg = { uniqueId: 'org-1' } as unknown as SalesforceOrgUi; + +function buildDescribe() { + return { + data: { + label: 'Account', + queryable: true, + custom: false, + fields: [ + { + name: 'Id', + label: 'Id', + type: 'id', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: false, + length: 18, + scale: 0, + }, + { + name: 'LastModifiedDate', + label: 'LMD', + type: 'datetime', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: false, + length: 0, + scale: 0, + }, + { + name: 'Name', + label: 'Name', + type: 'string', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: true, + length: 255, + scale: 0, + }, + { + name: 'Industry', + label: 'Industry', + type: 'picklist', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: false, + length: 0, + scale: 0, + }, + ], + }, + }; +} + +function buildPage(records: Record[], nextRecordsUrl: string | null) { + return { + queryResults: { + done: nextRecordsUrl == null, + records, + ...(nextRecordsUrl ? { nextRecordsUrl } : {}), + }, + }; +} + +describe('runFieldUsageQueryForObjects (streaming)', () => { + beforeEach(() => { + describeMock.mockReset(); + queryMock.mockReset(); + queryMoreMock.mockReset(); + }); + + it('aggregates 1000 records across 5 pages without retaining record arrays', async () => { + describeMock.mockResolvedValue(buildDescribe()); + + const pageSize = 200; + const totalPages = 5; + const pages: Record[][] = []; + for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) { + const records: Record[] = []; + for (let recordIndex = 0; recordIndex < pageSize; recordIndex++) { + const globalIndex = pageIndex * pageSize + recordIndex; + records.push({ + Id: `001${String(globalIndex).padStart(15, '0')}`, + LastModifiedDate: `2026-01-${String((globalIndex % 28) + 1).padStart(2, '0')}T00:00:00.000+0000`, + Name: globalIndex % 2 === 0 ? `Account ${globalIndex}` : '', + Industry: globalIndex < 100 ? 'Technology' : null, + }); + } + pages.push(records); + } + + queryMock.mockResolvedValueOnce(buildPage(pages[0], '/q/next-1')); + queryMoreMock + .mockResolvedValueOnce(buildPage(pages[1], '/q/next-2')) + .mockResolvedValueOnce(buildPage(pages[2], '/q/next-3')) + .mockResolvedValueOnce(buildPage(pages[3], '/q/next-4')) + .mockResolvedValueOnce(buildPage(pages[4], null)); + + const progressEvents: { current: number; total: number; percent: number; label: string }[] = []; + const result = await runFieldUsageQueryForObjects(fakeOrg, ['Account'], { + onProgress: (progress) => progressEvents.push(progress), + }); + + expect(result.failedObjects).toEqual([]); + expect(result.anyQueryTruncated).toBe(false); + + const account = result.objects.Account; + expect(account).toBeDefined(); + expect(account.totalRecords).toBe(1000); + expect(account.queryTruncated).toBe(false); + + // Half the rows have a non-empty Name (even indices) — 500 filled out of 1000. + expect(account.fieldUsage.Name.filled).toBe(500); + expect(account.fieldUsage.Name.pct).toBeCloseTo(50, 5); + // First 100 rows have an Industry value. + expect(account.fieldUsage.Industry.filled).toBe(100); + expect(account.fieldUsage.Industry.pct).toBeCloseTo(10, 5); + + // Latest filled row modified for Name is the largest even index in any page (998 → day 27). + expect(account.fieldUsage.Name.latestFilledRowModified).toBe('2026-01-27T00:00:00.000+0000'); + + expect(progressEvents).toHaveLength(1); + expect(progressEvents[0]).toEqual({ + current: 1, + total: 1, + percent: 100, + label: 'Analyzing Account (1 of 1)', + }); + + expect(queryMock).toHaveBeenCalledTimes(1); + expect(queryMoreMock).toHaveBeenCalledTimes(4); + }); + + it('throws when the cancellation signal trips between objects', async () => { + describeMock.mockResolvedValue(buildDescribe()); + queryMock.mockResolvedValue(buildPage([], null)); + + let cancelCalls = 0; + const promise = runFieldUsageQueryForObjects(fakeOrg, ['Account'], { + isCanceled: () => { + cancelCalls += 1; + return cancelCalls > 0; + }, + }); + + await expect(promise).rejects.toThrow('Job canceled'); + }); +}); diff --git a/libs/features/data-analysis/src/field-usage/compute-field-usage-where-used.ts b/libs/features/data-analysis/src/field-usage/compute-field-usage-where-used.ts new file mode 100644 index 000000000..9d83d629e --- /dev/null +++ b/libs/features/data-analysis/src/field-usage/compute-field-usage-where-used.ts @@ -0,0 +1,421 @@ +import { queryAll } from '@jetstream/shared/data'; +import { logger } from '@jetstream/shared/client-logger'; +import { + dedupeFieldUsageWhereUsedRows, + parseCustomFieldApiNameForTooling, + sortFieldUsageWhereUsedRows, + splitArrayToMaxSize, +} from '@jetstream/shared/utils'; +import type { FieldUsageJobResultData, SalesforceOrgUi } from '@jetstream/types'; +import { composeQuery, getField } from '@jetstreamapp/soql-parser-js'; +import type { FieldUsageObjectPayload } from './run-field-usage'; + +const WHERE_USED_AUTOMATION_TYPES = new Set([ + 'ApexTrigger', + 'Flow', + 'FlowDefinition', + 'WorkflowFieldUpdate', + 'WorkflowRule', + 'ProcessDefinition', +]); + +/** Apex code / Visualforce metadata (not triggers — those stay in the automation bucket). */ +const WHERE_USED_APEX_TYPES = new Set(['ApexClass', 'ApexPage', 'ApexComponent']); + +/** Page UI metadata — On layout bucket (classic layout, Lightning page, field sets on layouts). */ +const WHERE_USED_LAYOUT_TYPES = new Set(['Layout', 'FlexiPage', 'FieldSet']); + +/** Batch size for the (object, developerName) tuples per Tooling CustomField lookup. */ +const CUSTOM_FIELD_LOOKUP_CHUNK_SIZE = 200; + +/** Concurrency for per-field `MetadataComponentDependency` lookups. */ +const DEPENDENCY_LOOKUP_CONCURRENCY = 5; + +/** Chunk size for Flow Id `IN (...)` filters during enrichment. */ +const FLOW_ID_CHUNK_SIZE = 200; + +export type WhereUsedDependencyRow = { + type: string; + name: string; + kind: 'automation' | 'apex' | 'layout' | 'other'; + /** Tooling `MetadataComponentDependency.MetadataComponentId` when returned. */ + componentId?: string; + /** Populated for `Flow` rows when Tooling `Flow` can be resolved (same dependency row as a specific version). */ + flowVersionNumber?: number | null; + /** + * Relative path in the org (leading `/`) to open this component (Flow Builder, Apex setup, etc.). + * Omitted when unknown; clients may fall back from {@link componentId} + {@link type}. + */ + openInSalesforcePath?: string | null; +}; + +export type WhereUsedMap = Record; + +function depKind(componentType: string): WhereUsedDependencyRow['kind'] { + if (WHERE_USED_AUTOMATION_TYPES.has(componentType)) { + return 'automation'; + } + if (WHERE_USED_APEX_TYPES.has(componentType)) { + return 'apex'; + } + if (WHERE_USED_LAYOUT_TYPES.has(componentType)) { + return 'layout'; + } + return 'other'; +} + +/** + * Fills {@link WhereUsedDependencyRow.flowVersionNumber}, {@link WhereUsedDependencyRow.openInSalesforcePath} + * for Flow rows via Tooling `Flow` (`Id` + `VersionNumber` only — not `DurableId`, which is not on Tooling `Flow`), + * and generic setup paths when we have a `MetadataComponentId`. + */ +async function enrichWhereUsedDependencyRows(org: SalesforceOrgUi, rows: WhereUsedDependencyRow[]): Promise { + const flowIds = new Set(); + for (const row of rows) { + if (row.type === 'Flow' && row.componentId) { + flowIds.add(row.componentId); + } + } + const flowVersionById = new Map(); + if (flowIds.size > 0) { + for (const idChunk of splitArrayToMaxSize([...flowIds], FLOW_ID_CHUNK_SIZE)) { + const soql = composeQuery({ + fields: [getField('Id'), getField('VersionNumber')], + sObject: 'Flow', + where: { + left: { + field: 'Id', + operator: 'IN', + value: idChunk, + literalType: 'STRING', + }, + }, + }); + const records = (await queryAll>(org, soql, true)).queryResults.records; + for (const record of records) { + const id = record.Id != null ? String(record.Id) : ''; + if (!id) { + continue; + } + const versionRaw = record.VersionNumber; + const versionNumber = typeof versionRaw === 'number' ? versionRaw : Number(versionRaw); + flowVersionById.set(id, { + versionNumber: Number.isFinite(versionNumber) ? versionNumber : 0, + }); + } + } + } + + for (const row of rows) { + const componentType = row.type; + const componentId = row.componentId; + if (componentType === 'Flow' && componentId) { + const info = flowVersionById.get(componentId); + if (info) { + row.flowVersionNumber = info.versionNumber; + row.openInSalesforcePath = `/builder_platform_interaction/flowBuilder.app?flowId=${encodeURIComponent(componentId)}`; + continue; + } + } + if (row.openInSalesforcePath) { + continue; + } + if (componentType === 'ProcessDefinition') { + row.openInSalesforcePath = '/lightning/setup/ProcessAutomation/home'; + continue; + } + if (!componentId) { + continue; + } + if (componentType === 'ApexClass') { + row.openInSalesforcePath = `/lightning/setup/ApexClasses/page?address=${encodeURIComponent(encodeURIComponent(`/${componentId}`))}`; + } else if (componentType === 'ApexTrigger') { + row.openInSalesforcePath = `/lightning/setup/ApexTriggers/page?address=${encodeURIComponent(encodeURIComponent(`/${componentId}`))}`; + } else if (componentType === 'ApexPage') { + row.openInSalesforcePath = `/lightning/setup/ApexPages/page?address=${encodeURIComponent(encodeURIComponent(`/${componentId}`))}`; + } else if (componentType === 'ApexComponent') { + row.openInSalesforcePath = `/lightning/setup/ApexComponents/page?address=${encodeURIComponent(encodeURIComponent(`/${componentId}`))}`; + } else if (componentType === 'FlexiPage') { + row.openInSalesforcePath = `/lightning/setup/FlexiPageList/page?address=${encodeURIComponent(encodeURIComponent(`/${componentId}`))}`; + } else if (componentType === 'Layout') { + row.openInSalesforcePath = `/lightning/setup/LayoutDefinitions/page?address=${encodeURIComponent(encodeURIComponent(`/${componentId}`))}`; + } else if (componentType === 'FieldSet') { + row.openInSalesforcePath = `/lightning/setup/FieldSets/page?address=${encodeURIComponent(encodeURIComponent(`/${componentId}`))}`; + } else if (componentType === 'WorkflowRule' || componentType === 'WorkflowFieldUpdate') { + row.openInSalesforcePath = `/lightning/setup/WorkflowRules/page?address=${encodeURIComponent(encodeURIComponent(`/${componentId}`))}`; + } + } +} + +interface ParsedFieldRef { + key: string; + object: string; + field: string; + developerName: string; + namespacePrefix: string | null; +} + +function namespaceMatches(rowNamespacePrefix: unknown, expected: string | null): boolean { + const rowValue = typeof rowNamespacePrefix === 'string' ? rowNamespacePrefix : ''; + if (expected == null || expected.length === 0) { + return rowValue.length === 0; + } + return rowValue === expected; +} + +/** + * Batches Tooling `CustomField` lookups by (object, developerName) tuples. + * Returns a map keyed by `${object}.${field}` → Tooling CustomField Id. + * Tries `EntityDefinition.QualifiedApiName` first, then falls back to `TableEnumOrId` for any unresolved fields. + */ +async function resolveCustomFieldIds(org: SalesforceOrgUi, refs: { object: string; field: string }[]): Promise> { + const parsedRefs: ParsedFieldRef[] = []; + for (const ref of refs) { + const parsed = parseCustomFieldApiNameForTooling(ref.field); + if (!ref.object || !parsed) { + continue; + } + parsedRefs.push({ + key: `${ref.object}.${ref.field}`, + object: ref.object, + field: ref.field, + developerName: parsed.developerName, + namespacePrefix: parsed.namespacePrefix, + }); + } + if (parsedRefs.length === 0) { + return new Map(); + } + + const resolved = new Map(); + const unresolved = new Map(); + for (const ref of parsedRefs) { + unresolved.set(ref.key, ref); + } + + async function runLookup(filterField: 'EntityDefinition.QualifiedApiName' | 'TableEnumOrId'): Promise { + const pending = [...unresolved.values()]; + if (pending.length === 0) { + return; + } + for (const chunk of splitArrayToMaxSize(pending, CUSTOM_FIELD_LOOKUP_CHUNK_SIZE)) { + const objectNames = [...new Set(chunk.map((ref) => ref.object))]; + const developerNames = [...new Set(chunk.map((ref) => ref.developerName))]; + const soql = composeQuery({ + fields: [ + getField('Id'), + getField('EntityDefinition.QualifiedApiName'), + getField('TableEnumOrId'), + getField('DeveloperName'), + getField('NamespacePrefix'), + ], + sObject: 'CustomField', + where: { + left: { + field: filterField, + operator: 'IN', + value: objectNames, + literalType: 'STRING', + }, + operator: 'AND', + right: { + left: { + field: 'DeveloperName', + operator: 'IN', + value: developerNames, + literalType: 'STRING', + }, + }, + }, + }); + + let records: Record[] = []; + try { + records = (await queryAll>(org, soql, true)).queryResults.records; + } catch (err) { + logger.warn('CustomField batch lookup failed; skipping chunk', { err, filterField }); + continue; + } + + const byObjectAndDevName = new Map[]>(); + for (const record of records) { + let objectName = ''; + if (filterField === 'EntityDefinition.QualifiedApiName') { + const entity = record.EntityDefinition; + if (entity && typeof entity === 'object') { + const qualifiedApiName = (entity as { QualifiedApiName?: unknown }).QualifiedApiName; + objectName = typeof qualifiedApiName === 'string' ? qualifiedApiName : ''; + } + } else { + objectName = typeof record.TableEnumOrId === 'string' ? record.TableEnumOrId : ''; + } + const developerName = typeof record.DeveloperName === 'string' ? record.DeveloperName : ''; + if (!objectName || !developerName) { + continue; + } + const bucketKey = `${objectName}.${developerName}`; + let bucket = byObjectAndDevName.get(bucketKey); + if (!bucket) { + bucket = []; + byObjectAndDevName.set(bucketKey, bucket); + } + bucket.push(record); + } + + for (const ref of chunk) { + if (resolved.has(ref.key)) { + continue; + } + const bucket = byObjectAndDevName.get(`${ref.object}.${ref.developerName}`); + if (!bucket) { + continue; + } + const match = bucket.find((rec) => namespaceMatches(rec.NamespacePrefix, ref.namespacePrefix)); + if (match && typeof match.Id === 'string') { + resolved.set(ref.key, match.Id); + unresolved.delete(ref.key); + } + } + } + } + + await runLookup('EntityDefinition.QualifiedApiName'); + if (unresolved.size > 0) { + await runLookup('TableEnumOrId'); + } + + return resolved; +} + +async function getFieldDependencies(org: SalesforceOrgUi, refComponentId: string): Promise { + const soql = composeQuery({ + fields: [getField('MetadataComponentId'), getField('MetadataComponentType'), getField('MetadataComponentName')], + sObject: 'MetadataComponentDependency', + where: { + left: { + field: 'RefMetadataComponentId', + operator: '=', + value: refComponentId, + literalType: 'STRING', + }, + operator: 'AND', + right: { + left: { + field: 'RefMetadataComponentType', + operator: '=', + value: 'CustomField', + literalType: 'STRING', + }, + }, + }, + }); + const records = (await queryAll>(org, soql, true)).queryResults.records; + const dependencyRows: WhereUsedDependencyRow[] = []; + for (const record of records) { + const componentType = record.MetadataComponentType != null ? String(record.MetadataComponentType) : ''; + const componentName = record.MetadataComponentName != null ? String(record.MetadataComponentName) : ''; + if (!componentType && !componentName) { + continue; + } + const componentIdRaw = record.MetadataComponentId; + const componentId = typeof componentIdRaw === 'string' ? componentIdRaw : ''; + dependencyRows.push({ + type: componentType, + name: componentName, + kind: depKind(componentType), + ...(componentId ? { componentId } : {}), + }); + } + return dependencyRows; +} + +async function runWithConcurrency( + items: TItem[], + concurrency: number, + handler: (item: TItem) => Promise, +): Promise { + const results: TResult[] = new Array(items.length); + const cursor = { next: 0 }; + const worker = async (): Promise => { + while (true) { + const currentIndex = cursor.next; + cursor.next += 1; + if (currentIndex >= items.length) { + return; + } + results[currentIndex] = await handler(items[currentIndex]); + } + }; + const workerCount = Math.min(concurrency, items.length); + const workers: Promise[] = []; + for (let workerIndex = 0; workerIndex < workerCount; workerIndex++) { + workers.push(worker()); + } + await Promise.all(workers); + return results; +} + +function collectCustomFieldKeys(objects: Record): { object: string; field: string }[] { + const refs: { object: string; field: string }[] = []; + for (const objectName of Object.keys(objects).sort()) { + const payload = objects[objectName]; + if (!payload || payload.error) { + continue; + } + for (const fieldName of Object.keys(payload.fieldUsage)) { + if (fieldName.endsWith('__c')) { + refs.push({ object: objectName, field: fieldName }); + } + } + } + return refs; +} + +/** + * Tooling MetadataComponentDependency map keyed `ObjectApi.FieldApi` (custom fields only). + * + * Resolves all CustomField Ids in batched Tooling queries, then fetches dependency rows + * with bounded concurrency. Flow enrichment runs once across the union of all rows. + */ +export async function computeFieldUsageWhereUsed( + org: SalesforceOrgUi, + objects: Record, +): Promise { + const refs = collectCustomFieldKeys(objects); + const results: WhereUsedMap = {}; + for (const ref of refs) { + results[`${ref.object}.${ref.field}`] = []; + } + if (refs.length === 0) { + return results as FieldUsageJobResultData['whereUsed']; + } + + const fieldIdByKey = await resolveCustomFieldIds(org, refs); + const resolvedEntries = [...fieldIdByKey.entries()]; + + const dependencyRowsByKey = await runWithConcurrency(resolvedEntries, DEPENDENCY_LOOKUP_CONCURRENCY, async ([key, fieldId]) => { + try { + const rows = await getFieldDependencies(org, fieldId); + return { key, rows }; + } catch (err) { + logger.warn('field usage where-used dependency lookup failed; returning empty rows', { err, key }); + return { key, rows: [] as WhereUsedDependencyRow[] }; + } + }); + + const allRows: WhereUsedDependencyRow[] = []; + for (const { rows } of dependencyRowsByKey) { + allRows.push(...rows); + } + try { + await enrichWhereUsedDependencyRows(org, allRows); + } catch (err) { + logger.warn('field usage where-used enrichment failed; returning dependency rows without paths/versions', { err }); + } + + for (const { key, rows } of dependencyRowsByKey) { + results[key] = sortFieldUsageWhereUsedRows(dedupeFieldUsageWhereUsedRows(rows)); + } + + return results as FieldUsageJobResultData['whereUsed']; +} diff --git a/libs/features/data-analysis/src/field-usage/run-field-usage.ts b/libs/features/data-analysis/src/field-usage/run-field-usage.ts new file mode 100644 index 000000000..db1cfe2bd --- /dev/null +++ b/libs/features/data-analysis/src/field-usage/run-field-usage.ts @@ -0,0 +1,318 @@ +import { describeSObject, queryWithRecordBudget } from '@jetstream/shared/data'; +import type { DescribeSObjectResult, Field, SalesforceOrgUi } from '@jetstream/types'; +import { composeQuery, getField } from '@jetstreamapp/soql-parser-js'; + +/** Aligns with permission export row budget intent — keeps long runs bounded per object. */ +export const FIELD_USAGE_MAX_ROWS_PER_OBJECT = 100_000; + +/** + * Hard cap used when {@link RunFieldUsageOptions.loadFullScan} is true. Keeps the worst case + * bounded even in the browser; the previous server-side `Number.MAX_SAFE_INTEGER` would have + * permitted billion-row scans which is hostile UX. + */ +export const FIELD_USAGE_FULL_SCAN_ROW_BUDGET = 5_000_000; + +/** Stay under typical REST SOQL URL limits; split field lists when SELECT grows large. */ +const TARGET_SELECT_CLAUSE_CHARS = 9000; + +export interface FieldUsageStat { + filled: number; + pct: number; + latestFilledRowModified: string | null; +} + +export interface FieldUsageObjectPayload { + label: string; + customizable: boolean; + totalRecords: number; + queryTruncated: boolean; + fieldUsage: Record; + fieldMeta: Record< + string, + { + label: string; + calculated: boolean; + type: string; + custom: boolean; + autoNumber: boolean; + calculatedFormula?: string | null; + externalId: boolean; + nameField: boolean; + extraTypeInfo?: string | null; + length: number; + precision?: number | null; + scale: number; + referenceTo?: string[] | null; + relationshipName?: string | null; + digits?: number | null; + /** Present on some describe payloads for auto-number fields (pattern like `ANN-{0000}`). */ + displayFormat?: string | null; + } + >; + error?: string; +} + +export interface FieldUsageProgress { + current: number; + total: number; + percent: number; + label: string; +} + +export interface RunFieldUsageOptions { + /** When true, lift the per-object row cap to {@link FIELD_USAGE_FULL_SCAN_ROW_BUDGET}. */ + loadFullScan?: boolean; + onProgress?: (progress: FieldUsageProgress) => void; + isCanceled?: () => boolean; +} + +export interface RunFieldUsageQueryResult { + objects: Record; + failedObjects: string[]; + anyQueryTruncated: boolean; +} + +function fieldIsQueryable(field: Field): boolean { + const queryable = (field as unknown as { queryable?: boolean }).queryable; + return queryable !== false; +} + +function getCountableFields(describe: DescribeSObjectResult): Field[] { + return describe.fields.filter((field) => { + if (field.type === 'address') { + return false; + } + if (!field.name || field.name === 'Id' || field.name === 'LastModifiedDate') { + return false; + } + return fieldIsQueryable(field); + }); +} + +function buildFieldUsageSoql(objectApiName: string, fieldNames: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('LastModifiedDate'), ...fieldNames.map((name) => getField(name))], + sObject: objectApiName, + }); +} + +/** + * Splits a list of field names into chunks whose composed SELECT clause fits within the SOQL URL limit. + * Uses a manual character count instead of re-composing the SOQL per candidate field (which made the + * previous implementation O(N²) in composeQuery calls for wide objects). + */ +function buildFieldChunks(fieldNames: string[], objectApiName: string): string[][] { + // Mirror composeQuery output: `SELECT Id, LastModifiedDate, FROM `. + // Each appended field contributes `, ` to the SELECT clause. + const baselineLength = `SELECT Id, LastModifiedDate FROM ${objectApiName}`.length; + const chunks: string[][] = []; + let current: string[] = []; + let currentLength = baselineLength; + + for (const name of fieldNames) { + const addedLength = name.length + 2; + if (current.length > 0 && currentLength + addedLength > TARGET_SELECT_CLAUSE_CHARS) { + chunks.push(current); + current = []; + currentLength = baselineLength; + } + current.push(name); + currentLength += addedLength; + } + if (current.length > 0) { + chunks.push(current); + } + return chunks; +} + +function mergeSfDatetimeMax(left: string | null, right: string | null): string | null { + if (!right) { + return left; + } + if (!left) { + return right; + } + return left.localeCompare(right) >= 0 ? left : right; +} + +function isFilledValue(value: unknown): boolean { + if (value === null || value === undefined) { + return false; + } + if (typeof value === 'string') { + return value.trim() !== ''; + } + return true; +} + +function buildFieldMeta(countable: Field[]): FieldUsageObjectPayload['fieldMeta'] { + const fieldMeta: FieldUsageObjectPayload['fieldMeta'] = {}; + for (const field of countable) { + const describeFieldExtras = field as Field & { displayFormat?: string | null }; + fieldMeta[field.name] = { + label: field.label, + calculated: field.calculated, + type: field.type, + custom: field.custom, + autoNumber: field.autoNumber, + calculatedFormula: field.calculatedFormula ?? null, + externalId: field.externalId, + nameField: field.nameField, + extraTypeInfo: field.extraTypeInfo ?? null, + length: field.length, + precision: field.precision ?? null, + scale: field.scale, + referenceTo: field.referenceTo ?? null, + relationshipName: field.relationshipName ?? null, + digits: field.digits ?? null, + displayFormat: describeFieldExtras.displayFormat ?? null, + }; + } + return fieldMeta; +} + +function throwIfCanceled(isCanceled?: () => boolean): void { + if (isCanceled?.()) { + throw new Error('Job canceled'); + } +} + +/** + * Wide SOQL field usage with streaming aggregation: counts filled values per field without + * retaining records in memory. Memory stays O(fields × objects) regardless of row volume. + */ +export async function runFieldUsageQueryForObjects( + org: SalesforceOrgUi, + objectApiNames: string[], + options?: RunFieldUsageOptions, +): Promise { + const objects: Record = {}; + const failedObjects: string[] = []; + let anyQueryTruncated = false; + const rowBudget = options?.loadFullScan === true ? FIELD_USAGE_FULL_SCAN_ROW_BUDGET : FIELD_USAGE_MAX_ROWS_PER_OBJECT; + const totalObjects = objectApiNames.length; + + for (let objectIndex = 0; objectIndex < totalObjects; objectIndex++) { + const objectApiName = objectApiNames[objectIndex]; + throwIfCanceled(options?.isCanceled); + + const currentProgress = objectIndex + 1; + options?.onProgress?.({ + current: currentProgress, + total: totalObjects, + percent: totalObjects > 0 ? (currentProgress / totalObjects) * 100 : 0, + label: `Analyzing ${objectApiName} (${currentProgress} of ${totalObjects})`, + }); + + try { + const describeResponse = await describeSObject(org, objectApiName); + const describe = describeResponse.data; + + if (!describe.queryable) { + objects[objectApiName] = { + label: describe.label, + customizable: describe.custom, + totalRecords: 0, + queryTruncated: false, + fieldUsage: {}, + fieldMeta: {}, + error: 'Object is not queryable', + }; + continue; + } + + const countable = getCountableFields(describe); + const names = countable.map((field) => field.name); + const fieldMeta = buildFieldMeta(countable); + + if (names.length === 0) { + objects[objectApiName] = { + label: describe.label, + customizable: describe.custom, + totalRecords: 0, + queryTruncated: false, + fieldUsage: {}, + fieldMeta, + }; + continue; + } + + const filled: Record = Object.fromEntries(names.map((name) => [name, 0])); + const maxLmdWhenFilled: Record = Object.fromEntries(names.map((name) => [name, null])); + let totalRecords = 0; + let queryTruncated = false; + const chunks = buildFieldChunks(names, objectApiName); + + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + throwIfCanceled(options?.isCanceled); + + const chunkNames = chunks[chunkIndex]; + const soql = buildFieldUsageSoql(objectApiName, chunkNames); + const budget = { remaining: rowBudget }; + let chunkRecordCount = 0; + + const { truncated } = await queryWithRecordBudget>(org, soql, false, budget, (records) => { + for (const record of records) { + chunkRecordCount += 1; + const lmdRaw = record.LastModifiedDate; + const lmd = typeof lmdRaw === 'string' ? lmdRaw : null; + for (const fieldName of chunkNames) { + const value = record[fieldName]; + if (isFilledValue(value)) { + filled[fieldName] += 1; + if (lmd) { + maxLmdWhenFilled[fieldName] = mergeSfDatetimeMax(maxLmdWhenFilled[fieldName], lmd); + } + } + } + } + }); + + if (truncated) { + queryTruncated = true; + anyQueryTruncated = true; + } + + if (chunkIndex === 0) { + totalRecords = chunkRecordCount; + } + } + + const fieldUsage: Record = {}; + for (const name of names) { + const filledCount = filled[name] ?? 0; + fieldUsage[name] = { + filled: filledCount, + pct: totalRecords > 0 ? (filledCount / totalRecords) * 100 : 0, + latestFilledRowModified: maxLmdWhenFilled[name] ?? null, + }; + } + + objects[objectApiName] = { + label: describe.label, + customizable: describe.custom, + totalRecords, + queryTruncated, + fieldUsage, + fieldMeta, + }; + } catch (ex) { + if (ex instanceof Error && ex.message === 'Job canceled') { + throw ex; + } + failedObjects.push(objectApiName); + const message = ex instanceof Error ? ex.message : 'Unknown error'; + objects[objectApiName] = { + label: objectApiName, + customizable: false, + totalRecords: 0, + queryTruncated: false, + fieldUsage: {}, + fieldMeta: {}, + error: message.slice(0, 500), + }; + } + } + + return { objects, anyQueryTruncated, failedObjects }; +} diff --git a/libs/features/data-analysis/src/index.ts b/libs/features/data-analysis/src/index.ts new file mode 100644 index 000000000..51e2f94c8 --- /dev/null +++ b/libs/features/data-analysis/src/index.ts @@ -0,0 +1,20 @@ +export * from './DataAnalysis'; +export * from './DataAnalysisSelection'; +export * from './FieldUsageAnalysisView'; +export * from './field-usage-result-parse'; +export { analysisJobRuntimeStateAtom, analysisJobRuntimeStateKey, isAnalysisJobActive } from './shared/analysis-job-runtime-state'; +export type { AnalysisJobRuntimeState } from './shared/analysis-job-runtime-state'; +export { computeFieldUsageWhereUsed } from './field-usage/compute-field-usage-where-used'; +export type { WhereUsedDependencyRow, WhereUsedMap } from './field-usage/compute-field-usage-where-used'; +export { + FIELD_USAGE_FULL_SCAN_ROW_BUDGET, + FIELD_USAGE_MAX_ROWS_PER_OBJECT, + runFieldUsageQueryForObjects, +} from './field-usage/run-field-usage'; +export type { + FieldUsageObjectPayload, + FieldUsageProgress, + FieldUsageStat, + RunFieldUsageOptions, + RunFieldUsageQueryResult, +} from './field-usage/run-field-usage'; diff --git a/libs/features/data-analysis/src/shared/analysis-job-runtime-state.ts b/libs/features/data-analysis/src/shared/analysis-job-runtime-state.ts new file mode 100644 index 000000000..60c423ba0 --- /dev/null +++ b/libs/features/data-analysis/src/shared/analysis-job-runtime-state.ts @@ -0,0 +1,51 @@ +import type { AnalysisJobType, AsyncJob, AsyncJobProgress } from '@jetstream/types'; +import { atom } from 'jotai'; + +export interface AnalysisJobRuntimeState { + /** AsyncJob.id from the global jobs system, links runtime state to the Jobs popover entry. */ + jobId: string; + /** Dexie row key that will receive the final result. Same as job.meta.jobHistoryKey. */ + jobHistoryKey: string; + /** Most recent progress event from the job runner. */ + progress: AsyncJobProgress | null; + /** When the job was kicked off, used for elapsed-time display. */ + startedAt: Date; +} + +/** + * Keyed by `${orgUniqueId}:${jobType}` so the views can subscribe to the matching in-flight job + * without scanning the global jobsState. Concurrency rule: one job per (org, jobType). + */ +export const analysisJobRuntimeStateAtom = atom>({}); + +export function analysisJobRuntimeStateKey(orgUniqueId: string, jobType: AnalysisJobType): string { + return `${orgUniqueId}:${jobType}`; +} + +/** + * Returns true if the global jobs map currently holds an in-flight analysis job of the given type + * for the given org. Used by selection screens to block double-enqueue. + */ +export function isAnalysisJobActive( + jobs: Record>, + orgUniqueId: string, + jobType: AnalysisJobType, +): boolean { + return Object.values(jobs).some((job) => { + if (!job) { + return false; + } + const matchesType = + (jobType === 'permission_export' && job.type === 'PermissionExportAnalysis') || + (jobType === 'field_usage' && job.type === 'FieldUsageAnalysis'); + if (!matchesType) { + return false; + } + const inFlight = job.status === 'pending' || job.status === 'in-progress'; + if (!inFlight) { + return false; + } + const meta = job.meta as { orgUniqueId?: string } | undefined; + return meta?.orgUniqueId === orgUniqueId; + }); +} diff --git a/libs/features/data-analysis/src/where-used-open-in-salesforce.ts b/libs/features/data-analysis/src/where-used-open-in-salesforce.ts new file mode 100644 index 000000000..2ae7ee289 --- /dev/null +++ b/libs/features/data-analysis/src/where-used-open-in-salesforce.ts @@ -0,0 +1,51 @@ +import type { WhereUsedDependencyRowParsed } from './field-usage-result-parse'; + +/** + * Relative path (leading `/`) to open this dependency in Salesforce when the job did not store + * {@link WhereUsedDependencyRowParsed.openInSalesforcePath} (older jobs) or for client-side fallback. + */ +export function getWhereUsedOpenInSalesforcePath(row: WhereUsedDependencyRowParsed): string | null { + const fromJob = row.openInSalesforcePath?.trim(); + if (fromJob) { + return fromJob; + } + const t = row.type.trim(); + if (t === 'ProcessDefinition') { + return '/lightning/setup/ProcessAutomation/home'; + } + const id = row.componentId?.trim(); + if (!id) { + return null; + } + if (t === 'ApexClass') { + return `/lightning/setup/ApexClasses/page?address=${encodeURIComponent(encodeURIComponent(`/${id}`))}`; + } + if (t === 'ApexTrigger') { + return `/lightning/setup/ApexTriggers/page?address=${encodeURIComponent(encodeURIComponent(`/${id}`))}`; + } + if (t === 'ApexPage') { + return `/lightning/setup/ApexPages/page?address=${encodeURIComponent(encodeURIComponent(`/${id}`))}`; + } + if (t === 'ApexComponent') { + return `/lightning/setup/ApexComponents/page?address=${encodeURIComponent(encodeURIComponent(`/${id}`))}`; + } + if (t === 'FlexiPage') { + return `/lightning/setup/FlexiPageList/page?address=${encodeURIComponent(encodeURIComponent(`/${id}`))}`; + } + if (t === 'Layout') { + return `/lightning/setup/LayoutDefinitions/page?address=${encodeURIComponent(encodeURIComponent(`/${id}`))}`; + } + if (t === 'FieldSet') { + return `/lightning/setup/FieldSets/page?address=${encodeURIComponent(encodeURIComponent(`/${id}`))}`; + } + if (t === 'WorkflowRule' || t === 'WorkflowFieldUpdate') { + return `/lightning/setup/WorkflowRules/page?address=${encodeURIComponent(encodeURIComponent(`/${id}`))}`; + } + if (t === 'Flow') { + return `/builder_platform_interaction/flowBuilder.app?flowId=${encodeURIComponent(id)}`; + } + if (t === 'FlowDefinition') { + return '/lightning/setup/Flows/home'; + } + return null; +} diff --git a/libs/features/data-analysis/tsconfig.json b/libs/features/data-analysis/tsconfig.json new file mode 100644 index 000000000..9fae121dd --- /dev/null +++ b/libs/features/data-analysis/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@emotion/react", + "allowJs": false, + "lib": ["DOM", "ESNext"], + "strict": true + }, + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }] +} diff --git a/libs/features/data-analysis/tsconfig.lib.json b/libs/features/data-analysis/tsconfig.lib.json new file mode 100644 index 000000000..edefd42fa --- /dev/null +++ b/libs/features/data-analysis/tsconfig.lib.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["node"] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "vite.config.ts", + "vitest.config.ts" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"], + "files": [ + "../../../node_modules/@nx/react/typings/cssmodule.d.ts", + "../../../node_modules/@nx/react/typings/image.d.ts", + "../../../custom-typings/index.d.ts" + ] +} diff --git a/libs/features/data-analysis/tsconfig.spec.json b/libs/features/data-analysis/tsconfig.spec.json new file mode 100644 index 000000000..6c423ca5b --- /dev/null +++ b/libs/features/data-analysis/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"] + }, + "include": [ + "vite.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/features/data-analysis/vite.config.ts b/libs/features/data-analysis/vite.config.ts new file mode 100644 index 000000000..d4d482559 --- /dev/null +++ b/libs/features/data-analysis/vite.config.ts @@ -0,0 +1,22 @@ +/// +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/features/data-analysis', + plugins: [nxViteTsPaths()], + test: { + name: 'features-data-analysis', + watch: false, + globals: true, + environment: 'jsdom', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + passWithNoTests: true, + coverage: { + reportsDirectory: '../../../coverage/libs/features/data-analysis', + provider: 'v8' as const, + }, + }, +})); diff --git a/libs/features/deploy/src/index.ts b/libs/features/deploy/src/index.ts index 1152efe45..c09e58f94 100644 --- a/libs/features/deploy/src/index.ts +++ b/libs/features/deploy/src/index.ts @@ -1,3 +1,4 @@ export * from './DeployMetadata'; export * from './DeployMetadataDeployment'; export * from './DeployMetadataSelection'; +export { DeleteMetadataModal } from './delete-metadata/DeleteMetadataModal'; diff --git a/libs/features/manage-permissions/eslint.config.js b/libs/features/manage-permissions/eslint.config.js index 63a44a705..84c65364a 100644 --- a/libs/features/manage-permissions/eslint.config.js +++ b/libs/features/manage-permissions/eslint.config.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line @nx/enforce-module-boundaries -- workspace root ESLint preset for Nx library projects const baseConfig = require('../../../eslint.config.js'); module.exports = [ diff --git a/libs/features/manage-permissions/src/ManagePermissionsEditor.tsx b/libs/features/manage-permissions/src/ManagePermissionsEditor.tsx index 44e598fd4..17eb99096 100644 --- a/libs/features/manage-permissions/src/ManagePermissionsEditor.tsx +++ b/libs/features/manage-permissions/src/ManagePermissionsEditor.tsx @@ -47,7 +47,7 @@ import { ConfirmPageChange, RequireMetadataApiBanner, fromJetstreamEvents, fromP import { applicationCookieState, googleDriveAccessState, selectedOrgState } from '@jetstream/ui/app-state'; import { useAtom, useAtomValue } from 'jotai'; import { useResetAtom } from 'jotai/utils'; -import { Fragment, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; +import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import ManagePermissionsEditorFieldTable from './ManagePermissionsEditorFieldTable'; import ManagePermissionsEditorObjectTable from './ManagePermissionsEditorObjectTable'; @@ -139,7 +139,18 @@ export const ManagePermissionsEditor: FunctionComponent[]>([]); + const objectColumnsProfilesOnly = useMemo( + () => getObjectColumns(selectedProfiles, [], profilesById, permissionSetsById), + [selectedProfiles, profilesById, permissionSetsById], + ); + const objectColumnsPermissionSetsOnly = useMemo( + () => getObjectColumns([], selectedPermissionSets, profilesById, permissionSetsById), + [selectedPermissionSets, profilesById, permissionSetsById], + ); + const objectColumnsCombined = useMemo( + () => getObjectColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById), + [selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById], + ); const [objectRows, setObjectRows] = useState(null); const [visibleObjectRows, setVisibleObjectRows] = useState(null); const [dirtyObjectRows, setDirtyObjectRows] = useState>>({}); @@ -351,7 +362,6 @@ export const ManagePermissionsEditor: FunctionComponent, ) { if (includeColumns) { - setObjectColumns(getObjectColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById)); setFieldColumns(getFieldColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById)); setTabVisibilityColumns(getTabVisibilityColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById)); } @@ -607,6 +617,84 @@ export const ManagePermissionsEditor: FunctionComponent 0) { + objectPermissionEditorTabs.push({ + id: 'profiles-object-permissions', + title: ( + + + + + Profiles {dirtyObjectCount ? `(${dirtyObjectCount})` : ''} + + + ), + titleText: 'Profiles', + disabled: true, + content: , + }); + } + if (selectedPermissionSets.length > 0) { + objectPermissionEditorTabs.push({ + id: 'permission-sets-object-permissions', + title: ( + + + + + Permission Sets {dirtyObjectCount ? `(${dirtyObjectCount})` : ''} + + + ), + titleText: 'Permission Sets', + disabled: true, + content: , + }); + } + if (objectPermissionEditorTabs.length === 0) { + objectPermissionEditorTabs.push({ + id: 'object-permissions', + title: ( + + + + + Object Permissions {dirtyObjectCount ? `(${dirtyObjectCount})` : ''} + + + ), + titleText: 'Object Permissions', + disabled: true, + content: , + }); + } + return (
@@ -697,40 +785,10 @@ export const ManagePermissionsEditor: FunctionComponent - - - - Object Permissions {dirtyObjectCount ? `(${dirtyObjectCount})` : ''} - - - ), - titleText: 'Object Permissions', - disabled: true, - content: ( - - ), - }, - + ...objectPermissionEditorTabs, { id: 'tab-visibility-permissions', title: ( diff --git a/libs/features/manage-permissions/src/ManagePermissionsSelection.tsx b/libs/features/manage-permissions/src/ManagePermissionsSelection.tsx index 8d47b9dc5..0123030e1 100644 --- a/libs/features/manage-permissions/src/ManagePermissionsSelection.tsx +++ b/libs/features/manage-permissions/src/ManagePermissionsSelection.tsx @@ -2,7 +2,15 @@ import { css } from '@emotion/react'; import { APP_ROUTES } from '@jetstream/shared/ui-router'; import { useNonInitialEffect, useProfilesAndPermSets } from '@jetstream/shared/ui-utils'; import { SplitWrapper as Split } from '@jetstream/splitjs'; -import { DescribeGlobalSObjectResult, ListItem, PermissionSetNoProfileRecord, PermissionSetWithProfileRecord } from '@jetstream/types'; +import { + AsyncJob, + AsyncJobNew, + DescribeGlobalSObjectResult, + ListItem, + PermissionExportAnalysisJob, + PermissionSetNoProfileRecord, + PermissionSetWithProfileRecord, +} from '@jetstream/types'; import { AutoFullHeightContainer, ConnectedSobjectListMultiSelect, @@ -15,21 +23,48 @@ import { PageHeaderTitle, ProfileOrPermSetPopover, ProfileOrPermSetRecordType, + Tooltip, + fireToast, } from '@jetstream/ui'; -import { RequireMetadataApiBanner, fromPermissionsState } from '@jetstream/ui-core'; +import { RequireMetadataApiBanner, fromJetstreamEvents, fromPermissionsState, jobsState } from '@jetstream/ui-core'; import { applicationCookieState, selectSkipFrontdoorAuth, selectedOrgState } from '@jetstream/ui/app-state'; import { recentHistoryItemsDb } from '@jetstream/ui/db'; import { useAtom, useAtomValue } from 'jotai'; import { useResetAtom } from 'jotai/utils'; -import { FunctionComponent, useEffect } from 'react'; -import { Link } from 'react-router-dom'; +import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { PermissionAnalysisHistoryModal } from './PermissionAnalysisHistoryModal'; import { filterPermissionsSobjects } from './utils/permission-manager-utils'; const HEIGHT_BUFFER = 170; -export interface ManagePermissionsSelectionProps {} +/** + * Local mirror of `isAnalysisJobActive` from `@jetstream/feature/data-analysis`. Inlined to avoid + * a circular dependency (data-analysis imports `PermissionAnalysisHistoryModal` from this lib). + */ +function isPermissionExportJobActive(jobs: Record>, orgUniqueId: string): boolean { + return Object.values(jobs).some((job) => { + if (!job || job.type !== 'PermissionExportAnalysis') { + return false; + } + const inFlight = job.status === 'pending' || job.status === 'in-progress'; + if (!inFlight) { + return false; + } + const meta = job.meta as { orgUniqueId?: string } | undefined; + return meta?.orgUniqueId === orgUniqueId; + }); +} + +export type ManagePermissionsSelectionMode = 'manage' | 'permission-analysis'; -export const ManagePermissionsSelection: FunctionComponent = () => { +export interface ManagePermissionsSelectionProps { + /** `permission-analysis` uses the same object picker to optionally narrow ObjectPermissions / FieldPermissions export rows (via job payload `objectApiNames`). */ + selectionMode?: ManagePermissionsSelectionMode; +} + +export const ManagePermissionsSelection: FunctionComponent = ({ selectionMode = 'manage' }) => { + const navigate = useNavigate(); const selectedOrg = useAtomValue(selectedOrgState); const { serverUrl } = useAtomValue(applicationCookieState); const skipFrontDoorAuth = useAtomValue(selectSkipFrontdoorAuth); @@ -60,10 +95,20 @@ export const ManagePermissionsSelection: FunctionComponent { + return selectedProfiles.length > 0 || selectedPermissionSets.length > 0; + }, [selectedProfiles.length, selectedPermissionSets.length]); + + const continueEnabled = selectionMode === 'permission-analysis' ? canContinueAnalysis : hasSelectionsMade; + // Run only on first render useEffect(() => { if (!profiles || !permissionSets) { @@ -90,17 +135,53 @@ export const ManagePermissionsSelection: FunctionComponent { + if (!canContinueAnalysis || !selectedOrg) { + return; + } + if (isPermissionExportJobActive(jobs, selectedOrg.uniqueId)) { + fireToast({ + message: 'A Permission Export job is already running for this org. Wait for it to finish before starting another.', + type: 'warning', + }); + return; + } + + const jobHistoryKey = `aj_${crypto.randomUUID()}`; + const meta: PermissionExportAnalysisJob = { + jobHistoryKey, + orgUniqueId: selectedOrg.uniqueId, + profileIds: selectedProfiles, + permissionSetIds: selectedPermissionSets, + ...(selectedSObjects.length > 0 ? { objectApiNames: selectedSObjects } : {}), + }; + const asyncJobNew: AsyncJobNew = { + type: 'PermissionExportAnalysis', + title: `Permission Export (${selectedProfiles.length + selectedPermissionSets.length} selection${ + selectedProfiles.length + selectedPermissionSets.length === 1 ? '' : 's' + })`, + org: selectedOrg, + meta, + viewUrl: `${APP_ROUTES.PERMISSION_ANALYSIS.ROUTE}/analysis?job=${encodeURIComponent(jobHistoryKey)}`, + }; + fromJetstreamEvents.emit({ type: 'newJob', payload: [asyncJobNew] }); + fireToast({ message: 'Permission Export job started. Loading results…', type: 'success' }); + navigate(`analysis?job=${encodeURIComponent(jobHistoryKey)}`); + }, [canContinueAnalysis, jobs, navigate, selectedOrg, selectedPermissionSets, selectedProfiles, selectedSObjects]); + function handleContinue() { - if (!sobjects?.length) { + if (!sobjects || !sobjects.length) { return; } recentHistoryItemsDb.addItemToRecentHistoryItems( selectedOrg.uniqueId, 'sobject', - sobjects.map(({ name }) => name), + sobjects.map((row: DescribeGlobalSObjectResult) => row.name), ); } + const isAnalysis = selectionMode === 'permission-analysis'; + return ( @@ -108,17 +189,44 @@ export const ManagePermissionsSelection: FunctionComponent - {hasSelectionsMade && ( + {isAnalysis && ( + + + + )} + {!isAnalysis && ( + + Open in Permission Analysis + + )} + {continueEnabled && !isAnalysis && ( Continue )} - {!hasSelectionsMade && ( + {continueEnabled && isAnalysis && ( + + )} + {!continueEnabled && (
+ {isHistoryOpen && selectedOrg && ( + setIsHistoryOpen(false)} + onSelectJob={(nextJobId) => { + setIsHistoryOpen(false); + navigate(`analysis?job=${encodeURIComponent(nextJobId)}`); + }} + /> + )} { + useTitle(TITLES.PERMISSION_ANALYSIS); + const location = useLocation(); + const selectedOrg = useAtomValue(selectedOrgState); + const resetProfilesState = useResetAtom(fromPermissionsState.profilesState); + const resetSelectedProfilesPermSetState = useResetAtom(fromPermissionsState.selectedProfilesPermSetState); + const resetPermissionSetsState = useResetAtom(fromPermissionsState.permissionSetsState); + const resetSelectedPermissionSetsState = useResetAtom(fromPermissionsState.selectedPermissionSetsState); + const resetSObjectsState = useResetAtom(fromPermissionsState.sObjectsState); + const resetSelectedSObjectsState = useResetAtom(fromPermissionsState.selectedSObjectsState); + const resetFieldsByObject = useResetAtom(fromPermissionsState.fieldsByObject); + const resetFieldsByKey = useResetAtom(fromPermissionsState.fieldsByKey); + const resetObjectPermissionMap = useResetAtom(fromPermissionsState.objectPermissionMap); + const resetFieldPermissionMap = useResetAtom(fromPermissionsState.fieldPermissionMap); + const resetTabVisibilityPermissionMap = useResetAtom(fromPermissionsState.tabVisibilityPermissionMap); + const [priorSelectedOrg, setPriorSelectedOrg] = useState(null); + + const selectedProfiles = useAtomValue(fromPermissionsState.selectedProfilesPermSetState); + const selectedPermissionSets = useAtomValue(fromPermissionsState.selectedPermissionSetsState); + const hasAnalysisSelections = selectedProfiles.length > 0 || selectedPermissionSets.length > 0; + const hasJobInUrl = Boolean(new URLSearchParams(location.search).get('job')); + + useEffect(() => { + if (selectedOrg && !priorSelectedOrg) { + setPriorSelectedOrg(selectedOrg.uniqueId); + } else if (selectedOrg && priorSelectedOrg !== selectedOrg.uniqueId) { + setPriorSelectedOrg(selectedOrg.uniqueId); + resetProfilesState(); + resetSelectedProfilesPermSetState(); + resetPermissionSetsState(); + resetSelectedPermissionSetsState(); + resetSObjectsState(); + resetSelectedSObjectsState(); + resetFieldsByObject(); + resetFieldsByKey(); + resetObjectPermissionMap(); + resetFieldPermissionMap(); + resetTabVisibilityPermissionMap(); + } else if (!selectedOrg?.uniqueId) { + resetProfilesState(); + resetSelectedProfilesPermSetState(); + resetPermissionSetsState(); + resetSelectedPermissionSetsState(); + resetSObjectsState(); + resetSelectedSObjectsState(); + resetFieldsByObject(); + resetFieldsByKey(); + resetObjectPermissionMap(); + resetFieldPermissionMap(); + resetTabVisibilityPermissionMap(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedOrg, priorSelectedOrg]); + + const blockAnalysisWithoutSelections = location.pathname.endsWith('/analysis') && !hasAnalysisSelections && !hasJobInUrl; + + return blockAnalysisWithoutSelections ? : ; +}; + +export default PermissionAnalysis; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisExportGrid.tsx b/libs/features/manage-permissions/src/PermissionAnalysisExportGrid.tsx new file mode 100644 index 000000000..1cd47ec85 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisExportGrid.tsx @@ -0,0 +1,711 @@ +import { css } from '@emotion/react'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import { + AutoFullHeightContainer, + DataTable, + Grid, + GridCol, + Icon, + KeyboardShortcut, + Popover, + ReadOnlyFormElement, + SalesforceLogin, + ScopedNotification, + salesforceLoginAndRedirect, +} from '@jetstream/ui'; +import type { ColumnWithFilter, RowWithKey } from '@jetstream/ui'; +import { FunctionComponent, Fragment, type MouseEvent, useCallback, useMemo, useState } from 'react'; +import type { CellMouseArgs } from 'react-data-grid'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { + buildContainerIdFindingSeverity, + buildDynamicExportColumns, + buildFieldPermissionFindingCellHighlights, + fieldPermissionCellSeverity, + formatObjectLabelForModalSummary, + listFindingsForExportContainer, + listFindingsForFieldPermissionCell, + pickAssignmentExportClickableColumnKeys, + pickPermissionSetExportClickableColumnKeys, + pickTabVisibilityExportClickableColumnKeys, + type PermissionAnalysisFinding, + type PermissionExportRow, + type SobjectExportDetail, +} from './permission-export-result-view'; + +const OBJECT_PERMISSIONS_OMIT_KEYS = new Set(['attributes', 'Id', 'ParentId']); + +/** Bare (borderless) base icon buttons for object cell actions, grouped in SLDS button group. */ +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +/** Default grid width for text columns; Object column needs extra space for label + actions. */ +const DEFAULT_TEXT_COLUMN_WIDTH = 175; + +/** Label + tooltip; optional Object Manager icon only (no separate info popover). */ +const OBJECT_NAME_COLUMN_MIN_WIDTH_ONE_ACTION = 200; +/** `minmax` + `fr`: Object column grows; floor keeps label + icon actions usable. */ +const EXPORT_GRID_OBJECT_NAME_FR = 1.65; + +const PERMISSION_ANALYSIS_POPOVER_PANEL_PROPS = { + onDoubleClick: (event: MouseEvent) => { + event.stopPropagation(); + }, +}; + +export type PermissionAnalysisExportGridVariant = 'default' | 'object_permissions'; + +/** Which export surface receives issue highlights and cell drill-in (see permission export analysis job). */ +export type PermissionAnalysisExportFindingSurface = + | 'none' + | 'field_permissions' + | 'container_row' + | 'assignment_row' + | 'tab_visibility_row'; + +export interface PermissionAnalysisExportGridProps { + rows: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + /** Describe + EntityDefinition metadata for SobjectType cells (label, API Name, description). */ + sobjectExportDetails?: Record; + /** `object_permissions` — hides Id/ParentId/attributes and adds Object Manager link on SobjectType. */ + variant?: PermissionAnalysisExportGridVariant; + /** Parsed `analysis_job.result.findings` when this grid should surface issue rows. */ + findings?: PermissionAnalysisFinding[]; + /** How issues map onto this grid; defaults to `none`. */ + findingSurface?: PermissionAnalysisExportFindingSurface; + /** Display names for permission set Ids (modal context lines). */ + containerLabelById?: Map; +} + +/** Object label (click for API / label / description popover, Field Usage–style) and optional Object Manager link. */ +export const SobjectTypeCellContent: FunctionComponent<{ + apiName: string; + detail: SobjectExportDetail | undefined; + objectManager?: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; +}> = ({ apiName, detail, objectManager }) => { + const label = detail?.label?.trim() ? detail.label.trim() : apiName; + const descriptionText = + detail?.description != null && String(detail.description).trim().length > 0 ? String(detail.description).trim() : null; + const slug = apiName.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const objectManagerReturnUrl = `/lightning/setup/ObjectManager/${encodeURIComponent(apiName)}/Details/view`; + const canDeepLink = Boolean(objectManager?.org?.uniqueId && objectManager.serverUrl); + + return ( +
+
+ + {canDeepLink && objectManager ? ( + + View in Salesforce + + ) : null} + + + + + + + + + + + {canDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+
+ } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + width: '100%', + minWidth: 0, + height: 'auto', + padding: 0, + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink || !objectManager) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl: objectManager.serverUrl, + org: objectManager.org, + returnUrl: objectManagerReturnUrl, + skipFrontDoorAuth: objectManager.skipFrontDoorAuth, + }); + } + }} + > + {label} + + +
+ {objectManager && ( +
+ + + +
+ )} + + ); +}; + +function relaxExportPermissionColumnsForFlex(columns: ColumnWithFilter[]): ColumnWithFilter[] { + return columns.map((column) => { + if (typeof column.key !== 'string' || !column.key.startsWith('Permissions')) { + return column; + } + return { + ...column, + width: 'minmax(100px, 0.32fr)', + minWidth: 100, + } as ColumnWithFilter; + }); +} + +function applySobjectTypeColumn( + columns: ColumnWithFilter[], + options: { + sobjectExportDetails?: Record; + objectManager?: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; + }, +): ColumnWithFilter[] { + const { sobjectExportDetails, objectManager } = options; + const detailCount = sobjectExportDetails ? Object.keys(sobjectExportDetails).length : 0; + if (!objectManager && detailCount === 0) { + return columns; + } + + return columns.map((column) => { + if (column.key !== 'SobjectType') { + return column; + } + const priorMinWidth = typeof column.minWidth === 'number' ? column.minWidth : 0; + const reservedMinWidth = objectManager ? OBJECT_NAME_COLUMN_MIN_WIDTH_ONE_ACTION : DEFAULT_TEXT_COLUMN_WIDTH; + const objectNameFloor = Math.max(priorMinWidth, reservedMinWidth, DEFAULT_TEXT_COLUMN_WIDTH); + + return { + ...column, + minWidth: objectNameFloor, + width: `minmax(${objectNameFloor}px, ${EXPORT_GRID_OBJECT_NAME_FR}fr)`, + getValue: ({ row }) => { + const api = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!api) { + return null; + } + const detail = sobjectExportDetails?.[api]; + const label = detail?.label?.trim() ? detail.label.trim() : api; + const parts = [label, api]; + if (detail?.description != null && String(detail.description).trim().length > 0) { + parts.push(String(detail.description).trim()); + } + return parts.join(' '); + }, + renderCell: (props) => { + const raw = props.row?.SobjectType; + const apiName = typeof raw === 'string' ? raw.trim() : ''; + if (!apiName) { + return
; + } + const detail = sobjectExportDetails?.[apiName]; + return ; + }, + } as ColumnWithFilter; + }); +} + +type FieldCellModalState = { + kind: 'field'; + parentId: string; + objectApiName: string; + fieldApiName: string; + columnKey: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +}; + +type ContainerModalState = { + kind: 'container'; + containerId: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +}; + +type ExportFindingsModalState = FieldCellModalState | ContainerModalState | null; + +function mergeFindingCellClass( + column: ColumnWithFilter, + extraClass: (row: T) => string | undefined, +): ColumnWithFilter { + const prior = column.cellClass; + return { + ...column, + cellClass: (row: T) => { + const a = typeof prior === 'function' ? prior(row) : prior; + const b = extraClass(row); + const merged = [a, b].filter(Boolean).join(' '); + return merged.length > 0 ? merged : undefined; + }, + } as ColumnWithFilter; +} + +/** + * Read-only SOQL export rows with dynamic columns and quick filter. + * Optional issue highlights for field permissions, permission set / profile rows, and assignments. + */ +export const PermissionAnalysisExportGrid: FunctionComponent = ({ + rows, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, + variant = 'default', + findings = [], + findingSurface = 'none', + containerLabelById, +}) => { + const [modalState, setModalState] = useState(null); + + const fieldHighlights = useMemo(() => { + if (findingSurface !== 'field_permissions' || findings.length === 0) { + return null; + } + return buildFieldPermissionFindingCellHighlights(findings); + }, [findingSurface, findings]); + + const containerSeverity = useMemo(() => { + if ( + (findingSurface !== 'container_row' && findingSurface !== 'assignment_row' && findingSurface !== 'tab_visibility_row') || + findings.length === 0 + ) { + return null; + } + return buildContainerIdFindingSeverity(findings); + }, [findingSurface, findings]); + + const permissionSetClickColumns = useMemo(() => { + if (findingSurface !== 'container_row' || rows.length === 0) { + return [] as string[]; + } + return pickPermissionSetExportClickableColumnKeys(rows[0]); + }, [findingSurface, rows]); + + const assignmentClickColumns = useMemo(() => { + if (findingSurface !== 'assignment_row' || rows.length === 0) { + return [] as string[]; + } + return pickAssignmentExportClickableColumnKeys(rows[0]); + }, [findingSurface, rows]); + + const tabVisibilityClickColumns = useMemo(() => { + if (findingSurface !== 'tab_visibility_row' || rows.length === 0) { + return [] as string[]; + } + return pickTabVisibilityExportClickableColumnKeys(rows[0]); + }, [findingSurface, rows]); + + const baseColumns = useMemo(() => { + const dynamicOptions = variant === 'object_permissions' ? { omitColumnKeys: OBJECT_PERMISSIONS_OMIT_KEYS } : undefined; + + const base = buildDynamicExportColumns(rows, dynamicOptions); + + const objectManager = variant === 'object_permissions' ? { org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin } : undefined; + const detailCount = sobjectExportDetails ? Object.keys(sobjectExportDetails).length : 0; + if (!objectManager && detailCount === 0) { + return base; + } + const withObjectColumn = applySobjectTypeColumn(base, { sobjectExportDetails, objectManager }); + return relaxExportPermissionColumnsForFlex(withObjectColumn); + }, [rows, variant, org, serverUrl, skipFrontdoorLogin, sobjectExportDetails]); + + const columns = useMemo(() => { + if (findings.length === 0 || findingSurface === 'none') { + return baseColumns; + } + + if (findingSurface === 'field_permissions' && fieldHighlights) { + return baseColumns.map((col) => { + const key = typeof col.key === 'string' ? col.key : ''; + return mergeFindingCellClass(col, (row: RowWithKey) => { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const objectApi = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const fieldApi = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!parentId || !objectApi || !fieldApi) { + return undefined; + } + const severity = fieldPermissionCellSeverity(fieldHighlights, parentId, objectApi, fieldApi, key); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }); + }); + } + + if (findingSurface === 'container_row' && containerSeverity) { + return baseColumns.map((col) => { + const key = typeof col.key === 'string' ? col.key : ''; + const isClickColumn = permissionSetClickColumns.includes(key); + return mergeFindingCellClass(col, (row: RowWithKey) => { + if (!isClickColumn) { + return undefined; + } + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (!rowId) { + return undefined; + } + const severity = containerSeverity.get(rowId); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }); + }); + } + + if (findingSurface === 'assignment_row' && containerSeverity) { + return baseColumns.map((col) => { + const key = typeof col.key === 'string' ? col.key : ''; + const isClickColumn = assignmentClickColumns.includes(key); + return mergeFindingCellClass(col, (row: RowWithKey) => { + if (!isClickColumn) { + return undefined; + } + const permissionSetId = typeof row.PermissionSetId === 'string' ? row.PermissionSetId.trim() : ''; + if (!permissionSetId) { + return undefined; + } + const severity = containerSeverity.get(permissionSetId); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }); + }); + } + + if (findingSurface === 'tab_visibility_row' && containerSeverity) { + return baseColumns.map((col) => { + const key = typeof col.key === 'string' ? col.key : ''; + const isClickColumn = tabVisibilityClickColumns.includes(key); + return mergeFindingCellClass(col, (row: RowWithKey) => { + if (!isClickColumn) { + return undefined; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + if (!parentId) { + return undefined; + } + const severity = containerSeverity.get(parentId); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }); + }); + } + + return baseColumns; + }, [ + baseColumns, + findings.length, + findingSurface, + fieldHighlights, + containerSeverity, + permissionSetClickColumns, + assignmentClickColumns, + tabVisibilityClickColumns, + ]); + + const handleCellClick = useCallback( + ({ row, column }: CellMouseArgs) => { + if (findings.length === 0) { + return; + } + const columnKey = typeof column.key === 'string' ? column.key : ''; + const columnLabel = typeof column.name === 'string' && column.name.trim().length > 0 ? column.name : columnKey; + + if (findingSurface === 'field_permissions' && fieldHighlights) { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const objectApiName = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const fieldApiName = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!parentId || !objectApiName || !fieldApiName) { + return; + } + const severity = fieldPermissionCellSeverity(fieldHighlights, parentId, objectApiName, fieldApiName, columnKey); + if (!severity) { + return; + } + const matches = listFindingsForFieldPermissionCell(findings, parentId, objectApiName, fieldApiName, columnKey); + if (matches.length === 0) { + return; + } + setModalState({ + kind: 'field', + parentId, + objectApiName, + fieldApiName, + columnKey, + columnLabel, + matches, + }); + return; + } + + if (findingSurface === 'container_row' && containerSeverity) { + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (!rowId || !permissionSetClickColumns.includes(columnKey)) { + return; + } + if (!containerSeverity.has(rowId)) { + return; + } + const matches = listFindingsForExportContainer(findings, rowId); + if (matches.length === 0) { + return; + } + setModalState({ + kind: 'container', + containerId: rowId, + columnLabel, + matches, + }); + return; + } + + if (findingSurface === 'assignment_row' && containerSeverity) { + const permissionSetId = typeof row.PermissionSetId === 'string' ? row.PermissionSetId.trim() : ''; + if (!permissionSetId || !assignmentClickColumns.includes(columnKey)) { + return; + } + if (!containerSeverity.has(permissionSetId)) { + return; + } + const matches = listFindingsForExportContainer(findings, permissionSetId); + if (matches.length === 0) { + return; + } + setModalState({ + kind: 'container', + containerId: permissionSetId, + columnLabel, + matches, + }); + return; + } + + if (findingSurface === 'tab_visibility_row' && containerSeverity) { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + if (!parentId || !tabVisibilityClickColumns.includes(columnKey)) { + return; + } + if (!containerSeverity.has(parentId)) { + return; + } + const matches = listFindingsForExportContainer(findings, parentId); + if (matches.length === 0) { + return; + } + setModalState({ + kind: 'container', + containerId: parentId, + columnLabel, + matches, + }); + } + }, + [ + findings, + findingSurface, + fieldHighlights, + containerSeverity, + permissionSetClickColumns, + assignmentClickColumns, + tabVisibilityClickColumns, + ], + ); + + const rowsMap = useMemo(() => new WeakMap(rows.map((row, index) => [row, `export-row-${index}`])), [rows]); + const getRowKey = useCallback((row: PermissionExportRow) => rowsMap.get(row) ?? 'row', [rowsMap]); + + const fieldModalObjectSummary = useMemo(() => { + if (!modalState || modalState.kind !== 'field') { + return null; + } + return formatObjectLabelForModalSummary(modalState.objectApiName, sobjectExportDetails); + }, [modalState, sobjectExportDetails]); + + if (!rows.length) { + return ( +
+ No rows in this export slice. +
+ ); + } + + const showFindingClickHandler = + findings.length > 0 && + (findingSurface === 'field_permissions' || + findingSurface === 'container_row' || + findingSurface === 'assignment_row' || + findingSurface === 'tab_visibility_row'); + + return ( + + + + {modalState?.kind === 'field' && ( + setModalState(null)} + findings={modalState.matches} + summaryLine={ + + {modalState.columnLabel} + {' · '} + {fieldModalObjectSummary?.displayLabel ? ( + fieldModalObjectSummary.showApiInParens ? ( + + {fieldModalObjectSummary.displayLabel} + + {' '} + ({modalState.objectApiName}) + + + ) : ( + {fieldModalObjectSummary.displayLabel} + ) + ) : null} + {' · '} + {modalState.fieldApiName} + {' · '} + {containerLabelById?.get(modalState.parentId) ?? modalState.parentId} — {modalState.matches.length}{' '} + {modalState.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + {modalState?.kind === 'container' && ( + setModalState(null)} + findings={modalState.matches} + summaryLine={ + + {modalState.columnLabel} + {' · '} + {containerLabelById?.get(modalState.containerId) ?? modalState.containerId} — {modalState.matches.length}{' '} + {modalState.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisFieldPermissionsTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisFieldPermissionsTree.tsx new file mode 100644 index 000000000..1611dae26 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisFieldPermissionsTree.tsx @@ -0,0 +1,890 @@ +/* eslint-disable react-hooks/refs */ +import { css } from '@emotion/react'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import { + AutoFullHeightContainer, + ColumnWithFilter, + DataTree, + getProfileOrPermSetSetupUrl, + getRowTypeFromValue, + Grid, + GridCol, + Icon, + KeyboardShortcut, + Popover, + ReadOnlyFormElement, + SalesforceLogin, + salesforceLoginAndRedirect, + ScopedNotification, + setColumnFromType, + type ProfileOrPermSetRecordType, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { + forwardRef, + Fragment, + FunctionComponent, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type MouseEvent, +} from 'react'; +import type { CellMouseArgs, RenderCellProps, RenderGroupCellProps } from 'react-data-grid'; +import { usePermissionAnalysisExportMetadata } from './permission-analysis-export-metadata-context'; +import { permissionAnalysisPermissionContainerGroupTitleLine } from './permission-analysis-tree-group-title'; +import { permissionAnalysisAssignmentTypeLabelCss } from './permission-analysis-viewer-badge.styles'; +import { + buildFieldPermissionFindingCellHighlights, + buildPermissionSetIdLabelMap, + FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS, + fieldExportDetailLookupKey, + fieldPermissionCellSeverity, + fieldPermissionQualifiedFieldShortApi, + formatObjectLabelForModalSummary, + getExportColumnHeaderLabel, + listFindingsForFieldPermissionCell, + sortFieldPermissionExportRowsForAnalysisTree, + type PermissionAnalysisFinding, + type PermissionExportRow, + type SobjectExportDetail, +} from './permission-export-result-view'; +import { SobjectTypeCellContent } from './PermissionAnalysisExportGrid'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { buildPermissionSetTooltipFieldsFromExportRow, PermissionSetDetailPopoverContent } from './PermissionAnalysisPermissionSetsTree'; + +const TREE_GROUP_BY = ['_treePermSetGroupKey', '_treeObjectGroupKey'] as const; + +const TREE_PERM_SET_MIN_PX = 140; +const TREE_PERM_SET_MAX_PX = 420; +const TREE_COL_PERM_SET = `minmax(${TREE_PERM_SET_MIN_PX}px, min(${TREE_PERM_SET_MAX_PX}px, 1.35fr))`; + +const TREE_OBJECT_GROUP_WIDTH_PX = 236; + +const TREE_FIELD_COL = `minmax(200px, min(320px, 1.25fr))`; +const TREE_COL_PERMISSION_BOOL = 'minmax(104px, 0.42fr)'; + +const TREE_MIN_PERM_SET = TREE_PERM_SET_MIN_PX; +const TREE_MIN_PERMISSION_BOOL = 104; + +const TREE_ROW_HEIGHT_LEAF_PX = 35; +const TREE_ROW_HEIGHT_GROUP_PX = 68; + +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +const PERMISSION_ANALYSIS_POPOVER_PANEL_PROPS = { + onDoubleClick: (event: MouseEvent) => { + event.stopPropagation(); + }, +}; + +function resolveSetupTargetForFieldTreeParentRow( + permissionSetId: string, + row: PermissionExportRow | undefined, +): { recordType: ProfileOrPermSetRecordType; recordId: string } { + if (!row) { + return { recordType: 'PermissionSet', recordId: permissionSetId }; + } + const profileId = typeof row.ProfileId === 'string' && row.ProfileId.trim().length > 0 ? row.ProfileId.trim() : null; + const isProfileOwned = row.IsOwnedByProfile === true; + if (isProfileOwned && profileId) { + return { recordType: 'Profile', recordId: profileId }; + } + return { recordType: 'PermissionSet', recordId: permissionSetId }; +} + +export type FieldPermissionTreeRow = PermissionExportRow & { + _treePermSetGroupKey: string; + _treeObjectGroupKey: string; +}; + +function buildFieldPermissionTreeRows(fieldPermissionRows: PermissionExportRow[]): FieldPermissionTreeRow[] { + return fieldPermissionRows.map((row, index) => { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + return { + ...row, + _treePermSetGroupKey: parentId || `__missing_parent_${index}`, + _treeObjectGroupKey: sobjectType || `__missing_object_${index}`, + }; + }); +} + +function collectAllFieldPermissionExpandedGroupIds(rows: readonly PermissionExportRow[]): Set { + const ids = new Set(); + for (let index = 0; index < rows.length; index++) { + const row = rows[index]; + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const permSetKey = parentId || `__missing_parent_${index}`; + const objectKey = sobjectType || `__missing_object_${index}`; + ids.add(permSetKey); + ids.add(`${permSetKey}__${objectKey}`); + } + return ids; +} + +function renderFieldPermissionPermissionSetGroupCell( + labelByParentId: Map, + permissionSetRowById: Map, + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const id = String(groupKey); + const exportLabel = labelByParentId.get(id) ?? id; + const permSetRow = permissionSetRowById.get(id); + const isProfileOwned = permSetRow?.IsOwnedByProfile === true; + const titleLine = permissionAnalysisPermissionContainerGroupTitleLine(exportLabel, isProfileOwned); + const typeKind = isProfileOwned ? 'profile' : 'permission_set'; + const typeCaption = isProfileOwned ? 'Profile' : 'Permission set'; + const detailFields = buildPermissionSetTooltipFieldsFromExportRow(permSetRow) ?? { + label: exportLabel, + name: '—', + description: null, + createdWhen: null, + createdByName: null, + lastModifiedWhen: null, + lastModifiedByName: null, + }; + const { recordType, recordId } = resolveSetupTargetForFieldTreeParentRow(id, permSetRow); + const returnUrl = getProfileOrPermSetSetupUrl(recordType, recordId); + const containerKind: 'Profile' | 'PermissionSet' = recordType === 'Profile' ? 'Profile' : 'PermissionSet'; + const detailSlug = id.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const canDeepLink = Boolean(setupLogin.org?.uniqueId && setupLogin.serverUrl); + + return ( +
+ + + } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + flex: 1, + minWidth: 0, + height: 'auto', + alignItems: 'flex-start', + display: 'flex', + padding: 0, + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl: setupLogin.serverUrl, + org: setupLogin.org, + returnUrl, + skipFrontDoorAuth: setupLogin.skipFrontDoorAuth, + }); + } + }} + > +

+ {typeCaption} +

+ + {titleLine} + ({childRows.length}) + +
+
+
+ ); +} + +interface FieldPermissionObjectGroupCellContentProps { + groupKey: unknown; + childRows: readonly FieldPermissionTreeRow[]; + isExpanded: boolean; + toggleGroup: () => void; + objectManager: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; +} + +/** + * Renders the per-object group header inside the field permissions tree. + * Reads `sobjectExportDetails` via context so async label loads repaint this cell without rebuilding + * the parent `columns` memo (which would force `useDataTable` to redo its INIT/set-filter pass). + */ +function FieldPermissionObjectGroupCellContent({ + groupKey, + childRows, + isExpanded, + toggleGroup, + objectManager, +}: FieldPermissionObjectGroupCellContentProps) { + const { sobjectExportDetails } = usePermissionAnalysisExportMetadata(); + const apiName = String(groupKey).trim(); + if (!apiName) { + return null; + } + const detail = sobjectExportDetails?.[apiName]; + return ( +
+ +
+

Object

+
+
+ +
+ ({childRows.length}) + { + event.stopPropagation(); + }} + > + + +
+
+
+ ); +} + +interface FieldPermissionFieldCellContentProps { + row: FieldPermissionTreeRow | undefined; + objectManager: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; +} + +/** + * Renders the Field column cell. Subscribes to metadata via context so a metadata batch repaints + * just the visible cells without invalidating the parent `columns` memo (and dragging `useDataTable` + * through a full set-filter / search-index rebuild). + */ +function FieldPermissionFieldCellContent({ row, objectManager }: FieldPermissionFieldCellContentProps) { + const { sobjectExportDetails, fieldExportDetails } = usePermissionAnalysisExportMetadata(); + const obj = typeof row?.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const short = row ? fieldPermissionQualifiedFieldShortApi(row) : ''; + const full = typeof row?.Field === 'string' ? row.Field.trim() : ''; + const lookupKey = obj && short ? fieldExportDetailLookupKey(obj, short) : ''; + const fd = lookupKey ? fieldExportDetails?.[lookupKey] : undefined; + const label = fd?.label?.trim() ? fd.label.trim() : short || full; + const apiLine = short || full; + const descriptionText = fd?.description != null && String(fd.description).trim().length > 0 ? String(fd.description).trim() : null; + const durableId = fd?.durableId?.trim() ? fd.durableId.trim() : null; + const fieldSetupUrl = + durableId && obj + ? `/lightning/setup/ObjectManager/${encodeURIComponent(obj)}/FieldsAndRelationships/${encodeURIComponent(durableId)}/view` + : null; + const fieldSlug = `${obj}-${apiLine}`.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const canFieldDeepLink = Boolean(fieldSetupUrl && objectManager.org?.uniqueId && objectManager.serverUrl); + const parentObjectLabel = obj && sobjectExportDetails?.[obj]?.label?.trim() ? String(sobjectExportDetails[obj].label).trim() : obj; + const parentObjectSummary = obj ? `${parentObjectLabel} (${obj})` : '—'; + + if (!label && !full) { + return
; + } + + return ( +
+
+ + {canFieldDeepLink && fieldSetupUrl ? ( + + View in Salesforce + + ) : null} + + + + + + + + + + + + + + {canFieldDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+
+ } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + width: '100%', + minWidth: 0, + height: 'auto', + padding: 0, + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canFieldDeepLink || !fieldSetupUrl) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl: objectManager.serverUrl, + org: objectManager.org, + returnUrl: fieldSetupUrl, + skipFrontDoorAuth: objectManager.skipFrontDoorAuth, + }); + } + }} + > + {label || apiLine} + + +
+ {fieldSetupUrl ? ( +
+ + + +
+ ) : null} + + ); +} + +function isFieldPermissionLeafRow(row: unknown): row is FieldPermissionTreeRow { + if (row === null || typeof row !== 'object') { + return false; + } + const record = row as Record; + return typeof record.ParentId === 'string' && record.ParentId.trim().length > 0; +} + +interface CellFindingsModalState { + parentId: string; + objectApiName: string; + fieldApiName: string; + columnKey: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +} + +interface CellFindingsModalHostHandle { + open: (state: CellFindingsModalState) => void; +} + +interface CellFindingsModalHostProps { + labelByParentId: Map; + sobjectExportDetails?: Record; +} + +/** + * Owns the cell-findings modal state in isolation so opening/closing the modal doesn't re-render + * the field-permissions tree (which would cascade 1000+ Cell re-renders for large datasets). + * The parent calls `open(state)` imperatively via the forwarded ref. + */ +const CellFindingsModalHost = forwardRef( + ({ labelByParentId, sobjectExportDetails }, ref) => { + const [state, setState] = useState(null); + + useImperativeHandle(ref, () => ({ open: setState }), []); + + const objectSummary = useMemo(() => { + if (!state) { + return null; + } + return formatObjectLabelForModalSummary(state.objectApiName, sobjectExportDetails); + }, [state, sobjectExportDetails]); + + if (!state) { + return null; + } + return ( + setState(null)} + findings={state.matches} + summaryLine={ + + {state.columnLabel} + {' · '} + {objectSummary?.displayLabel ? ( + objectSummary.showApiInParens ? ( + + {objectSummary.displayLabel} + + {' '} + ({state.objectApiName}) + + + ) : ( + {objectSummary.displayLabel} + ) + ) : null} + {' · '} + {state.fieldApiName} + {' · '} + {labelByParentId.get(state.parentId) ?? state.parentId} — {state.matches.length}{' '} + {state.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + ); + }, +); +CellFindingsModalHost.displayName = 'CellFindingsModalHost'; + +export interface PermissionAnalysisFieldPermissionsTreeProps { + fieldPermissionRows: PermissionExportRow[]; + permissionSetRows: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + sobjectExportDetails?: Record; + findings?: PermissionAnalysisFinding[]; +} + +/** + * Field permissions grouped by profile or permission set, then by object; Read and Edit columns match object-level order. + */ +export const PermissionAnalysisFieldPermissionsTree: FunctionComponent = ({ + fieldPermissionRows, + permissionSetRows, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, + findings = [], +}) => { + const { fieldExportDetails } = usePermissionAnalysisExportMetadata(); + /** + * Sort by API name only — keeps `sortedRows` identity stable across async metadata loads so + * `treeRows`, `columns`, and the rdg row-array reference don't churn on each label batch. + * Cells still re-display updated labels because cell renderers read metadata at render time. + */ + const sortedRows = useMemo( + () => sortFieldPermissionExportRowsForAnalysisTree(fieldPermissionRows, permissionSetRows), + [fieldPermissionRows, permissionSetRows], + ); + const treeRows = useMemo(() => buildFieldPermissionTreeRows(sortedRows), [sortedRows]); + const labelByParentId = useMemo(() => buildPermissionSetIdLabelMap(permissionSetRows), [permissionSetRows]); + const permissionSetRowById = useMemo(() => { + const map = new Map(); + for (const row of permissionSetRows) { + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (rowId) { + map.set(rowId, row); + } + } + return map; + }, [permissionSetRows]); + + const findingCellHighlights = useMemo(() => buildFieldPermissionFindingCellHighlights(findings), [findings]); + + /** + * Read finding highlights through a ref inside `cellClass` so the `columns` memo identity stays + * stable when findings change. Otherwise every findings update invalidates `columns`, which forces + * `useDataTable` to rebuild the per-row search index (~40K ops on 6729 rows) and re-render. + */ + const findingCellHighlightsRef = useRef(findingCellHighlights); + findingCellHighlightsRef.current = findingCellHighlights; + + /** + * Same trick for async metadata: cells that need labels read them through context (see + * `FieldPermissionObjectGroupCellContent` / `FieldPermissionFieldCellContent`) so a metadata + * batch landing repaints just the visible cells. `getValue` runs outside of render (sort/filter + * pipelines) and can't use hooks, so it reads through these refs instead. + */ + const sobjectExportDetailsRef = useRef(sobjectExportDetails); + sobjectExportDetailsRef.current = sobjectExportDetails; + const fieldExportDetailsRef = useRef(fieldExportDetails); + fieldExportDetailsRef.current = fieldExportDetails; + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + const cellFindingsModalHostRef = useRef(null); + + /** + * Reset expand-all only when the underlying job data actually changes (new analysis loaded). + * Sorting / metadata refresh changes `treeRows` identity but not `fieldPermissionRows`, so + * the user's manually-collapsed groups stick during initial metadata loading. + */ + useEffect(() => { + setExpandedGroupIds(collectAllFieldPermissionExpandedGroupIds(fieldPermissionRows)); + }, [fieldPermissionRows]); + + const objectManager = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + const row0 = treeRows[0]; + + const groupPermSetCol: ColumnWithFilter = { + ...setColumnFromType('_treePermSetGroupKey', 'text'), + name: 'Profile / Permission set', + key: '_treePermSetGroupKey', + field: '_treePermSetGroupKey', + resizable: true, + width: TREE_COL_PERM_SET, + minWidth: TREE_MIN_PERM_SET, + maxWidth: TREE_PERM_SET_MAX_PX, + renderGroupCell: (props) => renderFieldPermissionPermissionSetGroupCell(labelByParentId, permissionSetRowById, objectManager, props), + getValue: ({ row }) => { + const id = row._treePermSetGroupKey; + return labelByParentId.get(id) ?? id; + }, + } as ColumnWithFilter; + + const groupObjectCol: ColumnWithFilter = { + ...setColumnFromType('_treeObjectGroupKey', 'textOrSalesforceId'), + name: 'Object', + key: '_treeObjectGroupKey', + field: '_treeObjectGroupKey', + resizable: false, + width: TREE_OBJECT_GROUP_WIDTH_PX, + minWidth: TREE_OBJECT_GROUP_WIDTH_PX, + maxWidth: TREE_OBJECT_GROUP_WIDTH_PX, + renderGroupCell: ({ groupKey, childRows, isExpanded, toggleGroup }) => ( + + ), + getValue: ({ row }) => { + const api = row._treeObjectGroupKey; + const detail = sobjectExportDetailsRef.current?.[api]; + const label = detail?.label?.trim() ? detail.label.trim() : api; + return label; + }, + } as ColumnWithFilter; + + const fieldCol: ColumnWithFilter = { + ...setColumnFromType('Field', 'text'), + name: 'Field', + key: 'Field', + field: 'Field', + resizable: true, + width: TREE_FIELD_COL, + getValue: ({ row }) => { + const obj = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const short = fieldPermissionQualifiedFieldShortApi(row); + const full = typeof row.Field === 'string' ? row.Field.trim() : ''; + const key = obj && short ? fieldExportDetailLookupKey(obj, short) : ''; + const fd = key ? fieldExportDetailsRef.current?.[key] : undefined; + const label = fd?.label?.trim() ? fd.label.trim() : short || full; + return label || '—'; + }, + renderCell: (props: RenderCellProps) => ( + + ), + } as ColumnWithFilter; + + const permissionCols: ColumnWithFilter[] = []; + for (const key of FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS) { + if (!(key in row0)) { + continue; + } + const fieldType = getRowTypeFromValue(row0[key], false); + const headerLabel = getExportColumnHeaderLabel(key); + permissionCols.push({ + ...setColumnFromType(key, fieldType), + name: headerLabel, + key, + field: key, + resizable: true, + width: TREE_COL_PERMISSION_BOOL, + minWidth: TREE_MIN_PERMISSION_BOOL, + cellClass: (row: FieldPermissionTreeRow) => { + if (!isFieldPermissionLeafRow(row)) { + return undefined; + } + if (key !== 'PermissionsRead' && key !== 'PermissionsEdit') { + return undefined; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const fieldFull = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!parentId || !sobjectType || !fieldFull) { + return undefined; + } + const severity = fieldPermissionCellSeverity(findingCellHighlightsRef.current, parentId, sobjectType, fieldFull, key); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }, + } as ColumnWithFilter); + } + + return [groupPermSetCol, groupObjectCol, fieldCol, ...permissionCols]; + // Reads intentionally routed through refs/context so the columns memo stays stable: + // - `findingCellHighlights` via `findingCellHighlightsRef.current` inside `cellClass` + // - `sobjectExportDetails` / `fieldExportDetails` via refs in `getValue` and via context in the + // `FieldPermissionObjectGroupCellContent` / `FieldPermissionFieldCellContent` cell components + // Keeping all of those out of deps stops `useDataTable` from redoing its INIT pass and the + // per-row search-index rebuild every time findings or async label metadata change. + }, [treeRows, labelByParentId, permissionSetRowById, objectManager]); + + const getRowKey = useCallback((row: FieldPermissionTreeRow) => { + if (typeof row.Id === 'string' && row.Id.length > 0) { + return row.Id; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const obj = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const field = typeof row.Field === 'string' ? row.Field.trim() : ''; + return `${parentId}::${obj}::${field}`; + }, []); + + const handleCellClick = useCallback( + ({ row, column }: CellMouseArgs) => { + if (!isFieldPermissionLeafRow(row)) { + return; + } + const columnKey = typeof column.key === 'string' ? column.key : ''; + if (columnKey !== 'PermissionsRead' && columnKey !== 'PermissionsEdit') { + return; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const objectApiName = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const fieldApiName = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!parentId || !objectApiName || !fieldApiName) { + return; + } + const highlightSeverity = fieldPermissionCellSeverity(findingCellHighlights, parentId, objectApiName, fieldApiName, columnKey); + if (!highlightSeverity) { + return; + } + const matches = listFindingsForFieldPermissionCell(findings, parentId, objectApiName, fieldApiName, columnKey); + if (matches.length === 0) { + return; + } + const columnLabel = typeof column.name === 'string' && column.name.trim().length > 0 ? column.name : columnKey; + cellFindingsModalHostRef.current?.open({ + parentId, + objectApiName, + fieldApiName, + columnKey, + columnLabel, + matches, + }); + }, + [findingCellHighlights, findings], + ); + + if (!fieldPermissionRows.length) { + return ( +
+ No field permission rows in this export. +
+ ); + } + + return ( + + (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + onCellClick={handleCellClick} + /> + + + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisFindingsFiltersBar.tsx b/libs/features/manage-permissions/src/PermissionAnalysisFindingsFiltersBar.tsx new file mode 100644 index 000000000..d28b4c4eb --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisFindingsFiltersBar.tsx @@ -0,0 +1,288 @@ +import { css } from '@emotion/react'; +import classNames from 'classnames'; +import { Icon, Popover, type PopoverRef } from '@jetstream/ui'; +import { FunctionComponent, useRef } from 'react'; +import { type UsePermissionAnalysisIssuesFiltersResult } from './permission-analysis-issues-filters'; +import { type PermissionAnalysisFinding } from './permission-export-result-view'; + +/** Keeps `` from sitting inline with the first radio (fieldset default layout). */ +const filterPanelLegendCss = css` + display: block; + width: 100%; + float: none; + padding: 0; + margin-bottom: 0.375rem; +`; + +const filterPanelHelpTextCss = css` + display: block; + width: 100%; + margin-bottom: 0.5rem; +`; + +/** Vertical spacing between filter sections inside the combined popover. */ +const filterSectionCss = css` + & + & { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--slds-g-color-border-base-1, #e5e5e5); + } +`; + +/** + * Inline-size container for this toolbar strip so nested `@container` rules track the middle column width, + * not the viewport. + */ +const findingsFiltersBarRootCss = css` + width: 100%; + min-width: 0; +`; + +/** + * Single horizontal row with the main toolbar (back | filters | history). Parent may scroll on very narrow widths. + */ +const findingsFiltersToolbarClusterCss = css` + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; + gap: 0.35rem 0.5rem; + width: max-content; + max-width: none; +`; + +/** Do not flex-shrink (avoids a ~0px box that breaks every word onto its own line). */ +const findingsFiltersStatsCss = css` + flex: 0 0 auto; + text-align: left; + white-space: nowrap; +`; + +export interface PermissionAnalysisFindingsFiltersBarProps { + /** Unfiltered findings (total count for toolbar stats). */ + findings: PermissionAnalysisFinding[]; + /** Single hook result from {@link usePermissionAnalysisIssuesFilters} in the parent view. */ + issuesFilters: UsePermissionAnalysisIssuesFiltersResult; +} + +/** + * Global issue filters (URL-backed) consolidated into a single popover; popover body matches DataTable filter panels. + */ +export const PermissionAnalysisFindingsFiltersBar: FunctionComponent = ({ + findings, + issuesFilters, +}) => { + const popoverRef = useRef(null); + const { + severityFilter, + olsFlsFilter, + directAssignmentFilter, + scopeFilter, + issueScopeFilterContext, + hasAssignmentData, + filteredFindings, + errorTotal, + warningTotal, + errorFiltered, + warningFiltered, + updateParams, + } = issuesFilters; + + const supportsExportScopeFilter = issueScopeFilterContext?.supportsExportScopeFilter === true; + const activeFilterCount = + (severityFilter !== 'all' ? 1 : 0) + + (olsFlsFilter !== 'all' ? 1 : 0) + + (directAssignmentFilter !== 'all' ? 1 : 0) + + (supportsExportScopeFilter && scopeFilter !== 'all' ? 1 : 0); + const hasActiveFilters = activeFilterCount > 0; + + const resetAllFilters = () => { + updateParams({ + issueSeverity: null, + issueOlsFls: null, + issueDirectAssign: null, + issueScope: null, + }); + }; + + return ( +
+
+
ev.stopPropagation()} + onPointerDown={(ev) => ev.stopPropagation()} + onKeyDown={(ev) => ev.stopPropagation()} + > + ev.stopPropagation()}> +

Filters

+ + } + footer={ +
+ +
+ } + content={ +
ev.stopPropagation()}> +
+ {supportsExportScopeFilter && ( +
+ + Export scope + +

+ Narrow issues to permission containers that were selected as profiles or as permission sets for this export job. +

+
+ {(['all', 'profiles', 'permissionSets'] as const).map((value) => ( +
+ updateParams({ issueScope: value === 'all' ? null : value })} + /> + +
+ ))} +
+
+ )} + +
+ + Direct assignments + +

+ Filter issues to permission sets that have—or lack—a direct assignment to a Salesforce User. +

+
+ {(['all', 'assigned', 'unassigned'] as const).map((value) => ( +
+ updateParams({ issueDirectAssign: value === 'all' ? null : value })} + /> + +
+ ))} +
+ {!hasAssignmentData && ( +

+ No assignment rows in this export; run a new export after upgrading Jetstream. +

+ )} +
+ +
+ + Issue severity + +
+ {(['all', 'errors', 'warnings'] as const).map((value) => ( +
+ updateParams({ issueSeverity: value === 'all' ? null : value })} + /> + +
+ ))} +
+
+ +
+ + Issue security layer + +
+ {(['all', 'ols', 'fls'] as const).map((value) => ( +
+ updateParams({ issueOlsFls: value === 'all' ? null : value })} + /> + +
+ ))} +
+
+
+
+ } + buttonProps={{ + className: 'slds-button slds-button_neutral', + onClick: (ev) => ev.stopPropagation(), + 'aria-label': 'Filters', + title: 'Filters', + }} + > + + Filters{hasActiveFilters ? ` (${activeFilterCount})` : ''} +
+
+ +
+ Errors: {errorFiltered} / {errorTotal} · Warnings: {warningFiltered} / {warningTotal} · Showing {filteredFindings.length} of{' '} + {findings.length} issues +
+
+
+ ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisFindingsModal.tsx b/libs/features/manage-permissions/src/PermissionAnalysisFindingsModal.tsx new file mode 100644 index 000000000..de2d3de4a --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisFindingsModal.tsx @@ -0,0 +1,232 @@ +import { css } from '@emotion/react'; +import { Grid, Modal } from '@jetstream/ui'; +import { FunctionComponent, ReactNode } from 'react'; +import { getFindingCodeDisplayParts, getFindingLabelForCode, type PermissionAnalysisFinding } from './permission-export-result-view'; + +function severityLabelForFinding(finding: PermissionAnalysisFinding): string { + const normalized = String(finding.severity ?? '').toLowerCase(); + if (normalized === 'error' || normalized === 'errors') { + return 'Error'; + } + if (normalized === 'warning' || normalized === 'warnings') { + return 'Warning'; + } + if (normalized.length > 0) { + return normalized.charAt(0).toUpperCase() + normalized.slice(1); + } + return 'Info'; +} + +function primaryFindingExplanation(finding: PermissionAnalysisFinding): string { + const message = String(finding.message ?? '').trim(); + if (message.length > 0) { + return message; + } + const code = typeof finding.code === 'string' ? finding.code : ''; + return getFindingLabelForCode(code) || '—'; +} + +function findingBlockChrome(finding: PermissionAnalysisFinding): { accent: string; tint: string } { + const normalized = String(finding.severity ?? '').toLowerCase(); + if (normalized === 'error' || normalized === 'errors') { + return { accent: '#ba0517', tint: 'rgba(186, 5, 23, 0.06)' }; + } + if (normalized === 'warning' || normalized === 'warnings') { + return { accent: '#dd7a01', tint: 'rgba(221, 122, 1, 0.1)' }; + } + return { accent: '#0176d3', tint: 'rgba(1, 118, 211, 0.07)' }; +} + +function findingDetailText(finding: PermissionAnalysisFinding, catalogSummary: string): string | null { + const detail = primaryFindingExplanation(finding).trim(); + if (!detail) { + return null; + } + if (detail === catalogSummary.trim()) { + return null; + } + return detail; +} + +export interface PermissionAnalysisFindingsModalProps { + testId?: string; + open: boolean; + title: string; + tagline: string; + summaryLine: ReactNode; + findings: PermissionAnalysisFinding[]; + onClose: () => void; +} + +/** + * Read-only modal listing structured permission export issues (shared by object tree and export grids). + */ +export const PermissionAnalysisFindingsModal: FunctionComponent = ({ + testId = 'permission-analysis-issues-modal', + open, + title, + tagline, + summaryLine, + findings, + onClose, +}) => { + if (!open) { + return null; + } + + return ( + + + + } + onClose={onClose} + className="slds-p-around_small" + > +
+
{summaryLine}
+
+ {findings.map((finding, index) => { + const code = typeof finding.code === 'string' ? finding.code.trim() : ''; + const codeParts = getFindingCodeDisplayParts(code || undefined); + const summaryTitle = codeParts.title.trim(); + const detailText = findingDetailText(finding, summaryTitle); + const { accent, tint } = findingBlockChrome(finding); + return ( +
+
+
+ {code ? ( + <> + {severityLabelForFinding(finding)} + + + + {codeParts.technicalCode ? ( + <> + {summaryTitle}{' '} + + {codeParts.technicalCode} + + + ) : ( + + {summaryTitle} + + )} + + ) : ( + {severityLabelForFinding(finding)} + )} +
+ {detailText ? ( +

+ {detailText} +

+ ) : null} + {typeof finding.objectApiName === 'string' && finding.objectApiName.trim().length > 0 ? ( +

+ Object: {finding.objectApiName.trim()} +

+ ) : null} + {typeof finding.fieldApiName === 'string' && finding.fieldApiName.trim().length > 0 ? ( +

+ Field: {finding.fieldApiName.trim()} +

+ ) : null} + {typeof finding.permissionSetId === 'string' && finding.permissionSetId.trim().length > 0 ? ( +

+ Permission set Id: {finding.permissionSetId.trim()} +

+ ) : null} +
+
+ ); + })} +
+
+
+ ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisHistoryModal.tsx b/libs/features/manage-permissions/src/PermissionAnalysisHistoryModal.tsx new file mode 100644 index 000000000..c1f682494 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisHistoryModal.tsx @@ -0,0 +1,626 @@ +import { css } from '@emotion/react'; +import { formatNumber } from '@jetstream/shared/ui-utils'; +import { multiWordObjectFilter } from '@jetstream/shared/utils'; +import { AnalysisJobHistoryItem, AnalysisJobType, SalesforceOrgUi } from '@jetstream/types'; +import { Badge, EmptyState, Grid, Icon, List, Modal, Popover, PopoverRef, SearchInput, Spinner } from '@jetstream/ui'; +import { dexieDb } from '@jetstream/ui/db'; +import classNames from 'classnames'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { formatAnalysisJobStatusForDisplay } from './analysis-job-status-display'; +import { permissionScopeBadgeCss } from './permission-analysis-viewer-badge.styles'; +import { FunctionComponent, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +export type PermissionScopeKind = 'profile' | 'permission_set' | 'object'; + +export type PermissionAnalysisScopeBadge = { + key: string; + id: string; + label: string; + kind: PermissionScopeKind; +}; + +export type PermissionAnalysisHistoryRow = { + key: string; + id: string; + status: string; + jobType: string; + startedLabel: string; + profileScopeIds: string[]; + permissionSetScopeIds: string[]; + scopeBadges: PermissionAnalysisScopeBadge[]; + /** Space-delimited labels and ids for text search */ + scopeSearchText: string; +}; + +export type PermissionScopeFilterOption = { + id: string; + label: string; + kind: PermissionScopeKind; +}; + +function formatJobStartedAt(value: unknown): string { + if (value == null) { + return '—'; + } + const date = value instanceof Date ? value : new Date(typeof value === 'string' ? value : String(value)); + if (Number.isNaN(date.getTime())) { + return '—'; + } + return date.toLocaleString(); +} + +/** Short reference for list rows; full id stays in `title` for copy/paste and search still matches full `id`. */ +function shortJobIdForDisplay(jobId: string): string { + const normalized = jobId.trim(); + if (normalized.length <= 14) { + return normalized; + } + return `${normalized.slice(0, 8)}…${normalized.slice(-4)}`; +} + +function badgeTypeForStatus(status: string): 'success' | 'error' | 'warning' | 'default' { + const normalized = status.trim().toLowerCase(); + if (normalized === 'completed') { + return 'success'; + } + if (normalized === 'failed') { + return 'error'; + } + if (normalized === 'running') { + return 'warning'; + } + return 'default'; +} + +function stringIdArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((id): id is string => typeof id === 'string'); +} + +/** + * Builds ordered scope badges from request payload IDs only. Full PermissionSet labels live inside + * the gzip-compressed `resultBlob`; we intentionally do not decode for the list view (would force + * a gunzip per row). Labels resolve to the raw Id; per-run drill-in still shows full labels. + */ +function buildScopeBadges(profileScopeIds: string[], permissionSetScopeIds: string[]): PermissionAnalysisScopeBadge[] { + const badges: PermissionAnalysisScopeBadge[] = []; + for (const id of profileScopeIds) { + badges.push({ + key: `profile:${id}`, + id, + label: id, + kind: 'profile', + }); + } + for (const id of permissionSetScopeIds) { + badges.push({ + key: `permset:${id}`, + id, + label: id, + kind: 'permission_set', + }); + } + return badges; +} + +function parseFieldUsageDexieRowScopes(row: AnalysisJobHistoryItem): { + profileScopeIds: string[]; + permissionSetScopeIds: string[]; + scopeBadges: PermissionAnalysisScopeBadge[]; + scopeSearchText: string; +} { + const objectApiNames = stringIdArray(row.requestPayload?.objectApiNames).filter((name) => name.trim().length > 0); + const scopeBadges: PermissionAnalysisScopeBadge[] = objectApiNames.map((name) => ({ + key: `object:${name}`, + id: name, + label: name, + kind: 'object', + })); + return { + profileScopeIds: [], + permissionSetScopeIds: [], + scopeBadges, + scopeSearchText: objectApiNames.join(' '), + }; +} + +function parsePermissionExportDexieRowScopes(row: AnalysisJobHistoryItem): { + profileScopeIds: string[]; + permissionSetScopeIds: string[]; + scopeBadges: PermissionAnalysisScopeBadge[]; + scopeSearchText: string; +} { + const profileScopeIds = stringIdArray(row.requestPayload?.profileIds); + const permissionSetScopeIds = stringIdArray(row.requestPayload?.permissionSetIds); + const scopeBadges = buildScopeBadges(profileScopeIds, permissionSetScopeIds); + return { + profileScopeIds, + permissionSetScopeIds, + scopeBadges, + scopeSearchText: [...profileScopeIds, ...permissionSetScopeIds].join(' '), + }; +} + +function mapDexieRowsToHistoryRows( + rows: readonly AnalysisJobHistoryItem[], + analysisJobType: AnalysisJobType, +): PermissionAnalysisHistoryRow[] { + return rows.map((row) => { + const { profileScopeIds, permissionSetScopeIds, scopeBadges, scopeSearchText } = + analysisJobType === 'field_usage' ? parseFieldUsageDexieRowScopes(row) : parsePermissionExportDexieRowScopes(row); + return { + key: row.key, + id: row.key, + status: row.status, + jobType: row.jobType, + startedLabel: formatJobStartedAt(row.createdAt), + profileScopeIds, + permissionSetScopeIds, + scopeBadges, + scopeSearchText, + }; + }); +} + +function sortScopeFilterOptions(options: PermissionScopeFilterOption[]): PermissionScopeFilterOption[] { + return [...options].sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === 'profile' ? -1 : 1; + } + return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); + }); +} + +function uniqueScopeOptionsFromRows(rows: PermissionAnalysisHistoryRow[]): PermissionScopeFilterOption[] { + const byId = new Map(); + for (const row of rows) { + for (const badge of row.scopeBadges) { + if (!byId.has(badge.id)) { + byId.set(badge.id, { id: badge.id, label: badge.label, kind: badge.kind }); + } + } + } + return sortScopeFilterOptions(Array.from(byId.values())); +} + +export interface PermissionAnalysisHistoryModalProps { + selectedOrg: SalesforceOrgUi; + /** Which analysis job family this modal lists; the Dexie query is keyed by (org, jobType). */ + analysisJobType: AnalysisJobType; + currentJobId: string | null; + onClose: () => void; + onSelectJob: (jobId: string) => void; +} + +const scopeBadgeWrapCss = css` + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; + max-width: 100%; +`; + +/** ~two lines of SLDS badges + flex gap; paired with overflow hidden when collapsed */ +const scopeBadgeTwoRowClampCss = css` + max-height: 4.5rem; + overflow: hidden; +`; + +type ScopeBadgesCollapsibleProps = { + badges: PermissionAnalysisScopeBadge[]; +}; + +/** + * Renders scope badges collapsed to ~two rows by default; offers expand/collapse when content overflows. + * Parent should pass `key={jobRowKey}` so expand state resets per run. + */ +const ScopeBadgesCollapsible: FunctionComponent = ({ badges }) => { + const [expanded, setExpanded] = useState(false); + const [canExpand, setCanExpand] = useState(false); + const wrapRef = useRef(null); + + useLayoutEffect(() => { + const el = wrapRef.current; + if (!el) { + return; + } + + function measureCollapsedOverflow(): void { + const node = wrapRef.current; + if (!node || expanded) { + return; + } + setCanExpand(node.scrollHeight > node.clientHeight + 1); + } + + measureCollapsedOverflow(); + const observer = new ResizeObserver(() => { + measureCollapsedOverflow(); + }); + observer.observe(el); + return () => { + observer.disconnect(); + }; + }, [badges, expanded]); + + const showToggle = canExpand || expanded; + + return ( +
+
+ {badges.map((badge) => ( + + {badge.label} + + ))} +
+ {showToggle && ( + + )} +
+ ); +}; + +export const PermissionAnalysisHistoryModal: FunctionComponent = ({ + selectedOrg, + analysisJobType, + currentJobId, + onClose, + onSelectJob, +}) => { + const [filterValue, setFilterValue] = useState(''); + const [scopeFilterParentId, setScopeFilterParentId] = useState(null); + const [selectedKey, setSelectedKey] = useState(currentJobId); + const scopePopoverRef = useRef(null); + + /** + * Live Dexie subscription to the per-(org, jobType) history. sortBy always materializes ascending — + * we reverse the array in JS to render newest first. + */ + const dexieRows = useLiveQuery( + () => + dexieDb.analysis_job_history + .where('[org+jobType+createdAt]') + .between([selectedOrg.uniqueId, analysisJobType, new Date(0)], [selectedOrg.uniqueId, analysisJobType, new Date(8.64e15)]) + .sortBy('createdAt') + .then((rows) => rows.reverse()), + [selectedOrg.uniqueId, analysisJobType], + ); + + const loading = dexieRows === undefined; + const rows = useMemo( + () => (dexieRows ? mapDexieRowsToHistoryRows(dexieRows, analysisJobType) : []), + [dexieRows, analysisJobType], + ); + + const scopeFilterOptions = useMemo(() => uniqueScopeOptionsFromRows(rows), [rows]); + + const activeScopeFilterLabel = useMemo(() => { + if (!scopeFilterParentId) { + return null; + } + const match = scopeFilterOptions.find((option) => option.id === scopeFilterParentId); + return match?.label ?? scopeFilterParentId; + }, [scopeFilterOptions, scopeFilterParentId]); + + const filteredRows = useMemo(() => { + let next = rows; + if (analysisJobType === 'permission_export' && scopeFilterParentId) { + next = next.filter( + (row) => row.profileScopeIds.includes(scopeFilterParentId) || row.permissionSetScopeIds.includes(scopeFilterParentId), + ); + } + if (!filterValue.trim()) { + return next; + } + return next.filter(multiWordObjectFilter(['id', 'status', 'startedLabel', 'scopeSearchText'], filterValue)); + }, [rows, filterValue, scopeFilterParentId, analysisJobType]); + + const handleListSelect = useCallback( + (key: string) => { + setSelectedKey(key); + onSelectJob(key); + onClose(); + }, + [onClose, onSelectJob], + ); + + const handleClearScopeFilter = useCallback(() => { + setScopeFilterParentId(null); + scopePopoverRef.current?.close(); + }, []); + + const handlePickScopeFilter = useCallback((parentId: string) => { + setScopeFilterParentId(parentId); + scopePopoverRef.current?.close(); + }, []); + + /** + * Single padded surface inside `slds-popover__body` (body padding removed) so header/body + * spacing is predictable and all rows share the same width. + */ + const scopePopoverSurfaceCss = css` + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.25rem; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 0.375rem 0.625rem 0.625rem; + max-height: min(50vh, 280px); + overflow-x: hidden; + overflow-y: auto; + + /* SLDS adds margin-left between adjacent .slds-button; stack spacing comes from gap above. */ + .slds-button + .slds-button { + margin-left: 0; + margin-inline-start: 0; + } + `; + + const scopePopoverHeaderCss = css` + margin: 0; + padding: 0.5rem 0.625rem 0.375rem; + box-sizing: border-box; + `; + + /** + * SLDS `slds-button_stretch` uses `justify-content: center`; neutral/brand borders differ by 1px. + * Stretch inner grid, equal insets, explicit 1px border so rows align with "All Runs". + */ + const scopeFilterPopoverButtonCss = css` + &&.slds-button_stretch { + justify-content: flex-start; + } + + display: flex; + align-items: center; + align-self: stretch; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + margin: 0; + text-align: left; + border-width: 1px; + border-style: solid; + --slds-c-button-spacing-inline-start: 0.625rem; + --slds-c-button-spacing-inline-end: 0.625rem; + --slds-c-button-neutral-spacing-inline-start: 0.625rem; + --slds-c-button-neutral-spacing-inline-end: 0.625rem; + + > .slds-grid { + flex: 1 1 auto; + width: 100%; + min-width: 0; + } + `; + + const scopePopoverOptionRowCss = css` + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + column-gap: 0.5rem; + `; + + const scopePopoverLabelCellCss = css` + min-width: 0; + `; + + const scopePopoverContent = ( +
+ + {scopeFilterOptions.length === 0 && ( +

No profiles or permission sets are recorded on these runs.

+ )} + {scopeFilterOptions.map((option) => { + const isSelected = scopeFilterParentId === option.id; + return ( + + ); + })} +
+ ); + + const modalHeader = analysisJobType === 'permission_export' ? 'Permission export history' : 'Field Usage History'; + const modalTagline = 'Runs for this org, newest first.'; + const emptyHeadline = analysisJobType === 'permission_export' ? 'No export jobs yet' : 'No Field Usage jobs yet'; + const emptySubHeading = + analysisJobType === 'permission_export' + ? 'Start a run from Permission Analysis selection, then open history again.' + : 'Start a run from Data Analysis selection, then open history again.'; + const searchPlaceholder = + analysisJobType === 'permission_export' + ? 'Filter by job id, status, time, or scope names' + : 'Filter by job id, status, time, or object API names'; + const scopeEmptyDetail = + analysisJobType === 'permission_export' ? 'No scope saved for this job.' : 'No objects recorded for this job payload.'; + + return ( + + + + } + directionalFooter + > + {loading && ( +
+ +
+ )} + {!loading && rows.length === 0 && } + {!loading && rows.length > 0 && ( +
+ +
+ +
+ {analysisJobType === 'permission_export' && ( +
+ +

+ Filter by Scope +

+ + } + bodyClassName="slds-popover__body slds-p-around_none" + bodyStyle={css` + box-sizing: border-box; + max-width: 100%; + margin: 0; + padding: 0; + overflow-x: hidden; + `} + content={scopePopoverContent} + tooltipProps={{ content: 'Filter runs by profile or permission set included in the export' }} + buttonProps={{ + className: 'slds-button slds-button_icon slds-button_icon-border-filled', + 'aria-label': 'Filter by Scope', + }} + > + +
+
+ )} +
+ {analysisJobType === 'permission_export' && scopeFilterParentId && activeScopeFilterLabel && ( +
+ + Showing runs that include + {activeScopeFilterLabel} + + +
+ )} +
+ Showing {formatNumber(filteredRows.length)} of {formatNumber(rows.length)} runs +
+
+ item.key === selectedKey} + onSelected={handleListSelect} + getContent={(item: PermissionAnalysisHistoryRow) => ({ + key: item.key, + heading: ( + + + {item.startedLabel} + + {formatAnalysisJobStatusForDisplay(item.status)} + + ), + subheading: `Job ${shortJobIdForDisplay(item.id)}`, + trailingHeader: + item.key === currentJobId ? ( + + ) : undefined, + children: + item.scopeBadges.length > 0 ? ( + + ) : ( +

{scopeEmptyDetail}

+ ), + })} + /> +
+

Select a row to open that run in this view.

+
+ )} +
+ ); +}; + +export default PermissionAnalysisHistoryModal; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisIssuesTab.tsx b/libs/features/manage-permissions/src/PermissionAnalysisIssuesTab.tsx new file mode 100644 index 000000000..8cce14e3a --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisIssuesTab.tsx @@ -0,0 +1,1160 @@ +import { css } from '@emotion/react'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import { + AutoFullHeightContainer, + Card, + ColumnWithFilter, + DataTable, + DataTree, + Icon, + Popover, + RowWithKey, + ScopedNotification, + setColumnFromType, +} from '@jetstream/ui'; +import { FunctionComponent, useCallback, useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import type { RenderCellProps } from 'react-data-grid'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { + ISSUES_GRID_COLUMN_KEYS, + ISSUES_GRID_COLUMN_LABELS, + type IssuesGridColumnKey, + type IssuesGroupBy, + type UsePermissionAnalysisIssuesFiltersResult, + isErrorSeverity, + isWarningSeverity, +} from './permission-analysis-issues-filters'; +import { + aggregatePermissionAnalysisFindings, + formatObjectLabelForModalSummary, + type PermissionAnalysisFinding, + type PermissionFindingCodeRollup, + type PermissionFindingObjectRollup, + type SobjectExportDetail, + getFindingCodeDisplayParts, + getFindingContainerId, +} from './permission-export-result-view'; + +export type { IssuesGroupBy } from './permission-analysis-issues-filters'; + +export interface PermissionAnalysisIssuesTabProps { + findings: PermissionAnalysisFinding[]; + /** Shared hook result from {@link PermissionAnalysisView} (single filter pipeline with the toolbar). */ + issuesFilters: UsePermissionAnalysisIssuesFiltersResult; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + /** Describe labels for object API names; when absent, rollup titles fall back to API names. */ + sobjectExportDetails?: Record; +} + +function normalizeSeverity(value: string | undefined): string { + return (value ?? '').toLowerCase(); +} + +function groupIssueFindingsByColumnKey( + rows: readonly PermissionAnalysisFinding[], + columnKey: string, +): Record { + const groups: Record = {}; + for (const row of rows) { + let key: string; + switch (columnKey) { + case 'severity': { + const normalized = normalizeSeverity(row.severity as string | undefined); + key = normalized.length > 0 ? normalized : '(none)'; + break; + } + case 'objectApiName': { + const raw = String(row.objectApiName ?? '').trim(); + key = raw.length > 0 ? raw : '(no object)'; + break; + } + case 'code': { + const raw = String(row.code ?? '').trim(); + key = raw.length > 0 ? raw : '(no code)'; + break; + } + case 'containerId': { + const id = getFindingContainerId(row); + key = id && id.length > 0 ? id : '(none)'; + break; + } + default: { + const raw = row[columnKey as keyof PermissionAnalysisFinding]; + key = raw != null && String(raw).trim().length > 0 ? String(raw) : '(none)'; + } + } + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(row); + } + return groups; +} + +const issuesTabFilterLegendCss = css` + display: block; + width: 100%; + float: none; + padding: 0; + margin-bottom: 0.375rem; +`; + +const issuesTabFilterHelpCss = css` + display: block; + width: 100%; + margin-bottom: 0.5rem; +`; + +const issuesTabVerticalRootCss = css` + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +`; + +const issuesWorkspaceCss = css` + position: relative; + display: flex; + flex: 1; + min-height: 0; + flex-direction: row; + align-items: stretch; +`; + +const issuesAggregatedRailCss = css` + flex-shrink: 0; + width: 2.75rem; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 0.25rem; + border-right: 1px solid var(--slds-g-color-border-base-1, #c9c9c9); + background: var(--slds-g-color-neutral-base-95, #f3f3f3); +`; + +const issuesAggregatedRailToggleCss = css` + display: flex; + flex-direction: column; + align-items: center; + gap: 0.375rem; + cursor: pointer; + border: none; + background: transparent; + color: var(--slds-g-color-neutral-base-30, #444); + padding: 0.375rem 0.125rem; + border-radius: 0.25rem; + width: 100%; + + .slds-icon { + width: 1.25rem; + height: 1.25rem; + fill: currentColor; + } + + &:hover, + &:focus { + background: var(--slds-g-color-neutral-base-90, #e5e5e5); + outline: none; + } + + &:focus-visible { + box-shadow: 0 0 0 2px var(--slds-g-color-brand-base-50, #0176d3); + } +`; + +const issuesAggregatedRailLabelCss = css` + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + white-space: nowrap; + line-height: 1.2; +`; + +const issuesAggregatedRailCountCss = css` + margin-top: 0.5rem; + font-size: 0.65rem; + font-weight: 700; + color: var(--slds-g-color-neutral-base-30, #444); + writing-mode: vertical-rl; + transform: rotate(180deg); +`; + +const issuesMainPaneCss = css` + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +`; + +const issuesAggregatedBackdropCss = css` + position: absolute; + inset: 0; + z-index: 350; + border: none; + padding: 0; + margin: 0; + background: rgba(8, 7, 7, 0.28); + cursor: pointer; +`; + +const issuesAggregatedFlyoutCss = css` + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: min(28rem, calc(100% - 1rem)); + max-width: calc(100vw - 4rem); + z-index: 400; + display: flex; + flex-direction: column; + background: var(--slds-g-color-neutral-base-100, #fff); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.14); + border-right: 1px solid var(--slds-g-color-border-base-1, #c9c9c9); + overflow: hidden; +`; + +const issuesAggregatedFlyoutHeaderCss = css` + flex-shrink: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 0.75rem 0.5rem; + border-bottom: 1px solid var(--slds-g-color-border-base-1, #e5e5e5); +`; + +const issuesAggregatedFlyoutBodyCss = css` + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.75rem; +`; + +const issuesGridSectionCss = css` + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +`; + +const aggregatedFlyoutSectionsCss = css` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const aggregatedNestedRollupsListCss = css` + display: flex; + flex-direction: column; + gap: 0.625rem; +`; + +const aggregatedRollupRowInteractiveCss = css` + cursor: pointer; + border-radius: 0.25rem; + + &:focus { + outline: none; + } + + &:focus-visible { + box-shadow: 0 0 0 2px var(--slds-g-color-brand-base-50, #0176d3); + outline: none; + } + + &:hover article.slds-card { + background: var(--slds-g-color-neutral-base-95, #f3f3f3); + } +`; + +const aggregatedRollupMetricsFooterCss = css` + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem 1rem; + width: 100%; +`; + +const aggregatedObjectRollupCardTitleCss = css` + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.125rem; + min-width: 0; + width: 100%; +`; + +const aggregatedRollupDrillInChevronCss = css` + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + color: var(--slds-g-color-neutral-base-50, #706e6b); + + svg { + width: 0.875rem; + height: 0.875rem; + fill: currentColor; + } +`; + +const aggregatedSectionToggleButtonCss = css` + && { + color: var(--slds-g-color-neutral-base-30, #444444); + } + + && svg { + fill: currentColor; + } +`; + +const aggregatedSectionToggleChevronSvgCss = (expanded: boolean) => css` + width: 0.875rem; + height: 0.875rem; + fill: currentColor; + transform: rotate(${expanded ? '90deg' : '0deg'}); + transition: transform 0.15s ease; +`; + +const FindingCodeInline: FunctionComponent<{ code: string | undefined }> = ({ code }) => { + const { title, technicalCode } = getFindingCodeDisplayParts(code); + return ( + + {title} + {technicalCode ? ( + + {' '} + ({technicalCode}) + + ) : null} + + ); +}; + +function aggregatedRollupRowKeyDown(onOpen: () => void) { + return (event: ReactKeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(); + } + }; +} + +const AggregatedIssueCodeRollupCard: FunctionComponent<{ + row: PermissionFindingCodeRollup; + onOpen: () => void; +}> = ({ row, onOpen }) => { + const subtitle = + row.label.trim().length > 0 && row.label.trim() !== row.code ? ( +

{row.label}

+ ) : null; + + return ( +
+ } + actions={ + + } + footer={ +
+ + {row.count} + issue{row.count === 1 ? '' : 's'} + + + {row.errorCount} errors + · + {row.warningCount} warnings + +
+ } + > + {subtitle} +
+
+ ); +}; + +const AggregatedObjectRollupCardTitle: FunctionComponent<{ + objectApiName: string; + sobjectExportDetails: Record | undefined; +}> = ({ objectApiName, sobjectExportDetails }) => { + if (objectApiName === '(no object)') { + return No object; + } + + const { displayLabel, showApiInParens } = formatObjectLabelForModalSummary(objectApiName, sobjectExportDetails); + + return ( +
+ + {displayLabel} + + {showApiInParens ? ( + + {objectApiName} + + ) : null} +
+ ); +}; + +const AggregatedObjectRollupCard: FunctionComponent<{ + row: PermissionFindingObjectRollup; + sobjectExportDetails: Record | undefined; + onOpen: () => void; +}> = ({ row, sobjectExportDetails, onOpen }) => { + const hint = + row.objectApiName === '(no object)' ? ( +

These issues are not tied to a specific object.

+ ) : null; + + return ( +
+ } + actions={ + + } + footer={ +
+ + {row.count} + issue{row.count === 1 ? '' : 's'} + + + {row.errorCount} errors + · + {row.warningCount} warnings + +
+ } + > + {hint} +
+
+ ); +}; + +/** + * Maps Issues "Group By" to the grid column key used by {@link TreeDataGrid} `groupBy` / `rowGrouper`. + * Container grouping uses {@link getFindingContainerId} in the grouper (not only `containerId` on the row). + */ +function issuesGroupByToTreeColumnKey(groupBy: IssuesGroupBy): IssuesGridColumnKey | null { + switch (groupBy) { + case 'none': + return null; + case 'severity': + return 'severity'; + case 'object': + return 'objectApiName'; + case 'code': + return 'code'; + case 'container': + return 'containerId'; + default: + return null; + } +} + +function sortFindings(rows: PermissionAnalysisFinding[], groupBy: IssuesGroupBy): PermissionAnalysisFinding[] { + const copy = [...rows]; + const getter = (row: PermissionAnalysisFinding): string => { + switch (groupBy) { + case 'severity': + return normalizeSeverity(row.severity as string | undefined); + case 'object': + return String(row.objectApiName ?? ''); + case 'code': + return String(row.code ?? ''); + case 'container': + return getFindingContainerId(row) ?? ''; + default: + return ''; + } + }; + copy.sort((a, b) => getter(a).localeCompare(getter(b)) || String(a.message ?? '').localeCompare(String(b.message ?? ''))); + return copy; +} + +type StandardFindingColumnKey = + | 'severity' + | 'code' + | 'objectApiName' + | 'fieldApiName' + | 'message' + | 'permissionSetId' + | 'parentId' + | 'containerId'; + +function mapStandardFindingColumn(key: StandardFindingColumnKey): ColumnWithFilter { + const fieldType = key.endsWith('Id') ? 'salesforceId' : 'text'; + const base = setColumnFromType(key, fieldType); + const severityCellClass = (row: RowWithKey) => { + const finding = row as PermissionAnalysisFinding; + const severityValue = finding.severity as string | undefined; + if (isWarningSeverity(severityValue) && !isErrorSeverity(severityValue)) { + return 'permission-finding-severity-cell--warning'; + } + return undefined; + }; + const renderCell = + key === 'message' + ? ({ row }: RenderCellProps) => ( + + {String((row as PermissionAnalysisFinding).message ?? '')} + + ) + : key === 'code' + ? ({ row }: RenderCellProps) => { + const finding = row as PermissionAnalysisFinding; + const rawCode = finding.code; + const normalized = typeof rawCode === 'string' ? rawCode : undefined; + const { title: codeTitle, technicalCode } = getFindingCodeDisplayParts(normalized); + return ( + + {codeTitle} + {technicalCode ? ( + + {' '} + ({technicalCode}) + + ) : null} + + ); + } + : base.renderCell; + + return { + ...base, + name: ISSUES_GRID_COLUMN_LABELS[key], + key, + field: key, + resizable: true, + ...(key === 'severity' ? { cellClass: severityCellClass } : {}), + renderCell, + } as ColumnWithFilter; +} + +function buildFindingColumns(): ColumnWithFilter[] { + const keys: StandardFindingColumnKey[] = [ + 'severity', + 'code', + 'objectApiName', + 'fieldApiName', + 'message', + 'permissionSetId', + 'parentId', + 'containerId', + ]; + return keys.map(mapStandardFindingColumn); +} + +const FINDING_COLUMNS = buildFindingColumns(); + +const issuesTabGroupByTriggerClassName = 'slds-button slds-button_neutral'; + +interface IssuesFindingTreeDataGridProps { + sortedFindings: PermissionAnalysisFinding[]; + treeGroupColumnKey: IssuesGridColumnKey; + dataTreeColumns: ColumnWithFilter[]; + getRowKey: (row: PermissionAnalysisFinding) => string; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + onSortedAndFilteredRowsChange: (rows: readonly PermissionAnalysisFinding[]) => void; +} + +/** + * Mount with a changing `key` from the parent whenever toolbar-filtered rows change so expanded groups + * reset without a state-sync effect. + */ +const IssuesFindingTreeDataGrid: FunctionComponent = ({ + sortedFindings, + treeGroupColumnKey, + dataTreeColumns, + getRowKey, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + onSortedAndFilteredRowsChange, +}) => { + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => { + const grouped = groupIssueFindingsByColumnKey(sortedFindings, treeGroupColumnKey); + return new Set(Object.keys(grouped)); + }); + + return ( + setExpandedGroupIds(nextExpanded)} + onSortedAndFilteredRowsChange={onSortedAndFilteredRowsChange} + rowClass={(row) => { + const severityValue = (row as PermissionAnalysisFinding).severity as string | undefined; + if (isErrorSeverity(severityValue)) { + return 'permission-finding-row--error'; + } + return undefined; + }} + /> + ); +}; + +export const PermissionAnalysisIssuesTab: FunctionComponent = ({ + findings, + issuesFilters, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, +}) => { + const [aggregatedDetailsModal, setAggregatedDetailsModal] = useState<{ + findings: PermissionAnalysisFinding[]; + title: string; + tagline: string; + summaryLine: string; + } | null>(null); + + const [gridFilteredFindings, setGridFilteredFindings] = useState(null); + const [aggregatedSidePanelOpen, setAggregatedSidePanelOpen] = useState(false); + const [aggregatedIssueCodeSectionExpanded, setAggregatedIssueCodeSectionExpanded] = useState(true); + const [aggregatedByObjectSectionExpanded, setAggregatedByObjectSectionExpanded] = useState(true); + + const { filteredFindings, groupBy, updateParams, hiddenIssueGridColumns } = issuesFilters; + + const gridColumns = useMemo(() => { + const filtered = FINDING_COLUMNS.filter((col) => !hiddenIssueGridColumns.has(col.key as IssuesGridColumnKey)); + return filtered.length > 0 ? filtered : FINDING_COLUMNS; + }, [hiddenIssueGridColumns]); + + const treeGroupColumnKey = issuesGroupByToTreeColumnKey(groupBy); + + const dataTreeColumns = useMemo(() => { + if (!treeGroupColumnKey) { + return gridColumns; + } + const hasGroupCol = gridColumns.some((col) => col.key === treeGroupColumnKey); + const groupColumnDef = FINDING_COLUMNS.find((col) => col.key === treeGroupColumnKey); + if (!groupColumnDef) { + return gridColumns; + } + if (hasGroupCol) { + return gridColumns; + } + return [groupColumnDef, ...gridColumns]; + }, [gridColumns, treeGroupColumnKey]); + + const setIssueColumnHidden = useCallback( + (columnKey: IssuesGridColumnKey, hide: boolean) => { + const nextHidden = new Set(hiddenIssueGridColumns); + if (hide) { + if (ISSUES_GRID_COLUMN_KEYS.length - nextHidden.size <= 1) { + return; + } + nextHidden.add(columnKey); + } else { + nextHidden.delete(columnKey); + } + updateParams({ issueHiddenCols: nextHidden.size === 0 ? null : [...nextHidden].sort().join(',') }); + }, + [hiddenIssueGridColumns, updateParams], + ); + + const sortedFindings = useMemo(() => sortFindings(filteredFindings, groupBy), [filteredFindings, groupBy]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- quick-filter state invalidates when sorted findings change + setGridFilteredFindings(null); + }, [sortedFindings]); + + const rollupFindings = gridFilteredFindings ?? sortedFindings; + + const aggregation = useMemo(() => aggregatePermissionAnalysisFindings(rollupFindings), [rollupFindings]); + + const openAggregatedDetailsForCode = useCallback( + (codeKey: string) => { + const matches = rollupFindings.filter((finding) => { + const codeRaw = String(finding.code ?? '').trim(); + const key = codeRaw.length > 0 ? codeRaw : '(no code)'; + return key === codeKey; + }); + const displayCode = codeKey === '(no code)' ? undefined : codeKey; + const { title: issueTitle } = getFindingCodeDisplayParts(displayCode); + const title = + codeKey === '(no code)' ? 'Issues with no code' : issueTitle.trim().length > 0 ? `Issues: ${issueTitle}` : `Issues: ${codeKey}`; + setAggregatedDetailsModal({ + findings: sortFindings(matches, groupBy), + title, + tagline: 'Issue details for the current filters.', + summaryLine: `${matches.length} issue${matches.length === 1 ? '' : 's'} for this issue code.`, + }); + }, + [rollupFindings, groupBy], + ); + + const openAggregatedDetailsForObject = useCallback( + (objectKey: string) => { + const matches = rollupFindings.filter((finding) => { + const objectRaw = String(finding.objectApiName ?? '').trim(); + const key = objectRaw.length > 0 ? objectRaw : '(no object)'; + return key === objectKey; + }); + const title = objectKey === '(no object)' ? 'Issues without object' : `Issues: ${objectKey}`; + setAggregatedDetailsModal({ + findings: sortFindings(matches, groupBy), + title, + tagline: 'Issue details for the current filters.', + summaryLine: `${matches.length} issue${matches.length === 1 ? '' : 's'} for this object.`, + }); + }, + [rollupFindings, groupBy], + ); + + const rowsMap = useMemo(() => new WeakMap(sortedFindings.map((row, index) => [row, `issue-${index}`])), [sortedFindings]); + const getRowKey = useCallback((row: PermissionAnalysisFinding) => rowsMap.get(row) ?? 'issue', [rowsMap]); + + const issueTreeMountKey = useMemo(() => sortedFindings.map(getRowKey).join('\u001f'), [sortedFindings, getRowKey]); + + const handleSortedAndFilteredRowsChange = useCallback((rows: readonly PermissionAnalysisFinding[]) => { + setGridFilteredFindings([...rows]); + }, []); + + useEffect(() => { + if (!aggregatedSidePanelOpen) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setAggregatedSidePanelOpen(false); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [aggregatedSidePanelOpen]); + + if (!findings.length) { + return ( +
+ + No issues for this job yet. Run a permission export analysis to evaluate object vs field read access; results include an + aggregated summary (opened from the left rail when issues exist), toolbar filters (same style as data table filters), column + filters on the grid, and Columns / Group By controls above the grid when issues exist. + +
+ ); + } + + const topCodeRollups = aggregation.byCode.slice(0, 12); + const topObjectRollups = aggregation.byObject.slice(0, 12); + + const aggregatedFlyoutBody = ( + <> +

+ Summary for the rows currently visible in the grid ({rollupFindings.length} row{rollupFindings.length === 1 ? '' : 's'}). Refine + results with toolbar filters and grid header filters; use Columns and Group By for layout and tree grouping. Select a card below to + open full issue messages and metadata. +

+
+ setAggregatedIssueCodeSectionExpanded((previous) => !previous)} + > + + + } + footer={ + aggregatedIssueCodeSectionExpanded && aggregation.byCode.length > topCodeRollups.length ? ( +

+ Showing top {topCodeRollups.length} of {aggregation.byCode.length} codes. +

+ ) : undefined + } + > + +
+ + setAggregatedByObjectSectionExpanded((previous) => !previous)} + > + + + } + footer={ + aggregatedByObjectSectionExpanded && aggregation.byObject.length > topObjectRollups.length ? ( +

+ Showing top {topObjectRollups.length} of {aggregation.byObject.length} objects. +

+ ) : undefined + } + > + +
+
+ + ); + + return ( +
+
+
+ + +
+ +
+ {aggregatedSidePanelOpen ? ( + <> + +
+
{aggregatedFlyoutBody}
+ + + ) : null} + +
+ +
+ + Visible columns + +

+ Uncheck to hide a column. At least one column stays visible. +

+
+ {ISSUES_GRID_COLUMN_KEYS.map((columnKey) => { + const visible = !hiddenIssueGridColumns.has(columnKey); + const disableUncheck = visible && ISSUES_GRID_COLUMN_KEYS.length - hiddenIssueGridColumns.size <= 1; + return ( +
+ setIssueColumnHidden(columnKey, !ev.target.checked)} + /> + +
+ ); + })} +
+
+
+ +
+
+ } + > + Columns + + +
+ + Issues group by + +
+ {( + [ + ['none', 'None (default sort)'], + ['severity', 'Severity'], + ['object', 'Object'], + ['code', 'Code'], + ['container', 'Container'], + ] as const + ).map(([value, label]) => ( +
+ updateParams({ cfGroup: value === 'none' ? null : value })} + /> + +
+ ))} +
+
+
+ } + > + Group By + +
+ + {findings.length > 0 && filteredFindings.length === 0 ? ( +
+ + No issues match the current toolbar filters. Clear them from the summary line under the toolbar, or change filter selections + there and in the toolbar popovers. + +
+ ) : null} + +
+ + {groupBy === 'none' || !treeGroupColumnKey ? ( + { + const severityValue = (row as PermissionAnalysisFinding).severity as string | undefined; + if (isErrorSeverity(severityValue)) { + return 'permission-finding-row--error'; + } + return undefined; + }} + /> + ) : ( + + )} + +
+ + + + {aggregatedDetailsModal && ( + setAggregatedDetailsModal(null)} + /> + )} + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisObjectPermissionsTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisObjectPermissionsTree.tsx new file mode 100644 index 000000000..c500a3b91 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisObjectPermissionsTree.tsx @@ -0,0 +1,429 @@ +import { css } from '@emotion/react'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import { + AutoFullHeightContainer, + ColumnWithFilter, + DataTree, + Icon, + ScopedNotification, + getRowTypeFromValue, + setColumnFromType, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; +import type { CellMouseArgs, RenderCellProps, RenderGroupCellProps } from 'react-data-grid'; +import { permissionAnalysisPermissionContainerGroupTitleLine } from './permission-analysis-tree-group-title'; +import { permissionAnalysisAssignmentTypeLabelCss } from './permission-analysis-viewer-badge.styles'; +import { SobjectTypeCellContent } from './PermissionAnalysisExportGrid'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { + buildObjectPermissionFindingCellHighlights, + buildPermissionSetIdLabelMap, + formatObjectLabelForModalSummary, + getExportColumnHeaderLabel, + listFindingsForObjectPermissionCell, + objectPermissionFindingRowKey, + sortObjectPermissionExportRowsForAnalysisTree, + sortedObjectPermissionBooleanKeys, + type PermissionAnalysisFinding, + type PermissionExportRow, + type SobjectExportDetail, +} from './permission-export-result-view'; + +/** Grouped by profile or permission set (`ParentId`); `TreeDataGrid` clears `renderCell` on every `groupBy` column, so object + actions live on a separate `SobjectType` column. */ +const TREE_GROUP_BY = ['_treePermSetGroupKey'] as const; + +const OMIT_FROM_LEAF_KEYS = new Set(['attributes', 'Id', 'ParentId', 'SobjectType', '_treePermSetGroupKey', '_treeObjectGroupKey']); + +/** Permission set: grows with grid width but never exceeds {@link TREE_PERM_SET_MAX_PX}. */ +const TREE_PERM_SET_MIN_PX = 140; +const TREE_PERM_SET_MAX_PX = 420; +const TREE_COL_PERM_SET = `minmax(${TREE_PERM_SET_MIN_PX}px, min(${TREE_PERM_SET_MAX_PX}px, 1.35fr))`; + +/** Fixed-width object column (label + info + Object Manager), not a flexible `fr` track. */ +/** Label + tooltip + optional Object Manager only (no info popover). */ +const TREE_OBJECT_WIDTH_PX = 236; + +/** Boolean permission cells can shrink to ~104px before headers feel too tight. */ +const TREE_COL_PERMISSION_BOOL = 'minmax(104px, 0.42fr)'; + +const TREE_MIN_PERM_SET = TREE_PERM_SET_MIN_PX; +const TREE_MIN_PERMISSION_BOOL = 104; + +/** Default data row height; group rows are taller for type pill + title + count. */ +const TREE_ROW_HEIGHT_LEAF_PX = 35; +const TREE_ROW_HEIGHT_GROUP_PX = 68; + +export type ObjectPermissionTreeRow = PermissionExportRow & { + _treePermSetGroupKey: string; + _treeObjectGroupKey: string; +}; + +function buildObjectPermissionTreeRows(objectPermissionRows: PermissionExportRow[]): ObjectPermissionTreeRow[] { + return objectPermissionRows.map((row, index) => { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + return { + ...row, + _treePermSetGroupKey: parentId || `__missing_parent_${index}`, + _treeObjectGroupKey: sobjectType || `__missing_object_${index}`, + }; + }); +} + +function collectAllPermissionSetGroupIds(rows: ObjectPermissionTreeRow[]): Set { + return new Set(rows.map((row) => row._treePermSetGroupKey)); +} + +/** TreeDataGrid injects synthetic group rows; only Salesforce leaf rows have `ParentId`. */ +function isObjectPermissionLeafRow(row: unknown): row is ObjectPermissionTreeRow { + if (row === null || typeof row !== 'object') { + return false; + } + const record = row as Record; + return typeof record.ParentId === 'string' && record.ParentId.trim().length > 0; +} + +interface CellFindingsModalState { + parentId: string; + objectApiName: string; + columnKey: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +} + +function renderPermissionSetGroupCell( + labelByParentId: Map, + permissionSetRowById: Map, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const id = String(groupKey); + const exportLabel = labelByParentId.get(id) ?? id; + const permSetRow = permissionSetRowById.get(id); + const isProfileOwned = permSetRow?.IsOwnedByProfile === true; + const titleLine = permissionAnalysisPermissionContainerGroupTitleLine(exportLabel, isProfileOwned); + const typeKind = isProfileOwned ? 'profile' : 'permission_set'; + const typeCaption = isProfileOwned ? 'Profile' : 'Permission set'; + return ( + + ); +} + +export interface PermissionAnalysisObjectPermissionsTreeProps { + objectPermissionRows: PermissionExportRow[]; + permissionSetRows: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + sobjectExportDetails?: Record; + /** When present, highlights object-level permission cells that correspond to each issue. */ + findings?: PermissionAnalysisFinding[]; +} + +/** + * Object permissions from the export, grouped by profile or permission set (`ParentId`), with Object + actions + * and CRUD / View All / Modify All columns on leaf rows. Groups sort profile-first then alphabetically; objects sort + * alphabetically by label within each group. + */ +export const PermissionAnalysisObjectPermissionsTree: FunctionComponent = ({ + objectPermissionRows, + permissionSetRows, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, + findings = [], +}) => { + const sortedObjectPermissionRows = useMemo( + () => sortObjectPermissionExportRowsForAnalysisTree(objectPermissionRows, permissionSetRows, sobjectExportDetails), + [objectPermissionRows, permissionSetRows, sobjectExportDetails], + ); + const treeRows = useMemo(() => buildObjectPermissionTreeRows(sortedObjectPermissionRows), [sortedObjectPermissionRows]); + const labelByParentId = useMemo(() => buildPermissionSetIdLabelMap(permissionSetRows), [permissionSetRows]); + const permissionSetRowById = useMemo(() => { + const map = new Map(); + for (const row of permissionSetRows) { + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (rowId) { + map.set(rowId, row); + } + } + return map; + }, [permissionSetRows]); + const findingCellHighlights = useMemo(() => buildObjectPermissionFindingCellHighlights(findings), [findings]); + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + const [cellFindingsModal, setCellFindingsModal] = useState(null); + + useEffect(() => { + setExpandedGroupIds(collectAllPermissionSetGroupIds(treeRows)); + }, [treeRows]); + + const objectManager = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + const row0 = treeRows[0]; + const groupPermSetCol: ColumnWithFilter = { + ...setColumnFromType('_treePermSetGroupKey', 'text'), + name: 'Profile / Permission set', + key: '_treePermSetGroupKey', + field: '_treePermSetGroupKey', + resizable: true, + width: TREE_COL_PERM_SET, + minWidth: TREE_MIN_PERM_SET, + maxWidth: TREE_PERM_SET_MAX_PX, + renderGroupCell: (props) => renderPermissionSetGroupCell(labelByParentId, permissionSetRowById, props), + getValue: ({ row }) => { + const id = row._treePermSetGroupKey; + return labelByParentId.get(id) ?? id; + }, + } as ColumnWithFilter; + + const objectCol: ColumnWithFilter = { + ...setColumnFromType('SobjectType', 'textOrSalesforceId'), + name: 'Object', + key: 'SobjectType', + field: 'SobjectType', + resizable: false, + width: TREE_OBJECT_WIDTH_PX, + minWidth: TREE_OBJECT_WIDTH_PX, + maxWidth: TREE_OBJECT_WIDTH_PX, + getValue: ({ row }) => { + const api = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!api) { + return null; + } + const detail = sobjectExportDetails?.[api]; + const label = detail?.label?.trim() ? detail.label.trim() : api; + const parts = [label, api]; + if (detail?.description != null && String(detail.description).trim().length > 0) { + parts.push(String(detail.description).trim()); + } + return parts.join(' '); + }, + renderCell: (props: RenderCellProps) => { + const raw = props.row?.SobjectType; + const apiName = typeof raw === 'string' ? raw.trim() : ''; + if (!apiName) { + return
; + } + const detail = sobjectExportDetails?.[apiName]; + return ; + }, + } as ColumnWithFilter; + + const permissionCols: ColumnWithFilter[] = []; + for (const key of sortedObjectPermissionBooleanKeys(treeRows)) { + if (OMIT_FROM_LEAF_KEYS.has(key)) { + continue; + } + const fieldType = getRowTypeFromValue(row0[key], false); + const headerLabel = getExportColumnHeaderLabel(key); + const columnKey = key; + permissionCols.push({ + ...setColumnFromType(key, fieldType), + name: headerLabel, + key, + field: key, + resizable: true, + width: TREE_COL_PERMISSION_BOOL, + minWidth: TREE_MIN_PERMISSION_BOOL, + cellClass: (row: ObjectPermissionTreeRow) => { + if (!isObjectPermissionLeafRow(row)) { + return undefined; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!parentId || !sobjectType) { + return undefined; + } + const rowKey = objectPermissionFindingRowKey(parentId, sobjectType); + const severity = findingCellHighlights.get(rowKey)?.get(columnKey); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }, + } as ColumnWithFilter); + } + + return [groupPermSetCol, objectCol, ...permissionCols]; + }, [treeRows, labelByParentId, permissionSetRowById, sobjectExportDetails, objectManager, findingCellHighlights]); + + const getRowKey = useCallback((row: ObjectPermissionTreeRow) => { + if (typeof row.Id === 'string' && row.Id.length > 0) { + return row.Id; + } + return `${row._treePermSetGroupKey}::${row._treeObjectGroupKey}`; + }, []); + + const handleCellClick = useCallback( + ({ row, column }: CellMouseArgs) => { + if (!isObjectPermissionLeafRow(row)) { + return; + } + const columnKey = typeof column.key === 'string' ? column.key : ''; + if (!columnKey.startsWith('Permissions')) { + return; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const objectApiName = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!parentId || !objectApiName) { + return; + } + const rowKey = objectPermissionFindingRowKey(parentId, objectApiName); + const highlightSeverity = findingCellHighlights.get(rowKey)?.get(columnKey); + if (!highlightSeverity) { + return; + } + const matches = listFindingsForObjectPermissionCell(findings, parentId, objectApiName, columnKey); + if (matches.length === 0) { + return; + } + const columnLabel = typeof column.name === 'string' && column.name.trim().length > 0 ? column.name : columnKey; + setCellFindingsModal({ + parentId, + objectApiName, + columnKey, + columnLabel, + matches, + }); + }, + [findingCellHighlights, findings], + ); + + const cellModalObjectSummary = useMemo(() => { + if (!cellFindingsModal) { + return null; + } + return formatObjectLabelForModalSummary(cellFindingsModal.objectApiName, sobjectExportDetails); + }, [cellFindingsModal, sobjectExportDetails]); + + if (!objectPermissionRows.length) { + return ( +
+ No object permission rows in this export. +
+ ); + } + + return ( + + (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + onCellClick={handleCellClick} + /> + + {cellFindingsModal && ( + setCellFindingsModal(null)} + findings={cellFindingsModal.matches} + summaryLine={ + + {cellFindingsModal.columnLabel} + {' · '} + {cellModalObjectSummary?.displayLabel ? ( + cellModalObjectSummary.showApiInParens ? ( + + {cellModalObjectSummary.displayLabel} + + {' '} + ({cellFindingsModal.objectApiName}) + + + ) : ( + {cellModalObjectSummary.displayLabel} + ) + ) : null} + {' · '} + {labelByParentId.get(cellFindingsModal.parentId) ?? cellFindingsModal.parentId} — {cellFindingsModal.matches.length}{' '} + {cellFindingsModal.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisPermissionSetsTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisPermissionSetsTree.tsx new file mode 100644 index 000000000..2e4f16d57 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisPermissionSetsTree.tsx @@ -0,0 +1,932 @@ +import { css } from '@emotion/react'; +import { logger } from '@jetstream/shared/client-logger'; +import { query } from '@jetstream/shared/data'; +import { escapeSoqlString } from '@jetstream/shared/ui-utils'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import { + AutoFullHeightContainer, + Badge, + ColumnWithFilter, + DataTree, + dataTableDateFormatter, + getProfileOrPermSetSetupUrl, + getSalesforceUserManageSetupUrl, + Grid, + GridCol, + Icon, + KeyboardShortcut, + Popover, + ReadOnlyFormElement, + SalesforceLogin, + ScopedNotification, + salesforceLoginAndRedirect, + setColumnFromType, + Spinner, + type ProfileOrPermSetRecordType, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { + Fragment, + FunctionComponent, + type MouseEvent, + type ReactElement, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from 'react'; +import type { RenderCellProps, RenderGroupCellProps } from 'react-data-grid'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { + buildContainerIdFindingSeverity, + buildPermissionSetAssignmentsTreeRows, + buildPermissionSetIdLabelMap, + isPermissionSetAssignmentsTreePlaceholderLeaf, + isPermissionSetAssignmentsTreeUserLeaf, + listFindingsForExportContainer, + type PermissionAnalysisFinding, + type PermissionExportRow, + type PermissionSetAssignmentsTreeRow, +} from './permission-export-result-view'; + +const TREE_GROUP_BY = ['_treePermissionSetGroupKey'] as const; + +const TREE_PERM_SET_MIN_PX = 140; +const TREE_PERM_SET_MAX_PX = 420; +const TREE_COL_PERM_SET = `minmax(${TREE_PERM_SET_MIN_PX}px, min(${TREE_PERM_SET_MAX_PX}px, 1.35fr))`; + +const USER_COL_MIN_PX = 200; +const TREE_COL_USER = `minmax(${USER_COL_MIN_PX}px, 1fr)`; + +/** Chunk size for `User` Id IN queries (Salesforce SOQL limits). */ +const USER_SOQL_CHUNK_SIZE = 200; + +const TREE_ROW_HEIGHT_LEAF_PX = 48; +/** Taller group rows so permission set label + created / last modified lines fit. */ +const TREE_ROW_HEIGHT_GROUP_PX = 60; + +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +const PERMISSION_ANALYSIS_POPOVER_PANEL_PROPS = { + onDoubleClick: (event: MouseEvent) => { + event.stopPropagation(); + }, +}; + +interface AssigneeDisplay { + name: string; + username: string; + /** When `false`, an inactive badge is shown next to the name. */ + isActive: boolean; +} + +function userRecordIsActive(record: { IsActive?: unknown }): boolean { + const value = record.IsActive; + if (value === false || value === 'false') { + return false; + } + return true; +} + +function collectUniqueUserAssigneeIds(assignments: PermissionExportRow[]): string[] { + const ids = new Set(); + for (const row of assignments) { + const assigneeId = row.AssigneeId; + if (typeof assigneeId === 'string' && assigneeId.trim().startsWith('005')) { + ids.add(assigneeId.trim()); + } + } + return [...ids].sort((a, b) => a.localeCompare(b)); +} + +export interface PermissionSetTooltipFields { + label: string; + name: string; + description: string | null; + createdWhen: string | null; + createdByName: string | null; + lastModifiedWhen: string | null; + lastModifiedByName: string | null; +} + +function readSalesforceRelationshipName(value: unknown): string | null { + if (value && typeof value === 'object' && value !== null && 'Name' in value) { + const name = (value as { Name?: unknown }).Name; + if (typeof name === 'string' && name.trim()) { + return name.trim(); + } + } + return null; +} + +function readIsoDatetimeDisplay(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) { + return null; + } + return dataTableDateFormatter(value.trim()); +} + +function formatAuditLine(prefix: string, when: string | null, by: string | null): string | null { + if (!when && !by) { + return null; + } + if (when && by) { + return `${prefix} ${when} · ${by}`; + } + if (when) { + return `${prefix} ${when}`; + } + return `${prefix} · ${by}`; +} + +/** Single permission-set (or profile-owned) export row → metadata for the detail popover. */ +export function buildPermissionSetTooltipFieldsFromExportRow(row: PermissionExportRow | undefined): PermissionSetTooltipFields | null { + if (!row) { + return null; + } + const id = row.Id; + if (typeof id !== 'string' || !id.trim()) { + return null; + } + const trimmedId = id.trim(); + const label = typeof row.Label === 'string' && row.Label.trim() ? row.Label.trim() : ''; + const name = typeof row.Name === 'string' && row.Name.trim() ? row.Name.trim() : ''; + const rawDescription = row.Description; + const description = rawDescription != null && String(rawDescription).trim().length > 0 ? String(rawDescription).trim() : null; + return { + label: label || name || trimmedId, + name: name || '—', + description, + createdWhen: readIsoDatetimeDisplay(row.CreatedDate), + createdByName: readSalesforceRelationshipName(row.CreatedBy), + lastModifiedWhen: readIsoDatetimeDisplay(row.LastModifiedDate), + lastModifiedByName: readSalesforceRelationshipName(row.LastModifiedBy), + }; +} + +export function PermissionSetDetailPopoverContent({ + fields, + containerKind, + setupLogin, + returnUrl, + slug, +}: { + fields: PermissionSetTooltipFields; + containerKind: 'Profile' | 'PermissionSet'; + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; + returnUrl: string; + slug: string; +}): ReactElement { + const labelHeading = containerKind === 'Profile' ? 'Profile Label' : 'Permission Set Label'; + const apiHeading = containerKind === 'Profile' ? 'Profile API Name' : 'Permission Set API Name'; + const canDeepLink = Boolean(setupLogin.org?.uniqueId && setupLogin.serverUrl); + const createdLine = formatAuditLine('Created', fields.createdWhen, fields.createdByName); + const modifiedLine = formatAuditLine('Last Modified', fields.lastModifiedWhen, fields.lastModifiedByName); + + return ( +
+ {canDeepLink ? ( + + View in Salesforce + + ) : null} + + + + + + + + + + + + + + + + + {canDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+
+ ); +} + +function buildPermissionSetTooltipFieldsById(rows: PermissionExportRow[]): Map { + const map = new Map(); + for (const row of rows) { + const id = row.Id; + if (typeof id !== 'string' || !id.trim()) { + continue; + } + const fields = buildPermissionSetTooltipFieldsFromExportRow(row); + if (fields) { + map.set(id.trim(), fields); + } + } + return map; +} + +type ContainerFindingsModalState = { + containerId: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +}; + +function renderPermissionSetGroupCell( + labelByPermissionSetId: Map, + tooltipByPermissionSetId: Map, + containerSeverity: Map | null, + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }, + onOpenFindings: (permissionSetId: string) => void, + resolveSetupTarget: (permissionSetId: string) => { recordType: ProfileOrPermSetRecordType; recordId: string }, + openInSetupTitle: string, + findingsForContainerButtonTitle: string, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const permissionSetId = String(groupKey); + const titleLabel = labelByPermissionSetId.get(permissionSetId) ?? permissionSetId; + const tooltipFields = tooltipByPermissionSetId.get(permissionSetId) ?? { + label: titleLabel, + name: '—', + description: null, + createdWhen: null, + createdByName: null, + lastModifiedWhen: null, + lastModifiedByName: null, + }; + const createdLine = formatAuditLine('Created', tooltipFields.createdWhen, tooltipFields.createdByName); + const modifiedLine = formatAuditLine('Last Modified', tooltipFields.lastModifiedWhen, tooltipFields.lastModifiedByName); + const severity = containerSeverity?.get(permissionSetId); + const { recordType, recordId } = resolveSetupTarget(permissionSetId); + const returnUrl = getProfileOrPermSetSetupUrl(recordType, recordId); + + const detailSlug = permissionSetId.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const containerKind: 'Profile' | 'PermissionSet' = recordType === 'Profile' ? 'Profile' : 'PermissionSet'; + const canDeepLink = Boolean(setupLogin.org?.uniqueId && setupLogin.serverUrl); + + return ( +
+
+ + + } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + flex: 1, + minWidth: 0, + height: 'auto', + alignItems: 'flex-start', + display: 'flex', + lineHeight: 1.35, + overflowWrap: 'anywhere', + whiteSpace: 'normal', + wordBreak: 'break-word', + padding: 0, + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl: setupLogin.serverUrl, + org: setupLogin.org, + returnUrl, + skipFrontDoorAuth: setupLogin.skipFrontDoorAuth, + }); + } + }} + > + + {titleLabel} + ({childRows.length}) + + {(createdLine || modifiedLine) && ( +
+ {createdLine && ( +
+ {createdLine} +
+ )} + {modifiedLine && ( +
+ {modifiedLine} +
+ )} +
+ )} +
+
+
+
+ { + event.stopPropagation(); + }} + > + + + {severity && ( + + )} +
+
+ ); +} + +export type PermissionAnalysisPermissionSetsTreePresentation = 'permission_sets' | 'profiles'; + +export interface PermissionAnalysisPermissionSetsTreeProps { + permissionSetRows: PermissionExportRow[]; + permissionSetAssignments: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + findings?: PermissionAnalysisFinding[]; + containerLabelById?: Map; + /** + * `profiles` uses Profile Setup URLs (via `ProfileId` on profile-owned permission set rows) and profile-oriented labels. + * `permission_sets` (default) matches standalone permission sets. + */ + treePresentation?: PermissionAnalysisPermissionSetsTreePresentation; +} + +/** + * Permission sets or profiles (profile-owned permission sets) from the export, grouped by container with tooltip metadata + * and expandable user assignment leaves. Use {@link PermissionAnalysisPermissionSetsTreeProps.treePresentation} to match the tab. + * Groups start expanded. + */ +export const PermissionAnalysisPermissionSetsTree: FunctionComponent = ({ + permissionSetRows, + permissionSetAssignments, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + findings = [], + containerLabelById, + treePresentation = 'permission_sets', +}) => { + const isProfilesTree = treePresentation === 'profiles'; + const groupColumnName = isProfilesTree ? 'Profile' : 'Permission Set'; + const openInSetupTitle = isProfilesTree ? 'Open this profile in Salesforce Setup' : 'Open this permission set in Salesforce Setup'; + const findingsForContainerButtonTitle = isProfilesTree ? 'View issues for this profile' : 'View issues for this permission set'; + + const rowByPermissionSetId = useMemo(() => { + const map = new Map(); + for (const row of permissionSetRows) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (id) { + map.set(id, row); + } + } + return map; + }, [permissionSetRows]); + + const resolveSetupTarget = useCallback( + (permissionSetId: string): { recordType: ProfileOrPermSetRecordType; recordId: string } => { + if (!isProfilesTree) { + return { recordType: 'PermissionSet', recordId: permissionSetId }; + } + const row = rowByPermissionSetId.get(permissionSetId); + const profileId = row && typeof row.ProfileId === 'string' && row.ProfileId.trim().length > 0 ? row.ProfileId.trim() : null; + const isProfileOwned = row?.IsOwnedByProfile === true; + if (isProfileOwned && profileId) { + return { recordType: 'Profile', recordId: profileId }; + } + return { recordType: 'PermissionSet', recordId: permissionSetId }; + }, + [isProfilesTree, rowByPermissionSetId], + ); + + const treeRows = useMemo( + () => buildPermissionSetAssignmentsTreeRows(permissionSetRows, permissionSetAssignments), + [permissionSetRows, permissionSetAssignments], + ); + const labelByPermissionSetId = useMemo(() => buildPermissionSetIdLabelMap(permissionSetRows), [permissionSetRows]); + const tooltipByPermissionSetId = useMemo(() => buildPermissionSetTooltipFieldsById(permissionSetRows), [permissionSetRows]); + const containerSeverity = useMemo(() => { + if (findings.length === 0) { + return null; + } + return buildContainerIdFindingSeverity(findings); + }, [findings]); + + const allExpandedGroupIds = useMemo(() => { + const ids = new Set(); + for (const row of treeRows) { + ids.add(row._treePermissionSetGroupKey); + } + return ids; + }, [treeRows]); + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + useLayoutEffect(() => { + setExpandedGroupIds(new Set(allExpandedGroupIds)); + }, [allExpandedGroupIds]); + + const [findingsModal, setFindingsModal] = useState(null); + const [assigneeDisplayById, setAssigneeDisplayById] = useState>(() => new Map()); + const [assigneeDisplayLoading, setAssigneeDisplayLoading] = useState(false); + + useEffect(() => { + if (!org?.uniqueId) { + setAssigneeDisplayById(new Map()); + setAssigneeDisplayLoading(false); + return; + } + const ids = collectUniqueUserAssigneeIds(permissionSetAssignments); + if (ids.length === 0) { + setAssigneeDisplayById(new Map()); + setAssigneeDisplayLoading(false); + return; + } + + let cancelled = false; + setAssigneeDisplayLoading(true); + + void (async () => { + try { + const merged = new Map(); + for (let index = 0; index < ids.length; index += USER_SOQL_CHUNK_SIZE) { + const chunk = ids.slice(index, index + USER_SOQL_CHUNK_SIZE); + const inList = chunk.map((id) => `'${escapeSoqlString(id)}'`).join(', '); + const soql = `SELECT Id, Name, Username, IsActive FROM User WHERE Id IN (${inList})`; + const response = await query<{ Id: string; Name?: string; Username?: string; IsActive?: boolean }>(org, soql); + for (const record of response.queryResults.records ?? []) { + const recordId = typeof record.Id === 'string' ? record.Id.trim() : ''; + if (!recordId) { + continue; + } + merged.set(recordId, { + name: typeof record.Name === 'string' && record.Name.trim() ? record.Name.trim() : recordId, + username: typeof record.Username === 'string' && record.Username.trim() ? record.Username.trim() : '', + isActive: userRecordIsActive(record), + }); + } + } + if (!cancelled) { + setAssigneeDisplayById(merged); + } + } catch (error) { + logger.warn('Failed to load User rows for permission set tree assignees', error); + if (!cancelled) { + setAssigneeDisplayById(new Map()); + } + } finally { + if (!cancelled) { + setAssigneeDisplayLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [org, permissionSetAssignments]); + + const setupLogin = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const openFindingsForPermissionSet = useCallback( + (permissionSetId: string) => { + const id = permissionSetId.trim(); + if (!id || !containerSeverity?.has(id)) { + return; + } + const matches = listFindingsForExportContainer(findings, id); + if (matches.length === 0) { + return; + } + setFindingsModal({ + containerId: id, + columnLabel: groupColumnName, + matches, + }); + }, + [containerSeverity, findings, groupColumnName], + ); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + const groupCol: ColumnWithFilter = { + ...setColumnFromType('_treePermissionSetGroupKey', 'text'), + name: groupColumnName, + key: '_treePermissionSetGroupKey', + field: '_treePermissionSetGroupKey', + resizable: true, + width: TREE_COL_PERM_SET, + minWidth: TREE_PERM_SET_MIN_PX, + maxWidth: TREE_PERM_SET_MAX_PX, + renderGroupCell: (props) => + renderPermissionSetGroupCell( + labelByPermissionSetId, + tooltipByPermissionSetId, + containerSeverity, + setupLogin, + openFindingsForPermissionSet, + resolveSetupTarget, + openInSetupTitle, + findingsForContainerButtonTitle, + props, + ), + getValue: ({ row }) => { + const id = row._treePermissionSetGroupKey; + return labelByPermissionSetId.get(id) ?? id; + }, + } as ColumnWithFilter; + + const userCol: ColumnWithFilter = { + ...setColumnFromType('AssigneeId', 'salesforceId'), + name: 'Assigned User', + key: 'AssigneeId', + field: 'AssigneeId', + resizable: true, + width: TREE_COL_USER, + minWidth: USER_COL_MIN_PX, + getValue: ({ row }) => { + if (isPermissionSetAssignmentsTreeUserLeaf(row)) { + const userLeaf = row; + const userId = typeof userLeaf.AssigneeId === 'string' ? userLeaf.AssigneeId.trim() : ''; + const display = userId ? assigneeDisplayById.get(userId) : undefined; + if (display) { + const inactiveTag = display.isActive ? '' : ' inactive'; + return `${display.name} ${display.username}${inactiveTag} ${userId}`.trim(); + } + return userId; + } + if (isPermissionSetAssignmentsTreePlaceholderLeaf(row)) { + return 'No direct user assignments'; + } + return ''; + }, + renderCell: (props: RenderCellProps) => { + const row = props.row; + if (!row) { + return null; + } + if (isPermissionSetAssignmentsTreeUserLeaf(row)) { + const userId = typeof row.AssigneeId === 'string' ? row.AssigneeId.trim() : ''; + if (!userId) { + return null; + } + const userReturnUrl = getSalesforceUserManageSetupUrl(userId); + const openUserButton = ( + { + event.stopPropagation(); + }} + > + + + ); + if (assigneeDisplayLoading) { + return ( +
+ + {openUserButton} +
+ ); + } + const display = assigneeDisplayById.get(userId); + if (display) { + const nameTitle = `${display.name} — ${display.username}${display.isActive ? '' : ' (inactive)'}`; + return ( +
+
+
+ {display.name} + {!display.isActive && ( + + + Inactive + + + )} +
+

{display.username}

+
+
{openUserButton}
+
+ ); + } + return ( +
+
+ {userId} +
+
{openUserButton}
+
+ ); + } + if (isPermissionSetAssignmentsTreePlaceholderLeaf(row)) { + return
No direct user assignments
; + } + return null; + }, + } as ColumnWithFilter; + + return [groupCol, userCol]; + }, [ + treeRows, + groupColumnName, + labelByPermissionSetId, + tooltipByPermissionSetId, + containerSeverity, + setupLogin, + openFindingsForPermissionSet, + resolveSetupTarget, + openInSetupTitle, + findingsForContainerButtonTitle, + assigneeDisplayById, + assigneeDisplayLoading, + ]); + + const getRowKey = useCallback((row: PermissionSetAssignmentsTreeRow) => { + if (typeof row.Id === 'string' && row.Id.length > 0) { + return row.Id; + } + return row._treePermissionSetGroupKey; + }, []); + + if (!permissionSetRows.length) { + return ( +
+ + {isProfilesTree ? 'No profiles in this export slice.' : 'No permission sets in this export slice.'} + +
+ ); + } + + if (!treeRows.length) { + return ( +
+ + {isProfilesTree + ? 'No profile rows with Ids were available to build this tree.' + : 'No permission set rows with Ids were available to build this tree.'} + +
+ ); + } + + return ( + + (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + /> + + {findingsModal && ( + setFindingsModal(null)} + findings={findingsModal.matches} + summaryLine={ + + {findingsModal.columnLabel} + {' · '} + {containerLabelById?.get(findingsModal.containerId) ?? findingsModal.containerId} — {findingsModal.matches.length}{' '} + {findingsModal.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisSelection.tsx b/libs/features/manage-permissions/src/PermissionAnalysisSelection.tsx new file mode 100644 index 000000000..6e77af34f --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisSelection.tsx @@ -0,0 +1,9 @@ +import { FunctionComponent } from 'react'; +import { ManagePermissionsSelection } from './ManagePermissionsSelection'; + +/** Permission analysis entry: same selection UX as Manage Permissions with objects disabled (v1). */ +export const PermissionAnalysisSelection: FunctionComponent = () => ( + +); + +export default PermissionAnalysisSelection; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisTabVisibilityTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisTabVisibilityTree.tsx new file mode 100644 index 000000000..3ce8d956f --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisTabVisibilityTree.tsx @@ -0,0 +1,410 @@ +import { css } from '@emotion/react'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import { + AutoFullHeightContainer, + ColumnWithFilter, + DataTree, + Icon, + SalesforceLogin, + ScopedNotification, + getProfileOrPermSetSetupUrl, + getRowTypeFromValue, + setColumnFromType, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; +import type { RenderCellProps, RenderGroupCellProps } from 'react-data-grid'; +import { usePermissionAnalysisExportMetadata } from './permission-analysis-export-metadata-context'; +import { permissionAnalysisPermissionContainerGroupTitleLine } from './permission-analysis-tree-group-title'; +import { permissionAnalysisAssignmentTypeLabelCss } from './permission-analysis-viewer-badge.styles'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { + buildContainerIdFindingSeverity, + buildPermissionSetIdLabelMap, + formatTabSettingVisibilityDisplay, + getExportColumnHeaderLabel, + listFindingsForExportContainer, + sortTabSettingExportRowsForAnalysisTree, + type PermissionAnalysisFinding, + type PermissionExportRow, + type PermissionObjectFindingCellSeverity, +} from './permission-export-result-view'; + +const TREE_GROUP_BY = ['_treeParentGroupKey'] as const; + +const TREE_PARENT_COL = `minmax(160px, min(380px, 1.35fr))`; +const TREE_TAB_COL = `minmax(180px, 1fr)`; +const TREE_VISIBILITY_COL = `minmax(120px, 0.45fr)`; + +const TREE_ROW_HEIGHT_LEAF_PX = 35; +const TREE_ROW_HEIGHT_GROUP_PX = 68; + +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +export type TabVisibilityTreeRow = PermissionExportRow & { + _treeParentGroupKey: string; + _treeTabKey: string; +}; + +function buildTabVisibilityTreeRows(rows: PermissionExportRow[]): TabVisibilityTreeRow[] { + return rows.map((row, index) => { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const tabName = typeof row.Name === 'string' ? row.Name.trim() : ''; + return { + ...row, + _treeParentGroupKey: parentId || `__missing_parent_${index}`, + _treeTabKey: tabName || `__missing_tab_${index}`, + }; + }); +} + +function collectAllParentGroupKeys(rows: TabVisibilityTreeRow[]): Set { + return new Set(rows.map((row) => row._treeParentGroupKey)); +} + +function renderTabVisibilityGroupCell( + labelByParentId: Map, + permissionSetRowById: Map, + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }, + containerSeverity: Map | null, + onOpenFindingsForParent: (parentId: string) => void, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const id = String(groupKey); + const exportLabel = labelByParentId.get(id) ?? id; + const severity = containerSeverity?.get(id); + const permSetRow = permissionSetRowById.get(id); + const isProfileOwned = permSetRow?.IsOwnedByProfile === true; + const titleLine = permissionAnalysisPermissionContainerGroupTitleLine(exportLabel, isProfileOwned); + const typeKind = isProfileOwned ? 'profile' : 'permission_set'; + const typeCaption = isProfileOwned ? 'Profile' : 'Permission set'; + const profileId = + permSetRow && typeof permSetRow.ProfileId === 'string' && permSetRow.ProfileId.trim().length > 0 ? permSetRow.ProfileId.trim() : null; + const recordType = isProfileOwned && profileId ? 'Profile' : 'PermissionSet'; + const setupTargetId = recordType === 'Profile' && profileId ? profileId : id; + const returnUrl = getProfileOrPermSetSetupUrl(recordType, setupTargetId); + + return ( +
+ +
+ { + event.stopPropagation(); + }} + > + + + {severity ? ( + + ) : null} +
+
+ ); +} + +export interface PermissionAnalysisTabVisibilityTreeProps { + tabSettingRows: PermissionExportRow[]; + permissionSetRows: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + findings?: PermissionAnalysisFinding[]; + containerLabelById?: Map; +} + +interface TabFindingsModalState { + parentId: string; + matches: PermissionAnalysisFinding[]; +} + +/** + * Tab visibility (`PermissionSetTabSetting`) from the export, grouped by profile or permission set (`ParentId`), + * with one leaf row per tab. Ordering matches the object-permissions tree: profiles first, then permission sets, + * then tabs alphabetically by label when loaded, otherwise by API name. + */ +export const PermissionAnalysisTabVisibilityTree: FunctionComponent = ({ + tabSettingRows, + permissionSetRows, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + findings = [], + containerLabelById, +}) => { + const { tabLabelBySettingName } = usePermissionAnalysisExportMetadata(); + const sortedRows = useMemo( + () => sortTabSettingExportRowsForAnalysisTree(tabSettingRows, permissionSetRows, tabLabelBySettingName), + [tabSettingRows, permissionSetRows, tabLabelBySettingName], + ); + const treeRows = useMemo(() => buildTabVisibilityTreeRows(sortedRows), [sortedRows]); + const labelByParentId = useMemo(() => buildPermissionSetIdLabelMap(permissionSetRows), [permissionSetRows]); + const permissionSetRowById = useMemo(() => { + const map = new Map(); + for (const row of permissionSetRows) { + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (rowId) { + map.set(rowId, row); + } + } + return map; + }, [permissionSetRows]); + + const containerSeverity = useMemo(() => { + if (findings.length === 0) { + return null; + } + return buildContainerIdFindingSeverity(findings); + }, [findings]); + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + const [findingsModal, setFindingsModal] = useState(null); + + const openFindingsForParent = useCallback( + (parentId: string) => { + const matches = listFindingsForExportContainer(findings, parentId); + if (matches.length === 0) { + return; + } + setFindingsModal({ parentId, matches }); + }, + [findings, setFindingsModal], + ); + + useEffect(() => { + setExpandedGroupIds(collectAllParentGroupKeys(treeRows)); + }, [treeRows]); + + const setupLogin = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + const row0 = treeRows[0]; + + const parentCol: ColumnWithFilter = { + ...setColumnFromType('_treeParentGroupKey', 'text'), + name: 'Profile / Permission set', + key: '_treeParentGroupKey', + field: '_treeParentGroupKey', + resizable: true, + width: TREE_PARENT_COL, + renderGroupCell: (props) => + renderTabVisibilityGroupCell(labelByParentId, permissionSetRowById, setupLogin, containerSeverity, openFindingsForParent, props), + getValue: ({ row }) => { + const parentKey = row._treeParentGroupKey; + return labelByParentId.get(parentKey) ?? parentKey; + }, + } as ColumnWithFilter; + + const visibilityHeader = getExportColumnHeaderLabel('Visibility'); + + const tabCol: ColumnWithFilter = { + ...setColumnFromType('Name', 'text'), + name: 'Tab', + key: 'Name', + field: 'Name', + resizable: true, + width: TREE_TAB_COL, + getValue: ({ row }) => { + const api = typeof row.Name === 'string' ? row.Name.trim() : ''; + const label = tabLabelBySettingName?.get(api)?.trim(); + const display = label && label.length > 0 ? label : api; + return display.length > 0 ? display : '—'; + }, + renderCell: (props: RenderCellProps) => { + const api = typeof props.row?.Name === 'string' ? props.row.Name.trim() : ''; + if (api.length === 0) { + return
; + } + const label = tabLabelBySettingName?.get(api)?.trim(); + const display = label && label.length > 0 ? label : api; + const title = display !== api ? `${display} (${api})` : display; + return ( +
+ {display} + {display !== api ? ({api}) : null} +
+ ); + }, + } as ColumnWithFilter; + + const visibilityFieldType = getRowTypeFromValue(row0.Visibility, false); + const visibilityCol: ColumnWithFilter = { + ...setColumnFromType('Visibility', visibilityFieldType), + name: visibilityHeader, + key: 'Visibility', + field: 'Visibility', + resizable: true, + width: TREE_VISIBILITY_COL, + getValue: ({ row }) => formatTabSettingVisibilityDisplay(row.Visibility), + renderCell: (props: RenderCellProps) => { + const text = formatTabSettingVisibilityDisplay(props.row?.Visibility); + return ( +
+ {text} +
+ ); + }, + } as ColumnWithFilter; + + return [parentCol, tabCol, visibilityCol]; + }, [treeRows, labelByParentId, permissionSetRowById, setupLogin, containerSeverity, openFindingsForParent, tabLabelBySettingName]); + + const getRowKey = useCallback((row: TabVisibilityTreeRow) => { + if (typeof row.Id === 'string' && row.Id.length > 0) { + return row.Id; + } + return `${row._treeParentGroupKey}::${row._treeTabKey}`; + }, []); + + if (!tabSettingRows.length) { + return ( +
+ No tab visibility rows in this export. +
+ ); + } + + return ( + + (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + /> + + {findingsModal && ( + setFindingsModal(null)} + findings={findingsModal.matches} + summaryLine={ + + {containerLabelById?.get(findingsModal.parentId) ?? findingsModal.parentId} + {' — '} + {findingsModal.matches.length} {findingsModal.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisUserAssignmentsTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisUserAssignmentsTree.tsx new file mode 100644 index 000000000..84ecd2ff3 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisUserAssignmentsTree.tsx @@ -0,0 +1,880 @@ +import { css } from '@emotion/react'; +import { logger } from '@jetstream/shared/client-logger'; +import { query } from '@jetstream/shared/data'; +import { escapeSoqlString } from '@jetstream/shared/ui-utils'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import { + AutoFullHeightContainer, + Badge, + ColumnWithFilter, + DataTree, + getPermissionSetGroupSetupUrl, + getProfileOrPermSetSetupUrl, + getSalesforceUserManageSetupUrl, + Icon, + SalesforceLogin, + ScopedNotification, + setColumnFromType, + Spinner, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { Fragment, FunctionComponent, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import type { RenderCellProps, RenderGroupCellProps } from 'react-data-grid'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { permissionAnalysisAssignmentTypeLabelCss } from './permission-analysis-viewer-badge.styles'; +import { + buildContainerIdFindingSeverity, + buildPermissionSetGroupLabelMap, + buildPermissionSetIdLabelMap, + buildUserAssignmentsTreeRows, + listFindingsForExportContainer, + sortUserAssignmentsTreeRowsByUserDisplay, + type PermissionAnalysisFinding, + type PermissionExportRow, + type UserAssignmentsTreeRow, + type UserLicenseLeafRecord, +} from './permission-export-result-view'; + +const TREE_GROUP_BY = ['_treeUserGroupKey'] as const; + +const TREE_USER_GROUP_MIN_PX = 200; +const TREE_USER_GROUP_MAX_PX = 420; +const TREE_COL_USER = `minmax(${TREE_USER_GROUP_MIN_PX}px, min(${TREE_USER_GROUP_MAX_PX}px, 1.35fr))`; + +const ASSIGNMENT_COL_MIN_PX = 220; +const TREE_COL_ASSIGNMENT = `minmax(${ASSIGNMENT_COL_MIN_PX}px, 1fr)`; + +const USER_SOQL_CHUNK_SIZE = 200; + +const TREE_ROW_HEIGHT_LEAF_PX = 48; +const TREE_ROW_HEIGHT_GROUP_PX = 48; + +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +interface UserTreeDisplay { + name: string; + username: string; + isActive: boolean; + profileId: string | null; + profileName: string | null; +} + +function userRecordIsActive(record: { IsActive?: unknown }): boolean { + const value = record.IsActive; + if (value === false || value === 'false') { + return false; + } + return true; +} + +function readProfileFromUserRecord(record: { ProfileId?: unknown; Profile?: unknown }): { + profileId: string | null; + profileName: string | null; +} { + const profileId = typeof record.ProfileId === 'string' && record.ProfileId.trim() ? record.ProfileId.trim() : null; + const profileBlock = record.Profile; + const profileName = + profileBlock && + typeof profileBlock === 'object' && + profileBlock !== null && + 'Name' in profileBlock && + typeof (profileBlock as { Name?: unknown }).Name === 'string' + ? String((profileBlock as { Name: string }).Name).trim() + : null; + return { profileId, profileName: profileName && profileName.length > 0 ? profileName : null }; +} + +function collectUniqueUserIdsFromAssignments(assignments: PermissionExportRow[]): string[] { + const ids = new Set(); + for (const row of assignments) { + const assigneeId = row.AssigneeId; + if (typeof assigneeId === 'string' && assigneeId.trim().startsWith('005')) { + ids.add(assigneeId.trim()); + } + } + return [...ids].sort((a, b) => a.localeCompare(b)); +} + +type ContainerFindingsModalState = { + containerId: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +}; + +function renderUserGroupCell( + userDisplayById: Map, + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const userId = String(groupKey); + const display = userDisplayById.get(userId); + const titleLabel = display ? `${display.name} — ${display.username}` : userId; + + return ( +
+ +
+ { + event.stopPropagation(); + }} + > + + +
+
+ ); +} + +export interface PermissionAnalysisUserAssignmentsTreeProps { + permissionSetAssignments: PermissionExportRow[]; + permissionSets: PermissionExportRow[]; + permissionSetGroupComponents: PermissionExportRow[]; + permissionSetGroups: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + findings?: PermissionAnalysisFinding[]; + containerLabelById?: Map; +} + +/** + * Assignments from the export, grouped by user: permission sets (from assignments), inferred permission set groups, + * Salesforce profile, and permission set licenses (from {@link UserPermissionSetLicense} when available). + */ +export const PermissionAnalysisUserAssignmentsTree: FunctionComponent = ({ + permissionSetAssignments, + permissionSets, + permissionSetGroupComponents, + permissionSetGroups, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + findings = [], + containerLabelById, +}) => { + const [licensesByUserId, setLicensesByUserId] = useState>(() => new Map()); + + const baseTreeRows = useMemo( + () => + buildUserAssignmentsTreeRows({ + assignments: permissionSetAssignments, + permissionSets, + groupComponents: permissionSetGroupComponents, + groups: permissionSetGroups, + licensesByUserId, + }), + [permissionSetAssignments, permissionSets, permissionSetGroupComponents, permissionSetGroups, licensesByUserId], + ); + + const labelByPermissionSetId = useMemo(() => buildPermissionSetIdLabelMap(permissionSets), [permissionSets]); + const labelByGroupId = useMemo(() => buildPermissionSetGroupLabelMap(permissionSetGroups), [permissionSetGroups]); + const permissionSetRowById = useMemo(() => { + const map = new Map(); + for (const row of permissionSets) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (id) { + map.set(id, row); + } + } + return map; + }, [permissionSets]); + + const [userDisplayById, setUserDisplayById] = useState>(() => new Map()); + const [userDisplayLoading, setUserDisplayLoading] = useState(false); + + useEffect(() => { + if (!org?.uniqueId) { + setUserDisplayById(new Map()); + setUserDisplayLoading(false); + return; + } + const ids = collectUniqueUserIdsFromAssignments(permissionSetAssignments); + if (ids.length === 0) { + setUserDisplayById(new Map()); + setUserDisplayLoading(false); + return; + } + + let cancelled = false; + setUserDisplayLoading(true); + + void (async () => { + try { + const merged = new Map(); + for (let index = 0; index < ids.length; index += USER_SOQL_CHUNK_SIZE) { + const chunk = ids.slice(index, index + USER_SOQL_CHUNK_SIZE); + const inList = chunk.map((id) => `'${escapeSoqlString(id)}'`).join(', '); + const soql = `SELECT Id, Name, Username, IsActive, ProfileId, Profile.Name FROM User WHERE Id IN (${inList})`; + const response = await query<{ + Id: string; + Name?: string; + Username?: string; + IsActive?: boolean; + ProfileId?: string; + Profile?: { Name?: string }; + }>(org, soql); + for (const record of response.queryResults.records ?? []) { + const recordId = typeof record.Id === 'string' ? record.Id.trim() : ''; + if (!recordId) { + continue; + } + const { profileId, profileName } = readProfileFromUserRecord(record); + merged.set(recordId, { + name: typeof record.Name === 'string' && record.Name.trim() ? record.Name.trim() : recordId, + username: typeof record.Username === 'string' && record.Username.trim() ? record.Username.trim() : '', + isActive: userRecordIsActive(record), + profileId, + profileName, + }); + } + } + if (!cancelled) { + setUserDisplayById(merged); + } + } catch (error) { + logger.warn('Failed to load User rows for assignments tree', error); + if (!cancelled) { + setUserDisplayById(new Map()); + } + } finally { + if (!cancelled) { + setUserDisplayLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [org, permissionSetAssignments]); + + useEffect(() => { + if (!org?.uniqueId) { + setLicensesByUserId(new Map()); + return; + } + const ids = collectUniqueUserIdsFromAssignments(permissionSetAssignments); + if (ids.length === 0) { + setLicensesByUserId(new Map()); + return; + } + + let cancelled = false; + + void (async () => { + try { + const merged = new Map(); + const seenKeys = new Set(); + for (let index = 0; index < ids.length; index += USER_SOQL_CHUNK_SIZE) { + const chunk = ids.slice(index, index + USER_SOQL_CHUNK_SIZE); + const inList = chunk.map((id) => `'${escapeSoqlString(id)}'`).join(', '); + const soql = `SELECT UserId, PermissionSetLicenseId, PermissionSetLicense.MasterLabel, PermissionSetLicense.DeveloperName FROM UserPermissionSetLicense WHERE UserId IN (${inList})`; + const response = await query<{ + UserId?: string; + PermissionSetLicenseId?: string; + PermissionSetLicense?: { MasterLabel?: string; DeveloperName?: string }; + }>(org, soql); + for (const record of response.queryResults.records ?? []) { + const userId = typeof record.UserId === 'string' ? record.UserId.trim() : ''; + const licenseId = typeof record.PermissionSetLicenseId === 'string' ? record.PermissionSetLicenseId.trim() : ''; + if (!userId || !licenseId) { + continue; + } + const dedupeKey = `${userId}::${licenseId}`; + if (seenKeys.has(dedupeKey)) { + continue; + } + seenKeys.add(dedupeKey); + const licBlock = record.PermissionSetLicense; + const master = + licBlock && typeof licBlock.MasterLabel === 'string' && licBlock.MasterLabel.trim() ? licBlock.MasterLabel.trim() : ''; + const dev = + licBlock && typeof licBlock.DeveloperName === 'string' && licBlock.DeveloperName.trim() ? licBlock.DeveloperName.trim() : ''; + const label = master || dev || licenseId; + const list = merged.get(userId) ?? []; + list.push({ permissionSetLicenseId: licenseId, label }); + merged.set(userId, list); + } + } + if (!cancelled) { + setLicensesByUserId(merged); + } + } catch (error) { + logger.warn('Failed to load UserPermissionSetLicense rows for assignments tree', error); + if (!cancelled) { + setLicensesByUserId(new Map()); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [org, permissionSetAssignments]); + + const displayLabelByUserId = useMemo(() => { + const map = new Map(); + for (const [userId, display] of userDisplayById) { + map.set(userId, display.name); + } + return map; + }, [userDisplayById]); + + const treeRows = useMemo(() => { + const sorted = sortUserAssignmentsTreeRowsByUserDisplay(baseTreeRows, displayLabelByUserId); + return sorted.map((row) => { + if (row._leafKind !== 'profile') { + return row; + } + const profileId = userDisplayById.get(row._treeUserGroupKey)?.profileId ?? null; + if (!profileId) { + return row; + } + return { ...row, _profileId: profileId }; + }); + }, [baseTreeRows, displayLabelByUserId, userDisplayById]); + + const containerSeverity = useMemo(() => { + if (findings.length === 0) { + return null; + } + return buildContainerIdFindingSeverity(findings); + }, [findings]); + + const allExpandedGroupIds = useMemo(() => { + const ids = new Set(); + for (const row of treeRows) { + ids.add(row._treeUserGroupKey); + } + return ids; + }, [treeRows]); + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + useLayoutEffect(() => { + setExpandedGroupIds(new Set(allExpandedGroupIds)); + }, [allExpandedGroupIds]); + + const [findingsModal, setFindingsModal] = useState(null); + + const setupLogin = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const openFindingsForPermissionSet = useCallback( + (permissionSetId: string) => { + const id = permissionSetId.trim(); + if (!id || !containerSeverity?.has(id)) { + return; + } + const matches = listFindingsForExportContainer(findings, id); + if (matches.length === 0) { + return; + } + setFindingsModal({ + containerId: id, + columnLabel: 'Permission Set', + matches, + }); + }, + [containerSeverity, findings], + ); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + + const userCol: ColumnWithFilter = { + ...setColumnFromType('_treeUserGroupKey', 'text'), + name: 'User', + key: '_treeUserGroupKey', + field: '_treeUserGroupKey', + resizable: true, + width: TREE_COL_USER, + minWidth: TREE_USER_GROUP_MIN_PX, + maxWidth: TREE_USER_GROUP_MAX_PX, + renderGroupCell: (props) => renderUserGroupCell(userDisplayById, setupLogin, props), + getValue: ({ row }) => { + const userId = row._treeUserGroupKey; + const display = userDisplayById.get(userId); + if (display) { + return `${display.name} ${display.username} ${userId}`.trim(); + } + return userId; + }, + } as ColumnWithFilter; + + const assignmentCol: ColumnWithFilter = { + ...setColumnFromType('Id', 'text'), + name: 'Assignment', + key: 'Id', + field: 'Id', + resizable: true, + width: TREE_COL_ASSIGNMENT, + minWidth: ASSIGNMENT_COL_MIN_PX, + getValue: ({ row }) => { + const userId = row._treeUserGroupKey; + if (row._leafKind === 'permission_set' && row._permissionSetId) { + const label = labelByPermissionSetId.get(row._permissionSetId) ?? row._permissionSetId; + return `${label} ${row._permissionSetId} ${userId}`.trim(); + } + if (row._leafKind === 'permission_set_group' && row._permissionSetGroupId) { + const label = labelByGroupId.get(row._permissionSetGroupId) ?? row._permissionSetGroupId; + return `${label} permission set group ${userId}`.trim(); + } + if (row._leafKind === 'profile') { + const profileName = userDisplayById.get(userId)?.profileName ?? ''; + return `profile ${profileName} ${userId}`.trim(); + } + if (row._leafKind === 'permission_set_license') { + return `${row._licenseLabel ?? ''} license ${userId}`.trim(); + } + return ''; + }, + renderCell: (props: RenderCellProps) => { + const row = props.row; + if (!row) { + return null; + } + + if (userDisplayLoading && row._leafKind === 'profile') { + return ; + } + + if (row._leafKind === 'permission_set' && row._permissionSetId) { + const permissionSetId = row._permissionSetId; + const label = labelByPermissionSetId.get(permissionSetId) ?? permissionSetId; + const permSetRow = permissionSetRowById.get(permissionSetId); + const isProfileOwned = permSetRow?.IsOwnedByProfile === true; + const profileIdForSetup = + permSetRow && typeof permSetRow.ProfileId === 'string' && permSetRow.ProfileId.trim().length > 0 && isProfileOwned + ? permSetRow.ProfileId.trim() + : null; + const recordType = isProfileOwned && profileIdForSetup ? 'Profile' : 'PermissionSet'; + const setupTargetId = recordType === 'Profile' && profileIdForSetup ? profileIdForSetup : permissionSetId; + const returnUrl = getProfileOrPermSetSetupUrl(recordType, setupTargetId); + const severity = containerSeverity?.get(permissionSetId); + const openUserButton = ( + { + event.stopPropagation(); + }} + > + + + ); + + return ( +
+
+

+ Permission set +

+

{label}

+
+
+ {openUserButton} + {severity && ( + + )} +
+
+ ); + } + + if (row._leafKind === 'permission_set_group' && row._permissionSetGroupId) { + const groupId = row._permissionSetGroupId; + const label = labelByGroupId.get(groupId) ?? groupId; + return ( +
+
+

+ Permission set group +

+

{label}

+
+
+ { + event.stopPropagation(); + }} + > + + +
+
+ ); + } + + if (row._leafKind === 'profile') { + const userId = row._treeUserGroupKey; + const display = userDisplayById.get(userId); + const profileName = display?.profileName ?? '—'; + const profileId = row._profileId ?? display?.profileId ?? null; + const returnUrl = profileId ? getProfileOrPermSetSetupUrl('Profile', profileId) : null; + return ( +
+
+

+ Profile +

+

{profileName}

+
+ {returnUrl && ( +
+ { + event.stopPropagation(); + }} + > + + +
+ )} +
+ ); + } + + if (row._leafKind === 'permission_set_license') { + const label = row._licenseLabel ?? row._permissionSetLicenseId ?? ''; + const licenseId = row._permissionSetLicenseId; + const returnUrl = licenseId ? `/${licenseId}` : null; + return ( +
+
+

Permission set license

+

{label}

+
+ {returnUrl && ( +
+ { + event.stopPropagation(); + }} + > + + +
+ )} +
+ ); + } + + return null; + }, + } as ColumnWithFilter; + + return [userCol, assignmentCol]; + }, [ + treeRows, + userDisplayById, + userDisplayLoading, + labelByPermissionSetId, + labelByGroupId, + permissionSetRowById, + containerSeverity, + setupLogin, + openFindingsForPermissionSet, + ]); + + const getRowKey = useCallback((row: UserAssignmentsTreeRow) => row.Id, []); + + if (!permissionSetAssignments.length) { + return ( +
+ No permission set assignments in this export slice. +
+ ); + } + + if (!collectUniqueUserIdsFromAssignments(permissionSetAssignments).length) { + return ( +
+ No user assignments (User Ids) were found in this export. +
+ ); + } + + if (!treeRows.length) { + return ( +
+ No rows were available to build the assignments tree. +
+ ); + } + + return ( + + (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + /> + + {findingsModal && ( + setFindingsModal(null)} + findings={findingsModal.matches} + summaryLine={ + + {findingsModal.columnLabel} + {' · '} + {containerLabelById?.get(findingsModal.containerId) ?? findingsModal.containerId} — {findingsModal.matches.length}{' '} + {findingsModal.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisView.tsx b/libs/features/manage-permissions/src/PermissionAnalysisView.tsx new file mode 100644 index 000000000..4aee2ba6d --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisView.tsx @@ -0,0 +1,1115 @@ +import { css } from '@emotion/react'; +import { logger } from '@jetstream/shared/client-logger'; +import { describeGlobal, queryWithCache } from '@jetstream/shared/data'; +import { escapeSoqlString, formatNumber } from '@jetstream/shared/ui-utils'; +import { getErrorMessage, gzipDecode } from '@jetstream/shared/utils'; +import type { AsyncJob, PermissionExportAnalysisJob, PermissionExportFullResult } from '@jetstream/types'; +import { + AutoFullHeightContainer, + Icon, + ProgressIndicator, + ScopedNotification, + Tabs, + Toast, + Toolbar, + ToolbarItemActions, + ToolbarItemGroup, + Tooltip, +} from '@jetstream/ui'; +import { RequireMetadataApiBanner, jobsState } from '@jetstream/ui-core'; +import { applicationCookieState, selectSkipFrontdoorAuth, selectedOrgState } from '@jetstream/ui/app-state'; +import { dexieDb } from '@jetstream/ui/db'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { useAtomValue } from 'jotai'; +import { Fragment, FunctionComponent, useEffect, useMemo, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { PermissionAnalysisExportGrid } from './PermissionAnalysisExportGrid'; +import { PermissionAnalysisFieldPermissionsTree } from './PermissionAnalysisFieldPermissionsTree'; +import { PermissionAnalysisFindingsFiltersBar } from './PermissionAnalysisFindingsFiltersBar'; +import { PermissionAnalysisHistoryModal } from './PermissionAnalysisHistoryModal'; +import { PermissionAnalysisIssuesTab } from './PermissionAnalysisIssuesTab'; +import { PermissionAnalysisObjectPermissionsTree } from './PermissionAnalysisObjectPermissionsTree'; +import { PermissionAnalysisPermissionSetsTree } from './PermissionAnalysisPermissionSetsTree'; +import { PermissionAnalysisTabVisibilityTree } from './PermissionAnalysisTabVisibilityTree'; +import { PermissionAnalysisUserAssignmentsTree } from './PermissionAnalysisUserAssignmentsTree'; +import { PermissionAnalysisExportMetadataProvider } from './permission-analysis-export-metadata-context'; +import { usePermissionAnalysisIssuesFilters } from './permission-analysis-issues-filters'; +import { + buildPermissionSetIdLabelMap, + collectSobjectApiNamesFromPermissionExport, + collectTabSettingNamesFromPermissionExport, + fieldExportDetailLookupKey, + fieldPermissionQualifiedFieldShortApi, + filterPermissionSetExportRowsById, + parsePermissionExportRequestScope, + parsePermissionExportResult, + type FieldExportDetail, + type PermissionAnalysisFinding, + type PermissionExportRow, + type SobjectExportDetail, +} from './permission-export-result-view'; + +const EMPTY_PERMISSION_ANALYSIS_FINDINGS: PermissionAnalysisFinding[] = []; +const EMPTY_PERMISSION_EXPORT_ASSIGNMENT_ROWS: PermissionExportRow[] = []; + +const HEIGHT_BUFFER = 170; +const ENTITY_DEFINITION_CHUNK_SIZE = 40; +const TAB_DEFINITION_CHUNK_SIZE = 100; +const FIELD_DEFINITION_CHUNK_SIZE = 50; +/** Max concurrent per-object FieldDefinition Tooling queries (avoids unbounded parallel requests). */ +const FIELD_DEFINITION_OBJECT_CONCURRENCY = 5; + +/** True for local Vite dev; false in production builds — used to avoid exposing raw job payloads in prod. */ +const SHOW_RAW_JOB_JSON_UI = import.meta.env.DEV; + +async function mapPool(items: readonly T[], concurrency: number, fn: (item: T) => Promise): Promise { + if (items.length === 0) { + return; + } + const workerCount = Math.max(1, Math.min(concurrency, items.length)); + let nextIndex = 0; + async function worker(): Promise { + while (true) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + await fn(items[index]); + } + } + await Promise.all(Array.from({ length: workerCount }, () => worker())); +} + +interface ToolingEntityDefinitionRow { + QualifiedApiName: string; + Label?: string | null; + Description?: string | null; +} + +interface ToolingTabDefinitionRow { + Name: string; + Label?: string | null; +} + +interface ToolingFieldDefinitionRow { + QualifiedApiName: string; + Label?: string | null; + Description?: string | null; + DurableId?: string | null; +} + +function formatJobResult(result: unknown): string { + try { + return JSON.stringify(result, null, 2); + } catch { + return String(result); + } +} + +/** + * Lazily stringifies the result when the Raw JSON tab actually renders. Stringifying inline inside + * the `resultTabs` useMemo would re-run on every memo dep change even if the tab isn't active — + * for ~20 MB blobs in dev that adds noticeable jank when toggling filters. + */ +const RawJsonTabContent: FunctionComponent<{ result: unknown }> = ({ result }) => { + const formatted = useMemo(() => formatJobResult(result), [result]); + return ( +
+
+        {formatted}
+      
+
+ ); +}; + +/** + * Read-only analysis workspace. Subscribes to the in-flight job entry (jotai jobsState) for progress + * and to Dexie `analysis_job_history` for the terminal row; no HTTP polling. Result decoding happens + * once per Dexie row (gzip decompress) and is reshaped into the envelope the parser expects. + */ +export const PermissionAnalysisView: FunctionComponent = () => { + const selectedOrg = useAtomValue(selectedOrgState); + const { serverUrl, defaultApiVersion } = useAtomValue(applicationCookieState); + const skipFrontdoorLogin = useAtomValue(selectSkipFrontdoorAuth); + const jobs = useAtomValue(jobsState); + const [searchParams, setSearchParams] = useSearchParams(); + const jobId = searchParams.get('job'); + + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [sobjectExportDetails, setSobjectExportDetails] = useState>({}); + const [tabLabelBySettingName, setTabLabelBySettingName] = useState>(() => new Map()); + const [fieldExportDetails, setFieldExportDetails] = useState>({}); + const [decodedFullResult, setDecodedFullResult] = useState(null); + const [decodeError, setDecodeError] = useState(null); + + /** + * Live in-flight AsyncJob for this jobHistoryKey, when present. Drives the progress UI before the + * Dexie terminal row lands; the Jobs popover shows the same entry. + */ + const inFlightJob: AsyncJob | null = useMemo(() => { + if (!jobId) { + return null; + } + for (const candidate of Object.values(jobs)) { + if (candidate.type !== 'PermissionExportAnalysis') { + continue; + } + const meta = candidate.meta as PermissionExportAnalysisJob | undefined; + if (meta?.jobHistoryKey === jobId) { + return candidate as AsyncJob; + } + } + return null; + }, [jobs, jobId]); + + const inFlightStatus = inFlightJob?.status; + const isJobRunning = inFlightStatus === 'pending' || inFlightStatus === 'in-progress'; + + /** + * Terminal Dexie row for this jobHistoryKey, kept reactive via useLiveQuery so the view updates + * the moment the JobWorker writes the row. + */ + const historyRow = useLiveQuery(() => (jobId ? dexieDb.analysis_job_history.get(jobId) : undefined), [jobId]); + + useEffect(() => { + // Eagerly drop any prior decoded payload so switching between large completed runs doesn't keep both + // the old and new uncompressed blobs in memory while the new gunzip resolves. + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale decoded payload when job/row changes + setDecodedFullResult(null); + setDecodeError(null); + if (!historyRow || historyRow.status !== 'completed' || !historyRow.resultBlob) { + return; + } + let cancelled = false; + gzipDecode(historyRow.resultBlob) + .then((decoded) => { + if (!cancelled) { + setDecodedFullResult(decoded); + } + }) + .catch((ex) => { + if (!cancelled) { + logger.error('Failed to decode permission_export history blob', ex); + setDecodeError(getErrorMessage(ex)); + } + }); + return () => { + cancelled = true; + }; + }, [historyRow]); + + const jobStatusNormalized = useMemo(() => { + if (historyRow?.status === 'completed' || historyRow?.status === 'failed') { + return historyRow.status; + } + if (isJobRunning) { + return 'running'; + } + if (inFlightStatus === 'failed' || inFlightStatus === 'aborted') { + return 'failed'; + } + return null; + }, [historyRow?.status, isJobRunning, inFlightStatus]); + + const isTerminal = jobStatusNormalized === 'completed' || jobStatusNormalized === 'failed'; + const fetchError = decodeError; + const terminalErrorMessage = historyRow?.errorMessage ?? inFlightJob?.statusMessage ?? null; + const liveProgress = inFlightJob?.progress; + + /** + * The decoded blob from Dexie has the FLAT merged shape (counts, findings, permissionSets, etc. + * at the top level). The parser expects a nested envelope `{ ...summary, export: { permissionSets, ... } }`. + * Reshape once and feed both the parser and downstream consumers (request-scope lookups) the same root. + */ + const reshapedJobResult = useMemo(() => { + if (!decodedFullResult) { + return null; + } + return { + requestPayload: decodedFullResult.requestPayload, + phase: decodedFullResult.phase, + summary: decodedFullResult.summary, + truncated: decodedFullResult.truncated, + counts: decodedFullResult.counts, + findings: decodedFullResult.findings, + issueCodeSummary: decodedFullResult.issueCodeSummary, + export: { + permissionSets: decodedFullResult.permissionSets, + permissionSetAssignments: decodedFullResult.permissionSetAssignments, + permissionSetGroups: decodedFullResult.permissionSetGroups, + permissionSetGroupComponents: decodedFullResult.permissionSetGroupComponents, + mutingPermissionSets: decodedFullResult.mutingPermissionSets, + objectPermissions: decodedFullResult.objectPermissions, + fieldPermissions: decodedFullResult.fieldPermissions, + permissionSetTabSettings: decodedFullResult.permissionSetTabSettings, + }, + }; + }, [decodedFullResult]); + + const parsedExport = useMemo(() => { + if (!reshapedJobResult) { + return null; + } + return parsePermissionExportResult(reshapedJobResult); + }, [reshapedJobResult]); + + const issueScopeFilterContext = useMemo(() => { + if (!reshapedJobResult) { + return undefined; + } + const scope = parsePermissionExportRequestScope(reshapedJobResult); + const supportsExportScopeFilter = scope.profilePermissionSetIds.length > 0 && scope.permissionSetIds.length > 0; + return { + supportsExportScopeFilter, + profilePermissionSetIds: new Set(scope.profilePermissionSetIds), + permissionSetIds: new Set(scope.permissionSetIds), + }; + }, [reshapedJobResult]); + + const exportObjectScopeNames = useMemo(() => parsePermissionExportRequestScope(reshapedJobResult).objectApiNames, [reshapedJobResult]); + + const permissionAnalysisIssuesFilters = usePermissionAnalysisIssuesFilters({ + findings: parsedExport?.findings ?? EMPTY_PERMISSION_ANALYSIS_FINDINGS, + permissionSetAssignments: parsedExport?.export.permissionSetAssignments ?? EMPTY_PERMISSION_EXPORT_ASSIGNMENT_ROWS, + searchParams, + setSearchParams, + issueScopeFilterContext, + }); + const globallyFilteredFindings = permissionAnalysisIssuesFilters.filteredFindings; + + useEffect(() => { + const exportSnapshot = parsedExport; + + if (!selectedOrg?.uniqueId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset cached labels when org clears + setSobjectExportDetails({}); + return; + } + + if (!exportSnapshot) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset when job payload missing + setSobjectExportDetails({}); + return; + } + + const exportBundleForSobjects = exportSnapshot.export; + + let cancelled = false; + + async function loadSobjectExportDetails() { + /** Primary labels/descriptions from EntityDefinition; describeGlobal only for APIs missing from ED (odd/large orgs). */ + const details: Record = {}; + const returnedFromEntityDefinition = new Set(); + + if (cancelled) { + return; + } + + const apiNames = collectSobjectApiNamesFromPermissionExport(exportBundleForSobjects); + for (let offset = 0; offset < apiNames.length; offset += ENTITY_DEFINITION_CHUNK_SIZE) { + if (cancelled) { + return; + } + const chunk = apiNames.slice(offset, offset + ENTITY_DEFINITION_CHUNK_SIZE); + const inList = chunk.map((name) => `'${escapeSoqlString(name)}'`).join(', '); + const soql = `SELECT QualifiedApiName, Label, Description FROM EntityDefinition WHERE QualifiedApiName IN (${inList})`; + try { + const { data } = await queryWithCache(selectedOrg, soql, true); + const records = data?.queryResults?.records; + if (!Array.isArray(records)) { + continue; + } + for (const record of records) { + const api = record.QualifiedApiName; + if (typeof api !== 'string' || api.trim().length === 0) { + continue; + } + returnedFromEntityDefinition.add(api); + const existing = details[api]; + const descriptionFromEd = + record.Description != null && String(record.Description).trim().length > 0 + ? String(record.Description).trim() + : (existing?.description ?? null); + const labelFromEd = typeof record.Label === 'string' && record.Label.trim().length > 0 ? record.Label.trim() : api; + details[api] = { + apiName: api, + label: labelFromEd, + description: descriptionFromEd, + }; + } + } catch (entityDefinitionError) { + logger.warn('EntityDefinition query failed for permission analysis object metadata', entityDefinitionError); + } + } + + const missingFromEntityDefinition = apiNames.filter((api) => !returnedFromEntityDefinition.has(api)); + if (missingFromEntityDefinition.length > 0 && !cancelled) { + try { + const { data } = await describeGlobal(selectedOrg, false); + const sobjects = data?.sobjects; + if (Array.isArray(sobjects)) { + const byName = new Map(sobjects.map((s) => [s.name, s])); + for (const api of missingFromEntityDefinition) { + const described = byName.get(api); + if (described) { + const label = typeof described.label === 'string' && described.label.trim().length > 0 ? described.label.trim() : api; + details[api] = { + apiName: api, + label, + description: null, + }; + } + } + } + } catch (describeGlobalError) { + logger.warn('describeGlobal fallback failed for permission analysis object metadata', describeGlobalError); + } + } + + for (const api of apiNames) { + if (!details[api]) { + details[api] = { apiName: api, label: api, description: null }; + } + } + + if (!cancelled) { + setSobjectExportDetails(details); + } + } + + void loadSobjectExportDetails(); + + return () => { + cancelled = true; + }; + }, [selectedOrg, parsedExport]); + + useEffect(() => { + if (!selectedOrg?.uniqueId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset cached tab labels when org clears + setTabLabelBySettingName(new Map()); + return; + } + + if (!parsedExport) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset when export snapshot missing + setTabLabelBySettingName(new Map()); + return; + } + + const exportBundleForTabs = parsedExport.export; + let cancelled = false; + + async function loadTabDefinitionLabels() { + const tabNames = collectTabSettingNamesFromPermissionExport(exportBundleForTabs); + const labelMap = new Map(); + + for (let offset = 0; offset < tabNames.length; offset += TAB_DEFINITION_CHUNK_SIZE) { + if (cancelled) { + return; + } + const chunk = tabNames.slice(offset, offset + TAB_DEFINITION_CHUNK_SIZE); + const inList = chunk.map((name) => `'${escapeSoqlString(name)}'`).join(', '); + const soql = `SELECT Name, Label FROM TabDefinition WHERE Name IN (${inList})`; + try { + const { data } = await queryWithCache(selectedOrg, soql, true); + const records = data?.queryResults?.records; + if (!Array.isArray(records)) { + continue; + } + for (const record of records) { + const api = record.Name; + if (typeof api !== 'string' || api.trim().length === 0) { + continue; + } + const trimmedApi = api.trim(); + const labelFromTd = typeof record.Label === 'string' && record.Label.trim().length > 0 ? record.Label.trim() : trimmedApi; + labelMap.set(trimmedApi, labelFromTd); + } + } catch (tabDefinitionError) { + logger.warn('TabDefinition query failed for permission analysis tab labels', tabDefinitionError); + } + } + + if (!cancelled) { + setTabLabelBySettingName(labelMap); + } + } + + void loadTabDefinitionLabels(); + + return () => { + cancelled = true; + }; + }, [selectedOrg, parsedExport]); + + useEffect(() => { + if (!selectedOrg?.uniqueId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset cached field labels when org clears + setFieldExportDetails({}); + return; + } + + if (!parsedExport) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset when export snapshot missing + setFieldExportDetails({}); + return; + } + + const bundle = parsedExport.export; + let cancelled = false; + + async function loadFieldDefinitions() { + const fieldsByObject = new Map>(); + for (const row of bundle.fieldPermissions) { + const obj = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!obj) { + continue; + } + const short = fieldPermissionQualifiedFieldShortApi(row); + if (!short) { + continue; + } + let fieldSet = fieldsByObject.get(obj); + if (!fieldSet) { + fieldSet = new Set(); + fieldsByObject.set(obj, fieldSet); + } + fieldSet.add(short); + } + + const details: Record = {}; + + const objectWorkItems = [...fieldsByObject.entries()].map(([objectApi, fieldSet]) => ({ + objectApi, + names: [...fieldSet].sort((a, b) => a.localeCompare(b)), + })); + + await mapPool(objectWorkItems, FIELD_DEFINITION_OBJECT_CONCURRENCY, async ({ objectApi, names }) => { + for (let offset = 0; offset < names.length; offset += FIELD_DEFINITION_CHUNK_SIZE) { + if (cancelled) { + return; + } + const chunk = names.slice(offset, offset + FIELD_DEFINITION_CHUNK_SIZE); + const inList = chunk.map((name) => `'${escapeSoqlString(name)}'`).join(', '); + const soql = `SELECT QualifiedApiName, Label, Description, DurableId FROM FieldDefinition WHERE EntityDefinition.QualifiedApiName = '${escapeSoqlString(objectApi)}' AND QualifiedApiName IN (${inList})`; + try { + const { data } = await queryWithCache(selectedOrg, soql, true); + const records = data?.queryResults?.records; + if (!Array.isArray(records)) { + continue; + } + for (const record of records) { + const qn = record.QualifiedApiName; + if (typeof qn !== 'string' || qn.trim().length === 0) { + continue; + } + const qualified = qn.trim(); + const key = fieldExportDetailLookupKey(objectApi, qualified); + const labelFromFd = typeof record.Label === 'string' && record.Label.trim().length > 0 ? record.Label.trim() : qualified; + const desc = + record.Description != null && String(record.Description).trim().length > 0 ? String(record.Description).trim() : null; + const durable = typeof record.DurableId === 'string' && record.DurableId.trim().length > 0 ? record.DurableId.trim() : null; + details[key] = { + objectApiName: objectApi, + qualifiedApiName: qualified, + label: labelFromFd, + description: desc, + durableId: durable, + }; + } + } catch (fieldDefinitionError) { + logger.warn('FieldDefinition query failed for permission analysis field metadata', fieldDefinitionError); + } + } + }); + + if (!cancelled) { + setFieldExportDetails(details); + } + } + + void loadFieldDefinitions(); + + return () => { + cancelled = true; + }; + }, [selectedOrg, parsedExport]); + + const findingsCount = parsedExport?.findings.length ?? 0; + + const permissionAnalysisExportMetadata = useMemo( + () => ({ fieldExportDetails, sobjectExportDetails, tabLabelBySettingName }), + [fieldExportDetails, sobjectExportDetails, tabLabelBySettingName], + ); + + const resultTabs = useMemo(() => { + if (!selectedOrg || !parsedExport) { + return null; + } + + const { export: exportBundle, truncated, findings: allFindings } = parsedExport; + const counts = parsedExport.counts; + + const containerLabelById = buildPermissionSetIdLabelMap(exportBundle.permissionSets); + const exportFindingProps = { findings: globallyFilteredFindings, containerLabelById }; + + const gridProps = { + org: selectedOrg, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, + }; + + const requestScope = parsePermissionExportRequestScope(reshapedJobResult); + const hasExplicitScope = requestScope.profilePermissionSetIds.length > 0 || requestScope.permissionSetIds.length > 0; + const profileIdSet = new Set(requestScope.profilePermissionSetIds); + const permissionSetIdSet = new Set(requestScope.permissionSetIds); + const profilePermissionSetRows = filterPermissionSetExportRowsById(exportBundle.permissionSets, profileIdSet); + let standalonePermissionSetRows = filterPermissionSetExportRowsById(exportBundle.permissionSets, permissionSetIdSet); + let showProfilesTab = requestScope.profilePermissionSetIds.length > 0; + let showPermissionSetsTab = requestScope.permissionSetIds.length > 0; + + if (!hasExplicitScope && exportBundle.permissionSets.length > 0) { + showProfilesTab = false; + showPermissionSetsTab = true; + standalonePermissionSetRows = exportBundle.permissionSets; + } + + const showPermissionSetGroupsTab = + exportBundle.permissionSetGroups.length > 0 || + exportBundle.permissionSetGroupComponents.length > 0 || + exportBundle.mutingPermissionSets.length > 0; + + function renderTruncationNotice() { + if (!truncated) { + return null; + } + return ( + + Export hit the row limit; some Salesforce rows may be missing from these tables. + + ); + } + + const profilesTab = { + id: 'profiles', + title: ( + + + + + Profiles ({profilePermissionSetRows.length}) + + ), + titleText: 'Profiles', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }; + + const permissionSetsTab = { + id: 'permission-sets', + title: ( + + + + + Permission Sets ({standalonePermissionSetRows.length}) + + ), + titleText: 'Permission Sets', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }; + + return [ + ...(showProfilesTab ? [profilesTab] : []), + ...(showPermissionSetsTab ? [permissionSetsTab] : []), + { + id: 'assignments', + title: ( + + + + + Assignments{counts.permissionSetAssignments != null ? ` (${counts.permissionSetAssignments})` : ''} + + ), + titleText: 'Assignments', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }, + ...(showPermissionSetGroupsTab + ? [ + { + id: 'permission-set-groups', + title: ( + + + + + Permission Set Groups + {counts.permissionSetGroups != null ? ` (${counts.permissionSetGroups})` : ''} + + ), + titleText: 'Permission Set Groups', + content: ( +
+ {renderTruncationNotice()} +
+

Groups

+ +
+
+

Group components

+ +
+
+

Muting permission sets

+ +
+
+ ), + }, + ] + : []), + { + id: 'object-permissions', + title: ( + + + + + Object Permissions{counts.objectPermissions != null ? ` (${counts.objectPermissions})` : ''} + + ), + titleText: 'Object Permissions', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }, + { + id: 'tab-visibility', + title: ( + + + + + Tab Visibility{counts.permissionSetTabSettings != null ? ` (${counts.permissionSetTabSettings})` : ''} + + ), + titleText: 'Tab Visibility', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }, + { + id: 'field-permissions', + title: ( + + + + + Field Permissions{counts.fieldPermissions != null ? ` (${counts.fieldPermissions})` : ''} + + ), + titleText: 'Field Permissions', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }, + { + id: 'issues', + title: ( + + + + + Issues{findingsCount > 0 ? ` (${findingsCount})` : ''} + + ), + titleText: 'Issues', + content: ( + + ), + }, + ...(SHOW_RAW_JOB_JSON_UI + ? [ + { + id: 'raw-json', + title: ( + + + + + Raw JSON + + ), + titleText: 'Raw JSON', + content: , + }, + ] + : []), + ]; + }, [ + selectedOrg, + parsedExport, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + reshapedJobResult, + findingsCount, + sobjectExportDetails, + permissionAnalysisIssuesFilters, + globallyFilteredFindings, + ]); + + return ( +
+ + +
+
+ + + + Go Back + + +
+
+ {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && parsedExport && selectedOrg ? ( +
+ +
+ ) : null} +
+
+ + + + + +
+
+
+ {isHistoryOpen && selectedOrg && ( + setIsHistoryOpen(false)} + onSelectJob={(nextJobId) => { + setSearchParams({ job: nextJobId }, { replace: true }); + }} + /> + )} + + {!jobId && ( +
+ + No analysis job is linked to this page. Use Continue on the selection screen to start a permission export job. + +
+ )} + {jobId && fetchError && {fetchError}} + {jobId && !fetchError && jobStatusNormalized === 'failed' && terminalErrorMessage != null && ( +
+ {terminalErrorMessage} +
+ )} + {jobId && !fetchError && !isTerminal && ( +
+

Permission analysis in progress…

+

+ {isJobRunning && liveProgress?.label ? liveProgress.label : 'Preparing'} + {isJobRunning && liveProgress && liveProgress.total > 0 + ? ` — step ${formatNumber(liveProgress.current)} of ${formatNumber(liveProgress.total)}` + : ''} +

+ +

+ You can leave this page, but keep this tab open — the job will keep running and you'll find it in the Background Jobs. +

+
+ )} + {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && parsedExport && selectedOrg && resultTabs && ( + + + {exportObjectScopeNames.length > 0 && ( +
+ + Object scope for object and field permissions ({exportObjectScopeNames.length} type + {exportObjectScopeNames.length === 1 ? '' : 's'} + ): + {exportObjectScopeNames.length <= 8 ? ( + <> {exportObjectScopeNames.join(', ')}. + ) : ( + <> + {' '} + {exportObjectScopeNames.slice(0, 8).join(', ')}… and {exportObjectScopeNames.length - 8} more. + + )}{' '} + Tab visibility and permission set lists are not filtered by object. + +
+ )} + {parsedExport.summary && ( +
+

{parsedExport.summary}

+
+ )} + tab.id).join('|')} + initialActiveId={resultTabs[0]?.id ?? 'assignments'} + tabs={resultTabs} + /> +
+
+ )} + {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && !parsedExport && reshapedJobResult && ( +
+ {SHOW_RAW_JOB_JSON_UI ? ( + + + This job result does not include a structured permission export payload. Showing raw JSON only. + + + {formatJobResult(reshapedJobResult)} + + ), + }, + ]} + /> + + ) : ( + + This job result does not include a structured permission export payload. The result cannot be shown in the analysis UI. + + )} +
+ )} +
+
+ ); +}; + +export default PermissionAnalysisView; diff --git a/libs/features/manage-permissions/src/__tests__/aggregate-permission-findings.spec.ts b/libs/features/manage-permissions/src/__tests__/aggregate-permission-findings.spec.ts new file mode 100644 index 000000000..fbd16650e --- /dev/null +++ b/libs/features/manage-permissions/src/__tests__/aggregate-permission-findings.spec.ts @@ -0,0 +1,23 @@ +import { PermissionExportFindingCode } from '@jetstream/shared/constants'; +import { describe, expect, it } from 'vitest'; +import { aggregatePermissionAnalysisFindings, type PermissionAnalysisFinding } from '../permission-export-result-view'; + +describe('aggregatePermissionAnalysisFindings', () => { + it('returns empty rollups for an empty list', () => { + expect(aggregatePermissionAnalysisFindings([])).toEqual({ byCode: [], byObject: [] }); + }); + + it('groups by code and object and excludes FINDINGS_TRUNCATED', () => { + const rows: PermissionAnalysisFinding[] = [ + { severity: 'error', code: 'FLS_READ_NO_OBJECT_READ', objectApiName: 'Account', message: 'a' }, + { severity: 'error', code: 'FLS_READ_NO_OBJECT_READ', objectApiName: 'Contact', message: 'b' }, + { severity: 'warning', code: 'OLS_READ_NO_FLS_ROWS', objectApiName: 'Account', message: 'c' }, + { severity: 'warning', code: PermissionExportFindingCode.FINDINGS_TRUNCATED, message: 'cap' }, + ]; + const agg = aggregatePermissionAnalysisFindings(rows); + expect(agg.byCode.map((r) => r.code)).toEqual(['FLS_READ_NO_OBJECT_READ', 'OLS_READ_NO_FLS_ROWS']); + expect(agg.byCode[0].count).toBe(2); + expect(agg.byCode[0].errorCount).toBe(2); + expect(agg.byObject.find((o) => o.objectApiName === 'Account')?.count).toBe(2); + }); +}); diff --git a/libs/features/manage-permissions/src/__tests__/export-grid-finding-cells.spec.ts b/libs/features/manage-permissions/src/__tests__/export-grid-finding-cells.spec.ts new file mode 100644 index 000000000..58da97a60 --- /dev/null +++ b/libs/features/manage-permissions/src/__tests__/export-grid-finding-cells.spec.ts @@ -0,0 +1,279 @@ +import { PermissionExportFindingCode } from '@jetstream/shared/constants'; +import { describe, expect, it } from 'vitest'; +import { + FIELD_PERMISSION_OBJECT_SCOPE_MARKER, + buildContainerIdFindingSeverity, + buildFieldPermissionFindingCellHighlights, + buildPermissionSetAssigneeIdsByPermissionSetId, + buildPermissionSetAssignmentsTreeRows, + buildPermissionSetGroupLabelMap, + buildPermissionSetIdToGroupIdsMap, + buildUserAssignmentsTreeRows, + fieldPermissionCellSeverity, + fieldPermissionFindingRowKey, + listFindingsForExportContainer, + listFindingsForFieldPermissionCell, + sortUserAssignmentsTreeRowsByUserDisplay, +} from '../permission-export-result-view'; + +describe('buildFieldPermissionFindingCellHighlights', () => { + it('maps FLS read mismatch to PermissionsRead for the field row key', () => { + const parentId = '0PS1'; + const findings = [ + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'Account', + fieldApiName: 'Account.Name', + parentId, + permissionSetId: parentId, + }, + ]; + const map = buildFieldPermissionFindingCellHighlights(findings); + const rowKey = fieldPermissionFindingRowKey(parentId, 'Account', 'Account.Name'); + expect(map.get(rowKey)?.get('PermissionsRead')).toBe('error'); + }); + + it('maps FLS_WITHOUT_OLS_ROW to object scope marker and Read/Edit columns', () => { + const parentId = '0PS2'; + const findings = [ + { + code: PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW, + severity: 'error', + objectApiName: 'Contact', + parentId, + }, + ]; + const map = buildFieldPermissionFindingCellHighlights(findings); + const scopeKey = fieldPermissionFindingRowKey(parentId, 'Contact', FIELD_PERMISSION_OBJECT_SCOPE_MARKER); + expect(map.get(scopeKey)?.get('PermissionsRead')).toBe('error'); + expect(map.get(scopeKey)?.get('PermissionsEdit')).toBe('error'); + }); + + it('resolves fieldPermissionCellSeverity from scope marker for any field row on Read', () => { + const parentId = '0PS3'; + const highlights = buildFieldPermissionFindingCellHighlights([ + { + code: PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW, + severity: 'error', + objectApiName: 'Case', + parentId, + }, + ]); + const severity = fieldPermissionCellSeverity(highlights, parentId, 'Case', 'Case.Subject', 'PermissionsRead'); + expect(severity).toBe('error'); + }); +}); + +describe('listFindingsForFieldPermissionCell', () => { + it('returns FLS_WITHOUT_OLS for any field on the object', () => { + const parentId = '0PS9'; + const findings = [ + { + code: PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW, + severity: 'error', + objectApiName: 'Lead', + parentId, + }, + ]; + const matches = listFindingsForFieldPermissionCell(findings, parentId, 'Lead', 'Lead.Company', 'PermissionsRead'); + expect(matches).toHaveLength(1); + }); + + it('requires field match for FLS read code', () => { + const parentId = '0PS8'; + const findings = [ + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'Account', + fieldApiName: 'Account.Name', + parentId, + }, + ]; + expect(listFindingsForFieldPermissionCell(findings, parentId, 'Account', 'Account.Name', 'PermissionsRead')).toHaveLength(1); + expect(listFindingsForFieldPermissionCell(findings, parentId, 'Account', 'Account.Other__c', 'PermissionsRead')).toHaveLength(0); + }); +}); + +describe('buildContainerIdFindingSeverity and listFindingsForExportContainer', () => { + it('aggregates max severity per container id', () => { + const id = '0PS55'; + const findings = [ + { code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, severity: 'warning', objectApiName: 'A', parentId: id }, + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'B', + fieldApiName: 'B.x', + parentId: id, + }, + ]; + const sev = buildContainerIdFindingSeverity(findings); + expect(sev.get(id)).toBe('error'); + const listed = listFindingsForExportContainer(findings, id); + expect(listed).toHaveLength(2); + }); + + it('uses row severity when issue code is not in the catalog (forward-compatible jobs)', () => { + const id = '0PS77'; + const findings = [ + { + code: 'FUTURE_ISSUE_CODE_V1', + severity: 'error', + objectApiName: 'Account', + parentId: id, + permissionSetId: id, + }, + ]; + const sev = buildContainerIdFindingSeverity(findings); + expect(sev.get(id)).toBe('error'); + }); + + it('excludes FINDINGS_TRUNCATED from container drill-in', () => { + const id = '0PS66'; + const findings = [ + { code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, severity: 'error', objectApiName: 'X', parentId: id }, + { code: PermissionExportFindingCode.FINDINGS_TRUNCATED, severity: 'warning', message: 'cap' }, + ]; + expect(listFindingsForExportContainer(findings, id)).toHaveLength(1); + }); +}); + +describe('buildPermissionSetAssigneeIdsByPermissionSetId', () => { + it('groups only user assignees (005 prefix), dedupes, and sorts', () => { + const psId = '0PS000000000001'; + const map = buildPermissionSetAssigneeIdsByPermissionSetId([ + { PermissionSetId: psId, AssigneeId: '005000000000002' }, + { PermissionSetId: psId, AssigneeId: '005000000000001' }, + { PermissionSetId: psId, AssigneeId: '005000000000002' }, + { PermissionSetId: psId, AssigneeId: '00G000000000003' }, + ]); + expect(map.get(psId)).toEqual(['005000000000001', '005000000000002']); + }); +}); + +describe('buildPermissionSetAssignmentsTreeRows', () => { + it('emits one leaf per user and a placeholder when there are no user assignees', () => { + const psA = '0PS0000000000AA'; + const psB = '0PS0000000000BB'; + const permSets = [ + { Id: psA, Label: 'Alpha', Name: 'Alpha' }, + { Id: psB, Label: 'Beta', Name: 'Beta' }, + ]; + const assignments = [ + { PermissionSetId: psA, AssigneeId: '005000000000001' }, + { PermissionSetId: psA, AssigneeId: '005000000000002' }, + ]; + const leaves = buildPermissionSetAssignmentsTreeRows(permSets, assignments); + const byGroup = new Map(); + for (const row of leaves) { + const groupKey = String(row._treePermissionSetGroupKey); + const list = byGroup.get(groupKey) ?? []; + if (row._noDirectUserAssignments) { + list.push('placeholder'); + } else if (typeof row.AssigneeId === 'string') { + list.push(row.AssigneeId); + } + byGroup.set(groupKey, list); + } + expect(byGroup.get(psA)).toEqual(['005000000000001', '005000000000002']); + expect(byGroup.get(psB)).toEqual(['placeholder']); + }); + + it('orders permission set groups alphabetically by display label', () => { + const psA = '0PS0000000000AA'; + const psB = '0PS0000000000BB'; + const permSets = [ + { Id: psB, Label: 'Zebra', Name: 'Zebra' }, + { Id: psA, Label: 'Alpha', Name: 'Alpha' }, + ]; + const leaves = buildPermissionSetAssignmentsTreeRows(permSets, []); + const groupOrder = [...new Set(leaves.map((row) => row._treePermissionSetGroupKey))]; + expect(groupOrder).toEqual([psA, psB]); + }); +}); + +describe('buildUserAssignmentsTreeRows', () => { + it('groups by user: profile first, permission sets (alpha), inferred groups, then licenses', () => { + const u1 = '005000000000001'; + const u2 = '005000000000002'; + const ps1 = '0PS000000000001'; + const ps2 = '0PS000000000002'; + const g1 = '0PG000000000001'; + const assignments = [ + { PermissionSetId: ps1, AssigneeId: u1 }, + { PermissionSetId: ps2, AssigneeId: u1 }, + { PermissionSetId: ps1, AssigneeId: u2 }, + ]; + const permissionSets = [ + { Id: ps1, Label: 'B Perm', Name: 'B_Perm' }, + { Id: ps2, Label: 'A Perm', Name: 'A_Perm' }, + ]; + const groupComponents = [ + { PermissionSetId: ps1, PermissionSetGroupId: g1 }, + { PermissionSetId: ps2, PermissionSetGroupId: g1 }, + ]; + const groups = [{ Id: g1, MasterLabel: 'My Group', DeveloperName: 'My_Group' }]; + const licensesByUserId = new Map([ + [u1, [{ permissionSetLicenseId: '0PL000000000001', label: 'License B' }]], + [u2, [{ permissionSetLicenseId: '0PL000000000002', label: 'License A' }]], + ]); + + const rows = buildUserAssignmentsTreeRows({ + assignments, + permissionSets, + groupComponents: groupComponents, + groups, + licensesByUserId, + }); + + const kindsForUser = (userId: string) => rows.filter((row) => row._treeUserGroupKey === userId).map((row) => row._leafKind); + + expect(kindsForUser(u1)).toEqual(['profile', 'permission_set', 'permission_set', 'permission_set_group', 'permission_set_license']); + expect(kindsForUser(u2)).toEqual(['profile', 'permission_set', 'permission_set_group', 'permission_set_license']); + + const psLeavesU1 = rows.filter((row) => row._treeUserGroupKey === u1 && row._leafKind === 'permission_set'); + expect(psLeavesU1.map((row) => row._permissionSetId)).toEqual([ps2, ps1]); + + const groupLeaf = rows.find((row) => row._leafKind === 'permission_set_group' && row._treeUserGroupKey === u1); + expect(groupLeaf?._permissionSetGroupId).toBe(g1); + + const licenseLeaf = rows.find((row) => row._leafKind === 'permission_set_license' && row._treeUserGroupKey === u1); + expect(licenseLeaf?._licenseLabel).toBe('License B'); + }); +}); + +describe('buildPermissionSetIdToGroupIdsMap', () => { + it('maps permission set Ids to group Ids', () => { + const map = buildPermissionSetIdToGroupIdsMap([ + { PermissionSetId: '0PS1', PermissionSetGroupId: '0PG1' }, + { PermissionSetId: '0PS1', PermissionSetGroupId: '0PG2' }, + ]); + expect([...(map.get('0PS1') ?? [])].sort()).toEqual(['0PG1', '0PG2']); + }); +}); + +describe('buildPermissionSetGroupLabelMap', () => { + it('prefers MasterLabel over DeveloperName', () => { + const map = buildPermissionSetGroupLabelMap([{ Id: '0PG1', MasterLabel: 'Nice', DeveloperName: 'X' }]); + expect(map.get('0PG1')).toBe('Nice'); + }); +}); + +describe('sortUserAssignmentsTreeRowsByUserDisplay', () => { + it('orders user blocks by display label', () => { + const rows = [ + { Id: '1', _treeUserGroupKey: '005B', _leafKind: 'profile' as const }, + { Id: '2', _treeUserGroupKey: '005A', _leafKind: 'profile' as const }, + ]; + const sorted = sortUserAssignmentsTreeRowsByUserDisplay( + rows, + new Map([ + ['005A', 'Zebra'], + ['005B', 'Alpha'], + ]), + ); + expect(sorted.map((row) => row._treeUserGroupKey)).toEqual(['005B', '005A']); + }); +}); diff --git a/libs/features/manage-permissions/src/__tests__/object-permission-finding-cells.spec.ts b/libs/features/manage-permissions/src/__tests__/object-permission-finding-cells.spec.ts new file mode 100644 index 000000000..bc34c4236 --- /dev/null +++ b/libs/features/manage-permissions/src/__tests__/object-permission-finding-cells.spec.ts @@ -0,0 +1,285 @@ +import { PermissionExportFindingCode } from '@jetstream/shared/constants'; +import { describe, expect, it } from 'vitest'; +import { + buildObjectPermissionFindingCellHighlights, + formatTabSettingVisibilityDisplay, + getFindingCodeDisplayParts, + getObjectPermissionHighlightColumnKeysForFindingCode, + listFindingsForObjectPermissionCell, + objectPermissionFindingRowKey, + sortFieldPermissionExportRowsForAnalysisTree, + sortObjectPermissionExportRowsForAnalysisTree, + sortTabSettingExportRowsForAnalysisTree, + type PermissionAnalysisFinding, +} from '../permission-export-result-view'; + +describe('buildObjectPermissionFindingCellHighlights', () => { + it('returns an empty map when there are no findings', () => { + expect(buildObjectPermissionFindingCellHighlights([]).size).toBe(0); + }); + + it('maps OLS read warning to PermissionsRead on the matching row key', () => { + const parentId = '0PSxx0000001'; + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, + severity: 'warning', + objectApiName: 'Account', + parentId, + }, + ]; + const map = buildObjectPermissionFindingCellHighlights(findings); + const rowKey = objectPermissionFindingRowKey(parentId, 'Account'); + expect(map.get(rowKey)?.get('PermissionsRead')).toBe('warning'); + }); + + it('maps FLS read without object read to read-path columns as error', () => { + const parentId = '0PSyy0000002'; + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'Contact', + permissionSetId: parentId, + }, + ]; + const map = buildObjectPermissionFindingCellHighlights(findings); + const rowKey = objectPermissionFindingRowKey(parentId, 'Contact'); + expect(map.get(rowKey)?.get('PermissionsRead')).toBe('error'); + expect(map.get(rowKey)?.get('PermissionsViewAllRecords')).toBe('error'); + expect(map.get(rowKey)?.get('PermissionsModifyAllRecords')).toBe('error'); + }); + + it('prefers error over warning when the same cell is targeted', () => { + const parentId = '0PSzz0000003'; + const rowKey = objectPermissionFindingRowKey(parentId, 'Case'); + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, + severity: 'warning', + objectApiName: 'Case', + parentId, + }, + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'Case', + parentId, + }, + ]; + const map = buildObjectPermissionFindingCellHighlights(findings); + expect(map.get(rowKey)?.get('PermissionsRead')).toBe('error'); + }); + + it('ignores FINDINGS_TRUNCATED', () => { + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.FINDINGS_TRUNCATED, + severity: 'warning', + message: 'cap', + }, + ]; + expect(buildObjectPermissionFindingCellHighlights(findings).size).toBe(0); + }); +}); + +describe('listFindingsForObjectPermissionCell', () => { + it('returns findings that highlight the requested column on the row', () => { + const parentId = '0PS000000001'; + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'Account', + parentId, + message: 'Field X read without object read', + fieldApiName: 'Account.MyField__c', + }, + { + code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, + severity: 'warning', + objectApiName: 'Account', + parentId, + message: 'OLS only', + }, + ]; + const forRead = listFindingsForObjectPermissionCell(findings, parentId, 'Account', 'PermissionsRead'); + expect(forRead).toHaveLength(2); + const forViewAll = listFindingsForObjectPermissionCell(findings, parentId, 'Account', 'PermissionsViewAllRecords'); + expect(forViewAll).toHaveLength(1); + expect(forViewAll[0]?.code).toBe(PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ); + }); + + it('returns empty when parent or object does not match', () => { + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, + severity: 'warning', + objectApiName: 'Contact', + parentId: '0PS1', + }, + ]; + expect(listFindingsForObjectPermissionCell(findings, '0PS1', 'Account', 'PermissionsRead')).toEqual([]); + }); +}); + +describe('getFindingCodeDisplayParts', () => { + it('uses catalog label as title and keeps raw code for technical suffix', () => { + const parts = getFindingCodeDisplayParts(PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS); + expect(parts.technicalCode).toBe(PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS); + expect(parts.title).toContain('field permission'); + expect(parts.title).not.toMatch(/^OLS_/); + }); + + it('returns raw code as title when unknown', () => { + const parts = getFindingCodeDisplayParts('UNKNOWN_FUTURE_CODE'); + expect(parts.title).toBe('UNKNOWN_FUTURE_CODE'); + expect(parts.technicalCode).toBeNull(); + }); +}); + +describe('getObjectPermissionHighlightColumnKeysForFindingCode', () => { + it('returns read-path columns for FLS_READ_NO_OBJECT_READ', () => { + expect(getObjectPermissionHighlightColumnKeysForFindingCode(PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ)).toEqual([ + 'PermissionsRead', + 'PermissionsViewAllRecords', + 'PermissionsModifyAllRecords', + ]); + }); + + it('returns empty for codes that do not map to object-permission cells', () => { + expect(getObjectPermissionHighlightColumnKeysForFindingCode(PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW)).toEqual([]); + expect(getObjectPermissionHighlightColumnKeysForFindingCode('UNKNOWN')).toEqual([]); + }); +}); + +describe('sortObjectPermissionExportRowsForAnalysisTree', () => { + const psProfile = '0PSPROFILE000001'; + const psStandalone = '0PSSTAND00000001'; + + it('orders profile-owned parents before standalone permission sets, then by parent label', () => { + const permissionSetRows = [ + { + Id: psStandalone, + Label: 'Zebra Perm', + Name: 'Zebra', + IsOwnedByProfile: false, + }, + { + Id: psProfile, + Label: 'X00ignored', + Name: 'X', + IsOwnedByProfile: true, + Profile: { Name: 'Alpha Profile' }, + }, + ]; + const objectPermissionRows = [ + { ParentId: psStandalone, SobjectType: 'Account', Id: 'op1' }, + { ParentId: psProfile, SobjectType: 'Contact', Id: 'op2' }, + ]; + const sorted = sortObjectPermissionExportRowsForAnalysisTree(objectPermissionRows, permissionSetRows); + expect(sorted.map((row) => row.ParentId)).toEqual([psProfile, psStandalone]); + }); + + it('orders standalone permission sets alphabetically when neither is profile-owned', () => { + const psB = '0PSBBBB000000001'; + const psA = '0PSAAAA000000001'; + const permissionSetRows = [ + { Id: psB, Label: 'B Perm', Name: 'B', IsOwnedByProfile: false }, + { Id: psA, Label: 'A Perm', Name: 'A', IsOwnedByProfile: false }, + ]; + const objectPermissionRows = [ + { ParentId: psB, SobjectType: 'Account', Id: '1' }, + { ParentId: psA, SobjectType: 'Account', Id: '2' }, + ]; + const sorted = sortObjectPermissionExportRowsForAnalysisTree(objectPermissionRows, permissionSetRows); + expect(sorted.map((row) => row.ParentId)).toEqual([psA, psB]); + }); + + it('orders objects by metadata label within the same parent', () => { + const permissionSetRows = [{ Id: psStandalone, Label: 'P', Name: 'P', IsOwnedByProfile: false }]; + const sobjectExportDetails = { + Zebra__c: { apiName: 'Zebra__c', label: 'Z', description: null }, + Account: { apiName: 'Account', label: 'Account', description: null }, + }; + const objectPermissionRows = [ + { ParentId: psStandalone, SobjectType: 'Zebra__c', Id: '1' }, + { ParentId: psStandalone, SobjectType: 'Account', Id: '2' }, + ]; + const sorted = sortObjectPermissionExportRowsForAnalysisTree(objectPermissionRows, permissionSetRows, sobjectExportDetails); + expect(sorted.map((row) => row.SobjectType)).toEqual(['Account', 'Zebra__c']); + }); +}); + +describe('formatTabSettingVisibilityDisplay', () => { + it('maps DefaultOn to Visible and DefaultOff to Hidden', () => { + expect(formatTabSettingVisibilityDisplay('DefaultOn')).toBe('Visible'); + expect(formatTabSettingVisibilityDisplay('DefaultOff')).toBe('Hidden'); + }); + + it('returns em dash for empty values and raw string for other picklist values', () => { + expect(formatTabSettingVisibilityDisplay('')).toBe('—'); + expect(formatTabSettingVisibilityDisplay(null)).toBe('—'); + expect(formatTabSettingVisibilityDisplay('Available')).toBe('Available'); + }); +}); + +describe('sortFieldPermissionExportRowsForAnalysisTree', () => { + const psStandalone = '0PSSTAND00000001'; + + it('orders objects then fields within the same permission set parent', () => { + const permissionSetRows = [{ Id: psStandalone, Label: 'P', Name: 'P', IsOwnedByProfile: false }]; + const fieldRows = [ + { ParentId: psStandalone, SobjectType: 'Zebra__c', Field: 'Zebra__c.B__c', PermissionsRead: true, Id: '1' }, + { ParentId: psStandalone, SobjectType: 'Alpha__c', Field: 'Alpha__c.A__c', PermissionsRead: true, Id: '2' }, + { ParentId: psStandalone, SobjectType: 'Alpha__c', Field: 'Alpha__c.Z__c', PermissionsRead: true, Id: '3' }, + ]; + const sorted = sortFieldPermissionExportRowsForAnalysisTree(fieldRows, permissionSetRows); + expect(sorted.map((row) => row.Field)).toEqual(['Alpha__c.A__c', 'Alpha__c.Z__c', 'Zebra__c.B__c']); + }); +}); + +describe('sortTabSettingExportRowsForAnalysisTree', () => { + const psProfile = '0PSPROFILE000001'; + const psStandalone = '0PSSTAND00000001'; + + it('orders profile-owned parents before standalone permission sets', () => { + const permissionSetRows = [ + { Id: psStandalone, Label: 'Zebra', Name: 'Zebra', IsOwnedByProfile: false }, + { Id: psProfile, Label: 'X', Name: 'X', IsOwnedByProfile: true, Profile: { Name: 'Admin' } }, + ]; + const tabRows = [ + { ParentId: psStandalone, Name: 'TabA', Visibility: 'DefaultOn', Id: 't1' }, + { ParentId: psProfile, Name: 'TabB', Visibility: 'DefaultOff', Id: 't2' }, + ]; + const sorted = sortTabSettingExportRowsForAnalysisTree(tabRows, permissionSetRows); + expect(sorted.map((row) => row.ParentId)).toEqual([psProfile, psStandalone]); + }); + + it('orders tabs by Name within the same parent', () => { + const permissionSetRows = [{ Id: psStandalone, Label: 'P', Name: 'P', IsOwnedByProfile: false }]; + const tabRows = [ + { ParentId: psStandalone, Name: 'Zebra', Visibility: 'DefaultOn', Id: '1' }, + { ParentId: psStandalone, Name: 'Alpha', Visibility: 'DefaultOff', Id: '2' }, + ]; + const sorted = sortTabSettingExportRowsForAnalysisTree(tabRows, permissionSetRows); + expect(sorted.map((row) => row.Name)).toEqual(['Alpha', 'Zebra']); + }); + + it('orders tabs by TabDefinition label when a label map is provided', () => { + const permissionSetRows = [{ Id: psStandalone, Label: 'P', Name: 'P', IsOwnedByProfile: false }]; + const tabRows = [ + { ParentId: psStandalone, Name: 'standard-ZebraTab', Visibility: 'DefaultOn', Id: '1' }, + { ParentId: psStandalone, Name: 'standard-AlphaTab', Visibility: 'DefaultOn', Id: '2' }, + ]; + const sortedByApi = sortTabSettingExportRowsForAnalysisTree(tabRows, permissionSetRows); + expect(sortedByApi.map((row) => row.Name)).toEqual(['standard-AlphaTab', 'standard-ZebraTab']); + + const labelMap = new Map([ + ['standard-ZebraTab', 'Zebra label'], + ['standard-AlphaTab', 'Alpha label'], + ]); + const sortedByLabel = sortTabSettingExportRowsForAnalysisTree(tabRows, permissionSetRows, labelMap); + expect(sortedByLabel.map((row) => row.Name)).toEqual(['standard-AlphaTab', 'standard-ZebraTab']); + }); +}); diff --git a/libs/features/manage-permissions/src/analysis-job-status-display.ts b/libs/features/manage-permissions/src/analysis-job-status-display.ts new file mode 100644 index 000000000..e335f608d --- /dev/null +++ b/libs/features/manage-permissions/src/analysis-job-status-display.ts @@ -0,0 +1,25 @@ +/** + * Analysis job `status` from the API is stored lowercase; format for UI labels. + */ +export function formatAnalysisJobStatusForDisplay(status: string | null | undefined): string { + if (status == null || !String(status).trim()) { + return '—'; + } + const normalized = String(status).trim().toLowerCase(); + switch (normalized) { + case 'completed': + return 'Completed'; + case 'failed': + return 'Failed'; + case 'running': + return 'Running'; + case 'pending': + return 'Pending'; + default: + return String(status) + .trim() + .split(/[\s_-]+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + } +} diff --git a/libs/features/manage-permissions/src/index.ts b/libs/features/manage-permissions/src/index.ts index 0687cda97..5da3cf399 100644 --- a/libs/features/manage-permissions/src/index.ts +++ b/libs/features/manage-permissions/src/index.ts @@ -1,3 +1,10 @@ export * from './ManagePermissions'; export * from './ManagePermissionsEditor'; export * from './ManagePermissionsSelection'; +export * from './PermissionAnalysis'; +export * from './PermissionAnalysisSelection'; +export * from './PermissionAnalysisView'; +export * from './PermissionAnalysisHistoryModal'; +export { formatAnalysisJobStatusForDisplay } from './analysis-job-status-display'; +export { filterPermissionsSobjects } from './utils/permission-manager-utils'; +export * from './permission-export'; diff --git a/libs/features/manage-permissions/src/permission-analysis-export-metadata-context.tsx b/libs/features/manage-permissions/src/permission-analysis-export-metadata-context.tsx new file mode 100644 index 000000000..22d9b6369 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-analysis-export-metadata-context.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import type { FieldExportDetail, SobjectExportDetail } from './permission-export-result-view'; + +export interface PermissionAnalysisExportMetadataContextValue { + fieldExportDetails: Record; + sobjectExportDetails: Record; + tabLabelBySettingName: ReadonlyMap; +} + +const EMPTY_TAB_LABELS: ReadonlyMap = new Map(); + +const defaultPermissionAnalysisExportMetadata: PermissionAnalysisExportMetadataContextValue = { + fieldExportDetails: {}, + sobjectExportDetails: {}, + tabLabelBySettingName: EMPTY_TAB_LABELS, +}; + +const PermissionAnalysisExportMetadataContext = createContext( + defaultPermissionAnalysisExportMetadata, +); + +export function usePermissionAnalysisExportMetadata(): PermissionAnalysisExportMetadataContextValue { + return useContext(PermissionAnalysisExportMetadataContext); +} + +export interface PermissionAnalysisExportMetadataProviderProps { + value: PermissionAnalysisExportMetadataContextValue; + children: ReactNode; +} + +export function PermissionAnalysisExportMetadataProvider({ value, children }: PermissionAnalysisExportMetadataProviderProps) { + return {children}; +} diff --git a/libs/features/manage-permissions/src/permission-analysis-issues-filters.ts b/libs/features/manage-permissions/src/permission-analysis-issues-filters.ts new file mode 100644 index 000000000..ad589b89c --- /dev/null +++ b/libs/features/manage-permissions/src/permission-analysis-issues-filters.ts @@ -0,0 +1,305 @@ +import { useCallback, useMemo } from 'react'; +import type { SetURLSearchParams } from 'react-router-dom'; +import { + type PermissionAnalysisFinding, + type PermissionExportRow, + getFindingContainerId, + getPermissionSetIdsWithDirectUserAssignment, +} from './permission-export-result-view'; + +export type IssuesSeverityFilter = 'all' | 'errors' | 'warnings'; + +/** Valid values for `issueSeverity` query param (anything else is treated as `all`). */ +export function parseIssuesSeverityFilterFromSearchParams(searchParams: URLSearchParams): IssuesSeverityFilter { + const raw = searchParams.get('issueSeverity'); + if (raw === 'errors' || raw === 'warnings') { + return raw; + } + return 'all'; +} + +export type IssuesOlsFlsFilter = 'all' | 'ols' | 'fls'; + +/** Valid values for `issueOlsFls` query param (anything else is treated as `all`). */ +export function parseIssuesOlsFlsFilterFromSearchParams(searchParams: URLSearchParams): IssuesOlsFlsFilter { + const raw = searchParams.get('issueOlsFls'); + if (raw === 'ols' || raw === 'fls') { + return raw; + } + return 'all'; +} + +export type IssuesDirectAssignmentFilter = 'all' | 'assigned' | 'unassigned'; +export type IssuesGroupBy = 'none' | 'severity' | 'object' | 'code' | 'container'; + +/** Issues grid column keys (order matches default grid). */ +export const ISSUES_GRID_COLUMN_KEYS = [ + 'severity', + 'code', + 'objectApiName', + 'fieldApiName', + 'message', + 'permissionSetId', + 'parentId', + 'containerId', +] as const; + +export type IssuesGridColumnKey = (typeof ISSUES_GRID_COLUMN_KEYS)[number]; + +export const ISSUES_GRID_COLUMN_LABELS: Record = { + severity: 'Severity', + code: 'Issue', + objectApiName: 'Object', + fieldApiName: 'Field', + message: 'Message', + permissionSetId: 'Permission set Id', + parentId: 'Parent Id', + containerId: 'Container Id', +}; + +/** Comma-separated keys in `issueHiddenCols`; unknown segments ignored. */ +export function parseIssueHiddenColumnsFromSearchParams(searchParams: URLSearchParams): Set { + const raw = searchParams.get('issueHiddenCols'); + if (!raw) { + return new Set(); + } + const allowed = new Set(ISSUES_GRID_COLUMN_KEYS); + const result = new Set(); + for (const part of raw.split(',')) { + const key = part.trim(); + if (allowed.has(key)) { + result.add(key as IssuesGridColumnKey); + } + } + return result; +} + +export type IssuesScopeFilter = 'all' | 'profiles' | 'permissionSets'; + +/** Valid values for `issueScope` when the export used explicit profile vs permission set scope. */ +export function parseIssuesScopeFilterFromSearchParams(searchParams: URLSearchParams): IssuesScopeFilter { + const raw = searchParams.get('issueScope'); + if (raw === 'profiles' || raw === 'permissionSets') { + return raw; + } + return 'all'; +} + +export interface IssueScopeFilterContext { + /** + * True when the job selected both profile permission sets and standalone permission sets. + * Export Scope filtering (and the toolbar control) applies only in that case. + */ + supportsExportScopeFilter: boolean; + profilePermissionSetIds: ReadonlySet; + permissionSetIds: ReadonlySet; +} + +export function readPermissionAnalysisSearchParam(searchParams: URLSearchParams, key: string, fallback: string): string { + const value = searchParams.get(key); + return value && value.length > 0 ? value : fallback; +} + +export function mergePermissionAnalysisSearchParams( + searchParams: URLSearchParams, + updates: Record, +): URLSearchParams { + const next = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === undefined || value === '') { + next.delete(key); + } else { + next.set(key, value); + } + } + return next; +} + +function normalizeSeverity(value: string | undefined): string { + return (value ?? '').toLowerCase(); +} + +export function isErrorSeverity(value: string | undefined): boolean { + const normalized = normalizeSeverity(value); + return normalized === 'error' || normalized === 'errors'; +} + +export function isWarningSeverity(value: string | undefined): boolean { + const normalized = normalizeSeverity(value); + return normalized === 'warning' || normalized === 'warnings'; +} + +/** + * Buckets an issue `code` for OLS vs FLS toolbar filters. + * Matches {@link PermissionExportFindingCode} prefixes (OLS_… / FLS_…). + * Meta codes such as FINDINGS_TRUNCATED and blank codes classify as `other` (visible only when filter is All). + */ +function findingCodeKind(code: string | undefined): 'ols' | 'fls' | 'other' { + const upper = (code ?? '').trim().toUpperCase(); + if (upper.startsWith('OLS')) { + return 'ols'; + } + if (upper.startsWith('FLS')) { + return 'fls'; + } + return 'other'; +} + +export interface UsePermissionAnalysisIssuesFiltersArgs { + findings: PermissionAnalysisFinding[]; + permissionSetAssignments: PermissionExportRow[]; + searchParams: URLSearchParams; + setSearchParams: SetURLSearchParams; + /** When set, issues can be narrowed to profile permission sets vs standalone permission sets from the job scope. */ + issueScopeFilterContext?: IssueScopeFilterContext; +} + +export interface UsePermissionAnalysisIssuesFiltersResult { + severityFilter: IssuesSeverityFilter; + olsFlsFilter: IssuesOlsFlsFilter; + directAssignmentFilter: IssuesDirectAssignmentFilter; + scopeFilter: IssuesScopeFilter; + /** Same reference passed into the hook; used by the toolbar for Export Scope visibility. */ + issueScopeFilterContext: IssueScopeFilterContext | undefined; + hiddenIssueGridColumns: ReadonlySet; + groupBy: IssuesGroupBy; + hasAssignmentData: boolean; + filteredFindings: PermissionAnalysisFinding[]; + errorTotal: number; + warningTotal: number; + errorFiltered: number; + warningFiltered: number; + updateParams: (updates: Record) => void; +} + +export function usePermissionAnalysisIssuesFilters({ + findings, + permissionSetAssignments, + searchParams, + setSearchParams, + issueScopeFilterContext, +}: UsePermissionAnalysisIssuesFiltersArgs): UsePermissionAnalysisIssuesFiltersResult { + const severityFilter = parseIssuesSeverityFilterFromSearchParams(searchParams); + const olsFlsFilter = parseIssuesOlsFlsFilterFromSearchParams(searchParams); + const scopeFilter = parseIssuesScopeFilterFromSearchParams(searchParams); + const hiddenIssueGridColumns = useMemo(() => parseIssueHiddenColumnsFromSearchParams(searchParams), [searchParams]); + const directAssignmentFilter = readPermissionAnalysisSearchParam( + searchParams, + 'issueDirectAssign', + 'all', + ) as IssuesDirectAssignmentFilter; + const groupBy = readPermissionAnalysisSearchParam(searchParams, 'cfGroup', 'none') as IssuesGroupBy; + + const updateParams = useCallback( + (updates: Record) => { + setSearchParams(mergePermissionAnalysisSearchParams(searchParams, updates), { replace: true }); + }, + [searchParams, setSearchParams], + ); + + const permissionSetsWithUsers = useMemo( + () => getPermissionSetIdsWithDirectUserAssignment(permissionSetAssignments), + [permissionSetAssignments], + ); + const hasAssignmentData = permissionSetAssignments.length > 0; + + const filteredFindings = useMemo(() => { + return findings.filter((finding) => { + const severityValue = finding.severity as string | undefined; + if (severityFilter === 'errors' && !isErrorSeverity(severityValue)) { + return false; + } + // Warnings-only: keep rows whose severity is warning/warnings (not errors, not unknown/other). + if (severityFilter === 'warnings' && !isWarningSeverity(severityValue)) { + return false; + } + if (olsFlsFilter === 'ols' && findingCodeKind(finding.code as string | undefined) !== 'ols') { + return false; + } + if (olsFlsFilter === 'fls' && findingCodeKind(finding.code as string | undefined) !== 'fls') { + return false; + } + if (directAssignmentFilter !== 'all' && hasAssignmentData) { + const containerId = getFindingContainerId(finding); + if (!containerId) { + return false; + } + const assigned = permissionSetsWithUsers.has(containerId); + if (directAssignmentFilter === 'assigned' && !assigned) { + return false; + } + if (directAssignmentFilter === 'unassigned' && assigned) { + return false; + } + } + if (issueScopeFilterContext?.supportsExportScopeFilter && scopeFilter !== 'all') { + const containerId = getFindingContainerId(finding); + if (!containerId) { + return false; + } + if (scopeFilter === 'profiles' && !issueScopeFilterContext.profilePermissionSetIds.has(containerId)) { + return false; + } + if (scopeFilter === 'permissionSets' && !issueScopeFilterContext.permissionSetIds.has(containerId)) { + return false; + } + } + return true; + }); + }, [ + findings, + severityFilter, + olsFlsFilter, + directAssignmentFilter, + hasAssignmentData, + permissionSetsWithUsers, + issueScopeFilterContext, + scopeFilter, + ]); + + const errorTotal = useMemo(() => findings.filter((f) => isErrorSeverity(f.severity as string | undefined)).length, [findings]); + const warningTotal = useMemo(() => findings.filter((f) => isWarningSeverity(f.severity as string | undefined)).length, [findings]); + const errorFiltered = useMemo( + () => filteredFindings.filter((f) => isErrorSeverity(f.severity as string | undefined)).length, + [filteredFindings], + ); + const warningFiltered = useMemo( + () => filteredFindings.filter((f) => isWarningSeverity(f.severity as string | undefined)).length, + [filteredFindings], + ); + + return useMemo( + () => ({ + severityFilter, + olsFlsFilter, + directAssignmentFilter, + scopeFilter, + issueScopeFilterContext, + hiddenIssueGridColumns, + groupBy, + hasAssignmentData, + filteredFindings, + errorTotal, + warningTotal, + errorFiltered, + warningFiltered, + updateParams, + }), + [ + severityFilter, + olsFlsFilter, + directAssignmentFilter, + scopeFilter, + issueScopeFilterContext, + hiddenIssueGridColumns, + groupBy, + hasAssignmentData, + filteredFindings, + errorTotal, + warningTotal, + errorFiltered, + warningFiltered, + updateParams, + ], + ); +} diff --git a/libs/features/manage-permissions/src/permission-analysis-tree-group-title.ts b/libs/features/manage-permissions/src/permission-analysis-tree-group-title.ts new file mode 100644 index 000000000..0c58751c1 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-analysis-tree-group-title.ts @@ -0,0 +1,13 @@ +/** + * Strip the redundant `Profile: ` prefix when showing a profile pill + title on two lines + * (tab visibility, field permissions, object permissions trees). + */ +export function permissionAnalysisPermissionContainerGroupTitleLine(exportLabel: string, isProfileOwned: boolean): string { + if (isProfileOwned && exportLabel.startsWith('Profile: ')) { + const rest = exportLabel.slice('Profile: '.length).trim(); + if (rest.length > 0) { + return rest; + } + } + return exportLabel; +} diff --git a/libs/features/manage-permissions/src/permission-analysis-viewer-badge.styles.ts b/libs/features/manage-permissions/src/permission-analysis-viewer-badge.styles.ts new file mode 100644 index 000000000..ff46cf58d --- /dev/null +++ b/libs/features/manage-permissions/src/permission-analysis-viewer-badge.styles.ts @@ -0,0 +1,187 @@ +import { css, SerializedStyles } from '@emotion/react'; + +export type PermissionScopeBadgeSurface = 'default' | 'onBrand'; + +/** + * Profile vs permission-set chip colors for Permission Analysis (history + scope filter). + * Palette matches the standalone permission matrix `viewer.html` / `viewer.css` used with export output: + * profiles use the blue column treatment; permission sets use the purple column treatment. + * + * If your viewer CSS uses different hex values, update them here so Jetstream stays in sync. + */ +const PROFILE_BG = '#d8edff'; +const PROFILE_BORDER = '#1589ee'; +const PROFILE_TEXT = '#032d60'; + +const PERM_SET_BG = '#eee5f7'; +const PERM_SET_BORDER = '#9050e9'; +const PERM_SET_TEXT = '#3e2497'; + +/** Teal treatment for permission set groups — distinct from profile (blue) and permission set (purple). */ +const PERM_SET_GROUP_BG = '#e3f7f5'; +const PERM_SET_GROUP_BORDER = '#06a59a'; +const PERM_SET_GROUP_TEXT = '#032f2e'; + +/** Neutral chip for non-permission scopes (e.g. analyzed Salesforce objects in field usage history). */ +const OBJECT_BG = '#f3f2f2'; +const OBJECT_BORDER = '#c9c9c9'; +const OBJECT_TEXT = '#080707'; + +const scopeBadgeTruncateCss = css` + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: middle; + font-weight: 600; + border-radius: 0.25rem; + box-shadow: none; +`; + +const objectDefaultCss = css` + ${scopeBadgeTruncateCss} + background-color: ${OBJECT_BG}; + color: ${OBJECT_TEXT}; + border: 1px solid ${OBJECT_BORDER}; +`; + +const objectOnBrandCss = css` + ${scopeBadgeTruncateCss} + background-color: rgba(255, 255, 255, 0.2); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.55); +`; + +const profileDefaultCss = css` + ${scopeBadgeTruncateCss} + background-color: ${PROFILE_BG}; + color: ${PROFILE_TEXT}; + border: 1px solid ${PROFILE_BORDER}; +`; + +const permissionSetDefaultCss = css` + ${scopeBadgeTruncateCss} + background-color: ${PERM_SET_BG}; + color: ${PERM_SET_TEXT}; + border: 1px solid ${PERM_SET_BORDER}; +`; + +const permissionSetGroupDefaultCss = css` + ${scopeBadgeTruncateCss} + background-color: ${PERM_SET_GROUP_BG}; + color: ${PERM_SET_GROUP_TEXT}; + border: 1px solid ${PERM_SET_GROUP_BORDER}; +`; + +/** Selected filter row uses brand (blue) background — chips need light outline treatment. */ +const profileOnBrandCss = css` + ${scopeBadgeTruncateCss} + background-color: rgba(255, 255, 255, 0.22); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.65); +`; + +const permissionSetOnBrandCss = css` + ${scopeBadgeTruncateCss} + background-color: rgba(255, 255, 255, 0.18); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.55); +`; + +const permissionSetGroupOnBrandCss = css` + ${scopeBadgeTruncateCss} + background-color: rgba(255, 255, 255, 0.16); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.5); +`; + +/** Container kinds that use the permission-matrix palette (analysis history, assignment rows, etc.). */ +export type PermissionAnalysisContainerKind = 'profile' | 'permission_set' | 'permission_set_group'; + +const assignmentTypeLabelShellCss = css` + display: inline-block; + vertical-align: middle; + font-weight: 600; + border-radius: 0.25rem; + box-shadow: none; + padding: 0.0625rem 0.4rem; + line-height: 1.35; + max-width: 100%; +`; + +const profileAssignmentTypeLabelCss = css` + ${assignmentTypeLabelShellCss} + background-color: ${PROFILE_BG}; + color: ${PROFILE_TEXT}; + border: 1px solid ${PROFILE_BORDER}; +`; + +const permissionSetAssignmentTypeLabelCss = css` + ${assignmentTypeLabelShellCss} + background-color: ${PERM_SET_BG}; + color: ${PERM_SET_TEXT}; + border: 1px solid ${PERM_SET_BORDER}; +`; + +const permissionSetGroupAssignmentTypeLabelCss = css` + ${assignmentTypeLabelShellCss} + background-color: ${PERM_SET_GROUP_BG}; + color: ${PERM_SET_GROUP_TEXT}; + border: 1px solid ${PERM_SET_GROUP_BORDER}; +`; + +/** + * Compact type label (e.g. “Profile”) for assignment rows — same palette as {@link permissionScopeBadgeCss}. + */ +export function permissionAnalysisAssignmentTypeLabelCss(kind: PermissionAnalysisContainerKind): SerializedStyles { + if (kind === 'profile') { + return profileAssignmentTypeLabelCss; + } + if (kind === 'permission_set_group') { + return permissionSetGroupAssignmentTypeLabelCss; + } + return permissionSetAssignmentTypeLabelCss; +} + +/** + * @param kind Profile vs permission set vs analyzed object (field usage history). + * @param surface Use `onBrand` when the badge sits on `slds-button_brand` (selected filter row). + */ +export function permissionScopeBadgeCss( + kind: 'profile' | 'permission_set' | 'object', + surface: PermissionScopeBadgeSurface = 'default', +): SerializedStyles { + if (kind === 'object') { + return surface === 'onBrand' ? objectOnBrandCss : objectDefaultCss; + } + if (surface === 'onBrand') { + return kind === 'profile' ? profileOnBrandCss : permissionSetOnBrandCss; + } + return kind === 'profile' ? profileDefaultCss : permissionSetDefaultCss; +} + +/** + * Scope / filter chips when the list can include permission set groups (e.g. future history filters). + */ +export function permissionAnalysisContainerBadgeCss( + kind: PermissionAnalysisContainerKind, + surface: PermissionScopeBadgeSurface = 'default', +): SerializedStyles { + if (surface === 'onBrand') { + if (kind === 'profile') { + return profileOnBrandCss; + } + if (kind === 'permission_set_group') { + return permissionSetGroupOnBrandCss; + } + return permissionSetOnBrandCss; + } + if (kind === 'profile') { + return profileDefaultCss; + } + if (kind === 'permission_set_group') { + return permissionSetGroupDefaultCss; + } + return permissionSetDefaultCss; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-columns-assignments.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-columns-assignments.ts new file mode 100644 index 000000000..bd7fd4031 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-columns-assignments.ts @@ -0,0 +1,495 @@ +import type { ColumnWithFilter, RowWithKey } from '@jetstream/ui'; +import { getRowTypeFromValue, setColumnFromType } from '@jetstream/ui'; +import type { BuildDynamicExportColumnsOptions, PermissionExportRow } from './export-result-types-labels'; +import { buildPermissionSetIdLabelMap } from './export-result-types-labels'; + +/** Salesforce User Id prefix (15- or 18-char Ids). */ +const USER_ID_PREFIX = '005'; + +const COLUMN_KEY_ORDER = ['Id', 'ParentId', 'PermissionSetId', 'PermissionSetGroupId', 'AssigneeId', 'SobjectType', 'Field']; + +/** Column key order for export grids and the object-permissions tree (stable, readable columns). */ +export function sortedExportColumnKeys(rows: PermissionExportRow[]): string[] { + const keys = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + keys.add(key); + } + } + const ordered: string[] = []; + for (const preferred of COLUMN_KEY_ORDER) { + if (keys.has(preferred)) { + ordered.push(preferred); + } + } + const rest = [...keys].filter((key) => !COLUMN_KEY_ORDER.includes(key)).sort((a, b) => a.localeCompare(b)); + ordered.push(...rest); + return ordered; +} + +/** + * Salesforce `ObjectPermissions` API fields in grid order: Create, Read, Edit, Delete, + * View All Records, Modify All Records, View All Fields. + */ +export const OBJECT_PERMISSION_COLUMN_KEY_ORDER = [ + 'PermissionsCreate', + 'PermissionsRead', + 'PermissionsEdit', + 'PermissionsDelete', + 'PermissionsViewAllRecords', + 'PermissionsModifyAllRecords', + 'PermissionsViewAllFields', +] as const; + +function isObjectPermissionsExportSample(sample: PermissionExportRow): boolean { + return 'PermissionsDelete' in sample; +} + +/** + * `Permissions*` keys present on rows, in {@link OBJECT_PERMISSION_COLUMN_KEY_ORDER}, then any extras (sorted). + */ +export function sortedObjectPermissionBooleanKeys(rows: PermissionExportRow[]): string[] { + if (!rows.length) { + return []; + } + const keySet = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + if (key.startsWith('Permissions')) { + keySet.add(key); + } + } + } + const orderSet = new Set(OBJECT_PERMISSION_COLUMN_KEY_ORDER); + const primary = OBJECT_PERMISSION_COLUMN_KEY_ORDER.filter((key) => keySet.has(key)); + const rest = [...keySet].filter((key) => !orderSet.has(key)).sort((a, b) => a.localeCompare(b)); + return [...primary, ...rest]; +} + +/** Reorders a full export key list so `Permissions*` on object-permission rows follow {@link OBJECT_PERMISSION_COLUMN_KEY_ORDER}. */ +export function reorderExportKeysForObjectPermissions(keys: string[]): string[] { + const nonPerm = keys.filter((key) => !key.startsWith('Permissions')); + const perm = keys.filter((key) => key.startsWith('Permissions')); + const orderSet = new Set(OBJECT_PERMISSION_COLUMN_KEY_ORDER); + const primary = OBJECT_PERMISSION_COLUMN_KEY_ORDER.filter((key) => perm.includes(key)); + const rest = perm.filter((key) => !orderSet.has(key)).sort((a, b) => a.localeCompare(b)); + return [...nonPerm, ...primary, ...rest]; +} + +/** Split PascalCase API suffixes into spaced words for column titles (e.g. `ViewAllRecords` → "View All Records"). */ +function splitPascalCaseToTitle(pascal: string): string { + return pascal + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .trim(); +} + +/** + * Human-readable DataTable header for export SOQL field names. + * Salesforce object/field permission booleans use `PermissionsRead`, `PermissionsEdit`, etc.; show "Read", "Edit", … + * + * @param fieldKey API field name from a row key. + * @returns Label for the column header; `fieldKey` unchanged when not a `Permissions*` column. + */ +const PERMISSIONS_COLUMN_MIN_WIDTH_PX = 200; +const PERMISSIONS_COLUMN_HEADER_CHAR_PX = 9; +const PERMISSIONS_COLUMN_HEADER_PADDING_PX = 48; + +function permissionsColumnWidthForLabel(headerLabel: string): number { + const fromLabel = Math.ceil(headerLabel.length * PERMISSIONS_COLUMN_HEADER_CHAR_PX + PERMISSIONS_COLUMN_HEADER_PADDING_PX); + return Math.max(PERMISSIONS_COLUMN_MIN_WIDTH_PX, fromLabel); +} + +export function getExportColumnHeaderLabel(fieldKey: string): string { + if (fieldKey === 'SobjectType') { + return 'Object'; + } + if (fieldKey === 'CreatedDate') { + return 'Created Date'; + } + if (fieldKey === 'LastModifiedDate') { + return 'Last Modified Date'; + } + if (fieldKey === 'CreatedBy') { + return 'Created By'; + } + if (fieldKey === 'LastModifiedBy') { + return 'Last Modified By'; + } + if (!fieldKey.startsWith('Permissions')) { + return fieldKey; + } + const suffix = fieldKey.slice('Permissions'.length); + if (!suffix) { + return fieldKey; + } + const explicitLabels: Record = { + Read: 'Read', + Create: 'Create', + Edit: 'Edit', + Delete: 'Delete', + ViewAllRecords: 'View All Records', + ModifyAllRecords: 'Modify All Records', + ViewAllFields: 'View All Fields', + }; + if (explicitLabels[suffix]) { + return explicitLabels[suffix]; + } + return splitPascalCaseToTitle(suffix); +} + +/** + * User Ids (`005…`) assigned to each permission set Id, deduped and sorted (for the Permission Sets tab). + */ +export function buildPermissionSetAssigneeIdsByPermissionSetId(assignments: PermissionExportRow[]): Map { + const idSets = new Map>(); + for (const row of assignments) { + const permissionSetId = row.PermissionSetId; + const assigneeId = row.AssigneeId; + if (typeof permissionSetId !== 'string' || typeof assigneeId !== 'string') { + continue; + } + const trimmedPermissionSetId = permissionSetId.trim(); + const trimmedAssigneeId = assigneeId.trim(); + if (!trimmedPermissionSetId || !trimmedAssigneeId.startsWith(USER_ID_PREFIX)) { + continue; + } + let assigneeSet = idSets.get(trimmedPermissionSetId); + if (!assigneeSet) { + assigneeSet = new Set(); + idSets.set(trimmedPermissionSetId, assigneeSet); + } + assigneeSet.add(trimmedAssigneeId); + } + const result = new Map(); + for (const [permissionSetId, assigneeSet] of idSets) { + result.set( + permissionSetId, + [...assigneeSet].sort((a, b) => a.localeCompare(b)), + ); + } + return result; +} + +/** Leaf rows for the Permission Sets analysis tree (user assignment or “no users” placeholder). */ +export type PermissionSetAssignmentsTreeRow = PermissionExportRow & { + _treePermissionSetGroupKey: string; + /** Present on a synthetic leaf when there are no direct user (`005…`) assignments. */ + _noDirectUserAssignments?: boolean; +}; + +/** User-assignment leaf (excludes the “no direct user assignments” placeholder row). */ +export type PermissionSetAssignmentsTreeUserLeafRow = PermissionSetAssignmentsTreeRow & { + AssigneeId: string; +}; + +/** + * Builds flat rows for a tree grouped by permission set Id: one leaf per assigned user, or one placeholder leaf. + * Permission sets are ordered alphabetically by the same display label as {@link buildPermissionSetIdLabelMap}. + */ +export function buildPermissionSetAssignmentsTreeRows( + permissionSetRows: PermissionExportRow[], + assignments: PermissionExportRow[], +): PermissionSetAssignmentsTreeRow[] { + const labelByPermissionSetId = buildPermissionSetIdLabelMap(permissionSetRows); + const permissionSetRowsAlphabetical = [...permissionSetRows].sort((a, b) => { + const idA = typeof a.Id === 'string' ? a.Id.trim() : ''; + const idB = typeof b.Id === 'string' ? b.Id.trim() : ''; + const labelA = idA ? (labelByPermissionSetId.get(idA) ?? idA) : ''; + const labelB = idB ? (labelByPermissionSetId.get(idB) ?? idB) : ''; + return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); + }); + const userIdsBySetId = buildPermissionSetAssigneeIdsByPermissionSetId(assignments); + const result: PermissionSetAssignmentsTreeRow[] = []; + for (const permSetRow of permissionSetRowsAlphabetical) { + const id = typeof permSetRow.Id === 'string' ? permSetRow.Id.trim() : ''; + if (!id) { + continue; + } + const userIds = userIdsBySetId.get(id) ?? []; + if (userIds.length === 0) { + result.push({ + _treePermissionSetGroupKey: id, + _noDirectUserAssignments: true, + Id: `__no_users__${id}`, + }); + } else { + for (const assigneeId of userIds) { + result.push({ + _treePermissionSetGroupKey: id, + PermissionSetId: id, + AssigneeId: assigneeId, + Id: `__user__${id}__${assigneeId}`, + }); + } + } + } + return result; +} + +export function isPermissionSetAssignmentsTreeUserLeaf(row: unknown): row is PermissionSetAssignmentsTreeUserLeafRow { + if (row === null || typeof row !== 'object') { + return false; + } + const record = row as PermissionSetAssignmentsTreeRow; + if (record._noDirectUserAssignments === true) { + return false; + } + const assigneeId = record.AssigneeId; + return typeof assigneeId === 'string' && assigneeId.startsWith(USER_ID_PREFIX); +} + +export function isPermissionSetAssignmentsTreePlaceholderLeaf(row: unknown): row is PermissionSetAssignmentsTreeRow { + if (row === null || typeof row !== 'object') { + return false; + } + return (row as PermissionSetAssignmentsTreeRow)._noDirectUserAssignments === true; +} + +/** Leaf kinds for the Assignments tab tree (grouped by user). */ +export type UserAssignmentTreeLeafKind = 'permission_set' | 'permission_set_group' | 'profile' | 'permission_set_license'; + +/** One permission set license row for {@link buildUserAssignmentsTreeRows}. */ +export interface UserLicenseLeafRecord { + permissionSetLicenseId: string; + label: string; +} + +/** Flat row for a tree grouped by Salesforce User Id (`005…`). */ +export type UserAssignmentsTreeRow = { + Id: string; + _treeUserGroupKey: string; + _leafKind: UserAssignmentTreeLeafKind; + /** Permission set Id when `_leafKind` is `permission_set`. */ + _permissionSetId?: string; + /** Permission set group Id when `_leafKind` is `permission_set_group`. */ + _permissionSetGroupId?: string; + /** Profile Id when `_leafKind` is `profile` (optional until enriched from `User`). */ + _profileId?: string; + /** Permission Set License definition Id when `_leafKind` is `permission_set_license`. */ + _permissionSetLicenseId?: string; + /** Display label for `permission_set_license` leaves. */ + _licenseLabel?: string; +}; + +export function buildPermissionSetGroupLabelMap(groups: PermissionExportRow[]): Map { + const map = new Map(); + for (const row of groups) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (!id) { + continue; + } + const masterLabel = typeof row.MasterLabel === 'string' && row.MasterLabel.trim() ? row.MasterLabel.trim() : ''; + const developerName = typeof row.DeveloperName === 'string' && row.DeveloperName.trim() ? row.DeveloperName.trim() : ''; + map.set(id, masterLabel || developerName || id); + } + return map; +} + +export function buildPermissionSetIdToGroupIdsMap(components: PermissionExportRow[]): Map> { + const map = new Map>(); + for (const row of components) { + const permissionSetId = typeof row.PermissionSetId === 'string' ? row.PermissionSetId.trim() : ''; + const groupId = typeof row.PermissionSetGroupId === 'string' ? row.PermissionSetGroupId.trim() : ''; + if (!permissionSetId || !groupId) { + continue; + } + let groupSet = map.get(permissionSetId); + if (!groupSet) { + groupSet = new Set(); + map.set(permissionSetId, groupSet); + } + groupSet.add(groupId); + } + return map; +} + +function buildUserIdToAssignedPermissionSetIds(assignments: PermissionExportRow[]): Map> { + const map = new Map>(); + for (const row of assignments) { + const assigneeId = row.AssigneeId; + const permissionSetId = row.PermissionSetId; + if (typeof assigneeId !== 'string' || typeof permissionSetId !== 'string') { + continue; + } + const trimmedUserId = assigneeId.trim(); + const trimmedPermissionSetId = permissionSetId.trim(); + if (!trimmedUserId.startsWith(USER_ID_PREFIX) || !trimmedPermissionSetId) { + continue; + } + let permissionSetSet = map.get(trimmedUserId); + if (!permissionSetSet) { + permissionSetSet = new Set(); + map.set(trimmedUserId, permissionSetSet); + } + permissionSetSet.add(trimmedPermissionSetId); + } + return map; +} + +/** + * Builds flat rows for the Assignments analysis tree: group = user, leaves = profile first, then permission sets + * (alphabetically by display label), then permission set groups, then permission set licenses (per user). Users are + * ordered by `userId` here; use {@link sortUserAssignmentsTreeRowsByUserDisplay} after loading display names. + */ +export function buildUserAssignmentsTreeRows(options: { + assignments: PermissionExportRow[]; + permissionSets: PermissionExportRow[]; + groupComponents: PermissionExportRow[]; + groups: PermissionExportRow[]; + licensesByUserId?: ReadonlyMap; +}): UserAssignmentsTreeRow[] { + const { assignments, permissionSets, groupComponents, groups, licensesByUserId = new Map() } = options; + const labelByPermissionSetId = buildPermissionSetIdLabelMap(permissionSets); + const labelByGroupId = buildPermissionSetGroupLabelMap(groups); + const permissionSetIdToGroupIds = buildPermissionSetIdToGroupIdsMap(groupComponents); + const userToPermissionSetIds = buildUserIdToAssignedPermissionSetIds(assignments); + + const userIds = [...userToPermissionSetIds.keys()].sort((a, b) => a.localeCompare(b)); + const result: UserAssignmentsTreeRow[] = []; + + for (const userId of userIds) { + const permissionSetIds = [...(userToPermissionSetIds.get(userId) ?? [])].sort((firstId, secondId) => { + const labelA = labelByPermissionSetId.get(firstId) ?? firstId; + const labelB = labelByPermissionSetId.get(secondId) ?? secondId; + return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); + }); + + result.push({ + Id: `__profile__${userId}`, + _treeUserGroupKey: userId, + _leafKind: 'profile', + }); + + for (const permissionSetId of permissionSetIds) { + result.push({ + Id: `__ps__${userId}__${permissionSetId}`, + _treeUserGroupKey: userId, + _leafKind: 'permission_set', + _permissionSetId: permissionSetId, + }); + } + + const groupIdsForUser = new Set(); + for (const permissionSetId of permissionSetIds) { + for (const groupId of permissionSetIdToGroupIds.get(permissionSetId) ?? []) { + groupIdsForUser.add(groupId); + } + } + const sortedGroupIds = [...groupIdsForUser].sort((firstId, secondId) => { + const labelA = labelByGroupId.get(firstId) ?? firstId; + const labelB = labelByGroupId.get(secondId) ?? secondId; + return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); + }); + for (const groupId of sortedGroupIds) { + result.push({ + Id: `__psg__${userId}__${groupId}`, + _treeUserGroupKey: userId, + _leafKind: 'permission_set_group', + _permissionSetGroupId: groupId, + }); + } + + const licenseRecords = licensesByUserId.get(userId) ?? []; + const sortedLicenses = [...licenseRecords].sort((first, second) => + first.label.localeCompare(second.label, undefined, { sensitivity: 'base' }), + ); + for (const license of sortedLicenses) { + result.push({ + Id: `__psl__${userId}__${license.permissionSetLicenseId}`, + _treeUserGroupKey: userId, + _leafKind: 'permission_set_license', + _permissionSetLicenseId: license.permissionSetLicenseId, + _licenseLabel: license.label, + }); + } + } + + return result; +} + +/** + * Reorders {@link UserAssignmentsTreeRow} blocks so users appear alphabetically by display label. + */ +export function sortUserAssignmentsTreeRowsByUserDisplay( + rows: UserAssignmentsTreeRow[], + displayLabelByUserId: ReadonlyMap, +): UserAssignmentsTreeRow[] { + const rowsByUserId = new Map(); + for (const row of rows) { + const userId = row._treeUserGroupKey; + const list = rowsByUserId.get(userId) ?? []; + list.push(row); + rowsByUserId.set(userId, list); + } + const sortedUserIds = [...rowsByUserId.keys()].sort((a, b) => { + const labelA = displayLabelByUserId.get(a) ?? a; + const labelB = displayLabelByUserId.get(b) ?? b; + return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); + }); + const output: UserAssignmentsTreeRow[] = []; + for (const userId of sortedUserIds) { + output.push(...(rowsByUserId.get(userId) ?? [])); + } + return output; +} + +export function isUserAssignmentsTreePermissionSetLeaf( + row: unknown, +): row is UserAssignmentsTreeRow & { _leafKind: 'permission_set'; _permissionSetId: string } { + if (row === null || typeof row !== 'object') { + return false; + } + const record = row as UserAssignmentsTreeRow; + return record._leafKind === 'permission_set' && typeof record._permissionSetId === 'string' && record._permissionSetId.trim().length > 0; +} + +/** + * Builds read-only DataTable columns from heterogeneous SOQL rows (first row drives types). + */ +export function buildDynamicExportColumns( + rows: PermissionExportRow[], + options?: BuildDynamicExportColumnsOptions, +): ColumnWithFilter[] { + if (!rows.length) { + return []; + } + const omit = options?.omitColumnKeys ?? new Set(); + let keys = sortedExportColumnKeys(rows).filter((key) => !omit.has(key)); + const firstRow = rows[0]; + if (isObjectPermissionsExportSample(firstRow)) { + keys = reorderExportKeysForObjectPermissions(keys); + } + return keys.map((key) => { + const fieldType = key === 'Id' || key === 'ParentId' || key.endsWith('Id') ? 'salesforceId' : getRowTypeFromValue(firstRow[key], false); + const base = setColumnFromType(key, fieldType); + const headerLabel = getExportColumnHeaderLabel(key); + const permissionsWidthPx = key.startsWith('Permissions') ? permissionsColumnWidthForLabel(headerLabel) : undefined; + return { + ...base, + name: headerLabel, + key, + field: key, + resizable: true, + ...(permissionsWidthPx !== undefined ? { width: permissionsWidthPx, minWidth: permissionsWidthPx } : {}), + } as ColumnWithFilter; + }); +} + +/** + * Permission sets that have at least one direct assignment to a User (`AssigneeId` prefix `005`). + */ +export function getPermissionSetIdsWithDirectUserAssignment(assignments: PermissionExportRow[]): Set { + const result = new Set(); + for (const row of assignments) { + const permissionSetId = row.PermissionSetId; + const assigneeId = row.AssigneeId; + if (typeof permissionSetId !== 'string' || typeof assigneeId !== 'string') { + continue; + } + if (assigneeId.startsWith(USER_ID_PREFIX)) { + result.add(permissionSetId); + } + } + return result; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-core.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-core.ts new file mode 100644 index 000000000..8fa7034c8 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-core.ts @@ -0,0 +1,4 @@ +export * from './export-result-types-labels'; +export * from './export-result-sorting'; +export * from './export-result-parse-collect'; +export * from './export-result-columns-assignments'; diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-findings.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-findings.ts new file mode 100644 index 000000000..a53c383db --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-findings.ts @@ -0,0 +1,481 @@ +import { getPermissionExportFindingDefinition, PermissionExportFindingCode } from '@jetstream/shared/constants'; + +import { + FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS, + type PermissionAnalysisFinding, + type PermissionExportRow, +} from './export-result-types-labels'; + +export function getFindingContainerId(finding: PermissionAnalysisFinding): string | null { + const candidates = [finding.permissionSetId, finding.parentId, finding.containerId]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.length > 0) { + return candidate; + } + } + return null; +} + +/** Matches {@link buildPermissionExportFindings} row keying: permission-set container + object API name. */ +export function objectPermissionFindingRowKey(parentId: string, objectApiName: string): string { + return `${parentId}::${objectApiName}`; +} + +export type PermissionObjectFindingCellSeverity = 'error' | 'warning'; + +const OBJECT_FINDING_READ_PATH_COLUMNS = ['PermissionsRead', 'PermissionsViewAllRecords', 'PermissionsModifyAllRecords'] as const; +const OBJECT_FINDING_EDIT_PATH_COLUMNS = ['PermissionsEdit', 'PermissionsModifyAllRecords'] as const; + +/** + * Object-permission grid column keys highlighted for a given issue `code` (empty when none). + */ +export function getObjectPermissionHighlightColumnKeysForFindingCode(code: string): readonly string[] { + const trimmed = code.trim(); + if (!trimmed || trimmed === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + return []; + } + if (trimmed === PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS) { + return ['PermissionsRead']; + } + if (trimmed === PermissionExportFindingCode.OLS_EDIT_NO_FLS_ROWS) { + return ['PermissionsEdit']; + } + if (trimmed === PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ) { + return OBJECT_FINDING_READ_PATH_COLUMNS; + } + if (trimmed === PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT) { + return OBJECT_FINDING_EDIT_PATH_COLUMNS; + } + return []; +} + +function severityForObjectPermissionFindingCode(code: string): PermissionObjectFindingCellSeverity | null { + const def = getPermissionExportFindingDefinition(code); + if (!def) { + return null; + } + return def.severity === 'error' ? 'error' : 'warning'; +} + +/** + * Issues that contribute to highlighting this leaf row cell on the Object Permissions tree. + */ +export function listFindingsForObjectPermissionCell( + findings: readonly PermissionAnalysisFinding[], + parentId: string, + objectApiName: string, + columnKey: string, +): PermissionAnalysisFinding[] { + const normalizedParent = parentId.trim(); + const normalizedObject = objectApiName.trim(); + const normalizedColumn = columnKey.trim(); + const matches: PermissionAnalysisFinding[] = []; + for (const finding of findings) { + const findingParent = getFindingContainerId(finding)?.trim() ?? ''; + const findingObject = typeof finding.objectApiName === 'string' ? finding.objectApiName.trim() : ''; + if (!findingParent || !findingObject) { + continue; + } + if (findingParent !== normalizedParent || findingObject !== normalizedObject) { + continue; + } + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + const highlightColumns = getObjectPermissionHighlightColumnKeysForFindingCode(codeRaw); + if (!highlightColumns.includes(normalizedColumn)) { + continue; + } + matches.push(finding); + } + return matches; +} + +/** + * Maps object-permission export rows (ParentId + SobjectType) to permission boolean columns that + * should be highlighted on the Object Permissions tree from analysis issues. + * + * @param findings Parsed `analysis_job.result.findings` (same issue rows as the Issues tab). + * @returns Outer key: {@link objectPermissionFindingRowKey}; inner key: `Permissions*` column name. + */ +export function buildObjectPermissionFindingCellHighlights( + findings: PermissionAnalysisFinding[], +): Map> { + const result = new Map>(); + + const mergeCell = (rowKey: string, columnKey: string, severity: PermissionObjectFindingCellSeverity): void => { + let columnMap = result.get(rowKey); + if (!columnMap) { + columnMap = new Map(); + result.set(rowKey, columnMap); + } + const existing = columnMap.get(columnKey); + const next: PermissionObjectFindingCellSeverity = existing === 'error' || severity === 'error' ? 'error' : severity; + columnMap.set(columnKey, next); + }; + + for (const finding of findings) { + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + const objectApi = typeof finding.objectApiName === 'string' ? finding.objectApiName.trim() : ''; + const parentId = getFindingContainerId(finding)?.trim() ?? ''; + if (!codeRaw || !objectApi || !parentId) { + continue; + } + const highlightColumns = getObjectPermissionHighlightColumnKeysForFindingCode(codeRaw); + if (highlightColumns.length === 0) { + continue; + } + const severity = severityForObjectPermissionFindingCode(codeRaw); + if (!severity) { + continue; + } + const rowKey = objectPermissionFindingRowKey(parentId, objectApi); + for (const columnKey of highlightColumns) { + mergeCell(rowKey, columnKey, severity); + } + } + + return result; +} + +/** + * Sentinel field segment for {@link fieldPermissionFindingRowKey} when an issue applies to every + * field-permission row for the same permission set + object (e.g. {@link PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW}). + */ +export const FIELD_PERMISSION_OBJECT_SCOPE_MARKER = '__FIELD_PERM_OBJECT_SCOPE__'; + +/** Row key for field-permission export highlights (`ParentId::SobjectType::Field` or scope marker). */ +export function fieldPermissionFindingRowKey(parentId: string, objectApiName: string, fieldSegment: string): string { + return `${parentId.trim()}::${objectApiName.trim()}::${fieldSegment.trim()}`; +} + +/** + * Field-permission grid column keys highlighted for a given issue `code` (empty when none on this surface). + */ +export function getFieldPermissionHighlightColumnKeysForFindingCode(code: string): readonly string[] { + const trimmed = code.trim(); + if (!trimmed || trimmed === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + return []; + } + if (trimmed === PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ) { + return ['PermissionsRead']; + } + if (trimmed === PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT) { + return ['PermissionsEdit']; + } + if (trimmed === PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW) { + return [...FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS]; + } + return []; +} + +function severityForFieldPermissionFindingCode(code: string): PermissionObjectFindingCellSeverity | null { + return severityForObjectPermissionFindingCode(code); +} + +/** + * Issues that highlight this field-permission export cell (same `ParentId` / `SobjectType` / `Field` row). + */ +export function listFindingsForFieldPermissionCell( + findings: readonly PermissionAnalysisFinding[], + parentId: string, + objectApiName: string, + fieldApiName: string, + columnKey: string, +): PermissionAnalysisFinding[] { + const normalizedParent = parentId.trim(); + const normalizedObject = objectApiName.trim(); + const normalizedField = fieldApiName.trim(); + const normalizedColumn = columnKey.trim(); + const matches: PermissionAnalysisFinding[] = []; + for (const finding of findings) { + const findingParent = getFindingContainerId(finding)?.trim() ?? ''; + const findingObject = typeof finding.objectApiName === 'string' ? finding.objectApiName.trim() : ''; + if (!findingParent || !findingObject || findingParent !== normalizedParent || findingObject !== normalizedObject) { + continue; + } + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + const highlightColumns = getFieldPermissionHighlightColumnKeysForFindingCode(codeRaw); + if (!highlightColumns.includes(normalizedColumn)) { + continue; + } + if (codeRaw === PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW) { + matches.push(finding); + continue; + } + const findingField = typeof finding.fieldApiName === 'string' ? finding.fieldApiName.trim() : ''; + if (findingField === normalizedField) { + matches.push(finding); + } + } + return matches; +} + +/** + * Maps field-permission export rows to cells that should highlight from analysis issues. + * + * @returns Outer key: {@link fieldPermissionFindingRowKey}; inner key: column name (e.g. `PermissionsRead`). + */ +export function buildFieldPermissionFindingCellHighlights( + findings: PermissionAnalysisFinding[], +): Map> { + const result = new Map>(); + + const mergeCell = (rowKey: string, columnKey: string, severity: PermissionObjectFindingCellSeverity): void => { + let columnMap = result.get(rowKey); + if (!columnMap) { + columnMap = new Map(); + result.set(rowKey, columnMap); + } + const existing = columnMap.get(columnKey); + const next: PermissionObjectFindingCellSeverity = existing === 'error' || severity === 'error' ? 'error' : severity; + columnMap.set(columnKey, next); + }; + + for (const finding of findings) { + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + const objectApi = typeof finding.objectApiName === 'string' ? finding.objectApiName.trim() : ''; + const parentId = getFindingContainerId(finding)?.trim() ?? ''; + if (!codeRaw || !objectApi || !parentId) { + continue; + } + const highlightColumns = getFieldPermissionHighlightColumnKeysForFindingCode(codeRaw); + if (highlightColumns.length === 0) { + continue; + } + const severity = severityForFieldPermissionFindingCode(codeRaw); + if (!severity) { + continue; + } + const fieldPart = + codeRaw === PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW + ? FIELD_PERMISSION_OBJECT_SCOPE_MARKER + : typeof finding.fieldApiName === 'string' + ? finding.fieldApiName.trim() + : ''; + if (!fieldPart) { + continue; + } + const rowKey = fieldPermissionFindingRowKey(parentId, objectApi, fieldPart); + for (const columnKey of highlightColumns) { + mergeCell(rowKey, columnKey, severity); + } + } + + return result; +} + +export function fieldPermissionCellSeverity( + highlights: Map>, + parentId: string, + objectApiName: string, + fieldApiName: string, + columnKey: string, +): PermissionObjectFindingCellSeverity | undefined { + const specificKey = fieldPermissionFindingRowKey(parentId, objectApiName, fieldApiName); + const scopeKey = fieldPermissionFindingRowKey(parentId, objectApiName, FIELD_PERMISSION_OBJECT_SCOPE_MARKER); + const fromSpecific = highlights.get(specificKey)?.get(columnKey); + const fromScope = highlights.get(scopeKey)?.get(columnKey); + if (fromSpecific === 'error' || fromScope === 'error') { + return 'error'; + } + return fromSpecific ?? fromScope; +} + +function isErrorLikeSeverity(value: unknown): boolean { + const normalized = String(value ?? '').toLowerCase(); + return normalized === 'error' || normalized === 'errors'; +} + +function isWarningLikeSeverity(value: unknown): boolean { + const normalized = String(value ?? '').toLowerCase(); + return normalized === 'warning' || normalized === 'warnings'; +} + +/** + * Severity used for container-level badges (permission set / profile rows), from catalog definition or row payload. + */ +function containerSeverityFromFinding(finding: PermissionAnalysisFinding, codeRaw: string): PermissionObjectFindingCellSeverity | null { + const def = getPermissionExportFindingDefinition(codeRaw); + if (def) { + return def.severity === 'error' ? 'error' : 'warning'; + } + if (isErrorLikeSeverity(finding.severity)) { + return 'error'; + } + if (isWarningLikeSeverity(finding.severity)) { + return 'warning'; + } + return null; +} + +/** + * Max severity per permission-set container Id for profile / permission-set / assignment export rows. + */ +export function buildContainerIdFindingSeverity( + findings: readonly PermissionAnalysisFinding[], +): Map { + const result = new Map(); + for (const finding of findings) { + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + if (!codeRaw || codeRaw === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + continue; + } + const containerId = getFindingContainerId(finding)?.trim() ?? ''; + if (!containerId) { + continue; + } + const next = containerSeverityFromFinding(finding, codeRaw); + if (!next) { + continue; + } + const existing = result.get(containerId); + const merged: PermissionObjectFindingCellSeverity = existing === 'error' || next === 'error' ? 'error' : next; + result.set(containerId, merged); + } + return result; +} + +/** + * All issues for a permission-set container (same list as Issues tab), excluding truncation rows. + */ +export function listFindingsForExportContainer( + findings: readonly PermissionAnalysisFinding[], + containerId: string, +): PermissionAnalysisFinding[] { + const id = containerId.trim(); + if (!id) { + return []; + } + return findings.filter((finding) => { + const code = String(finding.code ?? '').trim(); + if (code === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + return false; + } + return getFindingContainerId(finding)?.trim() === id; + }); +} + +/** Column keys on permission-set export rows that open the container issues modal (first match wins). */ +export function pickPermissionSetExportClickableColumnKeys(sample: PermissionExportRow): string[] { + const preferred = ['Label', 'Name', 'MasterLabel', 'DeveloperName', 'Profile', 'Id'] as const; + return preferred.filter((key) => key in sample); +} + +/** Column keys on assignment rows that open issues for the related permission set. */ +export function pickAssignmentExportClickableColumnKeys(sample: PermissionExportRow): string[] { + const preferred = ['PermissionSetId', 'AssigneeId', 'Id'] as const; + return preferred.filter((key) => key in sample); +} + +/** Column keys on PermissionSetTabSetting rows (`ParentId` = permission set). */ +export function pickTabVisibilityExportClickableColumnKeys(sample: PermissionExportRow): string[] { + const preferred = ['ParentId', 'Name', 'Visibility', 'Id'] as const; + return preferred.filter((key) => key in sample); +} + +export function getFindingLabelForCode(code: string | undefined): string { + if (!code) { + return ''; + } + return getPermissionExportFindingDefinition(code)?.label ?? ''; +} + +export interface FindingCodeDisplayParts { + /** Catalog label when the code is known; otherwise the raw value (or "(no code)"). */ + title: string; + /** Raw exporter `code` for muted parentheses when a catalog label exists. */ + technicalCode: string | null; +} + +/** + * Splits an issue `code` into a user-facing title vs optional technical identifier. + */ +export function getFindingCodeDisplayParts(code: string | undefined): FindingCodeDisplayParts { + const raw = typeof code === 'string' ? code.trim() : ''; + if (!raw) { + return { title: '(no code)', technicalCode: null }; + } + const catalogLabel = getFindingLabelForCode(raw); + if (catalogLabel.length > 0) { + return { title: catalogLabel, technicalCode: raw }; + } + return { title: raw, technicalCode: null }; +} + +export interface PermissionFindingCodeRollup { + code: string; + count: number; + errorCount: number; + warningCount: number; + label: string; +} + +export interface PermissionFindingObjectRollup { + objectApiName: string; + count: number; + errorCount: number; + warningCount: number; +} + +export interface AggregatePermissionFindingsResult { + byCode: PermissionFindingCodeRollup[]; + byObject: PermissionFindingObjectRollup[]; +} + +/** Rolls up the current issue list for summary tiles. */ +export function aggregatePermissionAnalysisFindings(findings: PermissionAnalysisFinding[]): AggregatePermissionFindingsResult { + const byCodeMap = new Map(); + const byObjectMap = new Map(); + + for (const row of findings) { + const codeRaw = String(row.code ?? '').trim(); + const code = codeRaw.length > 0 ? codeRaw : '(no code)'; + if (code === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + continue; + } + const objectKeyRaw = String(row.objectApiName ?? '').trim(); + const objectKey = objectKeyRaw.length > 0 ? objectKeyRaw : '(no object)'; + const isError = isErrorLikeSeverity(row.severity); + const isWarning = isWarningLikeSeverity(row.severity); + + const codeAgg = byCodeMap.get(code) ?? { count: 0, errors: 0, warnings: 0 }; + codeAgg.count += 1; + if (isError) { + codeAgg.errors += 1; + } + if (isWarning) { + codeAgg.warnings += 1; + } + byCodeMap.set(code, codeAgg); + + const objectAgg = byObjectMap.get(objectKey) ?? { count: 0, errors: 0, warnings: 0 }; + objectAgg.count += 1; + if (isError) { + objectAgg.errors += 1; + } + if (isWarning) { + objectAgg.warnings += 1; + } + byObjectMap.set(objectKey, objectAgg); + } + + const byCode: PermissionFindingCodeRollup[] = [...byCodeMap.entries()] + .map(([code, value]) => ({ + code, + count: value.count, + errorCount: value.errors, + warningCount: value.warnings, + label: getFindingLabelForCode(code === '(no code)' ? undefined : code), + })) + .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code)); + + const byObject: PermissionFindingObjectRollup[] = [...byObjectMap.entries()] + .map(([objectApiName, value]) => ({ + objectApiName, + count: value.count, + errorCount: value.errors, + warningCount: value.warnings, + })) + .sort((a, b) => b.count - a.count || a.objectApiName.localeCompare(b.objectApiName)); + + return { byCode, byObject }; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-parse-collect.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-parse-collect.ts new file mode 100644 index 000000000..38d5e75c3 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-parse-collect.ts @@ -0,0 +1,137 @@ +import type { + ParsedPermissionExportResult, + PermissionAnalysisFinding, + PermissionExportBundle, + PermissionExportRequestScope, + PermissionExportRow, +} from './export-result-types-labels'; + +export function collectSobjectApiNamesFromPermissionExport(exportBundle: PermissionExportBundle): string[] { + const names = new Set(); + for (const row of exportBundle.objectPermissions) { + const value = row.SobjectType; + if (typeof value === 'string' && value.trim().length > 0) { + names.add(value.trim()); + } + } + for (const row of exportBundle.fieldPermissions) { + const value = row.SobjectType; + if (typeof value === 'string' && value.trim().length > 0) { + names.add(value.trim()); + } + } + return [...names].sort((a, b) => a.localeCompare(b)); +} + +/** + * Unique tab API names from `PermissionSetTabSetting` rows (for Tooling `TabDefinition` enrichment). + */ +export function collectTabSettingNamesFromPermissionExport(exportBundle: PermissionExportBundle): string[] { + const names = new Set(); + for (const row of exportBundle.permissionSetTabSettings) { + const value = row.Name; + if (typeof value === 'string' && value.trim().length > 0) { + names.add(value.trim()); + } + } + return [...names].sort((a, b) => a.localeCompare(b)); +} + +function asRecord(value: unknown): Record | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +function stringIdArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((id): id is string => typeof id === 'string'); +} + +export function parsePermissionExportRequestScope(jobResult: unknown): PermissionExportRequestScope { + const root = asRecord(jobResult); + if (!root) { + return { profilePermissionSetIds: [], permissionSetIds: [], objectApiNames: [] }; + } + const payload = asRecord(root.requestPayload); + if (!payload) { + return { profilePermissionSetIds: [], permissionSetIds: [], objectApiNames: [] }; + } + return { + profilePermissionSetIds: stringIdArray(payload.profileIds), + permissionSetIds: stringIdArray(payload.permissionSetIds), + objectApiNames: stringIdArray(payload.objectApiNames), + }; +} + +export function filterPermissionSetExportRowsById( + rows: PermissionExportRow[], + permissionSetIds: ReadonlySet, +): PermissionExportRow[] { + if (permissionSetIds.size === 0) { + return []; + } + return rows.filter((row) => typeof row.Id === 'string' && permissionSetIds.has(row.Id)); +} + +function asRowArray(value: unknown): PermissionExportRow[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((row): row is PermissionExportRow => row !== null && typeof row === 'object' && !Array.isArray(row)); +} + +/** + * Normalizes `analysis_job.result` JSON for permission export jobs. + */ +export function parsePermissionExportResult(jobResult: unknown): ParsedPermissionExportResult | null { + const root = asRecord(jobResult); + if (!root) { + return null; + } + + const exportBlock = asRecord(root.export); + if (!exportBlock) { + return null; + } + + const countsRaw = asRecord(root.counts); + const counts: Record = {}; + if (countsRaw) { + for (const [key, value] of Object.entries(countsRaw)) { + if (typeof value === 'number' && Number.isFinite(value)) { + counts[key] = value; + } + } + } + + const findingsRaw = root.findings; + const findings: PermissionAnalysisFinding[] = Array.isArray(findingsRaw) + ? findingsRaw.filter((item): item is PermissionAnalysisFinding => item !== null && typeof item === 'object') + : []; + + return { + phase: root.phase != null ? String(root.phase) : null, + summary: root.summary != null ? String(root.summary) : null, + truncated: Boolean(root.truncated), + counts, + export: { + permissionSets: asRowArray(exportBlock.permissionSets), + permissionSetAssignments: asRowArray(exportBlock.permissionSetAssignments), + permissionSetGroups: asRowArray(exportBlock.permissionSetGroups), + permissionSetGroupComponents: asRowArray(exportBlock.permissionSetGroupComponents), + mutingPermissionSets: asRowArray(exportBlock.mutingPermissionSets), + objectPermissions: asRowArray(exportBlock.objectPermissions), + fieldPermissions: asRowArray(exportBlock.fieldPermissions), + permissionSetTabSettings: asRowArray(exportBlock.permissionSetTabSettings), + }, + findings, + issueCodeSummary: + root.issueCodeSummary != null && typeof root.issueCodeSummary === 'object' && !Array.isArray(root.issueCodeSummary) + ? (root.issueCodeSummary as Record) + : null, + }; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-sorting.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-sorting.ts new file mode 100644 index 000000000..8df0e5cdf --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-sorting.ts @@ -0,0 +1,226 @@ +import type { PermissionExportRow, SobjectExportDetail } from './export-result-types-labels'; +import { buildPermissionSetIdLabelMap, fieldPermissionQualifiedFieldShortApi } from './export-result-types-labels'; + +function isProfileOwnedPermissionSetRow(row: PermissionExportRow | undefined): boolean { + return row?.IsOwnedByProfile === true; +} + +/** + * Sorts `ObjectPermissions` export rows for the analysis tree: profile-owned parents first (label order), + * then other permission sets (label order), then rows for the same parent by object label (metadata label + * when {@link sobjectExportDetails} has it, else `SobjectType` API name). + */ +export function sortObjectPermissionExportRowsForAnalysisTree( + objectPermissionRows: PermissionExportRow[], + permissionSetRows: PermissionExportRow[], + sobjectExportDetails?: Readonly>, +): PermissionExportRow[] { + const labelByParentId = buildPermissionSetIdLabelMap(permissionSetRows); + const permissionSetById = new Map(); + for (const row of permissionSetRows) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (id) { + permissionSetById.set(id, row); + } + } + + function parentTier(parentId: string): number { + if (!parentId) { + return 2; + } + return isProfileOwnedPermissionSetRow(permissionSetById.get(parentId)) ? 0 : 1; + } + + function parentLabelCompareKey(parentId: string): string { + return labelByParentId.get(parentId) ?? parentId; + } + + function objectSortCompareKey(sobjectType: string): string { + const api = sobjectType.trim(); + if (!api) { + return ''; + } + const detail = sobjectExportDetails?.[api]; + const label = detail?.label?.trim() ? detail.label.trim() : api; + const primary = label.toLocaleLowerCase(); + const secondary = api.toLocaleLowerCase(); + return `${primary}\0${secondary}`; + } + + return [...objectPermissionRows].sort((rowA, rowB) => { + const parentA = typeof rowA.ParentId === 'string' ? rowA.ParentId.trim() : ''; + const parentB = typeof rowB.ParentId === 'string' ? rowB.ParentId.trim() : ''; + if (parentA !== parentB) { + const tierA = parentTier(parentA); + const tierB = parentTier(parentB); + if (tierA !== tierB) { + return tierA - tierB; + } + const labelCmp = parentLabelCompareKey(parentA).localeCompare(parentLabelCompareKey(parentB), undefined, { + sensitivity: 'base', + }); + if (labelCmp !== 0) { + return labelCmp; + } + return parentA.localeCompare(parentB, undefined, { sensitivity: 'base' }); + } + const objA = typeof rowA.SobjectType === 'string' ? rowA.SobjectType.trim() : ''; + const objB = typeof rowB.SobjectType === 'string' ? rowB.SobjectType.trim() : ''; + return objectSortCompareKey(objA).localeCompare(objectSortCompareKey(objB), undefined, { sensitivity: 'base' }); + }); +} + +/** + * Sorts `FieldPermissions` export rows: profile-owned parents first, then permission sets (by parent label + * from the export rows themselves), then object by `SobjectType` API name, then field by qualified `Field`. + * + * Sort keys are derived from row data only (no metadata) so the resulting order is stable across async + * label loads — async metadata responses update displayed labels in cells but never trigger a re-sort. + * + * Uses a decorate-sort-undecorate (Schwartzian) pattern — sort keys are computed once per row + * (O(N) trims/lookups/lowercases) and the comparator only compares primitives. + */ +export function sortFieldPermissionExportRowsForAnalysisTree( + fieldPermissionRows: PermissionExportRow[], + permissionSetRows: PermissionExportRow[], +): PermissionExportRow[] { + const labelByParentId = buildPermissionSetIdLabelMap(permissionSetRows); + const permissionSetById = new Map(); + for (const row of permissionSetRows) { + const id = typeof row.Id === 'string' ? row.Id : ''; + if (id) { + permissionSetById.set(id, row); + } + } + + const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); + + interface DecoratedRow { + row: PermissionExportRow; + tier: number; + parentId: string; + parentLabel: string; + objKey: string; + fieldKey: string; + } + + const decorated: DecoratedRow[] = new Array(fieldPermissionRows.length); + for (let i = 0; i < fieldPermissionRows.length; i++) { + const row = fieldPermissionRows[i]; + const parentId = typeof row.ParentId === 'string' ? row.ParentId : ''; + const tier = !parentId ? 2 : isProfileOwnedPermissionSetRow(permissionSetById.get(parentId)) ? 0 : 1; + const parentLabel = (labelByParentId.get(parentId) ?? parentId).toLocaleLowerCase(); + + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType : ''; + const objKey = sobjectType.toLocaleLowerCase(); + + const fieldFull = typeof row.Field === 'string' ? row.Field : ''; + const fieldShort = fieldPermissionQualifiedFieldShortApi(row); + const fieldKey = (fieldShort || fieldFull).toLocaleLowerCase(); + + decorated[i] = { row, tier, parentId, parentLabel, objKey, fieldKey }; + } + + decorated.sort((a, b) => { + if (a.parentId !== b.parentId) { + if (a.tier !== b.tier) { + return a.tier - b.tier; + } + const labelCmp = collator.compare(a.parentLabel, b.parentLabel); + if (labelCmp !== 0) { + return labelCmp; + } + return collator.compare(a.parentId, b.parentId); + } + const objCmp = collator.compare(a.objKey, b.objKey); + if (objCmp !== 0) { + return objCmp; + } + return collator.compare(a.fieldKey, b.fieldKey); + }); + + const sorted: PermissionExportRow[] = new Array(decorated.length); + for (let i = 0; i < decorated.length; i++) { + sorted[i] = decorated[i].row; + } + return sorted; +} + +/** + * User-facing copy for `PermissionSetTabSetting.Visibility` on analysis grids. + */ +export function formatTabSettingVisibilityDisplay(value: unknown): string { + const raw = typeof value === 'string' ? value.trim() : ''; + if (raw === 'DefaultOn') { + return 'Visible'; + } + if (raw === 'DefaultOff') { + return 'Hidden'; + } + if (raw.length === 0) { + return '—'; + } + return raw; +} + +/** + * Sorts `PermissionSetTabSetting` export rows for the analysis tree: profile-owned parents first (label order), + * then other permission sets (label order), then by tab display label (or {@link Name}) within the same {@link ParentId}. + * + * @param tabLabelBySettingName Optional `TabDefinition.Name` → `Label` map from Tooling (enriches sort when loaded). + */ +export function sortTabSettingExportRowsForAnalysisTree( + tabSettingRows: PermissionExportRow[], + permissionSetRows: PermissionExportRow[], + tabLabelBySettingName?: ReadonlyMap, +): PermissionExportRow[] { + const labelByParentId = buildPermissionSetIdLabelMap(permissionSetRows); + const permissionSetById = new Map(); + for (const row of permissionSetRows) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (id) { + permissionSetById.set(id, row); + } + } + + function parentTier(parentId: string): number { + if (!parentId) { + return 2; + } + return isProfileOwnedPermissionSetRow(permissionSetById.get(parentId)) ? 0 : 1; + } + + function parentLabelCompareKey(parentId: string): string { + return labelByParentId.get(parentId) ?? parentId; + } + + function tabSortCompareKey(row: PermissionExportRow): string { + const name = typeof row.Name === 'string' ? row.Name.trim() : ''; + const label = tabLabelBySettingName?.get(name)?.trim(); + const primary = (label && label.length > 0 ? label : name).toLocaleLowerCase(); + const secondary = name.toLocaleLowerCase(); + return `${primary}\0${secondary}`; + } + + return [...tabSettingRows].sort((rowA, rowB) => { + const parentA = typeof rowA.ParentId === 'string' ? rowA.ParentId.trim() : ''; + const parentB = typeof rowB.ParentId === 'string' ? rowB.ParentId.trim() : ''; + if (parentA !== parentB) { + const tierA = parentTier(parentA); + const tierB = parentTier(parentB); + if (tierA !== tierB) { + return tierA - tierB; + } + const labelCmp = parentLabelCompareKey(parentA).localeCompare(parentLabelCompareKey(parentB), undefined, { + sensitivity: 'base', + }); + if (labelCmp !== 0) { + return labelCmp; + } + return parentA.localeCompare(parentB, undefined, { sensitivity: 'base' }); + } + const keyA = tabSortCompareKey(rowA); + const keyB = tabSortCompareKey(rowB); + return keyA.localeCompare(keyB, undefined, { sensitivity: 'base' }); + }); +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-types-labels.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-types-labels.ts new file mode 100644 index 000000000..3f793a50b --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-types-labels.ts @@ -0,0 +1,141 @@ +export type PermissionExportRow = Record; + +export interface PermissionExportBundle { + permissionSets: PermissionExportRow[]; + permissionSetAssignments: PermissionExportRow[]; + permissionSetGroups: PermissionExportRow[]; + permissionSetGroupComponents: PermissionExportRow[]; + mutingPermissionSets: PermissionExportRow[]; + objectPermissions: PermissionExportRow[]; + fieldPermissions: PermissionExportRow[]; + permissionSetTabSettings: PermissionExportRow[]; +} + +/** Describe + EntityDefinition metadata for permission export SobjectType cells. */ +export interface SobjectExportDetail { + apiName: string; + label: string; + description: string | null; +} + +/** Tooling `FieldDefinition` metadata for permission export field cells (label, setup link). */ +export interface FieldExportDetail { + objectApiName: string; + qualifiedApiName: string; + label: string; + description: string | null; + /** Tooling `FieldDefinition.DurableId` for Lightning Fields & Relationships deep link when present. */ + durableId: string | null; +} + +/** + * Stable lookup key for {@link FieldExportDetail} maps: `objectApiName::qualifiedApiName` (short field API name). + */ +export function fieldExportDetailLookupKey(objectApiName: string, qualifiedApiName: string): string { + return `${objectApiName.trim()}::${qualifiedApiName.trim()}`; +} + +/** + * Short field API name from a `FieldPermissions` export row (`Field` is usually `ObjectApi.FieldApi`). + */ +export function fieldPermissionQualifiedFieldShortApi(row: PermissionExportRow): string { + const obj = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const full = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!obj || !full) { + return ''; + } + const prefix = `${obj}.`; + if (full.startsWith(prefix)) { + return full.slice(prefix.length); + } + const dot = full.lastIndexOf('.'); + return dot >= 0 ? full.slice(dot + 1) : full; +} + +/** Field-level permission booleans in the same order as the object-permissions subset (Read, Edit). */ +export const FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS: readonly string[] = ['PermissionsRead', 'PermissionsEdit']; + +/** Object column copy in issue detail modals when describe metadata exists. */ +export function formatObjectLabelForModalSummary( + apiName: string, + sobjectExportDetails: Record | undefined, +): { displayLabel: string; showApiInParens: boolean } { + const api = apiName.trim(); + if (!api) { + return { displayLabel: '', showApiInParens: false }; + } + const metadataLabel = sobjectExportDetails?.[api]?.label?.trim(); + if (metadataLabel && metadataLabel !== api) { + return { displayLabel: metadataLabel, showApiInParens: true }; + } + return { displayLabel: api, showApiInParens: false }; +} + +function permissionSetExportRowLabel(row: PermissionExportRow): string { + const label = typeof row.Label === 'string' && row.Label.trim() ? row.Label.trim() : null; + const name = typeof row.Name === 'string' && row.Name.trim() ? row.Name.trim() : null; + const profileBlock = row.Profile; + const profileName = + profileBlock && + typeof profileBlock === 'object' && + profileBlock !== null && + typeof (profileBlock as { Name?: unknown }).Name === 'string' + ? String((profileBlock as { Name: string }).Name).trim() + : null; + const isProfile = row.IsOwnedByProfile === true; + if (isProfile && profileName) { + return `Profile: ${profileName}`; + } + return label ?? name ?? (typeof row.Id === 'string' ? row.Id : 'Permission set'); +} + +/** Map permission set `Id` to a short display label (profiles, permission sets tab, modals). */ +export function buildPermissionSetIdLabelMap(permissionSetRows: PermissionExportRow[]): Map { + const map = new Map(); + for (const row of permissionSetRows) { + const id = row.Id; + if (typeof id !== 'string' || id.trim().length === 0) { + continue; + } + map.set(id.trim(), permissionSetExportRowLabel(row)); + } + return map; +} + +export interface PermissionAnalysisFinding { + severity?: string; + code?: string; + message?: string; + objectApiName?: string; + fieldApiName?: string; + permissionSetId?: string; + parentId?: string; + containerId?: string; + [key: string]: unknown; +} + +export interface ParsedPermissionExportResult { + phase: string | null; + summary: string | null; + truncated: boolean; + counts: Record; + export: PermissionExportBundle; + findings: PermissionAnalysisFinding[]; + issueCodeSummary: Record | null; +} + +/** + * IDs from the job's `requestPayload` (see permission export analysis jobs). + * `profileIds` are the profile **PermissionSet** Ids chosen on the selection screen, not Profile Ids. + */ +export interface PermissionExportRequestScope { + profilePermissionSetIds: string[]; + permissionSetIds: string[]; + /** When non-empty, the export job limited ObjectPermissions / FieldPermissions rows to these `SobjectType` values. */ + objectApiNames: string[]; +} + +export interface BuildDynamicExportColumnsOptions { + /** Row keys to exclude from the grid (e.g. REST `attributes`, `Id`, `ParentId` on object permission rows). */ + omitColumnKeys?: ReadonlySet; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/index.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/index.ts new file mode 100644 index 000000000..f6a35f5d5 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/index.ts @@ -0,0 +1,2 @@ +export * from './export-result-core'; +export * from './export-result-findings'; diff --git a/libs/features/manage-permissions/src/permission-export-result-view.ts b/libs/features/manage-permissions/src/permission-export-result-view.ts new file mode 100644 index 000000000..594783d13 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view.ts @@ -0,0 +1,5 @@ +/** + * Vite resolves `./permission-export-result-view` to this file before the directory. + * Implementation modules live under `./permission-export-result-view-modules/`. + */ +export * from './permission-export-result-view-modules/index'; diff --git a/libs/features/manage-permissions/src/permission-export/__tests__/build-permission-export-findings.spec.ts b/libs/features/manage-permissions/src/permission-export/__tests__/build-permission-export-findings.spec.ts new file mode 100644 index 000000000..e9e8c58fb --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/__tests__/build-permission-export-findings.spec.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import { buildIssueCodeSummary, buildPermissionExportFindings } from '../build-permission-export-findings'; + +describe('buildPermissionExportFindings', () => { + it('returns empty when there are no permission rows', () => { + expect(buildPermissionExportFindings([], [])).toEqual([]); + }); + + it('emits FLS_READ_NO_OBJECT_READ when field Read is true but object does not grant effective read', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + PermissionsRead: false, + PermissionsViewAllRecords: false, + PermissionsModifyAllRecords: false, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsRead: true, + }, + ]; + const findings = buildPermissionExportFindings(objectPermissions, fieldPermissions); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('FLS_READ_NO_OBJECT_READ'); + expect(findings[0].severity).toBe('error'); + expect(findings[0].objectApiName).toBe('Account'); + expect(findings[0].fieldApiName).toBe('Account.Name'); + }); + + it('does not emit FLS_READ_NO_OBJECT_READ when object grants View All Records without Read', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + PermissionsRead: false, + PermissionsViewAllRecords: true, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsRead: true, + }, + ]; + expect(buildPermissionExportFindings(objectPermissions, fieldPermissions)).toHaveLength(0); + }); + + it('emits FLS_EDIT_NO_OBJECT_EDIT when field Edit is true but object does not grant effective edit', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Contact', + PermissionsEdit: false, + PermissionsModifyAllRecords: false, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Contact', + Field: 'Contact.FirstName', + PermissionsEdit: true, + }, + ]; + const findings = buildPermissionExportFindings(objectPermissions, fieldPermissions); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('FLS_EDIT_NO_OBJECT_EDIT'); + }); + + it('does not emit FLS_EDIT_NO_OBJECT_EDIT when object grants Modify All Records without Edit', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + PermissionsEdit: false, + PermissionsModifyAllRecords: true, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsEdit: true, + }, + ]; + expect(buildPermissionExportFindings(objectPermissions, fieldPermissions)).toHaveLength(0); + }); + + it('emits FLS_WITHOUT_OLS_ROW when field rows exist but there is no object permission row', () => { + const parentId = '0PS000000000001'; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Case', + Field: 'Case.Subject', + PermissionsRead: true, + }, + ]; + const findings = buildPermissionExportFindings([], fieldPermissions); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('FLS_WITHOUT_OLS_ROW'); + expect(findings[0].objectApiName).toBe('Case'); + }); + + it('does not emit per-field FLS_READ when missing OLS row (FLS_WITHOUT_OLS_ROW covers the case)', () => { + const parentId = '0PS000000000001'; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Lead', + Field: 'Lead.Name', + PermissionsRead: true, + }, + ]; + const findings = buildPermissionExportFindings([], fieldPermissions); + expect(findings.map((f) => f.code)).toEqual(['FLS_WITHOUT_OLS_ROW']); + }); + + it('emits OLS_READ_NO_FLS_ROWS when object Read is true and there are no field rows for that parent+object', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Opportunity', + PermissionsRead: true, + }, + ]; + const findings = buildPermissionExportFindings(objectPermissions, []); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('OLS_READ_NO_FLS_ROWS'); + expect(findings[0].severity).toBe('warning'); + }); + + it('emits OLS_EDIT_NO_FLS_ROWS when object Edit is true and there are no field rows', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Task', + PermissionsEdit: true, + PermissionsRead: false, + }, + ]; + const findings = buildPermissionExportFindings(objectPermissions, []); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('OLS_EDIT_NO_FLS_ROWS'); + }); + + it('does not emit OLS_READ_NO_FLS_ROWS when at least one field permission exists for the object', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + PermissionsRead: true, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsRead: false, + }, + ]; + expect(buildPermissionExportFindings(objectPermissions, fieldPermissions)).toHaveLength(0); + }); +}); + +describe('buildIssueCodeSummary', () => { + it('aggregates counts by code', () => { + const summary = buildIssueCodeSummary([ + { code: 'FLS_READ_NO_OBJECT_READ', severity: 'error' }, + { code: 'FLS_READ_NO_OBJECT_READ', severity: 'error' }, + { code: 'OLS_READ_NO_FLS_ROWS', severity: 'warning' }, + ]); + expect(summary.FLS_READ_NO_OBJECT_READ).toEqual({ count: 2, errors: 2, warnings: 0 }); + expect(summary.OLS_READ_NO_FLS_ROWS).toEqual({ count: 1, errors: 0, warnings: 1 }); + }); +}); diff --git a/libs/features/manage-permissions/src/permission-export/__tests__/run-permission-export.spec.ts b/libs/features/manage-permissions/src/permission-export/__tests__/run-permission-export.spec.ts new file mode 100644 index 000000000..fe9b93451 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/__tests__/run-permission-export.spec.ts @@ -0,0 +1,183 @@ +import type { SalesforceOrgUi } from '@jetstream/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runPermissionExport } from '../run-permission-export'; + +const { mockedQuery, mockedQueryMore } = vi.hoisted(() => ({ + mockedQuery: vi.fn(), + mockedQueryMore: vi.fn(), +})); + +vi.mock('@jetstream/shared/data', async (importOriginal) => { + const actual = await importOriginal(); + async function queryWithRecordBudget( + org: unknown, + soql: string, + isTooling: boolean, + budget: { remaining: number }, + onPage: (records: Record[]) => void, + ): Promise<{ truncated: boolean }> { + let response = await mockedQuery(org, soql, isTooling); + while (true) { + const records = response.queryResults.records as Record[]; + if (budget.remaining <= 0) { + return { truncated: true }; + } + if (records.length > budget.remaining) { + onPage(records.slice(0, budget.remaining)); + budget.remaining = 0; + return { truncated: true }; + } + onPage(records); + budget.remaining -= records.length; + if (response.queryResults.done) { + break; + } + const nextUrl = response.queryResults.nextRecordsUrl; + if (!nextUrl) { + break; + } + response = await mockedQueryMore(org, nextUrl, isTooling); + } + return { truncated: false }; + } + return { + ...actual, + query: mockedQuery, + queryMore: mockedQueryMore, + queryWithRecordBudget, + }; +}); + +function done(records: T[]) { + return { + queryResults: { + records, + done: true, + totalSize: records.length, + }, + } as any; +} + +const PROFILE_PERM_SET_ID = '0PS000000000001'; +const PERM_SET_ID = '0PS000000000002'; +const GROUP_ID = '0PG000000000001'; + +const ORG = { uniqueId: 'org-1' } as unknown as SalesforceOrgUi; + +describe('runPermissionExport', () => { + beforeEach(() => { + mockedQuery.mockReset(); + mockedQueryMore.mockReset(); + }); + + it('returns an empty merged result when no valid parent ids are provided', async () => { + const result = await runPermissionExport(ORG, [], []); + expect(mockedQuery).not.toHaveBeenCalled(); + expect(result.truncated).toBe(false); + expect(result.full.counts).toEqual({ + permissionSets: 0, + permissionSetAssignments: 0, + permissionSetGroups: 0, + permissionSetGroupComponents: 0, + mutingPermissionSets: 0, + objectPermissions: 0, + fieldPermissions: 0, + permissionSetTabSettings: 0, + }); + expect(result.full.findings).toEqual([]); + expect(result.full.summary).toContain('Exported 0 permission sets'); + }); + + it('aggregates query results and builds the merged PermissionExportFullResult shape', async () => { + const permissionSetRows = [ + { Id: PROFILE_PERM_SET_ID, Name: 'Admin Profile' }, + { Id: PERM_SET_ID, Name: 'CustomPermSet' }, + ]; + const objectPermissionRows = [ + { + Id: '01p000000000001', + ParentId: PERM_SET_ID, + SobjectType: 'Account', + PermissionsRead: true, + PermissionsCreate: true, + PermissionsEdit: true, + PermissionsDelete: false, + PermissionsViewAllRecords: false, + PermissionsModifyAllRecords: false, + PermissionsViewAllFields: false, + }, + ]; + const fieldPermissionRows = [ + { + Id: '01k000000000001', + ParentId: PERM_SET_ID, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsRead: true, + PermissionsEdit: true, + }, + ]; + const tabSettingRows = [{ Id: '0t0000000000001', ParentId: PERM_SET_ID, Name: 'Account', Visibility: 'DefaultOn' }]; + const assignmentRows = [{ Id: '0Pa000000000001', PermissionSetId: PERM_SET_ID, AssigneeId: '005000000000001' }]; + const componentRows = [{ Id: '0PC000000000001', PermissionSetGroupId: GROUP_ID, PermissionSetId: PERM_SET_ID }]; + const groupRows = [{ Id: GROUP_ID, DeveloperName: 'GroupA', MasterLabel: 'Group A' }]; + const mutingRows: Record[] = []; + + // The order of queries matches the algorithm: permission sets, then (per parent chunk) object, + // field, tabs, assignments, components, then (per group chunk) group + muting. + mockedQuery + .mockResolvedValueOnce(done(permissionSetRows)) + .mockResolvedValueOnce(done(objectPermissionRows)) + .mockResolvedValueOnce(done(fieldPermissionRows)) + .mockResolvedValueOnce(done(tabSettingRows)) + .mockResolvedValueOnce(done(assignmentRows)) + .mockResolvedValueOnce(done(componentRows)) + .mockResolvedValueOnce(done(groupRows)) + .mockResolvedValueOnce(done(mutingRows)); + + const onProgress = vi.fn(); + + const result = await runPermissionExport(ORG, [PROFILE_PERM_SET_ID], [PERM_SET_ID], { onProgress }); + + expect(result.truncated).toBe(false); + expect(result.full.counts).toEqual({ + permissionSets: 2, + permissionSetAssignments: 1, + permissionSetGroups: 1, + permissionSetGroupComponents: 1, + mutingPermissionSets: 0, + objectPermissions: 1, + fieldPermissions: 1, + permissionSetTabSettings: 1, + }); + + expect(result.full.permissionSets).toEqual(permissionSetRows); + expect(result.full.objectPermissions).toEqual(objectPermissionRows); + expect(result.full.fieldPermissions).toEqual(fieldPermissionRows); + expect(result.full.permissionSetTabSettings).toEqual(tabSettingRows); + expect(result.full.permissionSetAssignments).toEqual(assignmentRows); + expect(result.full.permissionSetGroupComponents).toEqual(componentRows); + expect(result.full.permissionSetGroups).toEqual(groupRows); + expect(result.full.mutingPermissionSets).toEqual(mutingRows); + + expect(result.full.phase).toBe('permission_export_v1'); + expect(result.full.summary).toBe( + 'Exported 2 permission sets, 1 assignments, 1 permission set groups (1 components, 0 muting permission sets), 1 object permission rows, 1 field permission rows, 1 tab settings (truncated=false). 0 issue(s).', + ); + + expect(result.full.requestPayload).toEqual({ + profileIds: [PROFILE_PERM_SET_ID], + permissionSetIds: [PERM_SET_ID], + }); + + expect(onProgress).toHaveBeenCalled(); + const lastProgressCall = onProgress.mock.calls.at(-1)?.[0]; + expect(lastProgressCall?.label).toBe('Complete'); + expect(lastProgressCall?.percent).toBe(100); + }); + + it('throws when isCanceled returns true before queries run', async () => { + await expect(runPermissionExport(ORG, [PROFILE_PERM_SET_ID], [], { isCanceled: () => true })).rejects.toThrow('Job canceled'); + expect(mockedQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/features/manage-permissions/src/permission-export/__tests__/soql-templates.spec.ts b/libs/features/manage-permissions/src/permission-export/__tests__/soql-templates.spec.ts new file mode 100644 index 000000000..23c5de4d2 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/__tests__/soql-templates.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + buildFieldPermissionsByParentSoql, + buildMutingPermissionSetsByGroupSoql, + buildObjectPermissionsByParentSoql, + buildPermissionSetAssignmentsByPermissionSetSoql, + buildPermissionSetByIdSoql, + buildPermissionSetGroupByIdSoql, + buildPermissionSetGroupComponentsByPermissionSetSoql, + buildTabSettingsByParentSoql, +} from '../soql-templates'; + +describe('soql-templates permission export', () => { + it('buildPermissionSetByIdSoql composes a PermissionSet IN clause', () => { + const soql = buildPermissionSetByIdSoql(['p1', 'p2']); + expect(soql).toContain('FROM PermissionSet'); + expect(soql).toContain("Id IN ('p1', 'p2')"); + }); + + it('buildObjectPermissionsByParentSoql composes parent IN with no object filter', () => { + const soql = buildObjectPermissionsByParentSoql(['p1']); + expect(soql).toContain('FROM ObjectPermissions'); + expect(soql).toContain("ParentId IN ('p1')"); + expect(soql).not.toContain('SobjectType IN'); + }); + + it('buildObjectPermissionsByParentSoql adds optional SobjectType filter', () => { + const soql = buildObjectPermissionsByParentSoql(['p1'], ['Account', 'Case']); + expect(soql).toContain("ParentId IN ('p1')"); + expect(soql).toContain("SobjectType IN ('Account', 'Case')"); + expect(soql).toContain(' AND '); + }); + + it('buildFieldPermissionsByParentSoql adds optional SobjectType filter', () => { + const soql = buildFieldPermissionsByParentSoql(['p1'], ['Foo__c']); + expect(soql).toContain('FROM FieldPermissions'); + expect(soql).toContain("ParentId IN ('p1')"); + expect(soql).toContain("SobjectType IN ('Foo__c')"); + }); + + it('buildTabSettingsByParentSoql composes a PermissionSetTabSetting query', () => { + const soql = buildTabSettingsByParentSoql(['p1', 'p2']); + expect(soql).toContain('FROM PermissionSetTabSetting'); + expect(soql).toContain("ParentId IN ('p1', 'p2')"); + }); + + it('buildPermissionSetAssignmentsByPermissionSetSoql composes assignment query', () => { + const soql = buildPermissionSetAssignmentsByPermissionSetSoql(['p1']); + expect(soql).toContain('FROM PermissionSetAssignment'); + expect(soql).toContain("PermissionSetId IN ('p1')"); + }); + + it('buildPermissionSetGroupComponentsByPermissionSetSoql composes group component query', () => { + const soql = buildPermissionSetGroupComponentsByPermissionSetSoql(['p1']); + expect(soql).toContain('FROM PermissionSetGroupComponent'); + expect(soql).toContain("PermissionSetId IN ('p1')"); + }); + + it('buildPermissionSetGroupByIdSoql composes group query', () => { + const soql = buildPermissionSetGroupByIdSoql(['g1']); + expect(soql).toContain('FROM PermissionSetGroup'); + expect(soql).toContain("Id IN ('g1')"); + }); + + it('buildMutingPermissionSetsByGroupSoql composes muting permission set query', () => { + const soql = buildMutingPermissionSetsByGroupSoql(['g1']); + expect(soql).toContain('FROM MutingPermissionSet'); + expect(soql).toContain("PermissionSetGroupId IN ('g1')"); + }); +}); diff --git a/libs/features/manage-permissions/src/permission-export/build-permission-export-findings.ts b/libs/features/manage-permissions/src/permission-export/build-permission-export-findings.ts new file mode 100644 index 000000000..a31ee2f9a --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/build-permission-export-findings.ts @@ -0,0 +1,239 @@ +/** Derives permission analysis issues from exported ObjectPermissions and FieldPermissions rows. */ + +import { PERMISSION_EXPORT_FINDING_DEFINITIONS, PermissionExportFindingCode } from '@jetstream/shared/constants'; + +export const MAX_PERMISSION_EXPORT_FINDINGS = 8_000; + +export type PermissionExportFindingRecord = Record; + +function readTrimmedString(row: Record, key: string): string { + const value = row[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +function readBooleanTrue(row: Record, key: string): boolean { + const value = row[key]; + return value === true || value === 'true'; +} + +function objectPermissionKey(parentId: string, sobjectType: string): string { + return `${parentId}::${sobjectType}`; +} + +/** + * Object-level read path for FLS alignment: Read, View All Records, or Modify All Records. + */ +function objectGrantsEffectiveRead(row: Record): boolean { + return ( + readBooleanTrue(row, 'PermissionsRead') || + readBooleanTrue(row, 'PermissionsViewAllRecords') || + readBooleanTrue(row, 'PermissionsModifyAllRecords') + ); +} + +/** + * Object-level edit path for FLS alignment: Edit or Modify All Records. + */ +function objectGrantsEffectiveEdit(row: Record): boolean { + return readBooleanTrue(row, 'PermissionsEdit') || readBooleanTrue(row, 'PermissionsModifyAllRecords'); +} + +/** + * Builds deterministic issue rows from SOQL export payloads. + * + * @param objectPermissions ObjectPermissions rows keyed by ParentId + SobjectType. + * @param fieldPermissions FieldPermissions rows for the same permission sets. + * @returns Flat list suitable for `analysis_job.result.findings`. + */ +export function buildPermissionExportFindings( + objectPermissions: Record[], + fieldPermissions: Record[], +): PermissionExportFindingRecord[] { + const findings: PermissionExportFindingRecord[] = []; + let suppressedAfterCap = 0; + + const tryPush = (finding: PermissionExportFindingRecord): void => { + if (findings.length < MAX_PERMISSION_EXPORT_FINDINGS) { + findings.push(finding); + return; + } + suppressedAfterCap += 1; + }; + + const objectRowByKey = new Map>(); + for (const row of objectPermissions) { + if (!row || typeof row !== 'object') { + continue; + } + const parentId = readTrimmedString(row, 'ParentId'); + const sobjectType = readTrimmedString(row, 'SobjectType'); + if (!parentId || !sobjectType) { + continue; + } + objectRowByKey.set(objectPermissionKey(parentId, sobjectType), row); + } + + const fieldCountByParentObject = new Map(); + const fieldParentObjectKeys = new Set(); + for (const row of fieldPermissions) { + if (!row || typeof row !== 'object') { + continue; + } + const parentId = readTrimmedString(row, 'ParentId'); + const sobjectType = readTrimmedString(row, 'SobjectType'); + if (!parentId || !sobjectType) { + continue; + } + const key = objectPermissionKey(parentId, sobjectType); + fieldParentObjectKeys.add(key); + fieldCountByParentObject.set(key, (fieldCountByParentObject.get(key) ?? 0) + 1); + } + + for (const key of fieldParentObjectKeys) { + if (objectRowByKey.has(key)) { + continue; + } + const separatorIdx = key.indexOf('::'); + if (separatorIdx <= 0 || separatorIdx === key.length - 2) { + continue; + } + const parentId = key.slice(0, separatorIdx); + const sobjectType = key.slice(separatorIdx + 2); + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW, + message: `Field permissions exist for ${sobjectType}, but there is no ObjectPermissions row for the same permission set and object.`, + objectApiName: sobjectType, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } + + for (const fRow of fieldPermissions) { + if (!fRow || typeof fRow !== 'object') { + continue; + } + const parentId = readTrimmedString(fRow, 'ParentId'); + const sobjectType = readTrimmedString(fRow, 'SobjectType'); + const field = readTrimmedString(fRow, 'Field'); + if (!parentId || !sobjectType || !field) { + continue; + } + const objectRow = objectRowByKey.get(objectPermissionKey(parentId, sobjectType)); + if (!objectRow) { + continue; + } + if (readBooleanTrue(fRow, 'PermissionsRead') && !objectGrantsEffectiveRead(objectRow)) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + message: `Field ${field} on ${sobjectType} has Read at field level, but the object permission does not grant Read, View All Records, or Modify All Records.`, + objectApiName: sobjectType, + fieldApiName: field, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } + if (readBooleanTrue(fRow, 'PermissionsEdit') && !objectGrantsEffectiveEdit(objectRow)) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT, + message: `Field ${field} on ${sobjectType} has Edit at field level, but the object permission does not grant Edit or Modify All Records.`, + objectApiName: sobjectType, + fieldApiName: field, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } + } + + for (const oRow of objectPermissions) { + if (!oRow || typeof oRow !== 'object') { + continue; + } + const parentId = readTrimmedString(oRow, 'ParentId'); + const sobjectType = readTrimmedString(oRow, 'SobjectType'); + if (!parentId || !sobjectType) { + continue; + } + const key = objectPermissionKey(parentId, sobjectType); + const fieldCount = fieldCountByParentObject.get(key) ?? 0; + + if (readBooleanTrue(oRow, 'PermissionsRead') && fieldCount === 0) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, + message: `Object read is on for ${sobjectType}, but there are no field permission rows for this object on the same permission set.`, + objectApiName: sobjectType, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } + if (readBooleanTrue(oRow, 'PermissionsEdit') && fieldCount === 0) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.OLS_EDIT_NO_FLS_ROWS]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.OLS_EDIT_NO_FLS_ROWS, + message: `Object edit is on for ${sobjectType}, but there are no field permission rows for this object on the same permission set.`, + objectApiName: sobjectType, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } + } + + if (suppressedAfterCap > 0) { + const truncatedDef = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.FINDINGS_TRUNCATED]; + findings.push({ + severity: truncatedDef.severity, + code: PermissionExportFindingCode.FINDINGS_TRUNCATED, + message: `${suppressedAfterCap.toLocaleString()} additional issues were not included so the job result stays under ${MAX_PERMISSION_EXPORT_FINDINGS.toLocaleString()} rows. Narrow the permission set selection and re-run if you need full coverage.`, + objectApiName: undefined, + fieldApiName: undefined, + parentId: undefined, + permissionSetId: undefined, + containerId: undefined, + }); + } + + return findings; +} + +export interface IssueCodeSummaryEntry { + count: number; + errors: number; + warnings: number; +} + +/** + * Rolls up issues by `code` for `analysis_job.result.issueCodeSummary`. + */ +export function buildIssueCodeSummary(findings: PermissionExportFindingRecord[]): Record { + const summary: Record = {}; + for (const row of findings) { + const codeRaw = row.code; + const code = typeof codeRaw === 'string' && codeRaw.trim().length > 0 ? codeRaw.trim() : ''; + if (!code || code === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + continue; + } + const existing = summary[code] ?? { count: 0, errors: 0, warnings: 0 }; + existing.count += 1; + const severity = String(row.severity ?? '').toLowerCase(); + if (severity === 'error' || severity === 'errors') { + existing.errors += 1; + } else if (severity === 'warning' || severity === 'warnings') { + existing.warnings += 1; + } + summary[code] = existing; + } + return summary; +} diff --git a/libs/features/manage-permissions/src/permission-export/index.ts b/libs/features/manage-permissions/src/permission-export/index.ts new file mode 100644 index 000000000..4a80137f2 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/index.ts @@ -0,0 +1,5 @@ +export * from './build-permission-export-findings'; +export * from './permission-export-query-runner'; +export * from './run-permission-export'; +export * from './soql-templates'; +export * from './substitute-soql-placeholders'; diff --git a/libs/features/manage-permissions/src/permission-export/permission-export-query-runner.ts b/libs/features/manage-permissions/src/permission-export/permission-export-query-runner.ts new file mode 100644 index 000000000..7ce21e06a --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/permission-export-query-runner.ts @@ -0,0 +1,20 @@ +import { substituteSoqlPlaceholders } from './substitute-soql-placeholders'; + +export type PermissionExportQueryKind = 'data' | 'tooling'; + +/** + * Prepares SOQL from a template string for execution via Jetstream HTTP (not CLI). + * Server-side export jobs run equivalent queries from `apps/api/.../soql-templates.ts` + * (see `soql/*.soql` in this folder for reviewer-friendly copies). + * + * @param template Raw SOQL with `{{placeholders}}`. + * @param vars Placeholder values. + * @param kind Whether the query targets the Data or Tooling API (caller chooses transport). + */ +export function buildPermissionExportSoql( + template: string, + vars: Record, + _kind: PermissionExportQueryKind = 'data', +): string { + return substituteSoqlPlaceholders(template, vars); +} diff --git a/libs/features/manage-permissions/src/permission-export/run-permission-export.ts b/libs/features/manage-permissions/src/permission-export/run-permission-export.ts new file mode 100644 index 000000000..caa1f417d --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/run-permission-export.ts @@ -0,0 +1,409 @@ +import { queryWithRecordBudget } from '@jetstream/shared/data'; +import { sanitizeSobjectApiNames, splitArrayToMaxSize, uniqueSalesforceIds } from '@jetstream/shared/utils'; +import type { PermissionExportFullResult, SalesforceOrgUi } from '@jetstream/types'; +import { buildIssueCodeSummary, buildPermissionExportFindings } from './build-permission-export-findings'; +import { + buildFieldPermissionsByParentSoql, + buildMutingPermissionSetsByGroupSoql, + buildObjectPermissionsByParentSoql, + buildPermissionSetAssignmentsByPermissionSetSoql, + buildPermissionSetByIdSoql, + buildPermissionSetGroupByIdSoql, + buildPermissionSetGroupComponentsByPermissionSetSoql, + buildTabSettingsByParentSoql, +} from './soql-templates'; + +const PARENT_ID_CHUNK_SIZE = 200; +const GROUP_ID_CHUNK_SIZE = 200; +/** Keeps `SobjectType IN (...)` clauses within typical SOQL length limits when many objects are selected. */ +const OBJECT_SOBJECT_TYPE_IN_CHUNK_SIZE = 80; + +/** + * Per-category row caps. Each category has its own independent budget so that one heavy category + * (e.g. FieldPermissions on a large org) cannot silently starve later categories (Tab settings, + * Assignments, Groups, MutingPermissionSets) of their budget. Caps sum to ~200K worst case but in + * practice categories like Groups and TabSettings rarely use more than a few thousand rows. + */ +const CATEGORY_BUDGETS = { + permissionSets: 10_000, + objectPermissions: 50_000, + fieldPermissions: 100_000, + permissionSetTabSettings: 10_000, + permissionSetAssignments: 100_000, + permissionSetGroupComponents: 10_000, + permissionSetGroups: 10_000, + mutingPermissionSets: 10_000, +} as const; + +type ExportCategory = keyof typeof CATEGORY_BUDGETS; + +const CATEGORY_LABELS: Record = { + permissionSets: 'PermissionSet', + objectPermissions: 'ObjectPermissions', + fieldPermissions: 'FieldPermissions', + permissionSetTabSettings: 'PermissionSetTabSetting', + permissionSetAssignments: 'PermissionSetAssignment', + permissionSetGroupComponents: 'PermissionSetGroupComponent', + permissionSetGroups: 'PermissionSetGroup', + mutingPermissionSets: 'MutingPermissionSet', +}; + +export interface RunPermissionExportProgress { + current: number; + total: number; + percent: number; + label: string; +} + +export interface RunPermissionExportOptions { + /** Optional sobject scope for ObjectPermissions/FieldPermissions */ + objectApiNames?: unknown; + /** Called periodically with progress info; safe to be a no-op */ + onProgress?: (progress: RunPermissionExportProgress) => void; + /** Returns true if the caller wants to cancel; checked at chunk boundaries */ + isCanceled?: () => boolean; +} + +export interface RunPermissionExportResult { + truncated: boolean; + full: PermissionExportFullResult; +} + +function emptyResult(requestPayload?: PermissionExportFullResult['requestPayload']): RunPermissionExportResult { + const counts = { + permissionSets: 0, + permissionSetAssignments: 0, + permissionSetGroups: 0, + permissionSetGroupComponents: 0, + mutingPermissionSets: 0, + objectPermissions: 0, + fieldPermissions: 0, + permissionSetTabSettings: 0, + }; + return { + truncated: false, + full: { + ...(requestPayload ? { requestPayload } : {}), + phase: 'permission_export_v1', + summary: + 'Exported 0 permission sets, 0 assignments, 0 permission set groups (0 components, 0 muting permission sets), 0 object permission rows, 0 field permission rows, 0 tab settings (truncated=false). 0 issue(s).', + truncated: false, + counts, + findings: [], + issueCodeSummary: {}, + permissionSets: [], + permissionSetAssignments: [], + permissionSetGroups: [], + permissionSetGroupComponents: [], + mutingPermissionSets: [], + objectPermissions: [], + fieldPermissions: [], + permissionSetTabSettings: [], + }, + }; +} + +function throwIfCanceled(isCanceled: (() => boolean) | undefined): void { + if (isCanceled?.()) { + throw new Error('Job canceled'); + } +} + +/** + * Browser-side implementation of the permission export job. Issues many SOQL queries through + * `query`/`queryMore` (via `queryWithRecordBudget`), aggregates the rows in memory, and computes + * the same findings + issue-code summary the server processor used to produce. + * + * Mirrors the original server `runPermissionExportSoql` algorithm: PermissionSet first, then per + * parent-id chunk of 200 we fetch ObjectPermissions/FieldPermissions (optionally re-chunked by + * sobject type), Tab settings, Assignments, and Group components. Group ids harvested from the + * components query then drive PermissionSetGroup + MutingPermissionSet queries. + */ +export async function runPermissionExport( + org: SalesforceOrgUi, + profilePermissionSetIds: string[], + permissionSetIds: string[], + options?: RunPermissionExportOptions, +): Promise { + const requestPayload: PermissionExportFullResult['requestPayload'] = { + profileIds: profilePermissionSetIds, + permissionSetIds: permissionSetIds, + ...(options?.objectApiNames !== undefined ? { objectApiNames: options.objectApiNames } : {}), + }; + + const parentIds = uniqueSalesforceIds([...profilePermissionSetIds, ...permissionSetIds]); + const objectScope = sanitizeSobjectApiNames(options?.objectApiNames); + const objectTypeChunks: (string[] | undefined)[] = + objectScope.length === 0 ? [undefined] : splitArrayToMaxSize(objectScope, OBJECT_SOBJECT_TYPE_IN_CHUNK_SIZE); + + if (parentIds.length === 0) { + return emptyResult(requestPayload); + } + + const parentIdChunks = splitArrayToMaxSize(parentIds, PARENT_ID_CHUNK_SIZE); + + const budgets: Record = { + permissionSets: { remaining: CATEGORY_BUDGETS.permissionSets }, + objectPermissions: { remaining: CATEGORY_BUDGETS.objectPermissions }, + fieldPermissions: { remaining: CATEGORY_BUDGETS.fieldPermissions }, + permissionSetTabSettings: { remaining: CATEGORY_BUDGETS.permissionSetTabSettings }, + permissionSetAssignments: { remaining: CATEGORY_BUDGETS.permissionSetAssignments }, + permissionSetGroupComponents: { remaining: CATEGORY_BUDGETS.permissionSetGroupComponents }, + permissionSetGroups: { remaining: CATEGORY_BUDGETS.permissionSetGroups }, + mutingPermissionSets: { remaining: CATEGORY_BUDGETS.mutingPermissionSets }, + }; + const truncatedCategories = new Set(); + + const permissionSets: Record[] = []; + const permissionSetAssignments: Record[] = []; + const permissionSetGroupComponents: Record[] = []; + const permissionSetGroups: Record[] = []; + const mutingPermissionSets: Record[] = []; + const objectPermissions: Record[] = []; + const fieldPermissions: Record[] = []; + const permissionSetTabSettings: Record[] = []; + + // 1 for the initial PermissionSet query + 5 steps per parent chunk + 2 steps per group chunk (estimated upfront). + const initialTotal = 1 + parentIdChunks.length * 5; + let currentStep = 0; + let totalSteps = initialTotal; + + const emitProgress = (label: string): void => { + const percent = totalSteps === 0 ? 0 : Math.min(100, Math.round((currentStep / totalSteps) * 100)); + options?.onProgress?.({ current: currentStep, total: totalSteps, percent, label }); + }; + + emitProgress(`Loading ${parentIds.length} permission set(s)`); + + throwIfCanceled(options?.isCanceled); + const permSetResult = await queryWithRecordBudget>( + org, + buildPermissionSetByIdSoql(parentIds), + false, + budgets.permissionSets, + (page) => { + permissionSets.push(...page); + }, + ); + if (permSetResult.truncated) { + truncatedCategories.add('permissionSets'); + } + currentStep += 1; + emitProgress(`Loaded ${permissionSets.length} permission set(s)`); + + const permissionSetGroupIds = new Set(); + + for (let parentChunkIndex = 0; parentChunkIndex < parentIdChunks.length; parentChunkIndex++) { + const parentIdChunk = parentIdChunks[parentChunkIndex]; + const parentChunkLabel = parentIdChunks.length > 1 ? ` (batch ${parentChunkIndex + 1} of ${parentIdChunks.length})` : ''; + throwIfCanceled(options?.isCanceled); + + emitProgress(`Querying object permissions${parentChunkLabel}`); + for (const objectTypeChunk of objectTypeChunks) { + throwIfCanceled(options?.isCanceled); + if (budgets.objectPermissions.remaining <= 0) { + truncatedCategories.add('objectPermissions'); + break; + } + const objectResult = await queryWithRecordBudget>( + org, + buildObjectPermissionsByParentSoql(parentIdChunk, objectTypeChunk), + false, + budgets.objectPermissions, + (page) => { + objectPermissions.push(...page); + }, + ); + if (objectResult.truncated) { + truncatedCategories.add('objectPermissions'); + } + } + currentStep += 1; + + emitProgress(`Querying field permissions${parentChunkLabel}`); + for (const objectTypeChunk of objectTypeChunks) { + throwIfCanceled(options?.isCanceled); + if (budgets.fieldPermissions.remaining <= 0) { + truncatedCategories.add('fieldPermissions'); + break; + } + const fieldResult = await queryWithRecordBudget>( + org, + buildFieldPermissionsByParentSoql(parentIdChunk, objectTypeChunk), + false, + budgets.fieldPermissions, + (page) => { + fieldPermissions.push(...page); + }, + ); + if (fieldResult.truncated) { + truncatedCategories.add('fieldPermissions'); + } + } + currentStep += 1; + + throwIfCanceled(options?.isCanceled); + emitProgress(`Querying tab visibility settings${parentChunkLabel}`); + if (budgets.permissionSetTabSettings.remaining > 0) { + const tabResult = await queryWithRecordBudget>( + org, + buildTabSettingsByParentSoql(parentIdChunk), + false, + budgets.permissionSetTabSettings, + (page) => { + permissionSetTabSettings.push(...page); + }, + ); + if (tabResult.truncated) { + truncatedCategories.add('permissionSetTabSettings'); + } + } else { + truncatedCategories.add('permissionSetTabSettings'); + } + currentStep += 1; + + throwIfCanceled(options?.isCanceled); + emitProgress(`Querying user/group assignments${parentChunkLabel}`); + if (budgets.permissionSetAssignments.remaining > 0) { + const assignmentResult = await queryWithRecordBudget>( + org, + buildPermissionSetAssignmentsByPermissionSetSoql(parentIdChunk), + false, + budgets.permissionSetAssignments, + (page) => { + permissionSetAssignments.push(...page); + }, + ); + if (assignmentResult.truncated) { + truncatedCategories.add('permissionSetAssignments'); + } + } else { + truncatedCategories.add('permissionSetAssignments'); + } + currentStep += 1; + + throwIfCanceled(options?.isCanceled); + emitProgress(`Querying permission set group memberships${parentChunkLabel}`); + if (budgets.permissionSetGroupComponents.remaining > 0) { + const componentResult = await queryWithRecordBudget>( + org, + buildPermissionSetGroupComponentsByPermissionSetSoql(parentIdChunk), + false, + budgets.permissionSetGroupComponents, + (page) => { + for (const row of page) { + permissionSetGroupComponents.push(row); + const groupId = row.PermissionSetGroupId; + if (typeof groupId === 'string' && groupId.length > 0) { + permissionSetGroupIds.add(groupId); + } + } + }, + ); + if (componentResult.truncated) { + truncatedCategories.add('permissionSetGroupComponents'); + } + } else { + truncatedCategories.add('permissionSetGroupComponents'); + } + currentStep += 1; + } + + const sortedGroupIds = uniqueSalesforceIds([...permissionSetGroupIds]); + const groupIdChunks = sortedGroupIds.length === 0 ? [] : splitArrayToMaxSize(sortedGroupIds, GROUP_ID_CHUNK_SIZE); + // Now that we know how many group chunks there are, extend the total so progress reaches 100% accurately. + totalSteps = initialTotal + groupIdChunks.length * 2; + + for (let groupChunkIndex = 0; groupChunkIndex < groupIdChunks.length; groupChunkIndex++) { + const groupIdChunk = groupIdChunks[groupChunkIndex]; + const groupChunkLabel = groupIdChunks.length > 1 ? ` (batch ${groupChunkIndex + 1} of ${groupIdChunks.length})` : ''; + throwIfCanceled(options?.isCanceled); + + emitProgress(`Loading permission set group details${groupChunkLabel}`); + if (budgets.permissionSetGroups.remaining > 0) { + const groupResult = await queryWithRecordBudget>( + org, + buildPermissionSetGroupByIdSoql(groupIdChunk), + false, + budgets.permissionSetGroups, + (page) => { + permissionSetGroups.push(...page); + }, + ); + if (groupResult.truncated) { + truncatedCategories.add('permissionSetGroups'); + } + } else { + truncatedCategories.add('permissionSetGroups'); + } + currentStep += 1; + + throwIfCanceled(options?.isCanceled); + emitProgress(`Loading muting permission sets${groupChunkLabel}`); + if (budgets.mutingPermissionSets.remaining > 0) { + const mutingResult = await queryWithRecordBudget>( + org, + buildMutingPermissionSetsByGroupSoql(groupIdChunk), + false, + budgets.mutingPermissionSets, + (page) => { + mutingPermissionSets.push(...page); + }, + ); + if (mutingResult.truncated) { + truncatedCategories.add('mutingPermissionSets'); + } + } else { + truncatedCategories.add('mutingPermissionSets'); + } + currentStep += 1; + } + + const counts = { + permissionSets: permissionSets.length, + permissionSetAssignments: permissionSetAssignments.length, + permissionSetGroups: permissionSetGroups.length, + permissionSetGroupComponents: permissionSetGroupComponents.length, + mutingPermissionSets: mutingPermissionSets.length, + objectPermissions: objectPermissions.length, + fieldPermissions: fieldPermissions.length, + permissionSetTabSettings: permissionSetTabSettings.length, + }; + + const findings = buildPermissionExportFindings(objectPermissions, fieldPermissions); + const issueCodeSummary = buildIssueCodeSummary(findings); + + const truncated = truncatedCategories.size > 0; + const truncatedLabel = truncated + ? `truncated=true (${[...truncatedCategories].map((category) => CATEGORY_LABELS[category]).join(', ')} hit row cap)` + : 'truncated=false'; + const summary = + `Exported ${counts.permissionSets} permission sets, ${counts.permissionSetAssignments} assignments, ` + + `${counts.permissionSetGroups} permission set groups (${counts.permissionSetGroupComponents} components, ` + + `${counts.mutingPermissionSets} muting permission sets), ${counts.objectPermissions} object permission rows, ` + + `${counts.fieldPermissions} field permission rows, ${counts.permissionSetTabSettings} tab settings ` + + `(${truncatedLabel}). ${findings.length} issue(s).`; + + emitProgress('Complete'); + + return { + truncated, + full: { + requestPayload, + phase: 'permission_export_v1', + summary, + truncated, + counts, + findings, + issueCodeSummary, + permissionSets, + permissionSetAssignments, + permissionSetGroups, + permissionSetGroupComponents, + mutingPermissionSets, + objectPermissions, + fieldPermissions, + permissionSetTabSettings, + }, + }; +} diff --git a/libs/features/manage-permissions/src/permission-export/soql-templates.ts b/libs/features/manage-permissions/src/permission-export/soql-templates.ts new file mode 100644 index 000000000..efbd5e339 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql-templates.ts @@ -0,0 +1,179 @@ +import { composeQuery, getField, WhereClause } from '@jetstreamapp/soql-parser-js'; + +function whereSobjectTypeIn(objectTypes?: string[]): WhereClause | undefined { + if (!objectTypes || objectTypes.length === 0) { + return undefined; + } + return { + left: { + field: 'SobjectType', + operator: 'IN', + value: objectTypes, + literalType: 'STRING', + }, + }; +} + +export function buildPermissionSetByIdSoql(ids: string[]): string { + return composeQuery({ + fields: [ + getField('Id'), + getField('Name'), + getField('Label'), + getField('Description'), + getField('IsOwnedByProfile'), + getField('ProfileId'), + getField('Profile.Name'), + getField('CreatedDate'), + getField('LastModifiedDate'), + getField('CreatedBy.Name'), + getField('LastModifiedBy.Name'), + ], + sObject: 'PermissionSet', + where: { + left: { + field: 'Id', + operator: 'IN', + value: ids, + literalType: 'STRING', + }, + }, + }); +} + +export function buildObjectPermissionsByParentSoql(parentIds: string[], objectTypes?: string[]): string { + const objectTypeWhere = whereSobjectTypeIn(objectTypes); + return composeQuery({ + fields: [ + getField('Id'), + getField('ParentId'), + getField('SobjectType'), + getField('PermissionsRead'), + getField('PermissionsCreate'), + getField('PermissionsEdit'), + getField('PermissionsDelete'), + getField('PermissionsViewAllRecords'), + getField('PermissionsModifyAllRecords'), + getField('PermissionsViewAllFields'), + ], + sObject: 'ObjectPermissions', + where: { + left: { + field: 'ParentId', + operator: 'IN', + value: parentIds, + literalType: 'STRING', + }, + ...(objectTypeWhere + ? { + operator: 'AND', + right: objectTypeWhere, + } + : {}), + }, + }); +} + +export function buildFieldPermissionsByParentSoql(parentIds: string[], objectTypes?: string[]): string { + const objectTypeWhere = whereSobjectTypeIn(objectTypes); + return composeQuery({ + fields: [ + getField('Id'), + getField('ParentId'), + getField('SobjectType'), + getField('Field'), + getField('PermissionsRead'), + getField('PermissionsEdit'), + ], + sObject: 'FieldPermissions', + where: { + left: { + field: 'ParentId', + operator: 'IN', + value: parentIds, + literalType: 'STRING', + }, + ...(objectTypeWhere + ? { + operator: 'AND', + right: objectTypeWhere, + } + : {}), + }, + }); +} + +export function buildTabSettingsByParentSoql(parentIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('ParentId'), getField('Name'), getField('Visibility')], + sObject: 'PermissionSetTabSetting', + where: { + left: { + field: 'ParentId', + operator: 'IN', + value: parentIds, + literalType: 'STRING', + }, + }, + }); +} + +export function buildPermissionSetAssignmentsByPermissionSetSoql(permissionSetIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('PermissionSetId'), getField('AssigneeId')], + sObject: 'PermissionSetAssignment', + where: { + left: { + field: 'PermissionSetId', + operator: 'IN', + value: permissionSetIds, + literalType: 'STRING', + }, + }, + }); +} + +export function buildPermissionSetGroupComponentsByPermissionSetSoql(permissionSetIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('PermissionSetGroupId'), getField('PermissionSetId')], + sObject: 'PermissionSetGroupComponent', + where: { + left: { + field: 'PermissionSetId', + operator: 'IN', + value: permissionSetIds, + literalType: 'STRING', + }, + }, + }); +} + +export function buildPermissionSetGroupByIdSoql(groupIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('DeveloperName'), getField('MasterLabel')], + sObject: 'PermissionSetGroup', + where: { + left: { + field: 'Id', + operator: 'IN', + value: groupIds, + literalType: 'STRING', + }, + }, + }); +} + +export function buildMutingPermissionSetsByGroupSoql(groupIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('PermissionSetGroupId'), getField('DeveloperName'), getField('MasterLabel')], + sObject: 'MutingPermissionSet', + where: { + left: { + field: 'PermissionSetGroupId', + operator: 'IN', + value: groupIds, + literalType: 'STRING', + }, + }, + }); +} diff --git a/libs/features/manage-permissions/src/permission-export/soql/field-permissions-by-parent.soql b/libs/features/manage-permissions/src/permission-export/soql/field-permissions-by-parent.soql new file mode 100644 index 000000000..9dbef7979 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/field-permissions-by-parent.soql @@ -0,0 +1,6 @@ +-- Mirrors apps/api/src/app/lib/permission-export/soql-templates.ts (buildFieldPermissionsByParentSoql). +-- Server may append: AND SobjectType IN (...) when the job payload includes objectApiNames. +SELECT Id, ParentId, SobjectType, Field, + PermissionsRead, PermissionsEdit +FROM FieldPermissions +WHERE ParentId IN ({{PARENT_IDS}}) diff --git a/libs/features/manage-permissions/src/permission-export/soql/muting-permission-sets-by-group.soql b/libs/features/manage-permissions/src/permission-export/soql/muting-permission-sets-by-group.soql new file mode 100644 index 000000000..764dddaea --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/muting-permission-sets-by-group.soql @@ -0,0 +1,3 @@ +SELECT Id, PermissionSetGroupId, DeveloperName, MasterLabel +FROM MutingPermissionSet +WHERE PermissionSetGroupId IN (:groupIds) diff --git a/libs/features/manage-permissions/src/permission-export/soql/object-permissions-by-parent.soql b/libs/features/manage-permissions/src/permission-export/soql/object-permissions-by-parent.soql new file mode 100644 index 000000000..65940e89d --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/object-permissions-by-parent.soql @@ -0,0 +1,7 @@ +-- Mirrors apps/api/src/app/lib/permission-export/soql-templates.ts (buildObjectPermissionsByParentSoql). +-- Server may append: AND SobjectType IN (...) when the job payload includes objectApiNames. +SELECT Id, ParentId, SobjectType, + PermissionsRead, PermissionsCreate, PermissionsEdit, PermissionsDelete, + PermissionsViewAllRecords, PermissionsModifyAllRecords, PermissionsViewAllFields +FROM ObjectPermissions +WHERE ParentId IN ({{PARENT_IDS}}) diff --git a/libs/features/manage-permissions/src/permission-export/soql/permission-set-assignments-by-permission-set.soql b/libs/features/manage-permissions/src/permission-export/soql/permission-set-assignments-by-permission-set.soql new file mode 100644 index 000000000..84efa5873 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/permission-set-assignments-by-permission-set.soql @@ -0,0 +1,3 @@ +SELECT Id, PermissionSetId, AssigneeId +FROM PermissionSetAssignment +WHERE PermissionSetId IN (:parentIds) diff --git a/libs/features/manage-permissions/src/permission-export/soql/permission-set-by-id.soql b/libs/features/manage-permissions/src/permission-export/soql/permission-set-by-id.soql new file mode 100644 index 000000000..3ee328bb8 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/permission-set-by-id.soql @@ -0,0 +1,5 @@ +-- Mirrors apps/api/src/app/lib/permission-export/soql-templates.ts (buildPermissionSetByIdSoql). +SELECT Id, Name, Label, Description, IsOwnedByProfile, ProfileId, Profile.Name, + CreatedDate, LastModifiedDate, CreatedBy.Name, LastModifiedBy.Name +FROM PermissionSet +WHERE Id IN ({{PARENT_IDS}}) diff --git a/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-by-id.soql b/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-by-id.soql new file mode 100644 index 000000000..f4d5f11d1 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-by-id.soql @@ -0,0 +1,3 @@ +SELECT Id, DeveloperName, MasterLabel +FROM PermissionSetGroup +WHERE Id IN (:groupIds) diff --git a/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-components-by-permission-set.soql b/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-components-by-permission-set.soql new file mode 100644 index 000000000..6a5f24b21 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-components-by-permission-set.soql @@ -0,0 +1,3 @@ +SELECT Id, PermissionSetGroupId, PermissionSetId +FROM PermissionSetGroupComponent +WHERE PermissionSetId IN (:parentIds) diff --git a/libs/features/manage-permissions/src/permission-export/soql/tab-settings-by-parent.soql b/libs/features/manage-permissions/src/permission-export/soql/tab-settings-by-parent.soql new file mode 100644 index 000000000..57f0c8a70 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/tab-settings-by-parent.soql @@ -0,0 +1,5 @@ +-- Mirrors apps/api/src/app/lib/permission-export/soql-templates.ts (buildTabSettingsByParentSoql). +-- PermissionSetTabSetting: tab visibility (Name + Visibility) per permission set parent. +SELECT Id, ParentId, Name, Visibility +FROM PermissionSetTabSetting +WHERE ParentId IN ({{PARENT_IDS}}) diff --git a/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.spec.ts b/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.spec.ts new file mode 100644 index 000000000..d9f49eb61 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { substituteSoqlPlaceholders } from './substitute-soql-placeholders'; + +describe('substituteSoqlPlaceholders', () => { + it('replaces simple tokens', () => { + const soql = 'SELECT Id FROM PermissionSet WHERE Name IN ({{ permissionSetNames }})'; + const out = substituteSoqlPlaceholders(soql, { permissionSetNames: "'Foo','Bar'" }); + expect(out).toBe("SELECT Id FROM PermissionSet WHERE Name IN ('Foo','Bar')"); + }); +}); diff --git a/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.ts b/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.ts new file mode 100644 index 000000000..0ab9ff5a5 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.ts @@ -0,0 +1,16 @@ +/** + * Replaces `{{token}}` placeholders in SOQL templates (legacy `sf_permissions_analysis` style). + * + * @param soql SOQL string possibly containing `{{name}}` style placeholders. + * @param vars Map of placeholder name (without braces) to replacement text (already escaped for SOQL if needed). + */ +export function substituteSoqlPlaceholders(soql: string, vars: Record): string { + return Object.entries(vars).reduce((acc, [key, value]) => { + const pattern = new RegExp(`\\{\\{\\s*${escapeRegExp(key)}\\s*\\}\\}`, 'g'); + return acc.replace(pattern, value); + }, soql); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/libs/features/query/src/QueryResults/QueryResults.tsx b/libs/features/query/src/QueryResults/QueryResults.tsx index bf49b3375..4b3121ed0 100644 --- a/libs/features/query/src/QueryResults/QueryResults.tsx +++ b/libs/features/query/src/QueryResults/QueryResults.tsx @@ -170,14 +170,39 @@ export const QueryResults = React.memo(() => { navigate('', { replace: true, state: window.history.state.state }); return; } - // Fallback to session state if browser history is not available (e.g. browser extension) + // Fallback: sessionStorage (same tab / extension) then localStorage (cross-tab handoff from window.open — new tabs + // do not inherit the opener's sessionStorage). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let potentialState: any = null; + let handoffFromLocalStorage = false; try { - const potentialState = JSON.parse(sessionStorage.getItem('query') || ''); - if (isString(potentialState.soql)) { - navigate('', { replace: true, state: potentialState }); + const sessionRaw = sessionStorage.getItem('query'); + if (sessionRaw) { + potentialState = JSON.parse(sessionRaw); + } + } catch { + // ignore invalid session payload + } + if (!isString(potentialState?.soql)) { + try { + const localRaw = localStorage.getItem('query'); + if (localRaw) { + potentialState = JSON.parse(localRaw); + handoffFromLocalStorage = true; + } + } catch { + // ignore invalid local payload + } + } + if (isString(potentialState?.soql)) { + navigate('', { replace: true, state: potentialState }); + if (handoffFromLocalStorage) { + try { + localStorage.removeItem('query'); + } catch { + // ignore + } } - } catch (ex) { - // could not parse session, ignore } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/libs/icon-factory/src/lib/icon-factory.tsx b/libs/icon-factory/src/lib/icon-factory.tsx index 5961abe1d..2037aeff8 100644 --- a/libs/icon-factory/src/lib/icon-factory.tsx +++ b/libs/icon-factory/src/lib/icon-factory.tsx @@ -24,6 +24,8 @@ import StandardIcon_AssetRelationship from './icons/standard/AssetRelationship'; import StandardIcon_Billing from './icons/standard/Billing'; import StandardIcon_BundleConfig from './icons/standard/BundleConfig'; import StandardIcon_ConnectedApps from './icons/standard/ConnectedApps'; +import StandardIcon_Customers from './icons/standard/Customers'; +import StandardIcon_CustomerPortalUsers from './icons/standard/CustomerPortalUsers'; import StandardIcon_DataStreams from './icons/standard/DataStreams'; import StandardIcon_EmployeeOrganization from './icons/standard/EmployeeOrganization'; import StandardIcon_Entity from './icons/standard/Entity'; @@ -32,6 +34,8 @@ import StandardIcon_Feed from './icons/standard/Feed'; import StandardIcon_Feedback from './icons/standard/Feedback'; import StandardIcon_Form from './icons/standard/Form'; import StandardIcon_Formula from './icons/standard/Formula'; +import StandardIcon_Groups from './icons/standard/Groups'; +import StandardIcon_Incident from './icons/standard/Incident'; import StandardIcon_MultiPicklist from './icons/standard/MultiPicklist'; import StandardIcon_Opportunity from './icons/standard/Opportunity'; import StandardIcon_Outcome from './icons/standard/Outcome'; @@ -56,6 +60,8 @@ import UtilityIcon_Announcement from './icons/utility/Announcement'; import UtilityIcon_Apex from './icons/utility/Apex'; import UtilityIcon_ApexPlugin from './icons/utility/ApexPlugin'; import UtilityIcon_Archive from './icons/utility/Archive'; +import UtilityIcon_ArrowLeft from './icons/utility/ArrowLeft'; +import UtilityIcon_ArrowRight from './icons/utility/ArrowRight'; import UtilityIcon_Arrowdown from './icons/utility/Arrowdown'; import UtilityIcon_Arrowup from './icons/utility/Arrowup'; import UtilityIcon_Back from './icons/utility/Back'; @@ -88,6 +94,7 @@ import UtilityIcon_ExpandAll from './icons/utility/ExpandAll'; import UtilityIcon_ExpandAlt from './icons/utility/ExpandAlt'; import UtilityIcon_Fallback from './icons/utility/Fallback'; import UtilityIcon_Favorite from './icons/utility/Favorite'; +import UtilityIcon_Feed from './icons/utility/Feed'; import UtilityIcon_File from './icons/utility/File'; import UtilityIcon_Filter from './icons/utility/Filter'; import UtilityIcon_FilterList from './icons/utility/FilterList'; @@ -201,6 +208,8 @@ const standardIcons = { billing: StandardIcon_Billing, bundle_config: StandardIcon_BundleConfig, connected_apps: StandardIcon_ConnectedApps, + customers: StandardIcon_Customers, + customer_portal_users: StandardIcon_CustomerPortalUsers, data_streams: StandardIcon_DataStreams, employee_organization: StandardIcon_EmployeeOrganization, entity: StandardIcon_Entity, @@ -209,6 +218,8 @@ const standardIcons = { feedback: StandardIcon_Feedback, form: StandardIcon_Form, formula: StandardIcon_Formula, + groups: StandardIcon_Groups, + incident: StandardIcon_Incident, multi_picklist: StandardIcon_MultiPicklist, opportunity: StandardIcon_Opportunity, outcome: StandardIcon_Outcome, @@ -251,6 +262,8 @@ const utilityIcons = { apex_plugin: UtilityIcon_ApexPlugin, apex: UtilityIcon_Apex, archive: UtilityIcon_Archive, + arrow_left: UtilityIcon_ArrowLeft, + arrow_right: UtilityIcon_ArrowRight, arrowdown: UtilityIcon_Arrowdown, arrowup: UtilityIcon_Arrowup, back: UtilityIcon_Back, @@ -283,6 +296,7 @@ const utilityIcons = { expand_alt: UtilityIcon_ExpandAlt, fallback: UtilityIcon_Fallback, favorite: UtilityIcon_Favorite, + feed: UtilityIcon_Feed, file: UtilityIcon_File, filter: UtilityIcon_Filter, filterList: UtilityIcon_FilterList, diff --git a/libs/shared/constants/src/index.ts b/libs/shared/constants/src/index.ts index bf1c6bd1a..7cf2df655 100644 --- a/libs/shared/constants/src/index.ts +++ b/libs/shared/constants/src/index.ts @@ -1 +1,2 @@ +export * from './lib/permission-export-finding-codes'; export * from './lib/shared-constants'; diff --git a/libs/shared/constants/src/lib/permission-export-finding-codes.ts b/libs/shared/constants/src/lib/permission-export-finding-codes.ts new file mode 100644 index 000000000..44d3e929c --- /dev/null +++ b/libs/shared/constants/src/lib/permission-export-finding-codes.ts @@ -0,0 +1,65 @@ +/** + * Permission export analysis issue codes and severities (shared by API job processor and UI). + */ + +export const PermissionExportFindingSeverity = { + Error: 'error', + Warning: 'warning', +} as const; + +export type PermissionExportFindingSeverityValue = (typeof PermissionExportFindingSeverity)[keyof typeof PermissionExportFindingSeverity]; + +/** + * Stored on each analysis row as `code` and referenced by `issueCodeSummary` keys. + */ +export const PermissionExportFindingCode = { + OLS_READ_NO_FLS_ROWS: 'OLS_READ_NO_FLS_ROWS', + OLS_EDIT_NO_FLS_ROWS: 'OLS_EDIT_NO_FLS_ROWS', + FLS_EDIT_NO_OBJECT_EDIT: 'FLS_EDIT_NO_OBJECT_EDIT', + FLS_READ_NO_OBJECT_READ: 'FLS_READ_NO_OBJECT_READ', + FLS_WITHOUT_OLS_ROW: 'FLS_WITHOUT_OLS_ROW', + FINDINGS_TRUNCATED: 'FINDINGS_TRUNCATED', +} as const; + +export type PermissionExportFindingCodeValue = (typeof PermissionExportFindingCode)[keyof typeof PermissionExportFindingCode]; + +export type PermissionExportFindingDefinition = { + readonly severity: PermissionExportFindingSeverityValue; + /** Short label for Issue Codes / aggregated UI. */ + readonly label: string; +}; + +export const PERMISSION_EXPORT_FINDING_DEFINITIONS: Record = { + [PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Object read is on, but there are no field permission rows for the object.', + }, + [PermissionExportFindingCode.OLS_EDIT_NO_FLS_ROWS]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Object edit is on, but there are no field permission rows for the object.', + }, + [PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT]: { + severity: PermissionExportFindingSeverity.Error, + label: 'Field edit is on without object-level edit (or modify all records).', + }, + [PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ]: { + severity: PermissionExportFindingSeverity.Error, + label: 'Field read is on without object-level read (or view/modify all records).', + }, + [PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW]: { + severity: PermissionExportFindingSeverity.Error, + label: 'Field permissions exist for the object, but there is no object permissions row.', + }, + [PermissionExportFindingCode.FINDINGS_TRUNCATED]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Additional issues were omitted to keep the export result size bounded.', + }, +}; + +export function isPermissionExportFindingCode(value: string): value is PermissionExportFindingCodeValue { + return (Object.values(PermissionExportFindingCode) as readonly string[]).includes(value); +} + +export function getPermissionExportFindingDefinition(code: string): PermissionExportFindingDefinition | undefined { + return isPermissionExportFindingCode(code) ? PERMISSION_EXPORT_FINDING_DEFINITIONS[code] : undefined; +} diff --git a/libs/shared/constants/src/lib/shared-constants.ts b/libs/shared/constants/src/lib/shared-constants.ts index f4d9667f1..5b0d99999 100644 --- a/libs/shared/constants/src/lib/shared-constants.ts +++ b/libs/shared/constants/src/lib/shared-constants.ts @@ -354,6 +354,8 @@ export const TITLES = { FORMULA_EVALUATOR: 'Formula Evaluator | Jetstream', LOAD: 'Load | Jetstream', MANAGE_PERMISSIONS: 'Manage Permissions | Jetstream', + PERMISSION_ANALYSIS: 'Permission Analysis | Jetstream', + DATA_ANALYSIS: 'Data Analysis | Jetstream', MASS_UPDATE_RECORDS: 'Update Records | Jetstream', ORG_GROUPS: 'Manage Org Groups | Jetstream', PLATFORM_EVENTS: 'Platform Events | Jetstream', diff --git a/libs/shared/data/src/index.ts b/libs/shared/data/src/index.ts index 654e1fb4e..e4459bcca 100644 --- a/libs/shared/data/src/index.ts +++ b/libs/shared/data/src/index.ts @@ -2,4 +2,5 @@ export * from './lib/client-data'; export * from './lib/client-data-cache'; export { AxiosAdapterConfig, createMultipartFromFormData, getCsrfTokenFromCookie } from './lib/client-data-data-helper'; export * from './lib/client-socket-data'; +export * from './lib/data-utils'; export * from './lib/middleware'; diff --git a/libs/shared/data/src/lib/client-data.ts b/libs/shared/data/src/lib/client-data.ts index 07fa6f1ad..64984eeb4 100644 --- a/libs/shared/data/src/lib/client-data.ts +++ b/libs/shared/data/src/lib/client-data.ts @@ -190,7 +190,9 @@ export async function createInvitation(teamId: string, data: TeamInvitationReque } export async function resendInvitation(teamId: string, invitationId: string): Promise { - return handleRequest({ method: 'PUT', url: `/api/teams/${teamId}/invitations/${invitationId}`, data: {} }).then(unwrapResponseIgnoreCache); + return handleRequest({ method: 'PUT', url: `/api/teams/${teamId}/invitations/${invitationId}`, data: {} }).then( + unwrapResponseIgnoreCache, + ); } export async function cancelInvitation(teamId: string, invitationId: string): Promise { @@ -1440,3 +1442,4 @@ export async function updatePermissionSetRecords( }), ]); } + diff --git a/libs/shared/data/src/lib/data-utils.ts b/libs/shared/data/src/lib/data-utils.ts new file mode 100644 index 000000000..9d1c62bed --- /dev/null +++ b/libs/shared/data/src/lib/data-utils.ts @@ -0,0 +1,46 @@ +import type { SalesforceOrgUi } from '@jetstream/types'; +import { query, queryMore } from './client-data'; + +/** + * Runs a SOQL query and follows `nextRecordsUrl` until done or the shared row budget is exhausted. + * Each page of records is passed to `onPage` and then discarded — callers that need to retain + * records should accumulate them inside the callback. + * + * @param budget.remaining Decremented per record passed to `onPage`. + * @returns `truncated` true when the budget ran out before all Salesforce rows were read. + */ +export async function queryWithRecordBudget>( + org: SalesforceOrgUi, + soql: string, + isTooling: boolean, + budget: { remaining: number }, + onPage: (records: T[]) => void, +): Promise<{ truncated: boolean }> { + let response = await query(org, soql, isTooling); + + while (true) { + const records = response.queryResults.records; + if (budget.remaining <= 0) { + return { truncated: true }; + } + if (records.length > budget.remaining) { + const allowed = records.slice(0, budget.remaining); + onPage(allowed); + budget.remaining = 0; + return { truncated: true }; + } + onPage(records); + budget.remaining -= records.length; + + if (response.queryResults.done) { + break; + } + const nextUrl = response.queryResults.nextRecordsUrl; + if (!nextUrl) { + break; + } + response = await queryMore(org, nextUrl, isTooling); + } + + return { truncated: false }; +} diff --git a/libs/shared/ui-core/src/app/AppHome/AppHome.tsx b/libs/shared/ui-core/src/app/AppHome/AppHome.tsx index f6fea7dce..c1fcbc034 100644 --- a/libs/shared/ui-core/src/app/AppHome/AppHome.tsx +++ b/libs/shared/ui-core/src/app/AppHome/AppHome.tsx @@ -30,6 +30,11 @@ const HOME_ITEMS = [ icon: { type: 'standard', icon: 'portal' }, items: [APP_ROUTES.PERMISSION_MANAGER], }, + { + title: 'Analysis', + icon: { type: 'standard', icon: 'data_streams' }, + items: [APP_ROUTES.PERMISSION_ANALYSIS, APP_ROUTES.DATA_ANALYSIS], + }, { title: 'Deploy', icon: { type: 'standard', icon: 'asset_relationship' }, diff --git a/libs/shared/ui-core/src/app/HeaderNavbarItems.tsx b/libs/shared/ui-core/src/app/HeaderNavbarItems.tsx index 5da1e3b2c..dbc1b7f04 100644 --- a/libs/shared/ui-core/src/app/HeaderNavbarItems.tsx +++ b/libs/shared/ui-core/src/app/HeaderNavbarItems.tsx @@ -79,6 +79,26 @@ export const HeaderNavbarItems = () => { label={APP_ROUTES.PERMISSION_MANAGER.TITLE} /> + + "ns", "MyField__c" => null + * Uses {@link parseCustomFieldApiNameForTooling} so unmanaged names like `My_Field__c` are not mistaken for packaged fields. */ function getFieldNamespacePrefix(fieldName: string): string | null { - const match = /^([A-Za-z0-9]+)__.+__c$/.exec(fieldName); - return match ? match[1] : null; + const parsed = parseCustomFieldApiNameForTooling(fieldName); + return parsed?.namespacePrefix ?? null; } /** diff --git a/libs/shared/ui-core/src/index.ts b/libs/shared/ui-core/src/index.ts index 7ca6f5794..07e1a72f6 100644 --- a/libs/shared/ui-core/src/index.ts +++ b/libs/shared/ui-core/src/index.ts @@ -39,6 +39,7 @@ export * from './icons/JetstreamProLogo'; export * from './jetstream-events'; export * from './jobs/Job'; export * from './jobs/Jobs'; +export * from './jobs/jobs.state'; export * from './jobs/JobWorker'; export * from './load-records-results/LoadRecordsBulkApiResultsTable'; export * from './load-records-results/LoadRecordsBulkApiResultsTableRow'; diff --git a/libs/shared/ui-core/src/jobs/Job.tsx b/libs/shared/ui-core/src/jobs/Job.tsx index e65a3ba36..e1ad5d4e0 100644 --- a/libs/shared/ui-core/src/jobs/Job.tsx +++ b/libs/shared/ui-core/src/jobs/Job.tsx @@ -5,12 +5,23 @@ import { formatDate } from 'date-fns/format'; import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; import isString from 'lodash/isString'; import { FunctionComponent } from 'react'; +import { useNavigate } from 'react-router-dom'; import { downloadJob } from './job-utils'; const JOBS_WITH_DOWNLOAD = new Set(['BulkDelete', 'BulkUndelete']); -const JOBS_WITH_CANCEL = new Set(['BulkDownload', 'RetrievePackageZip']); +const JOBS_WITH_CANCEL = new Set([ + 'BulkDownload', + 'RetrievePackageZip', + 'PermissionExportAnalysis', + 'FieldUsageAnalysis', +]); const JOBS_WITH_LINK = new Set(['BulkDownload', 'UploadToGoogle', 'RetrievePackageZip']); -const JOBS_WITH_TIMESTAMP_UPDATE = new Set(['RetrievePackageZip', 'BulkDownload']); +const JOBS_WITH_TIMESTAMP_UPDATE = new Set([ + 'RetrievePackageZip', + 'BulkDownload', + 'PermissionExportAnalysis', + 'FieldUsageAnalysis', +]); const JOBS_WITH_FILE_ACTIONS = new Set(['DesktopFileDownload']); export interface JobProps { @@ -20,6 +31,7 @@ export interface JobProps { } export const Job: FunctionComponent = ({ job, cancelJob, dismiss }) => { + const navigate = useNavigate(); const status = job.statusMessage || job.status; let message; let timestamp; @@ -153,6 +165,14 @@ export const Job: FunctionComponent = ({ job, cancelJob, dismiss }) => )} + {job.viewUrl && ( +
+ +
+ )} {!inProgress && (
{JOBS_WITH_DOWNLOAD.has(job.type) && ( diff --git a/libs/shared/ui-core/src/jobs/JobWorker.ts b/libs/shared/ui-core/src/jobs/JobWorker.ts index f15c59e26..fb63bb042 100644 --- a/libs/shared/ui-core/src/jobs/JobWorker.ts +++ b/libs/shared/ui-core/src/jobs/JobWorker.ts @@ -1,5 +1,7 @@ /// /* eslint-disable @typescript-eslint/no-explicit-any */ +import { computeFieldUsageWhereUsed, FIELD_USAGE_MAX_ROWS_PER_OBJECT, runFieldUsageQueryForObjects } from '@jetstream/feature/data-analysis'; +import { runPermissionExport } from '@jetstream/feature/manage-permissions'; import { logger } from '@jetstream/shared/client-logger'; import { MIME_TYPES } from '@jetstream/shared/constants'; import { @@ -30,16 +32,21 @@ import { getIdFromRecordUrl, getMapOfBaseAndSubqueryRecords, getSObjectFromRecordUrl, + gzipEncode, replaceSubqueryQueryResultsWithRecords, splitArrayToMaxSize, } from '@jetstream/shared/utils'; import type { + AnalysisJobHistoryItem, AsyncJobType, AsyncJobWorkerMessagePayload, AsyncJobWorkerMessageResponse, BulkDownloadJob, CancelJob, DesktopFileDownloadJob, + FieldUsageAnalysisJob, + FieldUsageFullResult, + PermissionExportAnalysisJob, RetrievePackageFromListMetadataJob, RetrievePackageFromManifestJob, RetrievePackageFromPackageNamesJob, @@ -48,9 +55,33 @@ import type { UploadToGoogleJob, WorkerMessage, } from '@jetstream/types'; +import { dexieDb } from '@jetstream/ui/db'; import clamp from 'lodash/clamp'; import isString from 'lodash/isString'; +const ANALYSIS_HISTORY_PER_ORG_TYPE_CAP = 10; + +async function pruneAnalysisJobHistory(orgUniqueId: string, jobType: AnalysisJobHistoryItem['jobType']): Promise { + try { + // Wrap read + delete in a single transaction so a concurrent write (pin/unpin from another tab, or + // another job finishing on the same org+type) cannot race between the sortBy and the bulkDelete. + await dexieDb.transaction('rw', dexieDb.analysis_job_history, async () => { + // sortBy returns ascending by createdAt; reverse the array to put newest first so slice(N) drops the older rows. + const rowsAscending = await dexieDb.analysis_job_history + .where('[org+jobType+createdAt]') + .between([orgUniqueId, jobType, new Date(0)], [orgUniqueId, jobType, new Date(8640000000000000)]) + .sortBy('createdAt'); + const rowsNewestFirst = rowsAscending.slice().reverse(); + const overCap = rowsNewestFirst.filter((row) => !row.pinned).slice(ANALYSIS_HISTORY_PER_ORG_TYPE_CAP); + if (overCap.length > 0) { + await dexieDb.analysis_job_history.bulkDelete(overCap.map((row) => row.key)); + } + }); + } catch (ex) { + logger.warn('[JOB][ANALYSIS] Failed to prune analysis_job_history', ex); + } +} + /** * This class mimics a web-worker based on what the application uses for these methods */ @@ -342,6 +373,200 @@ export class JobWorker { } break; } + case 'PermissionExportAnalysis': { + const { org, job } = payloadData as AsyncJobWorkerMessagePayload; + const { jobHistoryKey, profileIds, permissionSetIds, objectApiNames } = job.meta; + const canceledRef = this.canceledJobIds; + try { + const { full } = await runPermissionExport(org, profileIds, permissionSetIds, { + objectApiNames, + onProgress: (progress) => { + const response: AsyncJobWorkerMessageResponse = { + job, + lastActivityUpdate: true, + results: { progress }, + }; + this.replyToMessage(name, response); + }, + isCanceled: () => canceledRef.has(job.id), + }); + + const blob = await gzipEncode(full); + const now = new Date(); + const historyRow: AnalysisJobHistoryItem = { + key: jobHistoryKey, + org: org.uniqueId, + jobType: 'permission_export', + status: 'completed', + requestPayload: full.requestPayload, + createdAt: now, + updatedAt: now, + errorMessage: null, + pinned: false, + summary: full.summary, + resultBlob: blob, + resultBlobSize: blob.byteLength, + }; + await dexieDb.analysis_job_history.put(historyRow); + await pruneAnalysisJobHistory(org.uniqueId, 'permission_export'); + + const response: AsyncJobWorkerMessageResponse = { + job, + results: { jobHistoryKey, summary: full.summary }, + }; + this.replyToMessage(name, response); + } catch (ex) { + const errorMessage = getErrorMessage(ex); + const wasCanceled = this.canceledJobIds.has(job.id); + if (!wasCanceled) { + try { + const now = new Date(); + const failedRow: AnalysisJobHistoryItem = { + key: jobHistoryKey, + org: org.uniqueId, + jobType: 'permission_export', + status: 'failed', + requestPayload: { + profileIds, + permissionSetIds, + ...(objectApiNames !== undefined ? { objectApiNames } : {}), + }, + createdAt: now, + updatedAt: now, + errorMessage, + pinned: false, + summary: null, + resultBlob: null, + resultBlobSize: 0, + }; + await dexieDb.analysis_job_history.put(failedRow); + await pruneAnalysisJobHistory(org.uniqueId, 'permission_export'); + } catch (writeEx) { + logger.warn('[JOB][PERMISSION_EXPORT] Failed to record failed analysis_job_history row', writeEx); + } + } else { + this.canceledJobIds.delete(job.id); + } + + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, errorMessage); + } + break; + } + case 'FieldUsageAnalysis': { + const { org, job } = payloadData as AsyncJobWorkerMessagePayload; + const { jobHistoryKey, objectApiNames, loadFullScan } = job.meta; + const canceledRef = this.canceledJobIds; + try { + const queryOutcome = await runFieldUsageQueryForObjects(org, objectApiNames, { + loadFullScan, + onProgress: (progress) => { + const response: AsyncJobWorkerMessageResponse = { + job, + lastActivityUpdate: true, + results: { progress }, + }; + this.replyToMessage(name, response); + }, + isCanceled: () => canceledRef.has(job.id), + }); + + let whereUsed: Record = {}; + try { + whereUsed = (await computeFieldUsageWhereUsed(org, queryOutcome.objects)) as unknown as Record; + } catch (whereUsedEx) { + logger.warn('[JOB][FIELD_USAGE] where-used lookup failed; continuing without map', whereUsedEx); + whereUsed = {}; + } + + const okObjectCount = objectApiNames.filter( + (objectApiName) => !queryOutcome.failedObjects.includes(objectApiName) && !queryOutcome.objects[objectApiName]?.error, + ).length; + const summaryParts = [ + `Field Usage for ${okObjectCount}/${objectApiNames.length} Object(s).${loadFullScan ? ' No per-object row cap.' : ''}`, + queryOutcome.anyQueryTruncated + ? loadFullScan + ? 'Some objects may still show truncated scans for very large data sets or API limits.' + : `Row scan capped at ${String(FIELD_USAGE_MAX_ROWS_PER_OBJECT)} rows per Object where noted.` + : '', + queryOutcome.failedObjects.length > 0 ? `Failed: ${queryOutcome.failedObjects.join(', ')}.` : '', + ].filter(Boolean); + const summary = summaryParts.join(' '); + + const full: FieldUsageFullResult = { + requestPayload: { + objectApiNames, + ...(loadFullScan !== undefined ? { loadFullScan } : {}), + }, + phase: 'field_usage_v1', + summary, + truncated: queryOutcome.anyQueryTruncated, + failedObjects: queryOutcome.failedObjects, + objects: queryOutcome.objects as unknown as FieldUsageFullResult['objects'], + whereUsed: whereUsed as unknown as FieldUsageFullResult['whereUsed'], + }; + + const blob = await gzipEncode(full); + const now = new Date(); + const historyRow: AnalysisJobHistoryItem = { + key: jobHistoryKey, + org: org.uniqueId, + jobType: 'field_usage', + status: 'completed', + requestPayload: full.requestPayload, + createdAt: now, + updatedAt: now, + errorMessage: null, + pinned: false, + summary, + resultBlob: blob, + resultBlobSize: blob.byteLength, + }; + await dexieDb.analysis_job_history.put(historyRow); + await pruneAnalysisJobHistory(org.uniqueId, 'field_usage'); + + const response: AsyncJobWorkerMessageResponse = { + job, + results: { jobHistoryKey, summary }, + }; + this.replyToMessage(name, response); + } catch (ex) { + const errorMessage = getErrorMessage(ex); + const wasCanceled = this.canceledJobIds.has(job.id); + if (!wasCanceled) { + try { + const now = new Date(); + const failedRow: AnalysisJobHistoryItem = { + key: jobHistoryKey, + org: org.uniqueId, + jobType: 'field_usage', + status: 'failed', + requestPayload: { + objectApiNames, + ...(loadFullScan !== undefined ? { loadFullScan } : {}), + }, + createdAt: now, + updatedAt: now, + errorMessage, + pinned: false, + summary: null, + resultBlob: null, + resultBlobSize: 0, + }; + await dexieDb.analysis_job_history.put(failedRow); + await pruneAnalysisJobHistory(org.uniqueId, 'field_usage'); + } catch (writeEx) { + logger.warn('[JOB][FIELD_USAGE] Failed to record failed analysis_job_history row', writeEx); + } + } else { + this.canceledJobIds.delete(job.id); + } + + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, errorMessage); + } + break; + } case 'DesktopFileDownload': { try { const { org, job } = payloadData as AsyncJobWorkerMessagePayload; diff --git a/libs/shared/ui-core/src/jobs/Jobs.tsx b/libs/shared/ui-core/src/jobs/Jobs.tsx index 59a387e40..2ccb760ca 100644 --- a/libs/shared/ui-core/src/jobs/Jobs.tsx +++ b/libs/shared/ui-core/src/jobs/Jobs.tsx @@ -555,6 +555,53 @@ export const Jobs: FunctionComponent = () => { } break; } + case 'PermissionExportAnalysis': + case 'FieldUsageAnalysis': { + try { + let newJob = { ...data.job }; + if (error) { + newJob = { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + status: 'failed', + statusMessage: error, + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + notifyUser(`${name === 'PermissionExportAnalysis' ? 'Permission export' : 'Field usage'} analysis failed`, { + body: newJob.statusMessage, + tag: name, + }); + } else if (data.lastActivityUpdate) { + const progressUpdate = (data.results as { progress?: AsyncJob['progress'] } | undefined)?.progress; + newJob = { + ...newJob, + lastActivity: new Date(), + ...(progressUpdate ? { progress: progressUpdate } : {}), + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + } else { + const { summary } = (data.results || {}) as { jobHistoryKey?: string; summary?: string }; + newJob = { + ...newJob, + results: data.results, + finished: new Date(), + lastActivity: new Date(), + status: 'success', + statusMessage: summary || 'Analysis complete', + progress: undefined, + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + notifyUser(`${name === 'PermissionExportAnalysis' ? 'Permission export' : 'Field usage'} analysis finished`, { + body: newJob.statusMessage, + tag: name, + }); + } + } catch (ex) { + logger.error('[ERROR][JOB] Error processing analysis job results', ex); + } + break; + } case 'DesktopFileDownload': { try { let newJob = { ...data.job }; diff --git a/libs/shared/ui-db/src/index.ts b/libs/shared/ui-db/src/index.ts index 76aefd01f..26fc521c7 100644 --- a/libs/shared/ui-db/src/index.ts +++ b/libs/shared/ui-db/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/analysis-job-history-retention'; export * from './lib/api-request-history.db'; export * from './lib/client-data.db'; export * from './lib/query-history-object.db'; diff --git a/libs/shared/ui-db/src/lib/analysis-job-history-retention.ts b/libs/shared/ui-db/src/lib/analysis-job-history-retention.ts new file mode 100644 index 000000000..13d78eff4 --- /dev/null +++ b/libs/shared/ui-db/src/lib/analysis-job-history-retention.ts @@ -0,0 +1,52 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { dexieDb } from './ui-db'; + +const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000; +const MAX_ROWS_PER_ORG_AND_JOB_TYPE = 10; +const JOB_TYPES = ['permission_export', 'field_usage'] as const; + +/** + * Prune `analysis_job_history` rows that exceed the retention policy: + * 1) drop unpinned rows older than 14 days + * 2) for each (org, jobType), keep at most the 10 most-recent unpinned rows + * + * Pinned rows are always preserved. Errors are logged and swallowed so a failed sweep + * never blocks app initialization. + */ +export async function pruneAnalysisJobHistory(): Promise { + try { + await dexieDb.transaction('rw', dexieDb.analysis_job_history, async () => { + const fourteenDaysAgo = new Date(Date.now() - FOURTEEN_DAYS_MS); + await dexieDb.analysis_job_history + .where('createdAt') + .below(fourteenDaysAgo) + .filter((row) => !row.pinned) + .delete(); + + const allOrgs = await dexieDb.analysis_job_history.orderBy('org').uniqueKeys(); + for (const orgKey of allOrgs) { + const org = String(orgKey); + for (const jobType of JOB_TYPES) { + // sortBy returns ascending by createdAt (Dexie always re-sorts in-memory; chained reverse() is a no-op). + // Reverse the array in JS to put newest first, then drop the first N to retain the newest N. + const rowsAscending = await dexieDb.analysis_job_history + .where('[org+jobType+createdAt]') + .between([org, jobType, new Date(0)], [org, jobType, new Date(8.64e15)]) + .sortBy('createdAt'); + const rowsNewestFirst = rowsAscending.slice().reverse(); + + const keysToDelete = rowsNewestFirst + .filter((row) => !row.pinned) + .slice(MAX_ROWS_PER_ORG_AND_JOB_TYPE) + .map((row) => row.key); + + if (keysToDelete.length > 0) { + await dexieDb.analysis_job_history.bulkDelete(keysToDelete); + } + } + } + }); + } catch (ex) { + logger.warn('[DB][ANALYSIS_JOB_HISTORY][PRUNE] Failed to prune analysis_job_history', ex); + } +} diff --git a/libs/shared/ui-db/src/lib/ui-db.ts b/libs/shared/ui-db/src/lib/ui-db.ts index f27af7870..ff2c1003d 100644 --- a/libs/shared/ui-db/src/lib/ui-db.ts +++ b/libs/shared/ui-db/src/lib/ui-db.ts @@ -1,6 +1,13 @@ /// import { logger } from '@jetstream/shared/client-logger'; -import type { ApiHistoryItem, LoadSavedMappingItem, QueryHistoryItem, QueryHistoryObject, RecentHistoryItem } from '@jetstream/types'; +import type { + AnalysisJobHistoryItem, + ApiHistoryItem, + LoadSavedMappingItem, + QueryHistoryItem, + QueryHistoryObject, + RecentHistoryItem, +} from '@jetstream/types'; import Dexie, { type EntityTable } from 'dexie'; import 'dexie-observable'; import 'dexie-syncable'; @@ -36,6 +43,17 @@ export const SyncableTables = { }, } as const; +/** + * Local-only Dexie tables. Not synced cross-device; the sync layer pulls only from `SyncableTables`. + * Kept separate from `SyncableTables` so the sync code stays type-tight without an extra prefix case. + */ +export const LocalOnlyTables = { + analysis_job_history: { + name: 'analysis_job_history', + keyPrefix: 'aj', + }, +} as const; + const isWebExtension = () => { try { return !!globalThis.__IS_BROWSER_EXTENSION__ || !!window?.chrome?.runtime?.id; @@ -70,6 +88,7 @@ export const dexieDb = new Dexie(DEXIE_DB_NAME) as Dexie & { load_saved_mapping: EntityTable; recent_history_item: EntityTable; api_request_history: EntityTable; + analysis_job_history: EntityTable; }; export const SyncableEntities = new Set(Object.keys(SyncableTables) as Array); @@ -89,6 +108,10 @@ dexieDb.version(3).stores({ api_request_history: 'key,org,lastRun,isFavorite,[org+isFavorite]', }); +dexieDb.version(4).stores({ + analysis_job_history: 'key,org,jobType,createdAt,pinned,[org+jobType+createdAt]', +}); + export const dexieDataSync = { connect: async () => { const status = await dexieDb.syncable.getStatus('/'); diff --git a/libs/shared/ui-router/src/lib/ui-router.ts b/libs/shared/ui-router/src/lib/ui-router.ts index 3e654d2ec..48ccf6dec 100644 --- a/libs/shared/ui-router/src/lib/ui-router.ts +++ b/libs/shared/ui-router/src/lib/ui-router.ts @@ -10,6 +10,8 @@ type RouteKey = | 'LOAD_CREATE_RECORD' | 'AUTOMATION_CONTROL' | 'PERMISSION_MANAGER' + | 'PERMISSION_ANALYSIS' + | 'DATA_ANALYSIS' | 'DEPLOY_METADATA' | 'CREATE_FIELDS' | 'FORMULA_EVALUATOR' @@ -127,6 +129,16 @@ export const APP_ROUTES: RouteMap = { TITLE: 'Manage Permissions', DESCRIPTION: 'View and update object and field permissions', }, + PERMISSION_ANALYSIS: { + ...getRoutePath('/permission-analysis'), + TITLE: 'Permission Analysis', + DESCRIPTION: 'Read and export permission coverage for profiles and permission sets', + }, + DATA_ANALYSIS: { + ...getRoutePath('/data-analysis'), + TITLE: 'Data Analysis', + DESCRIPTION: 'Field Usage and data coverage for selected Objects', + }, DEPLOY_METADATA: { ...getRoutePath('/deploy-metadata'), DOCS: 'https://docs.getjetstream.app/deploy-metadata', diff --git a/libs/shared/ui-utils/src/lib/shared-ui-utils.ts b/libs/shared/ui-utils/src/lib/shared-ui-utils.ts index 54b96c7ee..854294b52 100644 --- a/libs/shared/ui-utils/src/lib/shared-ui-utils.ts +++ b/libs/shared/ui-utils/src/lib/shared-ui-utils.ts @@ -237,10 +237,10 @@ export function polyfillFieldDefinition(field: Field): string { let value = ''; if (calculated && calculatedFormula) { - prefix = 'Formula('; + prefix = 'Formula ('; suffix = ')'; } else if (calculated) { - prefix = 'Roll-Up Summary('; + prefix = 'Roll-Up Summary ('; suffix = ')'; } else if (externalId) { suffix = ' (External Id)'; @@ -253,36 +253,36 @@ export function polyfillFieldDefinition(field: Field): string { } else if (nameField) { value = 'Name'; } else if (type === 'textarea' && extraTypeInfo === 'plaintextarea') { - value = `${length > 255 ? 'Long ' : ''}Text Area(${length})`; + value = `${length > 255 ? 'Long ' : ''}Text Area (${length})`; } else if (type === 'textarea' && extraTypeInfo === 'richtextarea') { - value = `Rich Text Area(${length})`; + value = `Rich Text Area (${length})`; } else if (isRelationshipField(field)) { // includes text/reference if referenceTo has data - value = `Lookup(${(referenceTo || []).join(',')})`; + value = `Lookup (${(referenceTo || []).join(',')})`; } else if (type === 'string') { - value = `Text(${length})`; + value = `Text (${length})`; } else if (type === 'boolean') { value = 'Checkbox'; } else if (type === 'datetime') { value = 'Date/Time'; } else if (type === 'currency') { - value = `Currency(${precision}, ${scale})`; + value = `Currency (${precision}, ${scale})`; } else if (calculated && !calculatedFormula && !autoNumber) { value = `Number`; } else if (type === 'double' || type === 'int') { if (calculated) { value = `Number`; } else { - value = `Number(${precision}, ${scale})`; + value = `Number (${precision}, ${scale})`; } } else if (type === 'encryptedstring') { - value = `Text (Encrypted)(${length})`; + value = `Text (Encrypted) (${length})`; } else if (type === 'location') { value = 'Geolocation'; } else if (type === 'percent') { - value = `Percent(${precision}, ${scale})`; + value = `Percent (${precision}, ${scale})`; } else if (type === 'url') { - value = `URL(${length})`; + value = `URL (${length})`; } else { // Address, Email, Date, Time, picklist, phone value = `${type[0].toUpperCase()}${type.substring(1)}`; diff --git a/libs/shared/utils/src/index.ts b/libs/shared/utils/src/index.ts index b5adb249e..34b0200d1 100644 --- a/libs/shared/utils/src/index.ts +++ b/libs/shared/utils/src/index.ts @@ -1,3 +1,7 @@ +export * from './lib/compression'; +export * from './lib/custom-field-tooling-names'; +export * from './lib/dedupe-field-usage-where-used'; export * from './lib/regex'; +export * from './lib/sobject-api-name-utils'; export * from './lib/utils'; export * from './lib/password-validation'; diff --git a/libs/shared/utils/src/lib/__tests__/custom-field-tooling-names.spec.ts b/libs/shared/utils/src/lib/__tests__/custom-field-tooling-names.spec.ts new file mode 100644 index 000000000..68283dc5d --- /dev/null +++ b/libs/shared/utils/src/lib/__tests__/custom-field-tooling-names.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + customFieldApiNameHasNamespacePrefix, + isCustomFieldApiName, + isUnmanagedCustomFieldApiName, + parseCustomFieldApiNameForTooling, +} from '../custom-field-tooling-names'; + +describe('parseCustomFieldApiNameForTooling', () => { + it('parses unmanaged custom fields (DeveloperName is API name without __c)', () => { + expect(parseCustomFieldApiNameForTooling('Amount__c')).toEqual({ + namespacePrefix: null, + developerName: 'Amount', + }); + expect(parseCustomFieldApiNameForTooling('Custom_Field__c')).toEqual({ + namespacePrefix: null, + developerName: 'Custom_Field', + }); + }); + + it('parses namespaced packaged fields into NamespacePrefix + DeveloperName', () => { + expect(parseCustomFieldApiNameForTooling('acme__Amount__c')).toEqual({ + namespacePrefix: 'acme', + developerName: 'Amount', + }); + expect(parseCustomFieldApiNameForTooling('ns__My_Custom_Field__c')).toEqual({ + namespacePrefix: 'ns', + developerName: 'My_Custom_Field', + }); + }); + + it('returns null for non-custom API names', () => { + expect(parseCustomFieldApiNameForTooling('Name')).toBeNull(); + expect(parseCustomFieldApiNameForTooling('Lookup__r')).toBeNull(); + }); +}); + +describe('isCustomFieldApiName', () => { + it('is true for custom field API names parseable for Tooling', () => { + expect(isCustomFieldApiName('Amount__c')).toBe(true); + expect(isCustomFieldApiName('acme__Amount__c')).toBe(true); + }); + + it('is false for standard and relationship suffixes', () => { + expect(isCustomFieldApiName('Name')).toBe(false); + expect(isCustomFieldApiName('Lookup__r')).toBe(false); + }); +}); + +describe('isUnmanagedCustomFieldApiName', () => { + it('is true only when there is no namespace prefix', () => { + expect(isUnmanagedCustomFieldApiName('Amount__c')).toBe(true); + expect(isUnmanagedCustomFieldApiName('My_Field__c')).toBe(true); + expect(isUnmanagedCustomFieldApiName('acme__Amount__c')).toBe(false); + expect(isUnmanagedCustomFieldApiName('Name')).toBe(false); + }); +}); + +describe('customFieldApiNameHasNamespacePrefix', () => { + it('is false for unmanaged custom fields', () => { + expect(customFieldApiNameHasNamespacePrefix('Amount__c')).toBe(false); + expect(customFieldApiNameHasNamespacePrefix('My_Field__c')).toBe(false); + }); + + it('is true for namespaced packaged custom fields', () => { + expect(customFieldApiNameHasNamespacePrefix('acme__Amount__c')).toBe(true); + expect(customFieldApiNameHasNamespacePrefix('ns__My_Custom_Field__c')).toBe(true); + }); + + it('is false for non-custom API names', () => { + expect(customFieldApiNameHasNamespacePrefix('Name')).toBe(false); + }); +}); diff --git a/libs/shared/utils/src/lib/__tests__/dedupe-field-usage-where-used.spec.ts b/libs/shared/utils/src/lib/__tests__/dedupe-field-usage-where-used.spec.ts new file mode 100644 index 000000000..c9d8f30fc --- /dev/null +++ b/libs/shared/utils/src/lib/__tests__/dedupe-field-usage-where-used.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { dedupeFieldUsageWhereUsedRows, flowLikeAutomationDedupeKey, sortFieldUsageWhereUsedRows } from '../dedupe-field-usage-where-used'; + +describe('dedupeFieldUsageWhereUsedRows', () => { + it('merges Flow and FlowDefinition with the same base name', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'Flow', name: 'My_Flow', kind: 'automation' }, + { type: 'FlowDefinition', name: 'My_Flow', kind: 'automation' }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe('FlowDefinition'); + expect(rows[0].name).toBe('My_Flow'); + }); + + it('merges Flow rows that only differ by a trailing version suffix', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'Flow', name: 'My_Flow-1', kind: 'automation' }, + { type: 'Flow', name: 'My_Flow-2', kind: 'automation' }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe('Flow'); + expect(rows[0].name).toBe('My_Flow-1'); + }); + + it('keeps distinct flows whose names do not normalize to the same key', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'Flow', name: 'Alpha_Flow', kind: 'automation' }, + { type: 'Flow', name: 'Beta_Flow', kind: 'automation' }, + ]); + expect(rows).toHaveLength(2); + }); + + it('does not merge ApexTrigger or WorkflowRule rows', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'ApexTrigger', name: 'T1', kind: 'automation' }, + { type: 'ApexTrigger', name: 'T1', kind: 'automation' }, + ]); + expect(rows).toHaveLength(2); + }); + + it('leaves apex, layout, and other kinds untouched', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'Flow', name: 'F', kind: 'automation' }, + { type: 'ApexClass', name: 'C', kind: 'apex' }, + { type: 'Layout', name: 'L', kind: 'layout' }, + ]); + expect(rows).toHaveLength(3); + }); +}); + +describe('flowLikeAutomationDedupeKey', () => { + it('strips trailing -digits', () => { + expect(flowLikeAutomationDedupeKey('X-12')).toBe('X'); + expect(flowLikeAutomationDedupeKey('My_Flow-3')).toBe('My_Flow'); + }); +}); + +describe('sortFieldUsageWhereUsedRows', () => { + it('orders by kind then type then name', () => { + const sorted = sortFieldUsageWhereUsedRows([ + { type: 'Layout', name: 'Z', kind: 'layout' }, + { type: 'Flow', name: 'A', kind: 'automation' }, + { type: 'ApexClass', name: 'B', kind: 'apex' }, + ]); + expect(sorted.map((r) => r.kind)).toEqual(['automation', 'apex', 'layout']); + }); +}); diff --git a/libs/shared/utils/src/lib/__tests__/sobject-api-name-utils.spec.ts b/libs/shared/utils/src/lib/__tests__/sobject-api-name-utils.spec.ts new file mode 100644 index 000000000..156dab685 --- /dev/null +++ b/libs/shared/utils/src/lib/__tests__/sobject-api-name-utils.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { isValidSalesforceId, sanitizeSobjectApiNames, uniqueSalesforceIds } from '../sobject-api-name-utils'; + +describe('sobject-api-name-utils', () => { + it('validates 15- and 18-character Salesforce ids', () => { + expect(isValidSalesforceId('00e000000000001')).toBe(true); + expect(isValidSalesforceId('00e000000000001AAA')).toBe(true); + expect(isValidSalesforceId('bad')).toBe(false); + // 16- and 17-char are not valid Salesforce ids + expect(isValidSalesforceId('00e0000000000001')).toBe(false); + expect(isValidSalesforceId('00e00000000000001')).toBe(false); + }); + + it('uniqueSalesforceIds filters invalid and duplicates', () => { + expect(uniqueSalesforceIds(['00e000000000001', '00e000000000001', '', 'nope', '00e000000000002'])).toEqual([ + '00e000000000001', + '00e000000000002', + ]); + }); + + it('sanitizeSobjectApiNames filters invalid and dedupes', () => { + expect(sanitizeSobjectApiNames(['Account', ' Account ', 'Account', 'bad name', '', 12, 'ns__Obj__c'])).toEqual([ + 'Account', + 'ns__Obj__c', + ]); + }); + + it('sanitizeSobjectApiNames returns empty for non-array', () => { + expect(sanitizeSobjectApiNames(null)).toEqual([]); + }); + + it('sanitizeSobjectApiNames rejects names with SOQL injection characters', () => { + expect(sanitizeSobjectApiNames(["Account', 'Contact", 'Account); --', 'Account/*', "Account'"])).toEqual([]); + }); + + it('sanitizeSobjectApiNames caps the list length', () => { + const input = Array.from({ length: 600 }, (_, i) => `Obj${i}__c`); + expect(sanitizeSobjectApiNames(input).length).toBe(500); + }); +}); diff --git a/libs/shared/utils/src/lib/compression.ts b/libs/shared/utils/src/lib/compression.ts new file mode 100644 index 000000000..b4eb16a7b --- /dev/null +++ b/libs/shared/utils/src/lib/compression.ts @@ -0,0 +1,15 @@ +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); + +export async function gzipEncode(value: unknown): Promise> { + const json = JSON.stringify(value); + const stream = new Blob([TEXT_ENCODER.encode(json) as BlobPart]).stream().pipeThrough(new CompressionStream('gzip')); + const buffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(buffer); +} + +export async function gzipDecode(bytes: Uint8Array): Promise { + const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream('gzip')); + const text = TEXT_DECODER.decode(await new Response(stream).arrayBuffer()); + return JSON.parse(text) as T; +} diff --git a/libs/shared/utils/src/lib/custom-field-tooling-names.ts b/libs/shared/utils/src/lib/custom-field-tooling-names.ts new file mode 100644 index 000000000..3d513ba3a --- /dev/null +++ b/libs/shared/utils/src/lib/custom-field-tooling-names.ts @@ -0,0 +1,60 @@ +/** + * Tooling CustomField uses DeveloperName (no trailing __c) and optional NamespacePrefix. + * + * @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm + */ +export interface CustomFieldToolingNameParts { + namespacePrefix: string | null; + developerName: string; +} + +/** + * Maps a custom field API name to Tooling CustomField query filters. + * Unmanaged: Amount__c → DeveloperName Amount, NamespacePrefix null. + * Managed: acme__Amount__c → DeveloperName Amount, NamespacePrefix acme. + */ +export function parseCustomFieldApiNameForTooling(fieldApiName: string): CustomFieldToolingNameParts | null { + const trimmed = fieldApiName.trim(); + if (!trimmed.endsWith('__c')) { + return null; + } + const withoutSuffix = trimmed.slice(0, -3); + const separatorIndex = withoutSuffix.indexOf('__'); + if (separatorIndex === -1) { + return { namespacePrefix: null, developerName: withoutSuffix }; + } + const namespacePrefix = withoutSuffix.slice(0, separatorIndex); + const developerName = withoutSuffix.slice(separatorIndex + 2); + if (!developerName) { + return null; + } + return { + namespacePrefix: namespacePrefix.length > 0 ? namespacePrefix : null, + developerName, + }; +} + +/** + * True when the API name parses as a custom field (trailing `__c` with a usable developer name). + * False for standard fields, `__r` relationship suffixes, and malformed names. + */ +export function isCustomFieldApiName(fieldApiName: string): boolean { + return parseCustomFieldApiNameForTooling(fieldApiName) != null; +} + +/** + * True for unmanaged custom fields (`Amount__c`). False for packaged (`acme__Amount__c`) and non-custom names. + */ +export function isUnmanagedCustomFieldApiName(fieldApiName: string): boolean { + const parsed = parseCustomFieldApiNameForTooling(fieldApiName); + return parsed != null && parsed.namespacePrefix == null; +} + +/** + * True when the field API name includes a namespace prefix (e.g. packaged `acme__Amount__c`). + * Unmanaged custom fields (`Amount__c`) return false. Non-custom API names return false. + */ +export function customFieldApiNameHasNamespacePrefix(fieldApiName: string): boolean { + const parsed = parseCustomFieldApiNameForTooling(fieldApiName); + return parsed != null && parsed.namespacePrefix != null; +} diff --git a/libs/shared/utils/src/lib/dedupe-field-usage-where-used.ts b/libs/shared/utils/src/lib/dedupe-field-usage-where-used.ts new file mode 100644 index 000000000..e60f857ab --- /dev/null +++ b/libs/shared/utils/src/lib/dedupe-field-usage-where-used.ts @@ -0,0 +1,104 @@ +/** + * Tooling `MetadataComponentDependency` often returns multiple rows for the same logical Flow + * (e.g. FlowDefinition plus Flow, or several Flow rows with version suffixes). + * We collapse those to one row per automation for counts and drill-down. + */ + +const FLOW_DEDUPE_METADATA_TYPES = new Set(['Flow', 'FlowDefinition']); + +export interface FieldUsageWhereUsedRowKind { + type: string; + name: string; + kind: 'automation' | 'apex' | 'layout' | 'other'; +} + +function kindSortOrder(kind: FieldUsageWhereUsedRowKind['kind']): number { + if (kind === 'automation') { + return 0; + } + if (kind === 'apex') { + return 1; + } + if (kind === 'layout') { + return 2; + } + return 3; +} + +/** + * Normalizes Flow metadata names so `My_Flow` and `My_Flow-3` share one key. + * Heuristic: trailing `-\d+` is treated as a version suffix (common in Tooling names). + */ +export function flowLikeAutomationDedupeKey(metadataComponentName: string): string { + return metadataComponentName.trim().replace(/-\d+$/, ''); +} + +function flowNameLooksVersioned(metadataComponentName: string): boolean { + return /-\d+$/.test(metadataComponentName.trim()); +} + +function preferFlowLikeRow(a: T, b: T): T { + const at = a.type.trim(); + const bt = b.type.trim(); + if (at === 'FlowDefinition' && bt !== 'FlowDefinition') { + return a; + } + if (bt === 'FlowDefinition' && at !== 'FlowDefinition') { + return b; + } + const aVersioned = flowNameLooksVersioned(a.name); + const bVersioned = flowNameLooksVersioned(b.name); + if (aVersioned !== bVersioned) { + return aVersioned ? b : a; + } + const an = (a.name || '').trim(); + const bn = (b.name || '').trim(); + return an.toLowerCase().localeCompare(bn.toLowerCase()) <= 0 ? a : b; +} + +/** + * Drops duplicate Flow / FlowDefinition rows that refer to the same logical flow. + * Other automation types (triggers, workflow, process) are left as returned by Tooling. + */ +export function dedupeFieldUsageWhereUsedRows(rows: T[]): T[] { + const flowLike = new Map(); + const rest: T[] = []; + + for (const row of rows) { + if (row.kind !== 'automation') { + rest.push(row); + continue; + } + const t = row.type.trim(); + if (!FLOW_DEDUPE_METADATA_TYPES.has(t)) { + rest.push(row); + continue; + } + const key = flowLikeAutomationDedupeKey(row.name); + const existing = flowLike.get(key); + if (!existing) { + flowLike.set(key, row); + } else { + flowLike.set(key, preferFlowLikeRow(row, existing)); + } + } + + return [...rest, ...flowLike.values()]; +} + +/** + * Same ordering as field-usage Tooling dependency lists (kind, then type, then name). + */ +export function sortFieldUsageWhereUsedRows(rows: T[]): T[] { + return [...rows].sort((a, b) => { + const ka = kindSortOrder(a.kind); + const kb = kindSortOrder(b.kind); + if (ka !== kb) { + return ka - kb; + } + return ( + (a.type || '').toLowerCase().localeCompare((b.type || '').toLowerCase()) || + (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()) + ); + }); +} diff --git a/libs/shared/utils/src/lib/regex.ts b/libs/shared/utils/src/lib/regex.ts index 2d28fbc82..f66a6794c 100644 --- a/libs/shared/utils/src/lib/regex.ts +++ b/libs/shared/utils/src/lib/regex.ts @@ -28,6 +28,6 @@ export const REGEX = { ENDS_WITH_NON_ALPHANUMERIC: /[^0-9a-zA-Z]+$/, CONSECUTIVE_UNDERSCORES: /_+/g, STARTS_WITH_NUMBER: /^[0-9]/, - SFDC_ID: /^([0-9a-zA-Z]{16}|[0-9a-zA-Z]{18})$/, + SFDC_ID: /^([0-9a-zA-Z]{15}|[0-9a-zA-Z]{18})$/, FILE_EXTENSION: /\.[a-z0-9]+$/i, }; diff --git a/libs/shared/utils/src/lib/sobject-api-name-utils.ts b/libs/shared/utils/src/lib/sobject-api-name-utils.ts new file mode 100644 index 000000000..3c74b1635 --- /dev/null +++ b/libs/shared/utils/src/lib/sobject-api-name-utils.ts @@ -0,0 +1,39 @@ +import { REGEX } from './regex'; + +export function isValidSalesforceId(id: string): boolean { + return REGEX.SFDC_ID.test(id.trim()); +} + +export function uniqueSalesforceIds(ids: string[]): string[] { + return Array.from(new Set(ids.filter((id) => REGEX.SFDC_ID.test(id)))); +} + +const OBJECT_API_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_.]*$/; +const MAX_SOBJECT_API_NAMES_PER_REQUEST = 500; + +/** + * Normalizes sobject API names from analysis job payloads for use in SOQL filters. + * Drops invalid tokens, dedupes, and caps list length to defend against SOQL injection. + */ +export function sanitizeSobjectApiNames(raw: unknown): string[] { + if (!Array.isArray(raw)) { + return []; + } + const seen = new Set(); + const output: string[] = []; + for (const item of raw) { + if (typeof item !== 'string') { + continue; + } + const name = item.trim(); + if (name.length === 0 || name.length > 255 || !OBJECT_API_NAME_PATTERN.test(name) || seen.has(name)) { + continue; + } + seen.add(name); + output.push(name); + if (output.length >= MAX_SOBJECT_API_NAMES_PER_REQUEST) { + break; + } + } + return output; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index f28b0c6c7..7fd9ab96b 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/analysis-job-types'; export * from './lib/billing.types'; export * from './lib/jobs/types'; export * from './lib/salesforce/apex.types'; diff --git a/libs/types/src/lib/analysis-job-types.ts b/libs/types/src/lib/analysis-job-types.ts new file mode 100644 index 000000000..6fb8d9fa8 --- /dev/null +++ b/libs/types/src/lib/analysis-job-types.ts @@ -0,0 +1,116 @@ +import { z } from 'zod'; + +/** + * Schemas + inferred types for client-side analysis jobs (permission_export and field_usage). + * + * Jobs run entirely in the browser via the JobWorker pattern and persist their results in Dexie + * (`analysis_job_history` table) as gzip-compressed blobs. The legacy split between a small + * `result` JSONB column and a heavy `resultData` JSONB column on the server is gone; everything + * is stored as a single merged shape per (org, jobType) row. + */ + +export const analysisJobTypeSchema = z.enum(['permission_export', 'field_usage']); +export type AnalysisJobType = z.infer; + +export const analysisJobRequestPayloadSchema = z.record(z.string(), z.unknown()); +export type AnalysisJobRequestPayload = z.infer; + +export const analysisJobIssueCodeSummaryEntrySchema = z.object({ + count: z.number(), + errors: z.number(), + warnings: z.number(), +}); +export type AnalysisJobIssueCodeSummaryEntry = z.infer; + +export const analysisJobFindingRecordSchema = z.record(z.string(), z.unknown()); +export type AnalysisJobFindingRecord = z.infer; + +export const permissionExportJobCountsSchema = z.object({ + permissionSets: z.number(), + permissionSetAssignments: z.number(), + permissionSetGroups: z.number(), + permissionSetGroupComponents: z.number(), + mutingPermissionSets: z.number(), + objectPermissions: z.number(), + fieldPermissions: z.number(), + permissionSetTabSettings: z.number(), +}); +export type PermissionExportJobCounts = z.infer; + +/** + * Small "metadata" portion of a permission_export result — the summary, counts, findings, etc. + * + * NOTE: The existing client-side parser `parsePermissionExportResult` reads heavy export rows from + * `result.export.*`. The Dexie row stores the merged shape (see `permissionExportFullResultSchema` + * below), and the view code reshapes back to `{ ...summary, export: { permissionSets, ... } }` for + * the parser. Parser migration is scheduled for Wave 5. + */ +export const permissionExportJobResultSchema = z.object({ + requestPayload: analysisJobRequestPayloadSchema.optional(), + phase: z.literal('permission_export_v1'), + summary: z.string(), + truncated: z.boolean(), + counts: permissionExportJobCountsSchema, + findings: z.array(analysisJobFindingRecordSchema), + issueCodeSummary: z.record(z.string(), analysisJobIssueCodeSummaryEntrySchema), +}); +export type PermissionExportJobResult = z.infer; + +/** Heavy export rows produced by the permission_export job. */ +export const permissionExportJobResultDataSchema = z.object({ + permissionSets: z.array(z.record(z.string(), z.unknown())), + permissionSetAssignments: z.array(z.record(z.string(), z.unknown())), + permissionSetGroups: z.array(z.record(z.string(), z.unknown())), + permissionSetGroupComponents: z.array(z.record(z.string(), z.unknown())), + mutingPermissionSets: z.array(z.record(z.string(), z.unknown())), + objectPermissions: z.array(z.record(z.string(), z.unknown())), + fieldPermissions: z.array(z.record(z.string(), z.unknown())), + permissionSetTabSettings: z.array(z.record(z.string(), z.unknown())), +}); +export type PermissionExportJobResultData = z.infer; + +/** Full permission_export result as stored in Dexie — small metadata + heavy export rows in one shape. */ +export const permissionExportFullResultSchema = permissionExportJobResultSchema.merge(permissionExportJobResultDataSchema); +export type PermissionExportFullResult = z.infer; + +export const fieldUsageJobResultSchema = z.object({ + requestPayload: analysisJobRequestPayloadSchema.optional(), + phase: z.literal('field_usage_v1'), + summary: z.string(), + truncated: z.boolean(), + failedObjects: z.array(z.string()), +}); +export type FieldUsageJobResult = z.infer; + +export const fieldUsageJobResultDataSchema = z.object({ + /** Keyed by sobject API name. Value shape matches the per-object payload produced by the field-usage runner. */ + objects: z.record(z.string(), z.record(z.string(), z.unknown())), + /** Tooling dependency rows keyed by `ObjectApi.FieldApi`. */ + whereUsed: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))), +}); +export type FieldUsageJobResultData = z.infer; + +/** Full field_usage result as stored in Dexie — small metadata + heavy object/whereUsed maps in one shape. */ +export const fieldUsageFullResultSchema = fieldUsageJobResultSchema.merge(fieldUsageJobResultDataSchema); +export type FieldUsageFullResult = z.infer; + +/** + * Dexie row shape for `analysis_job_history`. Stores the gzip-compressed JSON blob of the full + * result (decoded lazily when a view loads). `running` is intentionally absent from `status` — + * in-flight job state lives in jotai, not Dexie. + */ +export const analysisJobHistoryItemSchema = z.object({ + key: z.string(), + org: z.string(), + jobType: analysisJobTypeSchema, + status: z.enum(['completed', 'failed']), + requestPayload: analysisJobRequestPayloadSchema.optional(), + createdAt: z.date(), + updatedAt: z.date(), + errorMessage: z.string().nullable(), + pinned: z.boolean().default(false), + summary: z.string().nullable(), + resultBlob: z.instanceof(Uint8Array).nullable(), + resultBlobSize: z.number().default(0), +}); +export type AnalysisJobHistoryItem = z.infer; diff --git a/libs/types/src/lib/ui/types.ts b/libs/types/src/lib/ui/types.ts index 4f944c85b..84673cd04 100644 --- a/libs/types/src/lib/ui/types.ts +++ b/libs/types/src/lib/ui/types.ts @@ -427,7 +427,9 @@ export type AsyncJobType = | 'RetrievePackageZip' | 'UploadToGoogle' | 'DesktopFileDownload' - | 'CancelJob'; + | 'CancelJob' + | 'PermissionExportAnalysis' + | 'FieldUsageAnalysis'; export type AsyncJobStatus = 'pending' | 'in-progress' | 'success' | 'finished-warning' | 'failed' | 'aborted'; @@ -454,6 +456,33 @@ export interface AsyncJob { progress?: AsyncJobProgress; // optional progress information meta: T; results?: R; + /** + * Optional in-app navigation target shown as a "View" button in the Jobs popover when set. + * Used by jobs that produce results rendered in a dedicated route (e.g. analysis jobs). + */ + viewUrl?: string; +} + +/** + * Meta payload for the in-browser `PermissionExportAnalysis` job. The `jobHistoryKey` is the + * Dexie row id that will receive the final result blob; views derive the route from it. + */ +export interface PermissionExportAnalysisJob { + jobHistoryKey: string; + orgUniqueId: string; + profileIds: string[]; + permissionSetIds: string[]; + objectApiNames?: string[]; +} + +/** + * Meta payload for the in-browser `FieldUsageAnalysis` job. + */ +export interface FieldUsageAnalysisJob { + jobHistoryKey: string; + orgUniqueId: string; + objectApiNames: string[]; + loadFullScan?: boolean; } export interface AsyncJobWorkerMessagePayload { diff --git a/libs/ui/src/lib/card/Card.tsx b/libs/ui/src/lib/card/Card.tsx index 3a5f65f09..7c79ade60 100644 --- a/libs/ui/src/lib/card/Card.tsx +++ b/libs/ui/src/lib/card/Card.tsx @@ -51,10 +51,17 @@ export const Card = forwardRef(
-

{titleContent}

+

+ {titleContent} +

{actions && ( > = DataTablePropsBase & (PropsWithServer | PropsWithoutServer); -interface DataTablePropsBase> - extends Omit, 'columns' | 'rows' | 'rowKeyGetter' | 'onColumnsReorder'> { +interface DataTablePropsBase> extends Omit< + DataGridProps, + 'columns' | 'rows' | 'rowKeyGetter' | 'onColumnsReorder' +> { data: T[]; columns: ColumnWithFilter[]; org?: SalesforceOrgUi; diff --git a/libs/ui/src/lib/data-table/DataTree.tsx b/libs/ui/src/lib/data-table/DataTree.tsx index 11100a1b9..ab51bfdcb 100644 --- a/libs/ui/src/lib/data-table/DataTree.tsx +++ b/libs/ui/src/lib/data-table/DataTree.tsx @@ -22,8 +22,10 @@ interface PropsWithoutServer { export type DataTreeProps> = DataTreePropsBase & (PropsWithServer | PropsWithoutServer); -interface DataTreePropsBase> - extends Omit, 'columns' | 'rows' | 'rowKeyGetter'> { +interface DataTreePropsBase> extends Omit< + TreeDataGridProps, + 'columns' | 'rows' | 'rowKeyGetter' +> { data: T[]; columns: ColumnWithFilter[]; serverUrl?: string; @@ -125,6 +127,7 @@ export const DataTree = forwardRef>( // @ts-expect-error Types are incorrect, but they are generic and difficult to get correct onCellKeyDown={handleCellKeydown} onColumnsReorder={handleReorderColumns} + enableVirtualization {...rest} onSortColumnsChange={(columns) => { setSortColumns(columns); diff --git a/libs/ui/src/lib/data-table/data-table-styles.scss b/libs/ui/src/lib/data-table/data-table-styles.scss index 87a89227b..1fd4c1cf3 100644 --- a/libs/ui/src/lib/data-table/data-table-styles.scss +++ b/libs/ui/src/lib/data-table/data-table-styles.scss @@ -22,6 +22,29 @@ &.save-error { background-color: #ffdede; } + + /** Permission analysis Issues grid: full row for error-severity findings */ + &.permission-finding-row--error { + background-color: #ffe8ea; + box-shadow: inset 0 0 0 1px #8a0618; + } + } + + /** Warning findings: amber cell (Issues severity column + object permission boolean cells). */ + .rdg-cell.permission-finding-severity-cell--warning { + background-color: #fff5e0; + box-shadow: inset 0 0 0 1px #a86404; + } + + /** Error findings on object-permission boolean columns (FLS vs OLS mismatch). */ + .rdg-cell.permission-finding-cell--error { + background-color: #ffe8ea; + box-shadow: inset 0 0 0 1px #8a0618; + } + + /** Object-permission cells that open the findings drill-down modal. */ + .rdg-cell.permission-finding-cell--clickable { + cursor: pointer; } .rdg-cell { diff --git a/libs/ui/src/lib/data-table/useDataTable.tsx b/libs/ui/src/lib/data-table/useDataTable.tsx index 7bb0e4f70..dfe9e6d5a 100644 --- a/libs/ui/src/lib/data-table/useDataTable.tsx +++ b/libs/ui/src/lib/data-table/useDataTable.tsx @@ -27,6 +27,12 @@ import './data-table-styles.scss'; import { ColumnWithFilter, ContextMenuActionData, DataTableFilter, DataTableRef, FILTER_SET_TYPES, RowWithKey } from './data-table-types'; import { EMPTY_FIELD, NON_DATA_COLUMN_KEYS, filterRecord, getSearchTextByRow, isFilterActive, resetFilter } from './data-table-utils'; +/** + * Stable empty filter-text map returned when the quick filter isn't active. Reusing one frozen object + * keeps `filteredRows`' `rowFilterText` dependency identity-stable, so memoization holds. + */ +const EMPTY_ROW_FILTER_TEXT: Readonly> = Object.freeze({}); + export interface UseDataTableProps { data: any[]; columns: ColumnWithFilter[]; @@ -70,7 +76,6 @@ export function useDataTable({ const [gridId] = useState(() => uniqueId('grid-')); const [columns, setColumns] = useState(_columns || []); const [sortColumns, setSortColumns] = useState(() => initialSortColumns || []); - const [rowFilterText, setRowFilterText] = useState>({}); const [renderers] = useState>(() => ({ renderSortStatus })); const [columnsOrder, setColumnsOrder] = useState((): readonly number[] => columns.map((_, index) => index)); const [contextMenuProps, setContextMenuProps] = useState<{ @@ -115,13 +120,29 @@ export function useDataTable({ } }, [reorderedColumns, columnsOrder, onReorderColumns]); - useEffect(() => { - if (Array.isArray(columns) && columns.length && Array.isArray(data) && data.length) { - setRowFilterText(getSearchTextByRow(data, columns, getRowKey)); - } else { - setRowFilterText({}); + /** + * Lazy per-row search index for the global quick filter. Building the index on every `columns` / + * `data` identity change was a ~50ms hit per refresh on ~6.7k rows — async metadata loads cause + * 3+ rebuilds in quick succession on a fresh view. We defer the first build until the user actually + * types into the filter (`quickFilterHasBeenUsed`) and from then on it rebuilds only when `columns` + * or `data` identity changes — never on a keystroke (`quickFilterText` isn't in this memo's deps). + */ + const [quickFilterHasBeenUsed, setQuickFilterHasBeenUsed] = useState(false); + // Derive on the way down: the moment the user types into the filter, flip the latch in-place + // (React handles setState-during-render by discarding this render and starting a new one). + if (includeQuickFilter && quickFilterText && !quickFilterHasBeenUsed) { + setQuickFilterHasBeenUsed(true); + } + + const rowFilterText = useMemo>(() => { + if (!includeQuickFilter || !quickFilterHasBeenUsed) { + return EMPTY_ROW_FILTER_TEXT; + } + if (!Array.isArray(columns) || !columns.length || !Array.isArray(data) || !data.length) { + return EMPTY_ROW_FILTER_TEXT; } - }, [columns, data, getRowKey]); + return getSearchTextByRow(data, columns, getRowKey); + }, [columns, data, getRowKey, includeQuickFilter, quickFilterHasBeenUsed]); const updateFilter = useCallback((column: string, filter: DataTableFilter) => { dispatch({ type: 'UPDATE_FILTER', payload: { column, filter } }); diff --git a/libs/ui/src/lib/popover/Popover.tsx b/libs/ui/src/lib/popover/Popover.tsx index b44560678..816b82684 100644 --- a/libs/ui/src/lib/popover/Popover.tsx +++ b/libs/ui/src/lib/popover/Popover.tsx @@ -169,7 +169,7 @@ export const Popover = ({ }; }, [isOpen, refs.floating, refs.domReference, isInPortal]); - const { as: TriggerElement = 'button', ...restButtonProps } = buttonProps || {}; + const { as: TriggerElement = 'button', style: triggerStyleFromButtonProps, ...restButtonProps } = buttonProps || {}; const mergedButtonProps = { ...getReferenceProps(), @@ -182,7 +182,7 @@ export const Popover = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any 'onClick' in restButtonProps && typeof restButtonProps.onClick === 'function' && restButtonProps.onClick?.(ev as any); }, - style: buttonStyle, + style: { ...triggerStyleFromButtonProps, ...buttonStyle }, }; const triggerProps = TriggerElement === 'button' ? { ...mergedButtonProps, type: 'button' as const } : mergedButtonProps; diff --git a/libs/ui/src/lib/sobject-field-list/useWhereIsThisUsed.tsx b/libs/ui/src/lib/sobject-field-list/useWhereIsThisUsed.tsx index cafe4d6aa..02c557c40 100644 --- a/libs/ui/src/lib/sobject-field-list/useWhereIsThisUsed.tsx +++ b/libs/ui/src/lib/sobject-field-list/useWhereIsThisUsed.tsx @@ -1,5 +1,6 @@ import { logger } from '@jetstream/shared/client-logger'; import { clearCacheForOrg, queryWithCache } from '@jetstream/shared/data'; +import { parseCustomFieldApiNameForTooling } from '@jetstream/shared/utils'; import { useReducerFetchFn } from '@jetstream/shared/ui-utils'; import { ListItem, SalesforceOrgUi } from '@jetstream/types'; import orderBy from 'lodash/orderBy'; @@ -19,18 +20,19 @@ export interface MetadataDependency { MetadataComponentType: string; } -function getEntityDefinitionQuery(sobject: string, field: string) { - let namespace: string | undefined = undefined; - if (field.includes('__')) { - const [_namespace, fieldWithoutNamespace] = field.split('__'); - namespace = _namespace; - field = fieldWithoutNamespace; +function getEntityDefinitionQuery(sobject: string, fieldApiName: string) { + const parsed = parseCustomFieldApiNameForTooling(fieldApiName); + if (!parsed) { + return `SELECT Id, DeveloperName, EntityDefinitionId, TableEnumOrId FROM CustomField WHERE Id = NULL LIMIT 1`; } + const nsClause = + parsed.namespacePrefix != null && parsed.namespacePrefix.length > 0 + ? ` AND NamespacePrefix = '${parsed.namespacePrefix}'` + : ' AND NamespacePrefix = null'; return `SELECT Id, DeveloperName, EntityDefinitionId, TableEnumOrId FROM CustomField WHERE EntityDefinition.QualifiedApiName = '${sobject}' - AND DeveloperName = '${field}' - ${namespace ? `AND NamespacePrefix = '${namespace}'` : ''} + AND DeveloperName = '${parsed.developerName}'${nsClause} LIMIT 1`; } diff --git a/libs/ui/src/lib/sobject-list/ConnectedSobjectListMultiSelect.tsx b/libs/ui/src/lib/sobject-list/ConnectedSobjectListMultiSelect.tsx index 51da35cc8..05c95a7bf 100644 --- a/libs/ui/src/lib/sobject-list/ConnectedSobjectListMultiSelect.tsx +++ b/libs/ui/src/lib/sobject-list/ConnectedSobjectListMultiSelect.tsx @@ -44,6 +44,8 @@ export interface ConnectedSobjectListMultiSelectProps { onSobjects: (sobjects: DescribeGlobalSObjectResult[] | null) => void; onSelectedSObjects: (selectedSObjects: string[]) => void; onRefresh?: () => void; + /** When true, object list is read-only (selection and refresh disabled). */ + disabled?: boolean; } export const ConnectedSobjectListMultiSelect = forwardRef( @@ -61,6 +63,7 @@ export const ConnectedSobjectListMultiSelect = forwardRef { @@ -132,10 +135,10 @@ export const ConnectedSobjectListMultiSelect = forwardRef { - if (selectedOrg && !loading && !errorMessage && !sobjects) { + if (!disabled && selectedOrg && !loading && !errorMessage && !sobjects) { loadObjects().then(NOOP); } - }, [selectedOrg, loading, errorMessage, sobjects, onSobjects, loadObjects]); + }, [disabled, selectedOrg, loading, errorMessage, sobjects, onSobjects, loadObjects]); async function handleRefresh() { try { @@ -162,7 +165,11 @@ export const ConnectedSobjectListMultiSelect = forwardRef
- @@ -174,6 +181,7 @@ export const ConnectedSobjectListMultiSelect = forwardRef { if (value) { selectedSObjectSet.add(item.name); @@ -135,10 +141,11 @@ export const SobjectListMultiSelect: FunctionComponent ({ label: item, value: item }))} onClearAll={() => onSelected([])} onClearItem={handleSelection} @@ -158,6 +165,7 @@ export const SobjectListMultiSelect: FunctionComponent ; filteredSobjects: DescribeGlobalSObjectResult[]; searchTerm: string; @@ -208,13 +219,17 @@ interface SobjectListContentProps { } const SobjectListContent = forwardRef( - ({ selectedSObjectSet, filteredSobjects, searchTerm, onSelected }: SobjectListContentProps, ref: RefObject) => { + ( + { disabled = false, selectedSObjectSet, filteredSobjects, searchTerm, onSelected }: SobjectListContentProps, + ref: RefObject, + ) => { return ( <> selectedSObjectSet.has(item.name)} onSelected={onSelected} getContent={(item: DescribeGlobalSObjectResult) => ({ diff --git a/libs/ui/src/lib/widgets/ItemSelectionSummary.tsx b/libs/ui/src/lib/widgets/ItemSelectionSummary.tsx index 59edd00a2..a367620a7 100644 --- a/libs/ui/src/lib/widgets/ItemSelectionSummary.tsx +++ b/libs/ui/src/lib/widgets/ItemSelectionSummary.tsx @@ -28,6 +28,9 @@ export const ItemSelectionSummary: FunctionComponent } function handleClearItem(item: string) { + if (disabled) { + return; + } onClearItem(item); if (items.length === 1) { popoverRef.current?.close(); @@ -55,7 +58,11 @@ export const ItemSelectionSummary: FunctionComponent

Click on an item to de-select

    {items.map((item, i) => ( -
  • handleClearItem(item.value)}> +
  • handleClearItem(item.value)} + >
    {item.label}
    diff --git a/libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx b/libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx index 6b5cc81fc..2e6dbc07d 100644 --- a/libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx +++ b/libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx @@ -46,6 +46,18 @@ export function getProfileOrPermSetSetupUrl(recordType: ProfileOrPermSetRecordTy return `/lightning/setup/${basePath}/page?address=${encodeURIComponent(`/${recordId}?noredirect=1`)}`; } +/** Setup → Permission Set Groups → group detail. */ +export function getPermissionSetGroupSetupUrl(permissionSetGroupId: string): string { + const trimmed = permissionSetGroupId.trim(); + return `/lightning/setup/PermSetGroups/page?address=${encodeURIComponent(`/${trimmed}?noredirect=1`)}`; +} + +/** Setup → Users → user detail (expects a Salesforce User Id, prefix `005`). */ +export function getSalesforceUserManageSetupUrl(userId: string): string { + const trimmed = userId.trim(); + return `/lightning/setup/ManageUsers/page?address=${encodeURIComponent(`/${trimmed}?noredirect=1`)}`; +} + // Escape backslash, single quote, percent, and underscore in one pass so SOQL LIKE treats user input literally. function escapeSoqlLike(value: string): string { if (!value) { diff --git a/tsconfig.base.json b/tsconfig.base.json index 2998600e6..66439a4b5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -36,6 +36,7 @@ "@jetstream/feature/load-records": ["./libs/features/load-records/src/index.ts"], "@jetstream/feature/load-records-multi-object": ["./libs/features/load-records-multi-object/src/index.ts"], "@jetstream/feature/manage-permissions": ["./libs/features/manage-permissions/src/index.ts"], + "@jetstream/feature/data-analysis": ["./libs/features/data-analysis/src/index.ts"], "@jetstream/feature/orgGroups": ["./libs/features/org-groups/src/index.ts"], "@jetstream/feature/platform-event-monitor": ["./libs/features/platform-event-monitor/src/index.ts"], "@jetstream/feature/query": ["./libs/features/query/src/index.ts"],