/** * This file is part of the NocoBase (R) project. * Copyright (c) 2020-2024 NocoBase Co., Ltd. * Authors: NocoBase Team. * * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. * For more information, please refer to: https://www.nocobase.com/agreement. */ import { CaretRightOutlined, CloseOutlined, DeleteOutlined } from '@ant-design/icons'; import { createForm, Field } from '@formily/core'; import { toJS } from '@formily/reactive'; import { ISchema, observer, useField, useForm } from '@formily/react'; import { Alert, App, Button, Collapse, Dropdown, Empty, Input, Space, Tag, Tooltip, message } from 'antd'; import { cloneDeep, get, set } from 'lodash'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActionContextProvider, FormProvider, SchemaComponent, SchemaInitializerItemType, Variable, css, cx, useAPIClient, useActionContext, useCompile, usePlugin, useResourceActionContext, } from '@nocobase/client'; import { parse, str2moment } from '@nocobase/utils/client'; import WorkflowPlugin from '..'; import { AddButton } from '../AddNodeContext'; import { useFlowContext } from '../FlowContext'; import { DrawerDescription } from '../components/DrawerDescription'; import { StatusButton } from '../components/StatusButton'; import { JobStatusOptionsMap } from '../constants'; import { useGetAriaLabelOfAddButton, useWorkflowExecuted } from '../hooks'; import { lang } from '../locale'; import useStyles from '../style'; import { UseVariableOptions, VariableOption, WorkflowVariableInput } from '../variable'; export type NodeAvailableContext = { engine: WorkflowPlugin; workflow: object; upstream: object; branchIndex: number; }; type Config = Record; type Options = { label: string; value: any }[]; export abstract class Instruction { title: string; type: string; group: string; description?: string; icon?: JSX.Element; /** * @deprecated migrate to `presetFieldset` instead */ options?: { label: string; value: any; key: string }[]; fieldset: Record; /** * @experimental */ presetFieldset?: Record; /** * To presentation if the instruction is creating a branch * @experimental */ branching?: boolean | Options | ((config: Config) => boolean | Options); /** * @experimental */ view?: ISchema; scope?: Record; components?: Record; Component?(props): JSX.Element; /** * @experimental */ createDefaultConfig?(): Config { return {}; } useVariables?(node, options?: UseVariableOptions): VariableOption; useScopeVariables?(node, options?): VariableOption[]; useInitializers?(node): SchemaInitializerItemType | null; /** * @experimental */ isAvailable?(ctx: NodeAvailableContext): boolean; end?: boolean | ((node) => boolean); testable?: boolean; } function useUpdateAction() { const form = useForm(); const api = useAPIClient(); const ctx = useActionContext(); const { refresh } = useResourceActionContext(); const data = useNodeContext(); const executed = useWorkflowExecuted(); return { async run() { if (executed) { message.error(lang('Node in executed workflow cannot be modified')); return; } await form.submit(); await api.resource('flow_nodes').update?.({ filterByTk: data.id, values: { config: form.values, }, }); form.setInitialValues(toJS(form.values)); ctx.setFormValueChanged(false); ctx.setVisible(false); refresh(); }, }; } export const NodeContext = React.createContext({}); export function useNodeContext() { return useContext(NodeContext); } export function useNodeSavedConfig(keys = []) { const node = useNodeContext(); return keys.some((key) => get(node.config, key) != null); } /** * @experimental */ export function useAvailableUpstreams(node, filter?) { const stack: any[] = []; if (!node) { return []; } for (let current = node.upstream; current; current = current.upstream) { if (typeof filter !== 'function' || filter(current)) { stack.push(current); } } return stack; } /** * @experimental */ export function useUpstreamScopes(node) { const stack: any[] = []; for (let current = node; current; current = current.upstream) { if (current.upstream && current.branchIndex != null) { stack.push(current.upstream); } } return stack; } export function Node({ data }) { const { styles } = useStyles(); const { getAriaLabel } = useGetAriaLabelOfAddButton(data); const workflowPlugin = usePlugin(WorkflowPlugin); const { Component = NodeDefaultView, end } = workflowPlugin.instructions.get(data.type) ?? {}; return (
{!end || (typeof end === 'function' && !end(data)) ? ( ) : (
)}
); } export function RemoveButton() { const { t } = useTranslation(); const api = useAPIClient(); const { workflow, nodes, refresh } = useFlowContext() ?? {}; const current = useNodeContext(); const { modal } = App.useApp(); const executed = useWorkflowExecuted(); const resource = api.resource('flow_nodes'); const onOk = useCallback(async () => { await resource.destroy?.({ filterByTk: current.id, }); refresh(); }, [current.id, refresh, resource]); const onRemove = useCallback(async () => { const usingNodes = nodes.filter((node) => { if (node === current) { return false; } const template = parse(node.config); const refs = template.parameters.filter( ({ key }) => key.startsWith(`$jobsMapByNodeKey.${current.key}.`) || key === `$jobsMapByNodeKey.${current.key}`, ); return refs.length; }); if (usingNodes.length) { modal.error({ title: lang('Can not delete'), content: lang( 'The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.', { nodes: usingNodes.map((item) => item.title).join(', ') }, ), }); return; } const hasBranches = !nodes.find((item) => item.upstream === current && item.branchIndex != null); const message = hasBranches ? t('Are you sure you want to delete it?') : lang('This node contains branches, deleting will also be preformed to them, are you sure?'); modal.confirm({ title: t('Delete'), content: message, onOk, }); }, [current, modal, nodes, onOk, t]); if (!workflow) { return null; } return executed ? null : ( ); } export function NodeDefaultView(props) { const { data, children } = props; const compile = useCompile(); const api = useAPIClient(); const { workflow, refresh } = useFlowContext() ?? {}; const { styles } = useStyles(); const workflowPlugin = usePlugin(WorkflowPlugin); const executed = useWorkflowExecuted(); const instruction = workflowPlugin.instructions.get(data.type); const [editingTitle, setEditingTitle] = useState(data.title); const [editingConfig, setEditingConfig] = useState(false); const [formValueChanged, setFormValueChanged] = useState(false); const form = useMemo(() => { const values = cloneDeep(data.config); return createForm({ initialValues: values, disabled: Boolean(executed), }); }, [data, workflow]); const resetForm = useCallback( (editing) => { setEditingConfig(editing); if (!editing) { form.reset(); } }, [form], ); const onChangeTitle = useCallback( async function (next) { const title = next || compile(instruction?.title); setEditingTitle(title); if (title === data.title) { return; } await api.resource('flow_nodes').update?.({ filterByTk: data.id, values: { title, }, }); refresh(); }, [data, instruction], ); const onOpenDrawer = useCallback(function (ev) { if (ev.target === ev.currentTarget) { setEditingConfig(true); return; } const whiteSet = new Set(['workflow-node-meta', 'workflow-node-config-button', 'ant-input-disabled']); for (let el = ev.target; el && el !== ev.currentTarget && el !== document.documentElement; el = el.parentNode) { if ((Array.from(el.classList) as string[]).some((name: string) => whiteSet.has(name))) { setEditingConfig(true); ev.stopPropagation(); return; } } }, []); if (!instruction) { return (
{lang('Unknown node')} {data.id}
); } const typeTitle = compile(instruction.title); return (
{typeTitle} {data.id}
setEditingTitle(ev.target.value)} onBlur={(ev) => onChangeTitle(ev.target.value)} autoSize /> {data.title} {data.key}
), 'x-decorator': 'FormV2', 'x-use-decorator-props': 'useFormProviderProps', 'x-component': 'Action.Drawer', properties: { ...(instruction.description ? { description: { type: 'void', 'x-component': DrawerDescription, 'x-component-props': { label: lang('Node type'), title: instruction.title, icon: instruction.icon, description: instruction.description, }, }, } : {}), fieldset: { type: 'void', 'x-component': 'fieldset', 'x-component-props': { className: css` .ant-input, .ant-select, .ant-cascader-picker, .ant-picker, .ant-input-number, .ant-input-affix-wrapper { &.auto-width { width: auto; min-width: 6em; } } `, }, properties: instruction.fieldset, }, footer: executed ? null : { type: 'void', 'x-component': 'Action.Drawer.Footer', properties: { actions: { type: 'void', 'x-component': 'ActionBar', 'x-component-props': { style: { flexGrow: 1, }, }, properties: { ...(instruction.testable ? { test: { type: 'void', 'x-component': observer(TestButton), 'x-align': 'left', }, } : {}), cancel: { title: '{{t("Cancel")}}', 'x-component': 'Action', 'x-component-props': { useAction: '{{ cm.useCancelAction }}', }, }, submit: { title: '{{t("Submit")}}', 'x-component': 'Action', 'x-component-props': { type: 'primary', useAction: '{{ useUpdateAction }}', }, }, }, }, }, }, }, } as ISchema, }, }} />
{children} ); }