feat(ui): configurable form field constraints (WIP)

This commit is contained in:
psychedelicious
2025-03-14 10:25:35 +10:00
parent c9f2460ff2
commit 965bcba6c2
20 changed files with 850 additions and 247 deletions

View File

@@ -1768,7 +1768,9 @@
"containerPlaceholder": "Empty Container",
"headingPlaceholder": "Empty Heading",
"textPlaceholder": "Empty Text",
"workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release."
"workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release.",
"minimum": "Minimum",
"maximum": "Maximum"
}
},
"controlLayers": {

View File

@@ -3,24 +3,35 @@ import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/f
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import type { NodeFieldFloatSettings } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const FloatFieldInput = memo((props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props);
export const FloatFieldInput = memo(
(
props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate, { settings?: NodeFieldFloatSettings }>
) => {
const { nodeId, field, fieldTemplate, settings } = props;
const { defaultValue, onChange, min, max, step, fineStep } = useFloatField(
nodeId,
field.name,
fieldTemplate,
settings
);
return (
<CompositeNumberInput
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className={NO_DRAG_CLASS}
flex="1 1 0"
/>
);
});
return (
<CompositeNumberInput
defaultValue={defaultValue}
onChange={onChange}
value={field.value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className={NO_DRAG_CLASS}
flex="1 1 0"
/>
);
}
);
FloatFieldInput.displayName = 'FloatFieldInput ';

View File

@@ -3,18 +3,27 @@ import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/f
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import type { NodeFieldFloatSettings } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const FloatFieldInputAndSlider = memo(
(props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props);
(
props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate, { settings?: NodeFieldFloatSettings }>
) => {
const { nodeId, field, fieldTemplate, settings } = props;
const { defaultValue, onChange, min, max, step, fineStep } = useFloatField(
nodeId,
field.name,
fieldTemplate,
settings
);
return (
<>
<CompositeSlider
defaultValue={defaultValue}
onChange={onChange}
value={value}
value={field.value}
min={min}
max={max}
step={step}
@@ -27,7 +36,7 @@ export const FloatFieldInputAndSlider = memo(
<CompositeNumberInput
defaultValue={defaultValue}
onChange={onChange}
value={value}
value={field.value}
min={min}
max={max}
step={step}

View File

@@ -3,26 +3,37 @@ import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/f
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import type { NodeFieldFloatSettings } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const FloatFieldSlider = memo((props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useFloatField(props);
export const FloatFieldSlider = memo(
(
props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate, { settings?: NodeFieldFloatSettings }>
) => {
const { nodeId, field, fieldTemplate, settings } = props;
const { defaultValue, onChange, min, max, step, fineStep } = useFloatField(
nodeId,
field.name,
fieldTemplate,
settings
);
return (
<CompositeSlider
defaultValue={defaultValue}
onChange={onChange}
value={value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className={NO_DRAG_CLASS}
marks
withThumbTooltip
flex="1 1 0"
/>
);
});
return (
<CompositeSlider
defaultValue={defaultValue}
onChange={onChange}
value={field.value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className={NO_DRAG_CLASS}
marks
withThumbTooltip
flex="1 1 0"
/>
);
}
);
FloatFieldSlider.displayName = 'FloatFieldSlider ';

View File

@@ -1,65 +1,93 @@
import { NUMPY_RAND_MAX } from 'app/constants';
import { useAppDispatch } from 'app/store/storeHooks';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple';
import { fieldFloatValueChanged } from 'features/nodes/store/nodesSlice';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import type { FloatFieldInputTemplate } from 'features/nodes/types/field';
import { constrainNumber } from 'features/nodes/util/constrainNumber';
import { isNil } from 'lodash-es';
import { useCallback, useMemo } from 'react';
export const useFloatField = (props: FieldComponentProps<FloatFieldInputInstance, FloatFieldInputTemplate>) => {
const { nodeId, field, fieldTemplate } = props;
export const useFloatField = (
nodeId: string,
fieldName: string,
fieldTemplate: FloatFieldInputTemplate,
overrides: { min?: number; max?: number; step?: number } = {}
) => {
const { min: overrideMin, max: overrideMax, step: overrideStep } = overrides;
const dispatch = useAppDispatch();
const onChange = useCallback(
(value: number) => {
dispatch(fieldFloatValueChanged({ nodeId, fieldName: field.name, value }));
},
[dispatch, field.name, nodeId]
);
const min = useMemo(() => {
let min = -NUMPY_RAND_MAX;
if (!isNil(fieldTemplate.minimum)) {
min = fieldTemplate.minimum;
}
if (!isNil(fieldTemplate.exclusiveMinimum)) {
min = fieldTemplate.exclusiveMinimum + 0.01;
}
return min;
}, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum]);
const max = useMemo(() => {
let max = NUMPY_RAND_MAX;
if (!isNil(fieldTemplate.maximum)) {
max = fieldTemplate.maximum;
}
if (!isNil(fieldTemplate.exclusiveMaximum)) {
max = fieldTemplate.exclusiveMaximum - 0.01;
}
return max;
}, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum]);
const step = useMemo(() => {
if (overrideStep !== undefined) {
return overrideStep;
}
if (isNil(fieldTemplate.multipleOf)) {
return 0.1;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf]);
}, [fieldTemplate.multipleOf, overrideStep]);
const fineStep = useMemo(() => {
if (overrideStep !== undefined) {
return overrideStep;
}
if (isNil(fieldTemplate.multipleOf)) {
return 0.01;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf]);
}, [fieldTemplate.multipleOf, overrideStep]);
const min = useMemo(() => {
let min = -NUMPY_RAND_MAX;
if (overrideMin !== undefined) {
min = overrideMin;
} else if (!isNil(fieldTemplate.minimum)) {
min = fieldTemplate.minimum;
} else if (!isNil(fieldTemplate.exclusiveMinimum)) {
min = fieldTemplate.exclusiveMinimum + 0.01;
}
return roundUpToMultiple(min, step);
}, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum, overrideMin, step]);
const max = useMemo(() => {
let max = NUMPY_RAND_MAX;
if (overrideMax !== undefined) {
max = overrideMax;
} else if (!isNil(fieldTemplate.maximum)) {
max = fieldTemplate.maximum;
} else if (!isNil(fieldTemplate.exclusiveMaximum)) {
max = fieldTemplate.exclusiveMaximum - 0.01;
}
return roundDownToMultiple(max, step);
}, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum, overrideMax, step]);
const constrainValue = useCallback(
(v: number) =>
constrainNumber(
v,
{ min, max, multipleOf: step },
{ min: overrideMin, max: overrideMax, multipleOf: overrideStep }
),
[max, min, overrideMax, overrideMin, overrideStep, step]
);
const onChange = useCallback(
(value: number) => {
dispatch(fieldFloatValueChanged({ nodeId, fieldName, value }));
},
[dispatch, fieldName, nodeId]
);
return {
defaultValue: fieldTemplate.default,
onChange,
value: field.value,
min,
max,
step,
fineStep,
constrainValue,
};
};

View File

@@ -95,6 +95,8 @@ import {
} from 'features/nodes/types/field';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
import BoardFieldInputComponent from './inputs/BoardFieldInputComponent';
import BooleanFieldInputComponent from './inputs/BooleanFieldInputComponent';
@@ -157,6 +159,8 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
return <StringFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'textarea') {
return <StringFieldTextarea nodeId={nodeId} field={field} fieldTemplate={template} />;
} else {
assert<Equals<never, typeof settings.component>>(false, 'Unexpected settings.component');
}
}
@@ -171,32 +175,47 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
if (!isIntegerFieldInputInstance(field)) {
return null;
}
if (settings?.type !== 'integer-field-config') {
if (!settings || settings.type !== 'integer-field-config') {
return <IntegerFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (settings.component === 'number-input') {
return <IntegerFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'slider') {
}
if (settings.component === 'slider') {
return <IntegerFieldSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'number-input-and-slider') {
}
if (settings.component === 'number-input-and-slider') {
return <IntegerFieldInputAndSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
}
assert<Equals<never, typeof settings.component>>(false, 'Unexpected settings.component');
}
if (isFloatFieldInputTemplate(template)) {
if (!isFloatFieldInputInstance(field)) {
return null;
}
if (settings?.type !== 'float-field-config') {
if (!settings || settings.type !== 'float-field-config') {
return <FloatFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
}
if (settings.component === 'number-input') {
return <FloatFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'slider') {
return <FloatFieldSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'number-input-and-slider') {
return <FloatFieldInputAndSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
return <FloatFieldInput nodeId={nodeId} field={field} fieldTemplate={template} settings={settings} />;
}
if (settings.component === 'slider') {
return <FloatFieldSlider nodeId={nodeId} field={field} fieldTemplate={template} settings={settings} />;
}
if (settings.component === 'number-input-and-slider') {
return <FloatFieldInputAndSlider nodeId={nodeId} field={field} fieldTemplate={template} settings={settings} />;
}
assert<Equals<never, typeof settings.component>>(false, 'Unexpected settings.component');
}
if (isIntegerFieldCollectionInputTemplate(template)) {

View File

@@ -3,23 +3,37 @@ import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/I
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import type { NodeFieldIntegerSettings } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const IntegerFieldInput = memo(
(props: FieldComponentProps<IntegerFieldInputInstance, IntegerFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props);
(
props: FieldComponentProps<
IntegerFieldInputInstance,
IntegerFieldInputTemplate,
{ settings?: NodeFieldIntegerSettings }
>
) => {
const { nodeId, field, fieldTemplate, settings } = props;
const { defaultValue, onChange, min, max, step, fineStep, constrainValue } = useIntegerField(
nodeId,
field.name,
fieldTemplate,
settings
);
return (
<CompositeNumberInput
defaultValue={defaultValue}
onChange={onChange}
value={value}
value={field.value}
min={min}
max={max}
step={step}
fineStep={fineStep}
className={NO_DRAG_CLASS}
flex="1 1 0"
constrainValue={constrainValue}
/>
);
}

View File

@@ -3,18 +3,31 @@ import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/I
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import type { NodeFieldIntegerSettings } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const IntegerFieldInputAndSlider = memo(
(props: FieldComponentProps<IntegerFieldInputInstance, IntegerFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props);
(
props: FieldComponentProps<
IntegerFieldInputInstance,
IntegerFieldInputTemplate,
{ settings?: NodeFieldIntegerSettings }
>
) => {
const { nodeId, field, fieldTemplate, settings } = props;
const { defaultValue, onChange, min, max, step, fineStep } = useIntegerField(
nodeId,
field.name,
fieldTemplate,
settings
);
return (
<>
<CompositeSlider
defaultValue={defaultValue}
onChange={onChange}
value={value}
value={field.value}
min={min}
max={max}
step={step}
@@ -27,7 +40,7 @@ export const IntegerFieldInputAndSlider = memo(
<CompositeNumberInput
defaultValue={defaultValue}
onChange={onChange}
value={value}
value={field.value}
min={min}
max={max}
step={step}

View File

@@ -3,17 +3,30 @@ import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/I
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import type { NodeFieldIntegerSettings } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const IntegerFieldSlider = memo(
(props: FieldComponentProps<IntegerFieldInputInstance, IntegerFieldInputTemplate>) => {
const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props);
(
props: FieldComponentProps<
IntegerFieldInputInstance,
IntegerFieldInputTemplate,
{ settings?: NodeFieldIntegerSettings }
>
) => {
const { nodeId, field, fieldTemplate, settings } = props;
const { defaultValue, onChange, min, max, step, fineStep } = useIntegerField(
nodeId,
field.name,
fieldTemplate,
settings
);
return (
<CompositeSlider
defaultValue={defaultValue}
onChange={onChange}
value={value}
value={field.value}
min={min}
max={max}
step={step}

View File

@@ -1,65 +1,93 @@
import { NUMPY_RAND_MAX } from 'app/constants';
import { useAppDispatch } from 'app/store/storeHooks';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple';
import { fieldIntegerValueChanged } from 'features/nodes/store/nodesSlice';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import type { IntegerFieldInputTemplate } from 'features/nodes/types/field';
import { constrainNumber } from 'features/nodes/util/constrainNumber';
import { isNil } from 'lodash-es';
import { useCallback, useMemo } from 'react';
export const useIntegerField = (props: FieldComponentProps<IntegerFieldInputInstance, IntegerFieldInputTemplate>) => {
const { nodeId, field, fieldTemplate } = props;
export const useIntegerField = (
nodeId: string,
fieldName: string,
fieldTemplate: IntegerFieldInputTemplate,
overrides: { min?: number; max?: number; step?: number } = {}
) => {
const { min: overrideMin, max: overrideMax, step: overrideStep } = overrides;
const dispatch = useAppDispatch();
const onChange = useCallback(
(value: number) => {
dispatch(fieldIntegerValueChanged({ nodeId, fieldName: field.name, value: Math.floor(Number(value)) }));
},
[dispatch, field.name, nodeId]
);
const step = useMemo(() => {
if (overrideStep !== undefined) {
return overrideStep;
}
if (isNil(fieldTemplate.multipleOf)) {
return 1;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf, overrideStep]);
const fineStep = useMemo(() => {
if (overrideStep !== undefined) {
return overrideStep;
}
if (isNil(fieldTemplate.multipleOf)) {
return 1;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf, overrideStep]);
const min = useMemo(() => {
let min = -NUMPY_RAND_MAX;
if (!isNil(fieldTemplate.minimum)) {
if (overrideMin !== undefined) {
min = overrideMin;
} else if (!isNil(fieldTemplate.minimum)) {
min = fieldTemplate.minimum;
}
if (!isNil(fieldTemplate.exclusiveMinimum)) {
} else if (!isNil(fieldTemplate.exclusiveMinimum)) {
min = fieldTemplate.exclusiveMinimum + 1;
}
return min;
}, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum]);
return roundUpToMultiple(min, step);
}, [fieldTemplate.exclusiveMinimum, fieldTemplate.minimum, overrideMin, step]);
const max = useMemo(() => {
let max = NUMPY_RAND_MAX;
if (!isNil(fieldTemplate.maximum)) {
if (overrideMax !== undefined) {
max = overrideMax;
} else if (!isNil(fieldTemplate.maximum)) {
max = fieldTemplate.maximum;
}
if (!isNil(fieldTemplate.exclusiveMaximum)) {
} else if (!isNil(fieldTemplate.exclusiveMaximum)) {
max = fieldTemplate.exclusiveMaximum - 1;
}
return max;
}, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum]);
const step = useMemo(() => {
if (isNil(fieldTemplate.multipleOf)) {
return 1;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf]);
return roundDownToMultiple(max, step);
}, [fieldTemplate.exclusiveMaximum, fieldTemplate.maximum, overrideMax, step]);
const fineStep = useMemo(() => {
if (isNil(fieldTemplate.multipleOf)) {
return 1;
}
return fieldTemplate.multipleOf;
}, [fieldTemplate.multipleOf]);
const constrainValue = useCallback(
(v: number) =>
constrainNumber(
v,
{ min, max, multipleOf: step },
{ min: overrideMin, max: overrideMax, multipleOf: overrideStep }
),
[max, min, overrideMax, overrideMin, overrideStep, step]
);
const onChange = useCallback(
(value: number) => {
dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value }));
},
[dispatch, fieldName, nodeId]
);
return {
defaultValue: fieldTemplate.default,
onChange,
value: field.value,
min,
max,
step,
fineStep,
constrainValue,
};
};

View File

@@ -1,7 +1,11 @@
import type { FieldInputInstance, FieldInputTemplate } from 'features/nodes/types/field';
export type FieldComponentProps<V extends FieldInputInstance, T extends FieldInputTemplate, C = void> = {
export type FieldComponentProps<
TFieldInstance extends FieldInputInstance,
TFieldTemplate extends FieldInputTemplate,
FieldSettings = void,
> = {
nodeId: string;
field: V;
fieldTemplate: T;
} & Omit<C, 'nodeId' | 'field' | 'fieldTemplate'>;
field: TFieldInstance;
fieldTemplate: TFieldTemplate;
} & Omit<FieldSettings, 'nodeId' | 'field' | 'fieldTemplate'>;

View File

@@ -1,12 +1,35 @@
import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { CompositeNumberInput, Flex, FormControl, FormLabel, Select, Switch } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { fieldFloatValueChanged } from 'features/nodes/store/nodesSlice';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import { type NodeFieldFloatSettings, zNumberComponent } from 'features/nodes/types/workflow';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const NodeFieldElementFloatSettings = memo(({ id, config }: { id: string; config: NodeFieldFloatSettings }) => {
type Props = {
id: string;
config: NodeFieldFloatSettings;
nodeId: string;
fieldName: string;
fieldTemplate: FloatFieldInputTemplate;
};
export const NodeFieldElementFloatSettings = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props) => {
return (
<>
<SettingComponent id={id} config={config} nodeId={nodeId} fieldName={fieldName} fieldTemplate={fieldTemplate} />
<SettingMin id={id} config={config} nodeId={nodeId} fieldName={fieldName} fieldTemplate={fieldTemplate} />
<SettingMax id={id} config={config} nodeId={nodeId} fieldName={fieldName} fieldTemplate={fieldTemplate} />
</>
);
});
NodeFieldElementFloatSettings.displayName = 'NodeFieldElementFloatSettings';
const SettingComponent = memo(({ id, config }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
@@ -22,7 +45,7 @@ export const NodeFieldElementFloatSettings = memo(({ id, config }: { id: string;
);
return (
<FormControl>
<FormControl orientation="vertical">
<FormLabel flex={1}>{t('workflows.builder.component')}</FormLabel>
<Select value={config.component} onChange={onChangeComponent} size="sm">
<option value="number-input">{t('workflows.builder.numberInput')}</option>
@@ -32,4 +55,113 @@ export const NodeFieldElementFloatSettings = memo(({ id, config }: { id: string;
</FormControl>
);
});
NodeFieldElementFloatSettings.displayName = 'NodeFieldElementFloatSettings';
SettingComponent.displayName = 'SettingComponent';
const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const field = useInputFieldInstance<FloatFieldInputInstance>(nodeId, fieldName);
const floatField = useFloatField(nodeId, fieldName, fieldTemplate);
const onToggleSetting = useCallback(() => {
const newConfig: NodeFieldFloatSettings = {
...config,
min: config.min !== undefined ? undefined : floatField.min,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
}, [config, dispatch, floatField.min, id]);
const onChange = useCallback(
(v: number) => {
const newConfig: NodeFieldFloatSettings = {
...config,
min: v,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
const constrained = constrain(v, floatField, newConfig);
if (field.value !== constrained) {
dispatch(fieldFloatValueChanged({ nodeId, fieldName, value: v }));
}
},
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
);
return (
<FormControl orientation="vertical">
<Flex justifyContent="space-between" w="full" alignItems="center">
<FormLabel m={0}>{t('workflows.builder.minimum')}</FormLabel>
<Switch isChecked={config.min !== undefined} onChange={onToggleSetting} size="sm" />
</Flex>
<CompositeNumberInput
w="full"
isDisabled={config.min === undefined}
value={config.min === undefined ? (`${floatField.min} (inherited)` as unknown as number) : config.min}
onChange={onChange}
min={floatField.min}
max={(config.max ?? floatField.max) - 0.1}
/>
</FormControl>
);
});
SettingMin.displayName = 'SettingMin';
const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const field = useInputFieldInstance<FloatFieldInputInstance>(nodeId, fieldName);
const floatField = useFloatField(nodeId, fieldName, fieldTemplate);
const onToggleSetting = useCallback(() => {
const newConfig: NodeFieldFloatSettings = {
...config,
max: config.max !== undefined ? undefined : floatField.max,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
}, [config, dispatch, floatField.max, id]);
const onChange = useCallback(
(v: number) => {
const newConfig: NodeFieldFloatSettings = {
...config,
max: v,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
const constrained = constrain(v, floatField, newConfig);
if (field.value !== constrained) {
dispatch(fieldFloatValueChanged({ nodeId, fieldName, value: v }));
}
},
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
);
return (
<FormControl orientation="vertical">
<Flex justifyContent="space-between" w="full" alignItems="center">
<FormLabel m={0}>{t('workflows.builder.maximum')}</FormLabel>
<Switch isChecked={config.max !== undefined} onChange={onToggleSetting} size="sm" />
</Flex>
<CompositeNumberInput
w="full"
isDisabled={config.max === undefined}
value={config.max === undefined ? (`${floatField.max} (inherited)` as unknown as number) : config.max}
onChange={onChange}
min={(config.min ?? floatField.min) + 0.1}
max={floatField.max}
/>
</FormControl>
);
});
SettingMax.displayName = 'SettingMax';
const constrain = (v: number, fieldSettings: ReturnType<typeof useFloatField>, overrides: NodeFieldFloatSettings) => {
const min = overrides.min ?? fieldSettings.min;
const max = overrides.max ?? fieldSettings.max;
const step = overrides.step ?? fieldSettings.step;
const _v = Math.min(max, Math.max(min, v));
const _diff = _v - min;
const _steps = Math.round(_diff / step);
return min + _steps * step;
};

View File

@@ -1,37 +1,173 @@
import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { CompositeNumberInput, Flex, FormControl, FormLabel, Select, Switch } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { fieldIntegerValueChanged } from 'features/nodes/store/nodesSlice';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import { type NodeFieldIntegerSettings, zNumberComponent } from 'features/nodes/types/workflow';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import type { NodeFieldIntegerSettings } from 'features/nodes/types/workflow';
import { zNumberComponent } from 'features/nodes/types/workflow';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { PartialDeep } from 'type-fest';
export const NodeFieldElementIntegerConfig = memo(
({ id, config }: { id: string; config: NodeFieldIntegerSettings }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
type Props = {
id: string;
config: NodeFieldIntegerSettings;
nodeId: string;
fieldName: string;
fieldTemplate: IntegerFieldInputTemplate;
};
const onChangeComponent = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const newConfig: NodeFieldIntegerSettings = {
...config,
component: zNumberComponent.parse(e.target.value),
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
},
[config, dispatch, id]
);
export const NodeFieldElementIntegerSettings = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props) => {
return (
<>
<SettingComponent id={id} config={config} nodeId={nodeId} fieldName={fieldName} fieldTemplate={fieldTemplate} />
<SettingMin id={id} config={config} nodeId={nodeId} fieldName={fieldName} fieldTemplate={fieldTemplate} />
<SettingMax id={id} config={config} nodeId={nodeId} fieldName={fieldName} fieldTemplate={fieldTemplate} />
</>
);
});
NodeFieldElementIntegerSettings.displayName = 'NodeFieldElementIntegerSettings';
return (
<FormControl>
<FormLabel flex={1}>{t('workflows.builder.component')}</FormLabel>
<Select value={config.component} onChange={onChangeComponent} size="sm">
<option value="number-input">{t('workflows.builder.numberInput')}</option>
<option value="slider">{t('workflows.builder.slider')}</option>
<option value="number-input-and-slider">{t('workflows.builder.both')}</option>
</Select>
</FormControl>
);
}
);
NodeFieldElementIntegerConfig.displayName = 'NodeFieldElementIntegerConfig';
const SettingComponent = memo(({ id, config }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onChangeComponent = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const newConfig: NodeFieldIntegerSettings = {
...config,
component: zNumberComponent.parse(e.target.value),
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
},
[config, dispatch, id]
);
return (
<FormControl orientation="vertical">
<FormLabel flex={1}>{t('workflows.builder.component')}</FormLabel>
<Select value={config.component} onChange={onChangeComponent} size="sm">
<option value="number-input">{t('workflows.builder.numberInput')}</option>
<option value="slider">{t('workflows.builder.slider')}</option>
<option value="number-input-and-slider">{t('workflows.builder.both')}</option>
</Select>
</FormControl>
);
});
SettingComponent.displayName = 'SettingComponent';
const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const field = useInputFieldInstance<IntegerFieldInputInstance>(nodeId, fieldName);
const floatField = useIntegerField(nodeId, fieldName, fieldTemplate);
const onToggleSetting = useCallback(() => {
const newConfig: NodeFieldIntegerSettings = {
...config,
min: config.min !== undefined ? undefined : floatField.min,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
}, [config, dispatch, floatField.min, id]);
const onChange = useCallback(
(v: number) => {
const newConfig: NodeFieldIntegerSettings = {
...config,
min: v,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
const constrained = constrain(v, floatField, newConfig);
if (field.value !== constrained) {
dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value: v }));
}
},
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
);
return (
<FormControl orientation="vertical">
<Flex justifyContent="space-between" w="full" alignItems="center">
<FormLabel m={0}>{t('workflows.builder.minimum')}</FormLabel>
<Switch isChecked={config.min !== undefined} onChange={onToggleSetting} size="sm" />
</Flex>
<CompositeNumberInput
w="full"
isDisabled={config.min === undefined}
value={config.min === undefined ? (`${floatField.min} (inherited)` as unknown as number) : config.min}
onChange={onChange}
min={floatField.min}
max={(config.max ?? floatField.max) - 0.1}
/>
</FormControl>
);
});
SettingMin.displayName = 'SettingMin';
const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const field = useInputFieldInstance<IntegerFieldInputInstance>(nodeId, fieldName);
const floatField = useIntegerField(nodeId, fieldName, fieldTemplate);
const onToggleSetting = useCallback(() => {
const newConfig: NodeFieldIntegerSettings = {
...config,
max: config.max !== undefined ? undefined : floatField.max,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
}, [config, dispatch, floatField.max, id]);
const onChange = useCallback(
(v: number) => {
const newConfig: NodeFieldIntegerSettings = {
...config,
max: v,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
const constrained = constrain(v, floatField, newConfig);
if (field.value !== constrained) {
dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value: v }));
}
},
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
);
return (
<FormControl orientation="vertical">
<Flex justifyContent="space-between" w="full" alignItems="center">
<FormLabel m={0}>{t('workflows.builder.maximum')}</FormLabel>
<Switch isChecked={config.max !== undefined} onChange={onToggleSetting} size="sm" />
</Flex>
<CompositeNumberInput
w="full"
isDisabled={config.max === undefined}
value={config.max === undefined ? (`${floatField.max} (inherited)` as unknown as number) : config.max}
onChange={onChange}
min={(config.min ?? floatField.min) + 0.1}
max={floatField.max}
/>
</FormControl>
);
});
SettingMax.displayName = 'SettingMax';
type NumberConstraints = { min: number; max: number; step: number };
const constrain = (v: number, constraints: NumberConstraints, overrides: PartialDeep<NumberConstraints>) => {
const min = overrides.min ?? constraints.min;
const max = overrides.max ?? constraints.max;
const step = overrides.step ?? constraints.step;
console.log({ min, max, step });
const _v = Math.min(max, Math.max(min, v));
const _diff = _v - min;
const _steps = Math.round(_diff / step);
return min + _steps * step;
};

View File

@@ -1,4 +1,5 @@
import {
Flex,
FormControl,
FormLabel,
IconButton,
@@ -12,7 +13,7 @@ import {
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { NodeFieldElementFloatSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings';
import { NodeFieldElementIntegerConfig } from 'features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings';
import { NodeFieldElementIntegerSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings';
import { NodeFieldElementStringSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementStringSettings';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
@@ -70,13 +71,31 @@ export const NodeFieldElementSettings = memo(({ element }: { element: NodeFieldE
<PopoverContent>
<PopoverArrow />
<PopoverBody minW={48}>
<FormControl>
<FormLabel flex={1}>{t('workflows.builder.showDescription')}</FormLabel>
<Switch size="sm" isChecked={showDescription} onChange={toggleShowDescription} />
</FormControl>
{settings?.type === 'integer-field-config' && <NodeFieldElementIntegerConfig id={id} config={settings} />}
{settings?.type === 'float-field-config' && <NodeFieldElementFloatSettings id={id} config={settings} />}
{settings?.type === 'string-field-config' && <NodeFieldElementStringSettings id={id} config={settings} />}
<Flex w="full" h="full" gap={2} flexDir="column">
<FormControl>
<FormLabel flex={1}>{t('workflows.builder.showDescription')}</FormLabel>
<Switch size="sm" isChecked={showDescription} onChange={toggleShowDescription} />
</FormControl>
{settings?.type === 'integer-field-config' && isIntegerFieldInputTemplate(fieldTemplate) && (
<NodeFieldElementIntegerSettings
id={id}
config={settings}
nodeId={nodeId}
fieldName={fieldName}
fieldTemplate={fieldTemplate}
/>
)}
{settings?.type === 'float-field-config' && isFloatFieldInputTemplate(fieldTemplate) && (
<NodeFieldElementFloatSettings
id={id}
config={settings}
nodeId={nodeId}
fieldName={fieldName}
fieldTemplate={fieldTemplate}
/>
)}
{settings?.type === 'string-field-config' && <NodeFieldElementStringSettings id={id} config={settings} />}
</Flex>
</PopoverBody>
</PopoverContent>
</Portal>

View File

@@ -0,0 +1,37 @@
import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import { type NodeFieldIntegerSettings, zNumberComponent } from 'features/nodes/types/workflow';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const NodeFieldElementIntegerConfig = memo(
({ id, config }: { id: string; config: NodeFieldIntegerSettings }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onChangeComponent = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const newConfig: NodeFieldIntegerSettings = {
...config,
component: zNumberComponent.parse(e.target.value),
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
},
[config, dispatch, id]
);
return (
<FormControl>
<FormLabel flex={1}>{t('workflows.builder.component')}</FormLabel>
<Select value={config.component} onChange={onChangeComponent} size="sm">
<option value="number-input">{t('workflows.builder.numberInput')}</option>
<option value="slider">{t('workflows.builder.slider')}</option>
<option value="number-input-and-slider">{t('workflows.builder.both')}</option>
</Select>
</FormControl>
);
}
);
NodeFieldElementIntegerConfig.displayName = 'NodeFieldElementIntegerConfig';

View File

@@ -5,7 +5,7 @@ import type { FieldInputInstance } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useInputFieldInstance = (nodeId: string, fieldName: string): FieldInputInstance => {
export const useInputFieldInstance = <T extends FieldInputInstance>(nodeId: string, fieldName: string): T => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
@@ -18,5 +18,5 @@ export const useInputFieldInstance = (nodeId: string, fieldName: string): FieldI
const instance = useAppSelector(selector);
return instance;
return instance as T;
};

View File

@@ -4,20 +4,29 @@ import type {
FieldInputTemplate,
FloatFieldCollectionInputTemplate,
FloatFieldCollectionValue,
FloatFieldInputTemplate,
FloatFieldValue,
ImageFieldCollectionInputTemplate,
ImageFieldCollectionValue,
IntegerFieldCollectionInputTemplate,
IntegerFieldCollectionValue,
IntegerFieldInputTemplate,
IntegerFieldValue,
StatefulFieldValue,
StringFieldCollectionInputTemplate,
StringFieldCollectionValue,
} from 'features/nodes/types/field';
import {
isFloatFieldCollectionInputInstance,
isFloatFieldCollectionInputTemplate,
isFloatFieldInputInstance,
isFloatFieldInputTemplate,
isImageFieldCollectionInputInstance,
isImageFieldCollectionInputTemplate,
isIntegerFieldCollectionInputInstance,
isIntegerFieldCollectionInputTemplate,
isIntegerFieldInputInstance,
isIntegerFieldInputTemplate,
isStringFieldCollectionInputInstance,
isStringFieldCollectionInputTemplate,
} from 'features/nodes/types/field';
@@ -25,10 +34,15 @@ import { type InvocationNode, type InvocationTemplate, isInvocationNode } from '
import { t } from 'i18next';
import { assert } from 'tsafe';
const validateImageFieldCollectionValue = (
value: NonNullable<ImageFieldCollectionValue>,
template: ImageFieldCollectionInputTemplate
): string[] => {
type FieldValidationFunc<TValue extends StatefulFieldValue, TTemplate extends FieldInputTemplate> = (
value: TValue,
template: TTemplate
) => string[];
const validateImageFieldCollectionValue: FieldValidationFunc<
NonNullable<ImageFieldCollectionValue>,
ImageFieldCollectionInputTemplate
> = (value, template) => {
const reasons: string[] = [];
const { minItems, maxItems } = template;
const count = value.length;
@@ -49,10 +63,10 @@ const validateImageFieldCollectionValue = (
return reasons;
};
const validateStringFieldCollectionValue = (
value: NonNullable<StringFieldCollectionValue>,
template: StringFieldCollectionInputTemplate
): string[] => {
const validateStringFieldCollectionValue: FieldValidationFunc<
NonNullable<StringFieldCollectionValue>,
StringFieldCollectionInputTemplate
> = (value, template) => {
const reasons: string[] = [];
const { minItems, maxItems, minLength, maxLength } = template;
const count = value.length;
@@ -82,10 +96,10 @@ const validateStringFieldCollectionValue = (
return reasons;
};
const validateNumberFieldCollectionValue = (
value: NonNullable<IntegerFieldCollectionValue> | NonNullable<FloatFieldCollectionValue>,
template: IntegerFieldCollectionInputTemplate | FloatFieldCollectionInputTemplate
): string[] => {
const validateNumberFieldCollectionValue: FieldValidationFunc<
NonNullable<IntegerFieldCollectionValue> | NonNullable<FloatFieldCollectionValue>,
IntegerFieldCollectionInputTemplate | FloatFieldCollectionInputTemplate
> = (value, template) => {
const reasons: string[] = [];
const { minItems, maxItems, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = template;
const count = value.length;
@@ -124,6 +138,32 @@ const validateNumberFieldCollectionValue = (
return reasons;
};
const validateNumberFieldValue: FieldValidationFunc<
FloatFieldValue | IntegerFieldValue,
FloatFieldInputTemplate | IntegerFieldInputTemplate
> = (value, template) => {
const reasons: string[] = [];
const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = template;
if (maximum !== undefined && value > maximum) {
reasons.push(t('parameters.invoke.collectionNumberGTMax', { value, maximum }));
}
if (minimum !== undefined && value < minimum) {
reasons.push(t('parameters.invoke.collectionNumberLTMin', { value, minimum }));
}
if (exclusiveMaximum !== undefined && value >= exclusiveMaximum) {
reasons.push(t('parameters.invoke.collectionNumberGTExclusiveMax', { value, exclusiveMaximum }));
}
if (exclusiveMinimum !== undefined && value <= exclusiveMinimum) {
reasons.push(t('parameters.invoke.collectionNumberLTExclusiveMin', { value, exclusiveMinimum }));
}
if (multipleOf !== undefined && value % multipleOf !== 0) {
reasons.push(t('parameters.invoke.collectionNumberNotMultipleOf', { value, multipleOf }));
}
return reasons;
};
type NodeError = {
type: 'node-error';
nodeId: string;
@@ -147,6 +187,16 @@ const getFieldErrorPrefix = (
return `${node.data.label || nodeTemplate.title} -> ${field.label || fieldTemplate.title}`;
};
const getIssuesToFieldErrorsMapFunc =
(nodeId: string, fieldName: string, prefix: string): ((issue: string) => FieldError) =>
(issue: string) => ({
type: 'field-error',
nodeId,
fieldName,
prefix,
issue,
});
export const getFieldErrors = (
node: InvocationNode,
nodeTemplate: InvocationTemplate,
@@ -156,6 +206,7 @@ export const getFieldErrors = (
): FieldError[] => {
const errors: FieldError[] = [];
const prefix = getFieldErrorPrefix(node, nodeTemplate, field, fieldTemplate);
const issueToFieldError = getIssuesToFieldErrorsMapFunc(node.data.id, field.name, prefix);
const nodeId = node.data.id;
const fieldName = field.name;
@@ -176,68 +227,21 @@ export const getFieldErrors = (
});
} else if (isConnected) {
// Connected fields have no value to validate - they are OK
} else if (
field.value &&
isImageFieldCollectionInputTemplate(fieldTemplate) &&
isImageFieldCollectionInputInstance(field)
) {
const issues = validateImageFieldCollectionValue(field.value, fieldTemplate);
errors.push(
...issues.map<FieldError>((issue) => ({
type: 'field-error',
nodeId,
fieldName,
prefix,
issue,
}))
);
} else if (
field.value &&
isStringFieldCollectionInputTemplate(fieldTemplate) &&
isStringFieldCollectionInputInstance(field)
) {
const issues = validateStringFieldCollectionValue(field.value, fieldTemplate);
errors.push(
...issues.map<FieldError>((issue) => ({
type: 'field-error',
nodeId,
fieldName,
prefix,
issue,
}))
);
} else if (
field.value &&
isIntegerFieldCollectionInputTemplate(fieldTemplate) &&
isIntegerFieldCollectionInputInstance(field)
) {
const issues = validateNumberFieldCollectionValue(field.value, fieldTemplate);
errors.push(
...issues.map<FieldError>((issue) => ({
type: 'field-error',
nodeId,
fieldName,
prefix,
issue,
}))
);
} else if (
field.value &&
isFloatFieldCollectionInputTemplate(fieldTemplate) &&
isFloatFieldCollectionInputInstance(field)
) {
const issues = validateNumberFieldCollectionValue(field.value, fieldTemplate);
errors.push(
...issues.map<FieldError>((issue) => ({
type: 'field-error',
nodeId,
fieldName,
prefix,
issue,
}))
);
} else if (field.value !== undefined) {
if (isImageFieldCollectionInputTemplate(fieldTemplate) && isImageFieldCollectionInputInstance(field)) {
errors.push(...validateImageFieldCollectionValue(field.value, fieldTemplate).map(issueToFieldError));
} else if (isStringFieldCollectionInputTemplate(fieldTemplate) && isStringFieldCollectionInputInstance(field)) {
errors.push(...validateStringFieldCollectionValue(field.value, fieldTemplate).map(issueToFieldError));
} else if (isIntegerFieldCollectionInputTemplate(fieldTemplate) && isIntegerFieldCollectionInputInstance(field)) {
errors.push(...validateNumberFieldCollectionValue(field.value, fieldTemplate).map(issueToFieldError));
} else if (isFloatFieldCollectionInputTemplate(fieldTemplate) && isFloatFieldCollectionInputInstance(field)) {
errors.push(...validateNumberFieldCollectionValue(field.value, fieldTemplate).map(issueToFieldError));
} else if (isFloatFieldInputTemplate(fieldTemplate) && isFloatFieldInputInstance(field)) {
errors.push(...validateNumberFieldValue(field.value, fieldTemplate).map(issueToFieldError));
} else if (isIntegerFieldInputTemplate(fieldTemplate) && isIntegerFieldInputInstance(field)) {
errors.push(...validateNumberFieldValue(field.value, fieldTemplate).map(issueToFieldError));
}
}
return errors;
};

View File

@@ -75,6 +75,9 @@ const FLOAT_FIELD_SETTINGS_TYPE = 'float-field-config';
const zNodeFieldFloatSettings = z.object({
type: z.literal(FLOAT_FIELD_SETTINGS_TYPE).default(FLOAT_FIELD_SETTINGS_TYPE),
component: zNumberComponent.default('number-input'),
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional(),
});
export const getFloatFieldSettingsDefaults = (): NodeFieldFloatSettings => zNodeFieldFloatSettings.parse({});
export type NodeFieldFloatSettings = z.infer<typeof zNodeFieldFloatSettings>;
@@ -83,6 +86,9 @@ const INTEGER_FIELD_CONFIG_TYPE = 'integer-field-config';
const zNodeFieldIntegerSettings = z.object({
type: z.literal(INTEGER_FIELD_CONFIG_TYPE).default(INTEGER_FIELD_CONFIG_TYPE),
component: zNumberComponent.default('number-input'),
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional(),
});
export type NodeFieldIntegerSettings = z.infer<typeof zNodeFieldIntegerSettings>;
export const getIntegerFieldSettingsDefaults = (): NodeFieldIntegerSettings => zNodeFieldIntegerSettings.parse({});

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { constrainNumber } from './constrainNumber';
describe('constrainNumber', () => {
// Default constraints to be used in tests
const defaultConstraints = { min: 0, max: 10, multipleOf: 1 };
it('should keep values within range', () => {
expect(constrainNumber(5, defaultConstraints)).toBe(5);
expect(constrainNumber(-5, defaultConstraints)).toBe(0);
expect(constrainNumber(15, defaultConstraints)).toBe(10);
});
it('should round to nearest multiple', () => {
const constraints = { min: 0, max: 10, multipleOf: 2 };
expect(constrainNumber(1, constraints)).toBe(2);
expect(constrainNumber(2, constraints)).toBe(2);
expect(constrainNumber(3, constraints)).toBe(4);
expect(constrainNumber(9, constraints)).toBe(10);
expect(constrainNumber(11, constraints)).toBe(10);
});
it('should handle negative multiples', () => {
const constraints = { min: -10, max: 10, multipleOf: 3 };
expect(constrainNumber(-9, constraints)).toBe(-9);
expect(constrainNumber(-8, constraints)).toBe(-9);
expect(constrainNumber(-7, constraints)).toBe(-6);
expect(constrainNumber(-3, constraints)).toBe(-3);
expect(constrainNumber(-2, constraints)).toBe(-3);
// In JS, -0 !== +0... :)
expect(constrainNumber(-1, constraints)).toBe(0);
expect(constrainNumber(0, constraints)).toBe(0);
expect(constrainNumber(1, constraints)).toBe(0);
expect(constrainNumber(2, constraints)).toBe(3);
expect(constrainNumber(3, constraints)).toBe(3);
expect(constrainNumber(4, constraints)).toBe(3);
expect(constrainNumber(7, constraints)).toBe(6);
expect(constrainNumber(8, constraints)).toBe(9);
expect(constrainNumber(9, constraints)).toBe(9);
});
it('should respect boundaries when rounding', () => {
const constraints = { min: 0, max: 10, multipleOf: 4 };
// Value at 9 would normally round to 8
expect(constrainNumber(9, constraints)).toBe(8);
// Value at 11 would normally round to 12, but max is 10
expect(constrainNumber(11, constraints)).toBe(10);
});
it('should handle decimal multiples', () => {
const constraints = { min: 0, max: 1, multipleOf: 0.25 };
expect(constrainNumber(0.3, constraints)).toBe(0.25);
expect(constrainNumber(0.87, constraints)).toBe(0.75);
expect(constrainNumber(0.88, constraints)).toBe(1.0);
expect(constrainNumber(0.13, constraints)).toBe(0.25);
});
it('should apply overrides correctly', () => {
// Override min
expect(constrainNumber(2, defaultConstraints, { min: 5 })).toBe(5);
// Override max
expect(constrainNumber(8, defaultConstraints, { max: 7 })).toBe(7);
// Override multipleOf
expect(constrainNumber(4.7, defaultConstraints, { multipleOf: 2 })).toBe(4);
// Override all
expect(constrainNumber(15, defaultConstraints, { min: 5, max: 20, multipleOf: 5 })).toBe(15);
});
it('should handle edge cases', () => {
// Value exactly at min
expect(constrainNumber(0, defaultConstraints)).toBe(0);
// Value exactly at max
expect(constrainNumber(10, defaultConstraints)).toBe(10);
// multipleOf larger than range
const narrowConstraints = { min: 5, max: 7, multipleOf: 5 };
expect(constrainNumber(6, narrowConstraints)).toBe(5);
});
});

View File

@@ -0,0 +1,33 @@
import type { PartialDeep } from 'type-fest';
type NumberConstraints = { min: number; max: number; multipleOf?: number };
/**
* Constrain a number to a range and round to the nearest multiple of a given value.
* @param v
* @param constraints
* @param overrides
* @returns
*/
export const constrainNumber = (
v: number,
constraints: NumberConstraints,
overrides?: PartialDeep<NumberConstraints>
) => {
const min = overrides?.min ?? constraints.min;
const max = overrides?.max ?? constraints.max;
const multipleOf = overrides?.multipleOf ?? constraints.multipleOf;
if (multipleOf === undefined) {
return Math.min(Math.max(v, min), max);
}
// First clamp to range
v = Math.min(Math.max(v, min), max);
// Round to nearest multiple of multipleOf
const roundedValue = Math.round(v / multipleOf) * multipleOf;
// Ensure the result is still within the range
return Math.min(Math.max(roundedValue, min), max);
};