Browse Source

Basic stuff.

pull/382/head
Sebastian Stehle 7 years ago
parent
commit
d848393770
  1. 2
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  2. 7
      src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs
  3. 8
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs
  4. 1
      src/Squidex/app/features/settings/declarations.ts
  5. 2
      src/Squidex/app/features/settings/module.ts
  6. 4
      src/Squidex/app/features/settings/pages/clients/clients-page.component.ts
  7. 70
      src/Squidex/app/features/settings/pages/workflows/workflow.component.html
  8. 25
      src/Squidex/app/features/settings/pages/workflows/workflow.component.scss
  9. 120
      src/Squidex/app/features/settings/pages/workflows/workflow.component.ts
  10. 56
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html
  11. 78
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts
  12. 2
      src/Squidex/app/features/settings/settings-area.component.html
  13. 1
      src/Squidex/app/shared/internal.ts
  14. 117
      src/Squidex/app/shared/services/workflows.service.spec.ts
  15. 51
      src/Squidex/app/shared/services/workflows.service.ts
  16. 2
      src/Squidex/app/shared/state/clients.forms.ts
  17. 24
      src/Squidex/app/shared/state/workflows.forms.ts
  18. 52
      src/Squidex/app/shared/state/workflows.state.spec.ts
  19. 56
      src/Squidex/app/shared/state/workflows.state.ts

2
src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs

@ -11,7 +11,7 @@ namespace Squidex.Domain.Apps.Core.Contents
{
public sealed class Workflow : Named
{
private const string DefaultName = "Name";
private const string DefaultName = "Unnamed";
private static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>();
public static readonly Workflow Default = CreateDefault();

7
src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs

@ -15,6 +15,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class UpdateWorkflowDto
{
/// <summary>
/// The name of the workflow.
/// </summary>
public string Name { get; set; }
/// <summary>
/// The workflow steps.
/// </summary>
@ -37,7 +42,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
y => new WorkflowTransition(y.Value.Expression, y.Value.Role)),
x.Value.Color,
x.Value.NoUpdate)),
Initial);
Initial, Name);
return new UpdateWorkflow { Workflow = workflow };
}

8
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs

@ -23,6 +23,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The name of the workflow.
/// </summary>
public string Name { get; set; }
/// <summary>
/// The workflow steps.
/// </summary>
@ -38,7 +43,6 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
{
var result = new WorkflowDto
{
Id = id,
Steps = workflow.Steps.ToDictionary(
x => x.Key,
x => SimpleMapper.Map(x.Value, new WorkflowStepDto
@ -47,7 +51,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
y => y.Key,
y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role })
})),
Initial = workflow.Initial
Id = id, Name = workflow.Name, Initial = workflow.Initial
};
return result.CreateLinks(controller, app, id);

1
src/Squidex/app/features/settings/declarations.ts

@ -20,6 +20,7 @@ export * from './pages/roles/role.component';
export * from './pages/roles/roles-page.component';
export * from './pages/workflows/workflow-step.component';
export * from './pages/workflows/workflow-transition.component';
export * from './pages/workflows/workflow.component';
export * from './pages/workflows/workflows-page.component';
export * from './settings-area.component';

2
src/Squidex/app/features/settings/module.ts

