From 7761bc236f1e277d287976455b01627ae9ec3975 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 1 Jun 2024 09:49:31 -0500 Subject: [PATCH 1/3] Allow bulk updating multiple fields There are cases where someone may want to update multiple fields on a record Moved shared code into utils and added unit tests for most utility methods resolves #907 --- .../BulkUpdateFromQueryModal.tsx | 106 ++-- .../MassUpdateRecordsDeployment.tsx | 3 +- .../selection/MassUpdateRecordsObject.tsx | 25 +- .../selection/MassUpdateRecordsObjects.tsx | 31 +- .../selection/MassUpdateRecordsSelection.tsx | 5 +- .../selection/useMassUpdateFieldItems.ts | 139 +++-- libs/shared/ui-core-shared/jest.config.ts | 1 - libs/shared/ui-core-worker/jest.config.ts | 1 - libs/shared/ui-core/jest.config.ts | 1 - .../MassUpdateRecordTransformationText.tsx | 20 +- .../MassUpdateRecordsDeploymentRow.tsx | 44 +- .../MassUpdateRecordsObjectRow.tsx | 126 ++-- .../MassUpdateRecordsObjectRowField.tsx | 17 +- .../mass-update-records.utils.spec.ts | 550 ++++++++++++++++++ .../mass-update-records.types.tsx | 8 +- .../mass-update-records.utils.tsx | 254 +++++--- .../mass-update-records/useDeployRecords.ts | 69 +-- libs/shared/ui-core/tsconfig.json | 4 +- .../ui-utils/src/lib/shared-ui-utils.ts | 2 +- libs/shared/ui-utils/tsconfig.spec.json | 2 +- 20 files changed, 1072 insertions(+), 336 deletions(-) create mode 100644 libs/shared/ui-core/src/mass-update-records/__tests__/mass-update-records.utils.spec.ts diff --git a/apps/jetstream/src/app/components/query/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx b/apps/jetstream/src/app/components/query/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx index 3030ea044..2f30394fc 100644 --- a/apps/jetstream/src/app/components/query/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx +++ b/apps/jetstream/src/app/components/query/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/react'; import { clearCacheForOrg } from '@jetstream/shared/data'; -import { convertDateToLocale, filterLoadSobjects, useRollbar } from '@jetstream/shared/ui-utils'; -import { getRecordIdFromAttributes } from '@jetstream/shared/utils'; -import { Field, ListItem, Maybe, SalesforceOrgUi, SalesforceRecord } from '@jetstream/types'; +import { convertDateToLocale, filterLoadSobjects, formatNumber, useRollbar } from '@jetstream/shared/ui-utils'; +import { getRecordIdFromAttributes, pluralizeFromNumber } from '@jetstream/shared/utils'; +import { ListItem, Maybe, SalesforceOrgUi, SalesforceRecord } from '@jetstream/types'; import { Checkbox, Grid, @@ -19,16 +19,17 @@ import { useFieldListItemsWithDrillIn, } from '@jetstream/ui'; import { + DEFAULT_FIELD_CONFIGURATION, DeployResults, MassUpdateRecordsDeploymentRow, MassUpdateRecordsObjectRow, - TransformationOptions, + MetadataRowConfiguration, applicationCookieState, fetchRecordsWithRequiredFields, useDeployRecords, } from '@jetstream/ui-core'; import isNumber from 'lodash/isNumber'; -import { ChangeEvent, FunctionComponent, useCallback, useEffect, useState } from 'react'; +import { ChangeEvent, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; import { atom, useRecoilCallback, useRecoilState, useResetRecoilState } from 'recoil'; import { Query } from 'soql-parser-js'; import BulkUpdateFromQueryRecordSelection from './BulkUpdateFromQueryRecordSelection'; @@ -36,17 +37,19 @@ import BulkUpdateFromQueryRecordSelection from './BulkUpdateFromQueryRecordSelec const MAX_BATCH_SIZE = 10000; const IN_PROGRESS_STATUSES = new Set(['In Progress - Preparing', 'In Progress - Uploading', 'In Progress']); -function checkIfValid(selectedField: Maybe, transformationOptions: TransformationOptions) { - if (!selectedField) { - return false; - } - if (transformationOptions.option === 'anotherField' && !transformationOptions.alternateField) { - return false; - } - if (transformationOptions.option === 'staticValue' && !transformationOptions.staticValue) { - return false; - } - return true; +function checkIfValid(fieldConfig: MetadataRowConfiguration[]) { + return fieldConfig.every(({ selectedField, transformationOptions }) => { + if (!selectedField) { + return false; + } + if (transformationOptions.option === 'anotherField' && !transformationOptions.alternateField) { + return false; + } + if (transformationOptions.option === 'staticValue' && !transformationOptions.staticValue) { + return false; + } + return true; + }); } // These are stored in state to allow stable access from a callback to poll results @@ -90,16 +93,8 @@ export const BulkUpdateFromQueryModal: FunctionComponent(null); const [errorMessage, setErrorMessage] = useState(null); - const [selectedField, setSelectedField] = useState<{ field: string; metadata: Field } | null>(null); + const [selectedConfig, setSelectedConfig] = useState([{ ...DEFAULT_FIELD_CONFIGURATION }]); const [fields, setFields] = useState([]); - /** Fields that can be used as value */ - const [transformationOptions, setTransformationOptions] = useState({ - option: 'staticValue', - alternateField: undefined, - staticValue: '', - criteria: 'all', - whereClause: '', - }); const [hasMoreRecords, setHasMoreRecords] = useState(false); const [downloadRecordsValue, setDownloadRecordsValue] = useState(hasMoreRecords ? RADIO_ALL_SERVER : RADIO_ALL_BROWSER); const [batchSize, setBatchSize] = useState>(10000); @@ -110,6 +105,15 @@ export const BulkUpdateFromQueryModal: FunctionComponent { + if (downloadRecordsValue === RADIO_ALL_BROWSER || downloadRecordsValue === RADIO_ALL_SERVER) { + return totalRecordCount; + } + if (downloadRecordsValue === RADIO_FILTERED) { + return filteredRecords.length; + } + return selectedRecords.length; + }, [downloadRecordsValue, filteredRecords.length, selectedRecords.length, totalRecordCount]); // this allows the pollResults to have a stable data source for updated data const getDeploymentResults = useRecoilCallback( ({ snapshot }) => @@ -147,12 +151,8 @@ export const BulkUpdateFromQueryModal: FunctionComponent { - setIsValid(checkIfValid(selectedField?.field, transformationOptions)); - }, [selectedField, transformationOptions]); - - useEffect(() => { - setTransformationOptions((options) => (options.staticValue ? { ...options, staticValue: '' } : options)); - }, [selectedField]); + setIsValid(checkIfValid(selectedConfig)); + }, [selectedConfig]); useEffect(() => { if (!isNumber(batchSize) || batchSize <= 0 || batchSize > MAX_BATCH_SIZE) { @@ -192,7 +192,7 @@ export const BulkUpdateFromQueryModal: FunctionComponent { - if (!selectedField || batchSizeError) { + if (batchSizeError || !isValid || !selectedConfig || selectedConfig.some(({ selectedField }) => !selectedField)) { return; } @@ -222,9 +222,8 @@ export const BulkUpdateFromQueryModal: FunctionComponent selectedField!).filter(Boolean)], batchSize: batchSize ?? 10000, serialMode, - selectedField: selectedField.field, - selectedFieldMetadata: selectedField.metadata, - transformationOptions, + configuration: selectedConfig, }); pollResultsUntilDone(getDeploymentResults); } catch (ex) { @@ -294,7 +292,7 @@ export const BulkUpdateFromQueryModal: FunctionComponent - Update Records + Update {formatNumber(targetedRecordCount)} {pluralizeFromNumber('Record', targetedRecordCount)} @@ -333,17 +331,31 @@ export const BulkUpdateFromQueryModal: FunctionComponent { - setSelectedField({ field, metadata }); + onFieldChange={(index, field, metadata) => { + setSelectedConfig((prev) => { + const newConfig = [...prev]; + let transformationOptions = newConfig[index].transformationOptions; + if (transformationOptions.staticValue) { + transformationOptions = { ...transformationOptions, staticValue: '' }; + } + newConfig[index] = { selectedField: field, selectedFieldMetadata: metadata, transformationOptions }; + return newConfig; + }); + }} + onOptionsChange={(index, _, transformationOptions) => { + setSelectedConfig((prev) => { + const newConfig = [...prev]; + newConfig[index] = { ...newConfig[index], transformationOptions }; + return newConfig; + }); }} - onOptionsChange={(_, options) => setTransformationOptions(options)} onLoadChildFields={loadChildFields} filterCriteriaFn={(field) => field.value !== 'custom'} + onAddField={() => setSelectedConfig((prev) => [...prev, { ...DEFAULT_FIELD_CONFIGURATION }])} + onRemoveField={() => setSelectedConfig((prev) => prev.slice(0, -1))} />
@@ -383,10 +395,8 @@ export const BulkUpdateFromQueryModal: FunctionComponent diff --git a/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsObject.tsx b/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsObject.tsx index b9285e713..5559580e7 100644 --- a/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsObject.tsx +++ b/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsObject.tsx @@ -1,27 +1,30 @@ -import { ListItem, SalesforceOrgUi } from '@jetstream/types'; +import { ListItem } from '@jetstream/types'; import { Grid, GridCol, Tooltip } from '@jetstream/ui'; -import { MassUpdateRecordObjectHeading, MassUpdateRecordsObjectRow, MetadataRow, TransformationOptions } from '@jetstream/ui-core'; +import { MassUpdateRecordObjectHeading, MassUpdateRecordsObjectRow, MetadataRow } from '@jetstream/ui-core'; import { FunctionComponent, useCallback } from 'react'; +import { useMassUpdateFieldItems } from './useMassUpdateFieldItems'; export interface MassUpdateRecordsObjectProps { - selectedOrg: SalesforceOrgUi; row: MetadataRow; commonFields: ListItem[]; - onFieldSelected: (sobject: string, selectedField: string) => void; + onFieldSelected: ReturnType['onFieldSelected']; + handleOptionChange: ReturnType['handleOptionChange']; onLoadChildFields: (sobject: string, item: ListItem) => Promise; - handleOptionChange: (sobject: string, transformationOptions: TransformationOptions) => void; validateRowRecords: (sobject: string) => void; + handleAddField: (sobject: string) => void; + handleRemoveField: (sobject: string, configIndex: number) => void; } /** * Load listItem for single object and handle loading child fields */ export const MassUpdateRecordsObject: FunctionComponent = ({ - selectedOrg, row, onFieldSelected, onLoadChildFields, handleOptionChange, validateRowRecords, + handleAddField, + handleRemoveField, }) => { const handleLoadChildFields = useCallback( async (item: ListItem): Promise => { @@ -37,13 +40,13 @@ export const MassUpdateRecordsObject: FunctionComponent onFieldSelected(row.sobject, selectedField)} - onOptionsChange={handleOptionChange} + onFieldChange={(index, selectedField) => onFieldSelected(index, row.sobject, selectedField)} + onOptionsChange={(index, sobject, options) => handleOptionChange(index, sobject, options)} onLoadChildFields={handleLoadChildFields} + onAddField={handleAddField} + onRemoveField={handleRemoveField} > diff --git a/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsObjects.tsx b/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsObjects.tsx index 2e6e7b5bc..4058f3f64 100644 --- a/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsObjects.tsx +++ b/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsObjects.tsx @@ -1,25 +1,26 @@ -import { ListItem, SalesforceOrgUi } from '@jetstream/types'; +import { ListItem } from '@jetstream/types'; import { AutoFullHeightContainer, EmptyState, OpenRoadIllustration } from '@jetstream/ui'; -import { MetadataRow, TransformationCriteria, TransformationOption, TransformationOptions } from '@jetstream/ui-core'; +import { MetadataRow, TransformationOptions } from '@jetstream/ui-core'; import { Fragment, FunctionComponent } from 'react'; import MassUpdateRecordsApplyToAllRow from './MassUpdateRecordsApplyToAllRow'; import MassUpdateRecordsObject from './MassUpdateRecordsObject'; +import { useMassUpdateFieldItems } from './useMassUpdateFieldItems'; export interface MassUpdateRecordsObjectsProps { - selectedOrg: SalesforceOrgUi; rows: MetadataRow[]; commonFields: ListItem[]; - onFieldSelected: (sobject: string, selectedField: string) => void; + onFieldSelected: ReturnType['onFieldSelected']; onLoadChildFields: (sobject: string, item: ListItem) => Promise; - applyCommonField: (selectedField: string) => void; - applyCommonOption: (option: TransformationOption) => void; - applyCommonCriteria: (criteria: TransformationCriteria) => void; - handleOptionChange: (sobject: string, transformationOptions: TransformationOptions) => void; + applyCommonField: ReturnType['applyCommonField']; + applyCommonOption: ReturnType['applyCommonOption']; + applyCommonCriteria: ReturnType['applyCommonCriteria']; + handleOptionChange: (configIndex: number, sobject: string, transformationOptions: TransformationOptions) => void; + handleAddField: (sobject: string) => void; + handleRemoveField: (sobject: string, configIndex: number) => void; validateRowRecords: (sobject: string) => void; } export const MassUpdateRecordsObjects: FunctionComponent = ({ - selectedOrg, rows, commonFields, onFieldSelected, @@ -28,6 +29,8 @@ export const MassUpdateRecordsObjects: FunctionComponent { return ( @@ -37,21 +40,23 @@ export const MassUpdateRecordsObjects: FunctionComponent applyCommonField(0, selectedField)} + applyCommonOption={(option, staticValue) => applyCommonOption(0, option, staticValue)} + applyCommonCriteria={(criteria, whereClause) => applyCommonCriteria(0, criteria, whereClause)} />
    {rows.map((row) => ( ))}
diff --git a/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsSelection.tsx b/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsSelection.tsx index 028916086..f21146681 100644 --- a/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsSelection.tsx +++ b/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsSelection.tsx @@ -43,6 +43,8 @@ export const MassUpdateRecordsSelection: FunctionComponent {selectedSObjects && ( )} diff --git a/apps/jetstream/src/app/components/update-records/selection/useMassUpdateFieldItems.ts b/apps/jetstream/src/app/components/update-records/selection/useMassUpdateFieldItems.ts index 3a63547ba..0d8b172c3 100644 --- a/apps/jetstream/src/app/components/update-records/selection/useMassUpdateFieldItems.ts +++ b/apps/jetstream/src/app/components/update-records/selection/useMassUpdateFieldItems.ts @@ -11,6 +11,7 @@ import { } from '@jetstream/shared/ui-utils'; import { DescribeSObjectResult, Field, ListItem, Maybe, SalesforceOrgUi } from '@jetstream/types'; import { + DEFAULT_FIELD_CONFIGURATION, MetadataRow, TransformationCriteria, TransformationOption, @@ -27,11 +28,16 @@ type Action = | { type: 'RESET' } | { type: 'OBJECTS_SELECTED'; payload: { sobjects: string[] } } | { type: 'OBJECTS_REMOVED'; payload: { sobjects: string[] } } - | { type: 'FIELD_SELECTION_CHANGED'; payload: { sobject: string; selectedField: string } } - | { type: 'COMMON_FIELD_SELECTED'; payload: { selectedField: string } } - | { type: 'COMMON_OPTION_SELECTED'; payload: { option: TransformationOption; staticValue?: string } } - | { type: 'COMMON_CRITERIA_SELECTED'; payload: { criteria: TransformationCriteria; whereClause?: string } } - | { type: 'TRANSFORMATION_OPTION_CHANGED'; payload: { sobject: string; transformationOptions: TransformationOptions } } + | { type: 'FIELD_SELECTION_CHANGED'; payload: { sobject: string; selectedField: string; configIndex: number } } + | { type: 'COMMON_FIELD_SELECTED'; payload: { selectedField: string; configIndex: number } } + | { type: 'COMMON_OPTION_SELECTED'; payload: { option: TransformationOption; staticValue?: string; configIndex: number } } + | { type: 'COMMON_CRITERIA_SELECTED'; payload: { criteria: TransformationCriteria; whereClause?: string; configIndex: number } } + | { + type: 'TRANSFORMATION_OPTION_CHANGED'; + payload: { sobject: string; transformationOptions: TransformationOptions; configIndex: number }; + } + | { type: 'ADD_FIELD'; payload: { sobject: string } } + | { type: 'REMOVE_FIELD'; payload: { sobject: string; configIndex: number } } | { type: 'METADATA_LOADED'; payload: { sobject: string; metadata: DescribeSObjectResult } } | { type: 'CHILD_FIELDS_LOADED'; payload: { sobject: string; parentId: string; childFields: ListItem[] } } | { type: 'METADATA_ERROR'; payload: { sobject: string; error: string } } @@ -70,13 +76,7 @@ function reducer(state: State, action: Action): State { records: [], batchIdToIndex: {}, }, - transformationOptions: { - option: 'staticValue', - staticValue: '', - criteria: 'all', - alternateField: null, - whereClause: '', - }, + configuration: [{ ...DEFAULT_FIELD_CONFIGURATION }], }); }); return { ...state, rowsMap, allRowsValid: Array.from(rowsMap.values()).every((row) => row.isValid) }; @@ -94,42 +94,64 @@ function reducer(state: State, action: Action): State { }; } case 'FIELD_SELECTION_CHANGED': { - const { sobject, selectedField } = action.payload; + const { sobject, selectedField, configIndex } = action.payload; const rowsMap = new Map(state.rowsMap); - const row = { ...state.rowsMap.get(sobject), selectedField } as MetadataRow; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const prevRow = state.rowsMap.get(sobject)!; + const row: MetadataRow = { ...prevRow, configuration: [...prevRow.configuration] }; + row.configuration[configIndex] = { + ...row.configuration[configIndex], + selectedField, + transformationOptions: { ...row.configuration[configIndex].transformationOptions, staticValue: '' }, + }; row.isValid = isValidRow(row); - row.transformationOptions = { ...row.transformationOptions, staticValue: '' }; rowsMap.set(sobject, row); return { ...state, rowsMap, allRowsValid: Array.from(rowsMap.values()).every((row) => row.isValid) }; } case 'COMMON_FIELD_SELECTED': { - const { selectedField } = action.payload; + const { selectedField, configIndex } = action.payload; const rowsMap = new Map(state.rowsMap); - rowsMap.forEach((_row, key) => { - const row = { ..._row, selectedField, validationResults: null }; + rowsMap.forEach((prevRow, key) => { + const row: MetadataRow = { ...prevRow, configuration: [...prevRow.configuration] }; + row.configuration[configIndex] = { ...row.configuration[configIndex], selectedField }; row.isValid = isValidRow(row); rowsMap.set(key, row); }); return { ...state, rowsMap, allRowsValid: Array.from(rowsMap.values()).every((row) => row.isValid) }; } case 'COMMON_OPTION_SELECTED': { - const { option, staticValue } = action.payload; + const { option, staticValue, configIndex } = action.payload; const rowsMap = new Map(state.rowsMap); - rowsMap.forEach((_row, key) => { - const _staticValue = option === 'staticValue' && staticValue ? staticValue : _row.transformationOptions.staticValue; - const row = { ..._row, transformationOptions: { ..._row.transformationOptions, option, staticValue: _staticValue } }; + rowsMap.forEach((prevRow, key) => { + const row: MetadataRow = { ...prevRow, configuration: [...prevRow.configuration] }; + row.configuration[configIndex] = { + ...row.configuration[configIndex], + transformationOptions: { + ...row.configuration[configIndex].transformationOptions, + option, + staticValue: + option === 'staticValue' && staticValue ? staticValue : prevRow.configuration[configIndex].transformationOptions.staticValue, + }, + }; row.isValid = isValidRow(row); rowsMap.set(key, row); }); return { ...state, rowsMap, allRowsValid: Array.from(rowsMap.values()).every((row) => row.isValid) }; } case 'COMMON_CRITERIA_SELECTED': { - const { criteria, whereClause } = action.payload; + const { criteria, whereClause, configIndex } = action.payload; const rowsMap = new Map(state.rowsMap); - rowsMap.forEach((_row, key) => { - const row = { ..._row, transformationOptions: { ..._row.transformationOptions, criteria }, validationResults: null }; + rowsMap.forEach((prevRow, key) => { + const row: MetadataRow = { ...prevRow, configuration: [...prevRow.configuration], validationResults: null }; + row.configuration[configIndex] = { + ...row.configuration[configIndex], + transformationOptions: { + ...row.configuration[configIndex].transformationOptions, + criteria, + }, + }; if (criteria === 'custom' && whereClause) { - row.transformationOptions.whereClause = whereClause; + row.configuration[configIndex].transformationOptions.whereClause = whereClause; } row.isValid = isValidRow(row); rowsMap.set(key, row); @@ -137,9 +159,34 @@ function reducer(state: State, action: Action): State { return { ...state, rowsMap, allRowsValid: Array.from(rowsMap.values()).every((row) => row.isValid) }; } case 'TRANSFORMATION_OPTION_CHANGED': { - const { sobject, transformationOptions } = action.payload; + const { sobject, transformationOptions, configIndex } = action.payload; const rowsMap = new Map(state.rowsMap); - const row = { ...state.rowsMap.get(sobject), transformationOptions, validationResults: null } as MetadataRow; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const prevRow = state.rowsMap.get(sobject)!; + const row: MetadataRow = { ...prevRow, validationResults: null, configuration: [...prevRow.configuration] }; + row.configuration[configIndex] = { ...row.configuration[configIndex], transformationOptions }; + row.isValid = isValidRow(row); + rowsMap.set(sobject, row); + return { ...state, rowsMap, allRowsValid: Array.from(rowsMap.values()).every((row) => row.isValid) }; + } + case 'ADD_FIELD': { + const { sobject } = action.payload; + const rowsMap = new Map(state.rowsMap); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const prevRow = state.rowsMap.get(sobject)!; + const row: MetadataRow = { ...prevRow, configuration: [...prevRow.configuration] }; + row.configuration.push({ ...DEFAULT_FIELD_CONFIGURATION }); + row.isValid = false; + rowsMap.set(sobject, row); + return { ...state, rowsMap, allRowsValid: false }; + } + case 'REMOVE_FIELD': { + const { sobject, configIndex } = action.payload; + const rowsMap = new Map(state.rowsMap); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const prevRow = state.rowsMap.get(sobject)!; + const row: MetadataRow = { ...prevRow, configuration: [...prevRow.configuration] }; + row.configuration.splice(configIndex, 1); row.isValid = isValidRow(row); rowsMap.set(sobject, row); return { ...state, rowsMap, allRowsValid: Array.from(rowsMap.values()).every((row) => row.isValid) }; @@ -303,6 +350,9 @@ export function useMassUpdateFieldItems(org: SalesforceOrgUi, selectedSObjects: } dispatch({ type: 'START_VALIDATION', payload: { sobject } }); const soql = getValidationSoqlQuery(row); + if (!soql) { + return; + } const results = await query(org, soql); if (isMounted.current) { dispatch({ type: 'FINISH_VALIDATION', payload: { sobject, impactedRecords: results.queryResults.totalSize, error: null } }); @@ -326,6 +376,9 @@ export function useMassUpdateFieldItems(org: SalesforceOrgUi, selectedSObjects: const { sobject } = row; try { const soql = getValidationSoqlQuery(row); + if (!soql) { + return; + } const results = await query(org, soql); if (isMounted.current) { dispatch({ type: 'FINISH_VALIDATION', payload: { sobject, impactedRecords: results.queryResults.totalSize, error: null } }); @@ -340,8 +393,8 @@ export function useMassUpdateFieldItems(org: SalesforceOrgUi, selectedSObjects: trackEvent(ANALYTICS_KEYS.mass_update_ApplyAll, { numObjects: rows.length, type: 'VALIDATION' }); }, [org, rows, trackEvent]); - const onFieldSelected = useCallback((sobject: string, selectedField: string) => { - dispatch({ type: 'FIELD_SELECTION_CHANGED', payload: { sobject, selectedField } }); + const onFieldSelected = useCallback((configIndex: number, sobject: string, selectedField: string) => { + dispatch({ type: 'FIELD_SELECTION_CHANGED', payload: { sobject, selectedField, configIndex } }); }, []); const onLoadChildFields = useCallback( @@ -389,23 +442,31 @@ export function useMassUpdateFieldItems(org: SalesforceOrgUi, selectedSObjects: } }, [getObjectsMetadata, rows, selectedSObjects]); - function applyCommonField(selectedField: string) { - dispatch({ type: 'COMMON_FIELD_SELECTED', payload: { selectedField } }); + function applyCommonField(configIndex: number, selectedField: string) { + dispatch({ type: 'COMMON_FIELD_SELECTED', payload: { selectedField, configIndex } }); trackEvent(ANALYTICS_KEYS.mass_update_ApplyAll, { numObjects: rows.length, type: 'COMMON_FIELD_SELECTED' }); } - function applyCommonOption(option: TransformationOption, staticValue?: string) { - dispatch({ type: 'COMMON_OPTION_SELECTED', payload: { option, staticValue } }); + function applyCommonOption(configIndex: number, option: TransformationOption, staticValue?: string) { + dispatch({ type: 'COMMON_OPTION_SELECTED', payload: { option, staticValue, configIndex } }); trackEvent(ANALYTICS_KEYS.mass_update_ApplyAll, { numObjects: rows.length, type: 'COMMON_OPTION_SELECTED', option }); } - function applyCommonCriteria(criteria: TransformationCriteria, whereClause?: string) { - dispatch({ type: 'COMMON_CRITERIA_SELECTED', payload: { criteria, whereClause } }); + function applyCommonCriteria(configIndex: number, criteria: TransformationCriteria, whereClause?: string) { + dispatch({ type: 'COMMON_CRITERIA_SELECTED', payload: { criteria, whereClause, configIndex } }); trackEvent(ANALYTICS_KEYS.mass_update_ApplyAll, { numObjects: rows.length, type: 'COMMON_CRITERIA_SELECTED', criteria }); } - function handleOptionChange(sobject: string, transformationOptions: TransformationOptions) { - dispatch({ type: 'TRANSFORMATION_OPTION_CHANGED', payload: { sobject, transformationOptions } }); + function handleOptionChange(configIndex: number, sobject: string, transformationOptions: TransformationOptions) { + dispatch({ type: 'TRANSFORMATION_OPTION_CHANGED', payload: { sobject, transformationOptions, configIndex } }); + } + + function handleAddField(sobject: string) { + dispatch({ type: 'ADD_FIELD', payload: { sobject } }); + } + + function handleRemoveField(sobject: string, configIndex: number) { + dispatch({ type: 'REMOVE_FIELD', payload: { sobject, configIndex } }); } return { @@ -418,6 +479,8 @@ export function useMassUpdateFieldItems(org: SalesforceOrgUi, selectedSObjects: applyCommonOption, applyCommonCriteria, handleOptionChange, + handleAddField, + handleRemoveField, validateAllRowRecords, validateRowRecords, }; diff --git a/libs/shared/ui-core-shared/jest.config.ts b/libs/shared/ui-core-shared/jest.config.ts index bca417b16..5cd2b68c3 100644 --- a/libs/shared/ui-core-shared/jest.config.ts +++ b/libs/shared/ui-core-shared/jest.config.ts @@ -7,5 +7,4 @@ export default { '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], - coverageDirectory: '../../../coverage/libs/shared/ui-core-shared', }; diff --git a/libs/shared/ui-core-worker/jest.config.ts b/libs/shared/ui-core-worker/jest.config.ts index 76ecc322e..c18517694 100644 --- a/libs/shared/ui-core-worker/jest.config.ts +++ b/libs/shared/ui-core-worker/jest.config.ts @@ -7,5 +7,4 @@ export default { '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], - coverageDirectory: '../../../coverage/libs/shared/ui-core-worker', }; diff --git a/libs/shared/ui-core/jest.config.ts b/libs/shared/ui-core/jest.config.ts index 5dae97860..f86fe7176 100644 --- a/libs/shared/ui-core/jest.config.ts +++ b/libs/shared/ui-core/jest.config.ts @@ -7,5 +7,4 @@ export default { '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], - coverageDirectory: '../../../coverage/libs/shared/ui-core', }; diff --git a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordTransformationText.tsx b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordTransformationText.tsx index d27d7872a..33f112444 100644 --- a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordTransformationText.tsx +++ b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordTransformationText.tsx @@ -27,10 +27,10 @@ export const MassUpdateRecordTransformationText: FunctionComponent'}"`; objectAndField = ( - "{selectedField}" will be set to "{staticValue}" + "{selectedField}" will be set to "{staticValue || ''}" ); break; @@ -87,14 +87,16 @@ export const MassUpdateRecordTransformationText: FunctionComponent - - {objectAndField} - {updateCriteria} - - + // Wrapped in div because SFDC has a CSS rule to add left margin to sibling elements +
+ + + {objectAndField} + {updateCriteria} + + +
); }; diff --git a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsDeploymentRow.tsx b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsDeploymentRow.tsx index 40a963f43..6c449dfdf 100644 --- a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsDeploymentRow.tsx +++ b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsDeploymentRow.tsx @@ -28,23 +28,20 @@ export interface ViewModalData extends Omit type: DownloadType; } -export interface MassUpdateRecordsDeploymentRowProps - extends Pick { +export type MassUpdateRecordsDeploymentRowProps = { hasExternalWhereClause?: boolean; validationResults?: MetadataRow['validationResults']; selectedOrg: SalesforceOrgUi; batchSize: number; omitTransformationText?: boolean; onModalOpenChange?: (isOpen: boolean) => void; -} +} & Pick; export const MassUpdateRecordsDeploymentRow: FunctionComponent = ({ selectedOrg, sobject, deployResults, - transformationOptions, - selectedField, - selectedFieldMetadata, + configuration, hasExternalWhereClause, validationResults, batchSize, @@ -93,14 +90,14 @@ export const MassUpdateRecordsDeploymentRow: FunctionComponent selectedField!)]); if (action === 'view') { setResultsModalData({ ...downloadModalData, open: true, header, data: combinedResults, type }); trackEvent(ANALYTICS_KEYS.mass_update_DownloadRecords, { type, numRows: data.length, - transformationOptions: transformationOptions.option, + transformationOptions: configuration.map(({ transformationOptions }) => transformationOptions.option), }); } else { setDownloadModalData({ @@ -113,17 +110,16 @@ export const MassUpdateRecordsDeploymentRow: FunctionComponent transformationOptions.option), }); } } catch (ex) { logger.warn(ex); - // setDownloadError(ex.message); } } function handleDownloadProcessingErrors() { - const header = ['_id', '_success', '_errors'].concat(getFieldsToQuery({ transformationOptions, selectedField })); + const header = ['_id', '_success', '_errors'].concat(getFieldsToQuery(configuration)); setDownloadModalData({ ...downloadModalData, open: true, @@ -139,7 +135,7 @@ export const MassUpdateRecordsDeploymentRow: FunctionComponent transformationOptions.option), }); } @@ -197,17 +193,17 @@ export const MassUpdateRecordsDeploymentRow: FunctionComponent } footer={ - omitTransformationText ? ( - // eslint-disable-next-line react/jsx-no-useless-fragment - <> - ) : ( - - ) + omitTransformationText + ? null + : configuration.map(({ transformationOptions, selectedField, selectedFieldMetadata }, i) => ( + + )) } > {!processingStartTime && validationResults && ( diff --git a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRow.tsx b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRow.tsx index 36edeff79..2f899e22d 100644 --- a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRow.tsx +++ b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRow.tsx @@ -3,12 +3,12 @@ import { pluralizeFromNumber } from '@jetstream/shared/utils'; import { Field, ListItem, Maybe } from '@jetstream/types'; import { Grid, GridCol, ScopedNotification, Spinner } from '@jetstream/ui'; import isNumber from 'lodash/isNumber'; -import { FunctionComponent, ReactNode } from 'react'; +import { Fragment, FunctionComponent, ReactNode } from 'react'; import MassUpdateRecordTransformationText from './MassUpdateRecordTransformationText'; import MassUpdateRecordsObjectRowCriteria from './MassUpdateRecordsObjectRowCriteria'; import MassUpdateRecordsObjectRowField from './MassUpdateRecordsObjectRowField'; import MassUpdateRecordsObjectRowValue from './MassUpdateRecordsObjectRowValue'; -import { TransformationOptions, ValidationResults } from './mass-update-records.types'; +import { MetadataRowConfiguration, TransformationOptions, ValidationResults } from './mass-update-records.types'; export interface MassUpdateRecordsObjectRowProps { className?: string; @@ -16,17 +16,17 @@ export interface MassUpdateRecordsObjectRowProps { loading: boolean; fields: ListItem[]; valueFields: ListItem[]; - selectedField?: Maybe; - selectedFieldMetadata?: Maybe; + fieldConfigurations: MetadataRowConfiguration[]; validationResults?: Maybe; - transformationOptions: TransformationOptions; hasExternalWhereClause?: boolean; disabled?: boolean; - onFieldChange: (selectedField: string, fieldMetadata: Field) => void; - onOptionsChange: (sobject: string, options: TransformationOptions) => void; + onFieldChange: (index: number, selectedField: string, fieldMetadata: Field) => void; + onOptionsChange: (index: number, sobject: string, options: TransformationOptions) => void; /** Used if some options should not be included in criteria dropdown */ filterCriteriaFn?: (item: ListItem) => boolean; onLoadChildFields?: (item: ListItem) => Promise; + onAddField: (sobject: string) => void; + onRemoveField: (sobject: string, configIndex: number) => void; children?: ReactNode; } @@ -36,16 +36,16 @@ export const MassUpdateRecordsObjectRow: FunctionComponent { return ( @@ -53,51 +53,77 @@ export const MassUpdateRecordsObjectRow: FunctionComponent} {children} - - - - onOptionsChange(sobject, options)} - onLoadChildFields={onLoadChildFields} - /> + {fieldConfigurations.map(({ selectedField, selectedFieldMetadata, transformationOptions }, index) => ( + + {index !== 0 && ( + +
+
+ )} + + 1} + onchange={(selectedField, fieldMetadata) => onFieldChange(index, selectedField, fieldMetadata)} + onRemoveRow={() => onRemoveField(sobject, index)} + /> + + onOptionsChange(index, sobject, options)} + onLoadChildFields={onLoadChildFields} + /> + + onOptionsChange(index, sobject, options)} + /> + + + {!!selectedField && ( + + + + )} +
+ ))} - onOptionsChange(sobject, options)} - /> +
+ +
- {!!selectedField && ( + {validationResults && (
- -
- {validationResults && isNumber(validationResults?.impactedRecords) && ( - - {formatNumber(validationResults.impactedRecords)} {pluralizeFromNumber('record', validationResults.impactedRecords)} will be - updated - - )} - {validationResults?.error && ( - -
{validationResults.error}
-
- )} -
+ {validationResults && isNumber(validationResults?.impactedRecords) && ( + + {formatNumber(validationResults.impactedRecords)} {pluralizeFromNumber('record', validationResults.impactedRecords)} will be + updated + + )} + {validationResults?.error && ( + +
{validationResults.error}
+
+ )}
)} diff --git a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRowField.tsx b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRowField.tsx index 34e2de70f..cb84de520 100644 --- a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRowField.tsx +++ b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRowField.tsx @@ -1,20 +1,24 @@ import { css } from '@emotion/react'; import { Field, ListItem, Maybe } from '@jetstream/types'; -import { ComboboxWithItems, Grid } from '@jetstream/ui'; +import { ComboboxWithItems, Grid, Icon } from '@jetstream/ui'; import { FunctionComponent } from 'react'; export interface MassUpdateRecordsObjectRowFieldProps { fields: ListItem[]; selectedField?: Maybe; disabled?: boolean; + allowDelete?: boolean; onchange: (selectedField: string, fieldMetadata: Field) => void; + onRemoveRow: () => void; } export const MassUpdateRecordsObjectRowField: FunctionComponent = ({ fields, selectedField, disabled, + allowDelete, onchange, + onRemoveRow, }) => { function handleFieldSelection(item: ListItem) { onchange(item.id, item.meta as Field); @@ -40,6 +44,17 @@ export const MassUpdateRecordsObjectRowField: FunctionComponent + {allowDelete && ( + + )}
); }; diff --git a/libs/shared/ui-core/src/mass-update-records/__tests__/mass-update-records.utils.spec.ts b/libs/shared/ui-core/src/mass-update-records/__tests__/mass-update-records.utils.spec.ts new file mode 100644 index 000000000..babadb25e --- /dev/null +++ b/libs/shared/ui-core/src/mass-update-records/__tests__/mass-update-records.utils.spec.ts @@ -0,0 +1,550 @@ +import { + composeSoqlQueryCustomWhereClause, + composeSoqlQueryOptionalCustomWhereClause, + getFieldsToQuery, + isValidRow, + prepareRecords, +} from '../mass-update-records.utils'; + +describe('mass-update-records.utils#isValidRow', () => { + test('Should return true if all are valid', () => { + expect( + isValidRow({ + configuration: [ + { + selectedField: 'Name', + transformationOptions: { option: 'anotherField', alternateField: 'Id' }, + }, + { + selectedField: 'Name', + transformationOptions: { option: 'staticValue', staticValue: 'Jenny Jenny' }, + }, + { + selectedField: 'Name', + criteria: 'custom', + transformationOptions: { whereClause: `Id IN ('1', '2')` }, + }, + ], + } as any) + ).toBe(true); + }); + + test('Should return false if any are invalid', () => { + expect( + isValidRow({ + configuration: [ + { + selectedField: 'Name', + transformationOptions: { option: 'anotherField', alternateField: 'Id' }, + }, + { + selectedField: 'Name', + transformationOptions: { option: 'staticValue', staticValue: 'Jenny Jenny' }, + }, + { + // invalid where clause + criteria: 'custom', + transformationOptions: { whereClause: '' }, + }, + ], + } as any) + ).toBe(false); + }); + + test('Should return false if no configuration', () => { + expect(isValidRow({ configuration: null } as any)).toBe(false); + expect(isValidRow({ configuration: [] } as any)).toBe(false); + }); + + test('Should return false if invalid configuration', () => { + expect(isValidRow({ configuration: [{ selectedField: '' }] } as any)).toBe(false); + expect( + isValidRow({ + configuration: [ + { + selectedField: 'Name', + transformationOptions: { option: 'anotherField', alternateField: '' }, + }, + ], + } as any) + ).toBe(false); + expect( + isValidRow({ + configuration: [ + { + selectedField: 'Name', + transformationOptions: { option: 'staticValue', staticValue: '' }, + }, + ], + } as any) + ).toBe(false); + expect( + isValidRow({ + configuration: [ + { + criteria: 'custom', + transformationOptions: { whereClause: '' }, + }, + ], + } as any) + ).toBe(false); + expect( + isValidRow({ + configuration: [ + { + criteria: 'custom', + transformationOptions: { whereClause: 'Invalid Where Clause!' }, + }, + ], + } as any) + ).toBe(false); + }); +}); + +describe('mass-update-records.utils#getFieldsToQuery', () => { + test('Should return fields from configuration', () => { + const fields = getFieldsToQuery([ + { + selectedField: 'FirstName', + transformationOptions: { + criteria: 'all', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'LastName', + transformationOptions: { + criteria: 'onlyIfBlank', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'Type', + transformationOptions: { + criteria: 'onlyIfNotBlank', + option: 'anotherField', + staticValue: '', + alternateField: 'Id', + whereClause: '', + }, + }, + { + selectedField: 'Fax', + transformationOptions: { + criteria: 'custom', + option: 'anotherField', + staticValue: '', + alternateField: 'Fax', + whereClause: `Id = '12345'`, + }, + }, + ]); + + expect(fields).toEqual(['Id', 'FirstName', 'LastName', 'Type', 'Fax']); + }); +}); + +describe('mass-update-records.utils#composeSoqlQueryOptionalCustomWhereClause', () => { + it('Should remove where clause if any have "all" criteria', () => { + const soql = composeSoqlQueryOptionalCustomWhereClause( + { + sobject: 'Contact', + configuration: [ + { + selectedField: 'FirstName', + transformationOptions: { + criteria: 'all', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'LastName', + transformationOptions: { + criteria: 'onlyIfBlank', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'Type', + transformationOptions: { + criteria: 'onlyIfNotBlank', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'Fax', + transformationOptions: { + criteria: 'custom', + option: 'anotherField', + staticValue: '', + alternateField: 'Fax', + whereClause: `Id = '12345'`, + }, + }, + ], + } as any, + ['Id', 'FirstName', 'LastName', 'Type', 'Fax'] + ); + + expect(soql).toBe(`SELECT Id, FirstName, LastName, Type, Fax FROM Contact`); + }); + it('Should return a query with proper where clauses', () => { + const config = { + sobject: 'Contact', + configuration: [ + { + selectedField: 'FirstName', + transformationOptions: { + criteria: 'onlyIfBlank', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'LastName', + transformationOptions: { + criteria: 'onlyIfNotBlank', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'Fax', + transformationOptions: { + criteria: 'custom', + option: 'anotherField', + staticValue: '', + alternateField: 'Fax', + whereClause: `Id = '12345'`, + }, + }, + ], + } as any; + let soql = composeSoqlQueryOptionalCustomWhereClause(config, ['Id', 'FirstName', 'LastName', 'Fax']); + + expect(soql).toBe(`SELECT Id, FirstName, LastName, Fax FROM Contact WHERE (FirstName = NULL) OR (LastName != NULL)`); + + soql = composeSoqlQueryOptionalCustomWhereClause(config, ['Count()'], true); + expect(soql).toBe(`SELECT Count() FROM Contact WHERE (FirstName = NULL) OR (LastName != NULL) OR (Id = '12345')`); + }); + + it('Should return null if no records should be processed', () => { + const soql = composeSoqlQueryOptionalCustomWhereClause( + { + sobject: 'Account', + configuration: [ + { + selectedField: 'Fax', + transformationOptions: { + criteria: 'custom', + option: 'anotherField', + staticValue: '', + alternateField: 'Fax', + whereClause: `Id = '12345'`, + }, + }, + ], + } as any, + ['Id', 'Fax'] + ); + + expect(soql).toBe(null); + }); +}); + +describe('mass-update-records.utils#composeSoqlQueryCustomWhereClause', () => { + it('Should only return custom where clauses', () => { + const soql = composeSoqlQueryCustomWhereClause( + { + sobject: 'Contact', + configuration: [ + { + selectedField: 'FirstName', + transformationOptions: { + criteria: 'all', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'LastName', + transformationOptions: { + criteria: 'onlyIfBlank', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'Type', + transformationOptions: { + criteria: 'onlyIfNotBlank', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'Fax', + transformationOptions: { + criteria: 'custom', + option: 'anotherField', + staticValue: '', + alternateField: 'Fax', + whereClause: `Id = '12345'`, + }, + }, + ], + } as any, + ['Id', 'FirstName', 'LastName', 'Type', 'Fax'] + ); + + expect(soql).toBe(`SELECT Id, FirstName, LastName, Type, Fax FROM Contact WHERE (Id = '12345')`); + }); + + it('Should return null if no custom where clauses', () => { + const soql = composeSoqlQueryCustomWhereClause( + { + sobject: 'Contact', + configuration: [ + { + selectedField: 'FirstName', + transformationOptions: { + criteria: 'all', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'LastName', + transformationOptions: { + criteria: 'onlyIfBlank', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'Type', + transformationOptions: { + criteria: 'onlyIfNotBlank', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + ], + } as any, + ['Id', 'FirstName', 'LastName', 'Type', 'Fax'] + ); + + expect(soql).toBe(null); + }); +}); + +describe('mass-update-records.utils#prepareRecords', () => { + it('Should transform all records with static value', () => { + const [record1, record2, record3] = prepareRecords( + [ + { Id: '1', Name: 'Acct 1', Fax: null }, + { Id: '2', Name: 'Acct 2', Fax: null }, + { Id: '3', Name: 'Acct 3', Fax: null }, + ], + [ + { + selectedField: 'Name', + transformationOptions: { + criteria: 'all', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'Fax', + transformationOptions: { criteria: 'all', option: 'staticValue', staticValue: '867-5309', alternateField: null, whereClause: '' }, + }, + ] + ); + + expect(record1.Name).toBe('Jenny Jenny'); + expect(record1.Fax).toBe('867-5309'); + + expect(record2.Name).toBe('Jenny Jenny'); + expect(record2.Fax).toBe('867-5309'); + + expect(record3.Name).toBe('Jenny Jenny'); + expect(record3.Fax).toBe('867-5309'); + }); + + it('Should transform all records with another field', () => { + const [record1, record2, record3] = prepareRecords( + [ + { Id: '1', Name: 'Acct 1', Fax: '867-5309' }, + { Id: '2', Name: 'Acct 2', Fax: '867-5309' }, + { Id: '3', Name: 'Acct 3', Fax: '867-5309' }, + ], + [ + { + selectedField: 'Name', + transformationOptions: { + criteria: 'all', + option: 'anotherField', + staticValue: 'Jenny Jenny', + alternateField: 'Fax', + whereClause: '', + }, + }, + ] + ); + + expect(record1.Name).toBe('867-5309'); + expect(record1.Fax).toBe('867-5309'); + + expect(record2.Name).toBe('867-5309'); + expect(record2.Fax).toBe('867-5309'); + + expect(record3.Name).toBe('867-5309'); + expect(record3.Fax).toBe('867-5309'); + }); + + it('Should transform all records based on blank/not blank', () => { + const [record1, record2, record3] = prepareRecords( + [ + { Id: '1', Name: 'Acct 1', Fax: '867-5309-initial', Phone: null }, + { Id: '2', Name: 'Acct 2', Fax: null, Phone: '800-867-5309' }, + { Id: '3', Name: 'Acct 3', Fax: null, Phone: '800-867-5309' }, + ], + [ + { + selectedField: 'Phone', + transformationOptions: { + criteria: 'onlyIfBlank', + option: 'staticValue', + staticValue: '867-5309', + alternateField: null, + whereClause: '', + }, + }, + { + selectedField: 'Fax', + transformationOptions: { + criteria: 'onlyIfNotBlank', + option: 'staticValue', + staticValue: '867-5309', + alternateField: null, + whereClause: '', + }, + }, + ] + ); + + expect(record1.Name).toBe('Acct 1'); + expect(record1.Fax).toBe('867-5309'); + expect(record1.Phone).toBe('867-5309'); + + expect(record2.Name).toBe('Acct 2'); + expect(record2.Fax).toBe(null); + expect(record2.Phone).toBe(null); // set to null just for results clarity, not actually modified in SFDC + + expect(record3.Name).toBe('Acct 3'); + expect(record3.Fax).toBe(null); + expect(record3.Phone).toBe(null); // set to null just for results clarity, not actually modified in SFDC + }); + + it('Should clear value', () => { + const [record1, record2, record3] = prepareRecords( + [ + { Id: '1', Name: 'Acct 1', Fax: '867-5309' }, + { Id: '2', Name: 'Acct 2', Fax: '867-5309' }, + { Id: '3', Name: 'Acct 3', Fax: '867-5309' }, + ], + [ + { + selectedField: 'Name', + transformationOptions: { + criteria: 'all', + option: 'null', + staticValue: '', + alternateField: '', + whereClause: '', + }, + }, + { + selectedField: 'Fax', + transformationOptions: { + criteria: 'all', + option: 'null', + staticValue: '', + alternateField: '', + whereClause: '', + }, + }, + ] + ); + + expect(record1.Name).toBe('#N/A'); + expect(record1.Fax).toBe('#N/A'); + + expect(record2.Name).toBe('#N/A'); + expect(record2.Fax).toBe('#N/A'); + + expect(record3.Name).toBe('#N/A'); + expect(record3.Fax).toBe('#N/A'); + }); + + it('Should update records with custom criteria', () => { + const [record1, record2, record3] = prepareRecords( + [ + { Id: '1', Name: 'Acct 1' }, + { Id: '2', Name: 'Acct 2' }, + { Id: '3', Name: 'Acct 3' }, + ], + [ + { + selectedField: 'Name', + transformationOptions: { + criteria: 'custom', + option: 'staticValue', + staticValue: 'Jenny Jenny', + alternateField: null, + whereClause: 'Id = 1 OR Id = 3', + }, + }, + ], + new Set(['1', '3']) + ); + + expect(record1.Name).toBe('Jenny Jenny'); + expect(record2.Name).toBe(null); + expect(record3.Name).toBe('Jenny Jenny'); + }); +}); diff --git a/libs/shared/ui-core/src/mass-update-records/mass-update-records.types.tsx b/libs/shared/ui-core/src/mass-update-records/mass-update-records.types.tsx index d2c19678c..ff6c37809 100644 --- a/libs/shared/ui-core/src/mass-update-records/mass-update-records.types.tsx +++ b/libs/shared/ui-core/src/mass-update-records/mass-update-records.types.tsx @@ -10,11 +10,15 @@ export interface MetadataRow { metadata?: DescribeSObjectResult; fields: ListItem[]; valueFields: ListItem[]; + configuration: MetadataRowConfiguration[]; + validationResults?: Maybe; + deployResults: DeployResults; +} + +export interface MetadataRowConfiguration { selectedField?: Maybe; selectedFieldMetadata?: Maybe; transformationOptions: TransformationOptions; - validationResults?: Maybe; - deployResults: DeployResults; } export interface DeployResults { diff --git a/libs/shared/ui-core/src/mass-update-records/mass-update-records.utils.tsx b/libs/shared/ui-core/src/mass-update-records/mass-update-records.utils.tsx index 865ff9200..3cb18b623 100644 --- a/libs/shared/ui-core/src/mass-update-records/mass-update-records.utils.tsx +++ b/libs/shared/ui-core/src/mass-update-records/mass-update-records.utils.tsx @@ -1,12 +1,26 @@ import { logger } from '@jetstream/shared/client-logger'; +import { SFDC_BULK_API_NULL_VALUE } from '@jetstream/shared/constants'; import { queryAll } from '@jetstream/shared/data'; import { DescribeGlobalSObjectResult, ListItem, Maybe, SalesforceOrgUi, SalesforceRecord } from '@jetstream/types'; +import lodashGet from 'lodash/get'; import isNil from 'lodash/isNil'; import { Query, composeQuery, getField, isQueryValid } from 'soql-parser-js'; -import { MetadataRow, TransformationOptions } from './mass-update-records.types'; +import { MetadataRow, MetadataRowConfiguration } from './mass-update-records.types'; export const startsWithWhereRgx = /^( )*WHERE( )*/i; +export const DEFAULT_FIELD_CONFIGURATION: MetadataRowConfiguration = { + selectedField: null, + selectedFieldMetadata: null, + transformationOptions: { + option: 'staticValue', + alternateField: undefined, + staticValue: '', + criteria: 'all', + whereClause: '', + }, +}; + export function filterMassUpdateSobject(sobject: DescribeGlobalSObjectResult) { return ( (sobject.createable || sobject.updateable) && @@ -35,114 +49,171 @@ export const transformationCriteriaListItems: ListItem[] = [ * @returns */ export function isValidRow(row: Maybe) { - if (!row) { - return false; - } - if (!row.selectedField) { - return false; - } - if (row.transformationOptions.option === 'anotherField' && !row.transformationOptions.alternateField) { - return false; - } - if (row.transformationOptions.option === 'staticValue' && !row.transformationOptions.staticValue) { + if (!row?.configuration?.length) { return false; } - if (row.transformationOptions.criteria === 'custom') { - if ( - !row.transformationOptions.whereClause || - !isQueryValid(`WHERE ${row.transformationOptions.whereClause}`, { allowPartialQuery: true }) - ) { + return row.configuration.every(({ selectedField, transformationOptions }) => { + if (!selectedField) { return false; } - } - return true; + if (transformationOptions.option === 'anotherField' && !transformationOptions.alternateField) { + return false; + } + if (transformationOptions.option === 'staticValue' && !transformationOptions.staticValue) { + return false; + } + if (transformationOptions.criteria === 'custom') { + if (!transformationOptions.whereClause || !isQueryValid(`WHERE ${transformationOptions.whereClause}`, { allowPartialQuery: true })) { + return false; + } + } + return true; + }); } -export function getFieldsToQuery({ - transformationOptions, - selectedField, -}: Pick): string[] { - let fields = ['Id', selectedField]; - if (transformationOptions.option === 'anotherField' && transformationOptions.alternateField) { - fields.push(transformationOptions.alternateField); - } - // ensure no duplicates - fields = Array.from(new Set(fields)); +export function getFieldsToQuery(configuration: MetadataRowConfiguration[]): string[] { + let fields = ['Id']; + configuration.forEach(({ selectedField, transformationOptions }) => { + fields.push(selectedField || ''); + if (transformationOptions.option === 'anotherField' && transformationOptions.alternateField) { + fields.push(transformationOptions.alternateField); + } + // ensure no duplicates + fields = Array.from(new Set(fields)); + }); return fields.filter(Boolean) as string[]; } export function getValidationSoqlQuery(row: MetadataRow) { - return composeSoqlQuery(row, [`Count()`]); + return composeSoqlQueryOptionalCustomWhereClause(row, [`Count()`], true); +} + +export async function queryAndPrepareRecordsForUpdate(row: MetadataRow, fields: string[], org: SalesforceOrgUi) { + const standardQuery = composeSoqlQueryOptionalCustomWhereClause(row, fields); + const customWhereClauseQuery = composeSoqlQueryCustomWhereClause(row, fields); + + const recordsById: Record = {}; + const customCriteriaRecordIds = new Set(); + + if (standardQuery) { + await queryAll(org, standardQuery).then((res) => + res.queryResults.records.forEach((record) => { + recordsById[record.Id] = record; + }) + ); + } + + if (customWhereClauseQuery) { + await queryAll(org, customWhereClauseQuery).then((res) => + res.queryResults.records.forEach((record) => { + recordsById[record.Id] = record; + customCriteriaRecordIds.add(record.Id); + }) + ); + } + + return prepareRecords(Object.values(recordsById), row.configuration, customCriteriaRecordIds); } -export function composeSoqlQuery(row: MetadataRow, fields: string[]) { +export function composeSoqlQueryOptionalCustomWhereClause(row: MetadataRow, fields: string[], includeCustom = false) { const query: Query = { fields: fields.map((field) => getField(field)), sObject: row.sobject, }; - if (row.transformationOptions.criteria === 'onlyIfBlank' && row.selectedField) { - query.where = { - left: { - field: row.selectedField, - operator: '=', - value: 'null', - literalType: 'NULL', - }, - }; - } else if (row.transformationOptions.criteria === 'onlyIfNotBlank' && row.selectedField) { - query.where = { - left: { - field: row.selectedField, - operator: '!=', - value: 'null', - literalType: 'NULL', - }, - }; - } let soql = composeQuery(query); - if ( - row.transformationOptions.criteria === 'custom' && - row.transformationOptions.whereClause && - isQueryValid(`WHERE ${row.transformationOptions.whereClause}`, { allowPartialQuery: true }) - ) { - soql += ` WHERE ${row.transformationOptions.whereClause}`; + const processAllRecords = row.configuration.some((config) => config.transformationOptions.criteria === 'all'); + + const whereClauses = row.configuration + .map(({ selectedField, transformationOptions }) => { + if (transformationOptions.criteria === 'onlyIfBlank' && selectedField) { + return `(${selectedField} = NULL)`; + } else if (transformationOptions.criteria === 'onlyIfNotBlank' && selectedField) { + return `(${selectedField} != NULL)`; + } else if ( + includeCustom && + transformationOptions.criteria === 'custom' && + transformationOptions.whereClause && + isQueryValid(`WHERE ${transformationOptions.whereClause}`, { allowPartialQuery: true }) + ) { + return `(${transformationOptions.whereClause})`; + } + return null; + }) + .filter(Boolean) + .join(' OR '); + + if (!processAllRecords && !whereClauses) { + return null; } - logger.info('soqlQuery()', { soql }); + if (!processAllRecords && whereClauses) { + soql += ` WHERE ${whereClauses}`; + } + + logger.info('composeSoqlQueryExceptCustomWhereClause()', { soql }); + return soql; +} + +export function composeSoqlQueryCustomWhereClause(row: MetadataRow, fields: string[]) { + const query: Query = { + fields: fields.map((field) => getField(field)), + sObject: row.sobject, + }; + + const whereClauses = row.configuration + .filter( + ({ transformationOptions }) => + transformationOptions.criteria === 'custom' && + transformationOptions.whereClause && + isQueryValid(`WHERE ${transformationOptions.whereClause}`, { allowPartialQuery: true }) + ) + .map(({ transformationOptions }) => `(${transformationOptions.whereClause})`) + .join(' OR '); + + if (!whereClauses) { + return null; + } + + const soql = `${composeQuery(query)} WHERE ${whereClauses}`; + + logger.info('composeSoqlQueryCustomWhereClause()', { soql }); return soql; } /** - * Used from places where records are already fetched (query results,) + * Used from places where records are already fetched (query results) */ export async function fetchRecordsWithRequiredFields({ selectedOrg, records: existingRecords, parsedQuery, - transformationOptions, - selectedField, idsToInclude, + configuration, }: { selectedOrg: SalesforceOrgUi; records: SalesforceRecord[]; parsedQuery: Query; - transformationOptions: TransformationOptions; - selectedField: string; idsToInclude?: Set; + configuration: MetadataRowConfiguration[]; }): Promise { // selectedField is required so that transformationOptions.criteria can be applied to records - const fieldsRequiredInRecords = new Set(['Id', selectedField]); + const fieldsRequiredInRecords = new Set(['Id']); - if (transformationOptions.option === 'anotherField') { - const { alternateField } = transformationOptions; - // This should always exist in this state - if (!alternateField) { - throw new Error('Alternate field is required'); + configuration.forEach(({ transformationOptions, selectedField }) => { + if (selectedField) { + fieldsRequiredInRecords.add(selectedField); + if (transformationOptions.option === 'anotherField') { + const { alternateField } = transformationOptions; + // This should always exist in this state + if (!alternateField) { + throw new Error('Alternate field is required'); + } + fieldsRequiredInRecords.add(alternateField); + } } - fieldsRequiredInRecords.add(alternateField); - } + }); // Re-fetch records - this may not always be required, but for consistency this will happen every time parsedQuery = { ...parsedQuery, fields: Array.from(fieldsRequiredInRecords).map((field) => getField(field)) }; @@ -154,12 +225,43 @@ export async function fetchRecordsWithRequiredFields({ records = records.filter((record) => idsToInclude.has(record.Id)); } - // Skip records that don't meet additional criteria - if (transformationOptions.criteria === 'onlyIfBlank') { - records = records.filter((record) => isNil(record[selectedField])); - } else if (transformationOptions.criteria === 'onlyIfNotBlank') { - records = records.filter((record) => !isNil(record[selectedField])); - } - return records; } + +export function prepareRecords( + records: SalesforceRecord[], + configuration: MetadataRowConfiguration[], + customCriteriaRecordIds: Set = new Set() +) { + return records.map((record) => { + const newRecord = { ...record }; + configuration.forEach(({ selectedField, selectedFieldMetadata, transformationOptions }) => { + const isBoolean = selectedFieldMetadata?.type === 'boolean'; + const emptyFieldValue = isBoolean ? false : SFDC_BULK_API_NULL_VALUE; + if (selectedField) { + // Exit early if the criteria is not met, Set to null so the results show this field was not modified, otherwise it shows record field value + if (transformationOptions.criteria === 'onlyIfBlank' && !isNil(record[selectedField])) { + newRecord[selectedField] = null; + return; + } + if (transformationOptions.criteria === 'onlyIfNotBlank' && isNil(record[selectedField])) { + newRecord[selectedField] = null; + return; + } + if (transformationOptions.criteria === 'custom' && !customCriteriaRecordIds.has(record.Id)) { + newRecord[selectedField] = null; + return; + } + + if (transformationOptions.option === 'anotherField' && transformationOptions.alternateField) { + newRecord[selectedField] = lodashGet(newRecord, transformationOptions.alternateField, emptyFieldValue); + } else if (transformationOptions.option === 'staticValue') { + newRecord[selectedField] = transformationOptions.staticValue; + } else { + newRecord[selectedField] = emptyFieldValue; + } + } + }); + return newRecord; + }); +} diff --git a/libs/shared/ui-core/src/mass-update-records/useDeployRecords.ts b/libs/shared/ui-core/src/mass-update-records/useDeployRecords.ts index 5878038b6..dd7601b4a 100644 --- a/libs/shared/ui-core/src/mass-update-records/useDeployRecords.ts +++ b/libs/shared/ui-core/src/mass-update-records/useDeployRecords.ts @@ -1,17 +1,16 @@ import { logger } from '@jetstream/shared/client-logger'; -import { ANALYTICS_KEYS, SFDC_BULK_API_NULL_VALUE } from '@jetstream/shared/constants'; -import { bulkApiAddBatchToJob, bulkApiCreateJob, bulkApiGetJob, queryAll } from '@jetstream/shared/data'; +import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; +import { bulkApiAddBatchToJob, bulkApiCreateJob, bulkApiGetJob } from '@jetstream/shared/data'; import { checkIfBulkApiJobIsDone, convertDateToLocale, generateCsv, useBrowserNotifications, useRollbar } from '@jetstream/shared/ui-utils'; import { delay, getErrorMessage, splitArrayToMaxSize } from '@jetstream/shared/utils'; -import { BulkJobBatchInfo, Field, Maybe, SalesforceOrgUi } from '@jetstream/types'; +import { BulkJobBatchInfo, SalesforceOrgUi } from '@jetstream/types'; import { formatDate } from 'date-fns/format'; -import lodashGet from 'lodash/get'; import { useCallback, useEffect, useRef } from 'react'; import { useRecoilState } from 'recoil'; import { useAmplitude } from '../analytics'; import { applicationCookieState } from '../app-state/app-state'; -import { DeployResults, MetadataRow, TransformationOptions } from './mass-update-records.types'; -import { composeSoqlQuery, getFieldsToQuery } from './mass-update-records.utils'; +import { DeployResults, MetadataRow, MetadataRowConfiguration } from './mass-update-records.types'; +import { getFieldsToQuery, prepareRecords, queryAndPrepareRecordsForUpdate } from './mass-update-records.utils'; export function useDeployRecords( org: SalesforceOrgUi, @@ -31,37 +30,6 @@ export function useDeployRecords( }; }, []); - /** - * Update field on each record - */ - const prepareRecords = useCallback( - ( - records: any[], - { - selectedField, - selectedFieldMetadata, - transformationOptions, - }: { selectedField: Maybe; selectedFieldMetadata: Maybe; transformationOptions: TransformationOptions } - ) => { - return records.map((record) => { - const newRecord = { ...record }; - const isBoolean = selectedFieldMetadata?.type === 'boolean'; - const emptyFieldValue = isBoolean ? false : SFDC_BULK_API_NULL_VALUE; - if (selectedField) { - if (transformationOptions.option === 'anotherField' && transformationOptions.alternateField) { - newRecord[selectedField] = lodashGet(newRecord, transformationOptions.alternateField, emptyFieldValue); - } else if (transformationOptions.option === 'staticValue') { - newRecord[selectedField] = transformationOptions.staticValue; - } else { - newRecord[selectedField] = emptyFieldValue; - } - } - return newRecord; - }); - }, - [] - ); - /** * Submit bulk update job */ @@ -139,21 +107,18 @@ export function useDeployRecords( status: 'In Progress - Preparing', }; - const fields = getFieldsToQuery(row); + const fields = getFieldsToQuery( + row.configuration.map(({ transformationOptions, selectedField }) => ({ transformationOptions, selectedField })) + ); onDeployResults(row.sobject, { ...deployResults }); - const { queryResults } = await queryAll(org, composeSoqlQuery(row, fields)); + const records = await queryAndPrepareRecordsForUpdate(row, fields, org); + if (!isMounted.current) { return; } - const records = prepareRecords(queryResults.records, { - selectedField: row.selectedField, - selectedFieldMetadata: row.selectedFieldMetadata, - transformationOptions: row.transformationOptions, - }); - // There are no records to update for this object if (records.length === 0) { const deployResults: DeployResults = { @@ -176,7 +141,7 @@ export function useDeployRecords( deployResults, sobject: row.sobject, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - fields: ['Id', row.selectedField!], + fields: ['Id', ...row.configuration.map(({ selectedField }) => selectedField!)], records, batchSize, serialMode, @@ -186,7 +151,7 @@ export function useDeployRecords( return; } }, - [org, performLoad, prepareRecords, onDeployResults] + [org, performLoad, onDeployResults] ); /** @@ -233,9 +198,7 @@ export function useDeployRecords( fields, batchSize, serialMode, - selectedField, - selectedFieldMetadata, - transformationOptions, + configuration, }: { records: any[]; sobject: string; @@ -243,9 +206,7 @@ export function useDeployRecords( fields: string[]; batchSize: number; serialMode: boolean; - selectedField: Maybe; - selectedFieldMetadata: Maybe; - transformationOptions: TransformationOptions; + configuration: MetadataRowConfiguration[]; }) => { trackEvent(ANALYTICS_KEYS.mass_update_Submitted, { batchSize: batchSize, @@ -279,7 +240,7 @@ export function useDeployRecords( } onDeployResults(sobject, { ...deployResults }); - const records = prepareRecords(initialRecords, { selectedField, selectedFieldMetadata, transformationOptions }); + const records = prepareRecords(initialRecords, configuration); deployResults.status = 'In Progress - Uploading'; onDeployResults(sobject, { ...deployResults }); diff --git a/libs/shared/ui-core/tsconfig.json b/libs/shared/ui-core/tsconfig.json index 934015663..a2ffad644 100644 --- a/libs/shared/ui-core/tsconfig.json +++ b/libs/shared/ui-core/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../../tsconfig.base.json", "compilerOptions": { "allowJs": false, "allowSyntheticDefaultImports": true, @@ -19,6 +20,5 @@ { "path": "./tsconfig.spec.json" } - ], - "extends": "../../../tsconfig.base.json" + ] } 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 63e47240b..a0164ad55 100644 --- a/libs/shared/ui-utils/src/lib/shared-ui-utils.ts +++ b/libs/shared/ui-utils/src/lib/shared-ui-utils.ts @@ -72,7 +72,7 @@ export function initXlsx(_xlsx: typeof import('xlsx')) { _xlsx.set_cptable(module); }) .catch((ex) => { - logger.warn('Error loading xlsx package'); + // ignore error }); } diff --git a/libs/shared/ui-utils/tsconfig.spec.json b/libs/shared/ui-utils/tsconfig.spec.json index 1e964c9e9..48981be84 100644 --- a/libs/shared/ui-utils/tsconfig.spec.json +++ b/libs/shared/ui-utils/tsconfig.spec.json @@ -1,8 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../../dist/out-tsc", "module": "commonjs", + "outDir": "../../../dist/out-tsc", "strictNullChecks": false, "types": ["jest", "node"] }, From 5f2e7c42d0cc1dcfd678ae8e93063a0cb9231a0f Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 1 Jun 2024 10:27:18 -0500 Subject: [PATCH 2/3] Fix integration tests --- .../pageObjectModels/LoadWithoutFilePage.model.ts | 4 ++-- .../load-without-file/load-without-file.spec.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/jetstream-e2e/src/pageObjectModels/LoadWithoutFilePage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/LoadWithoutFilePage.model.ts index b130409b1..ca21c8477 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/LoadWithoutFilePage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/LoadWithoutFilePage.model.ts @@ -32,7 +32,7 @@ export class LoadWithoutFilePage { async validateAndReviewAndSubmit(batchSize?: string) { await this.page.getByRole('button', { name: 'Validate Results' }).click(); - await this.page.getByText(/[0-9]+ records will be updated/); + await this.page.getByText(/[0-9]+ records will be updated/).waitFor(); await this.page.getByRole('link', { name: 'Review Changes' }).click(); @@ -41,7 +41,7 @@ export class LoadWithoutFilePage { await this.page.getByPlaceholder('Set batch size').fill('1'); } - await this.page.getByRole('button', { name: 'Update Records' }).click(); + await this.page.getByRole('button', { name: /(Update [0-9]+ Records?)|(Update Records)/g }).click(); } async configureStaticField(value: string) { diff --git a/apps/jetstream-e2e/src/tests/load-without-file/load-without-file.spec.ts b/apps/jetstream-e2e/src/tests/load-without-file/load-without-file.spec.ts index dc6215f19..c0bf54b8a 100644 --- a/apps/jetstream-e2e/src/tests/load-without-file/load-without-file.spec.ts +++ b/apps/jetstream-e2e/src/tests/load-without-file/load-without-file.spec.ts @@ -25,23 +25,24 @@ test.describe('LOAD WITHOUT FILE', () => { await expect(page.getByRole('button', { name: 'Review Changes' })).toBeDisabled(); await loadWithoutFilePage.configureStaticPicklistField('Installation Partner'); - await page.getByTitle('"Type" will be set to "Installation Partner" on all records'); + await page.getByTitle('"Type" will be set to "Installation Partner" on all records').waitFor(); await expect(page.getByRole('button', { name: 'Validate Results' })).toBeEnabled(); await loadWithoutFilePage.configureStaticPicklistField('Prospect'); - await page.getByTitle('"Type" will be set to "Prospect" on all records'); + await page.getByTitle('"Type" will be set to "Prospect" on all records').waitFor(); await expect(page.getByRole('button', { name: 'Validate Results' })).toBeEnabled(); await loadWithoutFilePage.configureCriteria('Only if blank'); - await page.getByTitle(`"Type" will be set to "Prospect" on records where "Type" is blank`); + await page.getByTitle(`"Type" will be set to "Prospect" on records where "Type" is blank`).waitFor(); await expect(page.getByRole('button', { name: 'Validate Results' })).toBeEnabled(); await loadWithoutFilePage.configureCriteria('Only if not blank'); - await page.getByTitle(`"Type" will be set to "Prospect" on records where "Type" is not blank`); + await page.getByTitle(`"Type" will be set to "Prospect" on records where "Type" is not blank`).waitFor(); await expect(page.getByRole('button', { name: 'Validate Results' })).toBeEnabled(); await loadWithoutFilePage.configureCriteria('Custom criteria', 'type = null'); - await page.getByTitle(`"Type" will be set to "Prospect"on records that meet your custom criteria: "type = null"`); + await page.getByTitle(`"Type" will be set to "Prospect"`).waitFor(); + await page.getByTitle(`type = null`).waitFor(); await expect(page.getByRole('button', { name: 'Validate Results' })).toBeEnabled(); await page.getByRole('button', { name: 'Validate Results' }).click(); @@ -71,8 +72,8 @@ test.describe('LOAD WITHOUT FILE', () => { await page.getByTestId('dropdown-Which records should be updated?').getByPlaceholder('Select an Option').click(); await page.getByRole('option', { name: 'Only if not blank' }).click(); - await page.getByRole('button', { name: 'Update Records' }).click(); - await expect(page.getByRole('button', { name: 'Update Records' })).toBeDisabled(); + await page.getByRole('button', { name: /Update [0-9]+ Records?/g }).click(); + await expect(page.getByRole('button', { name: /Update [0-9]+ Records?/g })).toBeDisabled(); await expect(page.getByText(/(In Progress)|(Finished)/)).toBeVisible(); }); From 04c2823762181f89a234b04f17f8a6e25592e43c Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 1 Jun 2024 12:59:22 -0500 Subject: [PATCH 3/3] Bugfixes Fix removing bulk update field from query results Ensure bulk update results are cleared out if user goes back and then deploys again --- .../BulkUpdateFromQueryModal.tsx | 2 +- .../MassUpdateRecordsDeployment.tsx | 7 ++---- .../selection/MassUpdateRecordsSelection.tsx | 5 ++++- .../selection/useMassUpdateFieldItems.ts | 22 +++++++++++++++++++ .../MassUpdateRecordsObjectRow.tsx | 5 +++-- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/jetstream/src/app/components/query/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx b/apps/jetstream/src/app/components/query/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx index 2f30394fc..e45df6cbc 100644 --- a/apps/jetstream/src/app/components/query/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx +++ b/apps/jetstream/src/app/components/query/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx @@ -355,7 +355,7 @@ export const BulkUpdateFromQueryModal: FunctionComponent field.value !== 'custom'} onAddField={() => setSelectedConfig((prev) => [...prev, { ...DEFAULT_FIELD_CONFIGURATION }])} - onRemoveField={() => setSelectedConfig((prev) => prev.slice(0, -1))} + onRemoveField={(_, index) => setSelectedConfig((prev) => prev.toSpliced(index, 1))} />
diff --git a/apps/jetstream/src/app/components/update-records/deployment/MassUpdateRecordsDeployment.tsx b/apps/jetstream/src/app/components/update-records/deployment/MassUpdateRecordsDeployment.tsx index 04ddffd64..18eb4492d 100644 --- a/apps/jetstream/src/app/components/update-records/deployment/MassUpdateRecordsDeployment.tsx +++ b/apps/jetstream/src/app/components/update-records/deployment/MassUpdateRecordsDeployment.tsx @@ -13,7 +13,7 @@ import { } from '@jetstream/ui'; import { DeployResults, MassUpdateRecordsDeploymentRow, MetadataRow, selectedOrgState, useDeployRecords } from '@jetstream/ui-core'; import isNumber from 'lodash/isNumber'; -import { ChangeEvent, FunctionComponent, useCallback, useEffect, useState } from 'react'; +import { ChangeEvent, useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil'; import * as fromMassUpdateState from '../mass-update-records.state'; @@ -39,10 +39,7 @@ const updateDeploymentResultsState = return rowsMap; }; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface MassUpdateRecordsDeploymentProps {} - -export const MassUpdateRecordsDeployment: FunctionComponent = () => { +export const MassUpdateRecordsDeployment = () => { const selectedOrg = useRecoilValue(selectedOrgState); const rows = useRecoilValue(fromMassUpdateState.rowsState); const [loading, setLoading] = useState(false); diff --git a/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsSelection.tsx b/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsSelection.tsx index f21146681..2c8aa699b 100644 --- a/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsSelection.tsx +++ b/apps/jetstream/src/app/components/update-records/selection/MassUpdateRecordsSelection.tsx @@ -34,9 +34,10 @@ export const MassUpdateRecordsSelection: FunctionComponent clearResults(), [clearResults]); + useEffect(() => { if (allRowsValid) { setAllRowsValidated(!!rows.length && rows.every((row) => row.validationResults?.isValid)); diff --git a/apps/jetstream/src/app/components/update-records/selection/useMassUpdateFieldItems.ts b/apps/jetstream/src/app/components/update-records/selection/useMassUpdateFieldItems.ts index 0d8b172c3..517a2ef4a 100644 --- a/apps/jetstream/src/app/components/update-records/selection/useMassUpdateFieldItems.ts +++ b/apps/jetstream/src/app/components/update-records/selection/useMassUpdateFieldItems.ts @@ -26,6 +26,7 @@ import * as fromMassUpdateState from '../mass-update-records.state'; type Action = | { type: 'RESET' } + | { type: 'CLEAR_DEPLOYMENT_RESULTS' } | { type: 'OBJECTS_SELECTED'; payload: { sobjects: string[] } } | { type: 'OBJECTS_REMOVED'; payload: { sobjects: string[] } } | { type: 'FIELD_SELECTION_CHANGED'; payload: { sobject: string; selectedField: string; configIndex: number } } @@ -59,6 +60,22 @@ function reducer(state: State, action: Action): State { loading: false, }; } + case 'CLEAR_DEPLOYMENT_RESULTS': { + const rowsMap = new Map(state.rowsMap); + rowsMap.forEach((row) => { + rowsMap.set(row.sobject, { + ...row, + deployResults: { + status: 'Not Started', + done: false, + processingErrors: [], + records: [], + batchIdToIndex: {}, + }, + }); + }); + return { ...state, rowsMap }; + } case 'OBJECTS_SELECTED': { const { sobjects } = action.payload; const rowsMap = new Map(state.rowsMap); @@ -308,6 +325,10 @@ export function useMassUpdateFieldItems(org: SalesforceOrgUi, selectedSObjects: dispatch({ type: 'RESET' }); }, []); + const clearResults = useCallback(() => { + dispatch({ type: 'CLEAR_DEPLOYMENT_RESULTS' }); + }, []); + /** * Fetch metadata for all selected objects * If the user changes selection while this is running, then the results will be ignored @@ -471,6 +492,7 @@ export function useMassUpdateFieldItems(org: SalesforceOrgUi, selectedSObjects: return { reset, + clearResults, rows, allRowsValid, onFieldSelected, diff --git a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRow.tsx b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRow.tsx index 2f899e22d..59abd72a6 100644 --- a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRow.tsx +++ b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsObjectRow.tsx @@ -1,7 +1,7 @@ import { formatNumber } from '@jetstream/shared/ui-utils'; import { pluralizeFromNumber } from '@jetstream/shared/utils'; import { Field, ListItem, Maybe } from '@jetstream/types'; -import { Grid, GridCol, ScopedNotification, Spinner } from '@jetstream/ui'; +import { Grid, GridCol, Icon, ScopedNotification, Spinner } from '@jetstream/ui'; import isNumber from 'lodash/isNumber'; import { Fragment, FunctionComponent, ReactNode } from 'react'; import MassUpdateRecordTransformationText from './MassUpdateRecordTransformationText'; @@ -105,7 +105,8 @@ export const MassUpdateRecordsObjectRow: FunctionComponent
-