Browse Source

Schema ids.

pull/382/head
Sebastian 7 years ago
parent
commit
91bc2cddc1
  1. 30
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  2. 12
      src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs
  3. 10
      src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs
  4. 27
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs
  5. 38
      src/Squidex/app/features/settings/pages/workflows/schema-tag-converter.ts
  6. 14
      src/Squidex/app/features/settings/pages/workflows/workflow.component.html
  7. 2
      src/Squidex/app/features/settings/pages/workflows/workflow.component.scss
  8. 5
      src/Squidex/app/features/settings/pages/workflows/workflow.component.ts
  9. 4
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html
  10. 18
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts
  11. 166
      src/Squidex/app/framework/angular/forms/tag-editor.component.ts
  12. 31
      src/Squidex/app/shared/services/workflows.service.spec.ts
  13. 49
      src/Squidex/app/shared/services/workflows.service.ts
  14. 4
      src/Squidex/app/shared/state/workflows.state.spec.ts
  15. 4
      tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs
  16. 24
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs
  17. 4
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs
  18. 37
      tests/Squidex.Infrastructure.Tests/Json/Newtonsoft/ReadOnlyCollectionTests.cs

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Contents namespace Squidex.Domain.Apps.Core.Contents
@ -13,26 +14,41 @@ namespace Squidex.Domain.Apps.Core.Contents
{ {
private const string DefaultName = "Unnamed"; private const string DefaultName = "Unnamed";
private static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>(); private static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>();
private static readonly IReadOnlyList<Guid> EmptySchemaIds = new List<Guid>();
public static readonly Workflow Default = CreateDefault(); public static readonly Workflow Default = CreateDefault();
public static readonly Workflow Empty = new Workflow(EmptySteps, default); public static readonly Workflow Empty = new Workflow(default, EmptySteps);
public IReadOnlyDictionary<Status, WorkflowStep> Steps { get; } public IReadOnlyDictionary<Status, WorkflowStep> Steps { get; } = EmptySteps;
public IReadOnlyList<Guid> SchemaIds { get; } = EmptySchemaIds;
public Status Initial { get; } public Status Initial { get; }
public Workflow(IReadOnlyDictionary<Status, WorkflowStep> steps, Status initial, string name = null) public Workflow(
Status initial,
IReadOnlyDictionary<Status, WorkflowStep> steps,
IReadOnlyList<Guid> schemaIds = null,
string name = null)
: base(name ?? DefaultName) : base(name ?? DefaultName)
{ {
Steps = steps ?? EmptySteps;
Initial = initial; Initial = initial;
if (steps != null)
{
Steps = steps;
}
if (schemaIds != null)
{
SchemaIds = schemaIds;
}
} }
public static Workflow CreateDefault(string name = null) public static Workflow CreateDefault(string name = null)
{ {
return new Workflow( return new Workflow(
new Dictionary<Status, WorkflowStep> Status.Draft, new Dictionary<Status, WorkflowStep>
{ {
[Status.Archived] = [Status.Archived] =
new WorkflowStep( new WorkflowStep(
@ -57,7 +73,7 @@ namespace Squidex.Domain.Apps.Core.Contents
[Status.Draft] = new WorkflowTransition() [Status.Draft] = new WorkflowTransition()
}, },
StatusColors.Published) StatusColors.Published)
}, Status.Draft, name); }, null, name);
} }
public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status) public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status)

12
src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs

@ -36,6 +36,18 @@ namespace Squidex.Infrastructure.Json.Newtonsoft
} }
} }
protected override JsonArrayContract CreateArrayContract(Type objectType)
{
if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyList<>))
{
var implementationType = typeof(List<>).MakeGenericType(objectType.GetGenericArguments());
return base.CreateArrayContract(implementationType);
}
return base.CreateArrayContract(objectType);
}
protected override JsonDictionaryContract CreateDictionaryContract(Type objectType) protected override JsonDictionaryContract CreateDictionaryContract(Type objectType)
{ {
if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)) if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>))

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
@ -26,6 +27,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[Required] [Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; } public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The schema ids.
/// </summary>
public List<Guid> SchemaIds { get; set; }
/// <summary> /// <summary>
/// The initial step. /// The initial step.
/// </summary> /// </summary>
@ -34,6 +40,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public UpdateWorkflow ToCommand() public UpdateWorkflow ToCommand()
{ {
var workflow = new Workflow( var workflow = new Workflow(
Initial,
Steps?.ToDictionary( Steps?.ToDictionary(
x => x.Key, x => x.Key,
x => new WorkflowStep( x => new WorkflowStep(
@ -42,7 +49,8 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
y => new WorkflowTransition(y.Value.Expression, y.Value.Role)), y => new WorkflowTransition(y.Value.Expression, y.Value.Role)),
x.Value.Color, x.Value.Color,
x.Value.NoUpdate)), x.Value.NoUpdate)),
Initial, Name); SchemaIds,
Name);
return new UpdateWorkflow { Workflow = workflow }; return new UpdateWorkflow { Workflow = workflow };
} }

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

