diff --git a/README.md b/README.md index ecbdf8b..541b534 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,90 @@ # Workflow -> This package is still experimental and may change radically until the next minor version. +> **Note:** This package is still experimental and may change significantly until the next minor version. -If you've been programming for a while, you've likely encountered workflows. They represent the various **states** and **transitions** that an entity can go through in a system. +Workflows represent the various **states** and **transitions** that an entity can go through in a system. For example, let's say you're developing a task management application. You'll need to establish some rules: -For example, let's say you're developing a blog and want to allow users to share posts. You'll need to establish some rules: - -- When a user creates a post, does it start as a `draft`? -- Can a post move directly from `draft` to `published`, or does it need to be `moderated` first? -- You might allow deleting `draft`s, but should `published` posts only be `archived`? +- When a user creates a task, does it start as `new`? +- Can a task move directly from `in progress` to `completed`, or does it need to be `reviewed` first? +- You might allow deleting `new` tasks, but should `completed` tasks only be `archived`? These kinds of questions can be tricky, and it's crucial to represent this logic clearly in your code. ## Install -``` -npm i @jean-michelet/workflow -``` - -## Workflow class - -The `Workflow` class allows you to define transitions between different states. These transitions can be represented as simple state changes (`Transition`) or as transitions with multiple origins (`MultiOriginTransition`). - -In this example, the post can move from `draft` to `published`: - -```ts -import { Transition, Workflow } from "@jean-michelet/workflow"; -const workflow = new Workflow(); - -workflow.addTransition("publish", new Transition("draft", "published")); - -console.log(workflow.can("publish", "draft")); // true -console.log(workflow.can("publish", "published")); // false +```bash +npm i @jean-michelet/workflow ``` -### Multi-Origin Transitions +## Workflow -A `MultiOriginTransition` allows an entity to transition to a single target state from multiple states. +The `Workflow` class lets you define state transitions and apply them to the state property of an entity. You can define transitions from a single state or from multiple states. -In this example, the post can move to the `archived` state from either `aborted` or `completed` states: +In this example, a task can move through several stages in its lifecycle: from `new` to `in progress`, then either to `completed` or `canceled`, and finally be archived from either the `canceled` or `completed` states: ```ts -import { MultiOriginTransition, Workflow } from "@jean-michelet/workflow"; +// Example in typecript, but also works with vanilla JS +import { + Transition, + MultiOriginTransition, + Workflow, +} from "@jean-michelet/workflow"; + +class Task { + status = "new"; +} -const workflow = new Workflow(); +const workflow = new Workflow({ + stateProperty: "status", +}); +workflow.addTransition("start", new Transition("new", "in progress")); +workflow.addTransition("complete", new Transition("in progress", "completed")); +workflow.addTransition("cancel", new Transition("in progress", "canceled")); workflow.addTransition( "archive", - new MultiOriginTransition(["aborted", "completed"], "archived") + new MultiOriginTransition(["canceled", "completed"], "archived") ); -console.log(workflow.can("archive", "aborted")); // true -console.log(workflow.can("archive", "completed")); // true -``` - -## ClassWorkflow class +const task = new Task(); -The `ClassWorkflow` allows you to check and apply transitions directly to an entity’s state property. It is particularly useful when working with classes and TypeScript. - -### Example - -Suppose you have a `Post` class with a `status` property that tracks the state of the post: - -```ts -import { ClassWorkflow, Transition } from "@jean-michelet/workflow"; - -class Post { - status = "draft"; +if (workflow.can("start", task)) { + workflow.apply("start", task); } +console.log(task.status); // Output: "in progress" -const wf = new ClassWorkflow({ - entity: Post, - stateProperty: "status", -}); - -wf.addTransition("publish", new Transition("draft", "published")); - -const post = new Post(); - -if (wf.can("publish", post)) { - wf.apply("publish", post); +// Transition from 'in progress' to 'canceled' +if (workflow.can("cancel", task)) { + workflow.apply("cancel", task); } +console.log(task.status); // Output: "canceled" -console.log(post.status); // Output: "published" +if (workflow.can("archive", task)) { + workflow.apply("archive", task); +} +console.log(task.status); // Output: "archived" ``` -In this example, the `ClassWorkflow` manages the state transitions of the `Post` instance. The `apply` method automatically updates the entity's `status` property based on the defined transitions. If the transition isn't allowed, an error is thrown. - -### Handling unexpected states +### Handling Unexpected States -Both `Workflow` and `ClassWorkflow` support a `detectUnexpectedState` option. When enabled, this option throws an error if an entity is in an unexpected state that hasn't been accounted for in the transitions. +The `Workflow` class supports a `detectUnexpectedState` option. When enabled, this option throws an error if an entity is in an unexpected state that hasn't been accounted for in the defined transitions: ```ts -import { ClassWorkflow, Transition } from "@jean-michelet/workflow"; +// example in typescript but works with vanilla JS as well +import { Workflow, Transition } from "@jean-michelet/workflow"; -class Post { - status = "draft"; -} - -const wf = new ClassWorkflow({ - entity: Post, +const workflow = new Workflow({ stateProperty: "status", detectUnexpectedState: true, }); -wf.addTransition("publish", new Transition("draft", "published")); +workflow.addTransition("start", new Transition("new", "in progress")); -const post = new Post(); -post.status = "unknown"; +const task = new Task(); + +class Task { + status = "unknown"; +} -wf.can("publish", post); // throw an error "The instance has an unexpected state 'unknown'" +workflow.can("start", task); // Error: "The instance has an unexpected state 'unknown'" ``` diff --git a/package.json b/package.json index c03b5d5..9af9a32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jean-michelet/workflow", - "version": "1.0.5", + "version": "1.0.6", "description": "Manage the life cycle of an entity.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/index.ts b/src/index.ts index 2453ec1..f388957 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,10 @@ +import { Workflow } from "./workflow.js" + export { Workflow, - BaseWorkflow, - ClassWorkflow, MultiOriginTransition, Transition, - type BaseWorkflowOptions, - type ClassWorkflowOptions, + type WorkflowOptions, type ITransition, type State, -} from "./workflow.js" \ No newline at end of file +} from "./workflow.js" diff --git a/src/workflow.spec.ts b/src/workflow.spec.ts index 3f108fd..7721729 100644 --- a/src/workflow.spec.ts +++ b/src/workflow.spec.ts @@ -1,195 +1,127 @@ import { describe, it } from "node:test"; import assert from "node:assert"; import { - ClassWorkflow, MultiOriginTransition, Transition, Workflow, } from "./workflow.js"; -describe("BaseWorkflow - Common to all workflows", () => { - it("should throw an error when invalid transition", () => { - const wf = new Workflow({ - detectUnexpectedState: true, - }); - - assert.throws( - () => wf.can("invalid_transition", "draft"), - (error: Error) => { - assert.strictEqual( - error.message, - "Transition 'invalid_transition' not found." - ); - return true; - } - ); - }); -}); - describe("Workflow", () => { - it("should transition as expected", () => { - const wf = new Workflow(); - - wf.addTransition("publish", new Transition("draft", "published")); - wf.addTransition("abort", new Transition("published", "aborted")); - wf.addTransition("complete", new Transition("published", "completed")); - wf.addTransition( - "archive", - new MultiOriginTransition(["aborted", "completed"], "archived") - ); - - assert.ok(wf.can("publish", "draft")); - assert.ok(!wf.can("abort", "draft")); - assert.ok(!wf.can("complete", "draft")); - assert.ok(!wf.can("archive", "draft")); - - assert.ok(!wf.can("publish", "published")); - assert.ok(wf.can("abort", "published")); - assert.ok(wf.can("complete", "published")); - assert.ok(!wf.can("archive", "published")); - - assert.ok(!wf.can("publish", "aborted")); - assert.ok(!wf.can("abort", "aborted")); - assert.ok(!wf.can("complete", "aborted")); - assert.ok(wf.can("archive", "aborted")); - - assert.ok(!wf.can("publish", "completed")); - assert.ok(!wf.can("abort", "completed")); - assert.ok(!wf.can("complete", "completed")); - assert.ok(wf.can("archive", "completed")); - - assert.ok(!wf.can("publish", "invalid")); - }); - - it("should detect unexpected state if 'detectUnexpectedState = true'", () => { - const wf = new Workflow({ - detectUnexpectedState: true, - }); - - wf.addTransition("publish", new Transition("draft", "published")); - - assert.throws( - () => wf.can("publish", "invalid"), - (error: Error) => { - assert.strictEqual( - error.message, - "The instance has an unexpected state 'invalid'" - ); - return true; - } - ); - }); -}); - -describe("ClassWorkflow", () => { - enum WorkflowStateEnum { - Draft = 0, - Published = 1, - Aborted = 2, - Completed = 3, - Archived = 4, - Unused = 5, // to trigger unexpected state error + enum TaskState { + New = "new", + InProgress = "in progress", + Completed = "completed", + Canceled = "canceled", + Archived = "archived", + Unused = "unused", // to trigger unexpected state error } - class Post { - status: WorkflowStateEnum = WorkflowStateEnum.Draft; + class Task { + status: TaskState = TaskState.New; } it("should transition as expected", () => { - const wf = new ClassWorkflow({ - entity: Post, + const wf = new Workflow({ stateProperty: "status", }); wf.addTransition( - "publish", - new Transition(WorkflowStateEnum.Draft, WorkflowStateEnum.Published) + "start", + new Transition(TaskState.New, TaskState.InProgress) ); wf.addTransition( - "abort", - new Transition(WorkflowStateEnum.Published, WorkflowStateEnum.Aborted) + "complete", + new Transition(TaskState.InProgress, TaskState.Completed) ); wf.addTransition( - "complete", - new Transition(WorkflowStateEnum.Published, WorkflowStateEnum.Completed) + "cancel", + new Transition(TaskState.InProgress, TaskState.Canceled) ); wf.addTransition( "archive", new MultiOriginTransition( - [WorkflowStateEnum.Aborted, WorkflowStateEnum.Completed], - WorkflowStateEnum.Archived + [TaskState.Canceled, TaskState.Completed], + TaskState.Archived ) ); - const post = new Post(); - assert.ok(wf.can("publish", post)); - assert.ok(!wf.can("abort", post)); - assert.ok(!wf.can("complete", post)); - assert.ok(!wf.can("archive", post)); + const task = new Task(); + assert.ok(wf.can("start", task)); + assert.ok(!wf.can("complete", task)); + assert.ok(!wf.can("cancel", task)); + assert.ok(!wf.can("archive", task)); + + wf.apply("start", task); + assert.ok(!wf.can("start", task)); + assert.ok(wf.can("complete", task)); + assert.ok(wf.can("cancel", task)); + assert.ok(!wf.can("archive", task)); + + // test multiple origin + task.status = TaskState.Canceled + assert.ok(wf.can("archive", task)); - wf.apply("publish", post); - assert.ok(!wf.can("publish", post)); - assert.ok(wf.can("abort", post)); - assert.ok(wf.can("complete", post)); - assert.ok(!wf.can("archive", post)); + task.status = TaskState.Completed + assert.ok(wf.can("archive", task)); - wf.apply("abort", post); - assert.ok(wf.can("archive", post)); + wf.apply("archive", task) assert.throws( - () => wf.apply("complete", post), + () => wf.apply("start", task), (error: Error) => { assert.strictEqual( error.message, - "Can't apply transition 'complete' to current state '2'" + "Can't apply transition 'start' to current state 'archived'" ); return true; } ); }); - it("should throw an error if the stateProperty does not exist on the entity", () => { + it("should detect unexpected state if 'detectUnexpectedState = true'", () => { + const wf = new Workflow({ + stateProperty: "status", + detectUnexpectedState: true, + }); + + wf.addTransition( + "start", + new Transition(TaskState.New, TaskState.InProgress) + ); + + const task = new Task(); + task.status = TaskState.Unused; + assert.throws( - () => - new ClassWorkflow({ - entity: Post, - // @ts-expect-error - This is a deliberate invalid property for the test - stateProperty: "nonExistentProperty", - }), + () => wf.can("start", task), (error: Error) => { assert.strictEqual( error.message, - "Property 'nonExistentProperty' does not exist in the provided entity." + "The instance has an unexpected state 'unused'" ); return true; } ); }); - it("should detect unexpected state if 'detectUnexpectedState = true'", () => { - const wf = new ClassWorkflow({ - entity: Post, + it("should throw an error when invalid transition", () => { + const wf = new Workflow({ stateProperty: "status", detectUnexpectedState: true, }); - wf.addTransition( - "publish", - new Transition(WorkflowStateEnum.Draft, WorkflowStateEnum.Published) - ); - - const post = new Post(); - post.status = WorkflowStateEnum.Unused; + const task = new Task(); assert.throws( - () => wf.can("publish", post), + () => wf.can("invalid_transition", task), (error: Error) => { assert.strictEqual( error.message, - "The instance has an unexpected state '5'" // `5` corresponds to `WorkflowStateEnum.Unused` + "Transition 'invalid_transition' not found." ); return true; } ); }); }); + diff --git a/src/workflow.ts b/src/workflow.ts index 13f5479..24af05b 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -38,64 +38,7 @@ export class MultiOriginTransition implements ITransition { } } -export interface BaseWorkflowOptions { - detectUnexpectedState?: boolean; -} - -export abstract class BaseWorkflow { - public transitions: Map = new Map(); - protected detectUnexpectedState: boolean; - protected states = new Set(); - - constructor(options: BaseWorkflowOptions = {}) { - this.detectUnexpectedState = options.detectUnexpectedState ?? false; - } - - addTransition(name: string, transition: ITransition): void { - if (!this.transitions.has(name)) { - this.transitions.set(name, transition); - } - } - - getTransition(name: string) { - const transition = this.transitions.get(name); - if (!transition) { - throw new Error(`Transition '${name}' not found.`); - } - - return transition; - } - - protected doDetectUnexpectedState(currentState: State) { - if (this.detectUnexpectedState && !this.states.has(currentState)) { - throw new Error( - `The instance has an unexpected state '${currentState.toString()}'` - ); - } - } -} - -export class Workflow extends BaseWorkflow { - /** - * Try to perform the transition - * If success, return the next state - * If fails, return null - */ - can(transitionName: string, currentState: State) { - const transition = this.getTransition(transitionName); - - const state = transition.tryTransition(currentState); - if (state) return true; - - this.doDetectUnexpectedState(currentState); - - return false; - } -} - -type Constructor = new (...args: unknown[]) => T; -export interface ClassWorkflowOptions extends BaseWorkflowOptions { - entity: Constructor; +export interface WorkflowOptions { stateProperty: Extract< { [K in keyof T]: T[K] extends string | number ? K : never; @@ -105,19 +48,15 @@ export interface ClassWorkflowOptions extends BaseWorkflowOptions { detectUnexpectedState?: boolean; } -export class ClassWorkflow extends BaseWorkflow { - private entity: Constructor; +export class Workflow { + public transitions: Map = new Map(); + protected detectUnexpectedState: boolean; + protected states = new Set(); private stateProperty: keyof T; - constructor(options: ClassWorkflowOptions) { - super({ - detectUnexpectedState: options.detectUnexpectedState ?? false, - }); - - this.entity = options.entity; + constructor(options: WorkflowOptions) { + this.detectUnexpectedState = options.detectUnexpectedState ?? false; this.stateProperty = options.stateProperty; - - this.validateProperty(); } can(transitionName: string, instance: T) { @@ -154,13 +93,25 @@ export class ClassWorkflow extends BaseWorkflow { instance[this.stateProperty] = nextState as T[keyof T]; } - private validateProperty() { - const entity = new this.entity(); - if (typeof entity[this.stateProperty] === "undefined") { + addTransition(name: string, transition: ITransition): void { + if (!this.transitions.has(name)) { + this.transitions.set(name, transition); + } + } + + getTransition(name: string) { + const transition = this.transitions.get(name); + if (!transition) { + throw new Error(`Transition '${name}' not found.`); + } + + return transition; + } + + private doDetectUnexpectedState(currentState: State) { + if (this.detectUnexpectedState && !this.states.has(currentState)) { throw new Error( - `Property '${String( - this.stateProperty - )}' does not exist in the provided entity.` + `The instance has an unexpected state '${currentState.toString()}'` ); } }