Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP POC Allow working with record types #348 #349

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export async function checkMetadataResults(req: Request, res: Response, next: Ne

export async function retrievePackageFromLisMetadataResults(req: Request, res: Response, next: NextFunction) {
try {
const types: MapOf<ListMetadataResult[]> = req.body;
const types: MapOf<ListMetadataResult[] | { fullName: string }[]> = req.body;
const conn: jsforce.Connection = res.locals.jsforceConn;

const results = await conn.metadata.retrieve(getRetrieveRequestFromListMetadata(types, conn.version));
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app/services/sf-misc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ensureArray, orderObjectsBy } from '@jetstream/shared/utils';
import { ListMetadataResult, MapOf } from '@jetstream/types';
import type { PackageTypeMembers, RetrieveRequest } from 'jsforce';
import { get as lodashGet, isObjectLike, isString } from 'lodash';
import { isObjectLike, isString, get as lodashGet } from 'lodash';
import { create as xmlBuilder } from 'xmlbuilder2';
import { UserFacingError } from '../utils/error-handler';

Expand Down Expand Up @@ -33,7 +33,7 @@ export function buildPackageXml(types: MapOf<ListMetadataResult[]>, version: str
return packageNode.end({ prettyPrint });
}

export function getRetrieveRequestFromListMetadata(types: MapOf<ListMetadataResult[]>, version: string) {
export function getRetrieveRequestFromListMetadata<T extends { fullName: string }>(types: MapOf<T[]>, version: string) {
// https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_retrieve_request.htm
const retrieveRequest: RetrieveRequest = {
apiVersion: version,
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export const ManagePermissionsEditorFieldTable = forwardRef<any, ManagePermissio
[onBulkUpdate]
);

// TODO: check if we have rows, otherwise there may not be record types enabled on selected objects

return (
<div>
<AutoFullHeightContainer fillHeight setHeightAttr bottomBuffer={15}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ListItem,
MapOf,
PermissionSetNoProfileRecord,
PermissionSetProfileRecord,
PermissionSetWithProfileRecord,
} from '@jetstream/types';
import type { DescribeGlobalSObjectResult } from 'jsforce';
Expand Down Expand Up @@ -51,24 +52,6 @@ export const fieldsByKey = atom<MapOf<EntityParticlePermissionsRecord> | null>({
default: null,
});

// // key = either Sobject name or field name with object prefix
// export const permissionsByObjectAndField = atom<MapOf<string[]>>({
// key: 'permission-manager.permissionsByObjectAndField',
// default: null,
// });

// //KEY = {Id-SObjectName} ex: `${record.ParentId}-${record.Field}`
// export const objectPermissionsByKey = atom<MapOf<ObjectPermissionRecord>>({
// key: 'permission-manager.objectPermissionsByKey',
// default: null,
// });

// //KEY = {Id-FieldName} ex: `${record.ParentId}-${record.Field}`
// export const fieldPermissionsByKey = atom<MapOf<FieldPermissionRecord>>({
// key: 'permission-manager.fieldPermissionsByKey',
// default: null,
// });

export const objectPermissionMap = atom<MapOf<ObjectPermissionDefinitionMap> | null>({
key: 'permission-manager.objectPermissionMap',
default: null,
Expand All @@ -79,6 +62,8 @@ export const fieldPermissionMap = atom<MapOf<FieldPermissionDefinitionMap> | nul
default: null,
});

// TODO: add stuff for record type permissions

/**
* Returns true if all selections have been made
*/
Expand Down Expand Up @@ -122,3 +107,12 @@ export const permissionSetsByIdSelector = selector<MapOf<PermissionSetNoProfileR
return {};
},
});

export const selectedProfilesSelector = selector<PermissionSetProfileRecord[]>({
key: 'permission-manager.selectedProfilesSelector',
get: ({ get }) => {
const selectedProfiles = get(selectedProfilesPermSetState);
const profilesById = get(profilesByIdSelector);
return selectedProfiles.map((profileId) => profilesById[profileId].Profile).filter(Boolean);
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { AutoFullHeightContainer, ColumnWithFilter, DataTable } from '@jetstream/ui';
import groupBy from 'lodash/groupBy';
import { forwardRef, useCallback, useState } from 'react';
import { RowHeightArgs, RowsChangeData } from 'react-data-grid';
import { PermissionManagerRecordTypeRow, PermissionTableSummaryRow } from '../utils/permission-manager-types';

const getRowKey = ({ key }: PermissionManagerRecordTypeRow) => key;
function getRowHeight({ type, row }: RowHeightArgs<PermissionManagerRecordTypeRow>) {
if (type === 'ROW') {
return 40;
}
return 34;
}
const SUMMARY_ROWS: PermissionTableSummaryRow[] = [{ type: 'HEADING' }];
const groupedRows = ['sobject'] as const;

export interface ManagePermissionRecordTypesProps {
columns: ColumnWithFilter<PermissionManagerRecordTypeRow, PermissionTableSummaryRow>[];
rows: PermissionManagerRecordTypeRow[];
loading: boolean; // TODO: do I need this?
hasError?: boolean;
onBulkUpdate: (
rows: PermissionManagerRecordTypeRow[],
changeData: RowsChangeData<PermissionManagerRecordTypeRow, PermissionTableSummaryRow>
) => void;
}

export const ManagePermissionRecordTypes = forwardRef<any, ManagePermissionRecordTypesProps>(
({ columns, rows, loading, hasError, onBulkUpdate }, ref) => {
const [expandedGroupIds, setExpandedGroupIds] = useState(() => new Set<unknown>(rows.map((row) => row.sobject)));

const handleRowsChange = useCallback(
(rows: PermissionManagerRecordTypeRow[], changeData: RowsChangeData<PermissionManagerRecordTypeRow, PermissionTableSummaryRow>) => {
onBulkUpdate(rows, changeData);
},
[onBulkUpdate]
);

return (
<div>
<AutoFullHeightContainer fillHeight setHeightAttr bottomBuffer={15}>
<DataTable
columns={columns}
data={rows}
getRowKey={getRowKey}
topSummaryRows={SUMMARY_ROWS}
onRowsChange={handleRowsChange}
// context={
// {
// type: 'field',
// totalCount,
// onFilterRows: onFilter,
// onColumnAction: handleColumnAction,
// onBulkAction: onBulkUpdate,
// } as PermissionManagerTableContext
// }
rowHeight={getRowHeight}
summaryRowHeight={38}
groupBy={groupedRows}
rowGrouper={groupBy}
expandedGroupIds={expandedGroupIds}
onExpandedGroupIdsChange={(items) => setExpandedGroupIds(items)}
/>
</AutoFullHeightContainer>
</div>
);
}
);

export default ManagePermissionRecordTypes;
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { logger } from '@jetstream/shared/client-logger';
import { PROFILE_LABEL_TO_FULL_NAME_MAP } from '@jetstream/shared/constants';
import { queryAll, retrieveMetadataFromListMetadata } from '@jetstream/shared/data';
import {
ParsedProfile,
ParsedRecordTypePicklistValues,
parseProfile,
parseRecordTypePicklistValuesFromCustomObject,
pollRetrieveMetadataResultsUntilDone,
useRollbar,
} from '@jetstream/shared/ui-utils';
import { encodeHtmlEntitySalesforceCompatible, getMapOf, orderObjectsBy } from '@jetstream/shared/utils';
import { PermissionSetProfileRecord, SalesforceOrgUi } from '@jetstream/types';
import JSZip from 'jszip';
import isString from 'lodash/isString';
import { useCallback, useEffect, useRef, useState } from 'react';
import { composeQuery, getField } from 'soql-parser-js';

export type RecordTypeData = Awaited<ReturnType<typeof fetchRecordTypeData>>;

export function usePermissionRecordTypes(selectedOrg: SalesforceOrgUi, sobjects: string[], profiles: PermissionSetProfileRecord[]) {
const isMounted = useRef(true);
const rollbar = useRollbar();
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);

const [recordTypeData, setRecordTypeData] = useState<RecordTypeData>();

useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);

useEffect(() => {
if (selectedOrg) {
fetchMetadata();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedOrg, sobjects]);

const fetchMetadata = useCallback(async () => {
try {
// init and reset in case of prior
setLoading(true);
setHasError(false);
setRecordTypeData(undefined);

const results = await fetchRecordTypeData(
selectedOrg,
sobjects,
profiles.map(({ Name }) => Name)
);

logger.log('[usePermissionRecordTypes]', results);

if (isMounted.current) {
setRecordTypeData(results);
}
} catch (ex) {
logger.warn('[usePermissionRecordTypes][ERROR]', ex.message);
rollbar.error('[usePermissionRecordTypes][ERROR]', ex);
if (isMounted.current) {
setHasError(true);
}
} finally {
if (isMounted.current) {
setLoading(false);
}
}
}, [profiles, rollbar, selectedOrg, sobjects]);

return {
loading,
hasError,
recordTypeData,
};
}

/**
*
* @param selectedOrg
* @param sobjects
* @param profiles
* @returns
*/
async function fetchRecordTypeData(selectedOrg: SalesforceOrgUi, sobjects: string[], profileNames: string[]) {
const layouts = await queryAll<{ EntityDefinition: { QualifiedApiName: string }; Name: string }>(
selectedOrg,
composeQuery({
sObject: 'Layout',
fields: [getField('Name'), getField('EntityDefinition.QualifiedApiName')],
where: {
left: {
field: 'EntityDefinition.QualifiedApiName',
operator: 'IN',
value: sobjects,
literalType: 'STRING',
},
},
}),
true
).then((results) =>
results.queryResults.records.map((record) => ({
...record,
fullName: encodeHtmlEntitySalesforceCompatible(`${record.EntityDefinition.QualifiedApiName}-${record.Name}`),
}))
);

const recordTypes = await queryAll<{ SobjectType: string; DeveloperName: string }>(
selectedOrg,
composeQuery({
sObject: 'RecordType',
fields: [getField('DeveloperName'), getField('SobjectType')],
where: {
left: {
field: 'SobjectType',
operator: 'IN',
value: sobjects,
literalType: 'STRING',
},
},
}),
false
).then((results) =>
results.queryResults.records.map((record) => ({
...record,
fullName: encodeHtmlEntitySalesforceCompatible(`${record.SobjectType}.${record.DeveloperName}`),
}))
);

const { id } = await retrieveMetadataFromListMetadata(selectedOrg, {
CustomObject: sobjects.map((fullName) => ({ fullName })),
Layout: layouts,
Profile: profileNames.map((profile) => ({ fullName: PROFILE_LABEL_TO_FULL_NAME_MAP[profile] || profile })),
RecordType: recordTypes,
});

const recordTypeVisibilitiesMap = getMapOf(
recordTypes.map((recordType) => ({
default: false,
recordType: decodeURIComponent(recordType.fullName),
visible: false,
})),
'recordType'
);

const results = await pollRetrieveMetadataResultsUntilDone(selectedOrg, id);

if (isString(results.zipFile)) {
const salesforcePackage = await JSZip.loadAsync(results.zipFile, { base64: true });

const profilesWithLayoutAndRecordTypeVisibilities = await Promise.all(
profileNames.map((profile): Promise<{ profile: string; profileFullName: string } & ParsedProfile> => {
const decodedKeyMap = Object.keys(salesforcePackage.files).reduce((acc, item) => {
acc[decodeURIComponent(item)] = item;
return acc;
}, {});

const file = salesforcePackage.file(decodedKeyMap[`profiles/${PROFILE_LABEL_TO_FULL_NAME_MAP[profile] || profile}.profile`]);
if (file) {
return file.async('string').then((results) => {
logger.log('[fetchRecordTypeData][profile]', { profile, results });
const parsedResults = parseProfile(results);
return {
profile,
profileFullName: PROFILE_LABEL_TO_FULL_NAME_MAP[profile] || profile,
layoutAssignments: parsedResults.layoutAssignments,
// Fill in missing record types with defaults since they are omitted from the metadata response
recordTypeVisibilities: orderObjectsBy(
Object.values({ ...recordTypeVisibilitiesMap, ...getMapOf(parsedResults.recordTypeVisibilities, 'recordType') }),
'recordType'
),
};
});
}
return Promise.resolve({
profile,
profileFullName: PROFILE_LABEL_TO_FULL_NAME_MAP[profile] || profile,
recordTypeVisibilities: Object.values(recordTypeVisibilitiesMap),
layoutAssignments: [], // FIXME: this needs to have the defaults so that we know if it was modified
});
})
);

const sobjectsWithRecordTypes = await Promise.all(
sobjects.map((sobject): Promise<{ sobject: string; picklists: ParsedRecordTypePicklistValues }> => {
const decodedKeyMap = Object.keys(salesforcePackage.files).reduce((acc, item) => {
acc[decodeURIComponent(item)] = item;
return acc;
}, {});
const file = salesforcePackage.file(decodedKeyMap[`objects/${decodedKeyMap[sobject]}.object`]);
if (file) {
return file.async('string').then((results) => ({
sobject,
picklists: parseRecordTypePicklistValuesFromCustomObject(results),
}));
}
return Promise.resolve({ sobject, picklists: [] });
})
);

return {
layouts,
recordTypes,
profilesWithLayoutAndRecordTypeVisibilities,
sobjectsWithRecordTypes,
};
}

throw new Error('Metadata request did not contain any content');
}
Loading