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:
0264408
2026-09-29 15:22:59 +08:00
co-authored by Cursor
parent 43b4a83407
commit 18239a59ca
106 changed files with 4000 additions and 2038 deletions
+38 -28
View File
@@ -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)。
View File
+5 -5
View File
@@ -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>
+30
View File
@@ -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"
}
}
+5
View File
@@ -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 }
}
+8
View File
@@ -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 },
],
})
+73
View File
@@ -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)
}
+8
View File
@@ -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>
+11
View File
@@ -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"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["vite.config.ts"],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+29
View File
@@ -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,
},
},
},
})
+69
View File
@@ -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
View File
@@ -7,7 +7,7 @@
- 节点卡片展示候选人 / policy / action / due
- 校验错误挂到节点和边上,不只 toast 第一条
- 草稿 vs 已发布:可丢弃本地、对比后再打开
- 切定义不清空 Pinia,避免闪旧图
- 切定义不清空设计器 context,避免闪旧图
- 脏标记 + 离开未保存提示
## P1 好用
@@ -29,5 +29,5 @@
- 引擎 JSON 预览 / 导出
- 列表里同一 id 的草稿与已发布关系
- 统一 loading / 网络错误
- 属性面板 n-form 校验态
- 属性面板表单校验态
- 自动保存草稿
+4 -31
View File
@@ -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"
+7
View File
@@ -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
}
+51
View File
@@ -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({
return confirm({
title: 'Unsaved changes',
content: 'Leaving will discard unsaved edits.',
positiveText: 'Leave',
negativeText: 'Stay',
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
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({
const ok = await confirm({
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),
})
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({
const ok = await confirm({
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),
})
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>
<Button variant="ghost" size="sm" @click="emit('back')">
<ArrowLeft :size="16" />
</template>
Back
</n-button>
<span class="text-sm font-medium text-slate-800">{{ name || 'Designer' }}</span>
<n-tag size="small">{{ versionLabel }}</n-tag>
<n-tag v-if="publishedHint" size="small" type="info">{{ publishedHint }}</n-tag>
<n-tag v-if="dirtyLabel" size="small" type="warning">{{ dirtyLabel }}</n-tag>
<n-tag v-if="savedHint" size="small" type="success">Saved</n-tag>
</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,9 +755,8 @@ 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">
<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>
@@ -731,18 +766,15 @@ function downloadJson() {
<p>Save draft: Ctrl/Cmd + S</p>
<p v-if="readOnly" class="text-amber-600">Edit shortcuts are disabled in read-only mode.</p>
</div>
</n-card>
</n-modal>
<n-drawer v-model:show="jsonShow" :width="480" placement="right">
<n-drawer-content title="Engine JSON">
</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>
<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>
<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>
</template>
<pre class="overflow-auto rounded-md bg-slate-50 p-3 text-xs text-slate-700">{{ jsonText }}</pre>
</n-drawer-content>
</n-drawer>
</SheetPanel>
</div>
</template>
+11
View File
@@ -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>
@@ -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">&gt;</SelectItem>
<SelectItem value="gte">≥</SelectItem>
<SelectItem value="lt">&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>
@@ -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) {
@@ -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>
@@ -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>
@@ -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'
@@ -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
}
+25
View File
@@ -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
}
+25
View File
@@ -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))
}
+6
View File
@@ -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"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.build.json" }]
}
+39
View File
@@ -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,
},
})
+800 -242
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,2 +1,6 @@
packages:
- packages/*
- apps/*
allowBuilds:
vue-demi: false
-11
View File
@@ -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>
-75
View File
@@ -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>
-44
View File
@@ -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>
-388
View File
@@ -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>
-26
View File
@@ -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
View File
@@ -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')
-82
View File
@@ -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