diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs index 859e546ef..c04a8ecc5 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs @@ -9,49 +9,57 @@ using System.Collections.Generic; namespace Squidex.Domain.Apps.Core.Contents { - public sealed class Workflow + public sealed class Workflow : Named { + private const string DefaultName = "Name"; private static readonly IReadOnlyDictionary EmptySteps = new Dictionary(); - public static readonly Workflow Default = new Workflow( - new Dictionary - { - [Status.Archived] = - new WorkflowStep( - new Dictionary - { - [Status.Draft] = new WorkflowTransition() - }, - StatusColors.Archived, true), - [Status.Draft] = - new WorkflowStep( - new Dictionary - { - [Status.Archived] = new WorkflowTransition(), - [Status.Published] = new WorkflowTransition() - }, - StatusColors.Draft), - [Status.Published] = - new WorkflowStep( - new Dictionary - { - [Status.Archived] = new WorkflowTransition(), - [Status.Draft] = new WorkflowTransition() - }, - StatusColors.Published) - }, Status.Draft); + public static readonly Workflow Default = CreateDefault(); + public static readonly Workflow Empty = new Workflow(EmptySteps, default); public IReadOnlyDictionary Steps { get; } public Status Initial { get; } - public Workflow(IReadOnlyDictionary steps, Status initial) + public Workflow(IReadOnlyDictionary steps, Status initial, string name = null) + : base(name ?? DefaultName) { Steps = steps ?? EmptySteps; Initial = initial; } + public static Workflow CreateDefault(string name = null) + { + return new Workflow( + new Dictionary + { + [Status.Archived] = + new WorkflowStep( + new Dictionary + { + [Status.Draft] = new WorkflowTransition() + }, + StatusColors.Archived, true), + [Status.Draft] = + new WorkflowStep( + new Dictionary + { + [Status.Archived] = new WorkflowTransition(), + [Status.Published] = new WorkflowTransition() + }, + StatusColors.Draft), + [Status.Published] = + new WorkflowStep( + new Dictionary + { + [Status.Archived] = new WorkflowTransition(), + [Status.Draft] = new WorkflowTransition() + }, + StatusColors.Published) + }, Status.Draft, name); + } + public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status) { if (TryGetStep(status, out var step)) diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs index d027b8d32..2ffadc99d 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs @@ -27,6 +27,20 @@ namespace Squidex.Domain.Apps.Core.Contents { } + [Pure] + public Workflows Remove(Guid id) + { + return new Workflows(Without(id)); + } + + [Pure] + public Workflows Add(string name) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + return new Workflows(With(Guid.NewGuid(), Workflow.CreateDefault(name))); + } + [Pure] public Workflows Set(Workflow workflow) { @@ -35,6 +49,19 @@ namespace Squidex.Domain.Apps.Core.Contents return new Workflows(With(Guid.Empty, workflow)); } + [Pure] + public Workflows Update(Guid id, Workflow workflow) + { + Guard.NotNull(workflow, nameof(workflow)); + + if (!ContainsKey(id)) + { + return this; + } + + return new Workflows(With(id, workflow)); + } + public Workflow GetFirst() { return Values.FirstOrDefault() ?? Workflow.Default; diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs b/src/Squidex.Domain.Apps.Core.Model/Named.cs similarity index 93% rename from src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs rename to src/Squidex.Domain.Apps.Core.Model/Named.cs index 69ba9a3c1..fd76c4e8f 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Named.cs @@ -7,7 +7,7 @@ using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Core.Apps +namespace Squidex.Domain.Apps.Core { public abstract class Named { diff --git a/src/Squidex/app/shared/services/contributors.service.spec.ts b/src/Squidex/app/shared/services/contributors.service.spec.ts index 250d4bba0..20f6c27d5 100644 --- a/src/Squidex/app/shared/services/contributors.service.spec.ts +++ b/src/Squidex/app/shared/services/contributors.service.spec.ts @@ -119,7 +119,7 @@ describe('ContributorsService', () => { function contributorsResponse(...ids: number[]) { return { - items: ids.map(id => ({ + items: ids.map(id => ({ contributorId: `id${id}`, role: id % 2 === 0 ? 'Owner' : 'Developer', _links: { update: { method: 'PUT', href: `/contributors/id${id}` } diff --git a/src/Squidex/app/shared/services/workflows.service.spec.ts b/src/Squidex/app/shared/services/workflows.service.spec.ts index cdf96ce7f..09afc1d23 100644 --- a/src/Squidex/app/shared/services/workflows.service.spec.ts +++ b/src/Squidex/app/shared/services/workflows.service.spec.ts @@ -13,9 +13,9 @@ import { ApiUrlConfig, Resource, Version, - Versioned, WorkflowDto, - WorkflowPayload, + WorkflowsDto, + WorkflowsPayload, WorkflowsService } from '@app/shared/internal'; @@ -43,10 +43,10 @@ describe('WorkflowsService', () => { it('should make a get request to get app workflows', inject([WorkflowsService, HttpTestingController], (workflowsService: WorkflowsService, httpMock: HttpTestingController) => { - let workflow: Versioned; + let workflows: WorkflowsDto; - workflowsService.getWorkflow('my-app').subscribe(result => { - workflow = result; + workflowsService.getWorkflows('my-app').subscribe(result => { + workflows = result; }); const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow'); @@ -54,29 +54,81 @@ describe('WorkflowsService', () => { expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush(workflowsResponse('Draft'), + req.flush(workflowsResponse('1', '2'), { headers: { etag: '2' } }); - expect(workflow!).toEqual({ payload: createWorkflow('Draft'), version: new Version('2') }); + expect(workflows!).toEqual({ payload: createWorkflows('1', '2'), version: new Version('2') }); })); - it('should make a put request to assign a workflow', + it('should make a put request to create a workflow', + inject([WorkflowsService, HttpTestingController], (workflowsService: WorkflowsService, httpMock: HttpTestingController) => { + + let workflows: WorkflowsDto; + + workflowsService.postWorkflow('my-app', { name: 'New' }, version).subscribe(result => { + workflows = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow/123'); + + expect(req.request.method).toEqual('POST'); + expect(req.request.headers.get('If-Match')).toEqual(version.value); + + req.flush(workflowsResponse('1', '2'), { + headers: { + etag: '2' + } + }); + + expect(workflows!).toEqual({ payload: createWorkflows('1', '2'), version: new Version('2') }); + })); + + it('should make a put request to update a workflow', inject([WorkflowsService, HttpTestingController], (workflowsService: WorkflowsService, httpMock: HttpTestingController) => { const resource: Resource = { _links: { - update: { method: 'PUT', href: '/api/apps/my-app/workflow' } + update: { method: 'PUT', href: '/api/apps/my-app/workflow/123' } } }; - let workflow: Versioned; + let workflows: WorkflowsDto; workflowsService.putWorkflow('my-app', resource, {}, version).subscribe(result => { - workflow = result; + workflows = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow/123'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toEqual(version.value); + + req.flush(workflowsResponse('1', '2'), { + headers: { + etag: '2' + } + }); + + expect(workflows!).toEqual({ payload: createWorkflows('1', '2'), version: new Version('2') }); + })); + + it('should make a delete request to delete a workflow', + inject([WorkflowsService, HttpTestingController], (workflowsService: WorkflowsService, httpMock: HttpTestingController) => { + + const resource: Resource = { + _links: { + delete: { method: 'DELETE', href: '/api/apps/my-app/workflow/123' } + } + }; + + let workflows: WorkflowsDto; + + workflowsService.deleteWorkflow('my-app', resource, version).subscribe(result => { + workflows = result; }); const req = httpMock.expectOne('http://service/p/api/apps/my-app/workflow'); @@ -84,16 +136,25 @@ describe('WorkflowsService', () => { expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toEqual(version.value); - req.flush(workflowsResponse('Draft'), { + req.flush(workflowsResponse('1', '2'), { headers: { etag: '2' } }); - expect(workflow!).toEqual({ payload: createWorkflow('Draft'), version: new Version('2') }); + expect(workflows!).toEqual({ payload: createWorkflows('1', '2'), version: new Version('2') }); })); - function workflowsResponse(name: string) { + function workflowsResponse(...names: string[]) { + return { + items: names.map(name => workflowResponse(name)), + _links: { + create: { method: 'POST', href: '/workflows' } + } + }; + } + + function workflowResponse(name: string) { return { workflow: { steps: { @@ -125,10 +186,19 @@ describe('WorkflowsService', () => { } }); -export function createWorkflow(name: string): WorkflowPayload { +export function createWorkflows(...names: string[]): WorkflowsPayload { return { - workflow: new WorkflowDto({ - update: { method: 'PUT', href: '/api/workflows' } + items: names.map(name => createWorkflow(name)), + _links: { + create: { method: 'POST', href: '/workflows' } + }, + canCreate: true + }; +} + +export function createWorkflow(name: string): WorkflowDto { + return new WorkflowDto({ + update: { method: 'PUT', href: '/workflows' } }, `${name}1`, [ @@ -138,9 +208,7 @@ export function createWorkflow(name: string): WorkflowPayload { [ { from: `${name}1`, to: `${name}2`, expression: 'Expression1', role: 'Role1' }, { from: `${name}2`, to: `${name}1`, expression: 'Expression2', role: 'Role2' } - ]), - _links: {} - }; + ]); } describe('Workflow', () => { diff --git a/src/Squidex/app/shared/services/workflows.service.ts b/src/Squidex/app/shared/services/workflows.service.ts index ce9861f10..52e1ef3cd 100644 --- a/src/Squidex/app/shared/services/workflows.service.ts +++ b/src/Squidex/app/shared/services/workflows.service.ts @@ -24,8 +24,12 @@ import { Versioned } from '@app/framework'; -export type WorkflowsDto = Versioned; -export type WorkflowPayload = { workflow: WorkflowDto; } & Resource; +export type WorkflowsDto = Versioned; +export type WorkflowsPayload = { + readonly items: WorkflowDto[]; + + readonly canCreate: boolean; +} & Resource; export class WorkflowDto { public readonly _links: ResourceLinks; @@ -227,6 +231,10 @@ export type WorkflowTransition = { from: string; to: string } & WorkflowTransiti export type WorkflowTransitionView = { step: WorkflowStep } & WorkflowTransition; +export interface CreateWorkflowDto { + readonly name: string; +} + @Injectable() export class WorkflowsService { constructor( @@ -236,38 +244,69 @@ export class WorkflowsService { ) { } - public getWorkflow(appName: string): Observable> { + public getWorkflows(appName: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/workflow`); return HTTP.getVersioned(this.http, url).pipe( mapVersioned(({ body }) => { - return parseWorkflowPayload(body); + return parseWorkflows(body); }), pretifyError('Failed to load workflows. Please reload.')); } - public putWorkflow(appName: string, resource: Resource, dto: any, version: Version): Observable> { + public postWorkflow(appName: string, dto: CreateWorkflowDto, version: Version): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/workflow`); + + return HTTP.postVersioned(this.http, url, dto, version).pipe( + mapVersioned(({ body }) => { + return parseWorkflows(body); + }), + tap(() => { + this.analytics.trackEvent('Workflow', 'Created', appName); + }), + pretifyError('Failed to create workflow. Please reload.')); + } + + public putWorkflow(appName: string, resource: Resource, dto: any, version: Version): Observable { const link = resource._links['update']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( mapVersioned(({ body }) => { - return parseWorkflowPayload(body); + return parseWorkflows(body); }), tap(() => { - this.analytics.trackEvent('Workflow', 'Configured', appName); + this.analytics.trackEvent('Workflow', 'Updated', appName); }), - pretifyError('Failed to configure Workflow. Please reload.')); + pretifyError('Failed to update Workflow. Please reload.')); + } + + public deleteWorkflow(appName: string, resource: Resource, version: Version): Observable { + const link = resource._links['delete']; + + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version).pipe( + mapVersioned(({ body }) => { + return parseWorkflows(body); + }), + tap(() => { + this.analytics.trackEvent('Workflow', 'Deleted', appName); + }), + pretifyError('Failed to delete Workflow. Please reload.')); } } -function parseWorkflowPayload(response: any) { - const { workflow, _links } = response; +function parseWorkflows(response: any) { + const raw: any[] = response.items; + + const items = raw.map(item => + parseWorkflow(item)); - const result = parseWorkflow(workflow); + const { _links } = response; - return { workflow: result, _links }; + return { items, _links, canCreate: hasAnyLink(_links, 'create') }; } function parseWorkflow(workflow: any) { diff --git a/src/Squidex/app/shared/state/workflows.state.spec.ts b/src/Squidex/app/shared/state/workflows.state.spec.ts index 4ad671a27..ae3b646c2 100644 --- a/src/Squidex/app/shared/state/workflows.state.spec.ts +++ b/src/Squidex/app/shared/state/workflows.state.spec.ts @@ -47,7 +47,7 @@ describe('WorkflowsState', () => { describe('Loading', () => { it('should load workflow', () => { - workflowsService.setup(x => x.getWorkflow(app)) + workflowsService.setup(x => x.getWorkflows(app)) .returns(() => of(versioned(version, oldWorkflow))).verifiable(); workflowsState.load().subscribe(); @@ -60,7 +60,7 @@ describe('WorkflowsState', () => { }); it('should show notification on load when reload is true', () => { - workflowsService.setup(x => x.getWorkflow(app)) + workflowsService.setup(x => x.getWorkflows(app)) .returns(() => of(versioned(version, oldWorkflow))).verifiable(); workflowsState.load(true).subscribe(); @@ -73,7 +73,7 @@ describe('WorkflowsState', () => { describe('Updates', () => { beforeEach(() => { - workflowsService.setup(x => x.getWorkflow(app)) + workflowsService.setup(x => x.getWorkflows(app)) .returns(() => of(versioned(version, oldWorkflow))).verifiable(); workflowsState.load().subscribe(); diff --git a/src/Squidex/app/shared/state/workflows.state.ts b/src/Squidex/app/shared/state/workflows.state.ts index b43a558e0..42bdbf2c0 100644 --- a/src/Squidex/app/shared/state/workflows.state.ts +++ b/src/Squidex/app/shared/state/workflows.state.ts @@ -59,7 +59,7 @@ export class WorkflowsState extends State { this.resetState(); } - return this.workflowsService.getWorkflow(this.appName).pipe( + return this.workflowsService.getWorkflows(this.appName).pipe( tap(({ version, payload }) => { if (isReload) { this.dialogs.notifyInfo('Workflow reloaded.'); diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs index 9f95ac4b2..811dd97ef 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs @@ -95,7 +95,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var clients_1 = clients_0.Revoke("2"); - Assert.NotSame(clients_0, clients_1); + Assert.Empty(clients_1); } } } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs index 56d615159..5e22067b7 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs @@ -70,7 +70,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var patterns_1 = patterns_0.Remove(id); - Assert.NotSame(patterns_0, patterns_1); + Assert.Empty(patterns_1); } } } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs index e8337ac4e..ac89aa8ce 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs @@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Core.Model.Apps { var roles_1 = roles_0.Remove(role); - Assert.NotSame(roles_0, roles_1); + Assert.Empty(roles_1); } [Fact] diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowsTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowsTests.cs index 37ce537c4..c81db61cb 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowsTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowsTests.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Linq; using Squidex.Domain.Apps.Core.Contents; using Xunit; @@ -33,5 +34,47 @@ namespace Squidex.Domain.Apps.Core.Model.Contents Assert.Single(workflows_1); Assert.Same(Workflow.Default, workflows_1[Guid.Empty]); } + + [Fact] + public void Should_add_new_workflow_with_default_states() + { + var workflows_1 = workflows_0.Add("1"); + + Assert.Equal(workflows_1.GetFirst().Steps.Keys, new[] { Status.Archived, Status.Draft, Status.Published }); + } + + [Fact] + public void Should_update_workflow() + { + var workflows_1 = workflows_0.Add("1"); + var workflows_2 = workflows_1.Update(workflows_1.Keys.First(), Workflow.Empty); + + Assert.Empty(workflows_2.GetFirst().Steps.Keys); + } + + [Fact] + public void Should_do_nothing_if_workflow_to_update_not_found() + { + var workflows_1 = workflows_0.Update(Guid.NewGuid(), Workflow.Empty); + + Assert.Same(workflows_0, workflows_1); + } + + [Fact] + public void Should_remove_workflow() + { + var workflows_1 = workflows_0.Add("1"); + var workflows_2 = workflows_1.Remove(workflows_1.Keys.First()); + + Assert.Empty(workflows_2); + } + + [Fact] + public void Should_do_nothing_if_workflow_to_remove_not_found() + { + var workflows_1 = workflows_0.Remove(Guid.NewGuid()); + + Assert.Empty(workflows_1); + } } }