From 867fbb0a6a2b11ad7a97c4bc76222e576eed597a Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 10 Dec 2023 08:47:02 -0700 Subject: [PATCH] Add Tab Visibility to Permissions Editor Added tab visibility to permissions editor because this is often desired to be adjusted at the same time as other permissions Some objects do not have a tab, in which case the user is unable to adjust tab permissions for those objects. Tab visibility objects for standard objects have a prefix of 'standard-' instead of the full object name, in addition, the records need to be manually deleted to indicate no permissions. resolves #663 --- .../core/AppStateResetOnOrgChange.tsx | 5 +- .../manage-permissions/ManagePermissions.tsx | 4 + .../ManagePermissionsEditor.tsx | 174 +++++- .../ManagePermissionsEditorObjectTable.tsx | 2 - ...agePermissionsEditorTabVisibilityTable.tsx | 85 +++ .../ManagePermissionsSelection.tsx | 2 + .../manage-permissions.state.ts | 29 +- .../usePermissionRecords.tsx | 89 ++- .../utils/permission-manager-export-utils.ts | 66 ++- .../utils/permission-manager-table-utils.tsx | 542 ++++++++++++++---- .../utils/permission-manager-types.ts | 81 ++- .../utils/permission-manager-utils.ts | 252 +++++++- libs/icon-factory/src/lib/icon-factory.tsx | 2 + libs/types/src/lib/salesforce/types.ts | 19 + 14 files changed, 1154 insertions(+), 198 deletions(-) create mode 100644 apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditorTabVisibilityTable.tsx diff --git a/apps/jetstream/src/app/components/core/AppStateResetOnOrgChange.tsx b/apps/jetstream/src/app/components/core/AppStateResetOnOrgChange.tsx index f2e80ec05..246484309 100644 --- a/apps/jetstream/src/app/components/core/AppStateResetOnOrgChange.tsx +++ b/apps/jetstream/src/app/components/core/AppStateResetOnOrgChange.tsx @@ -1,5 +1,5 @@ import { SalesforceOrgUi } from '@jetstream/types'; -import { Fragment, FunctionComponent, useEffect, useState } from 'react'; +import { FunctionComponent, useEffect, useState } from 'react'; import { Resetter, useRecoilValue, useResetRecoilState } from 'recoil'; import * as fromAppState from '../../app-state'; import * as fromAutomationControlState from '../automation-control/automation-control.state'; @@ -49,6 +49,7 @@ export const AppStateResetOnOrgChange: FunctionComponent; + return null; }; export default AppStateResetOnOrgChange; diff --git a/apps/jetstream/src/app/components/manage-permissions/ManagePermissions.tsx b/apps/jetstream/src/app/components/manage-permissions/ManagePermissions.tsx index 105e2e22d..e2ed0164d 100644 --- a/apps/jetstream/src/app/components/manage-permissions/ManagePermissions.tsx +++ b/apps/jetstream/src/app/components/manage-permissions/ManagePermissions.tsx @@ -25,6 +25,7 @@ export const ManagePermissions: FunctionComponent = () = const resetFieldsByKey = useResetRecoilState(fromPermissionsState.fieldsByKey); const resetObjectPermissionMap = useResetRecoilState(fromPermissionsState.objectPermissionMap); const resetFieldPermissionMap = useResetRecoilState(fromPermissionsState.fieldPermissionMap); + const resetTabVisibilityPermissionMap = useResetRecoilState(fromPermissionsState.tabVisibilityPermissionMap); const [priorSelectedOrg, setPriorSelectedOrg] = useState(null); const hasSelectionsMade = useRecoilValue(fromPermissionsState.hasSelectionsMade); @@ -45,6 +46,7 @@ export const ManagePermissions: FunctionComponent = () = resetFieldsByKey(); resetObjectPermissionMap(); resetFieldPermissionMap(); + resetTabVisibilityPermissionMap(); } else if (!selectedOrg) { resetProfilesState(); resetSelectedProfilesPermSetState(); @@ -56,6 +58,7 @@ export const ManagePermissions: FunctionComponent = () = resetFieldsByKey(); resetObjectPermissionMap(); resetFieldPermissionMap(); + resetTabVisibilityPermissionMap(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedOrg, priorSelectedOrg]); @@ -75,6 +78,7 @@ export const ManagePermissions: FunctionComponent = () = ['fieldsByKey', fromPermissionsState.fieldsByKey], ['objectPermissionMap', fromPermissionsState.objectPermissionMap], ['fieldPermissionMap', fromPermissionsState.fieldPermissionMap], + ['tabVisibilityPermissionMap', fromPermissionsState.tabVisibilityPermissionMap], ]} /> {location.pathname.endsWith('/editor') && !hasSelectionsMade ? : } diff --git a/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditor.tsx b/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditor.tsx index 60bfbcc5b..d3cac9b9a 100644 --- a/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditor.tsx +++ b/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditor.tsx @@ -25,6 +25,7 @@ import { RequireMetadataApiBanner } from '../core/RequireMetadataApiBanner'; import * as fromJetstreamEvents from '../core/jetstream-events'; import ManagePermissionsEditorFieldTable from './ManagePermissionsEditorFieldTable'; import ManagePermissionsEditorObjectTable from './ManagePermissionsEditorObjectTable'; +import ManagePermissionsEditorTabVisibilityTable from './ManagePermissionsEditorTabVisibilityTable'; import * as fromPermissionsState from './manage-permissions.state'; import { usePermissionRecords } from './usePermissionRecords'; import { generateExcelWorkbookFromTable } from './utils/permission-manager-export-utils'; @@ -32,12 +33,16 @@ import { getConfirmationModalContent, getDirtyFieldPermissions, getDirtyObjectPermissions, + getDirtyTabVisibilityPermissions, getFieldColumns, getFieldRows, getObjectColumns, getObjectRows, + getTabVisibilityColumns, + getTabVisibilityRows, updateFieldRowsAfterSave, updateObjectRowsAfterSave, + updateTabVisibilityRowsAfterSave, } from './utils/permission-manager-table-utils'; import { DirtyRow, @@ -49,20 +54,27 @@ import { PermissionFieldSaveData, PermissionObjectSaveData, PermissionSaveResults, + PermissionTabVisibilitySaveData, PermissionTableFieldCell, PermissionTableFieldCellPermission, PermissionTableObjectCell, PermissionTableObjectCellPermission, PermissionTableSummaryRow, + PermissionTableTabVisibilityCell, + PermissionTableTabVisibilityCellPermission, + TabVisibilityPermissionDefinitionMap, + TabVisibilityPermissionRecordForSave, } from './utils/permission-manager-types'; import { clearPermissionErrorMessage, collectProfileAndPermissionIds, getUpdatedFieldPermissions, getUpdatedObjectPermissions, + getUpdatedTabVisibilityPermissions, permissionsHaveError, prepareFieldPermissionSaveData, prepareObjectPermissionSaveData, + prepareTabVisibilityPermissionSaveData, savePermissionRecords, updatePermissionSetRecords, } from './utils/permission-manager-utils'; @@ -113,10 +125,13 @@ export const ManagePermissionsEditor: FunctionComponent>>({}); const [objectFilter, setObjectFilter] = useState(''); + const [tabVisibilityColumns, setTabVisibilityColumns] = useState< + ColumnWithFilter[] + >([]); + const [tabVisibilityRows, setTabVisibilityRows] = useState(null); + const [visibleTabVisibilityRows, setVisibleTabVisibilityRows] = useState(null); + const [dirtyTabVisibilityRows, setDirtyTabVisibilityRows] = useState>>({}); + const [tabVisibilityFilter, setTabVisibilityFilter] = useState(''); + const [fieldColumns, setFieldColumns] = useState[]>([]); const [fieldRows, setFieldRows] = useState(null); const [visibleFieldRows, setVisibleFieldRows] = useState(null); @@ -134,9 +157,11 @@ export const ManagePermissionsEditor: FunctionComponent(0); const [dirtyFieldCount, setDirtyFieldCount] = useState(0); + const [dirtyTabVisibilityCount, setDirtyTabVisibilityCount] = useState(0); const [objectsHaveErrors, setObjectsHaveErrors] = useState(false); const [fieldsHaveErrors, setFieldsHaveErrors] = useState(false); + const [tabVisibilityHaveErrors, setTabVisibilityHaveErrors] = useState(false); useEffect(() => { isMounted.current = true; @@ -150,6 +175,7 @@ export const ManagePermissionsEditor: FunctionComponent { - if (objectPermissionMap && fieldPermissionMap) { + if (objectPermissionMap && fieldPermissionMap && tabVisibilityPermissionMap) { setObjectsHaveErrors(permissionsHaveError(objectPermissionMap)); setFieldsHaveErrors(permissionsHaveError(fieldPermissionMap)); + setTabVisibilityHaveErrors(permissionsHaveError(tabVisibilityPermissionMap)); } - }, [objectPermissionMap, fieldPermissionMap]); + }, [objectPermissionMap, fieldPermissionMap, tabVisibilityPermissionMap]); useEffect(() => { setDirtyFieldCount(Object.values(dirtyFieldRows).reduce((output, { dirtyCount }) => output + dirtyCount, 0)); @@ -184,6 +211,10 @@ export const ManagePermissionsEditor: FunctionComponent output + dirtyCount, 0)); }, [dirtyObjectRows]); + useEffect(() => { + setDirtyTabVisibilityCount(Object.values(dirtyTabVisibilityRows).reduce((output, { dirtyCount }) => output + dirtyCount, 0)); + }, [dirtyTabVisibilityRows]); + useEffect(() => { if (fieldRows && fieldFilter) { setVisibleFieldRows(fieldRows.filter(multiWordObjectFilter(['label', 'apiName'], fieldFilter))); @@ -200,6 +231,14 @@ export const ManagePermissionsEditor: FunctionComponent { + if (tabVisibilityRows && tabVisibilityFilter) { + setVisibleTabVisibilityRows(tabVisibilityRows.filter(multiWordObjectFilter(['label', 'apiName'], tabVisibilityFilter))); + } else { + setVisibleTabVisibilityRows(tabVisibilityRows); + } + }, [tabVisibilityFilter, tabVisibilityRows]); + const handleObjectBulkRowUpdate = useCallback((rows: PermissionTableObjectCell[], indexes?: number[]) => { const rowsByKey = getMapOf(rows, 'key'); setObjectRows((prevRows) => (prevRows ? prevRows?.map((row) => rowsByKey[row.key] || row) : rows)); @@ -211,12 +250,7 @@ export const ManagePermissionsEditor: FunctionComponent { - output += createIsDirty ? 1 : 0; - output += readIsDirty ? 1 : 0; - output += editIsDirty ? 1 : 0; - output += deleteIsDirty ? 1 : 0; - output += viewAllIsDirty ? 1 : 0; - output += modifyAllIsDirty ? 1 : 0; + output += createIsDirty || readIsDirty || editIsDirty || deleteIsDirty || viewAllIsDirty || modifyAllIsDirty ? 1 : 0; return output; }, 0 @@ -243,8 +277,7 @@ export const ManagePermissionsEditor: FunctionComponent { - output += readIsDirty ? 1 : 0; - output += editIsDirty ? 1 : 0; + output += readIsDirty || editIsDirty ? 1 : 0; return output; }, 0); newValues[rowKey] = { rowKey, dirtyCount, row }; @@ -259,14 +292,41 @@ export const ManagePermissionsEditor: FunctionComponent { + const rowsByKey = getMapOf(rows, 'key'); + setTabVisibilityRows((prevRows) => (prevRows ? prevRows.map((row) => rowsByKey[row.key] || row) : rows)); + indexes = indexes || rows.map((row, index) => index); + setDirtyTabVisibilityRows((priorValue) => { + const newValues = { ...priorValue }; + indexes?.forEach((rowIndex) => { + const row = rows[rowIndex]; + const rowKey = row.key; // e.x. Obj__c.Field__c + const dirtyCount = Object.values(row.permissions).reduce((output, { availableIsDirty, visibleIsDirty }) => { + output += availableIsDirty || visibleIsDirty ? 1 : 0; + return output; + }, 0); + newValues[rowKey] = { rowKey, dirtyCount, row }; + }); + // remove items with a dirtyCount of 0 to reduce future processing required + return Object.keys(newValues).reduce((output: MapOf>, key) => { + if (newValues[key].dirtyCount) { + output[key] = newValues[key]; + } + return output; + }, {}); + }); + }, []); + function initTableData( includeColumns = true, objectPermissionMapOverride?: MapOf, - fieldPermissionMapOverride?: MapOf + fieldPermissionMapOverride?: MapOf, + tabVisibilityPermissionMapOverride?: MapOf ) { if (includeColumns) { setObjectColumns(getObjectColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById)); setFieldColumns(getFieldColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById)); + setTabVisibilityColumns(getTabVisibilityColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById)); } const tempObjectRows = getObjectRows(selectedSObjects, objectPermissionMapOverride || objectPermissionMap || {}); setObjectRows(tempObjectRows); @@ -277,9 +337,16 @@ export const ManagePermissionsEditor: FunctionComponent 0) { + tabVisibilityPermissionData = prepareTabVisibilityPermissionSaveData(dirtyPermissions); + const ids = collectProfileAndPermissionIds(dirtyPermissions, profilesById, permissionSetsById); + profileIds = [...profileIds, ...ids.profileIds]; + permissionSetIds = [...permissionSetIds, ...ids.permissionSetIds]; + } + } let objectSaveResults: PermissionSaveResults[] | undefined = undefined; let fieldSaveResults: PermissionSaveResults[] | undefined = undefined; + let tabVisibilitySaveResults: + | PermissionSaveResults[] + | undefined = undefined; if (objectPermissionData) { objectSaveResults = await savePermissionRecords( @@ -353,6 +437,14 @@ export const ManagePermissionsEditor: FunctionComponent(selectedOrg, 'PermissionSetTabSetting', tabVisibilityPermissionData); + logger.log({ tabVisibilitySaveResults }); + } + // Update records so that SFDX is aware of the changes try { await updatePermissionSetRecords(selectedOrg, { @@ -378,6 +470,13 @@ export const ManagePermissionsEditor: FunctionComponent Reset Changes @@ -444,7 +554,7 @@ export const ManagePermissionsEditor: FunctionComponent Save @@ -495,6 +605,38 @@ export const ManagePermissionsEditor: FunctionComponent ), }, + + { + id: 'tab-visibility-permissions', + title: ( + + + + + Tab Visibility {dirtyTabVisibilityCount ? `(${dirtyTabVisibilityCount})` : ''} + + + ), + titleText: 'Tab Visibility', + disabled: true, + content: ( + + ), + }, + { id: 'field-permissions', title: ( diff --git a/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditorObjectTable.tsx b/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditorObjectTable.tsx index a34a80712..1ac19e0a4 100644 --- a/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditorObjectTable.tsx +++ b/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditorObjectTable.tsx @@ -31,9 +31,7 @@ export interface ManagePermissionsEditorObjectTableProps { export const ManagePermissionsEditorObjectTable = forwardRef( ({ columns, rows, totalCount, onFilter, onBulkUpdate, onDirtyRows }, ref) => { const [dirtyRows, setDirtyRows] = useState>>({}); - // const [expandedGroupIds, setExpandedGroupIds] = useState(() => new Set(rows.map((row) => row.sobject))); - // FIXME: figure out what we do and do not need here useImperativeHandle(ref, () => ({ resetChanges() { resetGridChanges({ rows, type: 'object' }); diff --git a/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditorTabVisibilityTable.tsx b/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditorTabVisibilityTable.tsx new file mode 100644 index 000000000..0a094ebe4 --- /dev/null +++ b/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsEditorTabVisibilityTable.tsx @@ -0,0 +1,85 @@ +import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; +import { MapOf } from '@jetstream/types'; +import { AutoFullHeightContainer, ColumnWithFilter, DataTable } from '@jetstream/ui'; +import { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; +import { resetGridChanges, updateRowsFromColumnAction } from './utils/permission-manager-table-utils'; +import { + DirtyRow, + FieldPermissionTypes, + ManagePermissionsEditorTableRef, + PermissionManagerTableContext, + PermissionTableSummaryRow, + PermissionTableTabVisibilityCell, +} from './utils/permission-manager-types'; + +function getRowKey(row: PermissionTableTabVisibilityCell) { + return row.key; +} + +// summary row is just a placeholder for rendered content +const SUMMARY_ROWS: PermissionTableSummaryRow[] = [{ type: 'HEADING' }, { type: 'ACTION' }]; + +export interface ManagePermissionsEditorTabVisibilityTableProps { + columns: ColumnWithFilter[]; + rows: PermissionTableTabVisibilityCell[]; + totalCount: number; + onFilter: (value: string) => void; + onBulkUpdate: (rows: PermissionTableTabVisibilityCell[], indexes?: number[]) => void; + onDirtyRows?: (values: MapOf>) => void; +} + +export const ManagePermissionsEditorTabVisibilityTable = forwardRef( + ({ columns, rows, totalCount, onFilter, onBulkUpdate, onDirtyRows }, ref) => { + const [dirtyRows, setDirtyRows] = useState>>({}); + + useImperativeHandle(ref, () => ({ + resetChanges() { + resetGridChanges({ rows, type: 'tabVisibility' }); + setDirtyRows({}); + }, + })); + + useNonInitialEffect(() => { + dirtyRows && onDirtyRows && onDirtyRows(dirtyRows); + }, [dirtyRows, onDirtyRows]); + + function handleColumnAction(action: 'selectAll' | 'unselectAll' | 'reset', columnKey: string) { + const [id, typeLabel] = columnKey.split('-'); + onBulkUpdate(updateRowsFromColumnAction('tabVisibility', action, typeLabel as FieldPermissionTypes, id, rows)); + } + + const handleRowsChange = useCallback( + (rows: PermissionTableTabVisibilityCell[], { indexes }) => { + onBulkUpdate(rows, indexes); + }, + [onBulkUpdate] + ); + + return ( +
+ + + +
+ ); + } +); + +export default ManagePermissionsEditorTabVisibilityTable; diff --git a/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsSelection.tsx b/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsSelection.tsx index 6f02a8dee..3bbb516be 100644 --- a/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsSelection.tsx +++ b/apps/jetstream/src/app/components/manage-permissions/ManagePermissionsSelection.tsx @@ -43,6 +43,7 @@ export const ManagePermissionsSelection: FunctionComponent({ key: 'permission-manager.sObjectsState', @@ -51,24 +55,6 @@ export const fieldsByKey = atom | null>({ default: null, }); -// // key = either Sobject name or field name with object prefix -// export const permissionsByObjectAndField = atom>({ -// key: 'permission-manager.permissionsByObjectAndField', -// default: null, -// }); - -// //KEY = {Id-SObjectName} ex: `${record.ParentId}-${record.Field}` -// export const objectPermissionsByKey = atom>({ -// key: 'permission-manager.objectPermissionsByKey', -// default: null, -// }); - -// //KEY = {Id-FieldName} ex: `${record.ParentId}-${record.Field}` -// export const fieldPermissionsByKey = atom>({ -// key: 'permission-manager.fieldPermissionsByKey', -// default: null, -// }); - export const objectPermissionMap = atom | null>({ key: 'permission-manager.objectPermissionMap', default: null, @@ -79,6 +65,11 @@ export const fieldPermissionMap = atom | nul default: null, }); +export const tabVisibilityPermissionMap = atom | null>({ + key: 'permission-manager.tabVisibilityPermissionMap', + default: null, +}); + /** * Returns true if all selections have been made */ diff --git a/apps/jetstream/src/app/components/manage-permissions/usePermissionRecords.tsx b/apps/jetstream/src/app/components/manage-permissions/usePermissionRecords.tsx index 6a299799b..74a3ca9e2 100644 --- a/apps/jetstream/src/app/components/manage-permissions/usePermissionRecords.tsx +++ b/apps/jetstream/src/app/components/manage-permissions/usePermissionRecords.tsx @@ -1,14 +1,29 @@ import { logger } from '@jetstream/shared/client-logger'; import { queryAll, queryAllUsingOffset } from '@jetstream/shared/data'; import { useRollbar } from '@jetstream/shared/ui-utils'; -import { EntityParticlePermissionsRecord, FieldPermissionRecord, MapOf, ObjectPermissionRecord, SalesforceOrgUi } from '@jetstream/types'; +import { getMapOf } from '@jetstream/shared/utils'; +import { + EntityParticlePermissionsRecord, + FieldPermissionRecord, + MapOf, + ObjectPermissionRecord, + SalesforceOrgUi, + TabDefinitionRecord, + TabVisibilityPermissionRecord, +} from '@jetstream/types'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { FieldPermissionDefinitionMap, ObjectPermissionDefinitionMap } from './utils/permission-manager-types'; +import { + FieldPermissionDefinitionMap, + ObjectPermissionDefinitionMap, + TabVisibilityPermissionDefinitionMap, +} from './utils/permission-manager-types'; import { getFieldDefinitionKey, getQueryForAllPermissionableFields, getQueryForFieldPermissions, getQueryObjectPermissions, + getQueryTabDefinition, + getQueryTabVisibilityPermissions, } from './utils/permission-manager-utils'; export function usePermissionRecords(selectedOrg: SalesforceOrgUi, sobjects: string[], profilePermSetIds: string[], permSetIds: string[]) { @@ -22,6 +37,7 @@ export function usePermissionRecords(selectedOrg: SalesforceOrgUi, sobjects: str const [objectPermissionMap, setObjectPermissionMap] = useState | null>(null); const [fieldPermissionMap, setFieldPermissionMap] = useState | null>(null); + const [tabVisibilityPermissionMap, setTabVisibilityPermissionMap] = useState | null>(null); useEffect(() => { isMounted.current = true; @@ -50,12 +66,26 @@ export function usePermissionRecords(selectedOrg: SalesforceOrgUi, sobjects: str queryAndCombineResults(selectedOrg, getQueryForAllPermissionableFields(sobjects), true, true), queryAndCombineResults(selectedOrg, getQueryObjectPermissions(sobjects, permSetIds, profilePermSetIds)), queryAndCombineResults(selectedOrg, getQueryForFieldPermissions(sobjects, permSetIds, profilePermSetIds)), - ]).then(([fieldDefinition, objectPermissions, fieldPermissions]) => { + queryAndCombineResults( + selectedOrg, + getQueryTabVisibilityPermissions(sobjects, permSetIds, profilePermSetIds) + ).then((record) => record.map((item) => ({ ...item, Name: item.Name.replace('standard-', '') }))), + queryAndCombineResults(selectedOrg, getQueryTabDefinition(sobjects), false, true).then((tabs) => + getMapOf(tabs, 'SobjectName') + ), + ]).then(([fieldDefinition, objectPermissions, fieldPermissions, tabVisibilityPermissions, tabDefinitions]) => { return { fieldsByObject: getAllFieldsByObject(fieldDefinition), fieldsByKey: groupFields(fieldDefinition), objectPermissionMap: getObjectPermissionMap(sobjects, profilePermSetIds, permSetIds, objectPermissions), fieldPermissionMap: getFieldPermissionMap(fieldDefinition, profilePermSetIds, permSetIds, fieldPermissions), + tabVisibilityPermissionMap: getTabVisibilityPermissionMap( + sobjects, + profilePermSetIds, + permSetIds, + tabVisibilityPermissions, + tabDefinitions + ), }; }); if (isMounted.current) { @@ -63,6 +93,7 @@ export function usePermissionRecords(selectedOrg: SalesforceOrgUi, sobjects: str setFieldsByKey(output.fieldsByKey); setObjectPermissionMap(output.objectPermissionMap); setFieldPermissionMap(output.fieldPermissionMap); + setTabVisibilityPermissionMap(output.tabVisibilityPermissionMap); } } catch (ex) { logger.warn('[useProfilesAndPermSets][ERROR]', ex.message); @@ -85,6 +116,7 @@ export function usePermissionRecords(selectedOrg: SalesforceOrgUi, sobjects: str /** permissionsByObjectAndField, objectPermissionsByKey, fieldPermissionsByKey, */ objectPermissionMap, fieldPermissionMap, + tabVisibilityPermissionMap, hasError, }; } @@ -226,3 +258,54 @@ function getFieldPermissionMap( return output; }, {}); } + +function getTabVisibilityPermissionMap( + sobjects: string[], + selectedProfiles: string[], + selectedPermissionSets: string[], + permissions: TabVisibilityPermissionRecord[], + tabDefinitions: MapOf +): MapOf { + const objectPermissionsByFieldByParentId = permissions.reduce((output: MapOf>, item) => { + output[item.Name] = output[item.Name] || {}; + output[item.Name][item.ParentId] = item; + return output; + }, {}); + + return sobjects.reduce((output: MapOf, item) => { + const currItem: TabVisibilityPermissionDefinitionMap = { + apiName: item, + label: item, + metadata: item, + permissions: {}, + permissionKeys: [], + canSetPermission: !!tabDefinitions[item], + }; + + function processProfileAndPermSet(id: string) { + const permissionRecord = objectPermissionsByFieldByParentId[item]?.[id]; + currItem.permissionKeys.push(id); + if (permissionRecord) { + currItem.permissions[id] = { + available: true, + visible: permissionRecord.Visibility === 'DefaultOn' ? true : false, + record: permissionRecord, + canSetPermission: true, + }; + } else { + currItem.permissions[id] = { + available: false, + visible: false, + record: null, + canSetPermission: !!tabDefinitions[item], + }; + } + } + + selectedProfiles.forEach(processProfileAndPermSet); + selectedPermissionSets.forEach(processProfileAndPermSet); + + output[item] = currItem; + return output; + }, {}); +} diff --git a/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-export-utils.ts b/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-export-utils.ts index 3cdca26db..c78a03119 100644 --- a/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-export-utils.ts +++ b/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-export-utils.ts @@ -1,29 +1,38 @@ import { excelWorkbookToArrayBuffer, getMaxWidthFromColumnContent, initXlsx } from '@jetstream/shared/ui-utils'; import { ColumnWithFilter } from '@jetstream/ui'; import * as XLSX from 'xlsx'; -import { PermissionTableFieldCell, PermissionTableObjectCell, PermissionTableSummaryRow } from './permission-manager-types'; +import { + PermissionTableFieldCell, + PermissionTableObjectCell, + PermissionTableSummaryRow, + PermissionTableTabVisibilityCell, +} from './permission-manager-types'; initXlsx(XLSX); -type ObjectOrFieldColumn = +type ObjectOrFieldOrTabVisibilityColumn = | ColumnWithFilter - | ColumnWithFilter; + | ColumnWithFilter + | ColumnWithFilter; export function generateExcelWorkbookFromTable( - objectData: { columns: ObjectOrFieldColumn[]; rows: PermissionTableObjectCell[] }, - fieldData: { columns: ObjectOrFieldColumn[]; rows: PermissionTableFieldCell[] } + objectData: { columns: ObjectOrFieldOrTabVisibilityColumn[]; rows: PermissionTableObjectCell[] }, + tabVisibilityData: { columns: ObjectOrFieldOrTabVisibilityColumn[]; rows: PermissionTableTabVisibilityCell[] }, + fieldData: { columns: ObjectOrFieldOrTabVisibilityColumn[]; rows: PermissionTableFieldCell[] } ) { const workbook = XLSX.utils.book_new(); const objectWorksheet = generateObjectWorksheet(objectData.columns, objectData.rows); + const tabVisibilityWorksheet = generateTabVisibilityWorksheet(tabVisibilityData.columns, tabVisibilityData.rows); const fieldWorksheet = generateFieldWorksheet(fieldData.columns, fieldData.rows); XLSX.utils.book_append_sheet(workbook, objectWorksheet, 'Object Permissions'); + XLSX.utils.book_append_sheet(workbook, tabVisibilityWorksheet, 'Tab Visibility'); XLSX.utils.book_append_sheet(workbook, fieldWorksheet, 'Field Permissions'); return excelWorkbookToArrayBuffer(workbook); } -function generateObjectWorksheet(columns: ObjectOrFieldColumn[], rows: PermissionTableObjectCell[]) { +function generateObjectWorksheet(columns: ObjectOrFieldOrTabVisibilityColumn[], rows: PermissionTableObjectCell[]) { const merges: XLSX.Range[] = []; const header1: string[] = ['']; const header2: string[] = ['Object']; @@ -77,7 +86,7 @@ function generateObjectWorksheet(columns: ObjectOrFieldColumn[], rows: Permissio return worksheet; } -function generateFieldWorksheet(columns: ObjectOrFieldColumn[], rows: PermissionTableFieldCell[]) { +function generateFieldWorksheet(columns: ObjectOrFieldOrTabVisibilityColumn[], rows: PermissionTableFieldCell[]) { const merges: XLSX.Range[] = []; const header1: string[] = ['', '', '']; const header2: string[] = ['Object', 'Field Api Name', 'Field Label']; @@ -121,3 +130,46 @@ function generateFieldWorksheet(columns: ObjectOrFieldColumn[], rows: Permission worksheet['!merges'] = merges; return worksheet; } + +function generateTabVisibilityWorksheet(columns: ObjectOrFieldOrTabVisibilityColumn[], rows: PermissionTableTabVisibilityCell[]) { + const merges: XLSX.Range[] = []; + const header1: string[] = ['']; + const header2: string[] = ['Object']; + const excelRows = [header1, header2]; + + const permissionKeys: string[] = []; + + columns + .filter((col) => col.key?.endsWith('-available')) + .forEach((col) => { + // header 1 + header1.push(col.name as string); + header1.push(''); + // header1.push(''); + // merge the added cells + merges.push({ + s: { r: 0, c: header1.length - 2 }, + e: { r: 0, c: header1.length - 1 }, + }); + // header 2 + header2.push('Available'); + header2.push('Visible'); + // keep track of group order to ensure same across all rows + permissionKeys.push(col.key.split('-')[0]); + }); + + rows.forEach((row, i) => { + const currRow = [row.sobject]; + permissionKeys.forEach((key) => { + const permission = row.permissions[key]; + currRow.push(permission.available ? 'TRUE' : 'FALSE'); + currRow.push(permission.visible ? 'TRUE' : 'FALSE'); + }); + excelRows.push(currRow); + }); + + const worksheet = XLSX.utils.aoa_to_sheet(excelRows); + worksheet['!cols'] = getMaxWidthFromColumnContent(excelRows, new Set([0])); + worksheet['!merges'] = merges; + return worksheet; +} diff --git a/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-table-utils.tsx b/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-table-utils.tsx index 94dc27cf9..961554283 100644 --- a/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-table-utils.tsx +++ b/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-table-utils.tsx @@ -17,7 +17,7 @@ import { setColumnFromType, } from '@jetstream/ui'; import startCase from 'lodash/startCase'; -import { Fragment, FunctionComponent, useContext, useRef, useState } from 'react'; +import { Fragment, FunctionComponent, useContext, useMemo, useRef, useState } from 'react'; import { RenderCellProps, RenderSummaryCellProps } from 'react-data-grid'; import { BulkActionCheckbox, @@ -29,14 +29,45 @@ import { ObjectPermissionItem, ObjectPermissionTypes, PermissionManagerTableContext, + PermissionTableCellExtended, PermissionTableFieldCell, PermissionTableFieldCellPermission, PermissionTableObjectCell, PermissionTableObjectCellPermission, PermissionTableSummaryRow, + PermissionTableTabVisibilityCell, + PermissionTableTabVisibilityCellPermission, PermissionType, + PermissionTypes, + TabVisibilityPermissionDefinitionMap, + TabVisibilityPermissionItem, + TabVisibilityPermissionTypes, } from './permission-manager-types'; +type PermissionTypeColumn = T extends 'object' + ? ColumnWithFilter + : T extends 'field' + ? ColumnWithFilter + : T extends 'tabVisibility' + ? ColumnWithFilter + : never; + +type PermissionActionType = T extends 'object' + ? 'Create' | 'Read' | 'Edit' | 'Delete' | 'ViewAll' | 'ModifyAll' + : T extends 'field' + ? 'Read' | 'Edit' + : T extends 'tabVisibility' + ? 'Available' | 'Visible' + : never; + +type PermissionActionAction = T extends 'object' + ? ObjectPermissionTypes + : T extends 'field' + ? FieldPermissionTypes + : T extends 'tabVisibility' + ? TabVisibilityPermissionTypes + : never; + function setObjectValue(which: ObjectPermissionTypes, row: PermissionTableObjectCell, permissionId: string, value: boolean) { const newRow = { ...row, permissions: { ...row.permissions, [permissionId]: { ...row.permissions[permissionId] } } }; const permission = newRow.permissions[permissionId]; @@ -75,6 +106,24 @@ function setFieldValue(which: FieldPermissionTypes, row: PermissionTableFieldCel return newRow; } +function setTabVisibilityValue( + which: TabVisibilityPermissionTypes, + row: PermissionTableTabVisibilityCell, + permissionId: string, + value: boolean +) { + const newRow = { ...row, permissions: { ...row.permissions, [permissionId]: { ...row.permissions[permissionId] } } }; + const permission = newRow.permissions[permissionId]; + if (which === 'available') { + permission.available = value; + setTabVisibilityDependencies(permission, value, [], ['visible']); + } else if (which === 'visible') { + permission.visible = value; + setTabVisibilityDependencies(permission, value, ['available'], []); + } + return newRow; +} + /** * Set dependent fields based on what selections are made */ @@ -115,11 +164,33 @@ function setFieldDependencies( permission.editIsDirty = permission.edit !== permission.record.edit; } -export function resetGridChanges(options: { rows: PermissionTableFieldCell[] | PermissionTableObjectCell[]; type: PermissionType }); +function setTabVisibilityDependencies( + permission: PermissionTableTabVisibilityCellPermission, + value: boolean, + setIfTrue: TabVisibilityPermissionTypes[], + setIfFalse: TabVisibilityPermissionTypes[] +) { + if (value) { + setIfTrue.forEach((prop) => (permission[prop] = value)); + } else { + setIfFalse.forEach((prop) => (permission[prop] = value)); + } + permission.availableIsDirty = permission.available !== permission.record.available; + permission.visibleIsDirty = permission.visible !== permission.record.visible; +} + +export function resetGridChanges(options: { + rows: PermissionTableFieldCell[] | PermissionTableObjectCell[] | PermissionTableTabVisibilityCell[]; + type: PermissionType; +}); +// eslint-disable-next-line no-redeclare export function resetGridChanges({ rows, type, -}: { rows: PermissionTableObjectCell[]; type: 'object' } | { rows: PermissionTableFieldCell[]; type: 'field' }) { +}: + | { rows: PermissionTableObjectCell[]; type: 'object' } + | { rows: PermissionTableFieldCell[]; type: 'field' } + | { rows: PermissionTableTabVisibilityCell[]; type: 'tabVisibility' }) { if (type === 'object') { return rows.map((row) => { row = { ...row }; @@ -149,7 +220,7 @@ export function resetGridChanges({ }); return row; }); - } else { + } else if (type === 'field') { return rows.map((row) => { Object.keys(row.permissions).forEach((permissionKey) => { let permission = row.permissions[permissionKey]; @@ -164,6 +235,21 @@ export function resetGridChanges({ }); return row; }); + } else if (type === 'tabVisibility') { + return rows.map((row) => { + Object.keys(row.permissions).forEach((permissionKey) => { + let permission = row.permissions[permissionKey]; + if (permission.visibleIsDirty) { + permission = { ...permission }; + row.permissions[permissionKey] = permission; + permission.available = permission.availableIsDirty ? !permission.available : permission.available; + permission.visible = permission.visibleIsDirty ? !permission.visible : permission.visible; + permission.availableIsDirty = false; + permission.visibleIsDirty = false; + } + }); + return row; + }); } } @@ -187,6 +273,12 @@ export function getDirtyFieldPermissions(dirtyRows: MapOf>) { + return Object.values(dirtyRows).flatMap(({ row }) => + Object.values(row.permissions).filter((permission) => permission.availableIsDirty || permission.visibleIsDirty) + ); +} + export function getObjectColumns( selectedProfiles: string[], selectedPermissionSets: string[], @@ -278,9 +370,7 @@ export function getObjectRows(selectedSObjects: string[], objectPermissionMap: M apiName: objectPermission.apiName, label: objectPermission.label, tableLabel: `${objectPermission.label} (${objectPermission.apiName})`, - // FIXME: are there circumstances where it should be read-only? - // // formula fields and auto-number fields do not allow editing - allowEditPermission: true, // objectPermission.metadata.IsCompound || objectPermission.metadata.IsCreatable, + allowEditPermission: true, permissions: {}, }; @@ -442,20 +532,6 @@ export function getFieldColumns( return newColumns; } -type PermissionTypeColumn = T extends 'object' - ? ColumnWithFilter - : T extends 'field' - ? ColumnWithFilter - : never; - -type PermissionActionType = T extends 'object' - ? 'Create' | 'Read' | 'Edit' | 'Delete' | 'ViewAll' | 'ModifyAll' - : T extends 'field' - ? 'Read' | 'Edit' - : never; - -type PermissionActionAction = T extends 'object' ? ObjectPermissionTypes : T extends 'field' ? FieldPermissionTypes : never; - function getColumnForProfileOrPermSet({ permissionType, isFirstItem, @@ -475,45 +551,78 @@ function getColumnForProfileOrPermSet({ }): PermissionTypeColumn { const numItems = permissionType === 'object' ? 6 : 2; const colWidth = Math.max(116, (`${label} (${type})`.length * 7.5) / numItems); - const column: ColumnWithFilter = { + const column: ColumnWithFilter = { name: `${label} (${type})`, key: `${id}-${actionKey}`, width: colWidth, filters: ['BOOLEAN_SET'], cellClass: (row) => { - const permission = row.permissions[id]; if (permissionType === 'object') { const permission = row.permissions[id] as PermissionTableObjectCellPermission; if ( (actionKey === 'create' && permission.createIsDirty) || + (actionKey === 'read' && permission.readIsDirty) || + (actionKey === 'edit' && permission.editIsDirty) || (actionKey === 'delete' && permission.deleteIsDirty) || (actionKey === 'viewAll' && permission.viewAllIsDirty) || (actionKey === 'modifyAll' && permission.modifyAllIsDirty) ) { return 'active-item-yellow-bg'; } - } - if ((actionKey === 'read' && permission.readIsDirty) || (actionKey === 'edit' && permission.editIsDirty)) { - return 'active-item-yellow-bg'; + } else if (permissionType === 'field') { + const permission = row.permissions[id] as PermissionTableFieldCellPermission; + if ((actionKey === 'read' && permission.readIsDirty) || (actionKey === 'edit' && permission.editIsDirty)) { + return 'active-item-yellow-bg'; + } + } else if (permissionType === 'tabVisibility') { + if ('canSetPermission' in row && !row.canSetPermission) { + return 'is-disabled'; + } + const permission = row.permissions[id] as PermissionTableTabVisibilityCellPermission; + if ((actionKey === 'available' && permission.availableIsDirty) || (actionKey === 'visible' && permission.visibleIsDirty)) { + return 'active-item-yellow-bg'; + } } return ''; }, - colSpan: (args) => (args.type === 'HEADER' && isFirstItem ? numItems : undefined), + colSpan: (args) => { + if (args.type === 'HEADER' && isFirstItem) { + return numItems; + } + // If the row is not editable, then we don't want to show the checkbox + if (args.type === 'ROW' && permissionType === 'tabVisibility' && 'canSetPermission' in args.row && !args.row.canSetPermission) { + return numItems; + } + return undefined; + }, renderCell: ({ row, onRowChange }) => { + // If the row is not editable, then we don't want to show the checkbox + if (permissionType === 'tabVisibility' && 'canSetPermission' in row && !row.canSetPermission) { + return null; + } + const errorMessage = row.permissions[id].errorMessage; const value = row.permissions[id][actionKey as any]; function handleChange(value: boolean) { if (permissionType === 'object') { - const newRow = setObjectValue(actionKey, row as PermissionTableObjectCell, id, value); + const newRow = setObjectValue(actionKey as PermissionActionAction<'object'>, row as PermissionTableObjectCell, id, value); onRowChange(newRow); - } else { + } else if (permissionType === 'field') { const newRow = setFieldValue(actionKey as PermissionActionAction<'field'>, row as PermissionTableFieldCell, id, value); onRowChange(newRow); + } else if (permissionType === 'tabVisibility') { + const newRow = setTabVisibilityValue( + actionKey as PermissionActionAction<'tabVisibility'>, + row as PermissionTableTabVisibilityCell, + id, + value + ); + onRowChange(newRow); } } - const disabled = actionKey === 'edit' && !row.allowEditPermission; + const disabled = actionKey === 'edit' && 'allowEditPermission' in row && !row.allowEditPermission; return (
!disabled && handleChange(!value)}> @@ -527,15 +636,6 @@ function getColumnForProfileOrPermSet({ }} disabled={disabled} > - {/* Rendering this custom checkbox was really slow, lot's of DOM elements */} - {/* */} {errorMessage && (
, + permissionSetsById: MapOf +) { + const newColumns: ColumnWithFilter[] = [ + { + ...setColumnFromType('tableLabel', 'text'), + name: 'Object', + key: 'tableLabel', + frozen: true, + width: 300, + getValue: ({ column, row }) => { + const data: PermissionTableFieldCell = row[column.key]; + return data && `${data.label} (${data.apiName})`; + }, + summaryCellClass: 'bg-color-gray-dark no-outline', + renderSummaryCell: ({ row }) => { + if (row.type === 'HEADING') { + return ; + } else if (row.type === 'ACTION') { + return ; + } + return undefined; + }, + cellClass: (row) => { + if ('canSetPermission' in row && !row.canSetPermission) { + return 'slds-text-color_weak'; + } + }, + }, + { + name: '', + key: '_ROW_ACTION', + width: 100, + resizable: false, + frozen: true, + renderCell: (props) => { + if (!props.row.canSetPermission) { + return ( +
+ + This object does not have a Tab. +
+ } + > + + +
+ ); + } + return ; + }, + summaryCellClass: ({ type }) => (type === 'HEADING' ? 'bg-color-gray' : null), + renderSummaryCell: ({ row }) => { + if (row.type === 'ACTION') { + return ; + } + return undefined; + }, + }, + ]; + // Create column groups for profiles + selectedProfiles.forEach((profileId) => { + const profile = profilesById[profileId]; + (['available', 'visible'] as const).forEach((actionKey, i) => { + newColumns.push( + getColumnForProfileOrPermSet({ + isFirstItem: i === 0, + permissionType: 'tabVisibility', + id: profileId, + type: 'Profile', + label: profile.Profile.Name, + actionType: startCase(actionKey) as 'Available' | 'Visible', + actionKey, + }) + ); + }); + }); + // Create column groups for permission sets + selectedPermissionSets.forEach((permissionSetId) => { + const permissionSet = permissionSetsById[permissionSetId]; + (['available', 'visible'] as const).forEach((actionKey, i) => { + newColumns.push( + getColumnForProfileOrPermSet({ + isFirstItem: i === 0, + permissionType: 'tabVisibility', + id: permissionSetId, + type: 'Permission Set', + label: permissionSet?.Name || '', + actionType: startCase(actionKey) as 'Available' | 'Visible', + actionKey, + }) + ); + }); + }); + return newColumns; +} + +export function getTabVisibilityRows(selectedSObjects: string[], tabVisibilityPermissionMap: MapOf) { + const rows: PermissionTableTabVisibilityCell[] = []; + orderStringsBy(selectedSObjects).forEach((sobject) => { + const fieldPermission = tabVisibilityPermissionMap[sobject]; + + const currRow: PermissionTableTabVisibilityCell = { + key: sobject, + sobject: sobject, + apiName: fieldPermission.apiName, + label: fieldPermission.label, + tableLabel: `${fieldPermission.label} (${fieldPermission.apiName})`, + canSetPermission: fieldPermission.canSetPermission, + permissions: {}, + }; + + fieldPermission.permissionKeys.forEach((key) => { + const item = fieldPermission.permissions[key]; + currRow.permissions[key] = getRowTabVisibilityPermissionFromFieldPermissionItem(key, sobject, item); + }); + + rows.push(currRow); + }); + return rows; +} + +export function updateTabVisibilityRowsAfterSave( + rows: PermissionTableTabVisibilityCell[], + tabVisibilityPermissionsMap: MapOf +): PermissionTableTabVisibilityCell[] { + return rows.map((oldRow) => { + const row = { ...oldRow }; + tabVisibilityPermissionsMap[row.key].permissionKeys.forEach((key) => { + row.permissions = { ...row.permissions }; + const objectPermission = tabVisibilityPermissionsMap[row.key].permissions[key]; + if (objectPermission.errorMessage) { + row.permissions[key] = { ...row.permissions[key], errorMessage: objectPermission.errorMessage }; + } else { + row.permissions[key] = getRowTabVisibilityPermissionFromFieldPermissionItem(key, row.sobject, objectPermission); + } + }); + return row; + }); +} + +function getRowTabVisibilityPermissionFromFieldPermissionItem( + key: string, + sobject: string, + item: TabVisibilityPermissionItem +): PermissionTableTabVisibilityCellPermission { + return { + rowKey: sobject, + parentId: key, + sobject, + visible: item.visible, + available: item.available, + visibleIsDirty: false, + availableIsDirty: false, + record: item, + errorMessage: item.errorMessage, + }; +} + /** * * JSX Components * */ -export function getConfirmationModalContent(dirtyObjectCount: number, dirtyFieldCount: number) { - let output; - const dirtyObj = ( - - - {dirtyObjectCount} Object {pluralizeFromNumber('Permission', dirtyObjectCount)} - - - ); - const dirtyField = ( - - - {dirtyFieldCount} Field {pluralizeFromNumber('Permission', dirtyFieldCount)} - - - ); - if (dirtyObjectCount && dirtyFieldCount) { - output = ( - - {dirtyObj} and {dirtyField} - - ); - } else if (dirtyObjectCount) { - output = dirtyObj; - } else { - output = dirtyField; - } +export function getConfirmationModalContent(dirtyObjectCount: number, dirtyFieldCount: number, dirtyTabVisibilityCount: number) { return (
-

You have made changes to {output}.

+

You have made changes to:

+
    + {[ + { + dirty: !!dirtyObjectCount, + jsx: ( + + {dirtyObjectCount} Object {pluralizeFromNumber('Permission', dirtyObjectCount)} + + ), + }, + { + dirty: !!dirtyFieldCount, + jsx: ( + + {dirtyFieldCount} Field {pluralizeFromNumber('Permission', dirtyFieldCount)} + + ), + }, + { + dirty: !!dirtyTabVisibilityCount, + jsx: ( + + {dirtyTabVisibilityCount} Tab Visibility {pluralizeFromNumber('Permission', dirtyTabVisibilityCount)} + + ), + }, + ] + .filter(({ dirty }) => dirty) + .map(({ jsx }, i) => ( +
  • {jsx}
  • + ))} +
); } @@ -691,10 +961,10 @@ export function getConfirmationModalContent(dirtyObjectCount: number, dirtyField /** * Performs bulk action against a column */ -export function updateRowsFromColumnAction( +export function updateRowsFromColumnAction( type: PermissionType, action: 'selectAll' | 'unselectAll' | 'reset', - which: ObjectPermissionTypes | FieldPermissionTypes, + which: PermissionTypes, id: string, rows: TRows[] ): TRows[] { @@ -731,23 +1001,34 @@ export function updateRowsFromColumnAction( +export function updateRowsFromRowAction( type: PermissionType, checkboxesById: MapOf, rows: TRows[] @@ -762,7 +1043,7 @@ export function updateRowsFromRowAction(type: PermissionType, rows: TRows[]): TRows[] { +export function resetRow(type: PermissionType, rows: TRows[]): TRows[] { const newRows = [...rows]; return newRows.map((row) => { row = { ...row }; @@ -823,7 +1111,7 @@ export function resetRow, value: boolean @@ -962,7 +1268,7 @@ export function updateCheckboxDependencies( checkboxesById['viewAll'].value = true; } } - } else { + } else if (type === 'field') { if (which === 'read') { checkboxesById['read'] = { ...checkboxesById['read'], value: value }; if (!checkboxesById['read'].value) { @@ -974,55 +1280,37 @@ export function updateCheckboxDependencies( checkboxesById['read'].value = true; } } + } else if (type === 'tabVisibility') { + if (which === 'available') { + checkboxesById['available'] = { ...checkboxesById['available'], value: value }; + if (!checkboxesById['available'].value) { + checkboxesById['visible'].value = false; + } + } else if (which === 'visible') { + checkboxesById['visible'] = { ...checkboxesById['visible'], value: value }; + if (checkboxesById['visible'].value) { + checkboxesById['available'].value = true; + } + } } } -function getDirtyCount({ row, type }: { row: PermissionTableObjectCell | PermissionTableFieldCell; type: PermissionType }); -function getDirtyCount({ - row, - type, -}: { row: PermissionTableObjectCell; type: 'object' } | { row: PermissionTableFieldCell; type: 'field' }): number { - let dirtyCount = 0; - if (type === 'object') { - // const data: PermissionTableObjectCell = rowNode.data; - dirtyCount = Object.values(row.permissions).reduce((output, permission) => { - output += permission.createIsDirty ? 1 : 0; - output += permission.readIsDirty ? 1 : 0; - output += permission.editIsDirty ? 1 : 0; - output += permission.deleteIsDirty ? 1 : 0; - output += permission.viewAllIsDirty ? 1 : 0; - output += permission.modifyAllIsDirty ? 1 : 0; - return output; - }, 0); - } else { - dirtyCount = Object.values(row.permissions).reduce((output, permission) => { - output += permission.readIsDirty ? 1 : 0; - output += permission.editIsDirty ? 1 : 0; - return output; - }, 0); - } - return dirtyCount; -} - /** * Row action renderer * * This component provides a popover that the user can open to make changes that apply to an entire row */ -export const RowActionRenderer: FunctionComponent> = ({ - column, - onRowChange, - row, -}) => { +export const RowActionRenderer: FunctionComponent> = ({ column, onRowChange, row }) => { const { type } = useContext(DataTableGenericContext) as PermissionManagerTableContext; const popoverRef = useRef(null); - const [dirtyItemCount, setDirtyItemCount] = useState(0); - const [checkboxes, setCheckboxes] = useState(defaultRowActionCheckboxes(type, row?.allowEditPermission)); + const [checkboxes, setCheckboxes] = useState(() => { + return defaultRowActionCheckboxes(type, 'allowEditPermission' in row ? row?.allowEditPermission : true); + }); /** * Set all dependencies when fields change */ - function handleChange(which: ObjectPermissionTypes, value: boolean) { + function handleChange(which: PermissionTypes, value: boolean) { const checkboxesById = getMapOf(checkboxes, 'id'); updateCheckboxDependencies(which, type, checkboxesById, value); if (type === 'object') { @@ -1034,8 +1322,10 @@ export const RowActionRenderer: FunctionComponent { const [isOpen, setIsOpen] = useState(false); const [checkboxes, setCheckboxes] = useState(defaultRowActionCheckboxes(type, true)); + const rowCount = useMemo(() => rows.filter((row) => !('canSetPermission' in row) || row.canSetPermission).length, [rows]); + /** * Set all dependencies when fields change */ - function handleChange(which: ObjectPermissionTypes, value: boolean) { + function handleChange(which: PermissionTypes, value: boolean) { const checkboxesById = getMapOf(checkboxes, 'id'); updateCheckboxDependencies(which, type, checkboxesById, value); if (type === 'object') { @@ -1163,8 +1451,10 @@ export const BulkActionRenderer = () => { checkboxesById['viewAll'], checkboxesById['modifyAll'], ]); - } else { + } else if (type === 'field') { setCheckboxes([checkboxesById['read'], checkboxesById['edit']]); + } else if (type === 'tabVisibility') { + setCheckboxes([checkboxesById['available'], checkboxesById['visible']]); } } @@ -1194,7 +1484,7 @@ export const BulkActionRenderer = () => { - @@ -1207,7 +1497,7 @@ export const BulkActionRenderer = () => {

This change will apply to{' '} - {formatNumber(rows.length)} {pluralizeFromNumber(type, rows.length)} + {formatNumber(rowCount)} {pluralizeFromNumber(type, rowCount)} {' '} and all selected profiles and permission sets

diff --git a/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-types.ts b/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-types.ts index c55013364..3d45385dd 100644 --- a/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-types.ts +++ b/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-types.ts @@ -6,22 +6,28 @@ import { ObjectPermissionRecord, RecordAttributes, RecordResult, + TabVisibilityPermissionRecord, } from '@jetstream/types'; -export type PermissionType = 'object' | 'field'; +export type PermissionType = 'object' | 'field' | 'tabVisibility'; export type ObjectPermissionTypes = 'create' | 'read' | 'edit' | 'delete' | 'viewAll' | 'modifyAll'; export type FieldPermissionTypes = 'read' | 'edit'; +export type TabVisibilityPermissionTypes = 'available' | 'visible'; + +export type PermissionTypes = ObjectPermissionTypes | FieldPermissionTypes | TabVisibilityPermissionTypes; export type BulkActionCheckbox = { - id: ObjectPermissionTypes; + id: PermissionTypes; label: string; value: boolean; disabled: boolean; }; +export type PermissionDefinitionMap = ObjectPermissionDefinitionMap | FieldPermissionDefinitionMap | TabVisibilityPermissionDefinitionMap; + export interface ObjectPermissionDefinitionMap { apiName: string; - label: string; // TODO: ;( + label: string; metadata: string; // FIXME: this should probably be Describe metadata // used to retain order of permissions permissionKeys: string[]; // this is permission set ids, which could apply to profile or perm set @@ -55,6 +61,31 @@ export interface FieldPermissionItem { errorMessage?: Maybe; } +export interface TabVisibilityPermissionDefinitionMap { + apiName: string; + label: string; + metadata: string; + /** + * False if a tab does not exist for this object + */ + canSetPermission: boolean; + // used to retain order of permissions + permissionKeys: string[]; // this is permission set ids, which could apply to profile or perm set + permissions: MapOf; +} + +export interface TabVisibilityPermissionItem { + available: boolean; + visible: boolean; + record?: Maybe; + /** + * False if a tab does not exist for this object + * In which case, permissions cannot be set + */ + canSetPermission: boolean; + errorMessage?: string; +} + export interface ObjectPermissionRecordForSave extends Omit { attributes: Partial; Id?: Maybe; @@ -65,11 +96,18 @@ export interface FieldPermissionRecordForSave extends Omit { + attributes: Partial; + Id?: string; + Name?: string; + ParentId?: string; +} + export interface PermissionSaveResults { dirtyPermission: DirtyPermType; dirtyPermissionIdx: number; - operation: 'insert' | 'update'; - record: RecordType; + operation: 'insert' | 'update' | 'delete'; + record?: RecordType; recordIdx: number; response?: Maybe; } @@ -83,6 +121,8 @@ export interface PermissionTableCell; } +export type PermissionTableCellExtended = PermissionTableObjectCell | PermissionTableFieldCell | PermissionTableTabVisibilityCell; + export interface PermissionTableObjectCell extends PermissionTableCell { allowEditPermission: boolean; // TODO: what other permissions may be restricted here?? } @@ -92,11 +132,15 @@ export interface PermissionTableFieldCell extends PermissionTableCell { + canSetPermission: boolean; +} + export interface PermissionTableSummaryRow { type: 'HEADING' | 'ACTION'; } -export interface PermissionTableObjectCellPermissionBase { +export interface PermissionTableObjectCellPermissionBase { rowKey: string; parentId: string; // permissions set (placeholder profile or permission set Id) sobject: string; @@ -126,6 +170,18 @@ export interface PermissionTableFieldCellPermission extends PermissionTableObjec editIsDirty: boolean; } +export interface PermissionTableTabVisibilityCellPermission extends PermissionTableObjectCellPermissionBase { + available: boolean; + availableIsDirty: boolean; + visible: boolean; + visibleIsDirty: boolean; +} + +export type PermissionTableCellPermission = + | PermissionTableObjectCellPermission + | PermissionTableFieldCellPermission + | PermissionTableTabVisibilityCellPermission; + export interface ManagePermissionsEditorTableProps { fieldsByObject: MapOf; } @@ -144,20 +200,29 @@ export interface PermissionObjectSaveData { permissionSaveResults: PermissionSaveResults[]; recordsToInsert: ObjectPermissionRecordForSave[]; recordsToUpdate: ObjectPermissionRecordForSave[]; + recordsToDelete: string[]; } export interface PermissionFieldSaveData { permissionSaveResults: PermissionSaveResults[]; recordsToInsert: FieldPermissionRecordForSave[]; recordsToUpdate: FieldPermissionRecordForSave[]; + recordsToDelete: string[]; +} + +export interface PermissionTabVisibilitySaveData { + permissionSaveResults: PermissionSaveResults[]; + recordsToInsert: TabVisibilityPermissionRecordForSave[]; + recordsToUpdate: TabVisibilityPermissionRecordForSave[]; + recordsToDelete: string[]; } export interface PermissionManagerTableContext { type: PermissionType; - rows: (PermissionTableObjectCell | PermissionTableFieldCell)[]; + rows: PermissionTableCellExtended[]; totalCount: number; onFilterRows: (value: string) => void; onRowAction: (action: 'selectAll' | 'unselectAll' | 'reset', columnKey: string) => void; onColumnAction: (action: 'selectAll' | 'unselectAll' | 'reset', columnKey: string) => void; - onBulkAction: (rows: (PermissionTableObjectCell | PermissionTableFieldCell)[]) => void; + onBulkAction: (rows: PermissionTableCellExtended[]) => void; } diff --git a/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-utils.ts b/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-utils.ts index 2eea1926b..33cab3cd2 100644 --- a/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-utils.ts +++ b/apps/jetstream/src/app/components/manage-permissions/utils/permission-manager-utils.ts @@ -11,19 +11,26 @@ import { PermissionSetWithProfileRecord, RecordResult, SalesforceOrgUi, + TabVisibilityPermissionRecord, } from '@jetstream/types'; import type { DescribeGlobalSObjectResult } from 'jsforce'; -import { composeQuery, getField, Query, WhereClause } from 'soql-parser-js'; +import { Query, WhereClause, composeQuery, getField } from 'soql-parser-js'; import { FieldPermissionDefinitionMap, FieldPermissionRecordForSave, ObjectPermissionDefinitionMap, ObjectPermissionRecordForSave, + PermissionDefinitionMap, PermissionFieldSaveData, PermissionObjectSaveData, PermissionSaveResults, + PermissionTabVisibilitySaveData, + PermissionTableCellPermission, PermissionTableFieldCellPermission, PermissionTableObjectCellPermission, + PermissionTableTabVisibilityCellPermission, + TabVisibilityPermissionDefinitionMap, + TabVisibilityPermissionRecordForSave, } from './permission-manager-types'; const MAX_OBJ_IN_QUERY = 100; @@ -58,6 +65,7 @@ export function prepareObjectPermissionSaveData(dirtyPermissions: PermissionTabl permissionSaveResults: PermissionSaveResults[]; recordsToInsert: ObjectPermissionRecordForSave[]; recordsToUpdate: ObjectPermissionRecordForSave[]; + recordsToDelete: string[]; }, perm, i @@ -93,7 +101,7 @@ export function prepareObjectPermissionSaveData(dirtyPermissions: PermissionTabl return output; }, - { permissionSaveResults: [], recordsToInsert: [], recordsToUpdate: [] } + { permissionSaveResults: [], recordsToInsert: [], recordsToUpdate: [], recordsToDelete: [] } ); } @@ -105,6 +113,7 @@ export function prepareFieldPermissionSaveData(dirtyPermissions: PermissionTable permissionSaveResults: PermissionSaveResults[]; recordsToInsert: FieldPermissionRecordForSave[]; recordsToUpdate: FieldPermissionRecordForSave[]; + recordsToDelete: string[]; }, perm, i @@ -137,22 +146,80 @@ export function prepareFieldPermissionSaveData(dirtyPermissions: PermissionTable return output; }, - { permissionSaveResults: [], recordsToInsert: [], recordsToUpdate: [] } + { permissionSaveResults: [], recordsToInsert: [], recordsToUpdate: [], recordsToDelete: [] } + ); +} + +export function prepareTabVisibilityPermissionSaveData( + dirtyPermissions: PermissionTableTabVisibilityCellPermission[] +): PermissionTabVisibilitySaveData { + return dirtyPermissions.reduce( + ( + output: { + // used to easily keep track of the input data with the actual results + permissionSaveResults: PermissionSaveResults[]; + recordsToInsert: TabVisibilityPermissionRecordForSave[]; + recordsToUpdate: TabVisibilityPermissionRecordForSave[]; + recordsToDelete: string[]; + }, + perm, + i + ) => { + let newRecord: TabVisibilityPermissionRecordForSave | undefined = undefined; + let recordIdx: number; + let operation: 'insert' | 'update' | 'delete' = 'insert'; + if (perm.record.record && !perm.available) { + output.recordsToDelete.push(perm.record.record.Id); + recordIdx = output.recordsToDelete.length - 1; + operation = 'delete'; + } else if (perm.record.record) { + newRecord = { + attributes: { type: 'PermissionSetTabSetting' }, + Id: perm.record.record.Id, + Visibility: perm.visible ? 'DefaultOn' : 'DefaultOff', + }; + output.recordsToUpdate.push(newRecord); + recordIdx = output.recordsToUpdate.length - 1; + operation = 'update'; + } else { + newRecord = { + attributes: { type: 'PermissionSetTabSetting' }, + Name: perm.sobject.endsWith('__c') ? perm.sobject : `standard-${perm.sobject}`, + Visibility: perm.visible ? 'DefaultOn' : 'DefaultOff', + ParentId: perm.parentId, + }; + output.recordsToInsert.push(newRecord); + recordIdx = output.recordsToInsert.length - 1; + } + + output.permissionSaveResults.push({ + dirtyPermission: perm, + dirtyPermissionIdx: i, + operation, + record: newRecord, + recordIdx, + }); + + return output; + }, + { permissionSaveResults: [], recordsToInsert: [], recordsToUpdate: [], recordsToDelete: [] } ); } export async function savePermissionRecords( org: SalesforceOrgUi, - type: 'ObjectPermissions' | 'FieldPermissions', + type: 'ObjectPermissions' | 'FieldPermissions' | 'PermissionSetTabSetting', preparedData: { permissionSaveResults: PermissionSaveResults[]; recordsToInsert: RecordType[]; recordsToUpdate: RecordType[]; + recordsToDelete: string[]; } ): Promise[]> { - const { permissionSaveResults, recordsToInsert, recordsToUpdate } = preparedData; + const { permissionSaveResults, recordsToInsert, recordsToUpdate, recordsToDelete } = preparedData; let recordInsertResults: RecordResult[] = []; let recordUpdateResults: RecordResult[] = []; + let recordDeleteResults: RecordResult[] = []; if (recordsToInsert.length) { recordInsertResults = ( await Promise.all( @@ -167,12 +234,21 @@ export async function savePermissionRecords( ) ).flat(); } + if (recordsToDelete.length) { + recordDeleteResults = ( + await Promise.all( + splitArrayToMaxSize(recordsToDelete, 200).map((ids) => sobjectOperation(org, type, 'delete', { ids }, { allOrNone: false })) + ) + ).flat(); + } permissionSaveResults.forEach((saveResults) => { if (saveResults.operation === 'insert') { saveResults.response = recordInsertResults[saveResults.recordIdx]; - } else { + } else if (saveResults.operation === 'update') { saveResults.response = recordUpdateResults[saveResults.recordIdx]; + } else if (saveResults.operation === 'delete') { + saveResults.response = recordDeleteResults[saveResults.recordIdx]; } }); @@ -209,7 +285,7 @@ export async function updatePermissionSetRecords( } export function collectProfileAndPermissionIds( - dirtyPermissions: (PermissionTableObjectCellPermission | PermissionTableFieldCellPermission)[], + dirtyPermissions: PermissionTableCellPermission[], profilesById: MapOf, permissionSetsById: MapOf ) { @@ -408,9 +484,88 @@ export function getUpdatedFieldPermissions( return output; } -export function clearPermissionErrorMessage( - permissionMap: MapOf -): MapOf { +export function getUpdatedTabVisibilityPermissions( + objectPermissionMap: MapOf, + permissionSaveResults: PermissionSaveResults[] +) { + const output: MapOf = { ...objectPermissionMap }; + // remove all error messages across all objects + Object.keys(output).forEach((key) => { + output[key] = { ...output[key] }; + output[key].permissionKeys.forEach((permKey) => { + output[key].permissions = { + ...output[key].permissions, + [permKey]: { + ...output[key].permissions[permKey], + errorMessage: undefined, + }, + }; + }); + }); + + permissionSaveResults.forEach(({ dirtyPermission, operation, response }) => { + const fieldKey = dirtyPermission.sobject; + if (!isErrorResponse(response)) { + const fieldPermission: Partial = { + Id: response?.id, + ParentId: dirtyPermission.parentId, + Visibility: dirtyPermission.visible ? 'DefaultOn' : 'DefaultOff', + Name: dirtyPermission.sobject, + // missing Parent related lookup, as we do not have data for it + }; + if (operation === 'insert') { + output[fieldKey] = { + ...output[fieldKey], + permissions: { + ...output[fieldKey].permissions, + [dirtyPermission.parentId]: { + ...output[fieldKey].permissions[dirtyPermission.parentId], + available: dirtyPermission.available, + visible: dirtyPermission.visible, + record: fieldPermission as TabVisibilityPermissionRecord, + }, + }, + }; + } else { + const isDelete = !dirtyPermission.available; + output[fieldKey] = { + ...output[fieldKey], + permissions: { + ...output[fieldKey].permissions, + [dirtyPermission.parentId]: { + ...output[fieldKey].permissions[dirtyPermission.parentId], + available: dirtyPermission.available, + visible: dirtyPermission.visible, + record: isDelete ? null : (fieldPermission as TabVisibilityPermissionRecord), + }, + }, + }; + } + } else { + logger.warn('[SAVE ERROR]', { dirtyPermission, response }); + output[fieldKey] = { + ...output[fieldKey], + permissions: { + ...output[fieldKey].permissions, + [dirtyPermission.parentId]: { + ...output[fieldKey].permissions[dirtyPermission.parentId], + errorMessage: response.errors + .map((err) => + // (field not detectable in advance): Field Name: bad value for restricted picklist field: X.X + err.statusCode === 'INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST' + ? 'Salesforce does not allow modification of permissions for this field.' + : err.message + ) + .join('\n'), + }, + }, + }; + } + }); + return output; +} + +export function clearPermissionErrorMessage(permissionMap: MapOf): MapOf { return Object.keys(permissionMap).reduce((output: MapOf, key) => { output[key] = { ...permissionMap[key] }; output[key].permissions = { ...output[key].permissions }; @@ -421,9 +576,7 @@ export function clearPermissionErrorMessage( - permissionMap: MapOf -): boolean { +export function permissionsHaveError(permissionMap: MapOf): boolean { return Object.values(permissionMap).some((item) => Object.values(item.permissions).some((permission) => permission.errorMessage)); } @@ -567,6 +720,70 @@ export function getQueryForFieldPermissions(allSobjects: string[], profilePermSe return queries; } +/** + * Gets query object permissions + * @param allSobjects + * @param permSetIds + * @param profilePermSetIds + * @returns query object permissions + */ +export function getQueryTabVisibilityPermissions(allSobjects: string[], permSetIds: string[], profilePermSetIds: string[]): string[] { + const queries = splitArrayToMaxSize(allSobjects, MAX_OBJ_IN_QUERY).map((sobjects) => { + const query: Query = { + fields: [ + getField('Id'), + getField('Name'), + getField('Visibility'), + getField('ParentId'), + getField('Parent.Id'), + getField('Parent.Name'), + getField('Parent.IsOwnedByProfile'), + getField('Parent.ProfileId'), + ], + sObject: 'PermissionSetTabSetting', + where: getWhereClauseForPermissionQuery( + sobjects.map((sobject) => (sobject.endsWith('__c') ? sobject : `standard-${sobject}`)), + permSetIds, + profilePermSetIds, + 'Name' + ), + orderBy: { + field: 'Name', + order: 'ASC', + }, + }; + + return composeQuery(query); + }); + logger.log('getQueryTabVisibilityPermissions()', queries); + return queries; +} + +export function getQueryTabDefinition(allSobjects: string[]): string[] { + const queries = splitArrayToMaxSize(allSobjects, MAX_OBJ_IN_QUERY).map((sobjects) => { + const query: Query = { + fields: [getField('Id'), getField('Name'), getField('Label'), getField('SobjectName')], + sObject: 'TabDefinition', + where: { + left: { + field: 'SobjectName', + operator: 'IN', + value: sobjects, + literalType: 'STRING', + }, + }, + orderBy: { + field: 'SobjectName', + order: 'ASC', + }, + }; + + return composeQuery(query); + }); + logger.log('getQueryTabDefinition()', queries); + return queries; +} + /** * Build WHERE clause for object/field permissions * Assumptions: @@ -577,13 +794,18 @@ export function getQueryForFieldPermissions(allSobjects: string[], profilePermSe * @param permSetIds * @param profilePermSetIds */ -function getWhereClauseForPermissionQuery(sobjects: string[], profilePermSetIds: string[], permSetIds: string[]): WhereClause | undefined { +function getWhereClauseForPermissionQuery( + sobjects: string[], + profilePermSetIds: string[], + permSetIds: string[], + sobjectNameField: 'SobjectType' | 'Name' = 'SobjectType' +): WhereClause | undefined { if (!sobjects.length || (!permSetIds.length && !profilePermSetIds.length)) { return undefined; } return { left: { - field: 'SobjectType', + field: sobjectNameField, operator: 'IN', value: sobjects, literalType: 'STRING', diff --git a/libs/icon-factory/src/lib/icon-factory.tsx b/libs/icon-factory/src/lib/icon-factory.tsx index a5e73a29c..67ab77ab7 100644 --- a/libs/icon-factory/src/lib/icon-factory.tsx +++ b/libs/icon-factory/src/lib/icon-factory.tsx @@ -31,6 +31,7 @@ import StandardIcon_MultiPicklist from './icons/standard/MultiPicklist'; import StandardIcon_Opportunity from './icons/standard/Opportunity'; import StandardIcon_Outcome from './icons/standard/Outcome'; import StandardIcon_Portal from './icons/standard/Portal'; +import StandardIcon_PortalRolesAndSubordinates from './icons/standard/PortalRolesAndSubordinates'; import StandardIcon_ProductConsumed from './icons/standard/ProductConsumed'; import StandardIcon_Record from './icons/standard/Record'; import StandardIcon_RecordCreate from './icons/standard/RecordCreate'; @@ -171,6 +172,7 @@ const standardIcons = { opportunity: StandardIcon_Opportunity, outcome: StandardIcon_Outcome, portal: StandardIcon_Portal, + portal_roles_and_subordinates: StandardIcon_PortalRolesAndSubordinates, product_consumed: StandardIcon_ProductConsumed, record_create: StandardIcon_RecordCreate, record_lookup: StandardIcon_RecordLookup, diff --git a/libs/types/src/lib/salesforce/types.ts b/libs/types/src/lib/salesforce/types.ts index 88344c57f..583c432d1 100644 --- a/libs/types/src/lib/salesforce/types.ts +++ b/libs/types/src/lib/salesforce/types.ts @@ -289,6 +289,25 @@ export type TabPermissionRecordInsert = { Visibility: 'DefaultOn' | 'DefaultOff'; }; +export interface TabVisibilityPermissionRecord { + Id: string; + Name: string; + Visibility: 'DefaultOff' | 'DefaultOn'; + ParentId: string; + Parent: PermissionPermissionSetRecord; +} + +export interface TabDefinitionRecord { + Id: string; + Name: string; + Label: string; + SobjectName: string; +} + +export type TabVisibilityPermissionRecordInsert = Omit & { + attributes?: { type: 'ObjectPermissions' }; +}; + export type BulkJobWithBatches = BulkJob & { batches: BulkJobBatchInfo[] }; export interface BulkJob {