mirror of https://github.com/Squidex/squidex.git
12 changed files with 603 additions and 2 deletions
@ -0,0 +1,63 @@ |
|||||
|
<div class="step"> |
||||
|
<div class="row no-gutters"> |
||||
|
<div class="col-auto color pr-2"> |
||||
|
<sqx-color-picker mode="Circle" |
||||
|
[ngModelOptions]="onBlur" |
||||
|
[ngModel]="step.color" |
||||
|
(ngModelChange)="changeColor($event)" |
||||
|
[disabled]="step.isLocked"> |
||||
|
</sqx-color-picker> |
||||
|
</div> |
||||
|
<div class="col"> |
||||
|
<sqx-editable-title |
||||
|
[name]="step.name" |
||||
|
(nameChanged)="changeName($event)" |
||||
|
[disabled]="step.isLocked"> |
||||
|
</sqx-editable-title> |
||||
|
</div> |
||||
|
<div class="col-auto"> |
||||
|
<button type="button" class="btn btn-text-danger" (click)="remove.emit()" *ngIf="!step.isLocked"> |
||||
|
<i class="icon-bin2"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="row transition no-gutters" *ngFor="let transition of transitions"> |
||||
|
<div class="col-auto"> |
||||
|
<i class="icon-arrow-right text-decent"></i> |
||||
|
</div> |
||||
|
<div class="col-3 pl-2"> |
||||
|
<div class="transition-to"> |
||||
|
<div class="color-circle" [style.background]="transition.step.color"></div> {{transition.to}} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col pl-2"> |
||||
|
<button class="btn btn-block btn-outline-secondary dashed"> |
||||
|
Add Expression |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="col-auto pl-2"> |
||||
|
<button type="button" class="btn btn-text-danger" (click)="transitionRemove.emit(transition)"> |
||||
|
<i class="icon-bin2"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="row transition no-gutters" *ngIf="openSteps.length > 0"> |
||||
|
<div class="col-auto"> |
||||
|
<i class="icon-arrow-right text-decent"></i> |
||||
|
</div> |
||||
|
<div class="col-3 pl-2"> |
||||
|
<sqx-dropdown [items]="openSteps" [(ngModel)]="openStep"> |
||||
|
<ng-template let-target="$implicit"> |
||||
|
<div class="color-circle" [style.background]="target.color"></div> {{target.name}} |
||||
|
</ng-template> |
||||
|
</sqx-dropdown> |
||||
|
</div> |
||||
|
<div class="col pl-2"> |
||||
|
<button class="btn btn-outline-secondary" (click)="transitionAdd.emit(openStep)"> |
||||
|
<i class="icon-plus"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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<WorkflowStep>(); |
||||
|
|
||||
|
@Output() |
||||
|
public transitionRemove = new EventEmitter<WorkflowTransition>(); |
||||
|
|
||||
|
@Output() |
||||
|
public update = new EventEmitter<WorkflowStepValues>(); |
||||
|
|
||||
|
@Output() |
||||
|
public rename = new EventEmitter<string>(); |
||||
|
|
||||
|
@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 }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,37 @@ |
|||||
|
<sqx-panel desiredWidth="50rem" isBlank="true" [isLazyLoaded]="false"> |
||||
|
<ng-container title> |
||||
|
Workflows |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container menu> |
||||
|
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="Refresh roles (CTRL + SHIFT + R)"> |
||||
|
<i class="icon-reset"></i> Refresh |
||||
|
</button> |
||||
|
|
||||
|
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut> |
||||
|
|
||||
|
<ng-container> |
||||
|
<button type="button" class="btn btn-primary" (click)="save()" title="Save (CTRL + S)"> |
||||
|
Save |
||||
|
</button> |
||||
|
|
||||
|
<sqx-shortcut keys="ctrl+s" (trigger)="save()"></sqx-shortcut> |
||||
|
</ng-container> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container content> |
||||
|
<sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep" |
||||
|
[workflow]="workflow" |
||||
|
[step]="step" |
||||
|
(rename)="renameStep(step, $event)" |
||||
|
(remove)="removeStep(step)" |
||||
|
(transitionAdd)="addTransiton(step, $event)" |
||||
|
(transitionRemove)="removeTransition(step, $event)" |
||||
|
(update)="updateStep(step, $event)"> |
||||
|
</sqx-workflow-step> |
||||
|
|
||||
|
<button class="btn btn-success" (click)="addStep()"> |
||||
|
Add Step |
||||
|
</button> |
||||
|
</ng-container> |
||||
|
</sqx-panel> |
||||
@ -0,0 +1,2 @@ |
|||||
|
@import '_vars'; |
||||
|
@import '_mixins'; |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -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)); |
||||
|
} |
||||
@ -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<WorkflowDto> { |
||||
|
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<WorkflowStepValues>) { |
||||
|
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<WorkflowTransitionValues>) { |
||||
|
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; |
||||
Loading…
Reference in new issue