diff --git a/src/components/collection/.ResourceConfig.tsx.swp b/src/components/collection/.ResourceConfig.tsx.swp new file mode 100644 index 000000000..77d3abf74 Binary files /dev/null and b/src/components/collection/.ResourceConfig.tsx.swp differ diff --git a/src/components/collection/ResourceConfig.tsx b/src/components/collection/ResourceConfig.tsx index 4a25381c1..80af3f516 100644 --- a/src/components/collection/ResourceConfig.tsx +++ b/src/components/collection/ResourceConfig.tsx @@ -6,7 +6,6 @@ import TimeTravel from 'components/editor/Bindings/TimeTravel'; import { useEditorStore_queryResponse_draftedBindingIndex } from 'components/editor/Store/hooks'; import ErrorBoundryWrapper from 'components/shared/ErrorBoundryWrapper'; import { useEntityType } from 'context/EntityContext'; -import { useEntityWorkflow_Editing } from 'context/Workflow'; import { FormattedMessage } from 'react-intl'; import { useBinding_currentBindingIndex, @@ -29,7 +28,6 @@ function ResourceConfig({ readOnly = false, }: Props) { const entityType = useEntityType(); - const isEdit = useEntityWorkflow_Editing(); const hydrated = useBinding_hydrated(); const stagedBindingIndex = useBinding_currentBindingIndex(); @@ -50,7 +48,11 @@ function ResourceConfig({ return ( <> - + @@ -68,16 +70,10 @@ function ResourceConfig({ )} - {isEdit && draftedBindingIndex > -1 && !collectionDisabled ? ( - - } - /> - ) : null} + {entityType === 'materialization' && !collectionDisabled ? ( ) : null} - - {/* TODO (onIncompatibleSchemaChange) - uncomment to enable feature - {entityType === 'materialization' ? ( - - - - ) : null} - */} ); } diff --git a/src/components/editor/Bindings/Backfill/BackfillButton.tsx b/src/components/editor/Bindings/Backfill/BackfillButton.tsx new file mode 100644 index 000000000..38af8d05f --- /dev/null +++ b/src/components/editor/Bindings/Backfill/BackfillButton.tsx @@ -0,0 +1,227 @@ +import { Box, Stack, Typography } from '@mui/material'; +import BooleanToggleButton from 'components/shared/buttons/BooleanToggleButton'; +import { BooleanString } from 'components/shared/buttons/types'; +import { useEntityWorkflow } from 'context/Workflow'; +import { useCallback, useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import { + useBinding_allBindingsDisabled, + useBinding_backfillAllBindings, + useBinding_backfilledBindings, + useBinding_backfillSupported, + useBinding_currentBindingUUID, + useBinding_currentCollection, + useBinding_enabledCollections_count, + useBinding_setBackfilledBindings, +} from 'stores/Binding/hooks'; +import { useBindingStore } from 'stores/Binding/Store'; +import { + useFormStateStore_isActive, + useFormStateStore_setFormState, +} from 'stores/FormState/hooks'; +import { FormStatus } from 'stores/FormState/types'; +import { BindingMetadata } from 'types'; +import { useEditorStore_queryResponse_draftSpecs } from '../../Store/hooks'; +import BackfillCount from './BackfillCount'; +import BackfillDataFlowOption from './BackfillDataFlowOption'; +import BackfillNotSupportedAlert from './BackfillNotSupportedAlert'; +import EvolvedAlert from './EvolvedAlert'; +import EvolvedCount from './EvolvedCount'; +import { BackfillButtonProps } from './types'; +import useUpdateBackfillCounter from './useUpdateBackfillCounter'; + +function BackfillButton({ + description, + bindingIndex = -1, +}: BackfillButtonProps) { + const intl = useIntl(); + const { updateBackfillCounter } = useUpdateBackfillCounter(); + + const workflow = useEntityWorkflow(); + + const evolvedCollections = useBindingStore( + (state) => state.evolvedCollections + ); + + // Binding Store + const currentCollection = useBinding_currentCollection(); + const currentBindingUUID = useBinding_currentBindingUUID(); + const collectionsCount = useBinding_enabledCollections_count(); + const allBindingsDisabled = useBinding_allBindingsDisabled(); + + const backfillAllBindings = useBinding_backfillAllBindings(); + const backfilledBindings = useBinding_backfilledBindings(); + const setBackfilledBindings = useBinding_setBackfilledBindings(); + const backfillSupported = useBinding_backfillSupported(); + + // Draft Editor Store + const draftSpecs = useEditorStore_queryResponse_draftSpecs(); + + // Form State Store + const formActive = useFormStateStore_isActive(); + const setFormState = useFormStateStore_setFormState(); + + const disabled = + formActive || + collectionsCount < 1 || + allBindingsDisabled || + !backfillSupported; + + const reversioned = useMemo(() => { + if (bindingIndex === -1) { + return false; + } + + return evolvedCollections.some( + (evolvedCollection) => + evolvedCollection.new_name === currentCollection + ); + }, [bindingIndex, currentCollection, evolvedCollections]); + + const selected = useMemo(() => { + if (bindingIndex === -1) { + return backfillAllBindings; + } + + return currentBindingUUID + ? backfilledBindings.includes(currentBindingUUID) + : false; + }, [ + backfillAllBindings, + backfilledBindings, + bindingIndex, + currentBindingUUID, + ]); + + const draftSpec = useMemo( + () => + draftSpecs.length > 0 && draftSpecs[0].spec ? draftSpecs[0] : null, + [draftSpecs] + ); + + const evaluateServerDifferences = useCallback( + (increment: BooleanString) => { + if (bindingIndex === -1) { + return ( + (backfillAllBindings && increment === 'false') || + (!backfillAllBindings && increment === 'true') + ); + } + + if (currentBindingUUID) { + return increment === 'true' + ? !backfilledBindings.includes(currentBindingUUID) + : backfilledBindings.includes(currentBindingUUID); + } + + return false; + }, + [ + backfillAllBindings, + backfilledBindings, + bindingIndex, + currentBindingUUID, + ] + ); + + const handleClick = useCallback( + (increment: BooleanString) => { + const serverUpdateRequired = evaluateServerDifferences(increment); + + if (draftSpec && serverUpdateRequired) { + setFormState({ status: FormStatus.UPDATING, error: null }); + + const singleBindingUpdate = + bindingIndex > -1 && + currentCollection && + currentBindingUUID; + + const bindingMetadata: BindingMetadata[] = singleBindingUpdate + ? [{ collection: currentCollection, bindingIndex }] + : []; + + updateBackfillCounter( + draftSpec, + increment, + bindingMetadata + ).then( + () => { + const targetBindingUUID = singleBindingUpdate + ? currentBindingUUID + : undefined; + + setBackfilledBindings(increment, targetBindingUUID); + setFormState({ status: FormStatus.UPDATED }); + }, + (error) => { + setFormState({ + status: FormStatus.FAILED, + error: { + title: 'workflows.collectionSelector.manualBackfill.error.title', + error, + }, + }); + } + ); + } + }, + [ + bindingIndex, + currentBindingUUID, + currentCollection, + draftSpec, + evaluateServerDifferences, + setBackfilledBindings, + setFormState, + updateBackfillCounter, + ] + ); + + // Do not want to overload the user with "this is not supported" so only showing message on the "backfill all" toggle. + if (!backfillSupported && bindingIndex !== -1) { + return null; + } + + return ( + + + {description} + + {!backfillSupported ? : null} + + + + { + event.preventDefault(); + event.stopPropagation(); + + handleClick(checked === 'true' ? 'false' : 'true'); + }} + > + {intl.formatMessage({ + id: 'workflows.collectionSelector.manualBackfill.cta.backfill', + })} + + + {backfillSupported && bindingIndex === -1 ? ( + <> + + + + ) : null} + + {reversioned && bindingIndex !== -1 ? : null} + + + {bindingIndex === -1 && workflow === 'capture_edit' ? ( + + ) : null} + + ); +} + +export default BackfillButton; diff --git a/src/components/editor/Bindings/Backfill/SectionWrapper.tsx b/src/components/editor/Bindings/Backfill/SectionWrapper.tsx new file mode 100644 index 000000000..55a1e295f --- /dev/null +++ b/src/components/editor/Bindings/Backfill/SectionWrapper.tsx @@ -0,0 +1,23 @@ +import { Box, Stack, Typography } from '@mui/material'; +import { useIntl } from 'react-intl'; +import { BaseComponentProps } from 'types'; + +export default function SectionWrapper({ children }: BaseComponentProps) { + const intl = useIntl(); + + return ( + + + {intl.formatMessage({ + id: 'workflows.collectionSelector.manualBackfill.header', + })} + + + {children} + + ); +} diff --git a/src/components/editor/Bindings/Backfill/index.tsx b/src/components/editor/Bindings/Backfill/index.tsx index 9df6246dd..3369a095c 100644 --- a/src/components/editor/Bindings/Backfill/index.tsx +++ b/src/components/editor/Bindings/Backfill/index.tsx @@ -1,232 +1,39 @@ -import { Box, Stack, Typography } from '@mui/material'; -import BooleanToggleButton from 'components/shared/buttons/BooleanToggleButton'; -import { BooleanString } from 'components/shared/buttons/types'; -import { useEntityWorkflow } from 'context/Workflow'; -import { useCallback, useMemo } from 'react'; -import { useIntl } from 'react-intl'; -import { - useBinding_allBindingsDisabled, - useBinding_backfillAllBindings, - useBinding_backfilledBindings, - useBinding_currentCollection, - useBinding_currentBindingUUID, - useBinding_setBackfilledBindings, - useBinding_backfillSupported, - useBinding_enabledCollections_count, -} from 'stores/Binding/hooks'; -import { useBindingStore } from 'stores/Binding/Store'; -import { - useFormStateStore_isActive, - useFormStateStore_setFormState, -} from 'stores/FormState/hooks'; -import { FormStatus } from 'stores/FormState/types'; -import { BindingMetadata } from 'types'; -import { useEditorStore_queryResponse_draftSpecs } from '../../Store/hooks'; -import BackfillCount from './BackfillCount'; -import BackfillDataFlowOption from './BackfillDataFlowOption'; -import BackfillNotSupportedAlert from './BackfillNotSupportedAlert'; -import EvolvedAlert from './EvolvedAlert'; -import EvolvedCount from './EvolvedCount'; +import ErrorBoundryWrapper from 'components/shared/ErrorBoundryWrapper'; +import { useEntityType } from 'context/EntityContext'; +import { useEntityWorkflow_Editing } from 'context/Workflow'; +import { FormattedMessage } from 'react-intl'; +import OnIncompatibleSchemaChange from '../OnIncompatibleSchemaChange'; +import BackfillButton from './BackfillButton'; +import SectionWrapper from './SectionWrapper'; import { BackfillProps } from './types'; -import useUpdateBackfillCounter from './useUpdateBackfillCounter'; -function Backfill({ description, bindingIndex = -1 }: BackfillProps) { - const intl = useIntl(); - const { updateBackfillCounter } = useUpdateBackfillCounter(); - - const workflow = useEntityWorkflow(); - - const evolvedCollections = useBindingStore( - (state) => state.evolvedCollections - ); - - // Binding Store - const currentCollection = useBinding_currentCollection(); - const currentBindingUUID = useBinding_currentBindingUUID(); - const collectionsCount = useBinding_enabledCollections_count(); - const allBindingsDisabled = useBinding_allBindingsDisabled(); - - const backfillAllBindings = useBinding_backfillAllBindings(); - const backfilledBindings = useBinding_backfilledBindings(); - const setBackfilledBindings = useBinding_setBackfilledBindings(); - const backfillSupported = useBinding_backfillSupported(); - - // Draft Editor Store - const draftSpecs = useEditorStore_queryResponse_draftSpecs(); - - // Form State Store - const formActive = useFormStateStore_isActive(); - const setFormState = useFormStateStore_setFormState(); - - const disabled = - formActive || - collectionsCount < 1 || - allBindingsDisabled || - !backfillSupported; - - const reversioned = useMemo(() => { - if (bindingIndex === -1) { - return false; - } - - return evolvedCollections.some( - (evolvedCollection) => - evolvedCollection.new_name === currentCollection - ); - }, [bindingIndex, currentCollection, evolvedCollections]); - - const selected = useMemo(() => { - if (bindingIndex === -1) { - return backfillAllBindings; - } - - return currentBindingUUID - ? backfilledBindings.includes(currentBindingUUID) - : false; - }, [ - backfillAllBindings, - backfilledBindings, - bindingIndex, - currentBindingUUID, - ]); - - const draftSpec = useMemo( - () => - draftSpecs.length > 0 && draftSpecs[0].spec ? draftSpecs[0] : null, - [draftSpecs] - ); - - const evaluateServerDifferences = useCallback( - (increment: BooleanString) => { - if (bindingIndex === -1) { - return ( - (backfillAllBindings && increment === 'false') || - (!backfillAllBindings && increment === 'true') - ); - } - - if (currentBindingUUID) { - return increment === 'true' - ? !backfilledBindings.includes(currentBindingUUID) - : backfilledBindings.includes(currentBindingUUID); - } - - return false; - }, - [ - backfillAllBindings, - backfilledBindings, - bindingIndex, - currentBindingUUID, - ] - ); - - const handleClick = useCallback( - (increment: BooleanString) => { - const serverUpdateRequired = evaluateServerDifferences(increment); - - if (draftSpec && serverUpdateRequired) { - setFormState({ status: FormStatus.UPDATING, error: null }); - - const singleBindingUpdate = - bindingIndex > -1 && - currentCollection && - currentBindingUUID; - - const bindingMetadata: BindingMetadata[] = singleBindingUpdate - ? [{ collection: currentCollection, bindingIndex }] - : []; - - updateBackfillCounter( - draftSpec, - increment, - bindingMetadata - ).then( - () => { - const targetBindingUUID = singleBindingUpdate - ? currentBindingUUID - : undefined; - - setBackfilledBindings(increment, targetBindingUUID); - setFormState({ status: FormStatus.UPDATED }); - }, - (error) => { - setFormState({ - status: FormStatus.FAILED, - error: { - title: 'workflows.collectionSelector.manualBackfill.error.title', - error, - }, - }); +export default function Backfill({ + bindingIndex, + collectionEnabled, +}: BackfillProps) { + const entityType = useEntityType(); + const isEdit = useEntityWorkflow_Editing(); + + const showBackfillButton = isEdit && bindingIndex > -1 && collectionEnabled; + + return showBackfillButton || entityType === 'materialization' ? ( + + {showBackfillButton ? ( + } - ); - } - }, - [ - bindingIndex, - currentBindingUUID, - currentCollection, - draftSpec, - evaluateServerDifferences, - setBackfilledBindings, - setFormState, - updateBackfillCounter, - ] - ); - - // Do not want to overload the user with "this is not supported" so only showing message on the "backfill all" toggle. - if (!backfillSupported && bindingIndex !== -1) { - return null; - } - - return ( - - - - {intl.formatMessage({ - id: 'workflows.collectionSelector.manualBackfill.header', - })} - - - {description} - - {!backfillSupported ? : null} - - - - { - event.preventDefault(); - event.stopPropagation(); - - handleClick(checked === 'true' ? 'false' : 'true'); - }} - > - {intl.formatMessage({ - id: 'workflows.collectionSelector.manualBackfill.cta.backfill', - })} - - - {backfillSupported && bindingIndex === -1 ? ( - <> - - - - ) : null} - - {reversioned && bindingIndex !== -1 ? : null} - + /> + ) : null} - {bindingIndex === -1 && workflow === 'capture_edit' ? ( - + {entityType === 'materialization' ? ( + + + ) : null} - - ); + + ) : null; } - -export default Backfill; diff --git a/src/components/editor/Bindings/Backfill/types.ts b/src/components/editor/Bindings/Backfill/types.ts index f72af53c9..d864d49ce 100644 --- a/src/components/editor/Bindings/Backfill/types.ts +++ b/src/components/editor/Bindings/Backfill/types.ts @@ -1,14 +1,19 @@ import { ReactNode } from 'react'; -export interface BackfillProps { +export interface BackfillButtonProps { description: ReactNode; bindingIndex?: number; } -export interface BackfillDataflowOptionProps { +export interface BackfillCountProps { disabled?: boolean; } -export interface BackfillCountProps { +export interface BackfillDataflowOptionProps { disabled?: boolean; } + +export interface BackfillProps { + bindingIndex: number; + collectionEnabled: boolean; +} diff --git a/src/components/editor/Bindings/Editor.tsx b/src/components/editor/Bindings/Editor.tsx index 03113e610..87d50c54b 100644 --- a/src/components/editor/Bindings/Editor.tsx +++ b/src/components/editor/Bindings/Editor.tsx @@ -129,7 +129,10 @@ function BindingsEditor({ itemType, readOnly = false }: Props) { justifyContent: 'space-between', }} > - + diff --git a/src/components/editor/Bindings/FieldSelection/index.tsx b/src/components/editor/Bindings/FieldSelection/index.tsx index 80e246db9..e87da69c6 100644 --- a/src/components/editor/Bindings/FieldSelection/index.tsx +++ b/src/components/editor/Bindings/FieldSelection/index.tsx @@ -275,8 +275,11 @@ function FieldSelectionViewer({ - - + + diff --git a/src/components/editor/Bindings/OnIncompatibleSchemaChange/Form.tsx b/src/components/editor/Bindings/OnIncompatibleSchemaChange/Form.tsx index c061d22ea..490eced86 100644 --- a/src/components/editor/Bindings/OnIncompatibleSchemaChange/Form.tsx +++ b/src/components/editor/Bindings/OnIncompatibleSchemaChange/Form.tsx @@ -1,47 +1,34 @@ +import { useEditorStore_queryResponse_draftSpecs_schemaProp } from 'components/editor/Store/hooks'; +import IncompatibleSchemaChangeForm from 'components/incompatibleSchemaChange/Form'; import { - Autocomplete, - Button, - Stack, - TextField, - Typography, -} from '@mui/material'; + AutoCompleteOption, + OnIncompatibleSchemaChangeProps, +} from 'components/incompatibleSchemaChange/types'; +import useBindingIncompatibleSchemaSetting from 'hooks/OnIncompatibleSchemaChange/useBindingIncompatibleSchemaSetting'; import { useSnackbar } from 'notistack'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; -import { - useFormStateStore_isActive, - useFormStateStore_setFormState, -} from 'stores/FormState/hooks'; -import { snackbarSettings } from 'utils/notification-utils'; import { useBinding_currentBindingUUID, useBinding_currentCollection, } from 'stores/Binding/hooks'; -import { useEditorStore_queryResponse_draftSpecs_schemaProp } from 'components/editor/Store/hooks'; +import { useBindingStore } from 'stores/Binding/Store'; +import { useFormStateStore_setFormState } from 'stores/FormState/hooks'; import { FormStatus } from 'stores/FormState/types'; -import AlertBox from 'components/shared/AlertBox'; -import useSupportedOptions from 'hooks/OnIncompatibleSchemaChange/useSupportedOptions'; -import useUpdateOnIncompatibleSchemaChange from 'hooks/OnIncompatibleSchemaChange/useUpdateOnIncompatibleSchemaChange'; import { BindingMetadata } from 'types'; -import { autoCompleteDefaultProps } from './shared'; -import { AutoCompleteOption } from './types'; -import SelectorOption from './SelectorOption'; - -interface Props { - bindingIndex: number; -} +import { snackbarSettings } from 'utils/notification-utils'; -function OnIncompatibleSchemaChangeForm({ bindingIndex = -1 }: Props) { +function Form({ bindingIndex = -1 }: OnIncompatibleSchemaChangeProps) { const intl = useIntl(); const { enqueueSnackbar } = useSnackbar(); - const [inputValue, setInputValue] = useState(''); - const [invalidSetting, setInvalidSetting] = useState(false); - const currentCollection = useBinding_currentCollection(); const currentBindingUUID = useBinding_currentBindingUUID(); - const formActive = useFormStateStore_isActive(); + const setIncompatibleSchemaChange = useBindingStore( + (state) => state.setBindingOnIncompatibleSchemaChange + ); + const setFormState = useFormStateStore_setFormState(); const currentSetting = useEditorStore_queryResponse_draftSpecs_schemaProp( @@ -49,16 +36,8 @@ function OnIncompatibleSchemaChangeForm({ bindingIndex = -1 }: Props) { 'onIncompatibleSchemaChange' ); - const options = useSupportedOptions(); const { updateOnIncompatibleSchemaChange } = - useUpdateOnIncompatibleSchemaChange(); - - const currentValue = useMemo(() => { - if (!currentSetting) { - return null; - } - return options.find((option) => option.val === currentSetting) ?? null; - }, [currentSetting, options]); + useBindingIncompatibleSchemaSetting(); const bindingMetadata = useMemo(() => { if (bindingIndex > -1 && currentCollection && currentBindingUUID) { @@ -74,6 +53,13 @@ function OnIncompatibleSchemaChangeForm({ bindingIndex = -1 }: Props) { updateOnIncompatibleSchemaChange(value?.val, bindingMetadata) .then(() => { + if (currentBindingUUID) { + setIncompatibleSchemaChange( + value?.val, + currentBindingUUID + ); + } + setFormState({ status: FormStatus.UPDATED }); }) .catch((err) => { @@ -87,129 +73,31 @@ function OnIncompatibleSchemaChangeForm({ bindingIndex = -1 }: Props) { { ...snackbarSettings, variant: 'error' } ); + setIncompatibleSchemaChange( + currentSetting, + currentBindingUUID + ); setFormState({ status: FormStatus.FAILED }); }); }, [ bindingMetadata, + currentBindingUUID, + currentSetting, enqueueSnackbar, intl, setFormState, + setIncompatibleSchemaChange, updateOnIncompatibleSchemaChange, ] ); - useEffect(() => { - // No setting at all so we're good - if (!currentSetting) { - setInputValue(''); - setInvalidSetting(false); - return; - } - - // We have a setting but could not find a matching option - // Set a flag to show an error and empty out the input - if (!currentValue) { - setInputValue(''); - setInvalidSetting(true); - return; - } - - setInputValue(currentValue.label); - setInvalidSetting(false); - }, [currentSetting, currentValue]); - return ( - - {invalidSetting ? ( - - - {intl.formatMessage( - { - id: 'incompatibleSchemaChange.error.message', - }, - { - currentSetting, - } - )} - - - - ) : null} - option.label} - isOptionEqualToValue={(option, optionValue) => { - // We force an undefined some times when we need to clear out the option - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!optionValue) { - return false; - } - - return ( - option.val === - (typeof optionValue === 'string' - ? optionValue - : optionValue.val) - ); - }} - onChange={(_state, newVal) => updateServer(newVal)} - onInputChange={(_event, newInputValue) => { - setInputValue(newInputValue); - }} - renderInput={(params) => { - return ( - - ); - }} - renderOption={(renderOptionProps, option) => { - return ( -
  • - -
  • - ); - }} - /> -
    + ); } -export default OnIncompatibleSchemaChangeForm; +export default Form; diff --git a/src/components/editor/Bindings/OnIncompatibleSchemaChange/index.tsx b/src/components/editor/Bindings/OnIncompatibleSchemaChange/index.tsx index 4988d3646..8d3e7b967 100644 --- a/src/components/editor/Bindings/OnIncompatibleSchemaChange/index.tsx +++ b/src/components/editor/Bindings/OnIncompatibleSchemaChange/index.tsx @@ -1,38 +1,30 @@ import { Box, Stack, Typography } from '@mui/material'; +import { OnIncompatibleSchemaChangeProps } from 'components/incompatibleSchemaChange/types'; import { useIntl } from 'react-intl'; -import OnIncompatibleSchemaChangeForm from './Form'; -import { OnIncompatibleSchemaChangeFormProps } from './types'; +import Form from './Form'; function OnIncompatibleSchemaChange({ bindingIndex = -1, -}: OnIncompatibleSchemaChangeFormProps) { +}: OnIncompatibleSchemaChangeProps) { const intl = useIntl(); - if (bindingIndex < 0) { - return null; - } - return ( - - - - - - {intl.formatMessage({ - id: 'incompatibleSchemaChange.header', - })} - - - - - {intl.formatMessage({ - id: 'incompatibleSchemaChange.message', - })} - - + + + + {intl.formatMessage({ + id: 'incompatibleSchemaChange.header', + })} + - + + {intl.formatMessage({ + id: 'incompatibleSchemaChange.message', + })} + + +
    ); } diff --git a/src/components/editor/Bindings/TimeTravel/index.tsx b/src/components/editor/Bindings/TimeTravel/index.tsx index 8156da15d..ca79331a7 100644 --- a/src/components/editor/Bindings/TimeTravel/index.tsx +++ b/src/components/editor/Bindings/TimeTravel/index.tsx @@ -13,7 +13,7 @@ function TimeTravel({ bindingUUID, collectionName }: Props) { - + diff --git a/src/components/editor/Bindings/index.tsx b/src/components/editor/Bindings/index.tsx index e7824251e..0aed5743c 100644 --- a/src/components/editor/Bindings/index.tsx +++ b/src/components/editor/Bindings/index.tsx @@ -6,6 +6,7 @@ import BindingSelector from 'components/editor/Bindings/Selector'; import ListAndDetails from 'components/editor/ListAndDetails'; import { createEditorStore } from 'components/editor/Store/create'; import SourceCapture from 'components/materialization/SourceCapture'; +import Backfill from 'components/shared/Entity/Backfill'; import { useEntityType } from 'context/EntityContext'; import { LocalZustandProvider } from 'context/LocalZustand'; import { alternativeReflexContainerBackground } from 'context/Theme'; @@ -22,7 +23,6 @@ import { import { useDetailsFormStore } from 'stores/DetailsForm/Store'; import { useFormStateStore_messagePrefix } from 'stores/FormState/hooks'; import { EditorStoreNames } from 'stores/names'; -import Backfill from './Backfill'; interface Props { draftSpecs: DraftSpecQuery[]; @@ -101,21 +101,14 @@ function BindingsMultiEditor({ /> - + {entityType === 'capture' ? : null} {entityType === 'capture' ? : null} {entityType === 'materialization' ? : null} - {workflow === 'capture_edit' || - workflow === 'materialization_edit' ? ( - - ) : null} + - + , any>( storeName(entityType, localScope), useShallow((state) => { - return state.queryResponse.draftSpecs[0]?.spec.bindings[ - bindingIndex - ][schemaProp]; + return bindingIndex > -1 + ? state.queryResponse.draftSpecs[0]?.spec.bindings[ + bindingIndex + ][schemaProp] + : undefined; }) ); }; diff --git a/src/components/incompatibleSchemaChange/Form.tsx b/src/components/incompatibleSchemaChange/Form.tsx new file mode 100644 index 000000000..e5d540d99 --- /dev/null +++ b/src/components/incompatibleSchemaChange/Form.tsx @@ -0,0 +1,169 @@ +import { + Autocomplete, + Button, + Stack, + TextField, + Typography, +} from '@mui/material'; +import SelectorOption from 'components/incompatibleSchemaChange/SelectorOption'; +import AlertBox from 'components/shared/AlertBox'; +import useSupportedOptions from 'hooks/OnIncompatibleSchemaChange/useSupportedOptions'; +import { useEffect, useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { stringifyJSON } from 'services/stringify'; +import { useFormStateStore_isActive } from 'stores/FormState/hooks'; +import { autoCompleteDefaultProps } from './shared'; +import { BaseFormProps } from './types'; + +export default function IncompatibleSchemaChangeForm({ + currentSetting, + updateDraftedSetting, +}: BaseFormProps) { + const intl = useIntl(); + + const [inputValue, setInputValue] = useState(''); + const [invalidSetting, setInvalidSetting] = useState(false); + + const formActive = useFormStateStore_isActive(); + + const options = useSupportedOptions(); + + const selection = useMemo(() => { + if (!currentSetting || typeof currentSetting !== 'string') { + return null; + } + + return options.find((option) => option.val === currentSetting) ?? null; + }, [currentSetting, options]); + + useEffect(() => { + // No setting at all so we're good + if (!currentSetting) { + setInputValue(''); + setInvalidSetting(false); + return; + } + + // We have a setting but could not find a matching option + // Set a flag to show an error and empty out the input + if (!selection) { + setInputValue(''); + setInvalidSetting(true); + return; + } + + setInputValue(selection.label); + setInvalidSetting(false); + }, [currentSetting, selection]); + + return ( + + {invalidSetting ? ( + + + {intl.formatMessage( + { + id: 'incompatibleSchemaChange.error.message', + }, + { + currentSetting: + typeof currentSetting === 'string' + ? currentSetting + : stringifyJSON(currentSetting), + } + )} + + + + + ) : null} + + option.label} + inputValue={inputValue} + isOptionEqualToValue={(option, optionValue) => { + // We force an undefined some times when we need to clear out the option + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!optionValue) { + return false; + } + + return ( + option.val === + (typeof optionValue === 'string' + ? optionValue + : optionValue.val) + ); + }} + onChange={(_state, newVal) => updateDraftedSetting(newVal)} + onInputChange={(event, newInputValue) => { + // Set the input value component state only when an option is clicked + // to avoid clashing with the effect which also updates this state. + if (Boolean(event)) { + setInputValue(newInputValue); + } + }} + options={options} + renderInput={(params) => { + return ( + + ); + }} + renderOption={(renderOptionProps, option) => { + return ( +
  • + +
  • + ); + }} + sx={{ + minWidth: 'fit-content', + maxWidth: '50%', + }} + value={selection} + /> +
    + ); +} diff --git a/src/components/editor/Bindings/OnIncompatibleSchemaChange/SelectorOption.tsx b/src/components/incompatibleSchemaChange/SelectorOption.tsx similarity index 80% rename from src/components/editor/Bindings/OnIncompatibleSchemaChange/SelectorOption.tsx rename to src/components/incompatibleSchemaChange/SelectorOption.tsx index 1145fd47a..a6637884a 100644 --- a/src/components/editor/Bindings/OnIncompatibleSchemaChange/SelectorOption.tsx +++ b/src/components/incompatibleSchemaChange/SelectorOption.tsx @@ -6,10 +6,12 @@ function SelectorOption({ option }: SelectorOptionProps) { return ( - {label} + + {label} + + state.setSpecOnIncompatibleSchemaChange + ); + + const setFormState = useFormStateStore_setFormState(); + + const { currentSetting, updateOnIncompatibleSchemaChange } = + useSpecificationIncompatibleSchemaSetting(); + + const updateServer = useCallback( + async (option?: AutoCompleteOption | null) => { + setFormState({ status: FormStatus.UPDATING, error: null }); + + updateOnIncompatibleSchemaChange(option?.val) + .then(() => { + setIncompatibleSchemaChange(option?.val); + + setFormState({ status: FormStatus.UPDATED }); + }) + .catch(() => { + enqueueSnackbar( + intl.formatMessage({ + id: 'incompatibleSchemaChange.update.error', + }), + { ...snackbarSettings, variant: 'error' } + ); + + setIncompatibleSchemaChange(currentSetting); + setFormState({ status: FormStatus.FAILED }); + }); + }, + [ + currentSetting, + enqueueSnackbar, + intl, + setFormState, + setIncompatibleSchemaChange, + updateOnIncompatibleSchemaChange, + ] + ); + + return ( + + ); +} diff --git a/src/components/materialization/OnIncompatibleSchemaChange/index.tsx b/src/components/materialization/OnIncompatibleSchemaChange/index.tsx new file mode 100644 index 000000000..08c52ffc7 --- /dev/null +++ b/src/components/materialization/OnIncompatibleSchemaChange/index.tsx @@ -0,0 +1,29 @@ +import { Box, Stack, Typography } from '@mui/material'; +import { useIntl } from 'react-intl'; +import Form from './Form'; + +function OnIncompatibleSchemaChange() { + const intl = useIntl(); + + return ( + + + + {intl.formatMessage({ + id: 'incompatibleSchemaChange.header', + })} + + + + {intl.formatMessage({ + id: 'incompatibleSchemaChange.message.specificationSetting', + })} + + + + + + ); +} + +export default OnIncompatibleSchemaChange; diff --git a/src/components/materialization/useGenerateCatalog.ts b/src/components/materialization/useGenerateCatalog.ts index c5eb61969..2994646c9 100644 --- a/src/components/materialization/useGenerateCatalog.ts +++ b/src/components/materialization/useGenerateCatalog.ts @@ -28,6 +28,7 @@ import { useBinding_resourceConfigs, useBinding_serverUpdateRequired, } from 'stores/Binding/hooks'; +import { useBindingStore } from 'stores/Binding/Store'; import { useDetailsFormStore } from 'stores/DetailsForm/Store'; import { useEndpointConfigStore_encryptedEndpointConfig_data, @@ -121,6 +122,10 @@ function useGenerateCatalog() { const fullSourceConfigs = useBinding_fullSourceConfigs(); const fullSourceErrorsExist = useBinding_fullSourceErrorsExist(); + const specIncompatibleSchemaChange = useBindingStore( + (state) => state.onIncompatibleSchemaChange + ); + // Source Capture Store const sourceCapture = useSourceCaptureStore_sourceCaptureDefinition(); @@ -233,6 +238,8 @@ function useGenerateCatalog() { { fullSource: fullSourceConfigs, sourceCapture, + specOnIncompatibleSchemaChange: + specIncompatibleSchemaChange, } ); @@ -356,6 +363,7 @@ function useGenerateCatalog() { setPersistedDraftId, setPreviousEndpointConfig, sourceCapture, + specIncompatibleSchemaChange, updateFormStatus, ] ); diff --git a/src/components/shared/Entity/Backfill.tsx b/src/components/shared/Entity/Backfill.tsx new file mode 100644 index 000000000..74ded26bc --- /dev/null +++ b/src/components/shared/Entity/Backfill.tsx @@ -0,0 +1,32 @@ +import BackfillButton from 'components/editor/Bindings/Backfill/BackfillButton'; +import SectionWrapper from 'components/editor/Bindings/Backfill/SectionWrapper'; +import { useEntityType } from 'context/EntityContext'; +import { useEntityWorkflow_Editing } from 'context/Workflow'; +import { useIntl } from 'react-intl'; +import OnIncompatibleSchemaChange from '../../materialization/OnIncompatibleSchemaChange'; +import ErrorBoundryWrapper from '../ErrorBoundryWrapper'; + +export default function Backfill() { + const intl = useIntl(); + + const entityType = useEntityType(); + const isEdit = useEntityWorkflow_Editing(); + + return isEdit || entityType === 'materialization' ? ( + + {isEdit ? ( + + ) : null} + + {entityType === 'materialization' ? ( + + + + ) : null} + + ) : null; +} diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index a8fc96bd5..04c56eb88 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -927,6 +927,7 @@ const themeSettings = createTheme({ fontSize: 14, }, formSectionHeader: { + fontSize: 18, fontWeight: 500, }, }, diff --git a/src/hooks/OnIncompatibleSchemaChange/useBindingIncompatibleSchemaSetting.ts b/src/hooks/OnIncompatibleSchemaChange/useBindingIncompatibleSchemaSetting.ts new file mode 100644 index 000000000..f64b59298 --- /dev/null +++ b/src/hooks/OnIncompatibleSchemaChange/useBindingIncompatibleSchemaSetting.ts @@ -0,0 +1,98 @@ +import { modifyDraftSpec } from 'api/draftSpecs'; +import { + useEditorStore_persistedDraftId, + useEditorStore_queryResponse_draftSpecs, + useEditorStore_queryResponse_mutate, +} from 'components/editor/Store/hooks'; +import { AutoCompleteOption } from 'components/incompatibleSchemaChange/types'; +import { useEntityType } from 'context/EntityContext'; +import { cloneDeep } from 'lodash'; +import { useCallback } from 'react'; +import { useIntl } from 'react-intl'; +import { logRocketEvent } from 'services/shared'; +import { BASE_ERROR } from 'services/supabase'; +import { CustomEvents } from 'services/types'; +import { BindingMetadata, Schema } from 'types'; +import { addOrRemoveOnIncompatibleSchemaChange } from 'utils/entity-utils'; +import { hasLength } from 'utils/misc-utils'; + +function useBindingIncompatibleSchemaSetting() { + const intl = useIntl(); + const entityType = useEntityType(); + + // Draft Editor Store + const draftId = useEditorStore_persistedDraftId(); + const draftSpecs = useEditorStore_queryResponse_draftSpecs(); + const mutateDraftSpecs = useEditorStore_queryResponse_mutate(); + + const updateOnIncompatibleSchemaChange = useCallback( + async ( + value: AutoCompleteOption['val'] | undefined, + bindingMetadata: BindingMetadata[] + ) => { + if (!mutateDraftSpecs || !draftId || draftSpecs.length === 0) { + logRocketEvent( + `${CustomEvents.INCOMPATIBLE_SCHEMA_CHANGE}:Missing Draft Resources`, + { + draftIdMissing: !draftId, + draftSpecMissing: draftSpecs.length === 0, + mutateMissing: !mutateDraftSpecs, + } + ); + + return Promise.resolve(); + } + + if (!hasLength(bindingMetadata)) { + return Promise.resolve(); + } + + const invalidBindingIndex = bindingMetadata.findIndex( + ({ bindingIndex }) => bindingIndex === -1 + ); + + if (invalidBindingIndex > -1) { + return Promise.reject({ + ...BASE_ERROR, + message: intl.formatMessage( + { + id: 'incompatibleSchemaChange.error.bindingSettingUpdateFailed', + }, + { + collection: + bindingMetadata[invalidBindingIndex].collection, + } + ), + }); + } + + const spec: Schema = cloneDeep(draftSpecs[0].spec); + + bindingMetadata.forEach(({ bindingIndex }) => { + if (bindingIndex > -1) { + addOrRemoveOnIncompatibleSchemaChange( + spec.bindings[bindingIndex], + value + ); + } + }); + + const updateResponse = await modifyDraftSpec(spec, { + draft_id: draftId, + catalog_name: draftSpecs[0].catalog_name, + spec_type: entityType, + }); + + if (updateResponse.error) { + return Promise.reject(updateResponse.error); + } + + return mutateDraftSpecs(); + }, + [draftId, draftSpecs, entityType, intl, mutateDraftSpecs] + ); + + return { updateOnIncompatibleSchemaChange }; +} + +export default useBindingIncompatibleSchemaSetting; diff --git a/src/hooks/OnIncompatibleSchemaChange/useSpecificationIncompatibleSchemaSetting.ts b/src/hooks/OnIncompatibleSchemaChange/useSpecificationIncompatibleSchemaSetting.ts new file mode 100644 index 000000000..49b8e54ea --- /dev/null +++ b/src/hooks/OnIncompatibleSchemaChange/useSpecificationIncompatibleSchemaSetting.ts @@ -0,0 +1,67 @@ +import { modifyDraftSpec } from 'api/draftSpecs'; +import { + useEditorStore_persistedDraftId, + useEditorStore_queryResponse_draftSpecs, + useEditorStore_queryResponse_mutate, +} from 'components/editor/Store/hooks'; +import { AutoCompleteOption } from 'components/incompatibleSchemaChange/types'; +import { cloneDeep } from 'lodash'; +import { useCallback, useMemo } from 'react'; +import { logRocketEvent } from 'services/shared'; +import { CustomEvents } from 'services/types'; +import { Schema } from 'types'; +import { addOrRemoveOnIncompatibleSchemaChange } from 'utils/entity-utils'; + +export default function useSpecificationIncompatibleSchemaSetting() { + // Draft Editor Store + const draftId = useEditorStore_persistedDraftId(); + const draftSpecs = useEditorStore_queryResponse_draftSpecs(); + const mutateDraftSpecs = useEditorStore_queryResponse_mutate(); + + const draftSpec = useMemo( + () => + draftSpecs.length > 0 && draftSpecs[0].spec ? draftSpecs[0] : null, + [draftSpecs] + ); + + const updateOnIncompatibleSchemaChange = useCallback( + async (value: AutoCompleteOption['val'] | undefined) => { + if (!mutateDraftSpecs || !draftId || !draftSpec) { + logRocketEvent( + `${CustomEvents.INCOMPATIBLE_SCHEMA_CHANGE}:Missing Draft Resources`, + { + draftIdMissing: !draftId, + draftSpecMissing: !draftSpec, + mutateMissing: !mutateDraftSpecs, + } + ); + + return Promise.resolve(); + } + + const spec: Schema = cloneDeep(draftSpec.spec); + + addOrRemoveOnIncompatibleSchemaChange(spec, value); + + const updateResponse = await modifyDraftSpec(spec, { + draft_id: draftId, + catalog_name: draftSpec.catalog_name, + spec_type: 'materialization', + }); + + if (updateResponse.error) { + return Promise.reject(updateResponse.error); + } + + return mutateDraftSpecs(); + }, + [draftId, draftSpec, mutateDraftSpecs] + ); + + return { + currentSetting: draftSpec?.spec?.onIncompatibleSchemaChange + ? draftSpec.spec.onIncompatibleSchemaChange + : '', + updateOnIncompatibleSchemaChange, + }; +} diff --git a/src/hooks/OnIncompatibleSchemaChange/useSupportedOptions.ts b/src/hooks/OnIncompatibleSchemaChange/useSupportedOptions.ts index 5b3691019..03b116b48 100644 --- a/src/hooks/OnIncompatibleSchemaChange/useSupportedOptions.ts +++ b/src/hooks/OnIncompatibleSchemaChange/useSupportedOptions.ts @@ -1,5 +1,5 @@ -import { choices } from 'components/editor/Bindings/OnIncompatibleSchemaChange/shared'; -import { AutoCompleteOption } from 'components/editor/Bindings/OnIncompatibleSchemaChange/types'; +import { choices } from 'components/incompatibleSchemaChange/shared'; +import { AutoCompleteOption } from 'components/incompatibleSchemaChange/types'; import { useMemo } from 'react'; import { useIntl } from 'react-intl'; diff --git a/src/hooks/OnIncompatibleSchemaChange/useUpdateOnIncompatibleSchemaChange.ts b/src/hooks/OnIncompatibleSchemaChange/useUpdateOnIncompatibleSchemaChange.ts deleted file mode 100644 index 8835ec1c9..000000000 --- a/src/hooks/OnIncompatibleSchemaChange/useUpdateOnIncompatibleSchemaChange.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { modifyDraftSpec } from 'api/draftSpecs'; -import { AutoCompleteOption } from 'components/editor/Bindings/OnIncompatibleSchemaChange/types'; -import { - useEditorStore_persistedDraftId, - useEditorStore_queryResponse_draftSpecs, - useEditorStore_queryResponse_mutate, -} from 'components/editor/Store/hooks'; -import { useEntityType } from 'context/EntityContext'; -import { useCallback } from 'react'; -import { useIntl } from 'react-intl'; -import { BASE_ERROR } from 'services/supabase'; -import { useBinding_bindings } from 'stores/Binding/hooks'; -import { BindingMetadata, Schema } from 'types'; -import { hasLength } from 'utils/misc-utils'; -import { getBindingIndex } from 'utils/workflow-utils'; - -const updateSchema = (binding: any, newVal: any) => { - if (newVal) { - binding.onIncompatibleSchemaChange = newVal; - } else { - delete binding.onIncompatibleSchemaChange; - } -}; - -function useUpdateOnIncompatibleSchemaChange() { - const intl = useIntl(); - const entityType = useEntityType(); - - // Binding Store - const bindings = useBinding_bindings(); - - // Draft Editor Store - const draftId = useEditorStore_persistedDraftId(); - const draftSpecs = useEditorStore_queryResponse_draftSpecs(); - const mutateDraftSpecs = useEditorStore_queryResponse_mutate(); - - const updateOnIncompatibleSchemaChange = useCallback( - async ( - newVal: AutoCompleteOption['val'] | undefined, - bindingMetadata: BindingMetadata[] - ) => { - const bindingMetadataExists = hasLength(bindingMetadata); - - const invalidBindingIndex = bindingMetadataExists - ? bindingMetadata.findIndex( - ({ bindingIndex }) => bindingIndex === -1 - ) - : -1; - - if ( - !draftId || - !mutateDraftSpecs || - draftSpecs.length === 0 || - invalidBindingIndex > -1 - ) { - // TODO (onschema) update message - const errorMessageId = bindingMetadataExists - ? 'workflows.collectionSelector.manualBackfill.error.message.singleCollection' - : 'workflows.collectionSelector.manualBackfill.error.message.allBindings'; - - const errorMessageValues = bindingMetadataExists - ? { - collection: - bindingMetadata[invalidBindingIndex].collection, - } - : undefined; - - return Promise.reject({ - ...BASE_ERROR, - message: intl.formatMessage( - { id: errorMessageId }, - errorMessageValues - ), - }); - } - - const spec: Schema = draftSpecs[0].spec; - - if (bindingMetadataExists) { - bindingMetadata.forEach(({ bindingIndex }) => { - if (bindingIndex > -1) { - updateSchema(spec.bindings[bindingIndex], newVal); - } - }); - } else { - Object.entries(bindings).forEach( - ([collection, bindingUUIDs]) => { - bindingUUIDs.forEach((bindingUUID, iteratedIndex) => { - const existingBindingIndex = getBindingIndex( - spec.bindings, - collection, - iteratedIndex - ); - - if (existingBindingIndex > -1) { - updateSchema( - spec.bindings[existingBindingIndex], - newVal - ); - } - }); - } - ); - } - - const updateResponse = await modifyDraftSpec(spec, { - draft_id: draftId, - catalog_name: draftSpecs[0].catalog_name, - spec_type: entityType, - }); - - if (updateResponse.error) { - return Promise.reject(updateResponse.error); - } - - return mutateDraftSpecs(); - }, - [bindings, draftId, draftSpecs, entityType, intl, mutateDraftSpecs] - ); - - return { updateOnIncompatibleSchemaChange }; -} - -export default useUpdateOnIncompatibleSchemaChange; diff --git a/src/lang/en-US/Workflows.ts b/src/lang/en-US/Workflows.ts index 5c97e471c..e93ba73b6 100644 --- a/src/lang/en-US/Workflows.ts +++ b/src/lang/en-US/Workflows.ts @@ -256,7 +256,9 @@ export const Workflows: Record = { // Incompatible Schema Change 'incompatibleSchemaChange.header': `Incompatible Schema Change`, 'incompatibleSchemaChange.message': `The action to take when a schema change is rejected due to incompatibility. If blank, the binding will backfill and be re-materialized.`, + 'incompatibleSchemaChange.message.specificationSetting': `The action to take when a schema change is rejected due to incompatibility. If blank, all bindings will backfill and be re-materialized.`, 'incompatibleSchemaChange.update.error': `Changes to draft not saved.`, + 'incompatibleSchemaChange.error.bindingSettingUpdateFailed': `There was an issue updating the incompatible schema change action for one or more bindings associated with collection, {collection}.`, 'incompatibleSchemaChange.input.label': `Action on rejected schema change`, 'incompatibleSchemaChange.error.cta': `Remove Setting`, diff --git a/src/services/types.ts b/src/services/types.ts index e57bd4910..53c1202fa 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -34,6 +34,7 @@ export enum CustomEvents { FIELD_SELECTION_REFRESH_MANUAL = 'Field_Selection_Refresh:Manual', FORM_STATE_PREVENTED = 'FormState:Prevented', FULL_PAGE_ERROR_DISPLAYED = 'Full_Page_Error_Displayed', + INCOMPATIBLE_SCHEMA_CHANGE = 'IncompatibleSchemaChange', LAZY_LOADING = 'Lazy Loading', LOGIN = 'Login', LOGS_DOCUMENT_COUNT = 'Logs:Document:Count', diff --git a/src/stores/Binding/Store.ts b/src/stores/Binding/Store.ts index 97499a212..459bd4145 100644 --- a/src/stores/Binding/Store.ts +++ b/src/stores/Binding/Store.ts @@ -122,6 +122,10 @@ const hydrateSpecificationDependentState = async ( : fallbackInterval, defaultInterval ); + + get().setSpecOnIncompatibleSchemaChange( + draftSpecs[0].spec?.onIncompatibleSchemaChange + ); } else { get().prefillBindingDependentState(entityType, liveSpec.bindings); @@ -129,6 +133,10 @@ const hydrateSpecificationDependentState = async ( liveSpec?.interval ?? fallbackInterval, defaultInterval ); + + get().setSpecOnIncompatibleSchemaChange( + liveSpec?.onIncompatibleSchemaChange + ); } return null; @@ -155,6 +163,7 @@ const getInitialMiscData = (): Pick< | 'defaultCaptureInterval' | 'discoveredCollections' | 'evolvedCollections' + | 'onIncompatibleSchemaChange' | 'rediscoveryRequired' | 'resourceConfigErrorsExist' | 'resourceConfigErrors' @@ -175,6 +184,7 @@ const getInitialMiscData = (): Pick< defaultCaptureInterval: null, discoveredCollections: [], evolvedCollections: [], + onIncompatibleSchemaChange: undefined, rediscoveryRequired: false, resourceConfigErrorsExist: false, resourceConfigErrors: [], @@ -860,6 +870,20 @@ const getInitialState = ( ); }, + setBindingOnIncompatibleSchemaChange: (value, bindingUUID) => { + set( + produce((state: BindingState) => { + if (bindingUUID) { + state.resourceConfigs[ + bindingUUID + ].meta.onIncompatibleSchemaChange = value; + } + }), + false, + 'Binding Incompatible Schema Change Set' + ); + }, + setCaptureInterval: (value, defaultInterval) => { set( produce((state: BindingState) => { @@ -971,6 +995,17 @@ const getInitialState = ( ); }, + setSpecOnIncompatibleSchemaChange: (value) => { + set( + produce((state: BindingState) => { + state.onIncompatibleSchemaChange = value; + }), + false, + 'Specification Incompatible Schema Change Set' + ); + }, + + // TODO (organization): Correct the location of store actions that are out-of-order. setBackfillDataFlow: (value) => { set( produce((state: BindingState) => { @@ -1120,11 +1155,18 @@ const getInitialState = ( }; if (!isEmpty(existingConfig)) { - const { disable, previouslyDisabled } = existingConfig.meta; + const { + disable, + onIncompatibleSchemaChange, + previouslyDisabled, + } = existingConfig.meta; evaluatedConfig.meta.disable = disable; evaluatedConfig.meta.previouslyDisabled = previouslyDisabled; + + evaluatedConfig.meta.onIncompatibleSchemaChange = + onIncompatibleSchemaChange; } // Only actually update if there was a change. This is mainly here because diff --git a/src/stores/Binding/shared.ts b/src/stores/Binding/shared.ts index 211715eba..f6458b9d5 100644 --- a/src/stores/Binding/shared.ts +++ b/src/stores/Binding/shared.ts @@ -132,7 +132,12 @@ export const getResourceConfig = ( return { data: resource, errors: [], - meta: { ...disableProp, collectionName, bindingIndex }, + meta: { + ...disableProp, + collectionName, + bindingIndex, + onIncompatibleSchemaChange: binding?.onIncompatibleSchemaChange, + }, }; }; diff --git a/src/stores/Binding/types.ts b/src/stores/Binding/types.ts index 8a65f86f6..e93afeafc 100644 --- a/src/stores/Binding/types.ts +++ b/src/stores/Binding/types.ts @@ -24,6 +24,7 @@ export interface ResourceConfig extends JsonFormsData { collectionName: string; bindingIndex: number; disable?: boolean; + onIncompatibleSchemaChange?: string; previouslyDisabled?: boolean; // Used to store if the binding was disabled last time we loaded in bindings }; } @@ -118,6 +119,16 @@ export interface BindingState ) => void; defaultCaptureInterval: DurationObjectUnits | null; + // On incompatible schema change (specification-level) + onIncompatibleSchemaChange: string | undefined; + setSpecOnIncompatibleSchemaChange: ( + value: BindingState['onIncompatibleSchemaChange'] + ) => void; + setBindingOnIncompatibleSchemaChange: ( + value: string | undefined, + bindingUUID: string | null + ) => void; + // Resource Config resourceConfigs: ResourceConfigDictionary; diff --git a/src/utils/entity-utils.ts b/src/utils/entity-utils.ts index fc8e6425d..9a69b3a39 100644 --- a/src/utils/entity-utils.ts +++ b/src/utils/entity-utils.ts @@ -1,6 +1,6 @@ import produce from 'immer'; -import { SourceCaptureDef } from 'types'; -import { specContainsDerivation } from 'utils/misc-utils'; +import { Schema, SourceCaptureDef } from 'types'; +import { hasLength, specContainsDerivation } from 'utils/misc-utils'; export const updateShardDisabled = (draftSpec: any, enabling: boolean) => { draftSpec.shards ??= {}; @@ -63,3 +63,16 @@ export const addOrRemoveSourceCapture = ( return draftSpec; }; + +export const addOrRemoveOnIncompatibleSchemaChange = ( + schema: Schema, + value: string | undefined +) => { + if (hasLength(value)) { + schema.onIncompatibleSchemaChange = value; + } else { + delete schema.onIncompatibleSchemaChange; + } + + return schema; +}; diff --git a/src/utils/workflow-utils.ts b/src/utils/workflow-utils.ts index b6fcec7e8..6aea38e4e 100644 --- a/src/utils/workflow-utils.ts +++ b/src/utils/workflow-utils.ts @@ -24,7 +24,10 @@ import { import { hasLength } from 'utils/misc-utils'; import { ConnectorConfig } from '../../deps/flow/flow'; import { isDekafEndpointConfig } from './connector-utils'; -import { addOrRemoveSourceCapture } from './entity-utils'; +import { + addOrRemoveOnIncompatibleSchemaChange, + addOrRemoveSourceCapture, +} from './entity-utils'; // This is the soft limit we recommend to users export const MAX_BINDINGS = 300; @@ -163,6 +166,7 @@ export const generateTaskSpec = ( options: { fullSource: FullSourceDictionary | null; sourceCapture: SourceCaptureDef | null; + specOnIncompatibleSchemaChange?: string; } ) => { const draftSpec = isEmpty(existingTaskData) @@ -186,8 +190,12 @@ export const generateTaskSpec = ( bindingUUIDs.forEach((bindingUUID, iteratedIndex) => { const resourceConfig = resourceConfigs[bindingUUID].data; - const { bindingIndex, collectionName, disable } = - resourceConfigs[bindingUUID].meta; + const { + bindingIndex, + collectionName, + disable, + onIncompatibleSchemaChange, + } = resourceConfigs[bindingUUID].meta; // Check if disable is a boolean otherwise default to false const bindingDisabled = isBoolean(disable) ? disable : false; @@ -212,6 +220,13 @@ export const generateTaskSpec = ( delete draftSpec.bindings[existingBindingIndex].disable; } + if (entityType === 'materialization') { + addOrRemoveOnIncompatibleSchemaChange( + draftSpec.bindings[existingBindingIndex], + onIncompatibleSchemaChange + ); + } + // Only update if there is a fullSource to populate. Otherwise just set the name. // This handles both captures that do not have these settings AND when draftSpec.bindings[existingBindingIndex] = { @@ -228,7 +243,7 @@ export const generateTaskSpec = ( } else if (Object.keys(resourceConfig).length > 0) { const disabledProps = getDisableProps(bindingDisabled); - draftSpec.bindings.push({ + const newBinding: Schema = { [collectionNameProp]: getFullSourceSetting( fullSource, collectionName, @@ -238,7 +253,16 @@ export const generateTaskSpec = ( resource: { ...resourceConfig, }, - }); + }; + + if (entityType === 'materialization') { + addOrRemoveOnIncompatibleSchemaChange( + newBinding, + onIncompatibleSchemaChange + ); + } + + draftSpec.bindings.push(newBinding); } }); }); @@ -256,9 +280,13 @@ export const generateTaskSpec = ( draftSpec.bindings = []; } - // Try adding at the end because this setting could be added/changed at any time + // Try adding at the end because these settings could be added/changed at any time if (entityType === 'materialization') { addOrRemoveSourceCapture(draftSpec, options.sourceCapture); + addOrRemoveOnIncompatibleSchemaChange( + draftSpec, + options.specOnIncompatibleSchemaChange + ); } return draftSpec; @@ -332,7 +360,10 @@ export const modifyExistingCaptureDraftSpec = async ( resourceConfigServerUpdateRequired, bindings, existingTaskData, - { fullSource: null, sourceCapture: null } + { + fullSource: null, + sourceCapture: null, + } ); return modifyDraftSpec(draftSpec, {