diff --git a/src/Squidex/app/features/settings/declarations.ts b/src/Squidex/app/features/settings/declarations.ts
index fdb82e6b5..17e095257 100644
--- a/src/Squidex/app/features/settings/declarations.ts
+++ b/src/Squidex/app/features/settings/declarations.ts
@@ -18,5 +18,7 @@ export * from './pages/patterns/patterns-page.component';
export * from './pages/plans/plans-page.component';
export * from './pages/roles/role.component';
export * from './pages/roles/roles-page.component';
+export * from './pages/workflows/workflow-step.component';
+export * from './pages/workflows/workflows-page.component';
export * from './settings-area.component';
\ No newline at end of file
diff --git a/src/Squidex/app/features/settings/module.ts b/src/Squidex/app/features/settings/module.ts
index 633249037..29802f591 100644
--- a/src/Squidex/app/features/settings/module.ts
+++ b/src/Squidex/app/features/settings/module.ts
@@ -30,7 +30,9 @@ import {
PlansPageComponent,
RoleComponent,
RolesPageComponent,
- SettingsAreaComponent
+ SettingsAreaComponent,
+ WorkflowsPageComponent,
+ WorkflowStepComponent
} from './declarations';
const routes: Routes = [
@@ -170,6 +172,10 @@ const routes: Routes = [
}
}
]
+ },
+ {
+ path: 'workflows',
+ component: WorkflowsPageComponent
}
]
}
@@ -196,7 +202,9 @@ const routes: Routes = [
PlansPageComponent,
RoleComponent,
RolesPageComponent,
- SettingsAreaComponent
+ SettingsAreaComponent,
+ WorkflowsPageComponent,
+ WorkflowStepComponent
]
})
export class SqxFeatureSettingsModule { }
\ No newline at end of file
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html
new file mode 100644
index 000000000..70f05540d
--- /dev/null
+++ b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0">
+
+
+
+
+
+
+ {{target.name}}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.scss b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.scss
new file mode 100644
index 000000000..a51dd500f
--- /dev/null
+++ b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.scss
@@ -0,0 +1,39 @@
+@import '_vars';
+@import '_mixins';
+
+.color {
+ line-height: 2.8rem;
+}
+
+.color-circle {
+ @include circle(12px);
+ border: 1px solid $color-border-dark;
+ background: $color-border;
+ display: inline-block;
+}
+
+.dashed {
+ border-style: dashed;
+}
+
+.transition {
+ & {
+ padding-left: 1rem;
+ margin-top: .25rem;
+ margin-bottom: .5rem;
+ line-height: 2rem;
+ }
+
+ &-to {
+ padding: .5rem .75rem;
+ background: transparent;
+ border: 1px solid transparent;
+ line-height: 1.2rem;
+ }
+}
+
+.step {
+ & {
+ margin-bottom: 1rem;
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.ts b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.ts
new file mode 100644
index 000000000..afbb45169
--- /dev/null
+++ b/src/Squidex/app/features/settings/pages/workflows/workflow-step.component.ts
@@ -0,0 +1,69 @@
+/*
+ * Squidex Headless CMS
+ *
+ * @license
+ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
+ */
+
+import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
+
+import {
+ WorkflowDto,
+ WorkflowStep,
+ WorkflowStepValues,
+ WorkflowTransition,
+ WorkflowTransitionView
+} from '@app/shared';
+
+@Component({
+ selector: 'sqx-workflow-step',
+ styleUrls: ['./workflow-step.component.scss'],
+ templateUrl: './workflow-step.component.html'
+})
+export class WorkflowStepComponent implements OnChanges {
+ @Input()
+ public workflow: WorkflowDto;
+
+ @Input()
+ public step: WorkflowStep;
+
+ @Output()
+ public transitionAdd = new EventEmitter();
+
+ @Output()
+ public transitionRemove = new EventEmitter();
+
+ @Output()
+ public update = new EventEmitter();
+
+ @Output()
+ public rename = new EventEmitter();
+
+ @Output()
+ public remove = new EventEmitter();
+
+ public onBlur = { updateOn: 'blur' };
+
+ public openSteps: WorkflowStep[];
+ public openStep: WorkflowStep;
+
+ public transitions: WorkflowTransitionView[];
+
+ public ngOnChanges(changes: SimpleChanges) {
+ if (changes['workflow'] || changes['step'] || false) {
+ this.openSteps = this.workflow.getOpenSteps(this.step);
+ this.openStep = this.openSteps[0];
+
+ this.transitions = this.workflow.getTransitions(this.step);
+ }
+ }
+
+ public changeName(name: string) {
+ this.rename.emit(name);
+ }
+
+ public changeColor(color: string) {
+ this.update.emit({ color });
+ }
+}
+
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html
new file mode 100644
index 000000000..c5782052b
--- /dev/null
+++ b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html
@@ -0,0 +1,37 @@
+
+
+ Workflows
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss
new file mode 100644
index 000000000..fbb752506
--- /dev/null
+++ b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss
@@ -0,0 +1,2 @@
+@import '_vars';
+@import '_mixins';
\ No newline at end of file
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts
new file mode 100644
index 000000000..272a2852b
--- /dev/null
+++ b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts
@@ -0,0 +1,65 @@
+/*
+ * Squidex Headless CMS
+ *
+ * @license
+ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
+ */
+
+import { Component, OnInit } from '@angular/core';
+
+import {
+ WorkflowDto,
+ WorkflowStep,
+ WorkflowStepValues,
+ WorkflowTransition
+} from '@app/shared';
+
+@Component({
+ selector: 'sqx-workflows-page',
+ styleUrls: ['./workflows-page.component.scss'],
+ templateUrl: './workflows-page.component.html'
+})
+export class WorkflowsPageComponent implements OnInit {
+ public workflow: WorkflowDto;
+
+ public ngOnInit() {
+ this.workflow = new WorkflowDto().setStep('Published', { color: 'green', isLocked: true });
+ }
+
+ public reload() {
+ return;
+ }
+
+ public save() {
+ return;
+ }
+
+ public addStep() {
+ this.workflow = this.workflow.setStep(`Step${this.workflow.steps.length + 1}`, {});
+ }
+
+ public addTransiton(from: WorkflowStep, to: WorkflowStep) {
+ this.workflow = this.workflow.setTransition(from.name, to.name, {});
+ }
+
+ public removeTransition(from: WorkflowStep, transition: WorkflowTransition) {
+ this.workflow = this.workflow.removeTransition(from.name, transition.to);
+ }
+
+ public updateStep(step: WorkflowStep, values: WorkflowStepValues) {
+ this.workflow = this.workflow.setStep(step.name, values);
+ }
+
+ public renameStep(step: WorkflowStep, newName: string) {
+ this.workflow = this.workflow.renameStep(step.name, newName);
+ }
+
+ public removeStep(step: WorkflowStep) {
+ this.workflow = this.workflow.removeStep(step.name);
+ }
+
+ public trackByStep(index: number, step: WorkflowStep) {
+ return step.name;
+ }
+}
+
diff --git a/src/Squidex/app/features/settings/settings-area.component.html b/src/Squidex/app/features/settings/settings-area.component.html
index 5669f4b3c..cee2481d3 100644
--- a/src/Squidex/app/features/settings/settings-area.component.html
+++ b/src/Squidex/app/features/settings/settings-area.component.html
@@ -43,6 +43,12 @@
+
+
+ Workflows
+
+
+
Subscription
diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts
index f467a7636..9050ba58a 100644
--- a/src/Squidex/app/shared/internal.ts
+++ b/src/Squidex/app/shared/internal.ts
@@ -32,6 +32,7 @@ export * from './services/ui.service';
export * from './services/usages.service';
export * from './services/users-provider.service';
export * from './services/users.service';
+export * from './services/workflows.service';
export * from './state/apps.forms';
export * from './state/apps.state';
diff --git a/src/Squidex/app/shared/services/workflows.service.spec.ts b/src/Squidex/app/shared/services/workflows.service.spec.ts
new file mode 100644
index 000000000..53089ad6c
--- /dev/null
+++ b/src/Squidex/app/shared/services/workflows.service.spec.ts
@@ -0,0 +1,188 @@
+/*
+ * Squidex Headless CMS
+ *
+ * @license
+ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
+ */
+
+import { WorkflowDto } from '@app/shared/internal';
+
+describe('Workflow', () => {
+ it('should create empty workflow', () => {
+ const workflow = new WorkflowDto();
+
+ expect(workflow.name).toEqual('Default');
+ });
+
+ it('should add step to workflow', () => {
+ const workflow =
+ new WorkflowDto()
+ .setStep('1', { color: '#00ff00' });
+
+ expect(simplify(workflow)).toEqual({
+ _links: {},
+ steps: [
+ { name: '1', color: '#00ff00' }
+ ],
+ transitions: [],
+ name: 'Default'
+ });
+ });
+
+ it('should override settings if step already exists', () => {
+ const workflow =
+ new WorkflowDto()
+ .setStep('1', { color: '#00ff00' })
+ .setStep('1', { color: 'red' });
+
+ expect(simplify(workflow)).toEqual({
+ _links: {},
+ steps: [
+ { name: '1', color: 'red' }
+ ],
+ transitions: [],
+ name: 'Default'
+ });
+ });
+
+ it('should not remove step if locked', () => {
+ const workflow =
+ new WorkflowDto()
+ .setStep('1', { color: '#00ff00', isLocked: true })
+ .setStep('2', { color: '#ff0000' })
+ .setTransition('1', '2', { expression: '1 === 2' })
+ .removeStep('1');
+
+ expect(simplify(workflow)).toEqual({
+ _links: {},
+ steps: [
+ { name: '1', color: '#00ff00', isLocked: true },
+ { name: '2', color: '#ff0000' }
+ ],
+ transitions: [
+ { from: '1', to: '2', expression: '1 === 2' }
+ ],
+ name: 'Default'
+ });
+ });
+
+ it('should remove step', () => {
+ const workflow =
+ new WorkflowDto()
+ .setStep('1', { color: '#00ff00' })
+ .setStep('2', { color: '#ff0000' })
+ .setStep('3', { color: '#0000ff' })
+ .setTransition('1', '2', { expression: '1 === 2' })
+ .setTransition('1', '3', { expression: '1 === 3' })
+ .setTransition('2', '3', { expression: '2 === 3' })
+ .removeStep('1');
+
+ expect(simplify(workflow)).toEqual({
+ _links: {},
+ steps: [
+ { name: '2', color: '#ff0000' },
+ { name: '3', color: '#0000ff' }
+ ],
+ transitions: [
+ { from: '2', to: '3', expression: '2 === 3' }
+ ],
+ name: 'Default'
+ });
+ });
+
+ it('should rename step', () => {
+ const workflow =
+ new WorkflowDto()
+ .setStep('1', { color: '#00ff00' })
+ .setStep('2', { color: '#ff0000' })
+ .setStep('3', { color: '#0000ff' })
+ .setTransition('1', '2', { expression: '1 === 2' })
+ .setTransition('2', '1', { expression: '2 === 1' })
+ .setTransition('2', '3', { expression: '2 === 3' })
+ .renameStep('1', '4');
+
+ expect(simplify(workflow)).toEqual({
+ _links: {},
+ steps: [
+ { name: '4', color: '#00ff00' },
+ { name: '2', color: '#ff0000' },
+ { name: '3', color: '#0000ff' }
+ ],
+ transitions: [
+ { from: '4', to: '2', expression: '1 === 2' },
+ { from: '2', to: '4', expression: '2 === 1' },
+ { from: '2', to: '3', expression: '2 === 3' }
+ ],
+ name: 'Default'
+ });
+ });
+
+ it('should add transitions to workflow', () => {
+ const workflow =
+ new WorkflowDto()
+ .setStep('1', { color: '#00ff00' })
+ .setStep('2', { color: '#ff0000' })
+ .setTransition('1', '2', { expression: '1 === 2' })
+ .setTransition('2', '1', { expression: '2 === 1' });
+
+ expect(simplify(workflow)).toEqual({
+ _links: {},
+ steps: [
+ { name: '1', color: '#00ff00' },
+ { name: '2', color: '#ff0000' }
+ ],
+ transitions: [
+ { from: '1', to: '2', expression: '1 === 2' },
+ { from: '2', to: '1', expression: '2 === 1' }
+ ],
+ name: 'Default'
+ });
+ });
+
+ it('should add remove transition from workflow', () => {
+ const workflow =
+ new WorkflowDto()
+ .setStep('1', { color: '#00ff00' })
+ .setStep('2', { color: '#ff0000' })
+ .setTransition('1', '2', { expression: '1 === 1' })
+ .setTransition('2', '1', { expression: '2 === 1' })
+ .removeTransition('1', '2');
+
+ expect(simplify(workflow)).toEqual({
+ _links: {},
+ steps: [
+ { name: '1', color: '#00ff00' },
+ { name: '2', color: '#ff0000' }
+ ],
+ transitions: [
+ { from: '2', to: '1', expression: '2 === 1' }
+ ],
+ name: 'Default'
+ });
+ });
+
+ it('should override settings if transition already exists', () => {
+ const workflow =
+ new WorkflowDto()
+ .setStep('1', { color: '#00ff00' })
+ .setStep('2', { color: '#ff0000' })
+ .setTransition('1', '2', { expression: '1 === 2' })
+ .setTransition('1', '2', { expression: '1 !== 2' });
+
+ expect(simplify(workflow)).toEqual({
+ _links: {},
+ steps: [
+ { name: '1', color: '#00ff00' },
+ { name: '2', color: '#ff0000' }
+ ],
+ transitions: [
+ { from: '1', to: '2', expression: '1 !== 2' }
+ ],
+ name: 'Default'
+ });
+ });
+});
+
+function simplify(value: any) {
+ return JSON.parse(JSON.stringify(value));
+}
\ No newline at end of file
diff --git a/src/Squidex/app/shared/services/workflows.service.ts b/src/Squidex/app/shared/services/workflows.service.ts
new file mode 100644
index 000000000..f5a98ba83
--- /dev/null
+++ b/src/Squidex/app/shared/services/workflows.service.ts
@@ -0,0 +1,121 @@
+/*
+ * Squidex Headless CMS
+ *
+ * @license
+ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
+ */
+
+import {
+ compareStringsAsc,
+ Model,
+ ResourceLinks
+} from '@app/framework';
+
+export class WorkflowDto extends Model {
+ public readonly _links: ResourceLinks;
+
+ constructor(links: ResourceLinks = {},
+ public readonly name: string = 'Default',
+ public readonly steps: WorkflowStep[] = [],
+ public readonly transitions: WorkflowTransition[] = []
+ ) {
+ super();
+
+ this._links = links;
+ }
+
+ public onCloned() {
+ this.steps.sort((a, b) => compareStringsAsc(a.name, b.name));
+ }
+
+ public getOpenSteps(step: WorkflowStep) {
+ return this.steps.filter(x => x.name !== step.name && !this.transitions.find(y => y.from === step.name && y.to === x.name));
+ }
+
+ public getTransitions(step: WorkflowStep): WorkflowTransitionView[] {
+ return this.transitions.filter(x => x.from === step.name).map(x => ({ step: this.getStep(x.to), ...x }));
+ }
+
+ public getStep(name: string): WorkflowStep {
+ return this.steps.find(x => x.name === name)!;
+ }
+
+ public setStep(name: string, values: Partial) {
+ const steps = [...this.steps.filter(s => s.name !== name), { name, ...values }];
+
+ return this.with({ steps });
+ }
+
+ public removeStep(name: string) {
+ const steps = this.steps.filter(s => s.name !== name || s.isLocked);
+
+ const transitions =
+ steps.length !== this.steps.length ?
+ this.transitions.filter(t => t.from !== name && t.to !== name) :
+ this.transitions;
+
+ return this.with({ steps, transitions });
+ }
+
+ public renameStep(name: string, newName: string) {
+ const steps = this.steps.map(step => {
+ if (step.name === name) {
+ return { ...step, name: newName };
+ }
+
+ return step;
+ });
+
+ const transitions = this.transitions.map(transition => {
+ if (transition.from === name || transition.to === name) {
+ let newTransition = { ...transition };
+
+ if (newTransition.from === name) {
+ newTransition.from = newName;
+ }
+
+ if (newTransition.to === name) {
+ newTransition.to = newName;
+ }
+
+ return newTransition;
+ }
+
+ return transition;
+ });
+
+ return this.with({ steps, transitions });
+ }
+
+ public removeTransition(from: string, to: string) {
+ const transitions = this.transitions.filter(t => t.from !== from || t.to !== to);
+
+ return this.with({ transitions });
+ }
+
+ public setTransition(from: string, to: string, values: Partial) {
+ const stepFrom = this.steps.find(s => s.name === from);
+
+ if (!stepFrom) {
+ return this;
+ }
+
+ const stepTo = this.steps.find(s => s.name === to);
+
+ if (!stepTo) {
+ return this;
+ }
+
+ const transitions = [...this.transitions.filter(t => t.from !== from || t.to !== to), { from, to, ...values }];
+
+ return this.with({ transitions });
+ }
+}
+
+export type WorkflowStepValues = { color?: string; isLocked?: boolean; };
+export type WorkflowStep = { name: string } & WorkflowStepValues;
+
+export type WorkflowTransitionValues = { expression?: string };
+export type WorkflowTransition = { from: string; to: string } & WorkflowTransitionValues;
+
+export type WorkflowTransitionView = { step: WorkflowStep } & WorkflowTransition;
\ No newline at end of file