@ -31,6 +31,7 @@ import {
RoleComponent,
RolesPageComponent,
SettingsAreaComponent,
WorkflowComponent,
WorkflowsPageComponent,
WorkflowStepComponent,
WorkflowTransitionComponent
@ -213,6 +214,7 @@ const routes: Routes = [
RoleComponent,
RolesPageComponent,
SettingsAreaComponent,
WorkflowComponent,
WorkflowsPageComponent,
WorkflowTransitionComponent,
WorkflowStepComponent

4
src/Squidex/app/features/settings/pages/clients/clients-page.component.ts

@ -9,8 +9,8 @@ import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import {
AddClientForm,
AppsState,
AttachClientForm,
ClientDto,
ClientsState,
RolesState
@ -22,7 +22,7 @@ import {
templateUrl: './clients-page.component.html'
})
export class ClientsPageComponent implements OnInit {
public addClientForm = new AttachClientForm(this.formBuilder);
public addClientForm = new AddClientForm(this.formBuilder);
constructor(
public readonly appsState: AppsState,

70
src/Squidex/app/features/settings/pages/workflows/workflow.component.html

@ -0,0 +1,70 @@
<div class="table-items-row table-items-row-expandable workflow">
<div class="table-items-row-summary">
<div class="row no-gutters">
<div class="col">
{{workflow.displayName}}
</div>
<div class="col-options">
<div class="float-right">
<button type="button" class="btn btn-secondary table-items-edit-button mr-1" [class.active]="isEditing" (click)="toggleEditing()">
<i class="icon-settings"></i>
</button>
<button type="button" class="btn btn-text-danger"
[disabled]="!workflow.canDelete"
(sqxConfirmClick)="remove()"
confirmTitle="Remove workflow"
confirmText="Do you really want to remove the workflow?">
<i class="icon-bin2"></i>
</button>
</div>
</div>
</div>
</div>
<div class="table-items-row-details" *ngIf="isEditing">
<div class="table-items-row-details-tabs clearfix">
<div class="float-right">
<button type="reset" class="btn btn-text" (click)="toggleEditing()">Cancel</button>
<button type="submit" class="btn btn-primary" *ngIf="isEditable" (click)="save()">Save</button>
</div>
</div>
<div class="table-items-row-details-tab">
<sqx-form-error [error]="error"></sqx-form-error>
<div class="form-group row">
<label class="col-form-label" for="{{workflow.id}}_name">Name</label>
<div class="col-6">
<input class="form-control" id="{{workflow.id}}_name"
[ngModelOptions]="onBlur"
[ngModel]="workflow.name"
(ngModelChange)="rename($event)" />
<sqx-form-hint>
Optional name for the workflow.
</sqx-form-hint>
</div>
</div>
<sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep"
[workflow]="workflow"
[disabled]="!workflow.canUpdate"
[roles]="roles"
[step]="step"
(makeInitial)="setInitial(step)"
(rename)="renameStep(step, $event)"
(remove)="removeStep(step)"
(transitionAdd)="addTransiton(step, $event)"
(transitionRemove)="removeTransition(step, $event)"
(transitionUpdate)="updateTransition($event)"
(update)="updateStep(step, $event)">
</sqx-workflow-step>
<button class="btn btn-success" (click)="addStep()" *ngIf="workflow.canUpdate">
Add Step
</button>
</div>
</div>
</div>

25
src/Squidex/app/features/settings/pages/workflows/workflow.component.scss

@ -0,0 +1,25 @@
@import '_vars';
@import '_mixins';
.table-items-row-details {
&::before {
right: 4.55rem;
}
}
.col-form-label {
min-width: 4rem;
max-width: 4rem;
text-align: left;
}
.form-group {
margin-bottom: 2rem;
margin-left: 2rem;
max-width: 60rem;
}
.btn-success {
margin-bottom: 1rem;
margin-left: 2rem;
}

120
src/Squidex/app/features/settings/pages/workflows/workflow.component.ts

@ -0,0 +1,120 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, Input, OnChanges } from '@angular/core';
import {
ErrorDto,
MathHelper,
RoleDto,
WorkflowDto,
WorkflowsState,
WorkflowStep,
WorkflowStepValues,
WorkflowTransition,
WorkflowTransitionValues
} from '@app/shared';
@Component({
selector: 'sqx-workflow',
styleUrls: ['./workflow.component.scss'],
templateUrl: './workflow.component.html'
})
export class WorkflowComponent implements OnChanges {
@Input()
public workflow: WorkflowDto;
@Input()
public roles: RoleDto[];
public error: ErrorDto | null;
public onBlur = { updateOn: 'blur' };
public isEditing = false;
public isEditable = false;
constructor(
private readonly workflowsState: WorkflowsState
) {
}
public ngOnChanges() {
this.isEditable = this.workflow.canUpdate;
}
public toggleEditing() {
this.isEditing = !this.isEditing;
}
public remove() {
this.workflowsState.delete(this.workflow);
}
public save() {
if (!this.isEditable) {
return;
}
this.workflowsState.update(this.workflow)
.subscribe(() => {
this.error = null;
}, error => {
this.error = error;
});
}
public addStep() {
let index = this.workflow.steps.length;
for (let i = index; i < index + 100; i++) {
const name = `Step${i}`;
if (!this.workflow.getStep(name)) {
this.workflow = this.workflow.setStep(name, { color: MathHelper.randomColor() });
return;
}
}
}
public rename(name: string) {
this.workflow = this.workflow.rename(name);
}
public setInitial(step: WorkflowStep) {
this.workflow = this.workflow.setInitial(step.name);
}
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 updateTransition(update: { transition: WorkflowTransition, values: WorkflowTransitionValues }) {
this.workflow = this.workflow.setTransition(update.transition.from, update.transition.to, update.values);
}
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(step: WorkflowStep) {
return step.name;
}
}

56
src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html

@ -1,47 +1,47 @@
<sqx-title message="{app} | Workflows | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title>
<sqx-panel desiredWidth="50rem" isBlank="true" [showSidebar]="true" [isLazyLoaded]="false">
<sqx-panel desiredWidth="60rem" [showSidebar]="true" [isLazyLoaded]="false">
<ng-container title>
Workflow
</ng-container>
<ng-container menu>
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="Refresh roles (CTRL + SHIFT + R)">
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="Refresh workflows (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh
</button>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<ng-container *ngIf="workflow && workflow.canUpdate">
<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>
<ng-container *ngIf="workflow">
<ng-container *ngIf="workflowsState.workflows | async; let workflows">
<ng-container *ngIf="rolesState.roles | async; let roles">
<sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep"
[workflow]="workflow"
[disabled]="!workflow.canUpdate"
[roles]="roles"
[step]="step"
(makeInitial)="setInitial(step)"
(rename)="renameStep(step, $event)"
(remove)="removeStep(step)"
(transitionAdd)="addTransiton(step, $event)"
(transitionRemove)="removeTransition(step, $event)"
(transitionUpdate)="updateTransition($event)"
(update)="updateStep(step, $event)">
</sqx-workflow-step>
<div class="table-items-row table-items-row-empty" *ngIf="workflows.length === 0">
No workflows created yet.
</div>
<sqx-workflow *ngFor="let workflow of workflows; trackBy: trackByWorkflow"
[workflow]="workflow" [roles]="roles">
</sqx-workflow>
<div class="table-items-footer" *ngIf="workflowsState.canCreate | async">
<form [formGroup]="addWorkflowForm.form" (ngSubmit)="addWorkflow()">
<div class="row no-gutters">
<div class="col">
<sqx-control-errors for="name" [submitted]="addWorkflowForm.submitted | async"></sqx-control-errors>
<input type="text" class="form-control" formControlName="name" maxlength="40" placeholder="Enter workflow name" autocomplete="off" />
</div>
<div class="col-auto pl-1">
<button type="submit" class="btn btn-success" [disabled]="addWorkflowForm.hasNoName | async">Add Workflow</button>
</div>
<div class="col-auto pl-1">
<button type="reset" class="btn btn-secondary" (click)="cancelAddWorkflow()">Cancel</button>
</div>
</div>
</form>
</div>
</ng-container>
<button class="btn btn-success" (click)="addStep()" *ngIf="workflow.canUpdate">
Add Step
</button>
</ng-container>
</ng-container>

78
src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts

@ -6,17 +6,14 @@
*/
import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import {
AddWorkflowForm,
AppsState,
MathHelper,
RolesState,
WorkflowDto,
WorkflowsState,
WorkflowStep,
WorkflowStepValues,
WorkflowTransition,
WorkflowTransitionValues
WorkflowsState
} from '@app/shared';
@Component({
@ -25,78 +22,45 @@ import {
templateUrl: './workflows-page.component.html'
})
export class WorkflowsPageComponent implements OnInit {
public workflow: WorkflowDto;
public addWorkflowForm = new AddWorkflowForm(this.formBuilder);
constructor(
public readonly appsState: AppsState,
public readonly rolesState: RolesState,
public readonly workflowsState: WorkflowsState
public readonly workflowsState: WorkflowsState,
private readonly formBuilder: FormBuilder
) {
}
public ngOnInit() {
this.workflowsState.load()
.subscribe(workflow => {
this.workflow = workflow;
});
this.workflowsState.load();
this.rolesState.load();
}
public reload() {
this.workflowsState.load(true)
.subscribe(workflow => {
this.workflow = workflow;
});
this.workflowsState.load(true);
}
public save() {
this.workflowsState.save(this.workflow);
}
public addStep() {
let index = this.workflow.steps.length;
public addWorkflow() {
const value = this.addWorkflowForm.submit();
for (let i = index; i < index + 100; i++) {
const name = `Step${i}`;
if (!this.workflow.getStep(name)) {
this.workflow = this.workflow.setStep(name, { color: MathHelper.randomColor() });
return;
}
if (value) {
this.workflowsState.add(value.name)
.subscribe(() => {
this.addWorkflowForm.submitCompleted();
}, error => {
this.addWorkflowForm.submitFailed(error);
});
}
}
public setInitial(step: WorkflowStep) {
this.workflow = this.workflow.setInitial(step.name);
}
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 updateTransition(update: { transition: WorkflowTransition, values: WorkflowTransitionValues }) {
this.workflow = this.workflow.setTransition(update.transition.from, update.transition.to, update.values);
}
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 cancelAddWorkflow() {
this.addWorkflowForm.submitCompleted();
}
public trackByStep(index: number, step: WorkflowStep) {
return step.name;
public trackByWorkflow(index: number, workflow: WorkflowDto) {
return workflow.id;
}
}

2
src/Squidex/app/features/settings/settings-area.component.html

@ -45,7 +45,7 @@
</li>
<li class="nav-item" *ngIf="selectedApp.canReadWorkflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active">
Workflow
Workflows
<i class="icon-angle-right"></i>
</a>
</li>

1
src/Squidex/app/shared/internal.ts

@ -63,6 +63,7 @@ export * from './state/rules.state';
export * from './state/schemas.forms';
export * from './state/schemas.state';
export * from './state/ui.state';
export * from './state/workflows.forms';
export * from './state/workflows.state';
export * from './utils/messages';

117
src/Squidex/app/shared/services/workflows.service.spec.ts

@ -49,7 +49,7 @@ describe('WorkflowsService', () => {
workflows = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow');
const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflows');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -64,7 +64,7 @@ describe('WorkflowsService', () => {
expect(workflows!).toEqual({ payload: createWorkflows('1', '2'), version: new Version('2') });
}));
it('should make a put request to create a workflow',
it('should make a post request to create a workflow',
inject([WorkflowsService, HttpTestingController], (workflowsService: WorkflowsService, httpMock: HttpTestingController) => {
let workflows: WorkflowsDto;
@ -73,7 +73,7 @@ describe('WorkflowsService', () => {
workflows = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow/123');
const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflows');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
@ -92,7 +92,7 @@ describe('WorkflowsService', () => {
const resource: Resource = {
_links: {
update: { method: 'PUT', href: '/api/apps/my-app/workflow/123' }
update: { method: 'PUT', href: '/api/apps/my-app/workflows/123' }
}
};
@ -102,7 +102,7 @@ describe('WorkflowsService', () => {
workflows = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow/123');
const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflows/123');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
@ -121,7 +121,7 @@ describe('WorkflowsService', () => {
const resource: Resource = {
_links: {
delete: { method: 'DELETE', href: '/api/apps/my-app/workflow/123' }
delete: { method: 'DELETE', href: '/api/apps/my-app/workflows/123' }
}
};
@ -131,9 +131,9 @@ describe('WorkflowsService', () => {
workflows = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow');
const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflows/123');
expect(req.request.method).toEqual('PUT');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush(workflowsResponse('1', '2'), {
@ -156,32 +156,28 @@ describe('WorkflowsService', () => {
function workflowResponse(name: string) {
return {
workflow: {
steps: {
[`${name}1`]: {
transitions: {
[`${name}2`]: {
expression: 'Expression1', role: 'Role1'
}
},
color: `${name}1`, noUpdate: true
name: `name_${name}`, id: `id_${name}`, initial: `${name}1`,
steps: {
[`${name}1`]: {
transitions: {
[`${name}2`]: {
expression: 'Expression1', role: 'Role1'
}
},
[`${name}2`]: {
transitions: {
[`${name}1`]: {
expression: 'Expression2', role: 'Role2'
}
},
color: `${name}2`, noUpdate: true
}
color: `${name}1`, noUpdate: true
},
initial: `${name}1`,
_links: {
update: { method: 'PUT', href: '/api/workflows' }
[`${name}2`]: {
transitions: {
[`${name}1`]: {
expression: 'Expression2', role: 'Role2'
}
},
color: `${name}2`, noUpdate: true
}
},
_links: {},
canCreate: true
_links: {
update: { method: 'PUT', href: `/workflows/${name}` }
}
};
}
});
@ -198,9 +194,9 @@ export function createWorkflows(...names: string[]): WorkflowsPayload {
export function createWorkflow(name: string): WorkflowDto {
return new WorkflowDto({
update: { method: 'PUT', href: '/workflows' }
update: { method: 'PUT', href: `/workflows/${name}` }
},
`${name}1`,
`id_${name}`, `name_${name}`, `${name}1`,
[
{ name: `${name}1`, color: `${name}1`, noUpdate: true, isLocked: false },
{ name: `${name}2`, color: `${name}2`, noUpdate: true, isLocked: false }
@ -213,17 +209,18 @@ export function createWorkflow(name: string): WorkflowDto {
describe('Workflow', () => {
it('should create empty workflow', () => {
const workflow = new WorkflowDto();
const workflow = new WorkflowDto({}, 'id');
expect(workflow.initial);
});
it('should add step to workflow', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1', { color: '#00ff00' });
expect(workflow.serialize()).toEqual({
name: null,
steps: {
'1': { transitions: {}, color: '#00ff00' }
},
@ -233,11 +230,12 @@ describe('Workflow', () => {
it('should override settings if step already exists', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1', { color: '#00ff00', noUpdate: true })
.setStep('1', { color: 'red' });
expect(workflow.serialize()).toEqual({
name: null,
steps: {
'1': { transitions: {}, color: 'red', noUpdate: true }
},
@ -247,7 +245,7 @@ describe('Workflow', () => {
it('should return same workflow if step to update is locked', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1', { color: '#00ff00', isLocked: true });
const updated = workflow.setStep('1', { color: 'red' });
@ -257,7 +255,7 @@ describe('Workflow', () => {
it('should sort steps case invariant', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('Z')
.setStep('a');
@ -269,7 +267,7 @@ describe('Workflow', () => {
it('should return same workflow if step to remove is locked', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1', { color: '#00ff00', isLocked: true });
const updated = workflow.removeStep('1');
@ -279,7 +277,7 @@ describe('Workflow', () => {
it('should return same workflow if step to remove not found', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1');
const updated = workflow.removeStep('3');
@ -289,7 +287,7 @@ describe('Workflow', () => {
it('should remove step', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1', { color: '#00ff00' })
.setStep('2', { color: '#ff0000' })
.setStep('3', { color: '#0000ff' })
@ -299,6 +297,7 @@ describe('Workflow', () => {
.removeStep('1');
expect(workflow.serialize()).toEqual({
name: null,
steps: {
'2': {
transitions: {
@ -314,13 +313,14 @@ describe('Workflow', () => {
it('should make first non-locked step the initial step if initial removed', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2', { isLocked: true })
.setStep('3')
.removeStep('1');
expect(workflow.serialize()).toEqual({
name: null,
steps: {
'2': { transitions: {}, isLocked: true },
'3': { transitions: {} }
@ -331,16 +331,16 @@ describe('Workflow', () => {
it('should unset initial step if initial removed', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.removeStep('1');
expect(workflow.serialize()).toEqual({ steps: {}, initial: undefined });
expect(workflow.serialize()).toEqual({ name: null, steps: {}, initial: null });
});
it('should rename step', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1', { color: '#00ff00' })
.setStep('2', { color: '#ff0000' })
.setStep('3', { color: '#0000ff' })
@ -350,6 +350,7 @@ describe('Workflow', () => {
.renameStep('1', 'a');
expect(workflow.serialize()).toEqual({
name: null,
steps: {
'a': {
transitions: {
@ -372,13 +373,14 @@ describe('Workflow', () => {
it('should add transitions to workflow', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2')
.setTransition('1', '2', { expression: '1 === 2' })
.setTransition('2', '1', { expression: '2 === 1' });
expect(workflow.serialize()).toEqual({
name: null,
steps: {
'1': {
transitions: {
@ -397,7 +399,7 @@ describe('Workflow', () => {
it('should remove transition from workflow', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2')
.setTransition('1', '2', { expression: '1 === 2' })
@ -405,6 +407,7 @@ describe('Workflow', () => {
.removeTransition('1', '2');
expect(workflow.serialize()).toEqual({
name: null,
steps: {
'1': { transitions: {}},
'2': {
@ -419,13 +422,14 @@ describe('Workflow', () => {
it('should override settings if transition already exists', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2')
.setTransition('2', '1', { expression: '2 === 1', role: 'Role' })
.setTransition('2', '1', { expression: '2 !== 1' });
expect(workflow.serialize()).toEqual({
name: null,
steps: {
'1': { transitions: {} },
'2': {
@ -440,7 +444,7 @@ describe('Workflow', () => {
it('should return same workflow if transition to update not found by from step', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2')
.setTransition('1', '2');
@ -452,7 +456,7 @@ describe('Workflow', () => {
it('should return same workflow if transition to update not found by to step', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2')
.setTransition('1', '2');
@ -464,7 +468,7 @@ describe('Workflow', () => {
it('should return same workflow if transition to remove not', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2')
.setTransition('1', '2');
@ -476,7 +480,7 @@ describe('Workflow', () => {
it('should return same workflow if step to make initial is locked', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2', { color: '#00ff00', isLocked: true });
@ -487,12 +491,13 @@ describe('Workflow', () => {
it('should set initial step', () => {
const workflow =
new WorkflowDto()
new WorkflowDto({}, 'id')
.setStep('1')
.setStep('2')
.setInitial('2');
expect(workflow.serialize()).toEqual({
name: null,
steps: {
'1': { transitions: {} },
'2': { transitions: {} }
@ -501,4 +506,12 @@ describe('Workflow', () => {
});
});
it('should rename workflow', () => {
const workflow =
new WorkflowDto({}, 'id')
.rename('name');
expect(workflow.serialize()).toEqual({ name: 'name', steps: {}, initial: null });
});
});

51
src/Squidex/app/shared/services/workflows.service.ts

@ -20,6 +20,8 @@ import {
pretifyError,
Resource,
ResourceLinks,
StringHelper,
Types,
Version,
Versioned
} from '@app/framework';
@ -35,9 +37,12 @@ export class WorkflowDto {
public readonly _links: ResourceLinks;
public readonly canUpdate: boolean;
public readonly canDelete: boolean;
public readonly displayName: string;
public static DEFAULT =
new WorkflowDto()
new WorkflowDto({}, 'id', 'name')
.setStep('Draft', { color: '#8091a5' })
.setStep('Archived', { color: '#eb3142', noUpdate: true })
.setStep('Published', { color: '#4bb958', isLocked: true })
@ -47,8 +52,11 @@ export class WorkflowDto {
.setTransition('Published', 'Draft')
.setTransition('Published', 'Archived');
constructor(links: ResourceLinks = {},
public readonly initial?: string,
constructor(
links: ResourceLinks = {},
public readonly id: string,
public readonly name: string | null = null,
public readonly initial: string | null = null,
public readonly steps: WorkflowStep[] = [],
private readonly transitions: WorkflowTransition[] = []
) {
@ -59,6 +67,9 @@ export class WorkflowDto {
this._links = links;
this.canUpdate = hasAnyLink(links, 'update');
this.canDelete = hasAnyLink(links, 'delete');
this.displayName = StringHelper.firstNonEmpty(name, 'Unnamed Workflow');
}
public getOpenSteps(step: WorkflowStep) {
@ -94,7 +105,7 @@ export class WorkflowDto {
initial = steps[0].name;
}
return new WorkflowDto(this._links, initial, steps, this.transitions);
return this.createNew({ initial, steps });
}
public setInitial(initial: string) {
@ -104,7 +115,7 @@ export class WorkflowDto {
return this;
}
return new WorkflowDto(this._links, initial, this.steps, this.transitions);
return this.createNew({ initial });
}
public removeStep(name: string) {
@ -124,10 +135,14 @@ export class WorkflowDto {
if (initial === name) {
const first = steps.find(x => !x.isLocked);
initial = first ? first.name : undefined;
initial = first ? first.name : null;
}
return new WorkflowDto(this._links, initial, steps, transitions);
return this.createNew({ initial, steps, transitions });
}
public rename(name: string) {
return this.createNew({ name });
}
public renameStep(name: string, newName: string) {
@ -163,7 +178,7 @@ export class WorkflowDto {
initial = newName;
}
return new WorkflowDto(this._links, initial, steps, transitions);
return this.createNew({ initial, steps, transitions });
}
public removeTransition(from: string, to: string) {
@ -173,7 +188,7 @@ export class WorkflowDto {
return this;
}
return new WorkflowDto(this._links, this.initial, this.steps, transitions);
return this.createNew({ transitions });
}
public setTransition(from: string, to: string, values: Partial<WorkflowTransitionValues> = {}) {
@ -199,11 +214,11 @@ export class WorkflowDto {
const transitions = [...this.transitions.filter(t => t !== found), { from, to, ...values }];
return new WorkflowDto(this._links, this.initial, this.steps, transitions);
return this.createNew({ transitions });
}
public serialize(): any {
const result = { steps: {}, initial: this.initial };
const result = { steps: {}, initial: this.initial, name: this.name };
for (let step of this.steps) {
const { name, ...values } = step;
@ -221,6 +236,14 @@ export class WorkflowDto {
return result;
}
private createNew(values: { steps?: WorkflowStep[], transitions?: WorkflowTransition[], initial?: string | null, name?: string | null }) {
return new WorkflowDto(this._links, this.id,
Types.isUndefined(values.name) ? this.name : values.name,
Types.isUndefined(values.initial) ? this.initial : values.initial,
values.steps || this.steps,
values.transitions || this.transitions);
}
}
export type WorkflowStepValues = { color?: string; isLocked?: boolean; noUpdate?: boolean; };
@ -245,7 +268,7 @@ export class WorkflowsService {
}
public getWorkflows(appName: string): Observable<WorkflowsDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/workflow`);
const url = this.apiUrl.buildUrl(`api/apps/${appName}/workflows`);
return HTTP.getVersioned(this.http, url).pipe(
mapVersioned(({ body }) => {
@ -255,7 +278,7 @@ export class WorkflowsService {
}
public postWorkflow(appName: string, dto: CreateWorkflowDto, version: Version): Observable<WorkflowsDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/workflow`);
const url = this.apiUrl.buildUrl(`api/apps/${appName}/workflows`);
return HTTP.postVersioned(this.http, url, dto, version).pipe(
mapVersioned(({ body }) => {
@ -329,5 +352,5 @@ function parseWorkflow(workflow: any) {
}
}
return new WorkflowDto(workflow._links, workflow.initial, steps, transitions);
return new WorkflowDto(workflow._links, workflow.id, workflow.name, workflow.initial, steps, transitions);
}

2
src/Squidex/app/shared/state/clients.forms.ts

@ -25,7 +25,7 @@ export class RenameClientForm extends Form<FormGroup, { name: string }> {
}
}
export class AttachClientForm extends Form<FormGroup, { name: string}> {
export class AddClientForm extends Form<FormGroup, { name: string}> {
public hasNoName = hasNoValue$(this.form.controls['name']);
constructor(formBuilder: FormBuilder) {

24
src/Squidex/app/shared/state/workflows.forms.ts

@ -0,0 +1,24 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Form, hasNoValue$ } from '@app/framework';
export class AddWorkflowForm extends Form<FormGroup, { name: string}> {
public hasNoName = hasNoValue$(this.form.controls['name']);
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
name: ['',
[
Validators.required
]
]
}));
}
}

52
src/Squidex/app/shared/state/workflows.state.spec.ts

@ -11,12 +11,12 @@ import { IMock, It, Mock, Times } from 'typemoq';
import {
DialogService,
versioned,
WorkflowPayload,
WorkflowsPayload,
WorkflowsService,
WorkflowsState
} from '@app/shared/internal';
import { createWorkflow } from '../services/workflows.service.spec';
import { createWorkflows } from '../services/workflows.service.spec';
import { TestValues } from './_test-helpers';
@ -28,7 +28,7 @@ describe('WorkflowsState', () => {
version
} = TestValues;
const oldWorkflow = createWorkflow('test');
const oldWorkflows = createWorkflows('1', '2');
let dialogs: IMock<DialogService>;
let workflowsService: IMock<WorkflowsService>;
@ -48,11 +48,11 @@ describe('WorkflowsState', () => {
describe('Loading', () => {
it('should load workflow', () => {
workflowsService.setup(x => x.getWorkflows(app))
.returns(() => of(versioned(version, oldWorkflow))).verifiable();
.returns(() => of(versioned(version, oldWorkflows))).verifiable();
workflowsState.load().subscribe();
expect(workflowsState.snapshot.workflow).toEqual(oldWorkflow.workflow);
expect(workflowsState.snapshot.workflows.values).toEqual(oldWorkflows.items);
expect(workflowsState.snapshot.isLoaded).toBeTruthy();
expect(workflowsState.snapshot.version).toEqual(version);
@ -61,7 +61,7 @@ describe('WorkflowsState', () => {
it('should show notification on load when reload is true', () => {
workflowsService.setup(x => x.getWorkflows(app))
.returns(() => of(versioned(version, oldWorkflow))).verifiable();
.returns(() => of(versioned(version, oldWorkflows))).verifiable();
workflowsState.load(true).subscribe();
@ -74,28 +74,50 @@ describe('WorkflowsState', () => {
describe('Updates', () => {
beforeEach(() => {
workflowsService.setup(x => x.getWorkflows(app))
.returns(() => of(versioned(version, oldWorkflow))).verifiable();
.returns(() => of(versioned(version, oldWorkflows))).verifiable();
workflowsState.load().subscribe();
});
it('should update workflows when saved', () => {
const updated = createWorkflow('updated');
it('should update workflows when workflow added', () => {
const updated = createWorkflows('1', '2', '3');
const request = oldWorkflow.workflow.serialize();
workflowsService.setup(x => x.putWorkflow(app, oldWorkflow.workflow, request, version))
workflowsService.setup(x => x.postWorkflow(app, { name: 'my-workflow' }, version))
.returns(() => of(versioned(newVersion, updated))).verifiable();
workflowsState.save(oldWorkflow.workflow).subscribe();
workflowsState.add('my-workflow' ).subscribe();
expectNewWorkflows(updated);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
});
function expectNewWorkflows(updated: WorkflowPayload) {
expect(workflowsState.snapshot.workflow).toEqual(updated.workflow);
it('should update workflows when workflow updated', () => {
const updated = createWorkflows('1', '2', '3');
const request = oldWorkflows.items[0].serialize();
workflowsService.setup(x => x.putWorkflow(app, oldWorkflows.items[0], request, version))
.returns(() => of(versioned(newVersion, updated))).verifiable();
workflowsState.update(oldWorkflows.items[0]).subscribe();
expectNewWorkflows(updated);
});
it('should update workflows when workflow deleted', () => {
const updated = createWorkflows('1', '2', '3');
workflowsService.setup(x => x.deleteWorkflow(app, oldWorkflows.items[0], version))
.returns(() => of(versioned(newVersion, updated))).verifiable();
workflowsState.delete(oldWorkflows.items[0]).subscribe();
expectNewWorkflows(updated);
});
function expectNewWorkflows(updated: WorkflowsPayload) {
expect(workflowsState.snapshot.workflows.values).toEqual(updated.items);
expect(workflowsState.snapshot.version).toEqual(newVersion);
}
});

56
src/Squidex/app/shared/state/workflows.state.ts

@ -13,7 +13,7 @@ import { tap } from 'rxjs/operators';
import {
DialogService,
shareMapSubscribed,
ImmutableArray,
shareSubscribed,
State,
Version
@ -23,38 +23,44 @@ import { AppsState } from './apps.state';
import {
WorkflowDto,
WorkflowPayload,
WorkflowsPayload,
WorkflowsService
} from './../services/workflows.service';
interface Snapshot {
// The current workflow.
workflow?: WorkflowDto;
workflows: ImmutableArray<WorkflowDto>;
// The app version.
version: Version;
// Indicates if the workflows are loaded.
isLoaded?: boolean;
// Indicates if the user can create new workflow.
canCreate?: boolean;
}
@Injectable()
export class WorkflowsState extends State<Snapshot> {
public workflow =
this.project(x => x.workflow);
public workflows =
this.project(x => x.workflows);
public isLoaded =
this.project(x => !!x.isLoaded);
public canCreate =
this.project(x => !!x.canCreate);
constructor(
private readonly workflowsService: WorkflowsService,
private readonly appsState: AppsState,
private readonly dialogs: DialogService
) {
super({ version: Version.EMPTY });
super({ workflows: ImmutableArray.empty(), version: Version.EMPTY });
}
public load(isReload = false): Observable<WorkflowDto> {
public load(isReload = false): Observable<any> {
if (!isReload) {
this.resetState();
}
@ -62,29 +68,47 @@ export class WorkflowsState extends State<Snapshot> {
return this.workflowsService.getWorkflows(this.appName).pipe(
tap(({ version, payload }) => {
if (isReload) {
this.dialogs.notifyInfo('Workflow reloaded.');
this.dialogs.notifyInfo('Workflows reloaded.');
}
this.replaceWorkflow(payload, version);
this.replaceWorkflows(payload, version);
}),
shareMapSubscribed(this.dialogs, x => x.payload.workflow));
shareSubscribed(this.dialogs));
}
public save(workflow: WorkflowDto): Observable<any> {
return this.workflowsService.putWorkflow(this.appName, workflow, workflow.serialize(), this.version).pipe(
public add(name: string): Observable<any> {
return this.workflowsService.postWorkflow(this.appName, { name }, this.version).pipe(
tap(({ version, payload }) => {
this.replaceWorkflow(payload, version);
this.replaceWorkflows(payload, version);
}),
shareSubscribed(this.dialogs));
}
public update(workflow: WorkflowDto): Observable<any> {
return this.workflowsService.putWorkflow(this.appName, workflow, workflow.serialize(), this.version).pipe(
tap(({ version, payload }) => {
this.dialogs.notifyInfo('Workflow has been saved.');
this.replaceWorkflows(payload, version);
}),
shareSubscribed(this.dialogs));
}
public delete(workflow: WorkflowDto): Observable<any> {
return this.workflowsService.deleteWorkflow(this.appName, workflow, this.version).pipe(
tap(({ version, payload }) => {
this.replaceWorkflows(payload, version);
}),
shareSubscribed(this.dialogs));
}
private replaceWorkflow(payload: WorkflowPayload, version: Version) {
const { workflow } = payload;
private replaceWorkflows(payload: WorkflowsPayload, version: Version) {
const { canCreate, items } = payload;
const workflows = ImmutableArray.of(items);
this.next(s => {
return { ...s, workflow, isLoaded: true, version };
return { ...s, workflows, isLoaded: true, version, canCreate };
});
}

Loading…
Cancel
Save