Skip to content

Commit

Permalink
[PLAT-5661][PLAT-5820] Add non-blocking validation and harden the cre…
Browse files Browse the repository at this point in the history
…ate xCluster config form

Summary:
This diff contains two changes:
1. Add non-blocking form validation as a warning message.
2. Reset table selection when changing target universe in create xCluster config form

Add non-blocking form validation as a warning message
-----------------------------------------------------
**Context**
Formik validation lets us set errors which block the user from submitting.
There are some checks (ex. disk space) where we validate against a recommendation
rather than a requirement.
This diff adds a way to pass non-blocking warnings to the various form steps.

**Changes**
- Add form warnings.
- Store `isTableSelectionValidated` status in CreateConfigModal state instead of using a separte step.
     - Removed `Enabled Replication` step. Form will use `isTableSelectionValidated` to decide submit label
       and action.

Reset table selection when changing target universe in create xCluster config form
------------------------------------------------------
**Context**
Consider the following case:
1) User chooses a target universe for the xCluster config -> proceeds to next page
2) User selects some tables (table eligibility is based on the target universe selection from the previous page)
3) User changes their mind and wants to select a different target universe -> proceeds to previous page (select target universe)
4) User changes target universe selection
5) User submits the form step (clicks next).
We should reset the table/keyspace selection since some of the selected items may not be ineligible for selection.

**Changes**
- Reset table selection only if user submits the 'select target universe' form step with a different target universe.

