diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs index c04a8ecc5..90a430965 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs +++ b/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 EmptySteps = new Dictionary(); public static readonly Workflow Default = CreateDefault(); diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs index 6a7a23653..6a6f0bfa6 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs @@ -15,6 +15,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models { public sealed class UpdateWorkflowDto { + /// + /// The name of the workflow. + /// + public string Name { get; set; } + /// /// The workflow steps. /// @@ -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 }; } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs index d347337da..7906addaa 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs @@ -23,6 +23,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public Guid Id { get; set; } + /// + /// The name of the workflow. + /// + public string Name { get; set; } + /// /// The workflow steps. /// @@ -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); diff --git a/src/Squidex/app/features/settings/declarations.ts b/src/Squidex/app/features/settings/declarations.ts index a6f8916c6..1a0553935 100644 --- a/src/Squidex/app/features/settings/declarations.ts +++ b/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'; \ 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 bf7eddbf8..d77bf3601 100644 --- a/src/Squidex/app/features/settings/module.ts +++ b/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 diff --git a/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts b/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts index 885085116..48bc18f61 100644 --- a/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts +++ b/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, diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow.component.html b/src/Squidex/app/features/settings/pages/workflows/workflow.component.html new file mode 100644 index 000000000..14b64d4e1 --- /dev/null +++ b/src/Squidex/app/features/settings/pages/workflows/workflow.component.html @@ -0,0 +1,70 @@ +
+
+
+
+ {{workflow.displayName}} +
+
+
+ + + +
+
+
+
+ +
+
+
+ + +
+
+ +
+ + +
+ + +
+ + + + Optional name for the workflow. + +
+
+ + + + + +
+
+
diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow.component.scss b/src/Squidex/app/features/settings/pages/workflows/workflow.component.scss new file mode 100644 index 000000000..253e84024 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/workflows/workflow.component.ts b/src/Squidex/app/features/settings/pages/workflows/workflow.component.ts new file mode 100644 index 000000000..ccfa13f95 --- /dev/null +++ b/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; + } +} + 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 index 6fd2a206a..0faddce9f 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html +++ b/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html @@ -1,47 +1,47 @@ - + Workflow - - - - - - - - + - - +
+ No workflows created yet. +
+ + + + +
- -
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 index a458bee00..4659b7781 100644 --- a/src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts +++ b/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; } } diff --git a/src/Squidex/app/features/settings/settings-area.component.html b/src/Squidex/app/features/settings/settings-area.component.html index 1a1a9bcb7..cee2481d3 100644 --- a/src/Squidex/app/features/settings/settings-area.component.html +++ b/src/Squidex/app/features/settings/settings-area.component.html @@ -45,7 +45,7 @@ diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts index 71a0a1561..c53694089 100644 --- a/src/Squidex/app/shared/internal.ts +++ b/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'; diff --git a/src/Squidex/app/shared/services/workflows.service.spec.ts b/src/Squidex/app/shared/services/workflows.service.spec.ts index 09afc1d23..ec10fc178 100644 --- a/src/Squidex/app/shared/services/workflows.service.spec.ts +++ b/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 }); + }); + }); \ 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 index 52e1ef3cd..9e8d06a54 100644 --- a/src/Squidex/app/shared/services/workflows.service.ts +++ b/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 = {}) { @@ -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 { - 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 { - 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); } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/clients.forms.ts b/src/Squidex/app/shared/state/clients.forms.ts index 4b1ad2372..fbd4c8ba7 100644 --- a/src/Squidex/app/shared/state/clients.forms.ts +++ b/src/Squidex/app/shared/state/clients.forms.ts @@ -25,7 +25,7 @@ export class RenameClientForm extends Form { } } -export class AttachClientForm extends Form { +export class AddClientForm extends Form { public hasNoName = hasNoValue$(this.form.controls['name']); constructor(formBuilder: FormBuilder) { diff --git a/src/Squidex/app/shared/state/workflows.forms.ts b/src/Squidex/app/shared/state/workflows.forms.ts new file mode 100644 index 000000000..7c0fc21ac --- /dev/null +++ b/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 { + public hasNoName = hasNoValue$(this.form.controls['name']); + + constructor(formBuilder: FormBuilder) { + super(formBuilder.group({ + name: ['', + [ + Validators.required + ] + ] + })); + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/state/workflows.state.spec.ts b/src/Squidex/app/shared/state/workflows.state.spec.ts index ae3b646c2..bb3567e3f 100644 --- a/src/Squidex/app/shared/state/workflows.state.spec.ts +++ b/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; let workflowsService: IMock; @@ -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); } }); diff --git a/src/Squidex/app/shared/state/workflows.state.ts b/src/Squidex/app/shared/state/workflows.state.ts index 42bdbf2c0..841365fba 100644 --- a/src/Squidex/app/shared/state/workflows.state.ts +++ b/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; // 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 { - 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 { + public load(isReload = false): Observable { if (!isReload) { this.resetState(); } @@ -62,29 +68,47 @@ export class WorkflowsState extends State { 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 { - return this.workflowsService.putWorkflow(this.appName, workflow, workflow.serialize(), this.version).pipe( + public add(name: string): Observable { + 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 { + 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 { + 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 }; }); }