diff --git a/airbyte-webapp/src/components/connection/CatalogTree/CatalogSection.tsx b/airbyte-webapp/src/components/connection/CatalogTree/CatalogSection.tsx index 6b8e6904dc3d..c8d2793e85ae 100644 --- a/airbyte-webapp/src/components/connection/CatalogTree/CatalogSection.tsx +++ b/airbyte-webapp/src/components/connection/CatalogTree/CatalogSection.tsx @@ -188,7 +188,6 @@ const CatalogSectionInner: React.FC = ({ onExpand={onExpand} changedSelected={changedSelected} hasError={hasError} - configErrors={configErrors} disabled={disabled} /> {isRowExpanded && diff --git a/airbyte-webapp/src/components/connection/CatalogTree/StreamHeader.tsx b/airbyte-webapp/src/components/connection/CatalogTree/StreamHeader.tsx index c30497c7f087..9a1690c118b2 100644 --- a/airbyte-webapp/src/components/connection/CatalogTree/StreamHeader.tsx +++ b/airbyte-webapp/src/components/connection/CatalogTree/StreamHeader.tsx @@ -43,7 +43,6 @@ export interface StreamHeaderProps { onExpand: () => void; changedSelected: boolean; hasError: boolean; - configErrors?: Record; disabled?: boolean; } diff --git a/airbyte-webapp/src/components/connection/CatalogTree/next/CatalogTreeTableRow.tsx b/airbyte-webapp/src/components/connection/CatalogTree/next/CatalogTreeTableRow.tsx index cf1f0a2933b0..8f86a94e939e 100644 --- a/airbyte-webapp/src/components/connection/CatalogTree/next/CatalogTreeTableRow.tsx +++ b/airbyte-webapp/src/components/connection/CatalogTree/next/CatalogTreeTableRow.tsx @@ -35,7 +35,6 @@ export const CatalogTreeTableRow: React.FC = ({ fields, onExpand, disabled, - configErrors, }) => { const { primaryKey, cursorField, syncMode, destinationSyncMode } = stream.config ?? {}; const { defaultCursorField } = stream.stream ?? {}; @@ -53,7 +52,7 @@ export const CatalogTreeTableRow: React.FC = ({ const fieldCount = fields?.length ?? 0; const onRowClick = fieldCount > 0 ? () => onExpand() : undefined; - const { streamHeaderContentStyle, pillButtonVariant } = useCatalogTreeTableRowProps(stream); + const { streamHeaderContentStyle, pillButtonVariant, configErrors } = useCatalogTreeTableRowProps(stream); const checkboxCellCustomStyle = classnames(styles.checkboxCell, styles.streamRowCheckboxCell); diff --git a/airbyte-webapp/src/components/connection/CatalogTree/next/useCatalogTreeTableRowProps.tsx b/airbyte-webapp/src/components/connection/CatalogTree/next/useCatalogTreeTableRowProps.tsx index 4a881040ea07..8d9b9665e68e 100644 --- a/airbyte-webapp/src/components/connection/CatalogTree/next/useCatalogTreeTableRowProps.tsx +++ b/airbyte-webapp/src/components/connection/CatalogTree/next/useCatalogTreeTableRowProps.tsx @@ -1,6 +1,6 @@ /* eslint-disable css-modules/no-unused-class */ import classNames from "classnames"; -import { useField } from "formik"; +import { getIn, useFormikContext } from "formik"; import isEqual from "lodash/isEqual"; import { useMemo } from "react"; @@ -11,16 +11,15 @@ import { useBulkEditSelect } from "hooks/services/BulkEdit/BulkEditService"; import { useConnectionFormService } from "hooks/services/ConnectionForm/ConnectionFormService"; import styles from "./CatalogTreeTableRow.module.scss"; + type StatusToDisplay = "disabled" | "added" | "removed" | "changed" | "unchanged"; export const useCatalogTreeTableRowProps = (stream: SyncSchemaStream) => { - const { initialValues } = useConnectionFormService(); const [isSelected] = useBulkEditSelect(stream.id); + const { initialValues } = useConnectionFormService(); + const { errors } = useFormikContext(); - const [, { error }] = useField(`schema.streams[${stream.id}].config`); - - // in case error is an empty string - const hasError = error !== undefined; + const configErrors = getIn(errors, `syncCatalog.streams[${stream.id}].config`); const isStreamEnabled = stream.config?.selected; @@ -67,7 +66,6 @@ export const useCatalogTreeTableRowProps = (stream: SyncSchemaStream) => { [styles.removed]: statusToDisplay === "removed" && !isSelected, [styles.changed]: statusToDisplay === "changed" || isSelected, [styles.disabled]: statusToDisplay === "disabled", - [styles.error]: hasError, }); return { @@ -75,5 +73,6 @@ export const useCatalogTreeTableRowProps = (stream: SyncSchemaStream) => { isSelected, statusToDisplay, pillButtonVariant, + configErrors, }; }; diff --git a/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.tsx b/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.tsx index ccc1a69a5ceb..ff3baccd4d73 100644 --- a/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.tsx +++ b/airbyte-webapp/src/hooks/services/ConnectionForm/ConnectionFormService.tsx @@ -85,12 +85,23 @@ const useConnectionForm = ({ const formId = useUniqueFormId(); const getErrorMessage = useCallback( - (formValid: boolean, connectionDirty: boolean) => - submitError + (formValid: boolean, connectionDirty: boolean) => { + const isNewStreamsTableEnabled = process.env.REACT_APP_NEW_STREAMS_TABLE ?? false; + + if (isNewStreamsTableEnabled) { + // There is a case when some fields could be dropped in the database. We need to validate the form without property dirty + return submitError + ? generateMessageFromError(submitError) + : !formValid + ? formatMessage({ id: "connectionForm.validation.error" }) + : null; + } + return submitError ? generateMessageFromError(submitError) : connectionDirty && !formValid ? formatMessage({ id: "connectionForm.validation.error" }) - : null, + : null; + }, [formatMessage, submitError] ); diff --git a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.tsx b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.tsx index 54b3c4a803df..75de41ca6543 100644 --- a/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.tsx +++ b/airbyte-webapp/src/pages/ConnectionPage/pages/ConnectionItemPage/ConnectionReplicationTab.tsx @@ -203,18 +203,20 @@ export const ConnectionReplicationTab: React.FC = () => { dirty={dirty || schemaHasBeenRefreshed} /> {status.editControlsVisible && ( - { - resetForm(); - discardRefreshedSchema(); - }} - successMessage={saved && !dirty && } - errorMessage={getErrorMessage(isValid, dirty)} - enableControls={schemaHasBeenRefreshed || dirty} - /> +
+ { + resetForm(); + discardRefreshedSchema(); + }} + successMessage={saved && !dirty && } + errorMessage={getErrorMessage(isValid, dirty)} + enableControls={schemaHasBeenRefreshed || dirty} + /> +
)} diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx index e906813606be..9a0422fa678b 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/formConfig.tsx @@ -90,8 +90,109 @@ export const createConnectionValidationSchema = ({ mode, allowSubOneHourCronExpressions, allowAutoDetectSchema, -}: CreateConnectionValidationSchemaArgs) => - yup +}: CreateConnectionValidationSchemaArgs) => { + const isNewStreamsTableEnabled = process.env.REACT_APP_NEW_STREAMS_TABLE ?? false; + + if (isNewStreamsTableEnabled) { + return yup + .object({ + // The connection name during Editing is handled separately from the form + name: mode === "create" ? yup.string().required("form.empty.error") : yup.string().notRequired(), + geography: yup.mixed().oneOf(Object.values(Geography)), + scheduleType: yup + .string() + .oneOf([ConnectionScheduleType.manual, ConnectionScheduleType.basic, ConnectionScheduleType.cron]), + scheduleData: yup.mixed().when("scheduleType", (scheduleType) => { + if (scheduleType === ConnectionScheduleType.basic) { + return yup.object({ + basicSchedule: yup + .object({ + units: yup.number().required("form.empty.error"), + timeUnit: yup.string().required("form.empty.error"), + }) + .defined("form.empty.error"), + }); + } else if (scheduleType === ConnectionScheduleType.manual) { + return yup.mixed().notRequired(); + } + return yup.object({ + cron: yup + .object({ + cronExpression: yup + .string() + .trim() + .required("form.empty.error") + .test("validCron", "form.cronExpression.error", validateCronExpression) + .test( + "validCronFrequency", + "form.cronExpression.underOneHourNotAllowed", + (expression) => allowSubOneHourCronExpressions || validateCronFrequencyOneHourOrMore(expression) + ), + cronTimeZone: yup.string().required("form.empty.error"), + }) + .defined("form.empty.error"), + }); + }), + nonBreakingChangesPreference: allowAutoDetectSchema + ? yup.mixed().oneOf(Object.values(NonBreakingChangesPreference)).required("form.empty.error") + : yup.mixed().notRequired(), + namespaceDefinition: yup + .string() + .oneOf([ + NamespaceDefinitionType.source, + NamespaceDefinitionType.destination, + NamespaceDefinitionType.customformat, + ]) + .required("form.empty.error"), + namespaceFormat: yup.string().when("namespaceDefinition", { + is: NamespaceDefinitionType.customformat, + then: yup.string().trim().required("form.empty.error"), + }), + prefix: yup.string(), + syncCatalog: yup.object({ + streams: yup.array().of( + yup.object({ + id: yup + .string() + // This is required to get rid of id fields we are using to detect stream for edition + .when("$isRequest", (isRequest: boolean, schema: yup.StringSchema) => + isRequest ? schema.strip(true) : schema + ), + stream: yup.object(), + config: yup.object({ + selected: yup.boolean(), + syncMode: yup.string(), + destinationSyncMode: yup.string(), + primaryKey: yup + .array() + .of(yup.array().of(yup.string())) + .when(["syncMode", "destinationSyncMode", "selected"], { + is: (syncMode: SyncMode, destinationSyncMode: DestinationSyncMode, selected: boolean) => + syncMode === SyncMode.incremental && + destinationSyncMode === DestinationSyncMode.append_dedup && + selected, + then: yup.array().of(yup.array().of(yup.string())).min(1, "form.empty.error"), + }), + cursorField: yup + .array() + .of(yup.string()) + .when(["syncMode", "destinationSyncMode", "selected"], { + is: (syncMode: SyncMode, destinationSyncMode: DestinationSyncMode, selected: boolean) => + (destinationSyncMode === DestinationSyncMode.append || + destinationSyncMode === DestinationSyncMode.append_dedup) && + syncMode === SyncMode.incremental && + selected, + then: yup.array().of(yup.string()).min(1, "form.empty.error"), + }), + }), + }) + ), + }), + }) + .noUnknown(); + } + + return yup .object({ // The connection name during Editing is handled separately from the form name: mode === "create" ? yup.string().required("form.empty.error") : yup.string().notRequired(), @@ -206,6 +307,7 @@ export const createConnectionValidationSchema = ({ }), }) .noUnknown(); +}; /** * Returns {@link Operation}[]