feat(ui): use auto-add board as default for nodes

Board fields in the workflow editor now default to using the auto-add board by default.

**This is a change in behaviour - previously, we defaulted to no board (i.e. Uncategorized).**

There is some translation needed between the UI field values for a board and what the graph expects.

A "BoardField" is an object in the shape of `{board_id: string}`.

Valid board field values in the graph:
- undefined
- a BoardField

Value UI values and their mapping to the graph values:
- 'none' -> undefined
- 'auto' -> BoardField for the auto-add board, or if the auto-add board is Uncategorized, undefined
- undefined -> undefined (this is a fallback case with the new logic)
- a BoardField -> the same BoardField
This commit is contained in:
psychedelicious
2025-02-25 07:16:42 +10:00
parent 759229e3c8
commit a626387a0b
6 changed files with 126 additions and 31 deletions

View File

@@ -1,5 +1,6 @@
import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation';
import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
@@ -17,7 +18,8 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
const state = getState();
const nodes = selectNodesSlice(state);
const workflow = state.workflow;
const graph = buildNodesGraph(nodes);
const templates = $templates.get();
const graph = buildNodesGraph(state, templates);
const builtWorkflow = buildWorkflowWithValidation({
nodes: nodes.nodes,
edges: nodes.edges,

View File

@@ -10,50 +10,99 @@ import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import type { FieldComponentProps } from './types';
/**
* The board field values in the UI do not map 1-to-1 to the values the graph expects.
*
* The graph value is either an object in the shape of `{board_id: string}` or undefined.
*
* But in the UI, we have the following options:
* - auto: Use the "auto add" board. During graph building, we pull the auto add board ID from the state and use it.
* - none: Do not assign a board. In the graph, this is represented as undefined.
* - board_id: Assign the specified board. In the graph, this is represented as `{board_id: string}`.
*
* It's also possible that the UI value is undefined, which may be the case for some older workflows. In this case, we
* map it to the "auto" option.
*
* So there is some translation that needs to happen in both directions - when the user selects a board in the UI, and
* when we build the graph. The former is handled in this component, the latter in the `buildNodesGraph` function.
*/
const listAllBoardsQueryArg = { include_archived: true };
const getBoardValue = (val: string) => {
if (val === 'auto' || val === 'none') {
return val;
}
return {
board_id: val,
};
};
const BoardFieldInputComponent = (props: FieldComponentProps<BoardFieldInputInstance, BoardFieldInputTemplate>) => {
const { nodeId, field } = props;
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { options, hasBoards } = useListAllBoardsQuery(
{ include_archived: true },
{
selectFromResult: ({ data }) => {
const options: ComboboxOption[] = [
{
label: 'None',
value: 'none',
},
].concat(
(data ?? []).map(({ board_id, board_name }) => ({
label: board_name,
value: board_id,
}))
);
return {
options,
hasBoards: options.length > 1,
};
},
const listAllBoardsQuery = useListAllBoardsQuery(listAllBoardsQueryArg);
const autoOption = useMemo<ComboboxOption>(() => {
return {
label: t('common.auto'),
value: 'auto',
};
}, [t]);
const noneOption = useMemo<ComboboxOption>(() => {
return {
label: `${t('common.none')} (${t('boards.uncategorized')})`,
value: 'none',
};
}, [t]);
const options = useMemo<ComboboxOption[]>(() => {
const _options: ComboboxOption[] = [autoOption, noneOption];
if (listAllBoardsQuery.data) {
for (const board of listAllBoardsQuery.data) {
_options.push({
label: board.board_name,
value: board.board_id,
});
}
}
);
return _options;
}, [autoOption, listAllBoardsQuery.data, noneOption]);
const onChange = useCallback<ComboboxOnChange>(
(v) => {
if (!v) {
// This should never happen
return;
}
const value = getBoardValue(v.value);
dispatch(
fieldBoardValueChanged({
nodeId,
fieldName: field.name,
value: v.value !== 'none' ? { board_id: v.value } : undefined,
value,
})
);
},
[dispatch, field.name, nodeId]
);
const value = useMemo(() => options.find((o) => o.value === field.value?.board_id), [options, field.value]);
const value = useMemo(() => {
const _value = field.value;
if (!_value || _value === 'auto') {
return autoOption;
}
if (_value === 'none') {
return noneOption;
}
const boardOption = options.find((o) => o.value === _value.board_id);
return boardOption ?? autoOption;
}, [field.value, options, autoOption, noneOption]);
const noOptionsMessage = useCallback(() => t('boards.noMatching'), [t]);
@@ -65,7 +114,6 @@ const BoardFieldInputComponent = (props: FieldComponentProps<BoardFieldInputInst
onChange={onChange}
placeholder={t('boards.selectBoard')}
noOptionsMessage={noOptionsMessage}
isDisabled={!hasBoards}
/>
);
};

View File

@@ -642,7 +642,7 @@ export const isImageFieldCollectionInputTemplate = buildTemplateTypeGuard<ImageF
// #endregion
// #region BoardField
export const zBoardFieldValue = zBoardField.optional();
export const zBoardFieldValue = z.union([zBoardField, z.enum(['none', 'auto'])]).optional();
const zBoardFieldInputInstance = zFieldInputInstanceBase.extend({
value: zBoardFieldValue,
});

View File

@@ -1,5 +1,11 @@
import { logger } from 'app/logging/logger';
import type { NodesState } from 'features/nodes/store/types';
import type { RootState } from 'app/store/store';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { Templates } from 'features/nodes/store/types';
import type { BoardField } from 'features/nodes/types/common';
import type { BoardFieldInputInstance } from 'features/nodes/types/field';
import { isBoardFieldInputInstance, isBoardFieldInputTemplate } from 'features/nodes/types/field';
import { isExecutableNode, isInvocationNode } from 'features/nodes/types/invocation';
import { omit, reduce } from 'lodash-es';
import type { AnyInvocation, Graph } from 'services/api/types';
@@ -7,11 +13,32 @@ import { v4 as uuidv4 } from 'uuid';
const log = logger('workflows');
const getBoardField = (field: BoardFieldInputInstance, state: RootState): BoardField | undefined => {
// Translate the UI value to the graph value. See note in BoardFieldInputComponent for more info.
const { value } = field;
if (value === 'auto' || !value) {
const autoAddBoardId = selectAutoAddBoardId(state);
if (autoAddBoardId === 'none') {
return undefined;
}
return {
board_id: autoAddBoardId,
};
}
if (value === 'none') {
return undefined;
}
return value;
};
/**
* Builds a graph from the node editor state.
*/
export const buildNodesGraph = (nodesState: NodesState): Graph => {
const { nodes, edges } = nodesState;
export const buildNodesGraph = (state: RootState, templates: Templates): Graph => {
const { nodes, edges } = selectNodesSlice(state);
// Exclude all batch nodes - we will handle these in the batch setup in a diff function
const filteredNodes = nodes.filter(isInvocationNode).filter(isExecutableNode);
@@ -21,11 +48,26 @@ export const buildNodesGraph = (nodesState: NodesState): Graph => {
const { id, data } = node;
const { type, inputs, isIntermediate } = data;
const nodeTemplate = templates[type];
if (!nodeTemplate) {
log.warn({ id, type }, 'Node template not found!');
return nodesAccumulator;
}
// Transform each node's inputs to simple key-value pairs
const transformedInputs = reduce(
inputs,
(inputsAccumulator, input, name) => {
inputsAccumulator[name] = input.value;
const fieldTemplate = nodeTemplate.inputs[name];
if (!fieldTemplate) {
log.warn({ id, name }, 'Field template not found!');
return inputsAccumulator;
}
if (isBoardFieldInputTemplate(fieldTemplate) && isBoardFieldInputInstance(input)) {
inputsAccumulator[name] = getBoardField(input, state);
} else {
inputsAccumulator[name] = input.value;
}
return inputsAccumulator;
},

View File

@@ -533,7 +533,7 @@ const buildBoardFieldInputTemplate: FieldInputTemplateBuilder<BoardFieldInputTem
const template: BoardFieldInputTemplate = {
...baseField,
type: fieldType,
default: schemaObject.default ?? undefined,
default: schemaObject.default ?? 'auto',
};
return template;

View File

@@ -135,6 +135,9 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise<Vali
}
}
if (fieldTemplate.type.name === 'BoardField' && input.value && isBoardFieldInputInstance(input)) {
if (input.value === 'none' || input.value === 'auto') {
continue;
}
const hasAccess = await checkBoardAccess(input.value.board_id);
if (!hasAccess) {
const message = t('nodes.boardAccessError', { board_id: input.value.board_id });