Skip to content

Commit

Permalink
Introduce an interval field to capture workflows (#1358)
Browse files Browse the repository at this point in the history
* Simplify capture interval field UX

* Remove FormHelperText component IDs

* Remove leading words from tooltip

* Use standard text field for input

* Prevent default interval from populating field

* Remove zeros from the default interval

* Use a template literal as the duration format string

* Extend formatCaptureInterval test cases with large numbers

* Remove postgres interval support from formatCaptureInterval
  • Loading branch information
kiahna-tucker authored Nov 15, 2024
1 parent 1a3f604 commit 46875a6
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 481 deletions.
264 changes: 70 additions & 194 deletions src/components/capture/Interval/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import {
FormControl,
Autocomplete,
AutocompleteRenderInputParams,
FormHelperText,
InputAdornment,
InputLabel,
MenuItem,
OutlinedInput,
Select,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
primaryButtonText,
primaryColoredBackground_hovered,
} from 'context/Theme';
import useCaptureInterval from 'hooks/captureInterval/useCaptureInterval';
import { HelpCircle } from 'iconoir-react';
import { isEmpty } from 'lodash';
Expand All @@ -27,18 +20,9 @@ import {
} from 'stores/FormState/hooks';
import { FormStatus } from 'stores/FormState/types';
import { hasLength } from 'utils/misc-utils';
import { getCaptureIntervalSegment } from 'utils/time-utils';
import {
CAPTURE_INTERVAL_RE,
NUMERIC_RE,
POSTGRES_INTERVAL_RE,
} from 'validation';
import { CAPTURE_INTERVAL_RE } from 'validation';
import { CaptureIntervalProps } from './types';

const DESCRIPTION_ID = 'capture-interval-description';
const INPUT_ID = 'capture-interval-input';
const INPUT_SIZE = 'small';

function CaptureInterval({ readOnly }: CaptureIntervalProps) {
const intl = useIntl();
const label = intl.formatMessage({
Expand All @@ -57,31 +41,17 @@ function CaptureInterval({ readOnly }: CaptureIntervalProps) {
const formActive = useFormStateStore_isActive();
const formStatus = useFormStateStore_status();

const lastIntervalChar = interval?.at(-1) ?? '';
const singleUnit = ['h', 'i', 'm', 's'].some(
(symbol) => lastIntervalChar === symbol
);

const [unit, setUnit] = useState(singleUnit ? lastIntervalChar : '');
const [input, setInput] = useState(
singleUnit ? interval?.substring(0, interval.length - 1) : interval
);
const [input, setInput] = useState(interval ?? '');

const loading = formActive || formStatus === FormStatus.TESTING_BACKGROUND;

const errorsExist = useMemo(() => {
const intervalErrorsExist =
input &&
hasLength(input) &&
!POSTGRES_INTERVAL_RE.test(input) &&
!CAPTURE_INTERVAL_RE.test(input);

return Boolean(
unit === 'i'
? intervalErrorsExist
: intervalErrorsExist && !NUMERIC_RE.test(input)
);
}, [input, unit]);
const errorsExist = useMemo(
() =>
Boolean(
input && hasLength(input) && !CAPTURE_INTERVAL_RE.test(input)
),
[input]
);

if (typeof input !== 'string' || isEmpty(defaultInterval)) {
return null;
Expand All @@ -104,166 +74,72 @@ function CaptureInterval({ readOnly }: CaptureIntervalProps) {
</Tooltip>
</Stack>

<Typography style={{ marginBottom: 16 }}>
<Typography>
<FormattedMessage id="captureInterval.message" />
</Typography>

<FormControl
<Autocomplete
disabled={readOnly ?? loading}
error={errorsExist}
fullWidth={false}
size={INPUT_SIZE}
variant="outlined"
sx={{
'& .MuiFormHelperText-root.Mui-error': {
whiteSpace: 'break-spaces',
},
freeSolo
onInputChange={(_event, value) => {
setInput(value);
updateStoredInterval(value);
}}
>
<InputLabel
disabled={readOnly ?? loading}
focused
htmlFor={INPUT_ID}
variant="outlined"
>
{label}
</InputLabel>

<OutlinedInput
aria-describedby={DESCRIPTION_ID}
disabled={readOnly ?? loading}
endAdornment={
<InputAdornment
position="end"
style={{ marginRight: 1 }}
>
<Select
disabled={readOnly ?? loading}
disableUnderline
error={false}
onChange={(event) => {
const value = event.target.value;
let evaluatedInterval = input;

if (unit === 'i' && value !== 'i') {
const intervalSegment =
getCaptureIntervalSegment(
input,
value
);

evaluatedInterval =
intervalSegment > -1
? intervalSegment.toString()
: '';

setInput(evaluatedInterval);
}

setUnit(value);

updateStoredInterval(
evaluatedInterval,
value
);
}}
required
size={INPUT_SIZE}
sx={{
'backgroundColor': (theme) =>
theme.palette.primary.main,
'borderBottomLeftRadius': 0,
'borderBottomRightRadius': 5,
'borderTopLeftRadius': 0,
'borderTopRightRadius': 5,
'color': (theme) =>
primaryButtonText[theme.palette.mode],
'maxWidth': 100,
'minWidth': 100,
'&.Mui-focused,&:hover': {
backgroundColor: (theme) =>
primaryColoredBackground_hovered[
theme.palette.mode
],
},
'& .MuiFilledInput-input': {
py: '8px',
},
'& .MuiSelect-iconFilled': {
color: (theme) =>
primaryButtonText[
theme.palette.mode
],
},
}}
value={unit}
variant="filled"
>
<MenuItem value="s">
<FormattedMessage id="captureInterval.input.seconds" />
</MenuItem>

<MenuItem value="m">
<FormattedMessage id="captureInterval.input.minutes" />
</MenuItem>

<MenuItem value="h">
<FormattedMessage id="captureInterval.input.hours" />
</MenuItem>

<MenuItem value="i">
<FormattedMessage id="captureInterval.input.interval" />
</MenuItem>
</Select>
</InputAdornment>
}
error={errorsExist}
id={INPUT_ID}
label={label}
onChange={(event) => {
const value = event.target.value;

onChange={(_event, value) => {
if (typeof value === 'string') {
setInput(value);
updateStoredInterval(value, unit, setUnit);
}}
size={INPUT_SIZE}
sx={{
borderRadius: 3,
pr: 0,
width: 400,
}}
value={input}
/>
updateStoredInterval(value);
}
}}
options={[
'0s',
'30s',
'1m',
'5m',
'15m',
'30m',
'1h',
'2h',
'4h',
]}
renderInput={({
InputProps,
...params
}: AutocompleteRenderInputParams) => (
<TextField
{...params}
InputProps={{
...InputProps,
sx: { borderRadius: 3 },
}}
error={errorsExist}
label={label}
size="small"
/>
)}
sx={{ width: 400 }}
value={input}
/>

<FormHelperText style={{ marginLeft: 0 }}>
{intl.formatMessage(
{ id: 'captureInterval.input.description' },
{
value: Duration.fromObject(defaultInterval).toHuman({
listStyle: 'long',
unitDisplay: 'short',
}),
}
)}
</FormHelperText>

<FormHelperText id={DESCRIPTION_ID} style={{ marginLeft: 0 }}>
{intl.formatMessage(
{ id: 'captureInterval.input.description' },
{
value: Duration.fromObject(defaultInterval).toHuman(
{
listStyle: 'long',
unitDisplay: 'short',
}
),
}
)}
{errorsExist ? (
<FormHelperText error={errorsExist} style={{ marginLeft: 0 }}>
{intl.formatMessage({
id: 'captureInterval.error.intervalFormat',
})}
</FormHelperText>

{errorsExist ? (
<FormHelperText
id={DESCRIPTION_ID}
error={errorsExist}
style={{ marginLeft: 0 }}
>
{intl.formatMessage({
id:
unit === 'i'
? 'captureInterval.error.intervalFormat'
: 'captureInterval.error.generalFormat',
})}
</FormHelperText>
) : null}
</FormControl>
) : null}
</Stack>
);
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/editor/Bindings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Stack, Typography, useTheme } from '@mui/material';
import AutoDiscoverySettings from 'components/capture/AutoDiscoverySettings';
import CaptureInterval from 'components/capture/Interval';
import BindingsEditor from 'components/editor/Bindings/Editor';
import BindingSelector from 'components/editor/Bindings/Selector';
import ListAndDetails from 'components/editor/ListAndDetails';
Expand Down Expand Up @@ -103,7 +104,7 @@ function BindingsMultiEditor({
<Stack spacing={3} sx={{ mb: 5 }}>
{entityType === 'capture' ? <AutoDiscoverySettings /> : null}

{/* {entityType === 'capture' ? <CaptureInterval /> : null} */}
{entityType === 'capture' ? <CaptureInterval /> : null}

{entityType === 'materialization' ? <SourceCapture /> : null}

Expand Down
45 changes: 6 additions & 39 deletions src/hooks/captureInterval/useCaptureInterval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,7 @@ import {
useEditorStore_queryResponse_mutate,
} from 'components/editor/Store/hooks';
import { debounce, omit } from 'lodash';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { logRocketEvent } from 'services/shared';
import { CustomEvents } from 'services/types';
import { useBindingStore } from 'stores/Binding/Store';
Expand All @@ -23,11 +15,7 @@ import { Schema } from 'types';
import { hasLength } from 'utils/misc-utils';
import { formatCaptureInterval } from 'utils/time-utils';
import { DEFAULT_DEBOUNCE_WAIT } from 'utils/workflow-utils';
import {
CAPTURE_INTERVAL_RE,
NUMERIC_RE,
POSTGRES_INTERVAL_RE,
} from 'validation';
import { CAPTURE_INTERVAL_RE } from 'validation';

export default function useCaptureInterval() {
// Binding Store
Expand Down Expand Up @@ -123,35 +111,14 @@ export default function useCaptureInterval() {
}, DEFAULT_DEBOUNCE_WAIT)
);

const updateStoredInterval = (
input: string,
unit: string,
setUnit?: Dispatch<SetStateAction<string>>
) => {
const updateStoredInterval = (input: string) => {
const trimmedInput = input.trim();

const postgresIntervalFormat = POSTGRES_INTERVAL_RE.test(trimmedInput);
const captureIntervalFormat = CAPTURE_INTERVAL_RE.test(trimmedInput);

const unitlessInterval =
!hasLength(trimmedInput) ||
postgresIntervalFormat ||
captureIntervalFormat;

if (
setUnit &&
hasLength(trimmedInput) &&
(postgresIntervalFormat || captureIntervalFormat)
!hasLength(trimmedInput) ||
CAPTURE_INTERVAL_RE.test(trimmedInput)
) {
setUnit('i');
}

if (unitlessInterval || NUMERIC_RE.test(trimmedInput)) {
const interval = unitlessInterval
? trimmedInput
: `${trimmedInput}${unit}`;

setCaptureInterval(interval);
setCaptureInterval(trimmedInput);
debounceSeverUpdate.current();
}
};
Expand Down
Loading

0 comments on commit 46875a6

Please sign in to comment.