@ -34,6 +34,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[Required] [Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; } public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The schema ids.
/// </summary>
public IReadOnlyList<Guid> SchemaIds { get; set; }
/// <summary> /// <summary>
/// The initial step. /// The initial step.
/// </summary> /// </summary>
@ -41,18 +46,16 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public static WorkflowDto FromWorkflow(Guid id, Workflow workflow, ApiController controller, string app) public static WorkflowDto FromWorkflow(Guid id, Workflow workflow, ApiController controller, string app)
{ {
var result = new WorkflowDto var result = SimpleMapper.Map(workflow, new WorkflowDto { Id = id });
{
Steps = workflow.Steps.ToDictionary( result.Steps = workflow.Steps.ToDictionary(
x => x.Key, x => x.Key,
x => SimpleMapper.Map(x.Value, new WorkflowStepDto x => SimpleMapper.Map(x.Value, new WorkflowStepDto
{ {
Transitions = x.Value.Transitions.ToDictionary( Transitions = x.Value.Transitions.ToDictionary(
y => y.Key, y => y.Key,
y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role }) y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role })
})), }));
Id = id, Name = workflow.Name, Initial = workflow.Initial
};
return result.CreateLinks(controller, app, id); return result.CreateLinks(controller, app, id);
} }

38
src/Squidex/app/features/settings/pages/workflows/schema-tag-converter.ts

@ -0,0 +1,38 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Converter, SchemaDto, TagValue } from '@app/shared';
export class SchemaTagConverter implements Converter {
public readonly suggestions: TagValue[];
constructor(
private readonly schemas: SchemaDto[]
) {
this.suggestions = schemas.map(x => new TagValue(x.id, x.name, x.id));
}
public convertInput(input: string): TagValue<any> | null {
const schema = this.schemas.find(x => x.name === input);
if (schema) {
return new TagValue(schema.id, schema.name, schema.id);
}
return null;
}
public convertValue(value: any): TagValue<any> | null {
const schema = this.schemas.find(x => x.id === value);
if (schema) {
return new TagValue(schema.id, schema.name, schema.id);
}
return null;
}
}

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

@ -36,7 +36,7 @@
<div class="form-group row"> <div class="form-group row">
<label class="col-form-label" for="{{workflow.id}}_name">Name</label> <label class="col-form-label" for="{{workflow.id}}_name">Name</label>
<div class="col-6"> <div class="col">
<input class="form-control" id="{{workflow.id}}_name" <input class="form-control" id="{{workflow.id}}_name"
[ngModelOptions]="onBlur" [ngModelOptions]="onBlur"
[ngModel]="workflow.name" [ngModel]="workflow.name"
@ -48,6 +48,18 @@
</div> </div>
</div> </div>
<div class="form-group row">
<label class="col-form-label" for="{{workflow.id}}_name">Schemas</label>
<div class="col">
<sqx-tag-editor [converter]="schemasSource" placeholder=", to add schema" [suggestedValues]="schemasSource.suggestions"></sqx-tag-editor>
<sqx-form-hint>
Restrict this workflow to specific schemas or keep it empty for all schemas.
</sqx-form-hint>
</div>
</div>
<sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep" <sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep"
[workflow]="workflow" [workflow]="workflow"
[disabled]="!workflow.canUpdate" [disabled]="!workflow.canUpdate"

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

