feat: split Ordo designer into library monorepo
Extract ordo-designer-vue package with shadcn UI and context-based state; add playground app, library-api docs, and README. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,42 +1,52 @@
|
||||
# ordo-designer-demo
|
||||
# ordo-designer-vue
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
Vue 3 流程定义设计器:可嵌入宿主应用的 `OrdoDesigner` 组件库,以及对接 Ordo REST 的本地 playground。
|
||||
|
||||
## Recommended IDE Setup
|
||||
## 仓库结构
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `packages/ordo-designer-vue` | 设计器库(Vue Flow、shadcn 风格 UI、provide/inject 状态,无 Pinia) |
|
||||
| `apps/playground` | 开发/演示应用:定义列表、设计页、本地草稿 |
|
||||
| `docs/library-api.md` | 库集成、主题、`OrdoClient` / `DraftStorage` 说明 |
|
||||
|
||||
## 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/)
|
||||
- Node `^22.18.0` 或 `>=24.12.0`
|
||||
- [pnpm](https://pnpm.io/)
|
||||
|
||||
## 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
|
||||
`pnpm dev` 会先构建 `ordo-designer-vue`,再启动 playground(默认 Vite 将 `ordo-designer-vue` 指向库产物 `dist/`)。
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
Playground 需 Ordo 后端;开发时通过 Vite 代理访问 `/ordo`(见 `apps/playground/vite.config.ts`)。
|
||||
|
||||
## 脚本
|
||||
|
||||
| 命令 | 作用 |
|
||||
|------|------|
|
||||
| `pnpm dev` | 构建库 + playground 热更新 |
|
||||
| `pnpm build` | 构建库 + playground 生产包 |
|
||||
| `pnpm type-check` | 全 workspace 类型检查 |
|
||||
| `pnpm -F ordo-designer-vue build` | 仅构建库 |
|
||||
| `pnpm -F playground dev` | 仅启动 playground(需已构建库) |
|
||||
|
||||
## 在宿主中使用库
|
||||
|
||||
```ts
|
||||
import { OrdoDesigner } from 'ordo-designer-vue'
|
||||
import 'ordo-designer-vue/style.css'
|
||||
```
|
||||
|
||||
实现 `OrdoClient` 与 `DraftStorage`,传入 `OrdoDesigner`。可选导出:`DraftConflictModal`、`Button`、`Badge`、`Alert`、`DialogPanel` 等,便于列表页与库 UI 一致。
|
||||
|
||||
详见 [docs/library-api.md](./docs/library-api.md)。
|
||||
|
||||
## IDE
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue - Official (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.volar)。
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<html lang="en">
|
||||
<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>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ordo Designer Playground</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "playground",
|
||||
"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": {
|
||||
"ordo-designer-vue": "workspace:*",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vue-tsc": "^3.3.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CatalogItem, OrdoClient, PageResponse, ProcessDefinitionDocument } from 'ordo-designer-vue'
|
||||
|
||||
async function request<T>(baseUrl: string, url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${baseUrl}${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 createOrdoClient(baseUrl = '/ordo'): OrdoClient {
|
||||
return {
|
||||
listConditions: () => request<CatalogItem[]>(baseUrl, '/catalog/conditions'),
|
||||
listActions: () => request<CatalogItem[]>(baseUrl, '/catalog/actions'),
|
||||
listAssignees: () => request<CatalogItem[]>(baseUrl, '/catalog/assignees'),
|
||||
getDefinition: (id) => request<ProcessDefinitionDocument>(baseUrl, `/definitions/${encodeURIComponent(id)}`),
|
||||
getDefinitionVersion: (id, version) =>
|
||||
request<ProcessDefinitionDocument>(baseUrl, `/definitions/${encodeURIComponent(id)}/versions/${version}`),
|
||||
parseDefinition: (doc) =>
|
||||
request<ProcessDefinitionDocument>(baseUrl, '/definitions/parse', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(doc),
|
||||
}),
|
||||
publishDefinition: (doc) =>
|
||||
request<ProcessDefinitionDocument>(baseUrl, '/definitions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(doc),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function listDefinitions(baseUrl: string, page = 0, size = 50) {
|
||||
return request<PageResponse<ProcessDefinitionDocument>>(
|
||||
baseUrl,
|
||||
`/definitions?page=${page}&size=${size}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function listDefinitionVersions(baseUrl: string, id: string, page = 0, size = 50) {
|
||||
return request<PageResponse<ProcessDefinitionDocument>>(
|
||||
baseUrl,
|
||||
`/definitions/${encodeURIComponent(id)}/versions?page=${page}&size=${size}`,
|
||||
)
|
||||
}
|
||||
|
||||
export type { PageResponse, ProcessDefinitionDocument }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useAsync() {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
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,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import 'ordo-designer-vue/style.css'
|
||||
import './styles/main.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import DefinitionListView from '@/views/DefinitionListView.vue'
|
||||
import DesignerView from '@/views/DesignerView.vue'
|
||||
import DesignerPage from '@/views/DesignerPage.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/', name: 'definitions', component: DefinitionListView },
|
||||
{ path: '/designer/:definitionId?', name: 'designer', component: DesignerView },
|
||||
{ path: '/designer/:definitionId?', name: 'designer', component: DesignerPage },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import type { DesignerDraft, DraftStorage } from 'ordo-designer-vue'
|
||||
import { layoutFromNodes } from 'ordo-designer-vue'
|
||||
import type { DesignerEdge, DesignerNode, ProcessDefinitionDocument } from 'ordo-designer-vue'
|
||||
|
||||
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 function createDraftStorage(): DraftStorage {
|
||||
const revision = ref(0)
|
||||
|
||||
return {
|
||||
revision,
|
||||
get(id: string) {
|
||||
return readAll()[id]
|
||||
},
|
||||
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
|
||||
},
|
||||
remove(id: string) {
|
||||
const drafts = readAll()
|
||||
delete drafts[id]
|
||||
writeAll(drafts)
|
||||
revision.value += 1
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const shared = createDraftStorage()
|
||||
|
||||
export function useDraftStorage(): DraftStorage {
|
||||
return shared
|
||||
}
|
||||
|
||||
export function listLocalDrafts(): DesignerDraft[] {
|
||||
return Object.values(readAll()).sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
export function fromPublished(doc: ProcessDefinitionDocument): DesignerDraft | undefined {
|
||||
return shared.get(doc.id)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
comparableDocument,
|
||||
toDocument,
|
||||
DraftConflictModal,
|
||||
Button,
|
||||
Badge,
|
||||
Alert,
|
||||
DialogPanel,
|
||||
type DesignerDraft,
|
||||
type ProcessDefinitionDocument,
|
||||
} from 'ordo-designer-vue'
|
||||
import { createOrdoClient, listDefinitionVersions, listDefinitions } from '@/api/createOrdoClient'
|
||||
import { listLocalDrafts, useDraftStorage } from '@/stores/drafts'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
|
||||
const router = useRouter()
|
||||
const drafts = useDraftStorage()
|
||||
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 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 openPublished(row: ProcessDefinitionDocument) {
|
||||
const draft = drafts.get(row.id)
|
||||
if (draft) {
|
||||
conflictDraft.value = draft
|
||||
conflictPublished.value = row
|
||||
conflictShow.value = true
|
||||
return
|
||||
}
|
||||
goDesigner(row.id)
|
||||
}
|
||||
|
||||
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 = listLocalDrafts()
|
||||
}
|
||||
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(() => listDefinitionVersions('/ordo', id))
|
||||
versions.value = page.content
|
||||
versionsShow.value = true
|
||||
} catch {
|
||||
error.value = versionsAsync.error.value || 'Failed to load versions'
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
localDrafts.value = listLocalDrafts()
|
||||
try {
|
||||
const page = await listAsync.run(() => listDefinitions('/ordo'))
|
||||
published.value = page.content
|
||||
} catch {
|
||||
published.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ordo-designer min-h-screen bg-background p-6 text-foreground">
|
||||
<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">Process definitions</h1>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" @click="load">Refresh</Button>
|
||||
<Button size="sm" @click="newProcess">New</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Alert v-if="error" variant="warning">{{ error }}</Alert>
|
||||
<section class="rounded-lg border border-border bg-card p-4 text-card-foreground">
|
||||
<h2 class="mb-3 text-sm font-semibold text-muted-foreground">Published</h2>
|
||||
<p v-if="loading" class="text-sm text-muted-foreground">Loading…</p>
|
||||
<table v-else class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-muted-foreground">
|
||||
<th class="py-2 pr-4 font-medium">id</th>
|
||||
<th class="py-2 pr-4 font-medium">name</th>
|
||||
<th class="py-2 pr-4 font-medium">version</th>
|
||||
<th class="py-2 pr-4 font-medium">draft</th>
|
||||
<th class="py-2 font-medium">actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in published" :key="row.id" class="border-b border-border">
|
||||
<td class="py-2 pr-4 font-mono text-xs">{{ row.id }}</td>
|
||||
<td class="py-2 pr-4">{{ row.name }}</td>
|
||||
<td class="py-2 pr-4">{{ row.version ?? '—' }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<template v-if="drafts.get(row.id)">
|
||||
<Badge variant="warning" class="mr-2">Draft</Badge>
|
||||
<Button variant="ghost" size="xs" class="h-auto px-1 text-primary" @click="goDesigner(row.id, 'draft')">
|
||||
Open draft
|
||||
</Button>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button variant="ghost" size="xs" class="h-auto px-1 text-primary" @click="openPublished(row)">Open</Button>
|
||||
<Button variant="ghost" size="xs" class="h-auto px-1 text-primary" @click="openVersions(row.id)">
|
||||
Versions
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="rounded-lg border border-border bg-card p-4 text-card-foreground">
|
||||
<h2 class="mb-3 text-sm font-semibold text-muted-foreground">Local drafts</h2>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-muted-foreground">
|
||||
<th class="py-2 pr-4 font-medium">id</th>
|
||||
<th class="py-2 pr-4 font-medium">name</th>
|
||||
<th class="py-2 pr-4 font-medium">updated</th>
|
||||
<th class="py-2 pr-4 font-medium">published</th>
|
||||
<th class="py-2 font-medium">actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in localDrafts" :key="row.id" class="border-b border-border">
|
||||
<td class="py-2 pr-4 font-mono text-xs">{{ row.id }}</td>
|
||||
<td class="py-2 pr-4">{{ row.name }}</td>
|
||||
<td class="py-2 pr-4">{{ new Date(row.updatedAt).toLocaleString() }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{{
|
||||
publishedById.get(row.id)?.version != null
|
||||
? `v${publishedById.get(row.id)!.version}`
|
||||
: '—'
|
||||
}}
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<Button variant="ghost" size="xs" class="h-auto px-1 text-primary" @click="goDesigner(row.id, 'draft')">
|
||||
Open
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
<DialogPanel v-model:open="versionsShow" :title="`Versions · ${versionsTitle}`" class="max-w-lg">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-muted-foreground">
|
||||
<th class="py-2 pr-4 font-medium">version</th>
|
||||
<th class="py-2 pr-4 font-medium">name</th>
|
||||
<th class="py-2 font-medium">actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in versions" :key="String(row.version)" class="border-b border-border">
|
||||
<td class="py-2 pr-4">{{ row.version }}</td>
|
||||
<td class="py-2 pr-4">{{ row.name }}</td>
|
||||
<td class="py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="h-auto px-1 text-primary"
|
||||
@click="goDesigner(versionsTitle, undefined, row.version)"
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</DialogPanel>
|
||||
<DraftConflictModal
|
||||
v-model:show="conflictShow"
|
||||
:draft-text="conflictDraftText"
|
||||
:published-text="conflictPublishedText"
|
||||
@open-draft="onOpenDraft"
|
||||
@discard="onDiscardDraft"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { OrdoDesigner } from 'ordo-designer-vue'
|
||||
import { createOrdoClient } from '@/api/createOrdoClient'
|
||||
import { useDraftStorage } from '@/stores/drafts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const client = createOrdoClient()
|
||||
const drafts = useDraftStorage()
|
||||
const designerRef = useTemplateRef<InstanceType<typeof OrdoDesigner>>('designerRef')
|
||||
|
||||
const definitionId = computed(() =>
|
||||
typeof route.params.definitionId === 'string' ? route.params.definitionId : undefined,
|
||||
)
|
||||
const version = computed(() => {
|
||||
const raw = route.query.version
|
||||
if (typeof raw === 'string' && Number.isFinite(Number(raw))) {
|
||||
return Number(raw)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const source = computed(() => (typeof route.query.source === 'string' ? route.query.source : undefined))
|
||||
|
||||
function onBack() {
|
||||
void router.push({ name: 'definitions' })
|
||||
}
|
||||
|
||||
function onNavigate(id: string) {
|
||||
void router.replace({ name: 'designer', params: { definitionId: id } })
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
designerRef.value?.flushAutosave?.()
|
||||
if (designerRef.value?.confirmLeave) {
|
||||
return designerRef.value.confirmLeave()
|
||||
}
|
||||
return true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen">
|
||||
<OrdoDesigner
|
||||
ref="designerRef"
|
||||
:client="client"
|
||||
:drafts="drafts"
|
||||
:definition-id="definitionId"
|
||||
:version="version"
|
||||
:source="source"
|
||||
@back="onBack"
|
||||
@navigate="onNavigate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"compilerOptions": {
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": ["vite.config.ts"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from 'node:path'
|
||||
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'
|
||||
|
||||
const pkgDist = fileURLToPath(new URL('../../packages/ordo-designer-vue/dist/ordo-designer-vue.js', import.meta.url))
|
||||
const pkgStyle = fileURLToPath(new URL('../../packages/ordo-designer-vue/dist/style.css', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), vueDevTools(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
{ find: 'ordo-designer-vue/style.css', replacement: pkgStyle },
|
||||
// Exact entry only — avoid breaking `ordo-designer-vue/style.css` subpath.
|
||||
{ find: /^ordo-designer-vue$/, replacement: pkgDist },
|
||||
],
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/ordo': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
# ordo-designer-vue 库 API
|
||||
|
||||
## 安装与样式
|
||||
|
||||
```ts
|
||||
import { OrdoDesigner } from 'ordo-designer-vue'
|
||||
import 'ordo-designer-vue/style.css'
|
||||
```
|
||||
|
||||
## 主题定制
|
||||
|
||||
设计器根节点为 `.ordo-designer`,亮色/暗色通过 **`theme` prop** 或 CSS 变量控制。
|
||||
|
||||
### 组件 prop
|
||||
|
||||
```vue
|
||||
<OrdoDesigner theme="dark" ... />
|
||||
```
|
||||
|
||||
`theme="dark"` 会在根上添加 class `ordo-designer dark`,启用暗色 token。
|
||||
|
||||
### CSS 变量(宿主覆盖)
|
||||
|
||||
在引入 `style.css` **之后**覆盖(作用域建议挂在 `.ordo-designer` 或父级包一层 class):
|
||||
|
||||
```css
|
||||
.ordo-designer {
|
||||
--ordo-primary: #7c3aed;
|
||||
--ordo-ring: #7c3aed;
|
||||
--ordo-background: #fafafa;
|
||||
}
|
||||
```
|
||||
|
||||
常用变量见 `packages/ordo-designer-vue/src/styles/theme.css`(`--ordo-background`、`--ordo-foreground`、`--ordo-card`、`--ordo-border`、`--ordo-primary`、`--ordo-canvas` 等)。
|
||||
|
||||
也可单独引入变量表做扩展:
|
||||
|
||||
```ts
|
||||
import 'ordo-designer-vue/theme.css'
|
||||
```
|
||||
|
||||
### 说明
|
||||
|
||||
- 工具栏、属性面板、弹层等已接 token;流程节点卡片仍保留类型色(sky/amber 等)以便区分步骤种类。
|
||||
- 画布网格与背景随 `--ordo-canvas` / `--ordo-canvas-grid` 变化。
|
||||
|
||||
## OrdoDesigner
|
||||
|
||||
| Prop | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `client` | `OrdoClient` | 必填,后端 API |
|
||||
| `drafts` | `DraftStorage` | 必填,本地草稿存储 |
|
||||
| `definitionId` | `string?` | 流程 id |
|
||||
| `version` | `number?` | 历史版本(只读) |
|
||||
| `source` | `string?` | `draft` / `published` |
|
||||
| `readOnly` | `boolean?` | 只读,默认可由 `version` 推导 |
|
||||
| `theme` | `'light' \| 'dark'?` | 默认 light |
|
||||
|
||||
| Event | 说明 |
|
||||
|-------|------|
|
||||
| `back` | 返回列表 |
|
||||
| `navigate` | 切换 `definitionId` |
|
||||
| `published` | 发布成功 |
|
||||
|
||||
`defineExpose`: `confirmLeave`, `isDirty`, `flushAutosave`
|
||||
|
||||
## OrdoClient
|
||||
|
||||
见 `packages/ordo-designer-vue/src/client.ts`。
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
- 节点卡片展示候选人 / policy / action / due
|
||||
- 校验错误挂到节点和边上,不只 toast 第一条
|
||||
- 草稿 vs 已发布:可丢弃本地、对比后再打开
|
||||
- 切定义不清空 Pinia,避免闪旧图
|
||||
- 切定义不清空设计器 context,避免闪旧图
|
||||
- 脏标记 + 离开未保存提示
|
||||
|
||||
## P1 好用
|
||||
@@ -29,5 +29,5 @@
|
||||
- 引擎 JSON 预览 / 导出
|
||||
- 列表里同一 id 的草稿与已发布关系
|
||||
- 统一 loading / 网络错误
|
||||
- 属性面板 n-form 校验态
|
||||
- 属性面板表单校验态
|
||||
- 自动保存草稿
|
||||
|
||||
+4
-31
@@ -1,38 +1,11 @@
|
||||
{
|
||||
"name": "ordo-designer-demo",
|
||||
"version": "0.0.0",
|
||||
"name": "ordo-designer-vue-root",
|
||||
"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"
|
||||
"dev": "pnpm -F ordo-designer-vue build && pnpm -F playground dev",
|
||||
"build": "pnpm -F ordo-designer-vue build && pnpm -F playground build",
|
||||
"type-check": "pnpm -r type-check"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "ordo-designer-vue",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/ordo-designer-vue.js",
|
||||
"development": "./src/index.ts"
|
||||
},
|
||||
"./style.css": "./dist/style.css",
|
||||
"./styles/index.css": "./src/styles/index.css",
|
||||
"./theme.css": "./src/styles/theme.css"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.46.0",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"reka-ui": "^2.10.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vue-sonner": "^2.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vue": "^3.5.40",
|
||||
"vue-tsc": "^3.3.7"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
<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 { computed, markRaw, nextTick, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
|
||||
import {
|
||||
VueFlow,
|
||||
useVueFlow,
|
||||
@@ -15,8 +13,16 @@ import {
|
||||
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 { toast } from 'vue-sonner'
|
||||
import { Toaster } from 'vue-sonner'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import DialogPanel from '@/components/ui/DialogPanel.vue'
|
||||
import SheetPanel from '@/components/ui/SheetPanel.vue'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
import Spinner from '@/components/ui/Spinner.vue'
|
||||
import { useConfirmDialog } from '@/composables/useConfirm'
|
||||
import NodePalette from '@/components/designer/NodePalette.vue'
|
||||
import PropertyPanel from '@/components/designer/PropertyPanel.vue'
|
||||
import GraphErrorList from '@/components/designer/GraphErrorList.vue'
|
||||
@@ -26,27 +32,65 @@ 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 { provideDesignerContext } from '@/context/designerContext'
|
||||
import type { OrdoClient } from '@/client'
|
||||
import type { DesignerDraft, DraftStorage } from '@/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 props = defineProps<{
|
||||
client: OrdoClient
|
||||
drafts: DraftStorage
|
||||
definitionId?: string
|
||||
version?: number
|
||||
source?: string
|
||||
readOnly?: boolean
|
||||
/** `dark` enables `.ordo-designer.dark` token set (CSS variables). */
|
||||
theme?: 'light' | 'dark'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
navigate: [definitionId: string]
|
||||
published: [doc: ProcessDefinitionDocument]
|
||||
}>()
|
||||
|
||||
const readOnlyProp = computed(
|
||||
() => props.readOnly ?? (props.version != null && Number.isFinite(props.version)),
|
||||
)
|
||||
|
||||
const rootClass = computed(() => [
|
||||
'ordo-designer',
|
||||
'flex h-full min-h-0 flex-col bg-background text-foreground',
|
||||
props.theme === 'dark' ? 'dark' : '',
|
||||
])
|
||||
|
||||
const ctx = provideDesignerContext({
|
||||
client: props.client,
|
||||
drafts: props.drafts,
|
||||
readOnly: () => readOnlyProp.value,
|
||||
})
|
||||
|
||||
const { designer, catalog, client, drafts } = ctx
|
||||
const { open: confirmOpen, options: confirmOptions, confirm, accept: confirmAccept, reject: confirmReject } =
|
||||
useConfirmDialog()
|
||||
const bootAsync = useAsync()
|
||||
const actionAsync = useAsync()
|
||||
const { nodes, edges, definitionId, name, publishedVersion, loadedFromDraft, isDirty, canUndo, canRedo, selectedNodeIds } =
|
||||
storeToRefs(designer)
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
definitionId,
|
||||
name,
|
||||
publishedVersion,
|
||||
loadedFromDraft,
|
||||
isDirty,
|
||||
canUndo,
|
||||
canRedo,
|
||||
selectedNodeIds,
|
||||
} = designer
|
||||
const { fitView, screenToFlowCoordinate, setNodes } = useVueFlow('ordo-designer')
|
||||
const { revision } = storeToRefs(drafts)
|
||||
const revision = toRef(drafts, 'revision')
|
||||
const loading = bootAsync.loading
|
||||
|
||||
const conflictShow = ref(false)
|
||||
@@ -72,10 +116,7 @@ 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 readOnly = readOnlyProp
|
||||
const versionLabel = computed(() => {
|
||||
if (readOnly.value && publishedVersion.value != null) {
|
||||
return `v${publishedVersion.value} (read-only)`
|
||||
@@ -114,19 +155,15 @@ async function fitAfterLoad() {
|
||||
}
|
||||
|
||||
async function confirmLeave(): Promise<boolean> {
|
||||
if (!designer.isDirty) {
|
||||
if (!isDirty.value) {
|
||||
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),
|
||||
})
|
||||
return confirm({
|
||||
title: 'Unsaved changes',
|
||||
description: 'Leaving will discard unsaved edits.',
|
||||
confirmText: 'Leave',
|
||||
cancelText: 'Stay',
|
||||
destructive: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,11 +174,11 @@ function persistDraft(silent: boolean) {
|
||||
drafts.save({
|
||||
id: definitionId.value,
|
||||
name: name.value,
|
||||
startStepId: designer.startStepId,
|
||||
startStepId: designer.startStepId.value,
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
})
|
||||
designer.loadedFromDraft = true
|
||||
loadedFromDraft.value = true
|
||||
designer.captureBaseline()
|
||||
if (silent) {
|
||||
savedHint.value = true
|
||||
@@ -153,7 +190,7 @@ function persistDraft(silent: boolean) {
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
message.success('Draft saved')
|
||||
toast.success('Draft saved')
|
||||
replaceDefinition(definitionId.value)
|
||||
}
|
||||
|
||||
@@ -175,7 +212,7 @@ function flushAutosave() {
|
||||
clearTimeout(autosaveTimer)
|
||||
autosaveTimer = null
|
||||
}
|
||||
if (allowAutosave && !readOnly.value && definitionId.value && designer.isDirty) {
|
||||
if (allowAutosave && !readOnly.value && definitionId.value && isDirty.value) {
|
||||
persistDraft(true)
|
||||
}
|
||||
}
|
||||
@@ -191,13 +228,12 @@ async function bootstrap() {
|
||||
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 : ''
|
||||
const id = props.definitionId ?? ''
|
||||
const version = props.version ?? NaN
|
||||
const source = props.source ?? ''
|
||||
|
||||
if (id && Number.isFinite(version)) {
|
||||
const doc = await ordo.getDefinitionVersion(id, version)
|
||||
const doc = await client.getDefinitionVersion(id, version)
|
||||
if (gen !== bootGen) {
|
||||
return
|
||||
}
|
||||
@@ -210,7 +246,7 @@ async function bootstrap() {
|
||||
const draft = drafts.get(id)
|
||||
let published: ProcessDefinitionDocument | null = null
|
||||
try {
|
||||
published = await ordo.getDefinition(id)
|
||||
published = await client.getDefinition(id)
|
||||
} catch {
|
||||
published = null
|
||||
}
|
||||
@@ -251,8 +287,8 @@ async function bootstrap() {
|
||||
designer.resetEmpty()
|
||||
})
|
||||
} catch {
|
||||
message.error(bootAsync.error.value || 'Failed to load')
|
||||
const id = typeof route.params.definitionId === 'string' ? route.params.definitionId : ''
|
||||
toast.error(bootAsync.error.value || 'Failed to load')
|
||||
const id = props.definitionId ?? ''
|
||||
designer.resetEmpty(id || undefined)
|
||||
} finally {
|
||||
if (gen === bootGen) {
|
||||
@@ -297,16 +333,12 @@ 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),
|
||||
})
|
||||
const ok = await confirm({
|
||||
title: 'Discard local draft',
|
||||
description: 'Delete the local draft and open the published version.',
|
||||
confirmText: 'Discard',
|
||||
cancelText: 'Cancel',
|
||||
destructive: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
@@ -314,11 +346,11 @@ async function discardLocalDraft() {
|
||||
const id = definitionId.value
|
||||
drafts.remove(id)
|
||||
try {
|
||||
const published = await actionAsync.run(() => ordo.getDefinition(id))
|
||||
const published = await actionAsync.run(() => client.getDefinition(id))
|
||||
designer.loadDocument(published)
|
||||
await fitAfterLoad()
|
||||
} catch {
|
||||
message.error(actionAsync.error.value || 'Failed to open published')
|
||||
toast.error(actionAsync.error.value || 'Failed to open published')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,23 +401,19 @@ async function requestDeleteSelected() {
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
const nodeIds = designer.selectedNodeIds.length
|
||||
? designer.selectedNodeIds
|
||||
: designer.selectedNodeId
|
||||
? [designer.selectedNodeId]
|
||||
const nodeIds = designer.selectedNodeIds.value.length
|
||||
? designer.selectedNodeIds.value
|
||||
: designer.selectedNodeId.value
|
||||
? [designer.selectedNodeId.value]
|
||||
: []
|
||||
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),
|
||||
})
|
||||
const ok = await confirm({
|
||||
title: 'Delete node',
|
||||
description: 'A selected node has outgoing edges. Delete selected nodes and connected edges?',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
destructive: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
@@ -420,7 +448,7 @@ function align(mode: 'left' | 'center' | 'top') {
|
||||
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
flushAutosave()
|
||||
if (!designer.isDirty) {
|
||||
if (!isDirty.value) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -442,27 +470,24 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
flushAutosave()
|
||||
return confirmLeave()
|
||||
})
|
||||
defineExpose({ confirmLeave, isDirty, flushAutosave })
|
||||
|
||||
function replaceDefinition(id: string) {
|
||||
if (route.params.definitionId === id) {
|
||||
if (props.definitionId === id) {
|
||||
return
|
||||
}
|
||||
skipRouteBootstrap = true
|
||||
void router.replace({ name: 'designer', params: { definitionId: id } })
|
||||
emit('navigate', id)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.definitionId, route.query.version] as const,
|
||||
() => [props.definitionId, props.version, props.source] as const,
|
||||
(curr, prev) => {
|
||||
if (skipRouteBootstrap) {
|
||||
skipRouteBootstrap = false
|
||||
return
|
||||
}
|
||||
if (prev && curr[0] === prev[0] && curr[1] === prev[1]) {
|
||||
if (prev && curr[0] === prev[0] && curr[1] === prev[1] && curr[2] === prev[2]) {
|
||||
return
|
||||
}
|
||||
void bootstrap()
|
||||
@@ -491,7 +516,7 @@ function onDrop(event: DragEvent) {
|
||||
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')
|
||||
toast.info('Only one End node is allowed')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,15 +565,14 @@ function saveAsDraft() {
|
||||
drafts.save({
|
||||
id: definitionId.value,
|
||||
name: name.value,
|
||||
startStepId: designer.startStepId,
|
||||
startStepId: designer.startStepId.value,
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
})
|
||||
designer.loadedFromDraft = true
|
||||
loadedFromDraft.value = true
|
||||
designer.captureBaseline()
|
||||
message.success('Saved as draft')
|
||||
skipRouteBootstrap = true
|
||||
void router.replace({ name: 'designer', params: { definitionId: definitionId.value } })
|
||||
toast.success('Saved as draft')
|
||||
replaceDefinition(definitionId.value)
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
@@ -577,50 +601,51 @@ async function autoLayout() {
|
||||
async function validate() {
|
||||
const local = designer.localErrors()
|
||||
if (local.length) {
|
||||
message.error(`${local.length} validation error(s)`)
|
||||
toast.error(`${local.length} validation error(s)`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await actionAsync.run(() => ordo.parseDefinition(designer.document()))
|
||||
message.success('Validation passed')
|
||||
await actionAsync.run(() => client.parseDefinition(designer.document()))
|
||||
toast.success('Validation passed')
|
||||
} catch {
|
||||
message.error(actionAsync.error.value || 'Validation failed')
|
||||
toast.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`)
|
||||
toast.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
|
||||
const previous = publishedVersion.value
|
||||
const published = await actionAsync.run(() => client.publishDefinition(designer.document()))
|
||||
publishedVersion.value = published.version ?? null
|
||||
drafts.save({
|
||||
id: definitionId.value,
|
||||
name: name.value,
|
||||
startStepId: designer.startStepId,
|
||||
startStepId: designer.startStepId.value,
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
})
|
||||
designer.loadedFromDraft = false
|
||||
loadedFromDraft.value = false
|
||||
designer.captureBaseline()
|
||||
if (published.version != null && previous != null && published.version === previous) {
|
||||
message.info(`Graph unchanged (still v${published.version})`)
|
||||
toast.info(`Graph unchanged (still v${published.version})`)
|
||||
} else {
|
||||
message.success(`Published v${published.version}`)
|
||||
toast.success(`Published v${published.version}`)
|
||||
}
|
||||
emit('published', published)
|
||||
replaceDefinition(definitionId.value)
|
||||
} catch {
|
||||
message.error(actionAsync.error.value || 'Publish failed')
|
||||
toast.error(actionAsync.error.value || 'Publish failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyJson() {
|
||||
await navigator.clipboard.writeText(jsonText.value)
|
||||
message.success('Copied')
|
||||
toast.success('Copied')
|
||||
}
|
||||
|
||||
function downloadJson() {
|
||||
@@ -635,40 +660,52 @@ function downloadJson() {
|
||||
</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="rootClass">
|
||||
<Toaster rich-colors position="top-center" :theme="props.theme === 'dark' ? 'dark' : 'light'" />
|
||||
<ConfirmDialog
|
||||
v-if="confirmOptions"
|
||||
:open="confirmOpen"
|
||||
:title="confirmOptions.title"
|
||||
:description="confirmOptions.description"
|
||||
:confirm-text="confirmOptions.confirmText"
|
||||
:cancel-text="confirmOptions.cancelText"
|
||||
:destructive="confirmOptions.destructive"
|
||||
@confirm="confirmAccept"
|
||||
@cancel="confirmReject"
|
||||
/>
|
||||
<header class="flex h-12 shrink-0 items-center justify-between border-b border-border bg-card 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>
|
||||
<Button variant="ghost" size="sm" @click="emit('back')">
|
||||
<ArrowLeft :size="16" />
|
||||
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>
|
||||
</Button>
|
||||
<span class="text-sm font-medium text-card-foreground">{{ name || 'Designer' }}</span>
|
||||
<Badge>{{ versionLabel }}</Badge>
|
||||
<Badge v-if="publishedHint" variant="info">{{ publishedHint }}</Badge>
|
||||
<Badge v-if="dirtyLabel" variant="warning">{{ dirtyLabel }}</Badge>
|
||||
<Badge v-if="savedHint" variant="success">Saved</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button v-if="hasLocalDraft && !readOnly" size="sm" variant="outline" @click="discardLocalDraft">Discard draft</Button>
|
||||
<Button v-if="readOnly" size="sm" @click="saveAsDraft">Save as draft</Button>
|
||||
<Button size="sm" variant="outline" :disabled="readOnly || !canUndo" @click="applyUndo">Undo</Button>
|
||||
<Button size="sm" variant="outline" :disabled="readOnly || !canRedo" @click="applyRedo">Redo</Button>
|
||||
<Button size="sm" variant="outline" :disabled="readOnly" @click="loadExample">Load example</Button>
|
||||
<Button size="sm" variant="outline" :disabled="readOnly" @click="autoLayout">Auto layout</Button>
|
||||
<Button size="sm" variant="outline" :disabled="readOnly" @click="saveDraft">Save draft</Button>
|
||||
<Button size="sm" variant="outline" :disabled="readOnly" @click="validate">Validate</Button>
|
||||
<Button size="sm" :disabled="readOnly" @click="publish">Publish</Button>
|
||||
<Button size="sm" variant="outline" @click="jsonShow = true">JSON</Button>
|
||||
<Button size="sm" variant="outline" @click="shortcutsShow = true">Shortcuts</Button>
|
||||
</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">
|
||||
<div v-if="loading" class="absolute inset-0 z-20 flex items-center justify-center bg-background/60">
|
||||
<Spinner class="size-8" />
|
||||
</div>
|
||||
<VueFlow
|
||||
id="ordo-designer"
|
||||
v-model:nodes="nodes"
|
||||
@@ -693,19 +730,18 @@ function downloadJson() {
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
<MiniMap class="!border !border-border !bg-card" />
|
||||
</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 v-if="canAlign" class="absolute top-3 left-3 z-10 flex gap-1 rounded-md border border-border bg-card p-1 shadow-sm">
|
||||
<Button size="xs" variant="outline" @click="align('left')">Align left</Button>
|
||||
<Button size="xs" variant="outline" @click="align('center')">Align center</Button>
|
||||
<Button size="xs" variant="outline" @click="align('top')">Align top</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">
|
||||
<div class="rounded-xl border border-dashed border-border bg-card/80 px-6 py-4 text-center text-sm text-muted-foreground">
|
||||
Drag a node from the left palette onto the canvas
|
||||
</div>
|
||||
</div>
|
||||
@@ -719,30 +755,26 @@ function downloadJson() {
|
||||
@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>
|
||||
<DialogPanel v-model:open="shortcutsShow" title="Shortcuts" class="max-w-md">
|
||||
<div class="space-y-2 text-sm text-muted-foreground">
|
||||
<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>
|
||||
</DialogPanel>
|
||||
<SheetPanel v-model:open="jsonShow" title="Engine JSON">
|
||||
<pre class="overflow-auto rounded-md bg-muted p-3 text-xs text-foreground">{{ jsonText }}</pre>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" @click="copyJson">Copy</Button>
|
||||
<Button size="sm" @click="downloadJson">Download .json</Button>
|
||||
</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>
|
||||
</template>
|
||||
</SheetPanel>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { CatalogItem, ProcessDefinitionDocument } from '@/types/definition'
|
||||
|
||||
export interface OrdoClient {
|
||||
listConditions(): Promise<CatalogItem[]>
|
||||
listActions(): Promise<CatalogItem[]>
|
||||
listAssignees(): Promise<CatalogItem[]>
|
||||
getDefinition(id: string): Promise<ProcessDefinitionDocument>
|
||||
getDefinitionVersion(id: string, version: number): Promise<ProcessDefinitionDocument>
|
||||
parseDefinition(doc: ProcessDefinitionDocument): Promise<ProcessDefinitionDocument>
|
||||
publishDefinition(doc: ProcessDefinitionDocument): Promise<ProcessDefinitionDocument>
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<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'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import DialogPanel from '@/components/ui/DialogPanel.vue'
|
||||
import FormField from '@/components/ui/FormField.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Switch from '@/components/ui/Switch.vue'
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { fromSelectValue, toSelectValue } from '@/lib/selectEmpty'
|
||||
|
||||
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 modeOptions = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'predicate', label: 'Predicate' },
|
||||
{ value: 'ref', label: 'Host' },
|
||||
]
|
||||
|
||||
const draftRef = computed(() => (isRefWhen(draft.value) ? draft.value.ref : null))
|
||||
const draftItem = computed(() => props.conditions.find((c) => c.key === draftRef.value))
|
||||
|
||||
function setMode(next: string) {
|
||||
const m = next as 'none' | 'predicate' | 'ref'
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
mode.value = m
|
||||
if (m === 'none') {
|
||||
emit('update:when', undefined)
|
||||
return
|
||||
}
|
||||
if (m === '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(value: unknown) {
|
||||
const key = fromSelectValue(value)
|
||||
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">
|
||||
<SegmentedControl :model-value="mode" :options="modeOptions" :disabled="disabled" @update:model-value="setMode" />
|
||||
|
||||
<template v-if="mode === 'predicate' || mode === 'ref'">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="h-auto min-h-9 w-full justify-start px-2 py-2 text-left text-xs leading-5 font-normal"
|
||||
:disabled="disabled"
|
||||
@click="openEditor"
|
||||
>
|
||||
{{ preview || (mode === 'ref' ? 'No host condition' : 'No predicate') }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" :disabled="disabled" @click="openEditor">Edit</Button>
|
||||
</template>
|
||||
|
||||
<DialogPanel v-model:open="open" :title="modalTitle" class="max-w-3xl">
|
||||
<Alert variant="info" 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>
|
||||
</Alert>
|
||||
<div class="max-h-[60vh] overflow-auto pr-1">
|
||||
<template v-if="editorKind === 'ref'">
|
||||
<FormField label="condition">
|
||||
<Select :disabled="disabled" :model-value="toSelectValue(draftRef)" @update:model-value="onDraftRefKey">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Host condition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="c in conditions" :key="c.key" :value="c.key">
|
||||
{{ c.label }} ({{ c.key }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField
|
||||
v-for="param in draftItem?.params ?? []"
|
||||
:key="param.name"
|
||||
:label="param.label || param.name"
|
||||
>
|
||||
<Input
|
||||
v-if="param.type === 'number'"
|
||||
type="number"
|
||||
:disabled="disabled"
|
||||
:model-value="String(isRefWhen(draft) ? (draft.args?.[param.name] ?? 0) : 0)"
|
||||
@update:model-value="(v: string) => patchDraftArg(param.name, param.type, Number(v) || 0)"
|
||||
/>
|
||||
<Switch
|
||||
v-else-if="param.type === 'boolean'"
|
||||
:disabled="disabled"
|
||||
:model-value="Boolean(isRefWhen(draft) ? draft.args?.[param.name] : false)"
|
||||
@update:model-value="(v: boolean) => patchDraftArg(param.name, param.type, v)"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:disabled="disabled"
|
||||
:model-value="String(isRefWhen(draft) ? (draft.args?.[param.name] ?? '') : '')"
|
||||
@update:model-value="(v: string) => patchDraftArg(param.name, param.type, v)"
|
||||
/>
|
||||
</FormField>
|
||||
</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-muted-foreground">Preview: {{ draftPreview }}</p>
|
||||
<p v-if="error" class="mt-2 text-xs text-red-600">{{ error }}</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" @click="open = false">Cancel</Button>
|
||||
<Button size="sm" @click="confirm">OK</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import DialogPanel from '@/components/ui/DialogPanel.vue'
|
||||
import Button from '@/components/ui/Button.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>
|
||||
<DialogPanel v-model:open="show" title="Local draft vs published" class="max-w-3xl">
|
||||
<p class="mb-3 text-sm text-muted-foreground">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-muted-foreground">Local draft</div>
|
||||
<pre class="overflow-auto rounded-md border border-border bg-muted p-2 text-[11px] leading-4 text-foreground">{{ draftText }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-muted-foreground">Published</div>
|
||||
<pre class="overflow-auto rounded-md border border-border bg-muted p-2 text-[11px] leading-4 text-foreground">{{ publishedText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="comparing = !comparing">{{ comparing ? 'Hide diff' : 'Compare' }}</Button>
|
||||
<Button variant="outline" @click="emit('openDraft')">Open draft</Button>
|
||||
<Button variant="destructive" @click="emit('discard')">Discard local & open published</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { parseIsoDuration, toIsoDuration, type DurationUnit } from '@/mapper/duration'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import FormField from '@/components/ui/FormField.vue'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
const props = defineProps<{
|
||||
iso: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:iso': [value: string]
|
||||
}>()
|
||||
|
||||
const parsed = computed(() => parseIsoDuration(props.iso))
|
||||
const amount = computed(() => parsed.value.value)
|
||||
const unit = computed(() => parsed.value.unit)
|
||||
|
||||
function emitIso(value: number, u: DurationUnit) {
|
||||
emit('update:iso', toIsoDuration(value, u))
|
||||
}
|
||||
|
||||
function onUnitChange(value: unknown) {
|
||||
emitIso(amount.value, String(value) as DurationUnit)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<FormField label="amount">
|
||||
<Input
|
||||
type="number"
|
||||
:disabled="disabled"
|
||||
:model-value="String(amount)"
|
||||
@update:model-value="(v: string) => emitIso(Number(v) || 1, unit)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="unit">
|
||||
<Select :disabled="disabled" :model-value="unit" @update:model-value="onUnitChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="minutes">minutes</SelectItem>
|
||||
<SelectItem value="hours">hours</SelectItem>
|
||||
<SelectItem value="days">days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useDesignerState } from '@/context/designerContext'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const designer = useDesignerState()
|
||||
const graphErrors = computed(() => designer.graphErrors.value)
|
||||
|
||||
function jump(nodeId?: string, edgeId?: string) {
|
||||
if (nodeId) {
|
||||
designer.selectNode(nodeId)
|
||||
return
|
||||
}
|
||||
if (edgeId) {
|
||||
designer.selectEdge(edgeId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="graphErrors.length"
|
||||
class="border-b border-(--ordo-error-border) bg-(--ordo-error-bg) px-4 py-2"
|
||||
>
|
||||
<div class="text-xs font-medium text-(--ordo-error-fg)">Validation errors {{ graphErrors.length }}</div>
|
||||
<Button
|
||||
v-for="(err, index) in graphErrors"
|
||||
:key="`${err.message}-${index}`"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="h-auto w-full justify-start truncate px-0 py-0.5 text-xs font-normal text-(--ordo-error-fg) hover:bg-transparent hover:underline"
|
||||
@click="jump(err.nodeId, err.edgeId)"
|
||||
>
|
||||
{{ err.message }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
+14
-14
@@ -8,8 +8,8 @@ const items = [
|
||||
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',
|
||||
badge: 'bg-sky-500/20 text-sky-700',
|
||||
border: 'border-border hover:border-sky-300',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
@@ -17,8 +17,8 @@ const items = [
|
||||
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',
|
||||
badge: 'bg-amber-500/20 text-amber-700',
|
||||
border: 'border-border hover:border-amber-300',
|
||||
icon: Bolt,
|
||||
},
|
||||
{
|
||||
@@ -26,8 +26,8 @@ const items = [
|
||||
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',
|
||||
badge: 'bg-violet-500/20 text-violet-700',
|
||||
border: 'border-border hover:border-violet-300',
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
@@ -35,8 +35,8 @@ const items = [
|
||||
label: 'End',
|
||||
hint: 'Process terminal',
|
||||
accent: 'bg-slate-500',
|
||||
badge: 'bg-slate-100 text-slate-600',
|
||||
border: 'border-slate-200 hover:border-slate-400',
|
||||
badge: 'bg-muted text-muted-foreground',
|
||||
border: 'border-border hover:border-muted-foreground',
|
||||
icon: Flag,
|
||||
},
|
||||
]
|
||||
@@ -51,13 +51,13 @@ function onDragStart(event: DragEvent, type: string) {
|
||||
</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>
|
||||
<aside class="flex h-full w-56 shrink-0 flex-col gap-3 border-r border-border bg-card p-3 text-card-foreground">
|
||||
<div class="text-xs font-semibold tracking-wide text-muted-foreground 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="group relative cursor-grab overflow-hidden rounded-xl border bg-card shadow-sm transition-all hover:shadow-md active:cursor-grabbing active:scale-[0.98]"
|
||||
:class="item.border"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, item.type)"
|
||||
@@ -71,12 +71,12 @@ function onDragStart(event: DragEvent, type: string) {
|
||||
<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 class="text-[13px] font-semibold">{{ item.label }}</div>
|
||||
<div class="mt-0.5 text-[11px] text-muted-foreground">{{ 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>
|
||||
<p class="mt-auto text-[10px] leading-relaxed text-muted-foreground">Drag a node onto the canvas</p>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,192 @@
|
||||
<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'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
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 groupOp = computed(() => ('or' in props.node ? 'or' : 'and'))
|
||||
const children = computed(() => (isLogicGroup(props.node) ? groupChildren(props.node) : []))
|
||||
|
||||
const groupOpOptions = [
|
||||
{ value: 'and', label: 'AND' },
|
||||
{ value: 'or', label: 'OR' },
|
||||
]
|
||||
|
||||
function setGroupOp(op: string) {
|
||||
const kind = op === 'or' ? 'or' : 'and'
|
||||
emit('update:node', kind === '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)
|
||||
}
|
||||
}
|
||||
|
||||
function onOpChange(value: unknown) {
|
||||
patchLeaf({ op: String(value) as CompareOp })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isLogicGroup(node)"
|
||||
class="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
|
||||
:class="groupOp === 'and' ? 'border-sky-500/35' : 'border-amber-500/35'"
|
||||
>
|
||||
<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-muted-foreground">Group</span>
|
||||
<SegmentedControl
|
||||
:model-value="groupOp"
|
||||
:options="groupOpOptions"
|
||||
:disabled="disabled"
|
||||
@update:model-value="setGroupOp"
|
||||
/>
|
||||
</div>
|
||||
<Button v-if="removable" size="xs" variant="destructive" :disabled="disabled" @click="emit('remove')">
|
||||
Remove group
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-2 border-l-2 pl-3"
|
||||
:class="groupOp === 'and' ? 'border-sky-500/50' : 'border-amber-500/50'"
|
||||
>
|
||||
<p v-if="children.length === 0" class="text-xs text-muted-foreground">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">
|
||||
<Button size="sm" variant="outline" :disabled="disabled || atLeafLimit" @click="addLeaf">Add condition</Button>
|
||||
<Button size="sm" variant="outline" :disabled="disabled || atDepthLimit || atLeafLimit" @click="addGroup">
|
||||
Add group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isNotWhen(node)"
|
||||
class="rounded-lg border border-dashed border-rose-500/40 bg-rose-500/10 p-3 text-card-foreground"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-rose-700">NOT</span>
|
||||
<Button size="xs" variant="ghost" :disabled="disabled" @click="unwrapNot">Remove NOT</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 border border-border bg-muted/60 px-2 py-2"
|
||||
>
|
||||
<Input
|
||||
class="min-w-36 flex-1"
|
||||
placeholder="variable"
|
||||
:disabled="disabled"
|
||||
:model-value="leafVar(node)"
|
||||
@update:model-value="(v: string) => patchLeaf({ variable: v })"
|
||||
/>
|
||||
<Select :disabled="disabled" :model-value="leafOp(node)" @update:model-value="onOpChange">
|
||||
<SelectTrigger class="w-36">
|
||||
<SelectValue placeholder="op" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="eq">=</SelectItem>
|
||||
<SelectItem value="ne">≠</SelectItem>
|
||||
<SelectItem value="gt">></SelectItem>
|
||||
<SelectItem value="gte">≥</SelectItem>
|
||||
<SelectItem value="lt"><</SelectItem>
|
||||
<SelectItem value="lte">≤</SelectItem>
|
||||
<SelectItem value="in">in</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
class="min-w-36 flex-1"
|
||||
:placeholder="leafOp(node) === 'in' ? 'comma-separated' : 'value'"
|
||||
:disabled="disabled"
|
||||
:model-value="leafValueText(node)"
|
||||
@update:model-value="(v: string) => patchLeaf({ value: v })"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" :disabled="disabled || atDepthLimit" @click="wrapNot">NOT</Button>
|
||||
<Button v-if="removable" size="sm" variant="destructive" :disabled="disabled" @click="emit('remove')">Remove</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useCatalogState, useDesignerState } from '@/context/designerContext'
|
||||
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'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import FormField from '@/components/ui/FormField.vue'
|
||||
import Switch from '@/components/ui/Switch.vue'
|
||||
import { useConfirmDialog } from '@/composables/useConfirm'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import TokenListInput from '@/components/ui/TokenListInput.vue'
|
||||
import { fromSelectValue, toSelectValue } from '@/lib/selectEmpty'
|
||||
|
||||
const props = defineProps<{
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const designer = useDesignerState()
|
||||
const catalog = useCatalogState()
|
||||
const { open: confirmOpen, options: confirmOptions, confirm, accept: confirmAccept, reject: confirmReject } =
|
||||
useConfirmDialog()
|
||||
const { definitionId, name, selectedNode, selectedEdge } = designer
|
||||
|
||||
const draftId = ref('')
|
||||
const idError = ref('')
|
||||
|
||||
watch(
|
||||
() => selectedNode.value?.id,
|
||||
(id) => {
|
||||
draftId.value = id ?? ''
|
||||
idError.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const actionOptions = computed(() => catalog.actions.value)
|
||||
const assigneeSuggestions = computed(() =>
|
||||
catalog.assignees.value.map((o) => ({ key: o.key, label: o.label })),
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
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 confirm({
|
||||
title: 'Delete node',
|
||||
description: 'This node has outgoing edges. Delete it and connected edges?',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
destructive: true,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const dueNotifyActionOptions = computed(() => {
|
||||
const fromCatalog = actionOptions.value
|
||||
const actionKey = approval.value?.due.action
|
||||
if (!actionKey || fromCatalog.some((o) => o.key === actionKey)) {
|
||||
return fromCatalog
|
||||
}
|
||||
return [...fromCatalog, { key: actionKey, label: actionKey }]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="h-full w-80 shrink-0 overflow-y-auto border-l border-border bg-card p-3 text-card-foreground">
|
||||
<ConfirmDialog
|
||||
v-if="confirmOptions"
|
||||
:open="confirmOpen"
|
||||
:title="confirmOptions.title"
|
||||
:description="confirmOptions.description"
|
||||
:confirm-text="confirmOptions.confirmText"
|
||||
:cancel-text="confirmOptions.cancelText"
|
||||
:destructive="confirmOptions.destructive"
|
||||
@confirm="confirmAccept"
|
||||
@cancel="confirmReject"
|
||||
/>
|
||||
<Alert v-if="catalog.error.value" variant="warning" class="mb-3">Catalog unavailable, type keys manually</Alert>
|
||||
<Alert v-if="readOnly" variant="info" class="mb-3">Read-only historical version</Alert>
|
||||
|
||||
<div v-if="selectedNode && selectedNode.type === END_TYPE" class="flex flex-col gap-3">
|
||||
<div class="text-xs font-semibold text-muted-foreground">End</div>
|
||||
<Button variant="destructive" :disabled="readOnly" @click="deleteSelectedNode">Delete node</Button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedNode" class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-semibold text-muted-foreground">Step</div>
|
||||
<Button size="xs" variant="destructive" :disabled="readOnly" @click="deleteSelectedNode">Delete</Button>
|
||||
</div>
|
||||
<FormField label="id" :error="idError">
|
||||
<Input v-model="draftId" :disabled="readOnly" @blur="commitId" @keydown.enter.prevent="commitId" />
|
||||
</FormField>
|
||||
<FormField label="name">
|
||||
<Input
|
||||
:model-value="selectedNode.data.name"
|
||||
:disabled="readOnly"
|
||||
@update:model-value="(v: string) => patchSelected({ name: v })"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField v-if="!selectedNode.parentNode" label="start step">
|
||||
<Switch :model-value="isStart" :disabled="readOnly" @update:model-value="onStartChange" />
|
||||
</FormField>
|
||||
|
||||
<template v-if="approval">
|
||||
<FormField label="policy">
|
||||
<Select
|
||||
:disabled="readOnly"
|
||||
:model-value="approval.policy"
|
||||
@update:model-value="(v) => patchSelected({ policy: String(v) as ApprovalPolicy })"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Policy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANY">ANY</SelectItem>
|
||||
<SelectItem value="ALL">ALL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="candidates">
|
||||
<TokenListInput
|
||||
:key="selectedNode.id"
|
||||
:model-value="approval.candidates"
|
||||
:disabled="readOnly"
|
||||
:suggestions="assigneeSuggestions"
|
||||
placeholder="Token, Enter to add"
|
||||
@update:model-value="(v) => patchSelected({ candidates: v })"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
Host resolves tokens at runtime via AssigneeResolver.
|
||||
</p>
|
||||
</FormField>
|
||||
<FormField label="due">
|
||||
<Switch
|
||||
:model-value="approval.dueEnabled"
|
||||
:disabled="readOnly"
|
||||
@update:model-value="(v: boolean) => patchSelected({ dueEnabled: v })"
|
||||
/>
|
||||
</FormField>
|
||||
<template v-if="approval.dueEnabled">
|
||||
<FormField label="after">
|
||||
<DurationAfter :iso="approval.due.after" :disabled="readOnly" @update:iso="(v) => patchDue({ after: v })" />
|
||||
</FormField>
|
||||
<FormField label="then">
|
||||
<Select
|
||||
:disabled="readOnly"
|
||||
:model-value="approval.due.then"
|
||||
@update:model-value="(v) => patchDue({ then: String(v) as DueThen })"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Then" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="reassign">reassign</SelectItem>
|
||||
<SelectItem value="notify">notify</SelectItem>
|
||||
<SelectItem value="goto">goto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField v-if="approval.due.then === 'reassign'" label="to">
|
||||
<TokenListInput
|
||||
:model-value="approval.due.to ? [approval.due.to] : []"
|
||||
:disabled="readOnly"
|
||||
:max-items="1"
|
||||
:suggestions="assigneeSuggestions"
|
||||
placeholder="Assignee token"
|
||||
@update:model-value="(v) => patchDue({ to: v[0] || undefined })"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField v-if="approval.due.then === 'goto'" label="to">
|
||||
<Input
|
||||
:model-value="approval.due.to ?? ''"
|
||||
:disabled="readOnly"
|
||||
@update:model-value="(v: string) => patchDue({ to: v })"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField v-if="approval.due.then === 'notify'" label="action">
|
||||
<Select
|
||||
:disabled="readOnly"
|
||||
:model-value="toSelectValue(approval.due.action)"
|
||||
@update:model-value="(v) => patchDue({ action: fromSelectValue(v) || undefined })"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Action" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="opt in dueNotifyActionOptions" :key="opt.key" :value="opt.key">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="action">
|
||||
<FormField label="action">
|
||||
<Select
|
||||
:disabled="readOnly"
|
||||
:model-value="toSelectValue(action.action)"
|
||||
@update:model-value="(v) => patchSelected({ action: fromSelectValue(v) })"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Action" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="opt in actionOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</template>
|
||||
|
||||
<template v-if="parallel && selectedNode">
|
||||
<FormField 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">
|
||||
<Input
|
||||
:model-value="lane"
|
||||
:disabled="readOnly"
|
||||
@update:model-value="(v: string) => designer.renameLane(selectedNode!.id, lane, v)"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="destructive"
|
||||
:disabled="readOnly || parallel.lanes.length <= 2"
|
||||
@click="designer.removeLane(selectedNode.id, lane)"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="readOnly" @click="designer.addLane(selectedNode.id)">
|
||||
Add branch
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<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-muted-foreground">Edge</div>
|
||||
<Button size="xs" variant="destructive" :disabled="readOnly" @click="deleteSelectedEdge">Delete</Button>
|
||||
</div>
|
||||
<FormField label="when">
|
||||
<ConditionWhenEditor
|
||||
:key="selectedEdge.id"
|
||||
:when="selectedEdge.data?.when"
|
||||
:conditions="catalog.conditions.value"
|
||||
:disabled="readOnly"
|
||||
@update:when="(v) => patchSelectedEdge({ when: v })"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="priority">
|
||||
<Input
|
||||
type="number"
|
||||
:model-value="String(selectedEdge.data?.priority ?? 0)"
|
||||
:disabled="readOnly"
|
||||
@update:model-value="(v: string) => patchSelectedEdge({ priority: Number(v) || 0 })"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div class="text-xs font-semibold text-muted-foreground">Process</div>
|
||||
<FormField label="id">
|
||||
<Input v-model="definitionId" :disabled="readOnly" />
|
||||
</FormField>
|
||||
<FormField label="name">
|
||||
<Input v-model="name" :disabled="readOnly" />
|
||||
</FormField>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
+9
-7
@@ -3,10 +3,10 @@ import { computed } from 'vue'
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow, type EdgeProps } from '@vue-flow/core'
|
||||
import type { DesignerEdgeData } from '@/types/definition'
|
||||
import { formatWhenKind } from '@/mapper/when'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
import { useDesignerState } from '@/context/designerContext'
|
||||
|
||||
const props = defineProps<EdgeProps<DesignerEdgeData>>()
|
||||
const designer = useDesignerStore()
|
||||
const designer = useDesignerState()
|
||||
const { addSelectedEdges, findEdge } = useVueFlow()
|
||||
|
||||
const route = computed(() => {
|
||||
@@ -23,8 +23,8 @@ const route = computed(() => {
|
||||
|
||||
const kind = computed(() => formatWhenKind(props.data?.when) || 'default')
|
||||
const caption = computed(() => (typeof props.label === 'string' && props.label.trim() ? props.label : kind.value))
|
||||
const picked = computed(() => props.selected || designer.selectedEdgeId === props.id)
|
||||
const hasError = computed(() => designer.graphErrors.some((e) => e.edgeId === props.id))
|
||||
const picked = computed(() => props.selected || designer.selectedEdgeId.value === props.id)
|
||||
const hasError = computed(() => designer.graphErrors.value.some((e) => e.edgeId === props.id))
|
||||
|
||||
const edgeStyle = computed(() => {
|
||||
if (hasError.value) {
|
||||
@@ -43,12 +43,14 @@ const labelClass = computed(() => {
|
||||
return 'border-red-200 bg-red-50 text-red-600'
|
||||
}
|
||||
if (kind.value === 'host') {
|
||||
return 'border-amber-300 bg-amber-50 text-amber-800'
|
||||
return 'border-amber-500/40 bg-amber-500/15 text-amber-800 shadow-sm'
|
||||
}
|
||||
if (kind.value === 'predicate') {
|
||||
return 'border-sky-300 bg-sky-50 text-sky-800'
|
||||
return 'border-sky-500/40 bg-sky-500/15 text-sky-800 shadow-sm'
|
||||
}
|
||||
return picked.value ? 'border-slate-400 bg-white text-slate-800' : 'border-slate-200 bg-white/95 text-slate-600'
|
||||
return picked.value
|
||||
? 'border-border bg-card text-card-foreground shadow-sm ring-1 ring-ring/30'
|
||||
: 'border-border bg-card/95 text-muted-foreground shadow-sm'
|
||||
})
|
||||
|
||||
function selectEdge(event: MouseEvent) {
|
||||
+12
-12
@@ -3,7 +3,7 @@ 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 { useDesignerState } from '@/context/designerContext'
|
||||
import NodeHandles from '@/components/designer/nodes/NodeHandles.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -11,30 +11,30 @@ const props = defineProps<{
|
||||
data: ActionNodeData
|
||||
}>()
|
||||
|
||||
const designer = useDesignerStore()
|
||||
const designer = useDesignerState()
|
||||
const { action } = useCatalogLabels()
|
||||
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
|
||||
const hasError = computed(() => designer.graphErrors.value.some((e) => e.nodeId === props.id))
|
||||
const showStart = computed(
|
||||
() => Boolean(props.data.isStart) && !designer.nodes.some((node) => node.id === props.id && node.parentNode),
|
||||
() => Boolean(props.data.isStart) && !designer.nodes.value.some((node) => node.id === props.id && node.parentNode),
|
||||
)
|
||||
</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="group relative min-w-55 overflow-visible rounded-xl border border-border bg-card text-card-foreground shadow-md transition-shadow hover:shadow-lg"
|
||||
:class="
|
||||
hasError
|
||||
? 'border-red-300 ring-2 ring-red-100'
|
||||
? 'border-red-400 ring-2 ring-red-500/20'
|
||||
: data.isStart && showStart
|
||||
? 'border-amber-300 ring-2 ring-amber-100'
|
||||
: 'border-slate-200 hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)]'
|
||||
? 'border-amber-400 ring-2 ring-amber-500/20'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<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">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-amber-700">
|
||||
<Bolt :size="12" />
|
||||
ACTION
|
||||
</span>
|
||||
@@ -45,9 +45,9 @@ const showStart = computed(
|
||||
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" />
|
||||
<div class="text-[13px] font-semibold text-card-foreground">{{ data.name }}</div>
|
||||
<div class="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<CirclePlay :size="12" class="shrink-0 opacity-70" />
|
||||
<span class="truncate">{{ data.action ? action(data.action) : 'Unset' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
+16
-16
@@ -3,7 +3,7 @@ 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 { useDesignerState } from '@/context/designerContext'
|
||||
import NodeHandles from '@/components/designer/nodes/NodeHandles.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -11,30 +11,30 @@ const props = defineProps<{
|
||||
data: ApprovalNodeData
|
||||
}>()
|
||||
|
||||
const designer = useDesignerStore()
|
||||
const designer = useDesignerState()
|
||||
const { assignees } = useCatalogLabels()
|
||||
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
|
||||
const hasError = computed(() => designer.graphErrors.value.some((e) => e.nodeId === props.id))
|
||||
const showStart = computed(
|
||||
() => Boolean(props.data.isStart) && !designer.nodes.some((node) => node.id === props.id && node.parentNode),
|
||||
() => Boolean(props.data.isStart) && !designer.nodes.value.some((node) => node.id === props.id && node.parentNode),
|
||||
)
|
||||
</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="group relative min-w-55 overflow-visible rounded-xl border border-border bg-card text-card-foreground shadow-md transition-shadow hover:shadow-lg"
|
||||
:class="
|
||||
hasError
|
||||
? 'border-red-300 ring-2 ring-red-100'
|
||||
? 'border-red-400 ring-2 ring-red-500/20'
|
||||
: data.isStart && showStart
|
||||
? 'border-sky-300 ring-2 ring-sky-100'
|
||||
: 'border-slate-200 hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)]'
|
||||
? 'border-sky-400 ring-2 ring-sky-500/20'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<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">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-sky-500/20 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-sky-700">
|
||||
<ShieldCheck :size="12" />
|
||||
APPROVAL
|
||||
</span>
|
||||
@@ -45,17 +45,17 @@ const showStart = computed(
|
||||
START
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[13px] font-semibold text-slate-800">{{ data.name }}</div>
|
||||
<div class="text-[13px] font-semibold text-card-foreground">{{ 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" />
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<Users :size="12" class="shrink-0 opacity-70" />
|
||||
<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 class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span class="rounded bg-muted px-1 py-px font-medium text-muted-foreground">{{ 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" />
|
||||
<div v-if="data.dueEnabled" class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<Clock3 :size="12" class="shrink-0 opacity-70" />
|
||||
<span class="truncate">{{ data.due.after }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Flag } from '@lucide/vue'
|
||||
import type { EndNodeData } from '@/types/definition'
|
||||
import { useDesignerState } from '@/context/designerContext'
|
||||
import NodeHandles from '@/components/designer/nodes/NodeHandles.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
data: EndNodeData
|
||||
}>()
|
||||
|
||||
const designer = useDesignerState()
|
||||
const hasError = computed(() => designer.graphErrors.value.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 border-border bg-card text-card-foreground shadow-md"
|
||||
:class="hasError ? 'border-red-400 ring-2 ring-red-500/20' : ''"
|
||||
>
|
||||
<NodeHandles color-class="bg-muted-foreground!" :sources="false" />
|
||||
<Flag :size="16" class="mb-0.5 text-muted-foreground" />
|
||||
<span class="text-[11px] font-semibold">{{ data.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
+28
-25
@@ -1,24 +1,21 @@
|
||||
<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_COL_GAP, LANE_HEADER, LANE_INSET_X, LANE_STEP_W } from '@/mapper/parallel'
|
||||
import { useDesignerStore } from '@/stores/designer'
|
||||
import { useDesignerContext, useDesignerState } from '@/context/designerContext'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
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))
|
||||
const designer = useDesignerState()
|
||||
const { readOnly: readOnlyFn } = useDesignerContext()
|
||||
const readOnly = computed(() => readOnlyFn())
|
||||
const hasError = computed(() => designer.graphErrors.value.some((e) => e.nodeId === props.id))
|
||||
|
||||
function columnStyle(index: number) {
|
||||
return {
|
||||
@@ -32,13 +29,13 @@ function columnStyle(index: number) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full overflow-visible rounded-xl border bg-violet-50/40"
|
||||
class="relative h-full w-full overflow-visible rounded-xl border border-border bg-card/95 text-card-foreground shadow-md"
|
||||
:class="
|
||||
hasError
|
||||
? 'border-red-300 ring-2 ring-red-100'
|
||||
? 'border-red-400 ring-2 ring-red-500/20'
|
||||
: data.isStart
|
||||
? 'border-violet-400 ring-2 ring-violet-100'
|
||||
: 'border-violet-200'
|
||||
? 'border-violet-400 ring-2 ring-violet-500/20'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<Handle
|
||||
@@ -53,22 +50,24 @@ function columnStyle(index: number) {
|
||||
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">
|
||||
<div class="flex h-11 items-center gap-2 border-b border-border px-3">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-violet-500/20 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
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-card-foreground">{{ data.name }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
class="nodrag nopan cursor-pointer! 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"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="nodrag nopan h-7 gap-1 px-1.5 text-[11px] text-violet-700"
|
||||
:disabled="readOnly"
|
||||
@click.stop="designer.addLane(id)"
|
||||
>
|
||||
<Plus :size="12" />
|
||||
Branch
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(lane, index) in data.lanes"
|
||||
@@ -79,24 +78,28 @@ function columnStyle(index: number) {
|
||||
<div class="pointer-events-auto flex flex-col gap-1 px-2 pt-2">
|
||||
<div class="truncate text-[11px] font-semibold text-violet-800">{{ lane }}</div>
|
||||
<div class="flex flex-wrap gap-x-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
class="nodrag nopan cursor-pointer! inline-flex items-center gap-0.5 text-[10px] font-medium text-sky-700 hover:text-sky-800 disabled:opacity-40"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="nodrag nopan h-6 gap-0.5 px-1 text-[10px] text-sky-700"
|
||||
:disabled="readOnly"
|
||||
@click.stop="designer.addLaneStep(id, lane, 'approval')"
|
||||
>
|
||||
<Plus :size="11" />
|
||||
Approval
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
class="nodrag nopan cursor-pointer! inline-flex items-center gap-0.5 text-[10px] font-medium text-amber-700 hover:text-amber-800 disabled:opacity-40"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="nodrag nopan h-6 gap-0.5 px-1 text-[10px] text-amber-700"
|
||||
:disabled="readOnly"
|
||||
@click.stop="designer.addLaneStep(id, lane, 'action')"
|
||||
>
|
||||
<Plus :size="11" />
|
||||
Action
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{ variant?: 'default' | 'warning' | 'info'; class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="alert"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-md border px-3 py-2 text-sm',
|
||||
variant === 'warning' &&
|
||||
'border-(--ordo-alert-warning-border) bg-(--ordo-alert-warning-bg) text-(--ordo-alert-warning-fg)',
|
||||
variant === 'info' &&
|
||||
'border-(--ordo-alert-info-border) bg-(--ordo-alert-info-bg) text-(--ordo-alert-info-fg)',
|
||||
(!variant || variant === 'default') &&
|
||||
'border-(--ordo-alert-default-border) bg-(--ordo-alert-default-bg) text-(--ordo-alert-default-fg)',
|
||||
$props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const badgeVariants = cva('inline-flex items-center rounded-md border border-transparent px-2 py-0.5 text-xs font-medium', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-[var(--ordo-badge-default-bg)] text-[var(--ordo-badge-default-fg)]',
|
||||
info: 'bg-[var(--ordo-badge-info-bg)] text-[var(--ordo-badge-info-fg)]',
|
||||
warning: 'bg-[var(--ordo-badge-warning-bg)] text-[var(--ordo-badge-warning-fg)]',
|
||||
success: 'bg-[var(--ordo-badge-success-bg)] text-[var(--ordo-badge-success-fg)]',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
})
|
||||
|
||||
type Variants = VariantProps<typeof badgeVariants>
|
||||
|
||||
defineProps<{ variant?: Variants['variant']; class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="cn(badgeVariants({ variant }), $props.class)"><slot /></span>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:opacity-90',
|
||||
outline: 'border border-border bg-card hover:bg-muted text-foreground',
|
||||
ghost: 'hover:bg-muted text-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:opacity-90',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
xs: 'h-7 px-2 text-xs',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
type Variants = VariantProps<typeof buttonVariants>
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: Variants['variant']
|
||||
size?: Variants['size']
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
class?: string
|
||||
}>(),
|
||||
{ type: 'button', disabled: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="props.type"
|
||||
:disabled="props.disabled"
|
||||
:class="cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
destructive?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ confirm: []; cancel: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-2000 flex items-center justify-center bg-(--ordo-overlay) p-4">
|
||||
<div class="w-full max-w-md rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg">
|
||||
<h2 class="text-base font-semibold">{{ title }}</h2>
|
||||
<p class="mt-2 text-sm text-muted-foreground">{{ description }}</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" @click="emit('cancel')">{{ cancelText ?? 'Cancel' }}</Button>
|
||||
<Button
|
||||
:variant="destructive ? 'destructive' : 'default'"
|
||||
size="sm"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ confirmText ?? 'Confirm' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from '@lucide/vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
defineProps<{ title: string; class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-1500 flex items-center justify-center bg-(--ordo-overlay) p-4">
|
||||
<div
|
||||
class="flex max-h-[90vh] w-full flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-lg"
|
||||
:class="$props.class"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 class="text-sm font-semibold">{{ title }}</h2>
|
||||
<Button variant="ghost" size="icon" class="size-8" @click="open = false">
|
||||
<X :size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-4"><slot /></div>
|
||||
<div v-if="$slots.footer" class="border-t border-border px-4 py-3">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import Label from '@/components/ui/Label.vue'
|
||||
|
||||
defineProps<{ label?: string; error?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label v-if="label">{{ label }}</Label>
|
||||
<slot />
|
||||
<p v-if="error" class="text-xs text-red-600">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
defineProps<{ disabled?: boolean; class?: string; placeholder?: string; type?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="model"
|
||||
:type="type ?? 'text'"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
class="flex h-9 w-full rounded-md border border-border bg-field px-3 py-1 text-sm text-field-foreground shadow-none outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="cn($props.class)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label :class="cn('text-sm font-medium text-foreground', $props.class)"><slot /></label>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
defineProps<{
|
||||
options: { value: string; label: string }[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex rounded-md border border-border bg-card p-0.5"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50"
|
||||
:class="
|
||||
model === opt.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
"
|
||||
@click="!disabled && (model = opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import Label from '@/components/ui/Label.vue'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label v-if="label">{{ label }}</Label>
|
||||
<Select v-model="model" :disabled="disabled">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="placeholder ?? 'Select'" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<slot />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from '@lucide/vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
defineProps<{ title: string; widthClass?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-1500 flex justify-end bg-(--ordo-overlay)">
|
||||
<div
|
||||
class="flex h-full flex-col border-l border-border bg-card text-card-foreground shadow-xl"
|
||||
:class="widthClass ?? 'w-120 max-w-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 class="text-sm font-semibold">{{ title }}</h2>
|
||||
<Button variant="ghost" size="icon" class="size-8" @click="open = false">
|
||||
<X :size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-4"><slot /></div>
|
||||
<div v-if="$slots.footer" class="border-t border-border px-4 py-3">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'inline-block size-5 animate-spin rounded-full border-2 border-muted border-t-primary',
|
||||
$props.class,
|
||||
)
|
||||
"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
defineProps<{ disabled?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="model"
|
||||
:disabled="disabled"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="model ? 'bg-primary' : 'bg-muted'"
|
||||
@click="!disabled && (model = !model)"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none block size-4 translate-x-0.5 rounded-full bg-white shadow transition-transform"
|
||||
:class="model ? 'translate-x-4' : 'translate-x-0.5'"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { X } from '@lucide/vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { normalizeTokenList, splitTokenInput } from '@/lib/tokenList'
|
||||
|
||||
export type TokenSuggestion = { key: string; label?: string }
|
||||
|
||||
const model = defineModel<string[]>({ default: () => [] })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
maxItems?: number
|
||||
suggestions?: TokenSuggestion[]
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
placeholder: 'Type and press Enter',
|
||||
maxItems: Number.POSITIVE_INFINITY,
|
||||
suggestions: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const draft = ref('')
|
||||
|
||||
const unusedSuggestions = computed(() =>
|
||||
props.suggestions.filter((s) => !model.value.includes(s.key)),
|
||||
)
|
||||
|
||||
function applyTokens(parts: string[]) {
|
||||
if (parts.length === 0) {
|
||||
return
|
||||
}
|
||||
if (props.maxItems === 1) {
|
||||
const token = parts[parts.length - 1]?.trim()
|
||||
model.value = token ? [token] : []
|
||||
return
|
||||
}
|
||||
model.value = normalizeTokenList([...model.value, ...parts], props.maxItems)
|
||||
}
|
||||
|
||||
function commitDraft() {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
const parts = splitTokenInput(draft.value)
|
||||
applyTokens(parts)
|
||||
draft.value = ''
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ',') {
|
||||
event.preventDefault()
|
||||
commitDraft()
|
||||
} else if (event.key === 'Backspace' && draft.value === '' && model.value.length > 0) {
|
||||
model.value = model.value.slice(0, -1)
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData('text')
|
||||
if (!text || !/[\n,]/.test(text)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
draft.value = text
|
||||
commitDraft()
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
model.value = model.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function addSuggestion(key: string) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
applyTokens([key])
|
||||
}
|
||||
|
||||
function labelFor(key: string) {
|
||||
return props.suggestions.find((s) => s.key === key)?.label
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-border bg-field px-2 py-1.5 text-sm text-field-foreground',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)
|
||||
"
|
||||
@click="($event.currentTarget as HTMLElement).querySelector('input')?.focus()"
|
||||
>
|
||||
<span
|
||||
v-for="(token, index) in model"
|
||||
:key="`${token}-${index}`"
|
||||
class="inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs text-foreground"
|
||||
:title="labelFor(token) ? `${labelFor(token)} (${token})` : token"
|
||||
>
|
||||
<span class="truncate">{{ token }}</span>
|
||||
<Button
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-5 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Remove"
|
||||
@click.stop="removeAt(index)"
|
||||
>
|
||||
<X :size="12" />
|
||||
</Button>
|
||||
</span>
|
||||
<input
|
||||
v-if="!disabled && (maxItems === 1 ? model.length === 0 : model.length < maxItems)"
|
||||
v-model="draft"
|
||||
type="text"
|
||||
class="min-w-24 flex-1 bg-transparent py-0.5 text-sm text-field-foreground outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="model.length === 0 ? placeholder : ''"
|
||||
@keydown="onKeydown"
|
||||
@paste="onPaste"
|
||||
@blur="commitDraft"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!disabled && unusedSuggestions.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
v-for="s in unusedSuggestions"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
@click="addSuggestion(s.key)"
|
||||
>
|
||||
+ {{ s.label ?? s.key }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import SelectScrollDownButton from './SelectScrollDownButton.vue'
|
||||
import SelectScrollUpButton from './SelectScrollUpButton.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{ position: 'popper' },
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'relative z-[1600] max-h-96 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border border-border bg-card text-card-foreground shadow-md',
|
||||
props.position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport class="p-1">
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectGroup } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup v-bind="delegatedProps" :class="cn(props.class)">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { SelectItem, SelectItemIndicator, SelectItemText, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<SelectItemIndicator>
|
||||
<Check :size="16" />
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
<SelectItemText>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel :class="cn('py-1.5 pl-8 pr-2 text-sm font-semibold text-foreground', props.class)">
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<ChevronDown :size="16" />
|
||||
</SelectScrollDownButton>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronUp } from '@lucide/vue'
|
||||
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<ChevronUp :size="16" />
|
||||
</SelectScrollUpButton>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { SelectSeparator } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-border', props.class)" />
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-border bg-field px-3 py-2 text-sm text-field-foreground shadow-none focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate [&>span]:text-field-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown :size="16" class="shrink-0 opacity-50" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectValueProps } from 'reka-ui'
|
||||
import { SelectValue } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue v-bind="props" />
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as Select } from './Select.vue'
|
||||
export { default as SelectContent } from './SelectContent.vue'
|
||||
export { default as SelectGroup } from './SelectGroup.vue'
|
||||
export { default as SelectItem } from './SelectItem.vue'
|
||||
export { default as SelectLabel } from './SelectLabel.vue'
|
||||
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
|
||||
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
|
||||
export { default as SelectSeparator } from './SelectSeparator.vue'
|
||||
export { default as SelectTrigger } from './SelectTrigger.vue'
|
||||
export { default as SelectValue } from './SelectValue.vue'
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useCatalogState } from '@/context/designerContext'
|
||||
import type { CatalogItem } from '@/types/definition'
|
||||
|
||||
function labelOf(items: CatalogItem[], key: string) {
|
||||
@@ -6,14 +6,14 @@ function labelOf(items: CatalogItem[], key: string) {
|
||||
}
|
||||
|
||||
export function useCatalogLabels() {
|
||||
const catalog = useCatalogStore()
|
||||
const catalog = useCatalogState()
|
||||
|
||||
function assignee(key: string) {
|
||||
return labelOf(catalog.assignees, key)
|
||||
return labelOf(catalog.assignees.value, key)
|
||||
}
|
||||
|
||||
function action(key: string) {
|
||||
return labelOf(catalog.actions, key)
|
||||
return labelOf(catalog.actions.value, key)
|
||||
}
|
||||
|
||||
function assignees(keys: string[]) {
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string
|
||||
description: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function useConfirmDialog() {
|
||||
const open = ref(false)
|
||||
const options = ref<ConfirmOptions | null>(null)
|
||||
let resolver: ((value: boolean) => void) | null = null
|
||||
|
||||
function confirm(opts: ConfirmOptions): Promise<boolean> {
|
||||
options.value = opts
|
||||
open.value = true
|
||||
return new Promise((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
}
|
||||
|
||||
function accept() {
|
||||
open.value = false
|
||||
resolver?.(true)
|
||||
resolver = null
|
||||
}
|
||||
|
||||
function reject() {
|
||||
open.value = false
|
||||
resolver?.(false)
|
||||
resolver = null
|
||||
}
|
||||
|
||||
return { open, options, confirm, accept, reject }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { inject, provide, type InjectionKey } from 'vue'
|
||||
import type { OrdoClient } from '@/client'
|
||||
import type { DraftStorage } from '@/drafts'
|
||||
import { createCatalogState } from '@/stores/catalog'
|
||||
import { createDesignerState } from '@/stores/designer'
|
||||
|
||||
export interface DesignerContextOptions {
|
||||
client: OrdoClient
|
||||
drafts: DraftStorage
|
||||
readOnly: () => boolean
|
||||
}
|
||||
|
||||
export interface DesignerContext {
|
||||
designer: ReturnType<typeof createDesignerState>
|
||||
catalog: ReturnType<typeof createCatalogState>
|
||||
client: OrdoClient
|
||||
drafts: DraftStorage
|
||||
readOnly: () => boolean
|
||||
}
|
||||
|
||||
const designerContextKey: InjectionKey<DesignerContext> = Symbol('ordoDesignerContext')
|
||||
|
||||
export function provideDesignerContext(options: DesignerContextOptions): DesignerContext {
|
||||
const designer = createDesignerState()
|
||||
const catalog = createCatalogState(() => options.client)
|
||||
const ctx: DesignerContext = {
|
||||
designer,
|
||||
catalog,
|
||||
client: options.client,
|
||||
drafts: options.drafts,
|
||||
readOnly: options.readOnly,
|
||||
}
|
||||
provide(designerContextKey, ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useDesignerContext(): DesignerContext {
|
||||
const ctx = inject(designerContextKey)
|
||||
if (!ctx) {
|
||||
throw new Error('useDesignerContext must be used within OrdoDesigner')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useDesignerState() {
|
||||
return useDesignerContext().designer
|
||||
}
|
||||
|
||||
export function useCatalogState() {
|
||||
return useDesignerContext().catalog
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { DesignerEdge, 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
|
||||
}
|
||||
|
||||
export interface DraftStorage {
|
||||
revision: Ref<number>
|
||||
get(id: string): DesignerDraft | undefined
|
||||
save(params: {
|
||||
id: string
|
||||
name: string
|
||||
startStepId: string
|
||||
nodes: DesignerNode[]
|
||||
edges: DesignerEdge[]
|
||||
}): void
|
||||
remove(id: string): void
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import './styles/index.css'
|
||||
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 OrdoDesigner from './OrdoDesigner.vue'
|
||||
|
||||
export { OrdoDesigner }
|
||||
export { default as DraftConflictModal } from './components/designer/DraftConflictModal.vue'
|
||||
export { default as Button } from './components/ui/Button.vue'
|
||||
export { default as Badge } from './components/ui/Badge.vue'
|
||||
export { default as Alert } from './components/ui/Alert.vue'
|
||||
export { default as DialogPanel } from './components/ui/DialogPanel.vue'
|
||||
export { layoutFromNodes, comparableDocument, toDocument } from './mapper/graph'
|
||||
export type { DesignerNode, DesignerEdge } from './mapper/graph'
|
||||
export type { OrdoClient } from './client'
|
||||
export type { DesignerDraft, DraftStorage } from './drafts'
|
||||
export type {
|
||||
ProcessDefinitionDocument,
|
||||
CatalogItem,
|
||||
StepDocument,
|
||||
TransitionDocument,
|
||||
PageResponse,
|
||||
} from './types/definition'
|
||||
@@ -0,0 +1,11 @@
|
||||
export function toSelectValue(value: string | undefined | null): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
export function fromSelectValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function normalizeTokenList(items: string[], maxItems = Number.POSITIVE_INFINITY): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const item of items) {
|
||||
const token = item.trim()
|
||||
if (!token || seen.has(token)) {
|
||||
continue
|
||||
}
|
||||
seen.add(token)
|
||||
out.push(token)
|
||||
if (out.length >= maxItems) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function splitTokenInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
declare module '*.css' {}
|
||||
|
||||
declare module '@vue-flow/core/dist/style.css' {}
|
||||
declare module '@vue-flow/core/dist/theme-default.css' {}
|
||||
declare module '@vue-flow/controls/dist/style.css' {}
|
||||
declare module '@vue-flow/minimap/dist/style.css' {}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as ordo from '@/api/ordo'
|
||||
import type { OrdoClient } from '@/client'
|
||||
import type { CatalogItem } from '@/types/definition'
|
||||
|
||||
export const useCatalogStore = defineStore('catalog', () => {
|
||||
export function createCatalogState(getClient: () => OrdoClient) {
|
||||
const conditions = ref<CatalogItem[]>([])
|
||||
const actions = ref<CatalogItem[]>([])
|
||||
const assignees = ref<CatalogItem[]>([])
|
||||
@@ -12,11 +11,12 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
|
||||
async function load() {
|
||||
error.value = null
|
||||
const client = getClient()
|
||||
try {
|
||||
const [c, a, s] = await Promise.all([
|
||||
ordo.listConditions(),
|
||||
ordo.listActions(),
|
||||
ordo.listAssignees(),
|
||||
client.listConditions(),
|
||||
client.listActions(),
|
||||
client.listAssignees(),
|
||||
])
|
||||
conditions.value = c
|
||||
actions.value = a
|
||||
@@ -32,4 +32,6 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
}
|
||||
|
||||
return { conditions, actions, assignees, loaded, error, load }
|
||||
})
|
||||
}
|
||||
|
||||
export type CatalogState = ReturnType<typeof createCatalogState>
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Connection } from '@vue-flow/core'
|
||||
import {
|
||||
ACTION_TYPE,
|
||||
@@ -64,7 +63,7 @@ function slimEdge(edge: DesignerEdge): DesignerEdge {
|
||||
}
|
||||
}
|
||||
|
||||
export const useDesignerStore = defineStore('designer', () => {
|
||||
export function createDesignerState() {
|
||||
const definitionId = ref('')
|
||||
const name = ref('')
|
||||
const startStepId = ref('')
|
||||
@@ -801,4 +800,4 @@ export const useDesignerStore = defineStore('designer', () => {
|
||||
addLaneStep,
|
||||
renameLane,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
.vue-flow {
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, var(--ordo-canvas-grid) 1px, transparent 0) 0 0 / 20px 20px,
|
||||
var(--ordo-canvas);
|
||||
}
|
||||
|
||||
.vue-flow__edge-path {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.vue-flow__handle {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.vue-flow__handle:hover {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.vue-flow__node-parallel button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vue-flow__edge-labels {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.pointer-events-all {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Vue Flow chrome — follow ordo tokens in dark mode */
|
||||
.ordo-designer.dark .vue-flow__minimap {
|
||||
background-color: var(--ordo-card) !important;
|
||||
border: 1px solid var(--ordo-border);
|
||||
border-radius: var(--ordo-radius-md);
|
||||
}
|
||||
|
||||
.ordo-designer.dark .vue-flow__minimap-mask {
|
||||
fill: var(--ordo-primary);
|
||||
fill-opacity: 0.12;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .vue-flow__minimap-node {
|
||||
fill: var(--ordo-muted-foreground);
|
||||
}
|
||||
|
||||
.ordo-designer.dark .vue-flow__controls {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .vue-flow__controls-button {
|
||||
background: var(--ordo-card) !important;
|
||||
border-color: var(--ordo-border) !important;
|
||||
color: var(--ordo-foreground) !important;
|
||||
fill: var(--ordo-foreground) !important;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .vue-flow__controls-button:hover {
|
||||
background: var(--ordo-muted) !important;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .vue-flow__controls-button svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .vue-flow__edge-path {
|
||||
stroke: var(--ordo-muted-foreground);
|
||||
}
|
||||
|
||||
.ordo-designer input::placeholder,
|
||||
.ordo-designer textarea::placeholder {
|
||||
color: var(--ordo-muted-foreground);
|
||||
}
|
||||
|
||||
.ordo-designer.dark .text-sky-700 {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .text-amber-700 {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .text-violet-700 {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .text-amber-800 {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .text-sky-800 {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.ordo-designer.dark .text-rose-700 {
|
||||
color: #fda4af;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "vue-sonner/style.css";
|
||||
@import "./theme.css";
|
||||
@import "./designer.css";
|
||||
@@ -0,0 +1,149 @@
|
||||
/* Defaults (also apply outside .ordo-designer for SSR/tooling) */
|
||||
:root {
|
||||
--ordo-background: #ffffff;
|
||||
--ordo-foreground: #0f172a;
|
||||
--ordo-muted: #f1f5f9;
|
||||
--ordo-muted-foreground: #64748b;
|
||||
--ordo-card: #ffffff;
|
||||
--ordo-card-foreground: #0f172a;
|
||||
--ordo-border: #e2e8f0;
|
||||
--ordo-input: #e2e8f0;
|
||||
--ordo-primary: #2563eb;
|
||||
--ordo-primary-foreground: #ffffff;
|
||||
--ordo-destructive: #dc2626;
|
||||
--ordo-destructive-foreground: #ffffff;
|
||||
--ordo-ring: #2563eb;
|
||||
--ordo-canvas: #f8fafc;
|
||||
--ordo-canvas-grid: #e2e8f0;
|
||||
--ordo-overlay: rgb(15 23 42 / 0.4);
|
||||
--ordo-alert-info-bg: #eff6ff;
|
||||
--ordo-alert-info-border: #bfdbfe;
|
||||
--ordo-alert-info-fg: #1e3a8a;
|
||||
--ordo-alert-warning-bg: #fffbeb;
|
||||
--ordo-alert-warning-border: #fde68a;
|
||||
--ordo-alert-warning-fg: #78350f;
|
||||
--ordo-alert-default-bg: #f8fafc;
|
||||
--ordo-alert-default-border: #e2e8f0;
|
||||
--ordo-alert-default-fg: #334155;
|
||||
--ordo-badge-default-bg: #f1f5f9;
|
||||
--ordo-badge-default-fg: #334155;
|
||||
--ordo-badge-info-bg: #eff6ff;
|
||||
--ordo-badge-info-fg: #1d4ed8;
|
||||
--ordo-badge-warning-bg: #fffbeb;
|
||||
--ordo-badge-warning-fg: #92400e;
|
||||
--ordo-badge-success-bg: #ecfdf5;
|
||||
--ordo-badge-success-fg: #047857;
|
||||
--ordo-error-bg: #fef2f2;
|
||||
--ordo-error-border: #fecaca;
|
||||
--ordo-error-fg: #b91c1c;
|
||||
--ordo-radius-md: 0.375rem;
|
||||
--ordo-radius-lg: 0.5rem;
|
||||
--ordo-field: #ffffff;
|
||||
--ordo-field-foreground: #0f172a;
|
||||
}
|
||||
|
||||
.ordo-designer {
|
||||
--ordo-background: #ffffff;
|
||||
--ordo-foreground: #0f172a;
|
||||
--ordo-muted: #f1f5f9;
|
||||
--ordo-muted-foreground: #64748b;
|
||||
--ordo-card: #ffffff;
|
||||
--ordo-card-foreground: #0f172a;
|
||||
--ordo-border: #e2e8f0;
|
||||
--ordo-input: #e2e8f0;
|
||||
--ordo-primary: #2563eb;
|
||||
--ordo-primary-foreground: #ffffff;
|
||||
--ordo-destructive: #dc2626;
|
||||
--ordo-destructive-foreground: #ffffff;
|
||||
--ordo-ring: #2563eb;
|
||||
--ordo-canvas: #f8fafc;
|
||||
--ordo-canvas-grid: #e2e8f0;
|
||||
--ordo-overlay: rgb(15 23 42 / 0.4);
|
||||
--ordo-alert-info-bg: #eff6ff;
|
||||
--ordo-alert-info-border: #bfdbfe;
|
||||
--ordo-alert-info-fg: #1e3a8a;
|
||||
--ordo-alert-warning-bg: #fffbeb;
|
||||
--ordo-alert-warning-border: #fde68a;
|
||||
--ordo-alert-warning-fg: #78350f;
|
||||
--ordo-alert-default-bg: #f8fafc;
|
||||
--ordo-alert-default-border: #e2e8f0;
|
||||
--ordo-alert-default-fg: #334155;
|
||||
--ordo-badge-default-bg: #f1f5f9;
|
||||
--ordo-badge-default-fg: #334155;
|
||||
--ordo-badge-info-bg: #eff6ff;
|
||||
--ordo-badge-info-fg: #1d4ed8;
|
||||
--ordo-badge-warning-bg: #fffbeb;
|
||||
--ordo-badge-warning-fg: #92400e;
|
||||
--ordo-badge-success-bg: #ecfdf5;
|
||||
--ordo-badge-success-fg: #047857;
|
||||
--ordo-error-bg: #fef2f2;
|
||||
--ordo-error-border: #fecaca;
|
||||
--ordo-error-fg: #b91c1c;
|
||||
--ordo-radius-md: 0.375rem;
|
||||
--ordo-radius-lg: 0.5rem;
|
||||
--ordo-field: #ffffff;
|
||||
--ordo-field-foreground: #0f172a;
|
||||
color: var(--ordo-foreground);
|
||||
background-color: var(--ordo-background);
|
||||
}
|
||||
|
||||
.ordo-designer.dark {
|
||||
--ordo-background: #0f172a;
|
||||
--ordo-foreground: #f1f5f9;
|
||||
--ordo-muted: #1e293b;
|
||||
--ordo-muted-foreground: #94a3b8;
|
||||
--ordo-card: #1e293b;
|
||||
--ordo-card-foreground: #f1f5f9;
|
||||
--ordo-border: #334155;
|
||||
--ordo-input: #334155;
|
||||
--ordo-primary: #3b82f6;
|
||||
--ordo-primary-foreground: #ffffff;
|
||||
--ordo-destructive: #ef4444;
|
||||
--ordo-destructive-foreground: #ffffff;
|
||||
--ordo-ring: #3b82f6;
|
||||
--ordo-canvas: #0b1220;
|
||||
--ordo-canvas-grid: #334155;
|
||||
--ordo-overlay: rgb(0 0 0 / 0.55);
|
||||
--ordo-alert-info-bg: #172554;
|
||||
--ordo-alert-info-border: #1e40af;
|
||||
--ordo-alert-info-fg: #bfdbfe;
|
||||
--ordo-alert-warning-bg: #422006;
|
||||
--ordo-alert-warning-border: #92400e;
|
||||
--ordo-alert-warning-fg: #fde68a;
|
||||
--ordo-alert-default-bg: #1e293b;
|
||||
--ordo-alert-default-border: #334155;
|
||||
--ordo-alert-default-fg: #cbd5e1;
|
||||
--ordo-badge-default-bg: #334155;
|
||||
--ordo-badge-default-fg: #e2e8f0;
|
||||
--ordo-badge-info-bg: #172554;
|
||||
--ordo-badge-info-fg: #93c5fd;
|
||||
--ordo-badge-warning-bg: #422006;
|
||||
--ordo-badge-warning-fg: #fcd34d;
|
||||
--ordo-badge-success-bg: #064e3b;
|
||||
--ordo-badge-success-fg: #6ee7b7;
|
||||
--ordo-error-bg: #450a0a;
|
||||
--ordo-error-border: #991b1b;
|
||||
--ordo-error-fg: #fecaca;
|
||||
--ordo-field: #334155;
|
||||
--ordo-field-foreground: #f1f5f9;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--ordo-background);
|
||||
--color-foreground: var(--ordo-foreground);
|
||||
--color-muted: var(--ordo-muted);
|
||||
--color-muted-foreground: var(--ordo-muted-foreground);
|
||||
--color-card: var(--ordo-card);
|
||||
--color-card-foreground: var(--ordo-card-foreground);
|
||||
--color-border: var(--ordo-border);
|
||||
--color-input: var(--ordo-input);
|
||||
--color-primary: var(--ordo-primary);
|
||||
--color-primary-foreground: var(--ordo-primary-foreground);
|
||||
--color-destructive: var(--ordo-destructive);
|
||||
--color-destructive-foreground: var(--ordo-destructive-foreground);
|
||||
--color-ring: var(--ordo-ring);
|
||||
--color-field: var(--ordo-field);
|
||||
--color-field-foreground: var(--ordo-field-foreground);
|
||||
--radius-md: var(--ordo-radius-md);
|
||||
--radius-lg: var(--ordo-radius-lg);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "6.0",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.build.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.build.json" }]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import dts from 'vite-plugin-dts'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
dts({
|
||||
tsconfigPath: './tsconfig.build.json',
|
||||
entryRoot: 'src',
|
||||
outDir: 'dist',
|
||||
include: ['env.d.ts', 'src/**/*.ts', 'src/**/*.vue'],
|
||||
rollupTypes: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
|
||||
name: 'OrdoDesignerVue',
|
||||
fileName: 'ordo-designer-vue',
|
||||
formats: ['es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['vue'],
|
||||
output: {
|
||||
assetFileNames: 'style.css',
|
||||
},
|
||||
},
|
||||
cssCodeSplit: false,
|
||||
},
|
||||
})
|
||||
Generated
+800
-242
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,6 @@
|
||||
packages:
|
||||
- packages/*
|
||||
- apps/*
|
||||
|
||||
allowBuilds:
|
||||
vue-demi: false
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
<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>
|
||||
@@ -1,75 +0,0 @@
|
||||
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')
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
<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>
|
||||
@@ -1,44 +0,0 @@
|
||||
<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>
|
||||
@@ -1,44 +0,0 @@
|
||||
<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>
|
||||
@@ -1,30 +0,0 @@
|
||||
<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>
|
||||
@@ -1,175 +0,0 @@
|
||||
<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>
|
||||
@@ -1,388 +0,0 @@
|
||||
<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 v-if="!selectedNode.parentNode" 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>
|
||||
@@ -1,26 +0,0 @@
|
||||
<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>
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
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')
|
||||
@@ -1,82 +0,0 @@
|
||||
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 }
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user