Skip to content

Commit

Permalink
Merge pull request #911 from jetstreamapp/feat/907
Browse files Browse the repository at this point in the history
Allow bulk updating multiple fields
  • Loading branch information
paustint authored Jun 1, 2024
2 parents 45ec903 + 04c2823 commit b57797a
Show file tree
Hide file tree
Showing 22 changed files with 1,112 additions and 352 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,34 +19,37 @@ 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';

const MAX_BATCH_SIZE = 10000;
const IN_PROGRESS_STATUSES = new Set<DeployResults['status']>(['In Progress - Preparing', 'In Progress - Uploading', 'In Progress']);

function checkIfValid(selectedField: Maybe<string>, 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
Expand Down Expand Up @@ -90,16 +93,8 @@ export const BulkUpdateFromQueryModal: FunctionComponent<BulkUpdateFromQueryModa
const [isValid, setIsValid] = useState(false);
const [fatalError, setFatalError] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedField, setSelectedField] = useState<{ field: string; metadata: Field } | null>(null);
const [selectedConfig, setSelectedConfig] = useState<MetadataRowConfiguration[]>([{ ...DEFAULT_FIELD_CONFIGURATION }]);
const [fields, setFields] = useState<ListItem[]>([]);
/** Fields that can be used as value */
const [transformationOptions, setTransformationOptions] = useState<TransformationOptions>({
option: 'staticValue',
alternateField: undefined,
staticValue: '',
criteria: 'all',
whereClause: '',
});
const [hasMoreRecords, setHasMoreRecords] = useState<boolean>(false);
const [downloadRecordsValue, setDownloadRecordsValue] = useState<string>(hasMoreRecords ? RADIO_ALL_SERVER : RADIO_ALL_BROWSER);
const [batchSize, setBatchSize] = useState<Maybe<number>>(10000);
Expand All @@ -110,6 +105,15 @@ export const BulkUpdateFromQueryModal: FunctionComponent<BulkUpdateFromQueryModa
const [didDeploy, setDidDeploy] = useState(false);
const resetDeployResults = useResetRecoilState(deployResultsState);
const [{ serverUrl }] = useRecoilState(applicationCookieState);
const targetedRecordCount = useMemo(() => {
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 }) =>
Expand Down Expand Up @@ -147,12 +151,8 @@ export const BulkUpdateFromQueryModal: FunctionComponent<BulkUpdateFromQueryModa
}, [selectedOrg, sobject, parsedQuery]);

useEffect(() => {
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) {
Expand Down Expand Up @@ -192,7 +192,7 @@ export const BulkUpdateFromQueryModal: FunctionComponent<BulkUpdateFromQueryModa
}

const handleLoadRecords = async () => {
if (!selectedField || batchSizeError) {
if (batchSizeError || !isValid || !selectedConfig || selectedConfig.some(({ selectedField }) => !selectedField)) {
return;
}

Expand Down Expand Up @@ -222,22 +222,20 @@ export const BulkUpdateFromQueryModal: FunctionComponent<BulkUpdateFromQueryModa
selectedOrg,
records,
parsedQuery,
transformationOptions,
selectedField: selectedField.field,
idsToInclude,
configuration: selectedConfig,
});

setLoading(true);

await loadDataForProvidedRecords({
records: recordsToLoad,
sobject,
fields: ['Id', selectedField.field],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
fields: ['Id', ...selectedConfig.map(({ selectedField }) => selectedField!).filter(Boolean)],
batchSize: batchSize ?? 10000,
serialMode,
selectedField: selectedField.field,
selectedFieldMetadata: selectedField.metadata,
transformationOptions,
configuration: selectedConfig,
});
pollResultsUntilDone(getDeploymentResults);
} catch (ex) {
Expand Down Expand Up @@ -294,7 +292,7 @@ export const BulkUpdateFromQueryModal: FunctionComponent<BulkUpdateFromQueryModa
onClick={handleLoadRecords}
disabled={!isValid || loading || !!batchSizeError || deployInProgress || !!fatalError}
>
Update Records
Update {formatNumber(targetedRecordCount)} {pluralizeFromNumber('Record', targetedRecordCount)}
</button>
</div>
</Grid>
Expand Down Expand Up @@ -333,17 +331,31 @@ export const BulkUpdateFromQueryModal: FunctionComponent<BulkUpdateFromQueryModa
loading={false}
fields={fields}
valueFields={valueFields}
selectedField={selectedField?.field}
selectedFieldMetadata={selectedField?.metadata}
transformationOptions={transformationOptions}
fieldConfigurations={selectedConfig}
hasExternalWhereClause={!!parsedQuery.where}
disabled={loading || deployInProgress || !!fatalError}
onFieldChange={(field: string, metadata: Field) => {
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={(_, index) => setSelectedConfig((prev) => prev.toSpliced(index, 1))}
/>

<Section id="mass-update-deploy-options" label="Advanced Options" initialExpanded={false}>
Expand Down Expand Up @@ -383,10 +395,8 @@ export const BulkUpdateFromQueryModal: FunctionComponent<BulkUpdateFromQueryModa
selectedOrg={selectedOrg}
sobject={sobject}
deployResults={deployResults}
transformationOptions={transformationOptions}
configuration={selectedConfig}
hasExternalWhereClause={!!parsedQuery.where}
selectedField={selectedField?.field}
selectedFieldMetadata={selectedField?.metadata}
batchSize={batchSize ?? 10000}
omitTransformationText
onModalOpenChange={setIsSecondModalOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -39,10 +39,7 @@ const updateDeploymentResultsState =
return rowsMap;
};

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface MassUpdateRecordsDeploymentProps {}

export const MassUpdateRecordsDeployment: FunctionComponent<MassUpdateRecordsDeploymentProps> = () => {
export const MassUpdateRecordsDeployment = () => {
const selectedOrg = useRecoilValue<SalesforceOrgUi>(selectedOrgState);
const rows = useRecoilValue(fromMassUpdateState.rowsState);
const [loading, setLoading] = useState(false);
Expand Down Expand Up @@ -157,8 +154,7 @@ export const MassUpdateRecordsDeployment: FunctionComponent<MassUpdateRecordsDep
selectedOrg={selectedOrg}
deployResults={row.deployResults}
sobject={row.sobject}
transformationOptions={row.transformationOptions}
selectedField={row.selectedField}
configuration={row.configuration}
validationResults={row.validationResults}
batchSize={batchSize ?? 1000}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof useMassUpdateFieldItems>['onFieldSelected'];
handleOptionChange: ReturnType<typeof useMassUpdateFieldItems>['handleOptionChange'];
onLoadChildFields: (sobject: string, item: ListItem) => Promise<ListItem[]>;
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<MassUpdateRecordsObjectProps> = ({
selectedOrg,
row,
onFieldSelected,
onLoadChildFields,
handleOptionChange,
validateRowRecords,
handleAddField,
handleRemoveField,
}) => {
const handleLoadChildFields = useCallback(
async (item: ListItem): Promise<ListItem[]> => {
Expand All @@ -37,13 +40,13 @@ export const MassUpdateRecordsObject: FunctionComponent<MassUpdateRecordsObjectP
loading={row.loading}
fields={row.fields}
valueFields={row.valueFields}
selectedField={row.selectedField}
selectedFieldMetadata={row.selectedFieldMetadata}
fieldConfigurations={row.configuration}
validationResults={row.validationResults}
transformationOptions={row.transformationOptions}
onFieldChange={(selectedField) => 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}
>
<GridCol size={12}>
<Grid align="spread" verticalAlign="center">
Expand Down
Loading

0 comments on commit b57797a

Please sign in to comment.