@ -10,13 +10,11 @@
.col-form-label { .col-form-label {
min-width: 4rem; min-width: 4rem;
max-width: 4rem; max-width: 4rem;
text-align: left;
} }
.form-group { .form-group {
margin-bottom: 2rem; margin-bottom: 2rem;
margin-left: 2rem; margin-left: 2rem;
max-width: 60rem;
} }
.btn-success { .btn-success {

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

@ -19,6 +19,8 @@ import {
WorkflowTransitionValues WorkflowTransitionValues
} from '@app/shared'; } from '@app/shared';
import { SchemaTagConverter } from './schema-tag-converter';
@Component({ @Component({
selector: 'sqx-workflow', selector: 'sqx-workflow',
styleUrls: ['./workflow.component.scss'], styleUrls: ['./workflow.component.scss'],
@ -31,6 +33,9 @@ export class WorkflowComponent implements OnChanges {
@Input() @Input()
public roles: RoleDto[]; public roles: RoleDto[];
@Input()
public schemasSource: SchemaTagConverter;
public error: ErrorDto | null; public error: ErrorDto | null;
public onBlur = { updateOn: 'blur' }; public onBlur = { updateOn: 'blur' };

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

@ -14,14 +14,14 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<ng-container *ngIf="workflowsState.workflows | async; let workflows"> <ng-container *ngIf="schemasSource && workflowsState.workflows | async; let workflows">
<ng-container *ngIf="rolesState.roles | async; let roles"> <ng-container *ngIf="rolesState.roles | async; let roles">
<div class="table-items-row table-items-row-empty" *ngIf="workflows.length === 0"> <div class="table-items-row table-items-row-empty" *ngIf="workflows.length === 0">
No workflows created yet. No workflows created yet.
</div> </div>
<sqx-workflow *ngFor="let workflow of workflows; trackBy: trackByWorkflow" <sqx-workflow *ngFor="let workflow of workflows; trackBy: trackByWorkflow"
[workflow]="workflow" [roles]="roles"> [workflow]="workflow" [roles]="roles" [schemasSource]="schemasSource">
</sqx-workflow> </sqx-workflow>
<div class="table-items-footer" *ngIf="workflowsState.canCreate | async"> <div class="table-items-footer" *ngIf="workflowsState.canCreate | async">

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

@ -11,31 +11,45 @@ import { FormBuilder } from '@angular/forms';
import { import {
AddWorkflowForm, AddWorkflowForm,
AppsState, AppsState,
ResourceOwner,
RolesState, RolesState,
SchemasState,
WorkflowDto, WorkflowDto,
WorkflowsState WorkflowsState
} from '@app/shared'; } from '@app/shared';
import { SchemaTagConverter } from './schema-tag-converter';
@Component({ @Component({
selector: 'sqx-workflows-page', selector: 'sqx-workflows-page',
styleUrls: ['./workflows-page.component.scss'], styleUrls: ['./workflows-page.component.scss'],
templateUrl: './workflows-page.component.html' templateUrl: './workflows-page.component.html'
}) })
export class WorkflowsPageComponent implements OnInit { export class WorkflowsPageComponent extends ResourceOwner implements OnInit {
public addWorkflowForm = new AddWorkflowForm(this.formBuilder); public addWorkflowForm = new AddWorkflowForm(this.formBuilder);
public schemasSource: SchemaTagConverter;
constructor( constructor(
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly rolesState: RolesState, public readonly rolesState: RolesState,
public readonly schemasState: SchemasState,
public readonly workflowsState: WorkflowsState, public readonly workflowsState: WorkflowsState,
private readonly formBuilder: FormBuilder private readonly formBuilder: FormBuilder
) { ) {
super();
} }
public ngOnInit() { public ngOnInit() {
this.workflowsState.load(); this.own(this.schemasState.changes.subscribe(s => {
if (s.isLoaded) {
this.schemasSource = new SchemaTagConverter(s.schemas.values);
}
}));
this.rolesState.load(); this.rolesState.load();
this.schemasState.load();
this.workflowsState.load();
} }
public reload() { public reload() {

166
src/Squidex/app/framework/angular/forms/tag-editor.component.ts

@ -13,52 +13,103 @@ import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import { Keys, StatefulControlComponent, Types } from '@app/framework/internal'; import { Keys, StatefulControlComponent, Types } from '@app/framework/internal';
export const CONVERSION_FAILED = {};
export class TagValue<T = any> {
public readonly lowerCaseName: string;
constructor(
public readonly id: any,
public readonly name: string,
public readonly value: T
) {
this.lowerCaseName = name.toLowerCase();
}
public toString() {
return this.name;
}
}
export interface Converter { export interface Converter {
convert(input: string): any; convertInput(input: string): TagValue | null;
isValidInput(input: string): boolean; convertValue(value: any): TagValue | null;
isValidValue(value: any): boolean;
} }
export class IntConverter implements Converter { export class IntConverter implements Converter {
public isValidInput(input: string): boolean { private static ZERO = new TagValue(0, '0', 0);
return !!parseInt(input, 10) || input === '0';
} public convertInput(input: string): TagValue<number> | null {
if (input === '0') {
return IntConverter.ZERO;
}
const parsed = parseInt(input, 10);
if (parsed) {
return new TagValue(parsed, input, parsed);
}
public isValidValue(value: any): boolean { return null;
return Types.isNumber(value);
} }
public convert(input: string): any { public convertValue(value: any): TagValue<number> | null {
return parseInt(input, 10) || 0; if (Types.isNumber(value)) {
return new TagValue(value, `${value}`, value);
}
return null;
} }
} }
export class FloatConverter implements Converter { export class FloatConverter implements Converter {
public isValidInput(input: string): boolean { private static ZERO = new TagValue(0, '0', 0);
return !!parseFloat(input) || input === '0';
}
public isValidValue(value: any): boolean { public convertInput(input: string): TagValue<number> | null {
return Types.isNumber(value); if (input === '0') {
return FloatConverter.ZERO;
}
const parsed = parseFloat(input);
if (parsed) {
return new TagValue(parsed, input, parsed);
}
return null;
} }
public convert(input: string): any { public convertValue(value: any): TagValue<number> | null {
return parseFloat(input) || 0; if (Types.isNumber(value)) {
return new TagValue(value, `${value}`, value);
}
return null;
} }
} }
export class StringConverter implements Converter { export class StringConverter implements Converter {
public isValidInput(input: string): boolean { public convertInput(input: string): TagValue<string> | null {
return input.trim().length > 0; if (input) {
} const trimmed = input.trim();
public isValidValue(value: any): boolean { if (trimmed.length > 0) {
return Types.isString(value); return new TagValue(trimmed, trimmed, trimmed);
}
}
return null;
} }
public convert(input: string): any { public convertValue(value: any): TagValue<string> | null {
return input.trim(); if (Types.isString(value)) {
const trimmed = value.trim();
return new TagValue(trimmed, trimmed, trimmed);
}
return null;
} }
} }
@ -73,10 +124,10 @@ let CACHED_FONT: string;
interface State { interface State {
hasFocus: boolean; hasFocus: boolean;
suggestedItems: string[]; suggestedItems: TagValue[];
suggestedIndex: number; suggestedIndex: number;
items: any[]; items: TagValue[];
} }
@Component({ @Component({
@ -93,6 +144,9 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
@ViewChild('input', { static: false }) @ViewChild('input', { static: false })
public inputElement: ElementRef<HTMLInputElement>; public inputElement: ElementRef<HTMLInputElement>;
@Input()
public suggestedValues: TagValue[] = [];
@Input() @Input()
public converter: Converter = new StringConverter(); public converter: Converter = new StringConverter();
@ -105,9 +159,6 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
@Input() @Input()
public allowDuplicates = true; public allowDuplicates = true;
@Input()
public suggestions: string[] = [];
@Input() @Input()
public singleLine = false; public singleLine = false;
@ -120,6 +171,15 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
@Input() @Input()
public inputName = 'tag-editor'; public inputName = 'tag-editor';
@Input()
public set suggestions(value: string[]) {
if (value) {
this.suggestedValues = value.map(x => new TagValue(x, x, x));
} else {
this.suggestedValues = [];
}
}
@Input() @Input()
public set disabled(value: boolean) { public set disabled(value: boolean) {
this.setDisabledState(value); this.setDisabledState(value);
@ -161,8 +221,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
}), }),
distinctUntilChanged(), distinctUntilChanged(),
map(query => { map(query => {
if (Types.isArray(this.suggestions) && query && query.length > 0) { if (Types.isArray(this.suggestedValues) && query && query.length > 0) {
return this.suggestions.filter(s => s.toLowerCase().indexOf(query) >= 0 && this.snapshot.items.indexOf(s) < 0); return this.suggestedValues.filter(s => s.lowerCaseName.indexOf(query) >= 0 && !this.snapshot.items.find(x => x.id === s.id));
} else { } else {
return []; return [];
} }
@ -180,11 +240,23 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
this.resetForm(); this.resetForm();
this.resetSize(); this.resetSize();
if (this.converter && Types.isArrayOf(obj, v => this.converter.isValidValue(v))) { const items: any[] = [];
this.next(s => ({ ...s, items: obj }));
} else { if (this.converter && Types.isArray(obj)) {
this.next(s => ({ ...s, items: [] })); for (let value of obj) {
if (Types.is(value, TagValue)) {
items.push(value);
}
const converted = this.converter.convertValue(obj);
if (converted) {
items.push(value);
}
}
} }
this.next(s => ({ ...s, items }));
} }
public setDisabledState(isDisabled: boolean): void { public setDisabledState(isDisabled: boolean): void {
@ -296,16 +368,22 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
return true; return true;
} }
public selectValue(value: string, noFocus?: boolean) { public selectValue(value: TagValue | string, noFocus?: boolean) {
if (!noFocus) { if (!noFocus) {
this.inputElement.nativeElement.focus(); this.inputElement.nativeElement.focus();
} }
if (value && this.converter.isValidInput(value)) { let tagValue: TagValue | null;
const converted = this.converter.convert(value);
if (Types.isString(value)) {
tagValue = this.converter.convertInput(value);
} else {
tagValue = value;
}
if (this.allowDuplicates || this.snapshot.items.indexOf(converted) < 0) { if (tagValue) {
this.updateItems([...this.snapshot.items, converted]); if (this.allowDuplicates || !this.snapshot.items.find(x => x.id === tagValue!.id)) {
this.updateItems([...this.snapshot.items, tagValue]);
} }
this.resetForm(); this.resetForm();
@ -363,7 +441,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
public onCopy(event: ClipboardEvent) { public onCopy(event: ClipboardEvent) {
if (!this.hasSelection()) { if (!this.hasSelection()) {
if (event.clipboardData) { if (event.clipboardData) {
event.clipboardData.setData('text/plain', this.snapshot.items.filter(x => !!x).join(',')); event.clipboardData.setData('text/plain', this.snapshot.items.map(x => x.name).join(','));
} }
event.preventDefault(); event.preventDefault();
@ -380,7 +458,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
const values = [...this.snapshot.items]; const values = [...this.snapshot.items];
for (let part of value.split(',')) { for (let part of value.split(',')) {
const converted = this.converter.convert(part); const converted = this.converter.convertInput(part);
if (converted) { if (converted) {
values.push(converted); values.push(converted);
@ -401,13 +479,13 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
return s && e && (e - s) > 0; return s && e && (e - s) > 0;
} }
private updateItems(items: any[]) { private updateItems(items: TagValue[]) {
this.next(s => ({ ...s, items })); this.next(s => ({ ...s, items }));
if (items.length === 0 && this.undefinedWhenEmpty) { if (items.length === 0 && this.undefinedWhenEmpty) {
this.callChange(undefined); this.callChange(undefined);
} else { } else {
this.callChange(items); this.callChange(items.map(x => x.value));
} }
this.resetSize(); this.resetSize();

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

@ -156,7 +156,10 @@ describe('WorkflowsService', () => {
function workflowResponse(name: string) { function workflowResponse(name: string) {
return { return {
name: `name_${name}`, id: `id_${name}`, initial: `${name}1`, id: `id_${name}`,
name: `name_${name}`,
initial: `${name}1`,
schemaIds: [`schema_${name}`],
steps: { steps: {
[`${name}1`]: { [`${name}1`]: {
transitions: { transitions: {
@ -193,10 +196,14 @@ export function createWorkflows(...names: string[]): WorkflowsPayload {
} }
export function createWorkflow(name: string): WorkflowDto { export function createWorkflow(name: string): WorkflowDto {
return new WorkflowDto({ return new WorkflowDto(
{
update: { method: 'PUT', href: `/workflows/${name}` } update: { method: 'PUT', href: `/workflows/${name}` }
}, },
`id_${name}`, `name_${name}`, `${name}1`, `id_${name}`, `name_${name}`, `${name}1`,
[
`schema_${name}`
],
[ [
{ name: `${name}1`, color: `${name}1`, noUpdate: true, isLocked: false }, { name: `${name}1`, color: `${name}1`, noUpdate: true, isLocked: false },
{ name: `${name}2`, color: `${name}2`, noUpdate: true, isLocked: false } { name: `${name}2`, color: `${name}2`, noUpdate: true, isLocked: false }
@ -221,6 +228,7 @@ describe('Workflow', () => {
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
name: null, name: null,
schemaIds: [],
steps: { steps: {
'1': { transitions: {}, color: '#00ff00' } '1': { transitions: {}, color: '#00ff00' }
}, },
@ -236,6 +244,7 @@ describe('Workflow', () => {
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
name: null, name: null,
schemaIds: [],
steps: { steps: {
'1': { transitions: {}, color: 'red', noUpdate: true } '1': { transitions: {}, color: 'red', noUpdate: true }
}, },
@ -298,6 +307,7 @@ describe('Workflow', () => {
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
name: null, name: null,
schemaIds: [],
steps: { steps: {
'2': { '2': {
transitions: { transitions: {
@ -321,6 +331,7 @@ describe('Workflow', () => {
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
name: null, name: null,
schemaIds: [],
steps: { steps: {
'2': { transitions: {}, isLocked: true }, '2': { transitions: {}, isLocked: true },
'3': { transitions: {} } '3': { transitions: {} }
@ -335,7 +346,7 @@ describe('Workflow', () => {
.setStep('1') .setStep('1')
.removeStep('1'); .removeStep('1');
expect(workflow.serialize()).toEqual({ name: null, steps: {}, initial: null }); expect(workflow.serialize()).toEqual({ name: null, schemaIds: [], steps: {}, initial: null });
}); });
it('should rename step', () => { it('should rename step', () => {
@ -351,6 +362,7 @@ describe('Workflow', () => {
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
name: null, name: null,
schemaIds: [],
steps: { steps: {
'a': { 'a': {
transitions: { transitions: {
@ -381,6 +393,7 @@ describe('Workflow', () => {
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
name: null, name: null,
schemaIds: [],
steps: { steps: {
'1': { '1': {
transitions: { transitions: {
@ -408,6 +421,7 @@ describe('Workflow', () => {
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
name: null, name: null,
schemaIds: [],
steps: { steps: {
'1': { transitions: {}}, '1': { transitions: {}},
'2': { '2': {
@ -430,6 +444,7 @@ describe('Workflow', () => {
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
name: null, name: null,
schemaIds: [],
steps: { steps: {
'1': { transitions: {} }, '1': { transitions: {} },
'2': { '2': {
@ -498,6 +513,7 @@ describe('Workflow', () => {
expect(workflow.serialize()).toEqual({ expect(workflow.serialize()).toEqual({
name: null, name: null,
schemaIds: [],
steps: { steps: {
'1': { transitions: {} }, '1': { transitions: {} },
'2': { transitions: {} } '2': { transitions: {} }
@ -511,7 +527,14 @@ describe('Workflow', () => {
new WorkflowDto({}, 'id') new WorkflowDto({}, 'id')
.rename('name'); .rename('name');
expect(workflow.serialize()).toEqual({ name: 'name', steps: {}, initial: null }); expect(workflow.serialize()).toEqual({ name: 'name', schemaIds: [], steps: {}, initial: null });
}); });
it('should update schemaIds', () => {
const workflow =
new WorkflowDto({}, 'id')
.changeSchemaIds(['1', '2']);
expect(workflow.serialize()).toEqual({ name: null, schemaIds: ['1', '2'], steps: {}, initial: null });
});
}); });

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

@ -17,11 +17,11 @@ import {
hasAnyLink, hasAnyLink,
HTTP, HTTP,
mapVersioned, mapVersioned,
Model,
pretifyError, pretifyError,
Resource, Resource,
ResourceLinks, ResourceLinks,
StringHelper, StringHelper,
Types,
Version, Version,
Versioned Versioned
} from '@app/framework'; } from '@app/framework';
@ -33,7 +33,7 @@ export type WorkflowsPayload = {
readonly canCreate: boolean; readonly canCreate: boolean;
} & Resource; } & Resource;
export class WorkflowDto { export class WorkflowDto extends Model<WorkflowDto> {
public readonly _links: ResourceLinks; public readonly _links: ResourceLinks;
public readonly canUpdate: boolean; public readonly canUpdate: boolean;
@ -57,12 +57,13 @@ export class WorkflowDto {
public readonly id: string, public readonly id: string,
public readonly name: string | null = null, public readonly name: string | null = null,
public readonly initial: string | null = null, public readonly initial: string | null = null,
public readonly schemaIds: string[] = [],
public readonly steps: WorkflowStep[] = [], public readonly steps: WorkflowStep[] = [],
private readonly transitions: WorkflowTransition[] = [] public readonly transitions: WorkflowTransition[] = []
) { ) {
this.steps.sort((a, b) => compareStringsAsc(a.name, b.name)); super();
this.transitions.sort((a, b) => compareStringsAsc(a.to, b.to)); this.onCloned();
this._links = links; this._links = links;
@ -72,6 +73,12 @@ export class WorkflowDto {
this.displayName = StringHelper.firstNonEmpty(name, 'Unnamed Workflow'); this.displayName = StringHelper.firstNonEmpty(name, 'Unnamed Workflow');
} }
protected onCloned() {
this.steps.sort((a, b) => compareStringsAsc(a.name, b.name));
this.transitions.sort((a, b) => compareStringsAsc(a.to, b.to));
}
public getOpenSteps(step: WorkflowStep) { public getOpenSteps(step: WorkflowStep) {
return this.steps.filter(x => x.name !== step.name && !this.transitions.find(y => y.from === step.name && y.to === x.name)); return this.steps.filter(x => x.name !== step.name && !this.transitions.find(y => y.from === step.name && y.to === x.name));
} }
@ -105,7 +112,7 @@ export class WorkflowDto {
initial = steps[0].name; initial = steps[0].name;
} }
return this.createNew({ initial, steps }); return this.with({ initial, steps });
} }
public setInitial(initial: string) { public setInitial(initial: string) {
@ -115,7 +122,7 @@ export class WorkflowDto {
return this; return this;
} }
return this.createNew({ initial }); return this.with({ initial });
} }
public removeStep(name: string) { public removeStep(name: string) {
@ -138,11 +145,15 @@ export class WorkflowDto {
initial = first ? first.name : null; initial = first ? first.name : null;
} }
return this.createNew({ initial, steps, transitions }); return this.with({ initial, steps, transitions });
}
public changeSchemaIds(schemaIds: string[]) {
return this.with({ schemaIds });
} }
public rename(name: string) { public rename(name: string) {
return this.createNew({ name }); return this.with({ name });
} }
public renameStep(name: string, newName: string) { public renameStep(name: string, newName: string) {
@ -178,7 +189,7 @@ export class WorkflowDto {
initial = newName; initial = newName;
} }
return this.createNew({ initial, steps, transitions }); return this.with({ initial, steps, transitions });
} }
public removeTransition(from: string, to: string) { public removeTransition(from: string, to: string) {
@ -188,7 +199,7 @@ export class WorkflowDto {
return this; return this;
} }
return this.createNew({ transitions }); return this.with({ transitions });
} }
public setTransition(from: string, to: string, values: Partial<WorkflowTransitionValues> = {}) { public setTransition(from: string, to: string, values: Partial<WorkflowTransitionValues> = {}) {
@ -214,11 +225,11 @@ export class WorkflowDto {
const transitions = [...this.transitions.filter(t => t !== found), { from, to, ...values }]; const transitions = [...this.transitions.filter(t => t !== found), { from, to, ...values }];
return this.createNew({ transitions }); return this.with({ transitions });
} }
public serialize(): any { public serialize(): any {
const result = { steps: {}, initial: this.initial, name: this.name }; const result = { steps: {}, schemaIds: this.schemaIds, initial: this.initial, name: this.name };
for (let step of this.steps) { for (let step of this.steps) {
const { name, ...values } = step; const { name, ...values } = step;
@ -236,14 +247,6 @@ export class WorkflowDto {
return result; 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; }; export type WorkflowStepValues = { color?: string; isLocked?: boolean; noUpdate?: boolean; };
@ -333,6 +336,8 @@ function parseWorkflows(response: any) {
} }
function parseWorkflow(workflow: any) { function parseWorkflow(workflow: any) {
const { id, name, initial, schemaIds, _links } = workflow;
const steps: WorkflowStep[] = []; const steps: WorkflowStep[] = [];
const transitions: WorkflowTransition[] = []; const transitions: WorkflowTransition[] = [];
@ -352,5 +357,5 @@ function parseWorkflow(workflow: any) {
} }
} }
return new WorkflowDto(workflow._links, workflow.id, workflow.name, workflow.initial, steps, transitions); return new WorkflowDto(_links, id, name, initial, schemaIds, steps, transitions);
} }

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

@ -88,8 +88,6 @@ describe('WorkflowsState', () => {
workflowsState.add('my-workflow' ).subscribe(); workflowsState.add('my-workflow' ).subscribe();
expectNewWorkflows(updated); expectNewWorkflows(updated);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
}); });
it('should update workflows when workflow updated', () => { it('should update workflows when workflow updated', () => {
@ -103,6 +101,8 @@ describe('WorkflowsState', () => {
workflowsState.update(oldWorkflows.items[0]).subscribe(); workflowsState.update(oldWorkflows.items[0]).subscribe();
expectNewWorkflows(updated); expectNewWorkflows(updated);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
}); });
it('should update workflows when workflow deleted', () => { it('should update workflows when workflow deleted', () => {

4
tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowTests.cs

@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
public class WorkflowTests public class WorkflowTests
{ {
private readonly Workflow workflow = new Workflow( private readonly Workflow workflow = new Workflow(
new Dictionary<Status, WorkflowStep> Status.Draft, new Dictionary<Status, WorkflowStep>
{ {
[Status.Draft] = [Status.Draft] =
new WorkflowStep( new WorkflowStep(
@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Core.Model.Contents
new WorkflowStep(), new WorkflowStep(),
[Status.Published] = [Status.Published] =
new WorkflowStep() new WorkflowStep()
}, Status.Draft); });
[Fact] [Fact]
public void Should_provide_default_workflow_if_none_found() public void Should_provide_default_workflow_if_none_found()

24
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppWorkflowTests.cs

@ -69,11 +69,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
var command = new UpdateWorkflow var command = new UpdateWorkflow
{ {
Workflow = new Workflow( Workflow = new Workflow(
default,
new Dictionary<Status, WorkflowStep> new Dictionary<Status, WorkflowStep>
{ {
[Status.Published] = new WorkflowStep() [Status.Published] = new WorkflowStep()
}, }),
default),
WorkflowId = workflowId WorkflowId = workflowId
}; };
@ -87,11 +87,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
var command = new UpdateWorkflow var command = new UpdateWorkflow
{ {
Workflow = new Workflow( Workflow = new Workflow(
Status.Published,
new Dictionary<Status, WorkflowStep> new Dictionary<Status, WorkflowStep>
{ {
[Status.Published] = new WorkflowStep() [Status.Published] = new WorkflowStep()
}, }),
Status.Published),
WorkflowId = workflowId WorkflowId = workflowId
}; };
@ -105,11 +105,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
var command = new UpdateWorkflow var command = new UpdateWorkflow
{ {
Workflow = new Workflow( Workflow = new Workflow(
Status.Draft,
new Dictionary<Status, WorkflowStep> new Dictionary<Status, WorkflowStep>
{ {
[Status.Draft] = new WorkflowStep() [Status.Draft] = new WorkflowStep()
}, }),
Status.Draft),
WorkflowId = workflowId WorkflowId = workflowId
}; };
@ -123,12 +123,12 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
var command = new UpdateWorkflow var command = new UpdateWorkflow
{ {
Workflow = new Workflow( Workflow = new Workflow(
Status.Draft,
new Dictionary<Status, WorkflowStep> new Dictionary<Status, WorkflowStep>
{ {
[Status.Published] = null, [Status.Published] = null,
[Status.Draft] = new WorkflowStep() [Status.Draft] = new WorkflowStep()
}, }),
Status.Draft),
WorkflowId = workflowId WorkflowId = workflowId
}; };
@ -142,6 +142,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
var command = new UpdateWorkflow var command = new UpdateWorkflow
{ {
Workflow = new Workflow( Workflow = new Workflow(
Status.Draft,
new Dictionary<Status, WorkflowStep> new Dictionary<Status, WorkflowStep>
{ {
[Status.Published] = [Status.Published] =
@ -151,8 +152,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
[Status.Archived] = new WorkflowTransition() [Status.Archived] = new WorkflowTransition()
}), }),
[Status.Draft] = new WorkflowStep() [Status.Draft] = new WorkflowStep()
}, }),
Status.Draft),
WorkflowId = workflowId WorkflowId = workflowId
}; };
@ -166,6 +166,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
var command = new UpdateWorkflow var command = new UpdateWorkflow
{ {
Workflow = new Workflow( Workflow = new Workflow(
Status.Draft,
new Dictionary<Status, WorkflowStep> new Dictionary<Status, WorkflowStep>
{ {
[Status.Draft] = [Status.Draft] =
@ -176,8 +177,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
{ {
[Status.Draft] = null [Status.Draft] = null
}) })
}, }),
Status.Draft),
WorkflowId = workflowId WorkflowId = workflowId
}; };

4
tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs

@ -28,6 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly DynamicContentWorkflow sut; private readonly DynamicContentWorkflow sut;
private readonly Workflow workflow = new Workflow( private readonly Workflow workflow = new Workflow(
Status.Draft,
new Dictionary<Status, WorkflowStep> new Dictionary<Status, WorkflowStep>
{ {
[Status.Archived] = [Status.Archived] =
@ -53,8 +54,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Status.Draft] = new WorkflowTransition() [Status.Draft] = new WorkflowTransition()
}, },
StatusColors.Published) StatusColors.Published)
}, });
Status.Draft);
public DynamicContentWorkflowTests() public DynamicContentWorkflowTests()
{ {

37
tests/Squidex.Infrastructure.Tests/Json/Newtonsoft/ReadOnlyDictionaryTests.cs → tests/Squidex.Infrastructure.Tests/Json/Newtonsoft/ReadOnlyCollectionTests.cs

@ -11,17 +11,17 @@ using Xunit;
namespace Squidex.Infrastructure.Json.Newtonsoft namespace Squidex.Infrastructure.Json.Newtonsoft
{ {
public class ReadOnlyDictionaryTests public class ReadOnlyCollectionTests
{ {
public sealed class MyClass public sealed class MyClass<T>
{ {
public IReadOnlyDictionary<int, int> Values { get; set; } public T Values { get; set; }
} }
[Fact] [Fact]
public void Should_serialize_and_deserialize_without_type_name() public void Should_serialize_and_deserialize_dictionary_without_type_name()
{ {
var source = new MyClass var source = new MyClass<IReadOnlyDictionary<int, int>>
{ {
Values = new Dictionary<int, int> Values = new Dictionary<int, int>
{ {
@ -37,7 +37,32 @@ namespace Squidex.Infrastructure.Json.Newtonsoft
var json = JsonConvert.SerializeObject(source, serializerSettings); var json = JsonConvert.SerializeObject(source, serializerSettings);
var serialized = JsonConvert.DeserializeObject<MyClass>(json); var serialized = JsonConvert.DeserializeObject<MyClass<IReadOnlyDictionary<int, int>>>(json);
Assert.DoesNotContain("$type", json);
Assert.Equal(2, serialized.Values.Count);
}
[Fact]
public void Should_serialize_and_deserialize_list_without_type_name()
{
var source = new MyClass<IReadOnlyList<int>>
{
Values = new List<int>
{
2,
3
}
};
var serializerSettings = new JsonSerializerSettings
{
ContractResolver = new ConverterContractResolver()
};
var json = JsonConvert.SerializeObject(source, serializerSettings);
var serialized = JsonConvert.DeserializeObject<MyClass<IReadOnlyList<int>>>(json);
Assert.DoesNotContain("$type", json); Assert.DoesNotContain("$type", json);
Assert.Equal(2, serialized.Values.Count); Assert.Equal(2, serialized.Values.Count);
Loading…
Cancel
Save