We do not reset the table/keyspace selection if the user changes target universe selection (step #4)
and then changes back to the previous value (from step #1).

This is to give some tolerance for user error. Suppose the user misclicks and changes the target
universe after spending a lot of time selecting their tables, they would need to reselect every table again with no easy ‘undo’.

Test Plan:
Non-blocking warning
- Create xCluster config with a universe that does not have enough free disk space (< 100GB) and requires bootstrapping.
- Verify that a non-blocking warning message will show to let the user know about our 100GB free disk space recommendation.
- Verify that the user is free to ignore this warning by proceeding straight to the bootstrapping step.

Reset table selection
- Ensure you have at least 3 universes.
- Choose any one of the universes to create an xCluster config from. Call this Universe1
- Select any universe to be the target universe. Call this Universe2. Click next.
- Select 1+ tables.
- Go back to the 'Select target universe' step.
- Change the target universe to something else. Call this Universe3.
- Change the target universe from Universe3 back to Universe2 before clicking next.
- Verify that the table selection has not changed.
- Go back to the 'Select target universe' step
- Change the target universe to something else (can be anything other than Universe2). Click next
- Verify that table selection is now empty.

Reviewers: hzare, lsangappa, rmadhavan

Reviewed By: rmadhavan

Subscribers: jenkins-bot, agarg, yugaware

Differential Revision: https://phabricator.dev.yugabyte.com/D20253
  • Loading branch information
Jethro-M committed Oct 26, 2022
1 parent e968168 commit 50b5910
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 126 deletions.
9 changes: 7 additions & 2 deletions managed/ui/src/_style/colors.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ $YB_PENDING_COLOR: $YB_ORANGE;
$YB_FAIL_COLOR: #e8473f;
$YB_ERROR_TEXT_COLOR: #a94442;
$YB_ERROR_ICON_COLOR: #e73e36;
$YB_WARNING_ICON_COLOR: #C88900;
$YB_WARNING_ICON_COLOR: #c88900;
$YB_ERROR_LIGHT: #fdeceb;
$YB_BLUE_COLOR: #35a3ff;
$YB_FAIL_COLOR_ICON: darken($YB_FAIL_COLOR, 20%);
Expand All @@ -48,7 +48,7 @@ $YB_GRAY_HOVER: #e5e5e9;
$YB_GRAY_BORDER: #cfcfd8;
$YB_DARK_GRAY_2: #232329;
$YB_LIGHT_GRAY_BORDER: #e3e3e5;
$YB_LIGHT_GRAY: #F7FAFC;
$YB_LIGHT_GRAY: #f7fafc;

$YB_SUCCESS_COLOR_LIGHT: #f1f8f3;
$YB_TEXT_BLACK: #333;
Expand All @@ -71,6 +71,11 @@ $YB_BANNER_WARNING_BORDER: #d0ba8b;
$YB_BANNER_ERROR_BACKGROUND: #fde2e2;
$YB_BANNER_ERROR_BORDER: #dcadad;

$YB_VALIDATION_ERROR_BACKGROUND: #fee6e0;
$YB_VALIDATION_ERROR_BORDER: #e73e36;
$YB_VALIDATION_WARNING_BACKGROUND: #ffeec8;
$YB_VALIDATION_WARNING_BORDER: #d0ba8b;

.yb-orange {
color: $YB_ORANGE;
}
Expand Down
221 changes: 151 additions & 70 deletions managed/ui/src/components/xcluster/createConfig/CreateConfigModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ export interface CreateXClusterConfigFormErrors {
parallelThreads: string;
}

export interface CreateXClusterConfigFormWarnings {
configName?: string;
targetUniverse?: string;
tableUUIDs?: { title: string; body: string };
// Bootstrap fields
storageConfig?: string;
parallelThreads?: string;
}

interface ConfigureReplicationModalProps {
onHide: Function;
visible: boolean;
Expand All @@ -59,7 +68,6 @@ const MODAL_TITLE = 'Configure Replication';
export enum FormStep {
SELECT_TARGET_UNIVERSE = 'selectTargetUniverse',
SELECT_TABLES = 'selectTables',
ENABLE_REPLICATION = 'enableReplication',
CONFIGURE_BOOTSTRAP = 'configureBootstrap'
}

Expand All @@ -83,8 +91,15 @@ export const CreateConfigModal = ({
}: ConfigureReplicationModalProps) => {
const [currentStep, setCurrentStep] = useState<FormStep>(FIRST_FORM_STEP);
const [bootstrapRequiredTableUUIDs, setBootstrapRequiredTableUUIDs] = useState<string[]>([]);
const [isTableSelectionValidated, setIsTableSelectionValidated] = useState(false);
const [formWarnings, setFormWarnings] = useState<CreateXClusterConfigFormWarnings>({});

// The purpose of committedTargetUniverse is to store the targetUniverse field value prior
// to the user submitting their target universe step.
// This value updates whenever the user submits SelectTargetUniverseStep with a new
// target universe.
const [committedTargetUniverseUUID, setCommittedTargetUniverseUUID] = useState<string>();

// SelectTablesStep.tsx state
// Need to store this in CreateConfigModal.tsx to support navigating back to this step.
const [tableType, setTableType] = useState<XClusterTableType>(DEFAULT_TABLE_TYPE);
const [selectedKeyspaces, setSelectedKeyspaces] = useState<string[]>([]);
Expand Down Expand Up @@ -151,9 +166,19 @@ export const CreateConfigModal = ({
}
);

const tablesQuery = useQuery<YBTable[]>(['universe', currentUniverseUUID, 'tables'], () =>
fetchTablesInUniverse(currentUniverseUUID).then((res) => res.data)
);

const universeQuery = useQuery<Universe>(['universe', currentUniverseUUID], () =>
api.fetchUniverse(currentUniverseUUID)
);

const resetModalState = () => {
setCurrentStep(FormStep.SELECT_TARGET_UNIVERSE);
setBootstrapRequiredTableUUIDs([]);
setIsTableSelectionValidated(false);
setFormWarnings({});
setTableType(DEFAULT_TABLE_TYPE);
setSelectedKeyspaces([]);
};
Expand All @@ -163,35 +188,66 @@ export const CreateConfigModal = ({
onHide();
};

const tablesQuery = useQuery<YBTable[]>(['universe', currentUniverseUUID, 'tables'], () =>
fetchTablesInUniverse(currentUniverseUUID).then((res) => res.data)
);
/**
* Wrapper around setFieldValue from formik.
* Reset `isTableSelectionValidated` to false if changing
* a validated table selection.
*/
const setTableUUIDs = (
tableUUIDs: string[],
formikActions: FormikActions<CreateXClusterConfigFormValues>
) => {
const { setFieldValue } = formikActions;
if (isTableSelectionValidated) {
setIsTableSelectionValidated(false);
}
setFieldValue('tableUUIDs', tableUUIDs);
};

const universeQuery = useQuery<Universe>(['universe', currentUniverseUUID], () =>
api.fetchUniverse(currentUniverseUUID)
);
const resetTableSelection = (formikActions: FormikActions<CreateXClusterConfigFormValues>) => {
setTableUUIDs([], formikActions);
setSelectedKeyspaces([]);
setFormWarnings((formWarnings) => {
const { tableUUIDs, ...newformWarnings } = formWarnings;
return newformWarnings;
});
};

const isBootstrapStepRequired = bootstrapRequiredTableUUIDs.length > 0;
const handleFormSubmit = async (
values: CreateXClusterConfigFormValues,
actions: FormikActions<CreateXClusterConfigFormValues>
) => {
switch (currentStep) {
case FormStep.CONFIGURE_BOOTSTRAP:
case FormStep.ENABLE_REPLICATION:
xClusterConfigMutation.mutate(values, { onSettled: () => actions.setSubmitting(false) });
return;
case FormStep.SELECT_TARGET_UNIVERSE:
if (values.targetUniverse.value.universeUUID !== committedTargetUniverseUUID) {
// Reset table selection when changing target universe.
// This is because the current table selection may be invalid for
// the new target universe.
resetTableSelection(actions);
setCommittedTargetUniverseUUID(values.targetUniverse.value.universeUUID);
}
setCurrentStep(FormStep.SELECT_TABLES);
actions.setSubmitting(false);

return;
case FormStep.SELECT_TABLES:
if (bootstrapRequiredTableUUIDs.length > 0) {
if (!isTableSelectionValidated) {
// Validation in validateForm just passed.
setIsTableSelectionValidated(true);
actions.setSubmitting(false);
return;
}

// Table selection has already been validated.
if (isBootstrapStepRequired) {
setCurrentStep(FormStep.CONFIGURE_BOOTSTRAP);
} else {
setCurrentStep(FormStep.ENABLE_REPLICATION);
actions.setSubmitting(false);
return;
}
actions.setSubmitting(false);
xClusterConfigMutation.mutate(values, { onSettled: () => actions.setSubmitting(false) });
return;
default:
assertUnreachableCase(currentStep);
Expand All @@ -203,7 +259,6 @@ export const CreateConfigModal = ({
case FormStep.SELECT_TABLES:
setCurrentStep(FormStep.SELECT_TARGET_UNIVERSE);
return;
case FormStep.ENABLE_REPLICATION:
case FormStep.CONFIGURE_BOOTSTRAP:
setCurrentStep(FormStep.SELECT_TABLES);
return;
Expand All @@ -212,6 +267,11 @@ export const CreateConfigModal = ({
}
};

const submitLabel = getFormSubmitLabel(
currentStep,
isBootstrapStepRequired,
isTableSelectionValidated
);
if (tablesQuery.isLoading || universeQuery.isLoading) {
return (
<YBModal
Expand All @@ -221,7 +281,7 @@ export const CreateConfigModal = ({
onHide={() => {
closeModal();
}}
submitLabel={getFormSubmitLabel(currentStep)}
submitLabel={submitLabel}
>
<YBLoading />
</YBModal>
Expand Down Expand Up @@ -254,14 +314,21 @@ export const CreateConfigModal = ({
title={MODAL_TITLE}
visible={visible}
validate={(values: CreateXClusterConfigFormValues) =>
validateForm(values, currentStep, universeQuery.data, setBootstrapRequiredTableUUIDs)
validateForm(
values,
currentStep,
universeQuery.data,
isTableSelectionValidated,
setBootstrapRequiredTableUUIDs,
setFormWarnings
)
}
// Perform validation for select table when user submits.
validateOnChange={currentStep !== FormStep.SELECT_TABLES}
validateOnBlur={currentStep !== FormStep.SELECT_TABLES}
onFormSubmit={handleFormSubmit}
initialValues={INITIAL_VALUES}
submitLabel={getFormSubmitLabel(currentStep)}
submitLabel={submitLabel}
onHide={() => {
closeModal();
}}
Expand Down Expand Up @@ -304,7 +371,6 @@ export const CreateConfigModal = ({
/>
);
case FormStep.SELECT_TABLES:
case FormStep.ENABLE_REPLICATION:
return (
<SelectTablesStep
{...{
Expand All @@ -313,10 +379,13 @@ export const CreateConfigModal = ({
currentUniverseUUID,
currentStep,
setCurrentStep,
setTableUUIDs: (tableUUIDs: string[]) =>
setTableUUIDs(tableUUIDs, formik.current),
tableType,
setTableType,
selectedKeyspaces,
setSelectedKeyspaces
setSelectedKeyspaces,
formWarnings
}}
/>
);
Expand All @@ -342,7 +411,9 @@ const validateForm = async (
values: CreateXClusterConfigFormValues,
currentStep: FormStep,
currentUniverse: Universe,
setBootstrapRequiredTableUUIDs: (tableUUIDs: string[]) => void
isTableSelectionValidated: boolean,
setBootstrapRequiredTableUUIDs: (tableUUIDs: string[]) => void,
setFormWarnings: (formWarnings: CreateXClusterConfigFormWarnings) => void
) => {
// Since our formik verision is < 2.0 , we need to throw errors instead of
// returning them in custom async validation:
Expand Down Expand Up @@ -383,57 +454,59 @@ const validateForm = async (
}
case FormStep.SELECT_TABLES: {
const errors: Partial<CreateXClusterConfigFormErrors> = {};
const warnings: CreateXClusterConfigFormWarnings = {};
if (!isTableSelectionValidated) {
if (!values.tableUUIDs || values.tableUUIDs.length === 0) {
errors.tableUUIDs = {
title: 'No tables selected.',
body: 'Select at least 1 table to proceed'
};
}

if (!values.tableUUIDs || values.tableUUIDs.length === 0) {
errors.tableUUIDs = {
title: 'No tables selected.',
body: 'Select at least 1 table to proceed'
};
}

// Check if bootstrap is required, for each selected table
const bootstrapTests = await isBootstrapRequired(
currentUniverse.universeUUID,
values.tableUUIDs.map(adaptTableUUID)
);

const bootstrapTableUUIDs = bootstrapTests.reduce(
(bootstrapTableUUIDs: string[], bootstrapTest) => {
// Each bootstrapTest response is of the form {<tableUUID>: boolean}.
// Until the backend supports multiple tableUUIDs per request, the response object
// will only contain one tableUUID.
// Note: Once backend does support multiple tableUUIDs per request, we will replace this
// logic with one that simply filters on the keys (tableUUIDs) of the returned object.
const tableUUID = Object.keys(bootstrapTest)[0];

if (bootstrapTest[tableUUID]) {
bootstrapTableUUIDs.push(tableUUID);
}
return bootstrapTableUUIDs;
},
[]
);
setBootstrapRequiredTableUUIDs(bootstrapTableUUIDs);

// If some tables require bootstrapping, we need to validate the source universe has enough
// disk space.
if (bootstrapTableUUIDs.length > 0) {
// Disk space validation
const currentUniverseNodePrefix = currentUniverse.universeDetails.nodePrefix;
const diskUsageMetric = await fetchUniverseDiskUsageMetric(currentUniverseNodePrefix);
const freeSpaceTrace = diskUsageMetric.disk_usage.data.find(
(trace) => trace.name === 'free'
// Check if bootstrap is required, for each selected table
const bootstrapTests = await isBootstrapRequired(
currentUniverse.universeUUID,
values.tableUUIDs.map(adaptTableUUID)
);
const freeDiskSpace = parseFloatIfDefined(freeSpaceTrace?.y[freeSpaceTrace.y.length - 1]);

if (freeDiskSpace !== undefined && freeDiskSpace < MIN_FREE_DISK_SPACE_GB) {
errors.tableUUIDs = {
title: 'Insufficient disk space.',
body: `Some selected tables require bootstrapping. Please ensure the source universe has at least ${MIN_FREE_DISK_SPACE_GB} GB of free disk space.`
};
const bootstrapTableUUIDs = bootstrapTests.reduce(
(bootstrapTableUUIDs: string[], bootstrapTest) => {
// Each bootstrapTest response is of the form {<tableUUID>: boolean}.
// Until the backend supports multiple tableUUIDs per request, the response object
// will only contain one tableUUID.
// Note: Once backend does support multiple tableUUIDs per request, we will replace this
// logic with one that simply filters on the keys (tableUUIDs) of the returned object.
const tableUUID = Object.keys(bootstrapTest)[0];

if (bootstrapTest[tableUUID]) {
bootstrapTableUUIDs.push(tableUUID);
}
return bootstrapTableUUIDs;
},
[]
);
setBootstrapRequiredTableUUIDs(bootstrapTableUUIDs);

// If some tables require bootstrapping, we need to validate the source universe has enough
// disk space.
if (bootstrapTableUUIDs.length > 0) {
// Disk space validation
const currentUniverseNodePrefix = currentUniverse.universeDetails.nodePrefix;
const diskUsageMetric = await fetchUniverseDiskUsageMetric(currentUniverseNodePrefix);
const freeSpaceTrace = diskUsageMetric.disk_usage.data.find(
(trace) => trace.name === 'free'
);
const freeDiskSpace = parseFloatIfDefined(freeSpaceTrace?.y[freeSpaceTrace.y.length - 1]);

if (freeDiskSpace !== undefined && freeDiskSpace < MIN_FREE_DISK_SPACE_GB) {
warnings.tableUUIDs = {
title: 'Insufficient disk space.',
body: `Some selected tables require bootstrapping. We recommend having at least ${MIN_FREE_DISK_SPACE_GB} GB of free disk space in the source universe.`
};
}
}
setFormWarnings(warnings);
}

throw errors;
}
case FormStep.CONFIGURE_BOOTSTRAP: {
Expand All @@ -459,13 +532,21 @@ const validateForm = async (
}
};

const getFormSubmitLabel = (formStep: FormStep) => {
const getFormSubmitLabel = (
formStep: FormStep,
bootstrapRequired: boolean,
validTableSelection: boolean
) => {
switch (formStep) {
case FormStep.SELECT_TARGET_UNIVERSE:
return 'Next: Select Tables';
case FormStep.SELECT_TABLES:
return 'Validate Connectivity and Schema';
case FormStep.ENABLE_REPLICATION:
if (!validTableSelection) {
return 'Validate Table Selection';
}
if (bootstrapRequired) {
return 'Next: Configure Bootstrap';
}
return 'Enable Replication';
case FormStep.CONFIGURE_BOOTSTRAP:
return 'Bootstrap and Enable Replication';
Expand Down
Loading

0 comments on commit 50b5910

Please sign in to comment.