feat: bootstrap Ordo process designer with PARALLEL swimlanes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+39
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# ordo-designer-demo
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# Ordo Designer Roadmap
|
||||
|
||||
## P0 能用
|
||||
|
||||
- 分支边错开(同 from 多出边不再叠线)
|
||||
- 加载后分层自动排布,分支横向错开
|
||||
- 节点卡片展示候选人 / policy / action / due
|
||||
- 校验错误挂到节点和边上,不只 toast 第一条
|
||||
- 草稿 vs 已发布:可丢弃本地、对比后再打开
|
||||
- 切定义不清空 Pinia,避免闪旧图
|
||||
- 脏标记 + 离开未保存提示
|
||||
|
||||
## P1 好用
|
||||
|
||||
- 撤销 / 重做
|
||||
- 框选、多选删除、复制粘贴
|
||||
- due.after 用时长控件,禁止手填乱 ISO
|
||||
- 改步骤 id:失焦提交 + 冲突提示
|
||||
- 发布结果区分「新版本」和「图未变」
|
||||
- catalog 失败时的空态与可手输
|
||||
- 空画布引导;结束节点唯一性提示
|
||||
- 历史版本只读或「另存为草稿」
|
||||
- 删除确认(有出边时)
|
||||
|
||||
## P2 打磨
|
||||
|
||||
- 左右 Handle、回边、网格吸附、对齐
|
||||
- @vue-flow/minimap、快捷键说明
|
||||
- 引擎 JSON 预览 / 导出
|
||||
- 列表里同一 id 的草稿与已发布关系
|
||||
- 统一 loading / 网络错误
|
||||
- 属性面板 n-form 校验态
|
||||
- 自动保存草稿
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ordo Designer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "ordo-designer-demo",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.46.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"naive-ui": "^2.45.3",
|
||||
"pinia": "^4.0.2",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vue-tsc": "^3.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
}
|
||||
}
|
||||
Generated
+2625
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
vue-demi: false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
+11
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<n-config-provider>
|
||||
<n-dialog-provider>
|
||||
<n-message-provider>
|
||||
<router-view />
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CatalogItem, PageResponse, ProcessDefinitionDocument } from '@/types/definition'
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const body = (await res.json()) as { message?: string }
|
||||
if (body.message) {
|
||||
message = body.message
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
if (res.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export function parseDefinition(doc: ProcessDefinitionDocument) {
|
||||
return request<ProcessDefinitionDocument>('/ordo/definitions/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(doc),
|
||||
})
|
||||
}
|
||||
|
||||
export function publishDefinition(doc: ProcessDefinitionDocument) {
|
||||
return request<ProcessDefinitionDocument>('/ordo/definitions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(doc),
|
||||
})
|
||||
}
|
||||
|
||||
export function listDefinitions(page = 0, size = 50) {
|
||||
return request<PageResponse<ProcessDefinitionDocument>>(
|
||||
`/ordo/definitions?page=${page}&size=${size}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function getDefinition(id: string) {
|
||||
return request<ProcessDefinitionDocument>(`/ordo/definitions/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export function listDefinitionVersions(id: string, page = 0, size = 50) {
|
||||
return request<PageResponse<ProcessDefinitionDocument>>(
|
||||
`/ordo/definitions/${encodeURIComponent(id)}/versions?page=${page}&size=${size}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function getDefinitionVersion(id: string, version: number) {
|
||||
return request<ProcessDefinitionDocument>(
|
||||
`/ordo/definitions/${encodeURIComponent(id)}/versions/${version}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function listConditions() {
|
||||
return request<CatalogItem[]>('/ordo/catalog/conditions')
|
||||
}
|
||||
|
||||
export function listActions() {
|
||||
return request<CatalogItem[]>('/ordo/catalog/actions')
|
||||
}
|
||||
|
||||
export function listAssignees() {
|
||||
return request<CatalogItem[]>('/ordo/catalog/assignees')
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CatalogItem } from '@/types/definition'
|
||||
import PredicateBuilder from '@/components/designer/PredicateBuilder.vue'
|
||||
import {
|
||||
collapseTrivialGroups,
|
||||
defaultLeaf,
|
||||
formatWhen,
|
||||
isRefWhen,
|
||||
validatePredicate,
|
||||
type RoutingWhen,
|
||||
} from '@/mapper/when'
|
||||
|
||||
const props = defineProps<{
|
||||
when?: RoutingWhen
|
||||
conditions: CatalogItem[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:when': [value: RoutingWhen | undefined]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const editorKind = ref<'predicate' | 'ref'>('predicate')
|
||||
const draft = ref<RoutingWhen>({ and: [defaultLeaf()] })
|
||||
const error = ref('')
|
||||
|
||||
const mode = ref<'none' | 'predicate' | 'ref'>(
|
||||
!props.when ? 'none' : isRefWhen(props.when) ? 'ref' : 'predicate',
|
||||
)
|
||||
|
||||
const preview = computed(() => formatWhen(props.when))
|
||||
const draftPreview = computed(() => formatWhen(draft.value))
|
||||
const modalTitle = computed(() => (editorKind.value === 'ref' ? 'Edit host condition' : 'Edit predicate'))
|
||||
|
||||
const conditionOptions = computed(() =>
|
||||
props.conditions.map((i) => ({ label: `${i.label} (${i.key})`, value: i.key })),
|
||||
)
|
||||
|
||||
const draftRef = computed(() => (isRefWhen(draft.value) ? draft.value.ref : null))
|
||||
const draftItem = computed(() => props.conditions.find((c) => c.key === draftRef.value))
|
||||
|
||||
function setMode(next: 'none' | 'predicate' | 'ref') {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
mode.value = next
|
||||
if (next === 'none') {
|
||||
emit('update:when', undefined)
|
||||
return
|
||||
}
|
||||
if (next === 'ref') {
|
||||
if (isRefWhen(props.when)) {
|
||||
return
|
||||
}
|
||||
const first = props.conditions[0]
|
||||
emit('update:when', first ? { ref: first.key, args: emptyArgs(first) } : undefined)
|
||||
return
|
||||
}
|
||||
if (props.when && !isRefWhen(props.when)) {
|
||||
return
|
||||
}
|
||||
emit('update:when', undefined)
|
||||
}
|
||||
|
||||
function emptyArgs(item: CatalogItem): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = {}
|
||||
for (const param of item.params ?? []) {
|
||||
args[param.name] = param.type === 'boolean' ? false : param.type === 'number' ? 0 : ''
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
function defaultRef(): RoutingWhen {
|
||||
const first = props.conditions[0]
|
||||
return first ? { ref: first.key, args: emptyArgs(first) } : { ref: '', args: {} }
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
if (mode.value === 'ref') {
|
||||
editorKind.value = 'ref'
|
||||
draft.value = isRefWhen(props.when) ? cloneJson(props.when) : defaultRef()
|
||||
} else {
|
||||
editorKind.value = 'predicate'
|
||||
draft.value = editableCopy(props.when)
|
||||
}
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function editableCopy(when: RoutingWhen | undefined): RoutingWhen {
|
||||
if (!when || isRefWhen(when)) {
|
||||
return { and: [defaultLeaf()] }
|
||||
}
|
||||
const copy = cloneJson(when)
|
||||
if ('and' in copy || 'or' in copy || 'not' in copy) {
|
||||
return copy
|
||||
}
|
||||
return { and: [copy] }
|
||||
}
|
||||
|
||||
function onDraftRefKey(key: string | null) {
|
||||
if (!key) {
|
||||
draft.value = { ref: '', args: {} }
|
||||
return
|
||||
}
|
||||
const item = props.conditions.find((c) => c.key === key)
|
||||
draft.value = { ref: key, args: item ? emptyArgs(item) : {} }
|
||||
}
|
||||
|
||||
function patchDraftArg(name: string, type: string, raw: unknown) {
|
||||
if (!isRefWhen(draft.value)) {
|
||||
return
|
||||
}
|
||||
let value: unknown = raw
|
||||
if (type === 'number') {
|
||||
value = typeof raw === 'number' ? raw : Number(raw)
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
value = Boolean(raw)
|
||||
}
|
||||
draft.value = { ref: draft.value.ref, args: { ...(draft.value.args ?? {}), [name]: value } }
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (editorKind.value === 'ref') {
|
||||
if (!isRefWhen(draft.value) || !draft.value.ref.trim()) {
|
||||
error.value = 'Select a host condition'
|
||||
return
|
||||
}
|
||||
emit('update:when', draft.value)
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
const normalized = collapseTrivialGroups(draft.value)
|
||||
const message = validatePredicate(normalized)
|
||||
if (message) {
|
||||
error.value = message
|
||||
return
|
||||
}
|
||||
emit('update:when', normalized)
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<n-radio-group :value="mode" :disabled="disabled" size="small" @update:value="setMode">
|
||||
<n-radio-button value="none">None</n-radio-button>
|
||||
<n-radio-button value="predicate">Predicate</n-radio-button>
|
||||
<n-radio-button value="ref">Host</n-radio-button>
|
||||
</n-radio-group>
|
||||
|
||||
<template v-if="mode === 'predicate' || mode === 'ref'">
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md border border-slate-200 bg-slate-50 px-2 py-2 text-left text-xs leading-5 text-slate-700 hover:border-sky-300 hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="disabled"
|
||||
@click="openEditor"
|
||||
>
|
||||
{{ preview || (mode === 'ref' ? 'No host condition' : 'No predicate') }}
|
||||
</button>
|
||||
<n-button size="small" secondary :disabled="disabled" @click="openEditor">Edit</n-button>
|
||||
</template>
|
||||
|
||||
<n-modal v-model:show="open" :mask-closable="false">
|
||||
<n-card :title="modalTitle" style="width: 720px" closable @close="open = false">
|
||||
<n-alert type="info" :bordered="false" class="mb-3">
|
||||
<template v-if="editorKind === 'ref'">
|
||||
Host conditions are resolved by the application. Arguments are passed to the selected ref.
|
||||
</template>
|
||||
<template v-else>
|
||||
Variables come from the process context, dotted paths included.
|
||||
AND requires every child, OR any child, NOT inverts. IN is comma-separated.
|
||||
A missing variable is false. Conditional edges are matched before the default edge.
|
||||
</template>
|
||||
</n-alert>
|
||||
<div class="max-h-[60vh] overflow-auto pr-1">
|
||||
<template v-if="editorKind === 'ref'">
|
||||
<n-form-item label="condition" :show-feedback="false">
|
||||
<n-select
|
||||
filterable
|
||||
tag
|
||||
:disabled="disabled"
|
||||
:value="draftRef"
|
||||
:options="conditionOptions"
|
||||
@update:value="onDraftRefKey"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
v-for="param in draftItem?.params ?? []"
|
||||
:key="param.name"
|
||||
:label="param.label || param.name"
|
||||
:show-feedback="false"
|
||||
>
|
||||
<n-input-number
|
||||
v-if="param.type === 'number'"
|
||||
:disabled="disabled"
|
||||
:value="Number(isRefWhen(draft) ? (draft.args?.[param.name] ?? 0) : 0)"
|
||||
@update:value="(v: number) => patchDraftArg(param.name, param.type, v ?? 0)"
|
||||
/>
|
||||
<n-switch
|
||||
v-else-if="param.type === 'boolean'"
|
||||
:disabled="disabled"
|
||||
:value="Boolean(isRefWhen(draft) ? draft.args?.[param.name] : false)"
|
||||
@update:value="(v: boolean) => patchDraftArg(param.name, param.type, v)"
|
||||
/>
|
||||
<n-input
|
||||
v-else
|
||||
:disabled="disabled"
|
||||
:value="String(isRefWhen(draft) ? (draft.args?.[param.name] ?? '') : '')"
|
||||
@update:value="(v: string) => patchDraftArg(param.name, param.type, v)"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
<PredicateBuilder v-else :node="draft" :disabled="disabled" @update:node="(value) => (draft = value)" />
|
||||
</div>
|
||||
<p v-if="draftPreview" class="mt-3 text-xs leading-5 text-slate-500">Preview: {{ draftPreview }}</p>
|
||||
<p v-if="error" class="mt-2 text-xs text-red-600">{{ error }}</p>
|
||||
<n-space justify="end" class="mt-4">
|
||||
<n-button @click="open = false">Cancel</n-button>
|
||||
<n-button type="primary" @click="confirm">OK</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const show = defineModel<boolean>('show', { default: false })
|
||||
defineProps<{
|
||||
draftText: string
|
||||
publishedText: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
openDraft: []
|
||||
discard: []
|
||||
}>()
|
||||
|
||||
const comparing = ref(false)
|
||||
|
||||
watch(show, (value) => {
|
||||
if (!value) {
|
||||
comparing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="show">
|
||||
<n-card title="Local draft vs published" style="width: 720px" closable @close="show = false">
|
||||
<p class="mb-3 text-sm text-slate-600">This process has both a local draft and a published version.</p>
|
||||
<div v-if="comparing" class="mb-3 grid max-h-90 grid-cols-2 gap-3 overflow-auto">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-slate-500">Local draft</div>
|
||||
<pre class="overflow-auto rounded bg-slate-50 p-2 text-[11px] leading-4">{{ draftText }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-slate-500">Published</div>
|
||||
<pre class="overflow-auto rounded bg-slate-50 p-2 text-[11px] leading-4">{{ publishedText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<n-space justify="end">
|
||||
<n-button @click="comparing = !comparing">{{ comparing ? 'Hide diff' : 'Compare' }}</n-button>
|
||||
<n-button @click="emit('openDraft')">Open draft</n-button>
|
||||
<n-button type="warning" @click="emit('discard')">Discard local & open published</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { parseIsoDuration, toIsoDuration, type DurationUnit } from '@/mapper/duration'
|
||||
|
||||
const props = defineProps<{
|
||||
iso: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:iso': [value: string]
|
||||
}>()
|
||||
|
||||
const parsed = computed(() => parseIsoDuration(props.iso))
|
||||
|
||||
const unitOptions = [
|
||||
{ label: 'minutes', value: 'minutes' },
|
||||
{ label: 'hours', value: 'hours' },
|
||||
{ label: 'days', value: 'days' },
|
||||
]
|
||||
|
||||
function emitIso(value: number, unit: DurationUnit) {
|
||||
emit('update:iso', toIsoDuration(value, unit))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<n-input-number
|
||||
:style="{ width: '100%' }"
|
||||
:min="1"
|
||||
:disabled="disabled"
|
||||
:value="parsed.value"
|
||||
@update:value="(v: number | null) => emitIso(v ?? 1, parsed.unit)"
|
||||
/>
|
||||
<n-select
|
||||
:style="{ width: '100%' }"
|
||||
:disabled="disabled"
|
||||
:value="parsed.unit"
|
||||
:options="unitOptions"
|
||||
@update:value="(v: DurationUnit) => emitIso(parsed.value, v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
|
||||
const designer = useDesignerStore()
|
||||
|
||||
function jump(nodeId?: string, edgeId?: string) {
|
||||
if (nodeId) {
|
||||
designer.selectNode(nodeId)
|
||||
return
|
||||
}
|
||||
if (edgeId) {
|
||||
designer.selectEdge(edgeId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="designer.graphErrors.length" class="border-b border-red-100 bg-red-50 px-4 py-2">
|
||||
<div class="text-xs font-medium text-red-700">Validation errors {{ designer.graphErrors.length }}</div>
|
||||
<button
|
||||
v-for="(err, index) in designer.graphErrors"
|
||||
:key="`${err.message}-${index}`"
|
||||
class="block w-full truncate text-left text-xs text-red-600 hover:underline"
|
||||
type="button"
|
||||
@click="jump(err.nodeId, err.edgeId)"
|
||||
>
|
||||
{{ err.message }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { Bolt, Flag, GitBranch, ShieldCheck } from '@lucide/vue'
|
||||
import { ACTION_TYPE, APPROVAL_TYPE, END_TYPE, PARALLEL_TYPE } from '@/mapper/graph'
|
||||
|
||||
const items = [
|
||||
{
|
||||
type: APPROVAL_TYPE,
|
||||
label: 'Approval',
|
||||
hint: 'Human review step',
|
||||
accent: 'bg-sky-500',
|
||||
badge: 'bg-sky-50 text-sky-700',
|
||||
border: 'border-slate-200 hover:border-sky-300',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
type: ACTION_TYPE,
|
||||
label: 'Action',
|
||||
hint: 'System action step',
|
||||
accent: 'bg-amber-500',
|
||||
badge: 'bg-amber-50 text-amber-700',
|
||||
border: 'border-slate-200 hover:border-amber-300',
|
||||
icon: Bolt,
|
||||
},
|
||||
{
|
||||
type: PARALLEL_TYPE,
|
||||
label: 'Parallel',
|
||||
hint: 'Join all branches',
|
||||
accent: 'bg-violet-500',
|
||||
badge: 'bg-violet-50 text-violet-700',
|
||||
border: 'border-slate-200 hover:border-violet-300',
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
type: END_TYPE,
|
||||
label: 'End',
|
||||
hint: 'Process terminal',
|
||||
accent: 'bg-slate-500',
|
||||
badge: 'bg-slate-100 text-slate-600',
|
||||
border: 'border-slate-200 hover:border-slate-400',
|
||||
icon: Flag,
|
||||
},
|
||||
]
|
||||
|
||||
function onDragStart(event: DragEvent, type: string) {
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
event.dataTransfer.setData('application/vueflow', type)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex h-full w-56 shrink-0 flex-col gap-3 border-r border-slate-200 bg-white p-3">
|
||||
<div class="text-xs font-semibold tracking-wide text-slate-500 uppercase">Nodes</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.type"
|
||||
class="group relative cursor-grab overflow-hidden rounded-xl border bg-white shadow-[0_1px_2px_rgba(15,23,42,0.05)] transition-all hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)] active:cursor-grabbing active:scale-[0.98]"
|
||||
:class="item.border"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, item.type)"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 w-1" :class="item.accent" />
|
||||
<div class="flex items-start gap-2.5 px-3 py-2.5 pl-3.5">
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
:class="item.badge"
|
||||
>
|
||||
<component :is="item.icon" :size="14" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-semibold text-slate-800">{{ item.label }}</div>
|
||||
<div class="mt-0.5 text-[11px] text-slate-500">{{ item.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-auto text-[10px] leading-relaxed text-slate-400">Drag a node onto the canvas</p>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
MAX_PREDICATE_DEPTH,
|
||||
MAX_PREDICATE_LEAVES,
|
||||
countLeaves,
|
||||
defaultLeaf,
|
||||
groupChildren,
|
||||
isLogicGroup,
|
||||
isNotWhen,
|
||||
isRefWhen,
|
||||
leafOp,
|
||||
leafValueText,
|
||||
leafVar,
|
||||
makeLeaf,
|
||||
type CompareOp,
|
||||
type RoutingWhen,
|
||||
} from '@/mapper/when'
|
||||
|
||||
const props = defineProps<{
|
||||
node: RoutingWhen
|
||||
depth?: number
|
||||
removable?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:node': [value: RoutingWhen]
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const depth = computed(() => props.depth ?? 1)
|
||||
const atLeafLimit = computed(() => countLeaves(props.node) >= MAX_PREDICATE_LEAVES)
|
||||
const atDepthLimit = computed(() => depth.value >= MAX_PREDICATE_DEPTH)
|
||||
|
||||
const opOptions = [
|
||||
{ label: '=', value: 'eq' },
|
||||
{ label: '≠', value: 'ne' },
|
||||
{ label: '>', value: 'gt' },
|
||||
{ label: '≥', value: 'gte' },
|
||||
{ label: '<', value: 'lt' },
|
||||
{ label: '≤', value: 'lte' },
|
||||
{ label: 'in', value: 'in' },
|
||||
]
|
||||
|
||||
const groupOp = computed(() => ('or' in props.node ? 'or' : 'and'))
|
||||
const children = computed(() => (isLogicGroup(props.node) ? groupChildren(props.node) : []))
|
||||
|
||||
function setGroupOp(op: 'and' | 'or') {
|
||||
emit('update:node', op === 'and' ? { and: children.value } : { or: children.value })
|
||||
}
|
||||
|
||||
function replaceChild(index: number, child: RoutingWhen) {
|
||||
const next = [...children.value]
|
||||
next[index] = child
|
||||
setChildren(next)
|
||||
}
|
||||
|
||||
function setChildren(next: RoutingWhen[]) {
|
||||
emit('update:node', groupOp.value === 'and' ? { and: next } : { or: next })
|
||||
}
|
||||
|
||||
function addLeaf() {
|
||||
setChildren([...children.value, defaultLeaf()])
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
setChildren([...children.value, { and: [defaultLeaf()] }])
|
||||
}
|
||||
|
||||
function removeChild(index: number) {
|
||||
setChildren(children.value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function patchLeaf(patch: { op?: CompareOp; variable?: string; value?: string }) {
|
||||
emit(
|
||||
'update:node',
|
||||
makeLeaf(patch.op ?? leafOp(props.node), patch.variable ?? leafVar(props.node), patch.value ?? leafValueText(props.node)),
|
||||
)
|
||||
}
|
||||
|
||||
function wrapNot() {
|
||||
emit('update:node', { not: props.node })
|
||||
}
|
||||
|
||||
function unwrapNot() {
|
||||
if (isNotWhen(props.node)) {
|
||||
emit('update:node', props.node.not)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isLogicGroup(node)"
|
||||
class="rounded-lg border bg-white p-3"
|
||||
:class="groupOp === 'and' ? 'border-sky-200' : 'border-amber-200'"
|
||||
>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-slate-500">Group</span>
|
||||
<n-radio-group :value="groupOp" size="small" :disabled="disabled" @update:value="setGroupOp">
|
||||
<n-radio-button value="and">AND</n-radio-button>
|
||||
<n-radio-button value="or">OR</n-radio-button>
|
||||
</n-radio-group>
|
||||
</div>
|
||||
<n-button v-if="removable" size="tiny" quaternary type="error" :disabled="disabled" @click="emit('remove')">
|
||||
Remove group
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-l-2 pl-3" :class="groupOp === 'and' ? 'border-sky-300' : 'border-amber-300'">
|
||||
<p v-if="children.length === 0" class="text-xs text-slate-400">No conditions yet.</p>
|
||||
<PredicateBuilder
|
||||
v-for="(child, index) in children"
|
||||
:key="index"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
removable
|
||||
:disabled="disabled"
|
||||
@update:node="(value) => replaceChild(index, value)"
|
||||
@remove="removeChild(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<n-button size="small" secondary :disabled="disabled || atLeafLimit" @click="addLeaf">
|
||||
Add condition
|
||||
</n-button>
|
||||
<n-button size="small" secondary :disabled="disabled || atDepthLimit || atLeafLimit" @click="addGroup">
|
||||
Add group
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isNotWhen(node)" class="rounded-lg border border-dashed border-rose-300 bg-rose-50/70 p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-rose-700">NOT</span>
|
||||
<n-button size="tiny" quaternary :disabled="disabled" @click="unwrapNot">Remove NOT</n-button>
|
||||
</div>
|
||||
<PredicateBuilder
|
||||
:node="node.not"
|
||||
:depth="depth + 1"
|
||||
:disabled="disabled"
|
||||
@update:node="(value) => emit('update:node', { not: value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isRefWhen(node)" class="flex flex-wrap items-center gap-2 rounded-md bg-slate-50 px-2 py-2 ring-1 ring-slate-200">
|
||||
<n-input
|
||||
class="min-w-36 flex-1"
|
||||
size="small"
|
||||
placeholder="variable"
|
||||
:disabled="disabled"
|
||||
:value="leafVar(node)"
|
||||
@update:value="(v: string) => patchLeaf({ variable: v })"
|
||||
/>
|
||||
<n-select
|
||||
class="w-36"
|
||||
size="small"
|
||||
:disabled="disabled"
|
||||
:value="leafOp(node)"
|
||||
:options="opOptions"
|
||||
@update:value="(v: CompareOp) => patchLeaf({ op: v })"
|
||||
/>
|
||||
<n-input
|
||||
class="min-w-36 flex-1"
|
||||
size="small"
|
||||
:placeholder="leafOp(node) === 'in' ? 'comma-separated' : 'value'"
|
||||
:disabled="disabled"
|
||||
:value="leafValueText(node)"
|
||||
@update:value="(v: string) => patchLeaf({ value: v })"
|
||||
/>
|
||||
<n-button size="small" quaternary :disabled="disabled || atDepthLimit" @click="wrapNot">NOT</n-button>
|
||||
<n-button v-if="removable" size="small" quaternary type="error" :disabled="disabled" @click="emit('remove')">Remove</n-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { FormRules } from 'naive-ui'
|
||||
import { useDialog } from 'naive-ui'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
import type { ActionNodeData, ApprovalNodeData, ParallelNodeData, ApprovalPolicy, DueThen } from '@/types/definition'
|
||||
import { END_TYPE } from '@/mapper/graph'
|
||||
import DurationAfter from '@/components/designer/DurationAfter.vue'
|
||||
import ConditionWhenEditor from '@/components/designer/ConditionWhenEditor.vue'
|
||||
import type { RoutingWhen } from '@/mapper/when'
|
||||
|
||||
const props = defineProps<{
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const designer = useDesignerStore()
|
||||
const catalog = useCatalogStore()
|
||||
const dialog = useDialog()
|
||||
const { definitionId, name, selectedNode, selectedEdge } = storeToRefs(designer)
|
||||
|
||||
const draftId = ref('')
|
||||
const idError = ref('')
|
||||
|
||||
watch(
|
||||
() => selectedNode.value?.id,
|
||||
(id) => {
|
||||
draftId.value = id ?? ''
|
||||
idError.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const actionOptions = computed(() =>
|
||||
catalog.actions.map((i) => ({ label: `${i.label} (${i.key})`, value: i.key })),
|
||||
)
|
||||
const assigneeOptions = computed(() =>
|
||||
catalog.assignees.map((i) => ({ label: `${i.label} (${i.key})`, value: i.key })),
|
||||
)
|
||||
|
||||
const policyOptions = [
|
||||
{ label: 'ANY', value: 'ANY' },
|
||||
{ label: 'ALL', value: 'ALL' },
|
||||
]
|
||||
const thenOptions = [
|
||||
{ label: 'reassign', value: 'reassign' },
|
||||
{ label: 'notify', value: 'notify' },
|
||||
{ label: 'goto', value: 'goto' },
|
||||
]
|
||||
|
||||
const approval = computed(() =>
|
||||
selectedNode.value?.data.kind === 'APPROVAL' ? (selectedNode.value.data as ApprovalNodeData) : null,
|
||||
)
|
||||
const action = computed(() =>
|
||||
selectedNode.value?.data.kind === 'ACTION' ? (selectedNode.value.data as ActionNodeData) : null,
|
||||
)
|
||||
const parallel = computed(() =>
|
||||
selectedNode.value?.data.kind === 'PARALLEL' ? (selectedNode.value.data as ParallelNodeData) : null,
|
||||
)
|
||||
const isStart = computed(() =>
|
||||
Boolean(selectedNode.value && selectedNode.value.data.kind !== 'END' && selectedNode.value.data.isStart),
|
||||
)
|
||||
|
||||
const processModel = computed(() => ({
|
||||
id: definitionId.value,
|
||||
name: name.value,
|
||||
}))
|
||||
|
||||
const processRules: FormRules = {
|
||||
id: { required: true, message: 'id is required', trigger: ['input', 'blur'] },
|
||||
name: { required: true, message: 'name is required', trigger: ['input', 'blur'] },
|
||||
}
|
||||
|
||||
const stepRules = computed<FormRules>(() => ({
|
||||
candidates: {
|
||||
trigger: ['change', 'blur'],
|
||||
validator() {
|
||||
if (!approval.value) {
|
||||
return true
|
||||
}
|
||||
if (approval.value.candidates.length < 1) {
|
||||
return new Error('Select at least one candidate')
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
after: {
|
||||
trigger: ['change', 'blur'],
|
||||
validator() {
|
||||
if (!approval.value?.dueEnabled) {
|
||||
return true
|
||||
}
|
||||
if (!approval.value.due.after?.trim()) {
|
||||
return new Error('after is required when due is on')
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
action: {
|
||||
trigger: ['change', 'blur'],
|
||||
validator() {
|
||||
if (!action.value) {
|
||||
return true
|
||||
}
|
||||
if (!action.value.action?.trim()) {
|
||||
return new Error('action is required')
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function commitId() {
|
||||
const node = selectedNode.value
|
||||
if (!node || props.readOnly) {
|
||||
return
|
||||
}
|
||||
const trimmed = draftId.value.trim()
|
||||
if (!trimmed || trimmed === node.id) {
|
||||
draftId.value = node.id
|
||||
idError.value = ''
|
||||
return
|
||||
}
|
||||
if (!designer.renameNode(node.id, trimmed)) {
|
||||
idError.value = 'Step id already exists'
|
||||
draftId.value = node.id
|
||||
return
|
||||
}
|
||||
idError.value = ''
|
||||
}
|
||||
|
||||
function onStartChange(checked: boolean) {
|
||||
const node = selectedNode.value
|
||||
if (checked && node && !props.readOnly) {
|
||||
designer.setStart(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
function patchSelected(patch: Record<string, unknown>) {
|
||||
const node = selectedNode.value
|
||||
if (!node || props.readOnly) {
|
||||
return
|
||||
}
|
||||
designer.patchNodeData(node.id, patch)
|
||||
}
|
||||
|
||||
function patchDue(patch: Partial<ApprovalNodeData['due']>) {
|
||||
const current = approval.value
|
||||
if (!current || props.readOnly) {
|
||||
return
|
||||
}
|
||||
patchSelected({ due: { ...current.due, ...patch } })
|
||||
}
|
||||
|
||||
function patchSelectedEdge(patch: { when?: RoutingWhen; priority?: number }) {
|
||||
const edge = selectedEdge.value
|
||||
if (!edge || props.readOnly) {
|
||||
return
|
||||
}
|
||||
designer.patchEdgeData(edge.id, patch)
|
||||
}
|
||||
|
||||
async function confirmOutgoing(ids: string[]): Promise<boolean> {
|
||||
if (!designer.hasOutgoingAmong(ids)) {
|
||||
return true
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
dialog.warning({
|
||||
title: 'Delete node',
|
||||
content: 'This node has outgoing edges. Delete it and connected edges?',
|
||||
positiveText: 'Delete',
|
||||
negativeText: 'Cancel',
|
||||
onPositiveClick: () => resolve(true),
|
||||
onNegativeClick: () => resolve(false),
|
||||
onClose: () => resolve(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteSelectedNode() {
|
||||
const node = selectedNode.value
|
||||
if (!node || props.readOnly) {
|
||||
return
|
||||
}
|
||||
const ok = await confirmOutgoing([node.id])
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
designer.removeNode(node.id)
|
||||
}
|
||||
|
||||
function deleteSelectedEdge() {
|
||||
const edge = selectedEdge.value
|
||||
if (!edge || props.readOnly) {
|
||||
return
|
||||
}
|
||||
designer.removeEdge(edge.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="h-full w-80 shrink-0 overflow-y-auto border-l border-slate-200 bg-white p-3">
|
||||
<n-alert v-if="catalog.error" class="mb-3" type="warning" :bordered="false">
|
||||
Catalog unavailable, type keys manually
|
||||
</n-alert>
|
||||
<n-alert v-if="readOnly" class="mb-3" type="info" :bordered="false">Read-only historical version</n-alert>
|
||||
|
||||
<div v-if="selectedNode && selectedNode.type === END_TYPE" class="flex flex-col gap-3">
|
||||
<div class="text-xs font-semibold text-slate-500">End</div>
|
||||
<n-button type="error" secondary :disabled="readOnly" @click="deleteSelectedNode">Delete node</n-button>
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
v-else-if="selectedNode"
|
||||
:model="selectedNode.data"
|
||||
:rules="stepRules"
|
||||
:disabled="readOnly"
|
||||
:show-feedback="true"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-semibold text-slate-500">Step</div>
|
||||
<n-button size="tiny" type="error" quaternary :disabled="readOnly" @click="deleteSelectedNode">Delete</n-button>
|
||||
</div>
|
||||
<n-form-item
|
||||
label="id"
|
||||
:validation-status="idError ? 'error' : undefined"
|
||||
:feedback="idError"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="draftId"
|
||||
:disabled="readOnly"
|
||||
@blur="commitId"
|
||||
@keydown.enter.prevent="commitId"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="name">
|
||||
<n-input
|
||||
:disabled="readOnly"
|
||||
:value="selectedNode.data.name"
|
||||
@update:value="(v: string) => patchSelected({ name: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="start step">
|
||||
<n-switch :disabled="readOnly" :value="isStart" @update:value="onStartChange" />
|
||||
</n-form-item>
|
||||
|
||||
<template v-if="approval">
|
||||
<n-form-item label="policy">
|
||||
<n-select
|
||||
:disabled="readOnly"
|
||||
:value="approval.policy"
|
||||
:options="policyOptions"
|
||||
@update:value="(v: ApprovalPolicy) => patchSelected({ policy: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="candidates" path="candidates">
|
||||
<n-select
|
||||
multiple
|
||||
filterable
|
||||
tag
|
||||
:disabled="readOnly"
|
||||
:value="approval.candidates"
|
||||
:options="assigneeOptions"
|
||||
@update:value="(v: string[]) => patchSelected({ candidates: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="due">
|
||||
<n-switch
|
||||
:disabled="readOnly"
|
||||
:value="approval.dueEnabled"
|
||||
@update:value="(v: boolean) => patchSelected({ dueEnabled: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<template v-if="approval.dueEnabled">
|
||||
<n-form-item label="after" path="after">
|
||||
<DurationAfter :iso="approval.due.after" :disabled="readOnly" @update:iso="(v) => patchDue({ after: v })" />
|
||||
</n-form-item>
|
||||
<n-form-item label="then">
|
||||
<n-select
|
||||
:disabled="readOnly"
|
||||
:value="approval.due.then"
|
||||
:options="thenOptions"
|
||||
@update:value="(v: DueThen) => patchDue({ then: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="approval.due.then === 'reassign'" label="to">
|
||||
<n-select
|
||||
filterable
|
||||
tag
|
||||
:disabled="readOnly"
|
||||
:value="approval.due.to || null"
|
||||
:options="assigneeOptions"
|
||||
@update:value="(v: string | null) => patchDue({ to: v ?? undefined })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="approval.due.then === 'goto'" label="to">
|
||||
<n-input :disabled="readOnly" :value="approval.due.to ?? ''" @update:value="(v: string) => patchDue({ to: v })" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="approval.due.then === 'notify'" label="action">
|
||||
<n-select
|
||||
filterable
|
||||
tag
|
||||
clearable
|
||||
:disabled="readOnly"
|
||||
:value="approval.due.action ?? null"
|
||||
:options="actionOptions"
|
||||
@update:value="(v: string | null) => patchDue({ action: v ?? undefined })"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="action">
|
||||
<n-form-item label="action" path="action">
|
||||
<n-select
|
||||
filterable
|
||||
tag
|
||||
:disabled="readOnly"
|
||||
:value="action.action || null"
|
||||
:options="actionOptions"
|
||||
@update:value="(v: string) => patchSelected({ action: v ?? '' })"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="parallel && selectedNode">
|
||||
<n-form-item label="branches">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div v-for="lane in parallel.lanes" :key="lane" class="flex items-center gap-2">
|
||||
<n-input
|
||||
:disabled="readOnly"
|
||||
:value="lane"
|
||||
@change="(v: string) => designer.renameLane(selectedNode!.id, lane, v)"
|
||||
/>
|
||||
<n-button
|
||||
size="small"
|
||||
quaternary
|
||||
type="error"
|
||||
:disabled="readOnly || parallel.lanes.length <= 2"
|
||||
@click="designer.removeLane(selectedNode.id, lane)"
|
||||
>
|
||||
Remove
|
||||
</n-button>
|
||||
</div>
|
||||
<n-button size="small" :disabled="readOnly" @click="designer.addLane(selectedNode.id)">Add branch</n-button>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</template>
|
||||
</n-form>
|
||||
|
||||
<div v-else-if="selectedEdge" class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-semibold text-slate-500">Edge</div>
|
||||
<n-button size="tiny" type="error" quaternary :disabled="readOnly" @click="deleteSelectedEdge">Delete</n-button>
|
||||
</div>
|
||||
<n-form :show-feedback="true" :disabled="readOnly">
|
||||
<n-form-item label="when">
|
||||
<ConditionWhenEditor
|
||||
:key="selectedEdge.id"
|
||||
:when="selectedEdge.data?.when"
|
||||
:conditions="catalog.conditions"
|
||||
:disabled="readOnly"
|
||||
@update:when="(v) => patchSelectedEdge({ when: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="priority">
|
||||
<n-input-number
|
||||
:disabled="readOnly"
|
||||
:value="selectedEdge.data?.priority ?? 0"
|
||||
:min="0"
|
||||
@update:value="(v: number | null) => patchSelectedEdge({ priority: v ?? 0 })"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
|
||||
<n-form v-else :model="processModel" :rules="processRules" :show-feedback="true" :disabled="readOnly">
|
||||
<div class="text-xs font-semibold text-slate-500">Process</div>
|
||||
<n-form-item label="id" path="id">
|
||||
<n-input :disabled="readOnly" v-model:value="definitionId" />
|
||||
</n-form-item>
|
||||
<n-form-item label="name" path="name">
|
||||
<n-input :disabled="readOnly" v-model:value="name" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from '@vue-flow/core'
|
||||
import type { DesignerEdgeData } from '@/types/definition'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
|
||||
const props = defineProps<EdgeProps<DesignerEdgeData>>()
|
||||
const designer = useDesignerStore()
|
||||
|
||||
const route = computed(() => {
|
||||
const [path, labelX, labelY] = getBezierPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
targetPosition: props.targetPosition,
|
||||
})
|
||||
return { path, labelX, labelY }
|
||||
})
|
||||
|
||||
const hasError = computed(() => designer.graphErrors.some((e) => e.edgeId === props.id))
|
||||
|
||||
const edgeStyle = computed(() => ({
|
||||
stroke: hasError.value ? '#ef4444' : '#94a3b8',
|
||||
strokeWidth: hasError.value ? 2 : 1.75,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseEdge :id="id" :path="route.path" :marker-end="markerEnd" :style="edgeStyle" />
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
class="nodrag nopan pointer-events-all absolute rounded-md border border-slate-200 bg-white/95 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 shadow-sm"
|
||||
:class="hasError ? 'border-red-200 text-red-600' : ''"
|
||||
:style="{
|
||||
transform: `translate(-50%, -50%) translate(${route.labelX}px, ${route.labelY}px)`,
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Bolt, CirclePlay } from '@lucide/vue'
|
||||
import type { ActionNodeData } from '@/types/definition'
|
||||
import { useCatalogLabels } from '@/composables/useCatalogLabels'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
import NodeHandles from '@/components/designer/nodes/NodeHandles.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
data: ActionNodeData
|
||||
}>()
|
||||
|
||||
const designer = useDesignerStore()
|
||||
const { action } = useCatalogLabels()
|
||||
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group relative min-w-55 overflow-visible rounded-xl border bg-white shadow-[0_1px_2px_rgba(15,23,42,0.06),0_8px_20px_rgba(15,23,42,0.06)] transition-shadow"
|
||||
:class="
|
||||
hasError
|
||||
? 'border-red-300 ring-2 ring-red-100'
|
||||
: data.isStart
|
||||
? 'border-amber-300 ring-2 ring-amber-100'
|
||||
: 'border-slate-200 hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)]'
|
||||
"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 w-1 bg-amber-500" />
|
||||
<NodeHandles color-class="bg-amber-500!" />
|
||||
<div class="px-3.5 py-3 pl-4">
|
||||
<div class="mb-1.5 flex items-center gap-1.5">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-amber-700">
|
||||
<Bolt :size="12" />
|
||||
ACTION
|
||||
</span>
|
||||
<span
|
||||
v-if="data.isStart"
|
||||
class="rounded-md bg-amber-500 px-1.5 py-0.5 text-[10px] font-medium text-white"
|
||||
>
|
||||
START
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[13px] font-semibold text-slate-800">{{ data.name }}</div>
|
||||
<div class="mt-2 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<CirclePlay :size="12" class="shrink-0 text-slate-400" />
|
||||
<span class="truncate">{{ data.action ? action(data.action) : 'Unset' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Clock3, ShieldCheck, Users } from '@lucide/vue'
|
||||
import type { ApprovalNodeData } from '@/types/definition'
|
||||
import { useCatalogLabels } from '@/composables/useCatalogLabels'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
import NodeHandles from '@/components/designer/nodes/NodeHandles.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
data: ApprovalNodeData
|
||||
}>()
|
||||
|
||||
const designer = useDesignerStore()
|
||||
const { assignees } = useCatalogLabels()
|
||||
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group relative min-w-55 overflow-visible rounded-xl border bg-white shadow-[0_1px_2px_rgba(15,23,42,0.06),0_8px_20px_rgba(15,23,42,0.06)] transition-shadow"
|
||||
:class="
|
||||
hasError
|
||||
? 'border-red-300 ring-2 ring-red-100'
|
||||
: data.isStart
|
||||
? 'border-sky-300 ring-2 ring-sky-100'
|
||||
: 'border-slate-200 hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)]'
|
||||
"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 w-1 bg-sky-500" />
|
||||
<NodeHandles color-class="bg-sky-500!" />
|
||||
<div class="px-3.5 py-3 pl-4">
|
||||
<div class="mb-1.5 flex items-center gap-1.5">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-sky-50 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-sky-700">
|
||||
<ShieldCheck :size="12" />
|
||||
APPROVAL
|
||||
</span>
|
||||
<span
|
||||
v-if="data.isStart"
|
||||
class="rounded-md bg-sky-500 px-1.5 py-0.5 text-[10px] font-medium text-white"
|
||||
>
|
||||
START
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[13px] font-semibold text-slate-800">{{ data.name }}</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<Users :size="12" class="shrink-0 text-slate-400" />
|
||||
<span class="truncate">{{ assignees(data.candidates) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<span class="rounded bg-slate-100 px-1 py-px font-medium text-slate-600">{{ data.policy }}</span>
|
||||
</div>
|
||||
<div v-if="data.dueEnabled" class="flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<Clock3 :size="12" class="shrink-0 text-slate-400" />
|
||||
<span class="truncate">{{ data.due.after }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Flag } from '@lucide/vue'
|
||||
import type { EndNodeData } from '@/types/definition'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
import NodeHandles from '@/components/designer/nodes/NodeHandles.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
data: EndNodeData
|
||||
}>()
|
||||
|
||||
const designer = useDesignerStore()
|
||||
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex h-19 w-19 flex-col items-center justify-center rounded-full border bg-linear-to-b from-slate-50 to-slate-100 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_8px_20px_rgba(15,23,42,0.08)]"
|
||||
:class="hasError ? 'border-red-300 ring-2 ring-red-100' : 'border-slate-300'"
|
||||
>
|
||||
<NodeHandles color-class="bg-slate-500!" :sources="false" />
|
||||
<Flag :size="16" class="mb-0.5 text-slate-500" />
|
||||
<span class="text-[11px] font-semibold text-slate-700">{{ data.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
colorClass: string
|
||||
sources?: boolean
|
||||
targets?: boolean
|
||||
}>(),
|
||||
{ sources: true, targets: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- One handle per side: top=in, bottom=out, left/right shared for back-edges (Loose mode). -->
|
||||
<Handle
|
||||
v-if="targets"
|
||||
id="target-top"
|
||||
class="h-2.5! w-2.5! border-2! border-white!"
|
||||
:class="colorClass"
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
/>
|
||||
<Handle
|
||||
id="left"
|
||||
class="h-2.5! w-2.5! border-2! border-white!"
|
||||
:class="colorClass"
|
||||
:type="sources ? 'source' : 'target'"
|
||||
:position="Position.Left"
|
||||
/>
|
||||
<Handle
|
||||
id="right"
|
||||
class="h-2.5! w-2.5! border-2! border-white!"
|
||||
:class="colorClass"
|
||||
:type="sources ? 'source' : 'target'"
|
||||
:position="Position.Right"
|
||||
/>
|
||||
<Handle
|
||||
v-if="sources"
|
||||
id="source-bottom"
|
||||
class="h-2.5! w-2.5! border-2! border-white!"
|
||||
:class="colorClass"
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { GitBranch, Plus } from '@lucide/vue'
|
||||
import type { ParallelNodeData } from '@/types/definition'
|
||||
import { LANE_H, LANE_HEADER, LANE_LABEL_W } from '@/mapper/parallel'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
data: ParallelNodeData
|
||||
}>()
|
||||
|
||||
const designer = useDesignerStore()
|
||||
const route = useRoute()
|
||||
const readOnly = computed(() => {
|
||||
const versionRaw = route.query.version
|
||||
return typeof versionRaw === 'string' && Number.isFinite(Number(versionRaw))
|
||||
})
|
||||
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full overflow-visible rounded-xl border bg-violet-50/40"
|
||||
:class="
|
||||
hasError
|
||||
? 'border-red-300 ring-2 ring-red-100'
|
||||
: data.isStart
|
||||
? 'border-violet-400 ring-2 ring-violet-100'
|
||||
: 'border-violet-200'
|
||||
"
|
||||
>
|
||||
<Handle
|
||||
id="target-top"
|
||||
class="h-2.5! w-2.5! border-2! border-white! bg-violet-500!"
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
/>
|
||||
<Handle
|
||||
id="source-bottom"
|
||||
class="h-2.5! w-2.5! border-2! border-white! bg-violet-500!"
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
/>
|
||||
<div class="flex h-11 items-center gap-2 border-b border-violet-100 px-3">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-violet-700">
|
||||
<GitBranch :size="12" />
|
||||
PARALLEL
|
||||
</span>
|
||||
<span v-if="data.isStart" class="rounded-md bg-violet-500 px-1.5 py-0.5 text-[10px] font-medium text-white">START</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-800">{{ data.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="nodrag nopan inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-violet-700 hover:bg-violet-100 disabled:opacity-40"
|
||||
:disabled="readOnly"
|
||||
@click.stop="designer.addLane(id)"
|
||||
>
|
||||
<Plus :size="12" />
|
||||
Branch
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(lane, index) in data.lanes"
|
||||
:key="lane"
|
||||
class="pointer-events-none absolute right-2 left-0 flex items-start border-b border-violet-100/80"
|
||||
:style="{ top: `${LANE_HEADER + index * LANE_H}px`, height: `${LANE_H}px` }"
|
||||
>
|
||||
<div class="pointer-events-auto flex h-full flex-col justify-center gap-1 px-2" :style="{ width: `${LANE_LABEL_W}px` }">
|
||||
<div class="truncate text-[11px] font-semibold text-violet-800">{{ lane }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="nodrag nopan inline-flex items-center gap-0.5 text-[10px] font-medium text-slate-500 hover:text-violet-700 disabled:opacity-40"
|
||||
:disabled="readOnly"
|
||||
@click.stop="designer.addLaneStep(id, lane)"
|
||||
>
|
||||
<Plus :size="11" />
|
||||
Step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useAsync() {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Request failed'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, run }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import type { CatalogItem } from '@/types/definition'
|
||||
|
||||
function labelOf(items: CatalogItem[], key: string) {
|
||||
return items.find((i) => i.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
export function useCatalogLabels() {
|
||||
const catalog = useCatalogStore()
|
||||
|
||||
function assignee(key: string) {
|
||||
return labelOf(catalog.assignees, key)
|
||||
}
|
||||
|
||||
function action(key: string) {
|
||||
return labelOf(catalog.actions, key)
|
||||
}
|
||||
|
||||
function assignees(keys: string[]) {
|
||||
if (keys.length === 0) {
|
||||
return 'Unset'
|
||||
}
|
||||
const labels = keys.map(assignee)
|
||||
if (labels.length <= 2) {
|
||||
return labels.join('、')
|
||||
}
|
||||
return `${labels.slice(0, 2).join('、')} +${labels.length - 2}`
|
||||
}
|
||||
|
||||
return { assignee, action, assignees }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import naive from 'naive-ui'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
import '@vue-flow/controls/dist/style.css'
|
||||
import '@vue-flow/minimap/dist/style.css'
|
||||
import './styles/main.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(naive)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,29 @@
|
||||
export type DurationUnit = 'minutes' | 'hours' | 'days'
|
||||
|
||||
export function parseIsoDuration(iso: string | undefined): { value: number; unit: DurationUnit } {
|
||||
const raw = iso?.trim() ?? ''
|
||||
const day = /^P(\d+)D$/i.exec(raw)
|
||||
if (day) {
|
||||
return { value: Number(day[1]), unit: 'days' }
|
||||
}
|
||||
const hour = /^PT(\d+)H$/i.exec(raw)
|
||||
if (hour) {
|
||||
return { value: Number(hour[1]), unit: 'hours' }
|
||||
}
|
||||
const min = /^PT(\d+)M$/i.exec(raw)
|
||||
if (min) {
|
||||
return { value: Number(min[1]), unit: 'minutes' }
|
||||
}
|
||||
return { value: 48, unit: 'hours' }
|
||||
}
|
||||
|
||||
export function toIsoDuration(value: number, unit: DurationUnit): string {
|
||||
const n = Math.max(1, Math.floor(value) || 1)
|
||||
if (unit === 'days') {
|
||||
return `P${n}D`
|
||||
}
|
||||
if (unit === 'hours') {
|
||||
return `PT${n}H`
|
||||
}
|
||||
return `PT${n}M`
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
export type HandleSide = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
export interface ObstacleRect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface OrthogonalPathParams {
|
||||
sourceX: number
|
||||
sourceY: number
|
||||
targetX: number
|
||||
targetY: number
|
||||
sourceSide?: HandleSide
|
||||
targetSide?: HandleSide
|
||||
/** Stagger when several edges share a corridor. */
|
||||
corridorOffset?: number
|
||||
obstacles?: ObstacleRect[]
|
||||
}
|
||||
|
||||
export type OrthogonalPathResult = {
|
||||
path: string
|
||||
labelX: number
|
||||
labelY: number
|
||||
}
|
||||
|
||||
export function sideFromHandleId(handleId?: string | null): HandleSide | undefined {
|
||||
if (!handleId) {
|
||||
return undefined
|
||||
}
|
||||
if (handleId.endsWith('-top') || handleId === 'top') {
|
||||
return 'top'
|
||||
}
|
||||
if (handleId.endsWith('-bottom') || handleId === 'bottom') {
|
||||
return 'bottom'
|
||||
}
|
||||
if (handleId.endsWith('-left') || handleId === 'left') {
|
||||
return 'left'
|
||||
}
|
||||
if (handleId.endsWith('-right') || handleId === 'right') {
|
||||
return 'right'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isHorizontal(side: HandleSide) {
|
||||
return side === 'left' || side === 'right'
|
||||
}
|
||||
|
||||
const NODE_CLEAR = 56
|
||||
|
||||
function hitsVertical(x: number, y0: number, y1: number, rects: ObstacleRect[], margin = 0) {
|
||||
const top = Math.min(y0, y1) + 3
|
||||
const bot = Math.max(y0, y1) - 3
|
||||
if (bot <= top) {
|
||||
return false
|
||||
}
|
||||
return rects.some(
|
||||
(r) =>
|
||||
x > r.x - margin &&
|
||||
x < r.x + r.width + margin &&
|
||||
bot > r.y &&
|
||||
top < r.y + r.height,
|
||||
)
|
||||
}
|
||||
|
||||
function hitsHorizontal(y: number, x0: number, x1: number, rects: ObstacleRect[], margin = 0) {
|
||||
const left = Math.min(x0, x1) + 3
|
||||
const right = Math.max(x0, x1) - 3
|
||||
if (right <= left) {
|
||||
return false
|
||||
}
|
||||
return rects.some(
|
||||
(r) =>
|
||||
y > r.y - margin &&
|
||||
y < r.y + r.height + margin &&
|
||||
right > r.x &&
|
||||
left < r.x + r.width,
|
||||
)
|
||||
}
|
||||
|
||||
function routeBottomTopAround(
|
||||
sx: number,
|
||||
sy: number,
|
||||
tx: number,
|
||||
ty: number,
|
||||
corridor: number,
|
||||
obstacles: ObstacleRect[],
|
||||
): OrthogonalPathResult {
|
||||
const preferRight = tx >= sx
|
||||
const blocked = obstacles.filter(
|
||||
(r) => hitsVertical(sx, sy, ty, [r]) || hitsVertical(tx, sy, ty, [r]),
|
||||
)
|
||||
const basis = blocked.length ? blocked : obstacles
|
||||
let lane = preferRight
|
||||
? Math.max(sx, ...basis.map((r) => r.x + r.width)) + NODE_CLEAR
|
||||
: Math.min(sx, ...basis.map((r) => r.x)) - NODE_CLEAR
|
||||
lane += preferRight ? corridor : -corridor
|
||||
const y0 = Math.min(sy, ty)
|
||||
const y1Span = Math.max(sy, ty)
|
||||
for (let i = 0; i < 16 && hitsVertical(lane, y0, y1Span, obstacles, NODE_CLEAR); i += 1) {
|
||||
lane += preferRight ? 18 : -18
|
||||
}
|
||||
|
||||
const below = basis.map((r) => r.y).filter((y) => y > sy + 4)
|
||||
const firstTop = below.length ? Math.min(...below) : sy + 64
|
||||
const yTurn = Math.max(sy + 14, Math.min(sy + 32, firstTop - 16))
|
||||
let yEnter = ty - 28
|
||||
if (hitsHorizontal(yEnter, lane, tx, obstacles)) {
|
||||
yEnter = ty - 14
|
||||
}
|
||||
if (yEnter < yTurn + 16) {
|
||||
yEnter = yTurn + 16
|
||||
}
|
||||
|
||||
return {
|
||||
path: `M ${sx},${sy} L ${sx},${yTurn} L ${lane},${yTurn} L ${lane},${yEnter} L ${tx},${yEnter} L ${tx},${ty}`,
|
||||
labelX: (sx + lane) / 2,
|
||||
labelY: yTurn,
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrthogonalPath(params: OrthogonalPathParams): OrthogonalPathResult {
|
||||
const sx = params.sourceX
|
||||
const sy = params.sourceY
|
||||
const tx = params.targetX
|
||||
const ty = params.targetY
|
||||
const corridor = params.corridorOffset ?? 0
|
||||
const sourceSide = params.sourceSide ?? (ty + 8 < sy ? 'left' : 'bottom')
|
||||
const targetSide = params.targetSide ?? (ty + 8 < sy ? 'left' : 'top')
|
||||
|
||||
if (Math.abs(sx - tx) < 2 && Math.abs(sy - ty) < 2) {
|
||||
return { path: `M ${sx},${sy}`, labelX: sx, labelY: sy }
|
||||
}
|
||||
|
||||
// Side → side (back-edge / loop): out horizontally, vertical, in horizontally.
|
||||
if (isHorizontal(sourceSide) && isHorizontal(targetSide)) {
|
||||
const pad = 48 + Math.abs(corridor)
|
||||
const outLeft = sourceSide === 'left'
|
||||
const inLeft = targetSide === 'left'
|
||||
let hx1 = outLeft ? sx - pad : sx + pad
|
||||
let hx2 = inLeft ? tx - pad : tx + pad
|
||||
if (outLeft === inLeft) {
|
||||
const shared = outLeft ? Math.min(sx, tx) - pad : Math.max(sx, tx) + pad
|
||||
hx1 = shared + corridor
|
||||
hx2 = hx1
|
||||
return {
|
||||
path: `M ${sx},${sy} L ${hx1},${sy} L ${hx1},${ty} L ${tx},${ty}`,
|
||||
labelX: hx1,
|
||||
labelY: (sy + ty) / 2,
|
||||
}
|
||||
}
|
||||
hx1 += corridor
|
||||
hx2 += corridor
|
||||
const midY = (sy + ty) / 2
|
||||
return {
|
||||
path: `M ${sx},${sy} L ${hx1},${sy} L ${hx1},${midY} L ${hx2},${midY} L ${hx2},${ty} L ${tx},${ty}`,
|
||||
labelX: (hx1 + hx2) / 2,
|
||||
labelY: midY,
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom → Top (default downward flow).
|
||||
if (sourceSide === 'bottom' && targetSide === 'top') {
|
||||
const obstacles = params.obstacles ?? []
|
||||
if (Math.abs(sx - tx) < 2) {
|
||||
if (!hitsVertical(sx, sy, ty, obstacles)) {
|
||||
return {
|
||||
path: `M ${sx},${sy} L ${tx},${ty}`,
|
||||
labelX: (sx + tx) / 2,
|
||||
labelY: (sy + ty) / 2,
|
||||
}
|
||||
}
|
||||
return routeBottomTopAround(sx, sy, tx, ty, corridor, obstacles)
|
||||
}
|
||||
const dy = ty - sy
|
||||
const minDrop = 36
|
||||
let midY = sy + Math.min(Math.max(dy / 2, minDrop), Math.max(minDrop, dy - minDrop))
|
||||
midY += corridor
|
||||
const lo = sy + 20
|
||||
const hi = ty - 20
|
||||
if (hi > lo) {
|
||||
midY = Math.min(hi, Math.max(lo, midY))
|
||||
}
|
||||
const blocked =
|
||||
hitsVertical(sx, sy, midY, obstacles) ||
|
||||
hitsHorizontal(midY, sx, tx, obstacles) ||
|
||||
hitsVertical(tx, midY, ty, obstacles)
|
||||
if (blocked) {
|
||||
return routeBottomTopAround(sx, sy, tx, ty, corridor, obstacles)
|
||||
}
|
||||
return {
|
||||
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
|
||||
labelX: (sx + tx) / 2,
|
||||
labelY: midY,
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom → side
|
||||
if (sourceSide === 'bottom' && isHorizontal(targetSide)) {
|
||||
const midY = sy + 36 + corridor
|
||||
return {
|
||||
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
|
||||
labelX: (sx + tx) / 2,
|
||||
labelY: midY,
|
||||
}
|
||||
}
|
||||
|
||||
// Side → top
|
||||
if (isHorizontal(sourceSide) && targetSide === 'top') {
|
||||
const midY = ty - 36 - Math.abs(corridor)
|
||||
return {
|
||||
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
|
||||
labelX: (sx + tx) / 2,
|
||||
labelY: midY,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: simple elbow
|
||||
const midY = (sy + ty) / 2 + corridor
|
||||
return {
|
||||
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
|
||||
labelX: (sx + tx) / 2,
|
||||
labelY: midY,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import type {
|
||||
ActionNodeData,
|
||||
ApprovalNodeData,
|
||||
DesignerEdgeData,
|
||||
DesignerNodeData,
|
||||
ProcessDefinitionDocument,
|
||||
StepDocument,
|
||||
} from '@/types/definition'
|
||||
import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition'
|
||||
import { formatWhen } from '@/mapper/when'
|
||||
import { approvalStepDocument, branchesOf, laneOf, materializeParallel } from '@/mapper/parallel'
|
||||
|
||||
export const APPROVAL_TYPE = 'approval'
|
||||
export const ACTION_TYPE = 'action'
|
||||
export const PARALLEL_TYPE = 'parallel'
|
||||
export const END_TYPE = 'end'
|
||||
export const END_NODE_ID = '__end__'
|
||||
|
||||
export interface DesignerNode {
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
data: DesignerNodeData
|
||||
parentNode?: string
|
||||
extent?: 'parent'
|
||||
style?: { width: string; height: string }
|
||||
zIndex?: number
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}
|
||||
|
||||
export interface DesignerEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
label?: string
|
||||
data: DesignerEdgeData
|
||||
}
|
||||
|
||||
export function edgeLabel(data: DesignerEdgeData | undefined): string {
|
||||
const when = formatWhen(data?.when)
|
||||
return when || 'default'
|
||||
}
|
||||
|
||||
export function toDocument(
|
||||
id: string,
|
||||
name: string,
|
||||
startStepId: string,
|
||||
nodes: DesignerNode[],
|
||||
edges: DesignerEdge[],
|
||||
): ProcessDefinitionDocument {
|
||||
const top = nodes.filter((node) => node.type !== END_TYPE && !node.parentNode)
|
||||
const start = top.find((node) => node.id === startStepId) ?? top[0]
|
||||
const rest = top.filter((node) => node.id !== start?.id)
|
||||
const ordered = start ? [start, ...rest] : rest
|
||||
|
||||
const steps: StepDocument[] = ordered.map((node) => {
|
||||
const data = node.data
|
||||
if (data?.kind === 'ACTION') {
|
||||
return { id: node.id, name: data.name, kind: 'ACTION', action: data.action }
|
||||
}
|
||||
if (data?.kind === 'PARALLEL') {
|
||||
return {
|
||||
id: node.id,
|
||||
name: data.name,
|
||||
kind: 'PARALLEL',
|
||||
branches: branchesOf(node.id, data.lanes, nodes, edges),
|
||||
}
|
||||
}
|
||||
return approvalStepDocument(data as ApprovalNodeData, node.id)
|
||||
})
|
||||
|
||||
const transitions = edges
|
||||
.filter((edge) => {
|
||||
const source = nodes.find((node) => node.id === edge.source)
|
||||
return source && !source.parentNode
|
||||
})
|
||||
.map((edge) => {
|
||||
const target = nodes.find((node) => node.id === edge.target)
|
||||
const data = edge.data ?? { priority: 0 }
|
||||
return {
|
||||
from: edge.source,
|
||||
to: target?.type === END_TYPE || target?.parentNode ? null : edge.target,
|
||||
...(data.when ? { when: data.when } : {}),
|
||||
priority: data.priority ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
startStep: start?.id,
|
||||
steps,
|
||||
transitions,
|
||||
}
|
||||
}
|
||||
|
||||
export function fromDocument(
|
||||
doc: ProcessDefinitionDocument,
|
||||
layout: Record<string, { x: number; y: number }> = {},
|
||||
): { nodes: DesignerNode[]; edges: DesignerEdge[]; startStepId: string } {
|
||||
const nodes: DesignerNode[] = []
|
||||
const innerEdges: DesignerEdge[] = []
|
||||
doc.steps.forEach((step, index) => {
|
||||
const position = layout[step.id] ?? { x: 220, y: 40 + index * 140 }
|
||||
if (step.kind === 'PARALLEL') {
|
||||
const built = materializeParallel(step.id, step.name, position, step.branches ?? [])
|
||||
nodes.push(built.parent, ...built.children)
|
||||
innerEdges.push(...built.edges)
|
||||
return
|
||||
}
|
||||
if (step.kind === 'ACTION') {
|
||||
const data: ActionNodeData = {
|
||||
...defaultActionData(),
|
||||
name: step.name,
|
||||
action: step.action ?? '',
|
||||
isStart: false,
|
||||
}
|
||||
nodes.push({ id: step.id, type: ACTION_TYPE, position, data })
|
||||
return
|
||||
}
|
||||
const data: ApprovalNodeData = {
|
||||
...defaultApprovalData(),
|
||||
name: step.name,
|
||||
candidates: step.candidates ?? [],
|
||||
policy: step.policy ?? 'ANY',
|
||||
dueEnabled: Boolean(step.due),
|
||||
due: step.due
|
||||
? {
|
||||
after: step.due.after,
|
||||
then: step.due.then,
|
||||
to: step.due.to ?? undefined,
|
||||
action: step.due.action ?? undefined,
|
||||
}
|
||||
: { after: 'PT48H', then: 'notify' },
|
||||
isStart: false,
|
||||
}
|
||||
nodes.push({ id: step.id, type: APPROVAL_TYPE, position, data })
|
||||
})
|
||||
|
||||
const hasEnd = doc.transitions.some((t) => t.to == null)
|
||||
if (hasEnd) {
|
||||
nodes.push({
|
||||
id: END_NODE_ID,
|
||||
type: END_TYPE,
|
||||
position: layout[END_NODE_ID] ?? { x: 220, y: 40 + doc.steps.length * 140 },
|
||||
data: defaultEndData(),
|
||||
})
|
||||
}
|
||||
|
||||
const startStepId = doc.startStep ?? doc.steps[0]?.id ?? ''
|
||||
for (const node of nodes) {
|
||||
if (node.data && node.data.kind !== 'END') {
|
||||
node.data.isStart = node.id === startStepId
|
||||
}
|
||||
}
|
||||
|
||||
const edges: DesignerEdge[] = [
|
||||
...innerEdges,
|
||||
...doc.transitions.map((t, i) => {
|
||||
const data: DesignerEdgeData = {
|
||||
when: t.when,
|
||||
priority: t.priority ?? 0,
|
||||
}
|
||||
return {
|
||||
id: `e-${i}-${t.from}-${t.to ?? 'end'}`,
|
||||
source: t.from,
|
||||
target: t.to ?? END_NODE_ID,
|
||||
label: edgeLabel(data),
|
||||
data,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
return { nodes, edges, startStepId }
|
||||
}
|
||||
|
||||
export function layoutFromNodes(nodes: DesignerNode[]): Record<string, { x: number; y: number }> {
|
||||
const layout: Record<string, { x: number; y: number }> = {}
|
||||
for (const node of nodes) {
|
||||
layout[node.id] = { x: node.position.x, y: node.position.y }
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
export interface GraphError {
|
||||
message: string
|
||||
nodeId?: string
|
||||
edgeId?: string
|
||||
}
|
||||
|
||||
export function comparableDocument(doc: ProcessDefinitionDocument): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
startStep: doc.startStep,
|
||||
steps: doc.steps,
|
||||
transitions: doc.transitions,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
|
||||
export function validateGraph(
|
||||
id: string,
|
||||
name: string,
|
||||
startStepId: string,
|
||||
nodes: DesignerNode[],
|
||||
edges: DesignerEdge[],
|
||||
): GraphError[] {
|
||||
const errors: GraphError[] = []
|
||||
if (!id.trim()) {
|
||||
errors.push({ message: 'Process id is required' })
|
||||
}
|
||||
if (!name.trim()) {
|
||||
errors.push({ message: 'Process name is required' })
|
||||
}
|
||||
const stepNodes = nodes.filter((n) => n.type !== END_TYPE)
|
||||
if (stepNodes.length === 0) {
|
||||
errors.push({ message: 'At least one step is required' })
|
||||
}
|
||||
const ids = new Set<string>()
|
||||
for (const node of stepNodes) {
|
||||
if (ids.has(node.id)) {
|
||||
errors.push({ message: `Duplicate step id: ${node.id}`, nodeId: node.id })
|
||||
}
|
||||
ids.add(node.id)
|
||||
const data = node.data
|
||||
if (data?.kind === 'APPROVAL' && data.candidates.length === 0) {
|
||||
errors.push({ message: `Approval step ${node.id} needs candidates`, nodeId: node.id })
|
||||
}
|
||||
if (data?.kind === 'ACTION' && !data.action.trim()) {
|
||||
errors.push({ message: `ACTION step ${node.id} needs action`, nodeId: node.id })
|
||||
}
|
||||
if (data?.kind === 'PARALLEL') {
|
||||
if (data.lanes.length < 2) {
|
||||
errors.push({ message: `PARALLEL ${node.id} needs at least two branches`, nodeId: node.id })
|
||||
}
|
||||
const seenLanes = new Set<string>()
|
||||
for (const lane of data.lanes) {
|
||||
if (!lane.trim() || seenLanes.has(lane)) {
|
||||
errors.push({ message: `PARALLEL ${node.id} has a bad branch id: ${lane}`, nodeId: node.id })
|
||||
}
|
||||
seenLanes.add(lane)
|
||||
if (!nodes.some((child) => child.parentNode === node.id && laneOf(child.data) === lane)) {
|
||||
errors.push({ message: `Branch ${lane} needs a step`, nodeId: node.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
const outgoing = edges.filter((e) => e.source === node.id)
|
||||
if (!node.parentNode && outgoing.length === 0) {
|
||||
errors.push({ message: `Step ${node.id} has no outgoing edge`, nodeId: node.id })
|
||||
}
|
||||
}
|
||||
if (startStepId && !ids.has(startStepId)) {
|
||||
errors.push({ message: `Unknown startStep: ${startStepId}` })
|
||||
}
|
||||
if (nodes.some((node) => node.id === startStepId && node.parentNode)) {
|
||||
errors.push({ message: 'startStep must not be inside a parallel branch' })
|
||||
}
|
||||
const seenPriority = new Set<string>()
|
||||
for (const edge of edges) {
|
||||
const source = nodes.find((n) => n.id === edge.source)
|
||||
const target = nodes.find((n) => n.id === edge.target)
|
||||
if (!target) {
|
||||
errors.push({ message: `Edge target missing: ${edge.target}`, edgeId: edge.id })
|
||||
} else if (target.type !== END_TYPE && !ids.has(edge.target)) {
|
||||
errors.push({ message: `Edge target is not a step: ${edge.target}`, edgeId: edge.id })
|
||||
}
|
||||
if (!source || !ids.has(edge.source)) {
|
||||
errors.push({ message: `Edge source is not a step: ${edge.source}`, edgeId: edge.id })
|
||||
}
|
||||
if (source?.parentNode || target?.parentNode) {
|
||||
if (source?.parentNode !== target?.parentNode || laneOf(source?.data) !== laneOf(target?.data)) {
|
||||
errors.push({ message: `Branch edge must stay in the same branch`, edgeId: edge.id })
|
||||
}
|
||||
}
|
||||
const key = `${edge.source}:${edge.data?.priority ?? 0}`
|
||||
if (seenPriority.has(key)) {
|
||||
errors.push({
|
||||
message: `Duplicate outgoing priority: ${edge.source} / ${edge.data?.priority ?? 0}`,
|
||||
edgeId: edge.id,
|
||||
})
|
||||
}
|
||||
seenPriority.add(key)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { END_TYPE, type DesignerEdge, type DesignerNode } from '@/mapper/graph'
|
||||
import { fitParallelFrames } from '@/mapper/parallel'
|
||||
|
||||
const RANK_Y = 220
|
||||
const NODE_GAP_X = 320
|
||||
const ORIGIN_X = 80
|
||||
const ORIGIN_Y = 40
|
||||
|
||||
function buildOutgoing(edges: DesignerEdge[]) {
|
||||
const outgoing = new Map<string, DesignerEdge[]>()
|
||||
for (const edge of edges) {
|
||||
const list = outgoing.get(edge.source) ?? []
|
||||
list.push(edge)
|
||||
outgoing.set(edge.source, list)
|
||||
}
|
||||
for (const list of outgoing.values()) {
|
||||
list.sort((a, b) => (a.data?.priority ?? 0) - (b.data?.priority ?? 0) || a.target.localeCompare(b.target))
|
||||
}
|
||||
return outgoing
|
||||
}
|
||||
|
||||
/** Edges that close a cycle (feedback arcs); excluded from rank / column inheritance. */
|
||||
function findBackEdgeIds(nodeIds: string[], edges: DesignerEdge[], startId?: string): Set<string> {
|
||||
const ids = new Set(nodeIds)
|
||||
const back = new Set<string>()
|
||||
const visited = new Set<string>()
|
||||
const stack = new Set<string>()
|
||||
const outgoing = buildOutgoing(edges)
|
||||
|
||||
function dfs(u: string) {
|
||||
visited.add(u)
|
||||
stack.add(u)
|
||||
for (const edge of outgoing.get(u) ?? []) {
|
||||
if (!ids.has(edge.target)) {
|
||||
continue
|
||||
}
|
||||
if (stack.has(edge.target)) {
|
||||
back.add(edge.id)
|
||||
continue
|
||||
}
|
||||
if (!visited.has(edge.target)) {
|
||||
dfs(edge.target)
|
||||
}
|
||||
}
|
||||
stack.delete(u)
|
||||
}
|
||||
|
||||
const roots = startId && ids.has(startId) ? [startId] : nodeIds
|
||||
for (const root of roots) {
|
||||
if (!visited.has(root)) {
|
||||
dfs(root)
|
||||
}
|
||||
}
|
||||
for (const id of nodeIds) {
|
||||
if (!visited.has(id)) {
|
||||
dfs(id)
|
||||
}
|
||||
}
|
||||
return back
|
||||
}
|
||||
|
||||
/** Longest-path ranks on the DAG after removing feedback arcs. */
|
||||
function computeRanks(
|
||||
nodeIds: string[],
|
||||
edges: DesignerEdge[],
|
||||
backEdgeIds: Set<string>,
|
||||
): Map<string, number> {
|
||||
const rank = new Map<string, number>()
|
||||
for (const id of nodeIds) {
|
||||
rank.set(id, 0)
|
||||
}
|
||||
const forward = edges.filter((e) => !backEdgeIds.has(e.id))
|
||||
let changed = true
|
||||
let guard = 0
|
||||
while (changed && guard < nodeIds.length + 4) {
|
||||
changed = false
|
||||
guard += 1
|
||||
for (const edge of forward) {
|
||||
if (!rank.has(edge.source) || !rank.has(edge.target)) {
|
||||
continue
|
||||
}
|
||||
const next = (rank.get(edge.source) ?? 0) + 1
|
||||
if (next > (rank.get(edge.target) ?? 0)) {
|
||||
rank.set(edge.target, next)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
function assignColumns(
|
||||
nodeIds: string[],
|
||||
startId: string | undefined,
|
||||
rank: Map<string, number>,
|
||||
outgoing: Map<string, DesignerEdge[]>,
|
||||
backEdgeIds: Set<string>,
|
||||
): Map<string, number> {
|
||||
const col = new Map<string, number>()
|
||||
let nextCol = 0
|
||||
const ordered = [...nodeIds].sort(
|
||||
(a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || a.localeCompare(b),
|
||||
)
|
||||
|
||||
if (startId && rank.has(startId)) {
|
||||
col.set(startId, 0)
|
||||
nextCol = 1
|
||||
}
|
||||
|
||||
for (const id of ordered) {
|
||||
if (!col.has(id)) {
|
||||
col.set(id, nextCol)
|
||||
nextCol += 1
|
||||
}
|
||||
const parentCol = col.get(id) ?? 0
|
||||
let inherited = false
|
||||
const seen = new Set<string>()
|
||||
for (const edge of outgoing.get(id) ?? []) {
|
||||
if (backEdgeIds.has(edge.id) || seen.has(edge.target) || !rank.has(edge.target)) {
|
||||
continue
|
||||
}
|
||||
seen.add(edge.target)
|
||||
if (col.has(edge.target)) {
|
||||
continue
|
||||
}
|
||||
if (!inherited) {
|
||||
col.set(edge.target, parentCol)
|
||||
inherited = true
|
||||
} else {
|
||||
col.set(edge.target, nextCol)
|
||||
nextCol += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of nodeIds) {
|
||||
if (!col.has(id)) {
|
||||
col.set(id, nextCol)
|
||||
nextCol += 1
|
||||
}
|
||||
}
|
||||
return col
|
||||
}
|
||||
|
||||
/** Map legacy dual side-handle ids onto the single left/right handles. */
|
||||
export function normalizeHandleId(handleId?: string | null): string | undefined {
|
||||
if (!handleId) {
|
||||
return undefined
|
||||
}
|
||||
if (handleId === 'source-left' || handleId === 'target-left' || handleId === 'left') {
|
||||
return 'left'
|
||||
}
|
||||
if (handleId === 'source-right' || handleId === 'target-right' || handleId === 'right') {
|
||||
return 'right'
|
||||
}
|
||||
if (handleId === 'source-bottom' || handleId === 'bottom') {
|
||||
return 'source-bottom'
|
||||
}
|
||||
if (handleId === 'target-top' || handleId === 'top') {
|
||||
return 'target-top'
|
||||
}
|
||||
return handleId
|
||||
}
|
||||
|
||||
/** Pick handles from node positions only (avoids rank ties marking every edge as a back-edge). */
|
||||
export function pickHandlesForEdge(
|
||||
source: DesignerNode,
|
||||
target: DesignerNode,
|
||||
): { sourceHandle: string; targetHandle: string } {
|
||||
const dy = target.position.y - source.position.y
|
||||
const dx = target.position.x - source.position.x
|
||||
const down = 64
|
||||
const up = -64
|
||||
|
||||
if (dy >= down) {
|
||||
return { sourceHandle: 'source-bottom', targetHandle: 'target-top' }
|
||||
}
|
||||
if (dy <= up) {
|
||||
const side = dx > 0 ? 'right' : 'left'
|
||||
return { sourceHandle: side, targetHandle: side }
|
||||
}
|
||||
// Same row: exit toward the target, enter from the facing side.
|
||||
if (dx >= 0) {
|
||||
return { sourceHandle: 'right', targetHandle: 'left' }
|
||||
}
|
||||
return { sourceHandle: 'left', targetHandle: 'right' }
|
||||
}
|
||||
|
||||
/** Recompute every edge's handles from current node positions. */
|
||||
export function assignEdgeHandles(nodes: DesignerNode[], edges: DesignerEdge[]): DesignerEdge[] {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
return edges.map((edge) => {
|
||||
const source = byId.get(edge.source)
|
||||
const target = byId.get(edge.target)
|
||||
if (!source || !target) {
|
||||
return {
|
||||
...edge,
|
||||
sourceHandle: normalizeHandleId(edge.sourceHandle) ?? 'source-bottom',
|
||||
targetHandle: normalizeHandleId(edge.targetHandle) ?? 'target-top',
|
||||
}
|
||||
}
|
||||
return { ...edge, ...pickHandlesForEdge(source, target) }
|
||||
})
|
||||
}
|
||||
|
||||
/** @deprecated use assignEdgeHandles — kept name for draft reopen path */
|
||||
export function fillMissingEdgeHandles(nodes: DesignerNode[], edges: DesignerEdge[]): DesignerEdge[] {
|
||||
return assignEdgeHandles(nodes, edges)
|
||||
}
|
||||
|
||||
export function applyLayeredLayout(
|
||||
nodes: DesignerNode[],
|
||||
edges: DesignerEdge[],
|
||||
startStepId: string,
|
||||
): { nodes: DesignerNode[]; edges: DesignerEdge[] } {
|
||||
const parked = nodes.filter((node) => node.parentNode)
|
||||
if (parked.length > 0) {
|
||||
const top = nodes.filter((node) => !node.parentNode)
|
||||
const topIds = new Set(top.map((node) => node.id))
|
||||
const topEdges = edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target))
|
||||
const laid = applyLayeredLayout(top, topEdges, startStepId)
|
||||
const merged = fitParallelFrames([...laid.nodes, ...parked])
|
||||
return { nodes: merged, edges: assignEdgeHandles(merged, edges) }
|
||||
}
|
||||
if (nodes.length === 0) {
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const nodeIds = nodes.map((n) => n.id)
|
||||
const outgoing = buildOutgoing(edges)
|
||||
const start = byId.has(startStepId)
|
||||
? startStepId
|
||||
: (nodes.find((n) => n.type !== END_TYPE)?.id ?? nodes[0]?.id)
|
||||
|
||||
const backEdgeIds = findBackEdgeIds(nodeIds, edges, start)
|
||||
const rank = computeRanks(nodeIds, edges, backEdgeIds)
|
||||
if (start && (rank.get(start) ?? 0) > 0) {
|
||||
const shift = rank.get(start) ?? 0
|
||||
for (const id of nodeIds) {
|
||||
rank.set(id, Math.max(0, (rank.get(id) ?? 0) - shift))
|
||||
}
|
||||
}
|
||||
|
||||
const col = assignColumns(nodeIds, start, rank, outgoing, backEdgeIds)
|
||||
|
||||
const laidOut = nodes.map((node) => {
|
||||
const r = rank.get(node.id) ?? 0
|
||||
const c = col.get(node.id) ?? 0
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: ORIGIN_X + c * NODE_GAP_X,
|
||||
y: ORIGIN_Y + r * RANK_Y,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: laidOut,
|
||||
edges: assignEdgeHandles(laidOut, edges),
|
||||
}
|
||||
}
|
||||
|
||||
export { RANK_Y, NODE_GAP_X }
|
||||
@@ -0,0 +1,327 @@
|
||||
import type {
|
||||
ActionNodeData,
|
||||
ApprovalNodeData,
|
||||
BranchDocument,
|
||||
DesignerNodeData,
|
||||
ParallelNodeData,
|
||||
StepDocument,
|
||||
TransitionDocument,
|
||||
} from '@/types/definition'
|
||||
import { defaultActionData, defaultApprovalData } from '@/types/definition'
|
||||
import { formatWhen } from '@/mapper/when'
|
||||
import type { RoutingWhen } from '@/mapper/when'
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
data: DesignerNodeData
|
||||
parentNode?: string
|
||||
extent?: 'parent'
|
||||
style?: { width: string; height: string }
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
export interface CanvasEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
label?: string
|
||||
data: { when?: RoutingWhen; priority: number }
|
||||
}
|
||||
|
||||
export const LANE_HEADER = 44
|
||||
export const LANE_H = 128
|
||||
export const LANE_LABEL_W = 76
|
||||
export const LANE_STEP_W = 220
|
||||
export const LANE_STEP_GAP = 28
|
||||
|
||||
export function laneOf(data: DesignerNodeData | undefined): string | undefined {
|
||||
if (!data || (data.kind !== 'APPROVAL' && data.kind !== 'ACTION')) {
|
||||
return undefined
|
||||
}
|
||||
return data.laneId
|
||||
}
|
||||
|
||||
export function parallelSize(laneCount: number, maxSteps: number): { width: number; height: number } {
|
||||
return {
|
||||
width: LANE_LABEL_W + 16 + Math.max(1, maxSteps) * (LANE_STEP_W + LANE_STEP_GAP) + 12,
|
||||
height: LANE_HEADER + Math.max(1, laneCount) * LANE_H + 10,
|
||||
}
|
||||
}
|
||||
|
||||
export function laneStepPosition(laneIndex: number, stepIndex: number): { x: number; y: number } {
|
||||
return {
|
||||
x: LANE_LABEL_W + 8 + stepIndex * (LANE_STEP_W + LANE_STEP_GAP),
|
||||
y: LANE_HEADER + laneIndex * LANE_H + 16,
|
||||
}
|
||||
}
|
||||
|
||||
export function fitParallelFrames(nodes: CanvasNode[]): CanvasNode[] {
|
||||
return nodes.map((node) => {
|
||||
if (node.data?.kind !== 'PARALLEL') {
|
||||
return node
|
||||
}
|
||||
const lanes = node.data.lanes
|
||||
const children = nodes.filter((child) => child.parentNode === node.id)
|
||||
const maxSteps = Math.max(1, ...lanes.map((lane) => children.filter((child) => laneOf(child.data) === lane).length))
|
||||
const size = parallelSize(lanes.length, maxSteps)
|
||||
return {
|
||||
...node,
|
||||
zIndex: 0,
|
||||
style: { width: `${size.width}px`, height: `${size.height}px` },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function settleLaneNodes(nodes: CanvasNode[]): CanvasNode[] {
|
||||
const next = nodes.map((node) => {
|
||||
if (!node.parentNode) {
|
||||
return node
|
||||
}
|
||||
const parent = nodes.find((item) => item.id === node.parentNode)
|
||||
if (!parent || parent.data?.kind !== 'PARALLEL') {
|
||||
return node
|
||||
}
|
||||
const lanes = parent.data.lanes
|
||||
const laneIndex = Math.min(
|
||||
lanes.length - 1,
|
||||
Math.max(0, Math.floor((node.position.y - LANE_HEADER) / LANE_H)),
|
||||
)
|
||||
const lane = lanes[laneIndex] ?? lanes[0]
|
||||
if (!lane) {
|
||||
return node
|
||||
}
|
||||
const data = node.data
|
||||
if (data.kind !== 'APPROVAL' && data.kind !== 'ACTION') {
|
||||
return node
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
zIndex: 1,
|
||||
data: { ...data, laneId: lane },
|
||||
position: {
|
||||
x: Math.max(LANE_LABEL_W, node.position.x),
|
||||
y: LANE_HEADER + laneIndex * LANE_H + 16,
|
||||
},
|
||||
}
|
||||
})
|
||||
return fitParallelFrames(next)
|
||||
}
|
||||
|
||||
export function approvalStepDocument(data: ApprovalNodeData, id: string): StepDocument {
|
||||
const step: StepDocument = {
|
||||
id,
|
||||
name: data.name,
|
||||
kind: 'APPROVAL',
|
||||
candidates: data.candidates,
|
||||
policy: data.policy,
|
||||
}
|
||||
if (data.dueEnabled && data.due.after && data.due.then) {
|
||||
step.due = {
|
||||
after: data.due.after,
|
||||
then: data.due.then,
|
||||
...(data.due.to ? { to: data.due.to } : {}),
|
||||
...(data.due.action ? { action: data.due.action } : {}),
|
||||
}
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
export function branchesOf(
|
||||
parallelId: string,
|
||||
lanes: string[],
|
||||
nodes: CanvasNode[],
|
||||
edges: CanvasEdge[],
|
||||
): BranchDocument[] {
|
||||
return lanes.map((lane) => {
|
||||
const steps = nodes
|
||||
.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane)
|
||||
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id))
|
||||
const ids = new Set(steps.map((step) => step.id))
|
||||
const transitions: TransitionDocument[] = edges
|
||||
.filter((edge) => ids.has(edge.source) && ids.has(edge.target))
|
||||
.map((edge) => ({
|
||||
from: edge.source,
|
||||
to: edge.target,
|
||||
...(edge.data.when ? { when: edge.data.when } : {}),
|
||||
priority: edge.data.priority ?? 0,
|
||||
}))
|
||||
for (const step of steps) {
|
||||
if (!edges.some((edge) => edge.source === step.id && ids.has(edge.target))) {
|
||||
transitions.push({ from: step.id, to: null, priority: 0 })
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: lane,
|
||||
steps: steps.map((node) => {
|
||||
if (node.data.kind === 'ACTION') {
|
||||
return { id: node.id, name: node.data.name, kind: 'ACTION' as const, action: node.data.action }
|
||||
}
|
||||
return approvalStepDocument(node.data as ApprovalNodeData, node.id)
|
||||
}),
|
||||
transitions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function spawnLaneStep(
|
||||
parallelId: string,
|
||||
laneId: string,
|
||||
laneIndex: number,
|
||||
stepIndex: number,
|
||||
id: string,
|
||||
): CanvasNode {
|
||||
const data: ApprovalNodeData = { ...defaultApprovalData(), name: 'Approval', laneId }
|
||||
return {
|
||||
id,
|
||||
type: 'approval',
|
||||
parentNode: parallelId,
|
||||
extent: 'parent',
|
||||
zIndex: 1,
|
||||
position: laneStepPosition(laneIndex, stepIndex),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
export function materializeParallel(
|
||||
id: string,
|
||||
name: string,
|
||||
position: { x: number; y: number },
|
||||
branches: BranchDocument[],
|
||||
isStart = false,
|
||||
): { parent: CanvasNode; children: CanvasNode[]; edges: CanvasEdge[] } {
|
||||
const lanes = branches.map((branch) => branch.id)
|
||||
const data: ParallelNodeData = { kind: 'PARALLEL', name, lanes, isStart }
|
||||
const maxSteps = Math.max(1, ...branches.map((branch) => branch.steps.length))
|
||||
const size = parallelSize(lanes.length, maxSteps)
|
||||
const parent: CanvasNode = {
|
||||
id,
|
||||
type: 'parallel',
|
||||
position,
|
||||
zIndex: 0,
|
||||
style: { width: `${size.width}px`, height: `${size.height}px` },
|
||||
data,
|
||||
}
|
||||
const children: CanvasNode[] = []
|
||||
const edges: CanvasEdge[] = []
|
||||
branches.forEach((branch, laneIndex) => {
|
||||
branch.steps.forEach((step, stepIndex) => {
|
||||
children.push(stepNode(step, id, branch.id, laneIndex, stepIndex))
|
||||
})
|
||||
branch.transitions.forEach((transition, index) => {
|
||||
if (!transition.to) {
|
||||
return
|
||||
}
|
||||
const data = { when: transition.when, priority: transition.priority ?? 0 }
|
||||
edges.push({
|
||||
id: `e-${id}-${branch.id}-${index}`,
|
||||
source: transition.from,
|
||||
target: transition.to,
|
||||
label: formatWhen(transition.when) || 'default',
|
||||
data,
|
||||
})
|
||||
})
|
||||
})
|
||||
return { parent, children, edges }
|
||||
}
|
||||
|
||||
function stepNode(
|
||||
step: StepDocument,
|
||||
parallelId: string,
|
||||
laneId: string,
|
||||
laneIndex: number,
|
||||
stepIndex: number,
|
||||
): CanvasNode {
|
||||
if (step.kind === 'ACTION') {
|
||||
const data: ActionNodeData = { ...defaultActionData(), name: step.name, action: step.action ?? '', laneId }
|
||||
return {
|
||||
id: step.id,
|
||||
type: 'action',
|
||||
parentNode: parallelId,
|
||||
extent: 'parent',
|
||||
zIndex: 1,
|
||||
position: laneStepPosition(laneIndex, stepIndex),
|
||||
data,
|
||||
}
|
||||
}
|
||||
const data: ApprovalNodeData = {
|
||||
...defaultApprovalData(),
|
||||
name: step.name,
|
||||
candidates: step.candidates ?? [],
|
||||
policy: step.policy ?? 'ANY',
|
||||
dueEnabled: Boolean(step.due),
|
||||
due: step.due
|
||||
? { after: step.due.after, then: step.due.then, to: step.due.to ?? undefined, action: step.due.action ?? undefined }
|
||||
: { after: 'PT48H', then: 'notify' },
|
||||
laneId,
|
||||
}
|
||||
return {
|
||||
id: step.id,
|
||||
type: 'approval',
|
||||
parentNode: parallelId,
|
||||
extent: 'parent',
|
||||
zIndex: 1,
|
||||
position: laneStepPosition(laneIndex, stepIndex),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
export function dropCrossLaneEdges(nodes: CanvasNode[], edges: CanvasEdge[]): CanvasEdge[] {
|
||||
const byId = new Map(nodes.map((node) => [node.id, node]))
|
||||
return edges.filter((edge) => {
|
||||
const source = byId.get(edge.source)
|
||||
const target = byId.get(edge.target)
|
||||
if (!source?.parentNode && !target?.parentNode) {
|
||||
return true
|
||||
}
|
||||
if (!source?.parentNode || !target?.parentNode) {
|
||||
return false
|
||||
}
|
||||
return source.parentNode === target.parentNode && laneOf(source.data) === laneOf(target.data)
|
||||
})
|
||||
}
|
||||
|
||||
export function reflowLanes(nodes: CanvasNode[], parallelId: string): CanvasNode[] {
|
||||
const parent = nodes.find((node) => node.id === parallelId)
|
||||
if (!parent || parent.data.kind !== 'PARALLEL') {
|
||||
return nodes
|
||||
}
|
||||
const placed = new Map<string, CanvasNode>()
|
||||
parent.data.lanes.forEach((lane, laneIndex) => {
|
||||
const steps = nodes
|
||||
.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane)
|
||||
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id))
|
||||
steps.forEach((step, stepIndex) => {
|
||||
const data = step.data
|
||||
if (data.kind !== 'APPROVAL' && data.kind !== 'ACTION') {
|
||||
return
|
||||
}
|
||||
placed.set(step.id, {
|
||||
...step,
|
||||
position: laneStepPosition(laneIndex, stepIndex),
|
||||
data: { ...data, laneId: lane },
|
||||
})
|
||||
})
|
||||
})
|
||||
return fitParallelFrames(nodes.map((node) => placed.get(node.id) ?? node))
|
||||
}
|
||||
|
||||
export function expandLegacyParallel(
|
||||
nodes: CanvasNode[],
|
||||
edges: CanvasEdge[],
|
||||
): { nodes: CanvasNode[]; edges: CanvasEdge[] } {
|
||||
const next: CanvasNode[] = []
|
||||
const extra: CanvasEdge[] = []
|
||||
for (const node of nodes) {
|
||||
const data = node.data as ParallelNodeData & { branches?: BranchDocument[] }
|
||||
if (data?.kind === 'PARALLEL' && data.branches?.length && !data.lanes) {
|
||||
const built = materializeParallel(node.id, data.name, node.position, data.branches, Boolean(data.isStart))
|
||||
next.push(built.parent, ...built.children)
|
||||
extra.push(...built.edges)
|
||||
continue
|
||||
}
|
||||
next.push(node)
|
||||
}
|
||||
return { nodes: next, edges: [...extra, ...edges] }
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
export type CompareOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in'
|
||||
|
||||
export type RoutingWhen =
|
||||
| { ref: string; args?: Record<string, unknown> }
|
||||
| { and: RoutingWhen[] }
|
||||
| { or: RoutingWhen[] }
|
||||
| { not: RoutingWhen }
|
||||
| { eq: [string, unknown] }
|
||||
| { ne: [string, unknown] }
|
||||
| { gt: [string, unknown] }
|
||||
| { gte: [string, unknown] }
|
||||
| { lt: [string, unknown] }
|
||||
| { lte: [string, unknown] }
|
||||
| { in: [string, unknown[]] }
|
||||
|
||||
const COMPARE_OPS: CompareOp[] = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in']
|
||||
|
||||
export function isRefWhen(when: RoutingWhen | undefined): when is { ref: string; args?: Record<string, unknown> } {
|
||||
return Boolean(when && typeof when === 'object' && 'ref' in when)
|
||||
}
|
||||
|
||||
export function formatWhen(when: RoutingWhen | undefined): string {
|
||||
if (!when) {
|
||||
return ''
|
||||
}
|
||||
if (isRefWhen(when)) {
|
||||
const args = when.args
|
||||
if (!args || Object.keys(args).length === 0) {
|
||||
return when.ref
|
||||
}
|
||||
const body = Object.entries(args)
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join(',')
|
||||
return `${when.ref}(${body})`
|
||||
}
|
||||
if ('and' in when) {
|
||||
return when.and.map(formatWhen).join(' AND ')
|
||||
}
|
||||
if ('or' in when) {
|
||||
return when.or.map((part) => `(${formatWhen(part)})`).join(' OR ')
|
||||
}
|
||||
if ('not' in when) {
|
||||
return `NOT (${formatWhen(when.not)})`
|
||||
}
|
||||
if ('in' in when) {
|
||||
return `${when.in[0]} in [${when.in[1].map(String).join(', ')}]`
|
||||
}
|
||||
for (const op of COMPARE_OPS) {
|
||||
if (op === 'in') {
|
||||
continue
|
||||
}
|
||||
if (op in when) {
|
||||
const pair = (when as Record<string, [string, unknown]>)[op]
|
||||
const symbol =
|
||||
op === 'eq' ? '=' : op === 'ne' ? '!=' : op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
|
||||
return `${pair?.[0]} ${symbol} ${String(pair?.[1])}`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function defaultLeaf(): RoutingWhen {
|
||||
return { gt: ['', 0] }
|
||||
}
|
||||
|
||||
export const MAX_PREDICATE_DEPTH = 8
|
||||
export const MAX_PREDICATE_LEAVES = 32
|
||||
|
||||
export function isLogicGroup(
|
||||
when: RoutingWhen,
|
||||
): when is { and: RoutingWhen[] } | { or: RoutingWhen[] } {
|
||||
return 'and' in when || 'or' in when
|
||||
}
|
||||
|
||||
export function isNotWhen(when: RoutingWhen): when is { not: RoutingWhen } {
|
||||
return 'not' in when
|
||||
}
|
||||
|
||||
export function groupChildren(when: RoutingWhen): RoutingWhen[] {
|
||||
if ('and' in when) {
|
||||
return when.and
|
||||
}
|
||||
if ('or' in when) {
|
||||
return when.or
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function leafOp(when: RoutingWhen): CompareOp {
|
||||
for (const op of COMPARE_OPS) {
|
||||
if (op in when) {
|
||||
return op
|
||||
}
|
||||
}
|
||||
return 'gt'
|
||||
}
|
||||
|
||||
export function leafVar(when: RoutingWhen): string {
|
||||
const pair = (when as Record<string, [string, unknown]>)[leafOp(when)]
|
||||
return pair?.[0] ?? ''
|
||||
}
|
||||
|
||||
export function leafValueText(when: RoutingWhen): string {
|
||||
const op = leafOp(when)
|
||||
const pair = (when as Record<string, [string, unknown]>)[op]
|
||||
const value = pair?.[1]
|
||||
if (op === 'in' && Array.isArray(value)) {
|
||||
return value.map(String).join(', ')
|
||||
}
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
|
||||
export function makeLeaf(op: CompareOp, variable: string, text: string): RoutingWhen {
|
||||
if (op === 'in') {
|
||||
const values = text
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map(coerceScalar)
|
||||
return { in: [variable, values] }
|
||||
}
|
||||
return { [op]: [variable, coerceScalar(text)] } as RoutingWhen
|
||||
}
|
||||
|
||||
function coerceScalar(text: string): unknown {
|
||||
if (text === 'true') {
|
||||
return true
|
||||
}
|
||||
if (text === 'false') {
|
||||
return false
|
||||
}
|
||||
if (text !== '' && !Number.isNaN(Number(text))) {
|
||||
return Number(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export function countLeaves(when: RoutingWhen | undefined): number {
|
||||
if (!when || isRefWhen(when)) {
|
||||
return 0
|
||||
}
|
||||
if (isLogicGroup(when)) {
|
||||
return groupChildren(when).reduce((sum, child) => sum + countLeaves(child), 0)
|
||||
}
|
||||
if (isNotWhen(when)) {
|
||||
return countLeaves(when.not)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export function predicateDepth(when: RoutingWhen | undefined): number {
|
||||
if (!when || isRefWhen(when)) {
|
||||
return 0
|
||||
}
|
||||
if (isLogicGroup(when)) {
|
||||
return 1 + groupChildren(when).reduce((max, child) => Math.max(max, predicateDepth(child)), 0)
|
||||
}
|
||||
if (isNotWhen(when)) {
|
||||
return 1 + predicateDepth(when.not)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export function validatePredicate(when: RoutingWhen | undefined): string | null {
|
||||
if (!when || isRefWhen(when)) {
|
||||
return 'Add at least one condition'
|
||||
}
|
||||
if (predicateDepth(when) > MAX_PREDICATE_DEPTH) {
|
||||
return `Nesting cannot exceed ${MAX_PREDICATE_DEPTH} levels`
|
||||
}
|
||||
if (countLeaves(when) > MAX_PREDICATE_LEAVES) {
|
||||
return `At most ${MAX_PREDICATE_LEAVES} conditions`
|
||||
}
|
||||
return validateNode(when)
|
||||
}
|
||||
|
||||
function validateNode(when: RoutingWhen): string | null {
|
||||
if (isLogicGroup(when)) {
|
||||
const children = groupChildren(when)
|
||||
if (children.length === 0) {
|
||||
return 'A group needs at least one condition'
|
||||
}
|
||||
for (const child of children) {
|
||||
const error = validateNode(child)
|
||||
if (error) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (isNotWhen(when)) {
|
||||
return validateNode(when.not)
|
||||
}
|
||||
if (!leafVar(when).trim()) {
|
||||
return 'Variable is required'
|
||||
}
|
||||
if (leafOp(when) === 'in' && leafValueText(when).trim() === '') {
|
||||
return 'in requires at least one value, comma-separated'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Single-child AND/OR is equivalent to its child; keep published JSON flat. */
|
||||
export function collapseTrivialGroups(when: RoutingWhen): RoutingWhen {
|
||||
if (isLogicGroup(when)) {
|
||||
const children = groupChildren(when).map(collapseTrivialGroups)
|
||||
if (children.length === 1) {
|
||||
return children[0] ?? defaultLeaf()
|
||||
}
|
||||
return 'and' in when ? { and: children } : { or: children }
|
||||
}
|
||||
if (isNotWhen(when)) {
|
||||
return { not: collapseTrivialGroups(when.not) }
|
||||
}
|
||||
return when
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import DefinitionListView from '@/views/DefinitionListView.vue'
|
||||
import DesignerView from '@/views/DesignerView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/', name: 'definitions', component: DefinitionListView },
|
||||
{ path: '/designer/:definitionId?', name: 'designer', component: DesignerView },
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as ordo from '@/api/ordo'
|
||||
import type { CatalogItem } from '@/types/definition'
|
||||
|
||||
export const useCatalogStore = defineStore('catalog', () => {
|
||||
const conditions = ref<CatalogItem[]>([])
|
||||
const actions = ref<CatalogItem[]>([])
|
||||
const assignees = ref<CatalogItem[]>([])
|
||||
const loaded = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
error.value = null
|
||||
try {
|
||||
const [c, a, s] = await Promise.all([
|
||||
ordo.listConditions(),
|
||||
ordo.listActions(),
|
||||
ordo.listAssignees(),
|
||||
])
|
||||
conditions.value = c
|
||||
actions.value = a
|
||||
assignees.value = s
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
conditions.value = []
|
||||
actions.value = []
|
||||
assignees.value = []
|
||||
loaded.value = false
|
||||
error.value = e instanceof Error ? e.message : 'Catalog unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
return { conditions, actions, assignees, loaded, error, load }
|
||||
})
|
||||
@@ -0,0 +1,795 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Connection } from '@vue-flow/core'
|
||||
import {
|
||||
ACTION_TYPE,
|
||||
APPROVAL_TYPE,
|
||||
END_TYPE,
|
||||
PARALLEL_TYPE,
|
||||
END_NODE_ID,
|
||||
edgeLabel,
|
||||
fromDocument,
|
||||
layoutFromNodes,
|
||||
toDocument,
|
||||
type DesignerEdge,
|
||||
type DesignerNode,
|
||||
validateGraph,
|
||||
} from '@/mapper/graph'
|
||||
import { dropCrossLaneEdges, expandLegacyParallel, fitParallelFrames, laneOf, materializeParallel, reflowLanes, settleLaneNodes, spawnLaneStep } from '@/mapper/parallel'
|
||||
import { applyLayeredLayout, assignEdgeHandles, normalizeHandleId } from '@/mapper/layout'
|
||||
import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition'
|
||||
import type { DesignerEdgeData, DesignerNodeData, ProcessDefinitionDocument } from '@/types/definition'
|
||||
|
||||
let seq = 0
|
||||
function nextId(prefix: string) {
|
||||
seq += 1
|
||||
return `${prefix}-${Date.now().toString(36)}-${seq}`
|
||||
}
|
||||
|
||||
interface GraphSnapshot {
|
||||
definitionId: string
|
||||
name: string
|
||||
startStepId: string
|
||||
nodes: DesignerNode[]
|
||||
edges: DesignerEdge[]
|
||||
}
|
||||
|
||||
interface ClipboardPayload {
|
||||
nodes: DesignerNode[]
|
||||
edges: DesignerEdge[]
|
||||
}
|
||||
|
||||
function slimNode(node: DesignerNode): DesignerNode {
|
||||
return {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
position: { x: node.position.x, y: node.position.y },
|
||||
parentNode: node.parentNode,
|
||||
extent: node.extent,
|
||||
style: node.style ? { ...node.style } : undefined,
|
||||
zIndex: node.zIndex,
|
||||
data: JSON.parse(JSON.stringify(node.data)) as DesignerNodeData,
|
||||
}
|
||||
}
|
||||
|
||||
function slimEdge(edge: DesignerEdge): DesignerEdge {
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
label: edge.label,
|
||||
data: { ...(edge.data ?? { priority: 0 }) },
|
||||
}
|
||||
}
|
||||
|
||||
export const useDesignerStore = defineStore('designer', () => {
|
||||
const definitionId = ref('')
|
||||
const name = ref('')
|
||||
const startStepId = ref('')
|
||||
const publishedVersion = ref<number | null>(null)
|
||||
const loadedFromDraft = ref(false)
|
||||
const nodes = ref<DesignerNode[]>([])
|
||||
const edges = ref<DesignerEdge[]>([])
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const selectedEdgeId = ref<string | null>(null)
|
||||
const selectedNodeIds = ref<string[]>([])
|
||||
const selectedEdgeIds = ref<string[]>([])
|
||||
const baseline = ref('')
|
||||
const undoStack = ref<GraphSnapshot[]>([])
|
||||
const redoStack = ref<GraphSnapshot[]>([])
|
||||
let historyPaused = false
|
||||
let clipboard: ClipboardPayload | null = null
|
||||
|
||||
const selectedNode = computed<DesignerNode | undefined>(() =>
|
||||
nodes.value.find((n) => n.id === selectedNodeId.value),
|
||||
)
|
||||
const selectedEdge = computed<DesignerEdge | undefined>(() =>
|
||||
edges.value.find((e) => e.id === selectedEdgeId.value),
|
||||
)
|
||||
const graphErrors = computed(() =>
|
||||
validateGraph(definitionId.value, name.value, startStepId.value, nodes.value, edges.value),
|
||||
)
|
||||
const canUndo = computed(() => undoStack.value.length > 0)
|
||||
const canRedo = computed(() => redoStack.value.length > 0)
|
||||
const canPaste = computed(() => Boolean(clipboard?.nodes.length))
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify({
|
||||
id: definitionId.value,
|
||||
name: name.value,
|
||||
startStepId: startStepId.value,
|
||||
doc: toDocument(definitionId.value, name.value, startStepId.value, nodes.value, edges.value),
|
||||
layout: layoutFromNodes(nodes.value),
|
||||
})
|
||||
}
|
||||
|
||||
function captureBaseline() {
|
||||
baseline.value = snapshot()
|
||||
}
|
||||
|
||||
const isDirty = computed(() => snapshot() !== baseline.value)
|
||||
|
||||
function cloneGraph(): GraphSnapshot {
|
||||
return {
|
||||
definitionId: definitionId.value,
|
||||
name: name.value,
|
||||
startStepId: startStepId.value,
|
||||
nodes: nodes.value.map(slimNode),
|
||||
edges: edges.value.map(slimEdge),
|
||||
}
|
||||
}
|
||||
|
||||
function resetHistory() {
|
||||
undoStack.value = []
|
||||
redoStack.value = []
|
||||
}
|
||||
|
||||
function pushHistory() {
|
||||
if (historyPaused) {
|
||||
return
|
||||
}
|
||||
undoStack.value = [...undoStack.value, cloneGraph()].slice(-50)
|
||||
redoStack.value = []
|
||||
}
|
||||
|
||||
function applySnapshot(snap: GraphSnapshot) {
|
||||
historyPaused = true
|
||||
definitionId.value = snap.definitionId
|
||||
name.value = snap.name
|
||||
startStepId.value = snap.startStepId
|
||||
nodes.value = snap.nodes.map(slimNode)
|
||||
edges.value = snap.edges.map(slimEdge)
|
||||
selectedNodeId.value = null
|
||||
selectedEdgeId.value = null
|
||||
selectedNodeIds.value = []
|
||||
selectedEdgeIds.value = []
|
||||
historyPaused = false
|
||||
}
|
||||
|
||||
function undo() {
|
||||
const prev = undoStack.value.at(-1)
|
||||
if (!prev) {
|
||||
return false
|
||||
}
|
||||
const current = cloneGraph()
|
||||
undoStack.value = undoStack.value.slice(0, -1)
|
||||
redoStack.value = [...redoStack.value, current]
|
||||
applySnapshot(prev)
|
||||
return true
|
||||
}
|
||||
|
||||
function redo() {
|
||||
const next = redoStack.value.at(-1)
|
||||
if (!next) {
|
||||
return false
|
||||
}
|
||||
const current = cloneGraph()
|
||||
redoStack.value = redoStack.value.slice(0, -1)
|
||||
undoStack.value = [...undoStack.value, current]
|
||||
applySnapshot(next)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncStartFlags() {
|
||||
nodes.value = nodes.value.map((node) => {
|
||||
if (!node.data || node.data.kind === 'END') {
|
||||
return node
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
data: { ...node.data, isStart: node.id === startStepId.value },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedNodeId.value = null
|
||||
selectedEdgeId.value = null
|
||||
selectedNodeIds.value = []
|
||||
selectedEdgeIds.value = []
|
||||
}
|
||||
|
||||
function clear() {
|
||||
definitionId.value = ''
|
||||
name.value = ''
|
||||
startStepId.value = ''
|
||||
publishedVersion.value = null
|
||||
loadedFromDraft.value = false
|
||||
nodes.value = []
|
||||
edges.value = []
|
||||
clearSelection()
|
||||
resetHistory()
|
||||
captureBaseline()
|
||||
}
|
||||
|
||||
function resetEmpty(id = `process-${Date.now().toString(36)}`) {
|
||||
definitionId.value = id
|
||||
name.value = 'Untitled process'
|
||||
startStepId.value = ''
|
||||
publishedVersion.value = null
|
||||
loadedFromDraft.value = false
|
||||
nodes.value = []
|
||||
edges.value = []
|
||||
clearSelection()
|
||||
resetHistory()
|
||||
captureBaseline()
|
||||
}
|
||||
|
||||
function loadDocument(doc: ProcessDefinitionDocument) {
|
||||
const mapped = fromDocument(doc)
|
||||
definitionId.value = doc.id
|
||||
name.value = doc.name
|
||||
startStepId.value = mapped.startStepId
|
||||
publishedVersion.value = doc.version ?? null
|
||||
loadedFromDraft.value = false
|
||||
const laid = applyLayeredLayout(mapped.nodes, mapped.edges, mapped.startStepId)
|
||||
nodes.value = laid.nodes
|
||||
edges.value = laid.edges
|
||||
clearSelection()
|
||||
resetHistory()
|
||||
captureBaseline()
|
||||
}
|
||||
|
||||
function loadGraph(params: {
|
||||
id: string
|
||||
name: string
|
||||
startStepId: string
|
||||
nodes: DesignerNode[]
|
||||
edges: DesignerEdge[]
|
||||
version?: number | null
|
||||
fromDraft?: boolean
|
||||
}) {
|
||||
definitionId.value = params.id
|
||||
name.value = params.name
|
||||
startStepId.value = params.startStepId
|
||||
publishedVersion.value = params.version ?? null
|
||||
loadedFromDraft.value = Boolean(params.fromDraft)
|
||||
const expanded = expandLegacyParallel(params.nodes, params.edges)
|
||||
nodes.value = fitParallelFrames(expanded.nodes)
|
||||
edges.value = assignEdgeHandles(expanded.nodes, expanded.edges)
|
||||
syncStartFlags()
|
||||
clearSelection()
|
||||
resetHistory()
|
||||
captureBaseline()
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
pushHistory()
|
||||
const laid = applyLayeredLayout(nodes.value, edges.value, startStepId.value)
|
||||
nodes.value = laid.nodes
|
||||
edges.value = laid.edges
|
||||
}
|
||||
|
||||
function syncEdgeHandles() {
|
||||
edges.value = assignEdgeHandles(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
function settleLanes() {
|
||||
const settled = settleLaneNodes(nodes.value)
|
||||
nodes.value = settled
|
||||
edges.value = assignEdgeHandles(settled, dropCrossLaneEdges(settled, edges.value))
|
||||
}
|
||||
|
||||
function addNode(type: string, position: { x: number; y: number }): 'ok' | 'duplicate-end' {
|
||||
let data: DesignerNodeData
|
||||
let nodeType = type
|
||||
if (type === APPROVAL_TYPE) {
|
||||
data = defaultApprovalData()
|
||||
} else if (type === ACTION_TYPE) {
|
||||
data = defaultActionData()
|
||||
} else if (type === PARALLEL_TYPE) {
|
||||
pushHistory()
|
||||
const id = nextId(nodeType)
|
||||
const built = materializeParallel(id, 'Parallel', position, [
|
||||
{ id: 'legal', steps: [{ id: nextId('legal'), name: 'Legal', candidates: [], policy: 'ANY' }], transitions: [] },
|
||||
{ id: 'finance', steps: [{ id: nextId('finance'), name: 'Finance', candidates: [], policy: 'ANY' }], transitions: [] },
|
||||
])
|
||||
nodes.value = fitParallelFrames([...nodes.value, built.parent, ...built.children])
|
||||
if (!startStepId.value) {
|
||||
startStepId.value = id
|
||||
syncStartFlags()
|
||||
}
|
||||
selectedNodeId.value = id
|
||||
selectedNodeIds.value = [id]
|
||||
selectedEdgeId.value = null
|
||||
selectedEdgeIds.value = []
|
||||
return 'ok'
|
||||
} else {
|
||||
nodeType = END_TYPE
|
||||
if (nodes.value.some((n) => n.type === END_TYPE)) {
|
||||
return 'duplicate-end'
|
||||
}
|
||||
data = defaultEndData()
|
||||
}
|
||||
pushHistory()
|
||||
const id = nodeType === END_TYPE ? END_NODE_ID : nextId(nodeType)
|
||||
const node: DesignerNode = { id, type: nodeType, position, data }
|
||||
nodes.value = [...nodes.value, node]
|
||||
if (!startStepId.value && nodeType !== END_TYPE) {
|
||||
startStepId.value = id
|
||||
syncStartFlags()
|
||||
}
|
||||
selectedNodeId.value = id
|
||||
selectedNodeIds.value = [id]
|
||||
selectedEdgeId.value = null
|
||||
selectedEdgeIds.value = []
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function setStart(id: string) {
|
||||
const node = nodes.value.find((n) => n.id === id)
|
||||
if (!node || node.type === END_TYPE || node.parentNode) {
|
||||
return
|
||||
}
|
||||
pushHistory()
|
||||
startStepId.value = id
|
||||
syncStartFlags()
|
||||
}
|
||||
|
||||
function renameNode(oldId: string, newId: string): boolean {
|
||||
const trimmed = newId.trim()
|
||||
if (!trimmed || trimmed === oldId) {
|
||||
return true
|
||||
}
|
||||
if (nodes.value.some((n) => n.id === trimmed)) {
|
||||
return false
|
||||
}
|
||||
pushHistory()
|
||||
nodes.value = nodes.value.map((n) => (n.id === oldId ? { ...n, id: trimmed } : n))
|
||||
edges.value = edges.value.map((e) => ({
|
||||
...e,
|
||||
source: e.source === oldId ? trimmed : e.source,
|
||||
target: e.target === oldId ? trimmed : e.target,
|
||||
}))
|
||||
if (startStepId.value === oldId) {
|
||||
startStepId.value = trimmed
|
||||
}
|
||||
if (selectedNodeId.value === oldId) {
|
||||
selectedNodeId.value = trimmed
|
||||
}
|
||||
selectedNodeIds.value = selectedNodeIds.value.map((id) => (id === oldId ? trimmed : id))
|
||||
return true
|
||||
}
|
||||
|
||||
function patchNodeData(id: string, patch: Record<string, unknown>) {
|
||||
pushHistory()
|
||||
nodes.value = nodes.value.map((n) => {
|
||||
if (n.id !== id) {
|
||||
return n
|
||||
}
|
||||
return { ...n, data: { ...n.data, ...patch } as DesignerNodeData }
|
||||
})
|
||||
}
|
||||
|
||||
function patchEdgeData(id: string, patch: Partial<DesignerEdgeData>) {
|
||||
pushHistory()
|
||||
edges.value = edges.value.map((e) => {
|
||||
if (e.id !== id) {
|
||||
return e
|
||||
}
|
||||
const data = { ...(e.data ?? { priority: 0 }), ...patch }
|
||||
if ('when' in patch && patch.when === undefined) {
|
||||
delete data.when
|
||||
}
|
||||
return { ...e, data, label: edgeLabel(data) }
|
||||
})
|
||||
}
|
||||
|
||||
function onConnect(connection: Connection) {
|
||||
if (!connection.source || !connection.target) {
|
||||
return
|
||||
}
|
||||
const sourceNode = nodes.value.find((n) => n.id === connection.source)
|
||||
const targetNode = nodes.value.find((n) => n.id === connection.target)
|
||||
if (sourceNode?.type === END_TYPE || targetNode?.type === PARALLEL_TYPE) {
|
||||
return
|
||||
}
|
||||
const inner = Boolean(sourceNode?.parentNode || targetNode?.parentNode)
|
||||
if (inner && (sourceNode?.parentNode !== targetNode?.parentNode || laneOf(sourceNode?.data) !== laneOf(targetNode?.data))) {
|
||||
return
|
||||
}
|
||||
pushHistory()
|
||||
const siblings = edges.value.filter((e) => e.source === connection.source)
|
||||
const used = new Set(siblings.map((e) => e.data?.priority ?? 0))
|
||||
let priority = 0
|
||||
while (used.has(priority)) {
|
||||
priority += 1
|
||||
}
|
||||
const data: DesignerEdgeData = { priority }
|
||||
const edge: DesignerEdge = {
|
||||
id: nextId('e'),
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: normalizeHandleId(connection.sourceHandle) ?? 'source-bottom',
|
||||
targetHandle: normalizeHandleId(connection.targetHandle) ?? 'target-top',
|
||||
label: edgeLabel(data),
|
||||
data,
|
||||
}
|
||||
edges.value = [...edges.value, edge]
|
||||
edges.value = assignEdgeHandles(nodes.value, edges.value)
|
||||
}
|
||||
|
||||
function selectNode(id: string | null) {
|
||||
selectedNodeId.value = id
|
||||
selectedNodeIds.value = id ? [id] : []
|
||||
if (id) {
|
||||
selectedEdgeId.value = null
|
||||
selectedEdgeIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function selectEdge(id: string | null) {
|
||||
selectedEdgeId.value = id
|
||||
selectedEdgeIds.value = id ? [id] : []
|
||||
if (id) {
|
||||
selectedNodeId.value = null
|
||||
selectedNodeIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function setSelection(nodeIds: string[], edgeIds: string[]) {
|
||||
selectedNodeIds.value = nodeIds
|
||||
selectedEdgeIds.value = edgeIds
|
||||
selectedNodeId.value = nodeIds.length === 1 ? (nodeIds[0] ?? null) : nodeIds[0] ?? null
|
||||
if (nodeIds.length > 1) {
|
||||
selectedNodeId.value = nodeIds[0] ?? null
|
||||
}
|
||||
selectedEdgeId.value = nodeIds.length ? null : (edgeIds[0] ?? null)
|
||||
}
|
||||
|
||||
function hasOutgoing(id: string) {
|
||||
return edges.value.some((e) => e.source === id)
|
||||
}
|
||||
|
||||
function hasOutgoingAmong(ids: string[]) {
|
||||
return ids.some((id) => hasOutgoing(id))
|
||||
}
|
||||
|
||||
function removeNode(id: string) {
|
||||
const node = nodes.value.find((n) => n.id === id)
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
const drop = new Set([id, ...nodes.value.filter((n) => n.parentNode === id).map((n) => n.id)])
|
||||
pushHistory()
|
||||
nodes.value = nodes.value.filter((n) => !drop.has(n.id))
|
||||
edges.value = edges.value.filter((e) => !drop.has(e.source) && !drop.has(e.target))
|
||||
selectedNodeIds.value = selectedNodeIds.value.filter((n) => !drop.has(n))
|
||||
if (selectedNodeId.value && drop.has(selectedNodeId.value)) {
|
||||
selectedNodeId.value = selectedNodeIds.value[0] ?? null
|
||||
}
|
||||
if (drop.has(startStepId.value)) {
|
||||
const next = nodes.value.find((n) => n.type !== END_TYPE && !n.parentNode)
|
||||
startStepId.value = next?.id ?? ''
|
||||
syncStartFlags()
|
||||
}
|
||||
}
|
||||
|
||||
function removeEdge(id: string) {
|
||||
pushHistory()
|
||||
edges.value = edges.value.filter((e) => e.id !== id)
|
||||
selectedEdgeIds.value = selectedEdgeIds.value.filter((e) => e !== id)
|
||||
if (selectedEdgeId.value === id) {
|
||||
selectedEdgeId.value = selectedEdgeIds.value[0] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function removeNodes(ids: string[]) {
|
||||
const set = new Set(ids)
|
||||
for (const node of nodes.value) {
|
||||
if (node.parentNode && set.has(node.parentNode)) {
|
||||
set.add(node.id)
|
||||
}
|
||||
}
|
||||
if (!set.size) {
|
||||
return
|
||||
}
|
||||
pushHistory()
|
||||
nodes.value = nodes.value.filter((n) => !set.has(n.id))
|
||||
edges.value = edges.value.filter((e) => !set.has(e.source) && !set.has(e.target))
|
||||
selectedNodeIds.value = []
|
||||
selectedNodeId.value = null
|
||||
if (set.has(startStepId.value)) {
|
||||
const next = nodes.value.find((n) => n.type !== END_TYPE && !n.parentNode)
|
||||
startStepId.value = next?.id ?? ''
|
||||
syncStartFlags()
|
||||
}
|
||||
}
|
||||
|
||||
function removeEdges(ids: string[]) {
|
||||
const set = new Set(ids)
|
||||
if (!set.size) {
|
||||
return
|
||||
}
|
||||
pushHistory()
|
||||
edges.value = edges.value.filter((e) => !set.has(e.id))
|
||||
selectedEdgeIds.value = []
|
||||
selectedEdgeId.value = null
|
||||
}
|
||||
|
||||
function removeSelected() {
|
||||
if (selectedNodeIds.value.length) {
|
||||
removeNodes(selectedNodeIds.value)
|
||||
return
|
||||
}
|
||||
if (selectedNodeId.value) {
|
||||
removeNode(selectedNodeId.value)
|
||||
return
|
||||
}
|
||||
if (selectedEdgeIds.value.length) {
|
||||
removeEdges(selectedEdgeIds.value)
|
||||
return
|
||||
}
|
||||
if (selectedEdgeId.value) {
|
||||
removeEdge(selectedEdgeId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function withDescendants(ids: string[]) {
|
||||
const set = new Set(ids)
|
||||
let grew = true
|
||||
while (grew) {
|
||||
grew = false
|
||||
for (const node of nodes.value) {
|
||||
if (node.parentNode && set.has(node.parentNode) && !set.has(node.id)) {
|
||||
set.add(node.id)
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
function copySelection() {
|
||||
const ids = selectedNodeIds.value.length
|
||||
? selectedNodeIds.value
|
||||
: selectedNodeId.value
|
||||
? [selectedNodeId.value]
|
||||
: []
|
||||
if (!ids.length) {
|
||||
clipboard = null
|
||||
return false
|
||||
}
|
||||
const idSet = new Set(withDescendants(ids))
|
||||
clipboard = {
|
||||
nodes: nodes.value.filter((n) => idSet.has(n.id)).map(slimNode),
|
||||
edges: edges.value
|
||||
.filter((e) => idSet.has(e.source) && idSet.has(e.target))
|
||||
.map(slimEdge),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function pasteClipboard() {
|
||||
if (!clipboard?.nodes.length) {
|
||||
return false
|
||||
}
|
||||
pushHistory()
|
||||
const hasEnd = nodes.value.some((n) => n.type === END_TYPE)
|
||||
const idMap = new Map<string, string>()
|
||||
const pastedNodes: DesignerNode[] = []
|
||||
const ordered = [...clipboard.nodes].sort((a, b) => Number(Boolean(a.parentNode)) - Number(Boolean(b.parentNode)))
|
||||
for (const node of ordered) {
|
||||
if (node.type === END_TYPE && hasEnd) {
|
||||
continue
|
||||
}
|
||||
const id = node.type === END_TYPE ? END_NODE_ID : nextId(node.type)
|
||||
idMap.set(node.id, id)
|
||||
const parentMoved = Boolean(node.parentNode && idMap.has(node.parentNode))
|
||||
pastedNodes.push({
|
||||
...slimNode(node),
|
||||
id,
|
||||
parentNode: node.parentNode ? (idMap.get(node.parentNode) ?? node.parentNode) : undefined,
|
||||
position: parentMoved || !node.parentNode
|
||||
? node.parentNode
|
||||
? { ...node.position }
|
||||
: { x: node.position.x + 40, y: node.position.y + 40 }
|
||||
: { x: node.position.x + 40, y: node.position.y },
|
||||
})
|
||||
}
|
||||
const pastedEdges: DesignerEdge[] = []
|
||||
for (const edge of clipboard.edges) {
|
||||
const source = idMap.get(edge.source)
|
||||
const target = idMap.get(edge.target)
|
||||
if (!source || !target) {
|
||||
continue
|
||||
}
|
||||
const data = { ...(edge.data ?? { priority: 0 }) }
|
||||
pastedEdges.push({
|
||||
...slimEdge(edge),
|
||||
id: nextId('e'),
|
||||
source,
|
||||
target,
|
||||
data,
|
||||
label: edgeLabel(data),
|
||||
})
|
||||
}
|
||||
nodes.value = fitParallelFrames([...nodes.value, ...pastedNodes])
|
||||
edges.value = [...edges.value, ...pastedEdges]
|
||||
selectedNodeIds.value = pastedNodes.map((n) => n.id)
|
||||
selectedNodeId.value = pastedNodes[0]?.id ?? null
|
||||
selectedEdgeId.value = null
|
||||
selectedEdgeIds.value = []
|
||||
return pastedNodes.length > 0
|
||||
}
|
||||
|
||||
function alignSelected(mode: 'left' | 'center' | 'top'): boolean {
|
||||
const ids = selectedNodeIds.value.length >= 2 ? selectedNodeIds.value : []
|
||||
if (ids.length < 2) {
|
||||
return false
|
||||
}
|
||||
const set = new Set(ids)
|
||||
const selected = nodes.value.filter((n) => set.has(n.id))
|
||||
pushHistory()
|
||||
if (mode === 'left') {
|
||||
const x = Math.min(...selected.map((n) => n.position.x))
|
||||
for (const n of selected) {
|
||||
n.position = { ...n.position, x }
|
||||
}
|
||||
} else if (mode === 'center') {
|
||||
const xs = selected.map((n) => n.position.x)
|
||||
const x = (Math.min(...xs) + Math.max(...xs)) / 2
|
||||
for (const n of selected) {
|
||||
n.position = { ...n.position, x }
|
||||
}
|
||||
} else {
|
||||
const y = Math.min(...selected.map((n) => n.position.y))
|
||||
for (const n of selected) {
|
||||
n.position = { ...n.position, y }
|
||||
}
|
||||
}
|
||||
nodes.value = settleLaneNodes(nodes.value)
|
||||
return true
|
||||
}
|
||||
|
||||
function laneName(lanes: string[]) {
|
||||
let index = lanes.length + 1
|
||||
let id = `branch-${index}`
|
||||
while (lanes.includes(id)) {
|
||||
index += 1
|
||||
id = `branch-${index}`
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
function addLane(parallelId: string) {
|
||||
const parent = nodes.value.find((node) => node.id === parallelId)
|
||||
if (!parent || parent.data.kind !== 'PARALLEL') {
|
||||
return
|
||||
}
|
||||
pushHistory()
|
||||
const lane = laneName(parent.data.lanes)
|
||||
const step = spawnLaneStep(parallelId, lane, parent.data.lanes.length, 0, nextId(lane))
|
||||
nodes.value = fitParallelFrames([
|
||||
...nodes.value.map((node) =>
|
||||
node.id === parallelId && node.data.kind === 'PARALLEL'
|
||||
? { ...node, data: { ...node.data, lanes: [...node.data.lanes, lane] } }
|
||||
: node,
|
||||
),
|
||||
step,
|
||||
])
|
||||
}
|
||||
|
||||
function removeLane(parallelId: string, lane: string) {
|
||||
const parent = nodes.value.find((node) => node.id === parallelId)
|
||||
if (!parent || parent.data.kind !== 'PARALLEL' || parent.data.lanes.length <= 2) {
|
||||
return
|
||||
}
|
||||
pushHistory()
|
||||
const drop = new Set(
|
||||
nodes.value.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane).map((node) => node.id),
|
||||
)
|
||||
nodes.value = reflowLanes(
|
||||
nodes.value
|
||||
.filter((node) => !drop.has(node.id))
|
||||
.map((node) =>
|
||||
node.id === parallelId && node.data.kind === 'PARALLEL'
|
||||
? { ...node, data: { ...node.data, lanes: node.data.lanes.filter((item: string) => item !== lane) } }
|
||||
: node,
|
||||
),
|
||||
parallelId,
|
||||
)
|
||||
edges.value = edges.value.filter((edge) => !drop.has(edge.source) && !drop.has(edge.target))
|
||||
}
|
||||
|
||||
function addLaneStep(parallelId: string, lane: string) {
|
||||
const parent = nodes.value.find((node) => node.id === parallelId)
|
||||
if (!parent || parent.data.kind !== 'PARALLEL' || !parent.data.lanes.includes(lane)) {
|
||||
return
|
||||
}
|
||||
pushHistory()
|
||||
const laneIndex = parent.data.lanes.indexOf(lane)
|
||||
const stepIndex = nodes.value.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane).length
|
||||
nodes.value = fitParallelFrames([...nodes.value, spawnLaneStep(parallelId, lane, laneIndex, stepIndex, nextId(lane))])
|
||||
}
|
||||
|
||||
function renameLane(parallelId: string, from: string, to: string): boolean {
|
||||
const trimmed = to.trim()
|
||||
const parent = nodes.value.find((node) => node.id === parallelId)
|
||||
if (!parent || parent.data.kind !== 'PARALLEL' || !trimmed || trimmed === from) {
|
||||
return trimmed === from
|
||||
}
|
||||
if (parent.data.lanes.includes(trimmed)) {
|
||||
return false
|
||||
}
|
||||
pushHistory()
|
||||
nodes.value = nodes.value.map((node) => {
|
||||
if (node.id === parallelId && node.data.kind === 'PARALLEL') {
|
||||
return { ...node, data: { ...node.data, lanes: node.data.lanes.map((lane: string) => (lane === from ? trimmed : lane)) } }
|
||||
}
|
||||
if (node.parentNode === parallelId && laneOf(node.data) === from && (node.data.kind === 'APPROVAL' || node.data.kind === 'ACTION')) {
|
||||
return { ...node, data: { ...node.data, laneId: trimmed } }
|
||||
}
|
||||
return node
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function document(): ProcessDefinitionDocument {
|
||||
return toDocument(definitionId.value, name.value, startStepId.value, nodes.value, edges.value)
|
||||
}
|
||||
|
||||
function localErrors() {
|
||||
return graphErrors.value
|
||||
}
|
||||
|
||||
return {
|
||||
definitionId,
|
||||
name,
|
||||
startStepId,
|
||||
publishedVersion,
|
||||
loadedFromDraft,
|
||||
nodes,
|
||||
edges,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
selectedNodeIds,
|
||||
selectedEdgeIds,
|
||||
selectedNode,
|
||||
selectedEdge,
|
||||
graphErrors,
|
||||
isDirty,
|
||||
canUndo,
|
||||
canRedo,
|
||||
canPaste,
|
||||
clear,
|
||||
resetEmpty,
|
||||
loadDocument,
|
||||
loadGraph,
|
||||
addNode,
|
||||
setStart,
|
||||
renameNode,
|
||||
patchNodeData,
|
||||
patchEdgeData,
|
||||
onConnect,
|
||||
selectNode,
|
||||
selectEdge,
|
||||
setSelection,
|
||||
removeNode,
|
||||
removeEdge,
|
||||
removeNodes,
|
||||
removeEdges,
|
||||
removeSelected,
|
||||
hasOutgoing,
|
||||
hasOutgoingAmong,
|
||||
copySelection,
|
||||
pasteClipboard,
|
||||
undo,
|
||||
redo,
|
||||
pushHistory,
|
||||
alignSelected,
|
||||
document,
|
||||
localErrors,
|
||||
captureBaseline,
|
||||
relayout,
|
||||
syncEdgeHandles,
|
||||
settleLanes,
|
||||
syncStartFlags,
|
||||
addLane,
|
||||
removeLane,
|
||||
addLaneStep,
|
||||
renameLane,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { ProcessDefinitionDocument } from '@/types/definition'
|
||||
import { layoutFromNodes, type DesignerEdge, type DesignerNode } from '@/mapper/graph'
|
||||
|
||||
export interface DesignerDraft {
|
||||
id: string
|
||||
name: string
|
||||
startStepId: string
|
||||
nodes: DesignerNode[]
|
||||
edges: DesignerEdge[]
|
||||
layout: Record<string, { x: number; y: number }>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ordo-designer-drafts'
|
||||
|
||||
function readAll(): Record<string, DesignerDraft> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
return JSON.parse(raw) as Record<string, DesignerDraft>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(drafts: Record<string, DesignerDraft>) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
|
||||
}
|
||||
|
||||
export const useDraftsStore = defineStore('drafts', () => {
|
||||
const revision = ref(0)
|
||||
|
||||
function list(): DesignerDraft[] {
|
||||
return Object.values(readAll()).sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
function get(id: string): DesignerDraft | undefined {
|
||||
return readAll()[id]
|
||||
}
|
||||
|
||||
function save(params: {
|
||||
id: string
|
||||
name: string
|
||||
startStepId: string
|
||||
nodes: DesignerNode[]
|
||||
edges: DesignerEdge[]
|
||||
}) {
|
||||
const drafts = readAll()
|
||||
drafts[params.id] = {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
startStepId: params.startStepId,
|
||||
nodes: params.nodes,
|
||||
edges: params.edges,
|
||||
layout: layoutFromNodes(params.nodes),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
writeAll(drafts)
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
function layoutOf(id: string): Record<string, { x: number; y: number }> {
|
||||
return readAll()[id]?.layout ?? {}
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
const drafts = readAll()
|
||||
delete drafts[id]
|
||||
writeAll(drafts)
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
function fromPublished(doc: ProcessDefinitionDocument): DesignerDraft | undefined {
|
||||
return get(doc.id)
|
||||
}
|
||||
|
||||
return { revision, list, get, save, layoutOf, remove, fromPublished }
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vue-flow {
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, #e2e8f0 1px, transparent 0) 0 0 / 20px 20px,
|
||||
#f8fafc;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.vue-flow__handle {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.vue-flow__handle:hover {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.pointer-events-all {
|
||||
pointer-events: all;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { RoutingWhen } from '@/mapper/when'
|
||||
|
||||
export type StepKind = 'APPROVAL' | 'ACTION' | 'PARALLEL'
|
||||
export type ApprovalPolicy = 'ANY' | 'ALL'
|
||||
export type DueThen = 'reassign' | 'notify' | 'goto'
|
||||
|
||||
export interface StepDueDocument {
|
||||
after: string
|
||||
then: DueThen
|
||||
to?: string | null
|
||||
action?: string | null
|
||||
}
|
||||
|
||||
export interface BranchDocument {
|
||||
id: string
|
||||
steps: StepDocument[]
|
||||
transitions: TransitionDocument[]
|
||||
}
|
||||
|
||||
export interface StepDocument {
|
||||
id: string
|
||||
name: string
|
||||
kind?: StepKind
|
||||
candidates?: string[]
|
||||
policy?: ApprovalPolicy
|
||||
action?: string
|
||||
due?: StepDueDocument | null
|
||||
branches?: BranchDocument[]
|
||||
}
|
||||
|
||||
export interface TransitionDocument {
|
||||
from: string
|
||||
to: string | null
|
||||
when?: RoutingWhen
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface ProcessDefinitionDocument {
|
||||
id: string
|
||||
name: string
|
||||
version?: number
|
||||
startStep?: string
|
||||
steps: StepDocument[]
|
||||
transitions: TransitionDocument[]
|
||||
}
|
||||
|
||||
export interface CatalogParam {
|
||||
name: string
|
||||
type: 'number' | 'string' | 'boolean'
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface CatalogItem {
|
||||
key: string
|
||||
label: string
|
||||
params?: CatalogParam[]
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
content: T[]
|
||||
totalElements: number
|
||||
page: number
|
||||
size: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
}
|
||||
|
||||
export type NodeKind = 'APPROVAL' | 'ACTION' | 'PARALLEL' | 'END'
|
||||
|
||||
export interface ApprovalNodeData {
|
||||
kind: 'APPROVAL'
|
||||
name: string
|
||||
candidates: string[]
|
||||
policy: ApprovalPolicy
|
||||
dueEnabled: boolean
|
||||
due: StepDueDocument
|
||||
isStart?: boolean
|
||||
laneId?: string
|
||||
}
|
||||
|
||||
export interface ActionNodeData {
|
||||
kind: 'ACTION'
|
||||
name: string
|
||||
action: string
|
||||
isStart?: boolean
|
||||
laneId?: string
|
||||
}
|
||||
|
||||
export interface ParallelNodeData {
|
||||
kind: 'PARALLEL'
|
||||
name: string
|
||||
lanes: string[]
|
||||
isStart?: boolean
|
||||
}
|
||||
|
||||
export interface EndNodeData {
|
||||
kind: 'END'
|
||||
name: string
|
||||
}
|
||||
|
||||
export type DesignerNodeData = ApprovalNodeData | ActionNodeData | ParallelNodeData | EndNodeData
|
||||
|
||||
export interface DesignerEdgeData {
|
||||
when?: RoutingWhen
|
||||
priority: number
|
||||
}
|
||||
|
||||
export const LEAVE_REQUEST_EXAMPLE: ProcessDefinitionDocument = {
|
||||
id: 'leave-request-routed',
|
||||
name: 'Leave request',
|
||||
startStep: 'notify-submitted',
|
||||
steps: [
|
||||
{ id: 'notify-submitted', name: 'Notify submitted', kind: 'ACTION', action: 'leave-submitted-mail' },
|
||||
{ id: 'manager', name: 'Manager approval', candidates: ['maria'], policy: 'ANY' },
|
||||
{ id: 'hr', name: 'HR approval', candidates: ['henry'], policy: 'ANY' },
|
||||
{ id: 'notify-approved', name: 'Notify approved', kind: 'ACTION', action: 'leave-approved-mail' },
|
||||
],
|
||||
transitions: [
|
||||
{ from: 'notify-submitted', to: 'manager' },
|
||||
{ from: 'manager', to: 'hr', when: { gt: ['days', 3] }, priority: 0 },
|
||||
{ from: 'manager', to: 'notify-approved', priority: 1 },
|
||||
{ from: 'hr', to: 'notify-approved' },
|
||||
{ from: 'notify-approved', to: null },
|
||||
],
|
||||
}
|
||||
|
||||
export function defaultApprovalData(): ApprovalNodeData {
|
||||
return {
|
||||
kind: 'APPROVAL',
|
||||
name: 'Approval',
|
||||
candidates: [],
|
||||
policy: 'ANY',
|
||||
dueEnabled: false,
|
||||
due: { after: 'PT48H', then: 'notify' },
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultActionData(): ActionNodeData {
|
||||
return {
|
||||
kind: 'ACTION',
|
||||
name: 'Action',
|
||||
action: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultParallelData(): ParallelNodeData {
|
||||
return {
|
||||
kind: 'PARALLEL',
|
||||
name: 'Parallel',
|
||||
lanes: ['legal', 'finance'],
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultEndData(): EndNodeData {
|
||||
return { kind: 'END', name: 'End' }
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NSpace, NTag } from 'naive-ui'
|
||||
import * as ordo from '@/api/ordo'
|
||||
import { useDraftsStore } from '@/stores/drafts'
|
||||
import type { ProcessDefinitionDocument } from '@/types/definition'
|
||||
import type { DesignerDraft } from '@/stores/drafts'
|
||||
import DraftConflictModal from '@/components/designer/DraftConflictModal.vue'
|
||||
import { comparableDocument, toDocument } from '@/mapper/graph'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
|
||||
const router = useRouter()
|
||||
const drafts = useDraftsStore()
|
||||
const listAsync = useAsync()
|
||||
const versionsAsync = useAsync()
|
||||
const loading = listAsync.loading
|
||||
const error = listAsync.error
|
||||
const published = ref<ProcessDefinitionDocument[]>([])
|
||||
const localDrafts = ref<DesignerDraft[]>([])
|
||||
const versionsShow = ref(false)
|
||||
const versions = ref<ProcessDefinitionDocument[]>([])
|
||||
const versionsTitle = ref('')
|
||||
const conflictShow = ref(false)
|
||||
const conflictDraft = ref<DesignerDraft | null>(null)
|
||||
const conflictPublished = ref<ProcessDefinitionDocument | null>(null)
|
||||
|
||||
const publishedById = computed(() => {
|
||||
const map = new Map<string, ProcessDefinitionDocument>()
|
||||
for (const row of published.value) {
|
||||
map.set(row.id, row)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const publishedColumns: DataTableColumns<ProcessDefinitionDocument> = [
|
||||
{ title: 'id', key: 'id' },
|
||||
{ title: 'name', key: 'name' },
|
||||
{ title: 'version', key: 'version', width: 100 },
|
||||
{
|
||||
title: 'draft',
|
||||
key: 'draft',
|
||||
width: 140,
|
||||
render(row) {
|
||||
const draft = drafts.get(row.id)
|
||||
if (!draft) {
|
||||
return '—'
|
||||
}
|
||||
return h(NSpace, { size: 8, align: 'center' }, () => [
|
||||
h(NTag, { size: 'small', type: 'warning' }, () => 'Draft'),
|
||||
h(NButton, { size: 'tiny', onClick: () => goDesigner(row.id, 'draft') }, () => 'Open draft'),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'actions',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render(row) {
|
||||
return h(NSpace, { size: 8 }, () => [
|
||||
h(NButton, { size: 'small', onClick: () => openPublished(row) }, () => 'Open'),
|
||||
h(NButton, { size: 'small', onClick: () => void openVersions(row.id) }, () => 'Versions'),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const draftColumns: DataTableColumns<DesignerDraft> = [
|
||||
{ title: 'id', key: 'id' },
|
||||
{ title: 'name', key: 'name' },
|
||||
{
|
||||
title: 'updated',
|
||||
key: 'updatedAt',
|
||||
render(row) {
|
||||
return new Date(row.updatedAt).toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'published',
|
||||
key: 'published',
|
||||
width: 100,
|
||||
render(row) {
|
||||
const doc = publishedById.value.get(row.id)
|
||||
return doc?.version != null ? `v${doc.version}` : '—'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'actions',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render(row) {
|
||||
return h(NButton, { size: 'small', onClick: () => goDesigner(row.id, 'draft') }, () => 'Open')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const versionColumns: DataTableColumns<ProcessDefinitionDocument> = [
|
||||
{ title: 'version', key: 'version', width: 100 },
|
||||
{ title: 'name', key: 'name' },
|
||||
{
|
||||
title: 'actions',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render(row) {
|
||||
return h(NSpace, { size: 8 }, () => [
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', onClick: () => openDesigner(row.id, row.version) },
|
||||
() => 'Open',
|
||||
),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const conflictDraftText = computed(() => {
|
||||
const draft = conflictDraft.value
|
||||
if (!draft) {
|
||||
return ''
|
||||
}
|
||||
return comparableDocument(toDocument(draft.id, draft.name, draft.startStepId, draft.nodes, draft.edges))
|
||||
})
|
||||
const conflictPublishedText = computed(() =>
|
||||
conflictPublished.value ? comparableDocument(conflictPublished.value) : '',
|
||||
)
|
||||
|
||||
function goDesigner(id: string, source?: 'draft' | 'published', version?: number) {
|
||||
versionsShow.value = false
|
||||
conflictShow.value = false
|
||||
void router.push({
|
||||
name: 'designer',
|
||||
params: { definitionId: id },
|
||||
query: {
|
||||
...(version != null ? { version: String(version) } : {}),
|
||||
...(source ? { source } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openDesigner(id: string, version?: number) {
|
||||
goDesigner(id, undefined, version)
|
||||
}
|
||||
|
||||
function openPublished(row: ProcessDefinitionDocument) {
|
||||
const draft = drafts.get(row.id)
|
||||
if (draft) {
|
||||
conflictDraft.value = draft
|
||||
conflictPublished.value = row
|
||||
conflictShow.value = true
|
||||
return
|
||||
}
|
||||
goDesigner(row.id, undefined)
|
||||
}
|
||||
|
||||
function onOpenDraft() {
|
||||
const draft = conflictDraft.value
|
||||
if (!draft) {
|
||||
return
|
||||
}
|
||||
goDesigner(draft.id, 'draft')
|
||||
}
|
||||
|
||||
function onDiscardDraft() {
|
||||
const publishedDoc = conflictPublished.value
|
||||
const draft = conflictDraft.value
|
||||
if (draft) {
|
||||
drafts.remove(draft.id)
|
||||
localDrafts.value = drafts.list()
|
||||
}
|
||||
if (publishedDoc) {
|
||||
goDesigner(publishedDoc.id, 'published')
|
||||
}
|
||||
}
|
||||
|
||||
function newProcess() {
|
||||
void router.push({ name: 'designer' })
|
||||
}
|
||||
|
||||
async function openVersions(id: string) {
|
||||
versionsTitle.value = id
|
||||
try {
|
||||
const page = await versionsAsync.run(() => ordo.listDefinitionVersions(id))
|
||||
versions.value = page.content
|
||||
versionsShow.value = true
|
||||
} catch {
|
||||
error.value = versionsAsync.error.value || 'Failed to load versions'
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
localDrafts.value = drafts.list()
|
||||
try {
|
||||
const page = await listAsync.run(() => ordo.listDefinitions())
|
||||
published.value = page.content
|
||||
} catch {
|
||||
published.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-100 p-6">
|
||||
<div class="mx-auto flex max-w-5xl flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold text-slate-800">Process definitions</h1>
|
||||
<n-space>
|
||||
<n-button @click="load">Refresh</n-button>
|
||||
<n-button type="primary" @click="newProcess">New</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-alert v-if="error" type="warning">{{ error }}</n-alert>
|
||||
<n-card title="Published">
|
||||
<n-data-table :columns="publishedColumns" :data="published" :loading="loading" :bordered="false" />
|
||||
</n-card>
|
||||
<n-card title="Local drafts">
|
||||
<n-data-table :columns="draftColumns" :data="localDrafts" :bordered="false" />
|
||||
</n-card>
|
||||
</div>
|
||||
<n-modal v-model:show="versionsShow">
|
||||
<n-card :title="`Versions · ${versionsTitle}`" style="width: 560px" closable @close="versionsShow = false">
|
||||
<n-data-table :columns="versionColumns" :data="versions" :bordered="false" />
|
||||
</n-card>
|
||||
</n-modal>
|
||||
<DraftConflictModal
|
||||
v-model:show="conflictShow"
|
||||
:draft-text="conflictDraftText"
|
||||
:published-text="conflictPublishedText"
|
||||
@open-draft="onOpenDraft"
|
||||
@discard="onDiscardDraft"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,748 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
VueFlow,
|
||||
useVueFlow,
|
||||
ConnectionMode,
|
||||
type Connection,
|
||||
type EdgeMouseEvent,
|
||||
type EdgeTypesObject,
|
||||
type NodeMouseEvent,
|
||||
type NodeTypesObject,
|
||||
} from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import { useDialog, useMessage } from 'naive-ui'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import NodePalette from '@/components/designer/NodePalette.vue'
|
||||
import PropertyPanel from '@/components/designer/PropertyPanel.vue'
|
||||
import GraphErrorList from '@/components/designer/GraphErrorList.vue'
|
||||
import DraftConflictModal from '@/components/designer/DraftConflictModal.vue'
|
||||
import ApprovalNode from '@/components/designer/nodes/ApprovalNode.vue'
|
||||
import ActionNode from '@/components/designer/nodes/ActionNode.vue'
|
||||
import ParallelNode from '@/components/designer/nodes/ParallelNode.vue'
|
||||
import EndNode from '@/components/designer/nodes/EndNode.vue'
|
||||
import BranchEdge from '@/components/designer/edges/BranchEdge.vue'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useDraftsStore, type DesignerDraft } from '@/stores/drafts'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import * as ordo from '@/api/ordo'
|
||||
import { comparableDocument, toDocument } from '@/mapper/graph'
|
||||
import { LEAVE_REQUEST_EXAMPLE, type ProcessDefinitionDocument } from '@/types/definition'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const designer = useDesignerStore()
|
||||
const catalog = useCatalogStore()
|
||||
const drafts = useDraftsStore()
|
||||
const bootAsync = useAsync()
|
||||
const actionAsync = useAsync()
|
||||
const { nodes, edges, definitionId, name, publishedVersion, loadedFromDraft, isDirty, canUndo, canRedo, selectedNodeIds } =
|
||||
storeToRefs(designer)
|
||||
const { fitView, screenToFlowCoordinate, setNodes } = useVueFlow('ordo-designer')
|
||||
const { revision } = storeToRefs(drafts)
|
||||
const loading = bootAsync.loading
|
||||
|
||||
const conflictShow = ref(false)
|
||||
const conflictDraft = ref<DesignerDraft | null>(null)
|
||||
const conflictPublished = ref<ProcessDefinitionDocument | null>(null)
|
||||
const shortcutsShow = ref(false)
|
||||
const jsonShow = ref(false)
|
||||
const savedHint = ref(false)
|
||||
let bootGen = 0
|
||||
let skipRouteBootstrap = false
|
||||
let allowAutosave = false
|
||||
let autosaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let savedHintTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const nodeTypes = {
|
||||
approval: markRaw(ApprovalNode),
|
||||
action: markRaw(ActionNode),
|
||||
parallel: markRaw(ParallelNode),
|
||||
end: markRaw(EndNode),
|
||||
} as unknown as NodeTypesObject
|
||||
|
||||
const edgeTypes = {
|
||||
default: markRaw(BranchEdge),
|
||||
} as unknown as EdgeTypesObject
|
||||
|
||||
const readOnly = computed(() => {
|
||||
const versionRaw = route.query.version
|
||||
return typeof versionRaw === 'string' && Number.isFinite(Number(versionRaw))
|
||||
})
|
||||
const versionLabel = computed(() => {
|
||||
if (readOnly.value && publishedVersion.value != null) {
|
||||
return `v${publishedVersion.value} (read-only)`
|
||||
}
|
||||
return loadedFromDraft.value ? 'Draft' : publishedVersion.value != null ? `v${publishedVersion.value}` : 'Draft'
|
||||
})
|
||||
const publishedHint = computed(() =>
|
||||
loadedFromDraft.value && publishedVersion.value != null ? `Published v${publishedVersion.value}` : '',
|
||||
)
|
||||
const hasLocalDraft = computed(() => {
|
||||
void revision.value
|
||||
return Boolean(definitionId.value && drafts.get(definitionId.value))
|
||||
})
|
||||
const dirtyLabel = computed(() => (isDirty.value ? 'Unsaved' : ''))
|
||||
const canAlign = computed(() => !readOnly.value && selectedNodeIds.value.length >= 2)
|
||||
const jsonText = computed(() => JSON.stringify(designer.document(), null, 2))
|
||||
|
||||
const conflictDraftText = computed(() => {
|
||||
const draft = conflictDraft.value
|
||||
if (!draft) {
|
||||
return ''
|
||||
}
|
||||
return comparableDocument(toDocument(draft.id, draft.name, draft.startStepId, draft.nodes, draft.edges))
|
||||
})
|
||||
const conflictPublishedText = computed(() =>
|
||||
conflictPublished.value ? comparableDocument(conflictPublished.value) : '',
|
||||
)
|
||||
|
||||
function syncFlowNodes() {
|
||||
setNodes(nodes.value.map((n) => ({ ...n, position: { ...n.position } })))
|
||||
}
|
||||
|
||||
async function fitAfterLoad() {
|
||||
await nextTick()
|
||||
fitView({ padding: 0.2 })
|
||||
}
|
||||
|
||||
async function confirmLeave(): Promise<boolean> {
|
||||
if (!designer.isDirty) {
|
||||
return true
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
dialog.warning({
|
||||
title: 'Unsaved changes',
|
||||
content: 'Leaving will discard unsaved edits.',
|
||||
positiveText: 'Leave',
|
||||
negativeText: 'Stay',
|
||||
onPositiveClick: () => resolve(true),
|
||||
onNegativeClick: () => resolve(false),
|
||||
onClose: () => resolve(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function persistDraft(silent: boolean) {
|
||||
if (readOnly.value || !definitionId.value) {
|
||||
return
|
||||
}
|
||||
drafts.save({
|
||||
id: definitionId.value,
|
||||
name: name.value,
|
||||
startStepId: designer.startStepId,
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
})
|
||||
designer.loadedFromDraft = true
|
||||
designer.captureBaseline()
|
||||
if (silent) {
|
||||
savedHint.value = true
|
||||
if (savedHintTimer) {
|
||||
clearTimeout(savedHintTimer)
|
||||
}
|
||||
savedHintTimer = setTimeout(() => {
|
||||
savedHint.value = false
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
message.success('Draft saved')
|
||||
replaceDefinition(definitionId.value)
|
||||
}
|
||||
|
||||
function scheduleAutosave() {
|
||||
if (!allowAutosave || readOnly.value || !definitionId.value || !isDirty.value) {
|
||||
return
|
||||
}
|
||||
if (autosaveTimer) {
|
||||
clearTimeout(autosaveTimer)
|
||||
}
|
||||
autosaveTimer = setTimeout(() => {
|
||||
autosaveTimer = null
|
||||
persistDraft(true)
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
function flushAutosave() {
|
||||
if (autosaveTimer) {
|
||||
clearTimeout(autosaveTimer)
|
||||
autosaveTimer = null
|
||||
}
|
||||
if (allowAutosave && !readOnly.value && definitionId.value && designer.isDirty) {
|
||||
persistDraft(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const gen = ++bootGen
|
||||
allowAutosave = false
|
||||
conflictShow.value = false
|
||||
designer.clear()
|
||||
try {
|
||||
await bootAsync.run(async () => {
|
||||
await catalog.load()
|
||||
if (gen !== bootGen) {
|
||||
return
|
||||
}
|
||||
const id = typeof route.params.definitionId === 'string' ? route.params.definitionId : ''
|
||||
const versionRaw = route.query.version
|
||||
const version = typeof versionRaw === 'string' ? Number(versionRaw) : NaN
|
||||
const source = typeof route.query.source === 'string' ? route.query.source : ''
|
||||
|
||||
if (id && Number.isFinite(version)) {
|
||||
const doc = await ordo.getDefinitionVersion(id, version)
|
||||
if (gen !== bootGen) {
|
||||
return
|
||||
}
|
||||
designer.loadDocument(doc)
|
||||
await fitAfterLoad()
|
||||
return
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const draft = drafts.get(id)
|
||||
let published: ProcessDefinitionDocument | null = null
|
||||
try {
|
||||
published = await ordo.getDefinition(id)
|
||||
} catch {
|
||||
published = null
|
||||
}
|
||||
if (gen !== bootGen) {
|
||||
return
|
||||
}
|
||||
|
||||
if (draft && published && source !== 'draft' && source !== 'published') {
|
||||
conflictDraft.value = draft
|
||||
conflictPublished.value = published
|
||||
conflictShow.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (draft && (source === 'draft' || !published)) {
|
||||
designer.loadGraph({
|
||||
...draft,
|
||||
version: published?.version ?? null,
|
||||
fromDraft: true,
|
||||
})
|
||||
await fitAfterLoad()
|
||||
return
|
||||
}
|
||||
|
||||
if (published) {
|
||||
if (source === 'published' && draft) {
|
||||
drafts.remove(id)
|
||||
}
|
||||
designer.loadDocument(published)
|
||||
await fitAfterLoad()
|
||||
return
|
||||
}
|
||||
|
||||
designer.resetEmpty(id)
|
||||
return
|
||||
}
|
||||
|
||||
designer.resetEmpty()
|
||||
})
|
||||
} catch {
|
||||
message.error(bootAsync.error.value || 'Failed to load')
|
||||
const id = typeof route.params.definitionId === 'string' ? route.params.definitionId : ''
|
||||
designer.resetEmpty(id || undefined)
|
||||
} finally {
|
||||
if (gen === bootGen) {
|
||||
allowAutosave = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyDraft(draft: DesignerDraft, version: number | null) {
|
||||
designer.loadGraph({
|
||||
...draft,
|
||||
version,
|
||||
fromDraft: true,
|
||||
})
|
||||
void fitAfterLoad()
|
||||
}
|
||||
|
||||
function onOpenDraft() {
|
||||
const draft = conflictDraft.value
|
||||
const published = conflictPublished.value
|
||||
conflictShow.value = false
|
||||
if (!draft) {
|
||||
return
|
||||
}
|
||||
applyDraft(draft, published?.version ?? null)
|
||||
}
|
||||
|
||||
function onDiscardDraft() {
|
||||
const published = conflictPublished.value
|
||||
const draft = conflictDraft.value
|
||||
conflictShow.value = false
|
||||
if (draft) {
|
||||
drafts.remove(draft.id)
|
||||
}
|
||||
if (published) {
|
||||
designer.loadDocument(published)
|
||||
void fitAfterLoad()
|
||||
}
|
||||
}
|
||||
|
||||
async function discardLocalDraft() {
|
||||
if (!definitionId.value) {
|
||||
return
|
||||
}
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
dialog.warning({
|
||||
title: 'Discard local draft',
|
||||
content: 'Delete the local draft and open the published version.',
|
||||
positiveText: 'Discard',
|
||||
negativeText: 'Cancel',
|
||||
onPositiveClick: () => resolve(true),
|
||||
onNegativeClick: () => resolve(false),
|
||||
onClose: () => resolve(false),
|
||||
})
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
const id = definitionId.value
|
||||
drafts.remove(id)
|
||||
try {
|
||||
const published = await actionAsync.run(() => ordo.getDefinition(id))
|
||||
designer.loadDocument(published)
|
||||
await fitAfterLoad()
|
||||
} catch {
|
||||
message.error(actionAsync.error.value || 'Failed to open published')
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.closest('input, textarea, [contenteditable="true"]')) {
|
||||
return
|
||||
}
|
||||
const meta = event.ctrlKey || event.metaKey
|
||||
if (meta && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault()
|
||||
if (!readOnly.value) {
|
||||
persistDraft(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
if (meta && event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
applyRedo()
|
||||
} else {
|
||||
applyUndo()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (meta && event.key.toLowerCase() === 'c') {
|
||||
event.preventDefault()
|
||||
designer.copySelection()
|
||||
return
|
||||
}
|
||||
if (meta && event.key.toLowerCase() === 'v') {
|
||||
event.preventDefault()
|
||||
designer.pasteClipboard()
|
||||
syncFlowNodes()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Delete' && event.key !== 'Backspace') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
void requestDeleteSelected()
|
||||
}
|
||||
|
||||
async function requestDeleteSelected() {
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
const nodeIds = designer.selectedNodeIds.length
|
||||
? designer.selectedNodeIds
|
||||
: designer.selectedNodeId
|
||||
? [designer.selectedNodeId]
|
||||
: []
|
||||
if (nodeIds.length) {
|
||||
if (designer.hasOutgoingAmong(nodeIds)) {
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
dialog.warning({
|
||||
title: 'Delete node',
|
||||
content: 'A selected node has outgoing edges. Delete selected nodes and connected edges?',
|
||||
positiveText: 'Delete',
|
||||
negativeText: 'Cancel',
|
||||
onPositiveClick: () => resolve(true),
|
||||
onNegativeClick: () => resolve(false),
|
||||
onClose: () => resolve(false),
|
||||
})
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
}
|
||||
designer.removeNodes(nodeIds)
|
||||
return
|
||||
}
|
||||
designer.removeSelected()
|
||||
}
|
||||
|
||||
function applyUndo() {
|
||||
if (designer.undo()) {
|
||||
syncFlowNodes()
|
||||
}
|
||||
}
|
||||
|
||||
function applyRedo() {
|
||||
if (designer.redo()) {
|
||||
syncFlowNodes()
|
||||
}
|
||||
}
|
||||
|
||||
function align(mode: 'left' | 'center' | 'top') {
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
if (designer.alignSelected(mode)) {
|
||||
syncFlowNodes()
|
||||
}
|
||||
}
|
||||
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
flushAutosave()
|
||||
if (!designer.isDirty) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void bootstrap()
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
flushAutosave()
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
if (savedHintTimer) {
|
||||
clearTimeout(savedHintTimer)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
flushAutosave()
|
||||
return confirmLeave()
|
||||
})
|
||||
|
||||
function replaceDefinition(id: string) {
|
||||
if (route.params.definitionId === id) {
|
||||
return
|
||||
}
|
||||
skipRouteBootstrap = true
|
||||
void router.replace({ name: 'designer', params: { definitionId: id } })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.definitionId, route.query.version] as const,
|
||||
(curr, prev) => {
|
||||
if (skipRouteBootstrap) {
|
||||
skipRouteBootstrap = false
|
||||
return
|
||||
}
|
||||
if (prev && curr[0] === prev[0] && curr[1] === prev[1]) {
|
||||
return
|
||||
}
|
||||
void bootstrap()
|
||||
},
|
||||
)
|
||||
|
||||
watch(isDirty, () => {
|
||||
scheduleAutosave()
|
||||
})
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = readOnly.value ? 'none' : 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
const type = event.dataTransfer?.getData('application/vueflow')
|
||||
if (!type) {
|
||||
return
|
||||
}
|
||||
const position = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
|
||||
const result = designer.addNode(type, position)
|
||||
if (result === 'duplicate-end') {
|
||||
message.info('Only one End node is allowed')
|
||||
}
|
||||
}
|
||||
|
||||
function onConnect(connection: Connection) {
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
designer.onConnect(connection)
|
||||
}
|
||||
|
||||
function onNodeClick({ node }: NodeMouseEvent) {
|
||||
designer.selectNode(node.id)
|
||||
}
|
||||
|
||||
function onEdgeClick({ edge }: EdgeMouseEvent) {
|
||||
designer.selectEdge(edge.id)
|
||||
}
|
||||
|
||||
function onPaneClick() {
|
||||
designer.selectNode(null)
|
||||
designer.selectEdge(null)
|
||||
}
|
||||
|
||||
function onSelectionChange(params: { nodes: { id: string }[]; edges: { id: string }[] }) {
|
||||
designer.setSelection(
|
||||
params.nodes.map((n) => n.id),
|
||||
params.edges.map((e) => e.id),
|
||||
)
|
||||
}
|
||||
|
||||
let dragHistoryStarted = false
|
||||
function onNodeDragStart() {
|
||||
if (readOnly.value || dragHistoryStarted) {
|
||||
return
|
||||
}
|
||||
dragHistoryStarted = true
|
||||
designer.pushHistory()
|
||||
}
|
||||
|
||||
function onNodeDragStop() {
|
||||
dragHistoryStarted = false
|
||||
designer.settleLanes()
|
||||
}
|
||||
|
||||
function saveAsDraft() {
|
||||
drafts.save({
|
||||
id: definitionId.value,
|
||||
name: name.value,
|
||||
startStepId: designer.startStepId,
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
})
|
||||
designer.loadedFromDraft = true
|
||||
designer.captureBaseline()
|
||||
message.success('Saved as draft')
|
||||
skipRouteBootstrap = true
|
||||
void router.replace({ name: 'designer', params: { definitionId: definitionId.value } })
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
persistDraft(false)
|
||||
}
|
||||
|
||||
async function loadExample() {
|
||||
const ok = await confirmLeave()
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
designer.loadDocument(LEAVE_REQUEST_EXAMPLE)
|
||||
void fitAfterLoad()
|
||||
replaceDefinition(LEAVE_REQUEST_EXAMPLE.id)
|
||||
}
|
||||
|
||||
async function autoLayout() {
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
designer.relayout()
|
||||
syncFlowNodes()
|
||||
await fitAfterLoad()
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
const local = designer.localErrors()
|
||||
if (local.length) {
|
||||
message.error(`${local.length} validation error(s)`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await actionAsync.run(() => ordo.parseDefinition(designer.document()))
|
||||
message.success('Validation passed')
|
||||
} catch {
|
||||
message.error(actionAsync.error.value || 'Validation failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const local = designer.localErrors()
|
||||
if (local.length) {
|
||||
message.error(`${local.length} validation error(s), cannot publish`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const previous = designer.publishedVersion
|
||||
const published = await actionAsync.run(() => ordo.publishDefinition(designer.document()))
|
||||
designer.publishedVersion = published.version ?? null
|
||||
drafts.save({
|
||||
id: definitionId.value,
|
||||
name: name.value,
|
||||
startStepId: designer.startStepId,
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
})
|
||||
designer.loadedFromDraft = false
|
||||
designer.captureBaseline()
|
||||
if (published.version != null && previous != null && published.version === previous) {
|
||||
message.info(`Graph unchanged (still v${published.version})`)
|
||||
} else {
|
||||
message.success(`Published v${published.version}`)
|
||||
}
|
||||
replaceDefinition(definitionId.value)
|
||||
} catch {
|
||||
message.error(actionAsync.error.value || 'Publish failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyJson() {
|
||||
await navigator.clipboard.writeText(jsonText.value)
|
||||
message.success('Copied')
|
||||
}
|
||||
|
||||
function downloadJson() {
|
||||
const blob = new Blob([jsonText.value], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${definitionId.value || 'process'}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col bg-slate-100">
|
||||
<header class="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<n-button quaternary size="small" @click="router.push({ name: 'definitions' })">
|
||||
<template #icon>
|
||||
<ArrowLeft :size="16" />
|
||||
</template>
|
||||
Back
|
||||
</n-button>
|
||||
<span class="text-sm font-medium text-slate-800">{{ name || 'Designer' }}</span>
|
||||
<n-tag size="small">{{ versionLabel }}</n-tag>
|
||||
<n-tag v-if="publishedHint" size="small" type="info">{{ publishedHint }}</n-tag>
|
||||
<n-tag v-if="dirtyLabel" size="small" type="warning">{{ dirtyLabel }}</n-tag>
|
||||
<n-tag v-if="savedHint" size="small" type="success">Saved</n-tag>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button v-if="hasLocalDraft && !readOnly" size="small" @click="discardLocalDraft">Discard draft</n-button>
|
||||
<n-button v-if="readOnly" size="small" type="primary" @click="saveAsDraft">Save as draft</n-button>
|
||||
<n-button size="small" :disabled="readOnly || !canUndo" @click="applyUndo">Undo</n-button>
|
||||
<n-button size="small" :disabled="readOnly || !canRedo" @click="applyRedo">Redo</n-button>
|
||||
<n-button size="small" :disabled="readOnly" @click="loadExample">Load example</n-button>
|
||||
<n-button size="small" :disabled="readOnly" @click="autoLayout">Auto layout</n-button>
|
||||
<n-button size="small" :disabled="readOnly" @click="saveDraft">Save draft</n-button>
|
||||
<n-button size="small" :disabled="readOnly" @click="validate">Validate</n-button>
|
||||
<n-button size="small" type="primary" :disabled="readOnly" @click="publish">Publish</n-button>
|
||||
<n-button size="small" @click="jsonShow = true">JSON</n-button>
|
||||
<n-button size="small" @click="shortcutsShow = true">Shortcuts</n-button>
|
||||
</n-space>
|
||||
</header>
|
||||
<GraphErrorList />
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<NodePalette />
|
||||
<div class="relative min-w-0 flex-1" @drop="onDrop" @dragover="onDragOver">
|
||||
<n-spin :show="loading" class="h-full w-full" content-class="h-full">
|
||||
<VueFlow
|
||||
id="ordo-designer"
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:node-types="nodeTypes"
|
||||
:edge-types="edgeTypes"
|
||||
:nodes-draggable="!readOnly"
|
||||
:nodes-connectable="!readOnly"
|
||||
:elements-selectable="true"
|
||||
:connection-mode="ConnectionMode.Loose"
|
||||
:snap-to-grid="true"
|
||||
:snap-grid="[16, 16]"
|
||||
multi-selection-key-code="Shift"
|
||||
class="h-full w-full"
|
||||
@connect="onConnect"
|
||||
@node-click="onNodeClick"
|
||||
@edge-click="onEdgeClick"
|
||||
@pane-click="onPaneClick"
|
||||
@selection-change="onSelectionChange"
|
||||
@node-drag-start="onNodeDragStart"
|
||||
@node-drag-stop="onNodeDragStop"
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
</VueFlow>
|
||||
</n-spin>
|
||||
<div v-if="canAlign" class="absolute top-3 left-3 z-10 flex gap-1 rounded-md border border-slate-200 bg-white p-1 shadow-sm">
|
||||
<n-button size="tiny" @click="align('left')">Align left</n-button>
|
||||
<n-button size="tiny" @click="align('center')">Align center</n-button>
|
||||
<n-button size="tiny" @click="align('top')">Align top</n-button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loading && !nodes.length"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div class="rounded-xl border border-dashed border-slate-300 bg-white/80 px-6 py-4 text-center text-sm text-slate-500">
|
||||
Drag a node from the left palette onto the canvas
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PropertyPanel :read-only="readOnly" />
|
||||
</div>
|
||||
<DraftConflictModal
|
||||
v-model:show="conflictShow"
|
||||
:draft-text="conflictDraftText"
|
||||
:published-text="conflictPublishedText"
|
||||
@open-draft="onOpenDraft"
|
||||
@discard="onDiscardDraft"
|
||||
/>
|
||||
<n-modal v-model:show="shortcutsShow">
|
||||
<n-card title="Shortcuts" style="width: 420px" closable @close="shortcutsShow = false">
|
||||
<div class="space-y-2 text-sm text-slate-600">
|
||||
<p>Undo: Ctrl/Cmd + Z</p>
|
||||
<p>Redo: Ctrl/Cmd + Shift + Z</p>
|
||||
<p>Copy: Ctrl/Cmd + C</p>
|
||||
<p>Paste: Ctrl/Cmd + V</p>
|
||||
<p>Delete: Delete / Backspace</p>
|
||||
<p>Box select: Shift + drag</p>
|
||||
<p>Save draft: Ctrl/Cmd + S</p>
|
||||
<p v-if="readOnly" class="text-amber-600">Edit shortcuts are disabled in read-only mode.</p>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
<n-drawer v-model:show="jsonShow" :width="480" placement="right">
|
||||
<n-drawer-content title="Engine JSON">
|
||||
<template #footer>
|
||||
<n-space>
|
||||
<n-button size="small" @click="copyJson">Copy</n-button>
|
||||
<n-button size="small" type="primary" @click="downloadJson">Download .json</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<pre class="overflow-auto rounded-md bg-slate-50 p-3 text-xs text-slate-700">{{ jsonText }}</pre>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/ordo': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user