@rytass/bpm-core-shared 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/eslint.config.mjs +19 -0
- package/jest.config.cts +10 -0
- package/package.json +2 -3
- package/project.json +49 -0
- package/src/lib/condition.ts +55 -0
- package/src/lib/form.ts +77 -0
- package/src/lib/identity.ts +25 -0
- package/src/lib/organization.ts +52 -0
- package/src/lib/shared.spec.ts +13 -0
- package/src/lib/status.ts +29 -0
- package/src/lib/workflow-command.spec.ts +278 -0
- package/src/lib/workflow-command.ts +848 -0
- package/src/lib/workflow-graph.ts +974 -0
- package/src/lib/workflow-toolset.spec.ts +230 -0
- package/src/lib/workflow-toolset.ts +1083 -0
- package/src/lib/workflow.ts +216 -0
- package/tsconfig.json +23 -0
- package/tsconfig.lib.json +16 -0
- package/tsconfig.spec.json +16 -0
- package/src/index.js +0 -13
- package/src/index.js.map +0 -1
- package/src/lib/condition.d.ts +0 -43
- package/src/lib/condition.js +0 -3
- package/src/lib/condition.js.map +0 -1
- package/src/lib/form.d.ts +0 -48
- package/src/lib/form.js +0 -3
- package/src/lib/form.js.map +0 -1
- package/src/lib/identity.d.ts +0 -24
- package/src/lib/identity.js +0 -3
- package/src/lib/identity.js.map +0 -1
- package/src/lib/organization.d.ts +0 -47
- package/src/lib/organization.js +0 -3
- package/src/lib/organization.js.map +0 -1
- package/src/lib/status.d.ts +0 -7
- package/src/lib/status.js +0 -3
- package/src/lib/status.js.map +0 -1
- package/src/lib/workflow-command.d.ts +0 -156
- package/src/lib/workflow-command.js +0 -292
- package/src/lib/workflow-command.js.map +0 -1
- package/src/lib/workflow-graph.d.ts +0 -96
- package/src/lib/workflow-graph.js +0 -605
- package/src/lib/workflow-graph.js.map +0 -1
- package/src/lib/workflow-toolset.d.ts +0 -105
- package/src/lib/workflow-toolset.js +0 -702
- package/src/lib/workflow-toolset.js.map +0 -1
- package/src/lib/workflow.d.ts +0 -160
- package/src/lib/workflow.js +0 -3
- package/src/lib/workflow.js.map +0 -1
- /package/src/{index.d.ts → index.ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
Releases are managed by [`nx release`](https://nx.dev/recipes/nx-release) with
|
|
9
9
|
Conventional Commits — see `nx.json` for the release config.
|
|
10
10
|
|
|
11
|
+
## 0.5.0 — 2026-07-06
|
|
12
|
+
|
|
13
|
+
No source changes. Version aligned with `@rytass/bpm-core-client` and
|
|
14
|
+
`@rytass/bpm-core-nestjs-module` 0.5.0 (fixed release group). The new
|
|
15
|
+
`BPMMemberSearchPage` paged-search types added in that release live in
|
|
16
|
+
`@rytass/bpm-core-nestjs-module`, not here.
|
|
17
|
+
|
|
18
|
+
## 0.4.0 — 2026-07-02
|
|
19
|
+
|
|
20
|
+
No source changes. Version aligned with `@rytass/bpm-core-client` and
|
|
21
|
+
`@rytass/bpm-core-nestjs-module` 0.4.0 (fixed release group).
|
|
22
|
+
|
|
11
23
|
## 0.3.0 — 2026-06-06
|
|
12
24
|
|
|
13
25
|
No source changes. Version aligned with `@rytass/bpm-core-client` and
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import baseConfig from '../../eslint.config.mjs';
|
|
2
|
+
|
|
3
|
+
export default [
|
|
4
|
+
...baseConfig,
|
|
5
|
+
{
|
|
6
|
+
files: ['**/*.json'],
|
|
7
|
+
rules: {
|
|
8
|
+
'@nx/dependency-checks': [
|
|
9
|
+
'error',
|
|
10
|
+
{
|
|
11
|
+
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
languageOptions: {
|
|
16
|
+
parser: await import('jsonc-eslint-parser'),
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
];
|
package/jest.config.cts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
displayName: 'shared',
|
|
3
|
+
preset: '../../jest.preset.js',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
transform: {
|
|
6
|
+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
|
7
|
+
},
|
|
8
|
+
moduleFileExtensions: ['ts', 'js', 'html'],
|
|
9
|
+
coverageDirectory: '../../coverage/libs/shared',
|
|
10
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rytass/bpm-core-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Shared TypeScript contracts for the Rytass BPM approval workflow stack (workflow, form, condition, identity, organization, status types).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bpm",
|
|
@@ -94,6 +94,5 @@
|
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
96
|
"tslib": "^2.3.0"
|
|
97
|
-
}
|
|
98
|
-
"type": "commonjs"
|
|
97
|
+
}
|
|
99
98
|
}
|
package/project.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shared",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "libs/shared/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"targets": {
|
|
8
|
+
"build": {
|
|
9
|
+
"executor": "@nx/js:tsc",
|
|
10
|
+
"outputs": ["{options.outputPath}"],
|
|
11
|
+
"options": {
|
|
12
|
+
"outputPath": "dist/libs/shared",
|
|
13
|
+
"main": "libs/shared/src/index.ts",
|
|
14
|
+
"tsConfig": "libs/shared/tsconfig.lib.json",
|
|
15
|
+
"assets": [
|
|
16
|
+
"libs/shared/*.md",
|
|
17
|
+
{ "input": ".", "glob": "LICENSE", "output": "." }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"typecheck": {
|
|
22
|
+
"executor": "nx:run-commands",
|
|
23
|
+
"options": {
|
|
24
|
+
"command": "tsc --noEmit -p libs/shared/tsconfig.lib.json"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"pkg-quality": {
|
|
28
|
+
"executor": "nx:run-commands",
|
|
29
|
+
"dependsOn": ["build"],
|
|
30
|
+
"options": {
|
|
31
|
+
"commands": [
|
|
32
|
+
"pnpm exec publint dist/libs/shared"
|
|
33
|
+
],
|
|
34
|
+
"parallel": false
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"pkg-quality-attw": {
|
|
38
|
+
"executor": "nx:run-commands",
|
|
39
|
+
"dependsOn": ["build"],
|
|
40
|
+
"options": {
|
|
41
|
+
"command": "pnpm exec attw --pack dist/libs/shared"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"prepublish-check": {
|
|
45
|
+
"executor": "nx:noop",
|
|
46
|
+
"dependsOn": ["typecheck", "lint", "build", "pkg-quality"]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type ConditionContextType =
|
|
2
|
+
| 'INITIATOR_POLICY'
|
|
3
|
+
| 'ENTRY_CONDITION'
|
|
4
|
+
| 'FLOW_CONDITION'
|
|
5
|
+
| 'APPROVER_RESOLVER'
|
|
6
|
+
| 'FORM_FIELD_CONDITION'
|
|
7
|
+
| 'DELEGATION_CONDITION';
|
|
8
|
+
|
|
9
|
+
export interface SubjectContext {
|
|
10
|
+
readonly customFields: Readonly<Record<string, unknown>>;
|
|
11
|
+
readonly email: string;
|
|
12
|
+
readonly memberId: string;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly org: OrgContext;
|
|
15
|
+
readonly position: PositionContext;
|
|
16
|
+
readonly roles: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OrgContext {
|
|
20
|
+
readonly code: string;
|
|
21
|
+
readonly costCenter?: string;
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly location?: string;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly parentId: string | null;
|
|
26
|
+
readonly path: readonly string[];
|
|
27
|
+
readonly type: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PositionContext {
|
|
31
|
+
readonly code: string;
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly level: number;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EnvContext {
|
|
38
|
+
readonly now: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface InstanceContext {
|
|
42
|
+
readonly id: string;
|
|
43
|
+
readonly startedAt: string;
|
|
44
|
+
readonly templateId: string;
|
|
45
|
+
readonly templateVersion: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LastDecisionContext {
|
|
49
|
+
readonly action: 'approved' | 'rejected' | 'returned';
|
|
50
|
+
readonly comment: string;
|
|
51
|
+
readonly decidedAt: string;
|
|
52
|
+
readonly decidedBy: SubjectContext;
|
|
53
|
+
readonly nodeId: string;
|
|
54
|
+
readonly taskId: string;
|
|
55
|
+
}
|
package/src/lib/form.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface FormDefinitionSchema {
|
|
2
|
+
readonly fields: readonly FormFieldDefinition[];
|
|
3
|
+
readonly schemaVersion: 1;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type FormFieldDefinition =
|
|
7
|
+
| TextFieldDefinition
|
|
8
|
+
| NumberFieldDefinition
|
|
9
|
+
| DateFieldDefinition
|
|
10
|
+
| SelectFieldDefinition
|
|
11
|
+
| BooleanFieldDefinition
|
|
12
|
+
| FileUploadFieldDefinition;
|
|
13
|
+
|
|
14
|
+
interface BaseFormFieldDefinition<TType extends string> {
|
|
15
|
+
readonly defaultValue?: FormFieldValue;
|
|
16
|
+
readonly description?: string;
|
|
17
|
+
readonly fieldKey: string;
|
|
18
|
+
readonly label: string;
|
|
19
|
+
readonly placeholder?: string;
|
|
20
|
+
readonly readonlyWhen?: string;
|
|
21
|
+
readonly required: boolean;
|
|
22
|
+
readonly requiredWhen?: string;
|
|
23
|
+
readonly type: TType;
|
|
24
|
+
readonly visibleWhen?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type FormFieldValue =
|
|
28
|
+
| boolean
|
|
29
|
+
| number
|
|
30
|
+
| string
|
|
31
|
+
| readonly string[]
|
|
32
|
+
| null;
|
|
33
|
+
|
|
34
|
+
export type TextFieldDefinition = BaseFormFieldDefinition<
|
|
35
|
+
'text' | 'textarea'
|
|
36
|
+
> & {
|
|
37
|
+
readonly maxLength?: number;
|
|
38
|
+
readonly minLength?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type NumberFieldDefinition = BaseFormFieldDefinition<
|
|
42
|
+
'number' | 'money'
|
|
43
|
+
> & {
|
|
44
|
+
readonly maximum?: number;
|
|
45
|
+
readonly minimum?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type DateFieldDefinition = BaseFormFieldDefinition<'date' | 'datetime'>;
|
|
49
|
+
|
|
50
|
+
export type SelectFieldDefinition = BaseFormFieldDefinition<
|
|
51
|
+
'select' | 'radio' | 'checkbox'
|
|
52
|
+
> & {
|
|
53
|
+
readonly options: readonly FormFieldOption[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface FormFieldOption {
|
|
57
|
+
readonly label: string;
|
|
58
|
+
readonly value: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type BooleanFieldDefinition = BaseFormFieldDefinition<'boolean'>;
|
|
62
|
+
|
|
63
|
+
export type FileUploadFieldDefinition =
|
|
64
|
+
BaseFormFieldDefinition<'file_upload'> & {
|
|
65
|
+
readonly acceptedMimeTypes?: readonly string[];
|
|
66
|
+
readonly maxFiles?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface FormUiSchema {
|
|
70
|
+
readonly layout: readonly FormLayoutItem[];
|
|
71
|
+
readonly schemaVersion: 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface FormLayoutItem {
|
|
75
|
+
readonly fieldKey: string;
|
|
76
|
+
readonly width: 'FULL' | 'HALF' | 'THIRD';
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface MemberMetadata {
|
|
2
|
+
readonly memberId: string;
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly email: string;
|
|
5
|
+
/**
|
|
6
|
+
* Opaque host-shaped JSON. BPM stores the object verbatim, serializes
|
|
7
|
+
* it back to GraphQL clients as `MemberProfile.customFieldsJson`, and
|
|
8
|
+
* never introspects keys — anything the host puts here flows through
|
|
9
|
+
* unchanged. Hosts using `createBPMMemberBaseResolverProvider` populate
|
|
10
|
+
* this field via the `options.readCustomFields?.(member)` hook;
|
|
11
|
+
* standalone `BPMMemberResolver` implementations return whatever shape
|
|
12
|
+
* the consumer UI needs. Use it as a side channel for picker labels,
|
|
13
|
+
* org context strings, avatar URLs, or any other host-specific
|
|
14
|
+
* metadata that should appear in member-display UIs.
|
|
15
|
+
*/
|
|
16
|
+
readonly customFields: Readonly<Record<string, unknown>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MemberMetadataCacheEntry {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly memberId: string;
|
|
22
|
+
readonly metadata: MemberMetadata;
|
|
23
|
+
readonly fetchedAt: string;
|
|
24
|
+
readonly expiresAt: string;
|
|
25
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org-unit type — matches the BPM GraphQL `OrgUnitType` enum SDL names
|
|
3
|
+
* (UPPERCASE). The server stores the value lower-cased internally
|
|
4
|
+
* (`'company'` etc. in the database row), but every read and write
|
|
5
|
+
* across the GraphQL wire and the REST surface uses the UPPERCASE form.
|
|
6
|
+
*
|
|
7
|
+
* Consumers of `@rytass/bpm-core-client/organization` must pass these
|
|
8
|
+
* UPPERCASE literals to `createOrgUnit` / `updateOrgUnit` (and the
|
|
9
|
+
* `readOrganizationDashboard` `orgUnitType` filter), and will receive
|
|
10
|
+
* UPPERCASE values back on `OrgUnitRecord.type`.
|
|
11
|
+
*/
|
|
12
|
+
export type OrgUnitType = 'COMPANY' | 'DIVISION' | 'DEPARTMENT' | 'TEAM';
|
|
13
|
+
|
|
14
|
+
export type ManagerResolutionScopeType = 'MEMBER' | 'ORG_UNIT' | 'POSITION';
|
|
15
|
+
|
|
16
|
+
export interface OrgUnit {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly parentId: string | null;
|
|
19
|
+
readonly code: string;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly type: OrgUnitType;
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly metadata: Readonly<Record<string, unknown>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Position {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly code: string;
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly level: number;
|
|
31
|
+
readonly metadata: Readonly<Record<string, unknown>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Membership {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly memberId: string;
|
|
37
|
+
readonly orgUnitId: string;
|
|
38
|
+
readonly positionId: string | null;
|
|
39
|
+
readonly isPrimary: boolean;
|
|
40
|
+
readonly effectiveFrom: string;
|
|
41
|
+
readonly effectiveTo: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ManagerResolution {
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly scopeType: ManagerResolutionScopeType;
|
|
47
|
+
readonly scopeId: string;
|
|
48
|
+
readonly managerMemberId: string;
|
|
49
|
+
readonly priority: number;
|
|
50
|
+
readonly effectiveFrom: string;
|
|
51
|
+
readonly effectiveTo: string | null;
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { WorkflowDefinition } from './workflow';
|
|
2
|
+
|
|
3
|
+
describe('shared', () => {
|
|
4
|
+
it('exports workflow definition types', () => {
|
|
5
|
+
const workflow: WorkflowDefinition = {
|
|
6
|
+
edges: [],
|
|
7
|
+
meta: { schemaVersion: 1 },
|
|
8
|
+
nodes: [],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
expect(workflow.meta.schemaVersion).toBe(1);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type VersionStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
|
2
|
+
|
|
3
|
+
export type ApprovalInstanceState =
|
|
4
|
+
| 'DRAFT'
|
|
5
|
+
| 'RUNNING'
|
|
6
|
+
| 'APPROVED'
|
|
7
|
+
| 'REJECTED'
|
|
8
|
+
| 'RETURNED'
|
|
9
|
+
| 'CANCELLED'
|
|
10
|
+
| 'EXPIRED';
|
|
11
|
+
|
|
12
|
+
export type WorkflowTokenStatus = 'ACTIVE' | 'WAITING' | 'CONSUMED';
|
|
13
|
+
|
|
14
|
+
export type TaskStatus =
|
|
15
|
+
| 'PENDING'
|
|
16
|
+
| 'IN_PROGRESS'
|
|
17
|
+
| 'COMPLETED'
|
|
18
|
+
| 'TRANSFERRED'
|
|
19
|
+
| 'CANCELLED';
|
|
20
|
+
|
|
21
|
+
export type TaskDecisionAction =
|
|
22
|
+
| 'APPROVED'
|
|
23
|
+
| 'REJECTED'
|
|
24
|
+
| 'RETURNED'
|
|
25
|
+
| 'TRANSFERRED';
|
|
26
|
+
|
|
27
|
+
export type DelegationRuleStatus = 'ACTIVE' | 'REVOKED' | 'EXPIRED';
|
|
28
|
+
|
|
29
|
+
export type DelegationScopeType = 'ALL' | 'TEMPLATE_LIST' | 'CONDITION_BASED';
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFallbackWorkflowDefinition,
|
|
3
|
+
readWorkflowDefinitionIssue,
|
|
4
|
+
} from './workflow-graph';
|
|
5
|
+
import { WorkflowDefinition } from './workflow';
|
|
6
|
+
import {
|
|
7
|
+
WorkflowCommandOptions,
|
|
8
|
+
WorkflowDesignerState,
|
|
9
|
+
applyWorkflowCommand,
|
|
10
|
+
applyWorkflowMacroCommand,
|
|
11
|
+
} from './workflow-command';
|
|
12
|
+
|
|
13
|
+
function deterministicEdgeIds(): WorkflowCommandOptions {
|
|
14
|
+
let counter = 0;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
createEdgeId: (source, target): string =>
|
|
18
|
+
`edge_${source}_${target}_${(counter += 1)}`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function initialState(): WorkflowDesignerState {
|
|
23
|
+
return {
|
|
24
|
+
definition: readFallbackWorkflowDefinition(),
|
|
25
|
+
editingEdgeId: null,
|
|
26
|
+
formDefinitionVersionId: null,
|
|
27
|
+
formSchema: null,
|
|
28
|
+
initiatorPolicyCel: null,
|
|
29
|
+
selectedEdgeIds: [],
|
|
30
|
+
selectedNodeId: 'start',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('applyWorkflowCommand', () => {
|
|
35
|
+
it('adds a user task between start and end, flagging layout', () => {
|
|
36
|
+
const result = applyWorkflowCommand(
|
|
37
|
+
initialState(),
|
|
38
|
+
{ nodeType: 'userTask', type: 'addNode' },
|
|
39
|
+
deterministicEdgeIds(),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(result.changed).toBe(true);
|
|
43
|
+
expect(result.effects.layout).toBe(true);
|
|
44
|
+
expect(
|
|
45
|
+
result.state.definition.nodes.some((node) => node.type === 'userTask'),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
// start -> userTask -> end
|
|
48
|
+
expect(result.state.definition.edges).toHaveLength(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('only node inserts and autoLayout request a relayout', () => {
|
|
52
|
+
const base = applyWorkflowCommand(
|
|
53
|
+
initialState(),
|
|
54
|
+
{ nodeType: 'userTask', type: 'addNode' },
|
|
55
|
+
deterministicEdgeIds(),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// addNode positions the new node -> relayout.
|
|
59
|
+
expect(base.effects.layout).toBe(true);
|
|
60
|
+
|
|
61
|
+
// Connecting already-positioned nodes preserves manual layout.
|
|
62
|
+
expect(
|
|
63
|
+
applyWorkflowCommand(base.state, {
|
|
64
|
+
source: 'start',
|
|
65
|
+
target: 'end',
|
|
66
|
+
type: 'connectEdge',
|
|
67
|
+
}).effects.layout,
|
|
68
|
+
).toBe(false);
|
|
69
|
+
|
|
70
|
+
// autoLayout is the explicit tidy step.
|
|
71
|
+
expect(
|
|
72
|
+
applyWorkflowCommand(base.state, { type: 'autoLayout' }).effects.layout,
|
|
73
|
+
).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects an invalid connection (end node cannot be a source)', () => {
|
|
77
|
+
const result = applyWorkflowCommand(initialState(), {
|
|
78
|
+
source: 'end',
|
|
79
|
+
target: 'start',
|
|
80
|
+
type: 'connectEdge',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result.changed).toBe(false);
|
|
84
|
+
expect(result.error).toContain('無法連線');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('refuses to delete the start node', () => {
|
|
88
|
+
const result = applyWorkflowCommand(initialState(), {
|
|
89
|
+
nodeId: 'start',
|
|
90
|
+
type: 'deleteNode',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.changed).toBe(false);
|
|
94
|
+
expect(result.error).toContain('無法刪除');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('sets a user task approver and resets the decision policy to SINGLE', () => {
|
|
98
|
+
const withNode = applyWorkflowCommand(
|
|
99
|
+
initialState(),
|
|
100
|
+
{ nodeType: 'userTask', type: 'addNode' },
|
|
101
|
+
deterministicEdgeIds(),
|
|
102
|
+
).state;
|
|
103
|
+
const nodeId = withNode.definition.nodes.find(
|
|
104
|
+
(node) => node.type === 'userTask',
|
|
105
|
+
)?.id;
|
|
106
|
+
|
|
107
|
+
const result = applyWorkflowCommand(withNode, {
|
|
108
|
+
approverResolver: { positionId: 'pos-1', type: 'POSITION' },
|
|
109
|
+
nodeId: nodeId ?? '',
|
|
110
|
+
type: 'setUserTaskApprover',
|
|
111
|
+
});
|
|
112
|
+
const updated = result.state.definition.nodes.find(
|
|
113
|
+
(node) => node.id === nodeId,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(updated?.type).toBe('userTask');
|
|
117
|
+
|
|
118
|
+
if (updated?.type === 'userTask') {
|
|
119
|
+
expect(updated.data.approverResolver).toEqual({
|
|
120
|
+
positionId: 'pos-1',
|
|
121
|
+
type: 'POSITION',
|
|
122
|
+
});
|
|
123
|
+
expect(updated.data.decisionPolicy).toEqual({ type: 'SINGLE' });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('reports a validation issue for an unconfigured DIRECT approver', () => {
|
|
128
|
+
const withNode = applyWorkflowCommand(
|
|
129
|
+
initialState(),
|
|
130
|
+
{ nodeType: 'userTask', type: 'addNode' },
|
|
131
|
+
deterministicEdgeIds(),
|
|
132
|
+
).state;
|
|
133
|
+
const nodeId =
|
|
134
|
+
withNode.definition.nodes.find((node) => node.type === 'userTask')?.id ??
|
|
135
|
+
'';
|
|
136
|
+
|
|
137
|
+
const result = applyWorkflowCommand(withNode, {
|
|
138
|
+
approverResolver: { memberIds: [], type: 'DIRECT' },
|
|
139
|
+
nodeId,
|
|
140
|
+
type: 'setUserTaskApprover',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.issue).toBe('簽核節點需要指定簽核會員。');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('compiles an edge condition against the bound form schema', () => {
|
|
147
|
+
// Build: start -> gateway -> end, then condition the gateway output.
|
|
148
|
+
const base = applyWorkflowCommand(
|
|
149
|
+
{
|
|
150
|
+
...initialState(),
|
|
151
|
+
formSchema: {
|
|
152
|
+
fields: [
|
|
153
|
+
{
|
|
154
|
+
fieldKey: 'amount',
|
|
155
|
+
label: '金額',
|
|
156
|
+
required: true,
|
|
157
|
+
type: 'money',
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
schemaVersion: 1,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{ nodeType: 'exclusiveGateway', type: 'addNode' },
|
|
164
|
+
deterministicEdgeIds(),
|
|
165
|
+
).state;
|
|
166
|
+
const gatewayOutEdge = base.definition.edges.find((edge) =>
|
|
167
|
+
base.definition.nodes.some(
|
|
168
|
+
(node) =>
|
|
169
|
+
node.id === edge.source && node.type === 'exclusiveGateway',
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const result = applyWorkflowCommand(base, {
|
|
174
|
+
edgeId: gatewayOutEdge?.id ?? '',
|
|
175
|
+
fieldKey: 'amount',
|
|
176
|
+
operator: 'GREATER_THAN',
|
|
177
|
+
type: 'setEdgeCondition',
|
|
178
|
+
value: '1000',
|
|
179
|
+
});
|
|
180
|
+
const updatedEdge = result.state.definition.edges.find(
|
|
181
|
+
(edge) => edge.id === gatewayOutEdge?.id,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(updatedEdge?.data.condition).toBe('form.amount > 1000');
|
|
185
|
+
expect(updatedEdge?.data.isDefault).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('applyWorkflowMacroCommand', () => {
|
|
190
|
+
it('insertApprovalStep adds a user task and sets its approver in one call', () => {
|
|
191
|
+
const result = applyWorkflowMacroCommand(
|
|
192
|
+
initialState(),
|
|
193
|
+
{
|
|
194
|
+
approverResolver: { memberIds: ['m-1'], type: 'DIRECT' },
|
|
195
|
+
label: '部門簽核',
|
|
196
|
+
type: 'insertApprovalStep',
|
|
197
|
+
},
|
|
198
|
+
deterministicEdgeIds(),
|
|
199
|
+
);
|
|
200
|
+
const userTask = result.state.definition.nodes.find(
|
|
201
|
+
(node) => node.type === 'userTask',
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(result.changed).toBe(true);
|
|
205
|
+
expect(userTask?.data.label).toBe('部門簽核');
|
|
206
|
+
|
|
207
|
+
if (userTask?.type === 'userTask') {
|
|
208
|
+
expect(userTask.data.approverResolver).toEqual({
|
|
209
|
+
memberIds: ['m-1'],
|
|
210
|
+
type: 'DIRECT',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('readWorkflowDefinitionIssue — exclusive gateway default path', () => {
|
|
217
|
+
function gatewayDefinition(
|
|
218
|
+
edges: WorkflowDefinition['edges'],
|
|
219
|
+
): WorkflowDefinition {
|
|
220
|
+
return {
|
|
221
|
+
edges,
|
|
222
|
+
meta: { schemaVersion: 1 },
|
|
223
|
+
nodes: [
|
|
224
|
+
{ data: { label: '開始' }, id: 'start', position: { x: 0, y: 0 }, type: 'startEvent' },
|
|
225
|
+
{
|
|
226
|
+
data: { direction: 'split', label: '分流', triggerMode: 'AND' },
|
|
227
|
+
id: 'gw',
|
|
228
|
+
position: { x: 0, y: 0 },
|
|
229
|
+
type: 'exclusiveGateway',
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
data: { endState: 'APPROVED', label: '完成', triggerMode: 'AND' },
|
|
233
|
+
id: 'end',
|
|
234
|
+
position: { x: 0, y: 0 },
|
|
235
|
+
type: 'endEvent',
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
it('flags a gateway whose outputs all have conditions but no "其他情況" default', () => {
|
|
242
|
+
const issue = readWorkflowDefinitionIssue(
|
|
243
|
+
gatewayDefinition([
|
|
244
|
+
{ data: {}, id: 'e0', source: 'start', target: 'gw' },
|
|
245
|
+
{
|
|
246
|
+
data: { condition: 'form.amount > 100000', label: '金額>10萬' },
|
|
247
|
+
id: 'e1',
|
|
248
|
+
source: 'gw',
|
|
249
|
+
target: 'end',
|
|
250
|
+
},
|
|
251
|
+
]),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(issue).toContain('其他情況');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('passes once one output edge is the default path', () => {
|
|
258
|
+
const issue = readWorkflowDefinitionIssue(
|
|
259
|
+
gatewayDefinition([
|
|
260
|
+
{ data: {}, id: 'e0', source: 'start', target: 'gw' },
|
|
261
|
+
{
|
|
262
|
+
data: { condition: 'form.amount > 100000', label: '金額>10萬' },
|
|
263
|
+
id: 'e1',
|
|
264
|
+
source: 'gw',
|
|
265
|
+
target: 'end',
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
data: { isDefault: true, label: '其他情況' },
|
|
269
|
+
id: 'e2',
|
|
270
|
+
source: 'gw',
|
|
271
|
+
target: 'end',
|
|
272
|
+
},
|
|
273
|
+
]),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(issue).toBeNull();
|
|
277
|
+
});
|
|
278
|
+
});
|