diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs index 2acb18d4d..c61a19654 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs @@ -30,6 +30,11 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] public string Name { get; set; } + /// + /// The name of the category. + /// + public string Category { get; set; } + /// /// Indicates if the schema is published. /// diff --git a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html b/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html index 6c26e907a..589002432 100644 --- a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html +++ b/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html @@ -16,11 +16,12 @@ - + + diff --git a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.ts b/src/Squidex/app/features/content/pages/schemas/schemas-page.component.ts index b85894f80..9070402df 100644 --- a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.ts +++ b/src/Squidex/app/features/content/pages/schemas/schemas-page.component.ts @@ -8,11 +8,7 @@ import { Component, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { - AppsState, - SchemaDto, - SchemasState -} from '@app/shared'; +import { AppsState, SchemasState } from '@app/shared'; @Component({ selector: 'sqx-schemas-page', @@ -21,20 +17,10 @@ import { }) export class SchemasPageComponent implements OnInit { public schemasFilter = new FormControl(); - public schemasFiltered = - this.schemasState.publishedSchemas - .combineLatest(this.schemasFilter.valueChanges.startWith(''), - (schemas, query) => { - if (query && query.length > 0) { - return schemas.filter(t => t.name.indexOf(query) >= 0); - } else { - return schemas; - } - }); constructor( public readonly appsState: AppsState, - private readonly schemasState: SchemasState + public readonly schemasState: SchemasState ) { } @@ -42,8 +28,8 @@ export class SchemasPageComponent implements OnInit { this.schemasState.load().onErrorResumeNext().subscribe(); } - public trackBySchema(index: number, schema: SchemaDto) { - return schema.id; + public trackByCategory(index: number, category: string) { + return category; } } diff --git a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html index 1a8305bcc..96c98a83b 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html +++ b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html @@ -21,27 +21,16 @@ - + + + +
+ +
diff --git a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.scss b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.scss index 15f173c4f..5a29b59c7 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.scss +++ b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.scss @@ -15,35 +15,16 @@ $button-size: calc(2.5rem - 2px); line-height: 2.5rem; } -.nav-link { - padding-top: .75rem; - padding-bottom: .75rem; -} - -.schema { - &-name { - @include truncate; - color: $color-dark-foreground; - } - - &-modified { - text-align: right; - width: auto; - white-space: nowrap; +.new-category-input { + & { + margin-top: 1rem; + background: 0; + border-width: 0; + border-bottom-width: 1px; padding-left: 0; } - &-user { - @include border-radius(1px); - @include truncate; - display: inline-block; - background: $color-dark2-control; - padding: .1rem .25rem; - font-size: .8rem; - font-weight: normal; - margin-left: 10px; - margin-bottom: 2px; - max-width: 100%; - vertical-align: middle; + &:focus { + @include box-shadow-none; } } \ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts index ab500d487..b381ea8ab 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts +++ b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts @@ -6,12 +6,13 @@ */ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { FormControl } from '@angular/forms'; +import { FormBuilder, FormControl } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { AppsState, + CreateCategoryForm, MessageBus, ModalView, SchemaDto, @@ -29,27 +30,19 @@ export class SchemasPageComponent implements OnDestroy, OnInit { private schemaCloningSubscription: Subscription; public addSchemaDialog = new ModalView(); + public addCategoryForm = new CreateCategoryForm(this.formBuilder); public schemasFilter = new FormControl(); - public schemasFiltered = - this.schemasState.schemas - .combineLatest(this.schemasFilter.valueChanges.startWith(''), - (schemas, query) => { - if (query && query.length > 0) { - return schemas.filter(t => t.name.indexOf(query) >= 0); - } else { - return schemas; - } - }); public import: any; constructor( public readonly appsState: AppsState, + public readonly schemasState: SchemasState, + private readonly formBuilder: FormBuilder, private readonly messageBus: MessageBus, private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly schemasState: SchemasState + private readonly router: Router ) { } @@ -76,6 +69,22 @@ export class SchemasPageComponent implements OnDestroy, OnInit { this.schemasState.load().onErrorResumeNext().subscribe(); } + public removeCategory(name: string) { + this.schemasState.removeCategory(name); + } + + public addCategory() { + const value = this.addCategoryForm.submit(); + + if (value) { + try { + this.schemasState.addCategory(value.name); + } finally { + this.addCategoryForm.submitCompleted({}); + } + } + } + public onSchemaCreated(schema: SchemaDto) { this.router.navigate([schema.name], { relativeTo: this.route }); @@ -88,8 +97,8 @@ export class SchemasPageComponent implements OnDestroy, OnInit { this.addSchemaDialog.show(); } - public trackBySchema(index: number, schema: SchemaDto) { - return schema.id; + public trackByCategory(index: number, category: string) { + return category; } } diff --git a/src/Squidex/app/shared/components/schema-category.component.html b/src/Squidex/app/shared/components/schema-category.component.html new file mode 100644 index 000000000..6c989e85b --- /dev/null +++ b/src/Squidex/app/shared/components/schema-category.component.html @@ -0,0 +1,40 @@ +
+
+ +
+ + + + +

{{displayName}} ({{schemasForCategory.length}})

+ + + + + +
+ + +
\ No newline at end of file diff --git a/src/Squidex/app/shared/components/schema-category.component.scss b/src/Squidex/app/shared/components/schema-category.component.scss new file mode 100644 index 000000000..320cc3303 --- /dev/null +++ b/src/Squidex/app/shared/components/schema-category.component.scss @@ -0,0 +1,85 @@ +@import '_mixins'; +@import '_vars'; + +$drag-margin: -8px; + +h3 { + display: inline-block; +} + +.btn { + width: 2rem; +} + +.category { + margin-bottom: 1rem; +} + +.dnd-drag-start { + border: 0; +} + +.droppable { + & { + position: relative; + } + + &.dnd-drag-over, + &.dnd-drag-enter { + & { + border: 0; + } + + .drop-indicator { + display: block; + } + } + + .drop-indicator { + @include absolute($drag-margin, $drag-margin, $drag-margin, $drag-margin); + border: 2px dashed $color-dark-black; + background: none; + display: none; + pointer-events: none; + } +} + +.header { + margin-left: -1rem; +} + +.nav-link { + padding-top: .75rem; + padding-bottom: .75rem; +} + +.schema { + &-name { + @include truncate; + } + + &-name-accent { + color: $color-dark-foreground; + } + + &-modified { + text-align: right; + width: auto; + white-space: nowrap; + padding-left: 0; + } + + &-user { + @include border-radius(1px); + @include truncate; + display: inline-block; + background: $color-dark2-control; + padding: .1rem .25rem; + font-size: .8rem; + font-weight: normal; + margin-left: 10px; + margin-bottom: 2px; + max-width: 100%; + vertical-align: middle; + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/components/schema-category.component.ts b/src/Squidex/app/shared/components/schema-category.component.ts new file mode 100644 index 000000000..2a6c260e6 --- /dev/null +++ b/src/Squidex/app/shared/components/schema-category.component.ts @@ -0,0 +1,99 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; + +import { + fadeAnimation, + ImmutableArray, + LocalStoreService, + SchemaDetailsDto, + SchemaDto, + SchemasState, + Types +} from '@app/shared/internal'; + +@Component({ + selector: 'sqx-schema-category', + styleUrls: ['./schema-category.component.scss'], + templateUrl: './schema-category.component.html', + animations: [ + fadeAnimation + ] +}) +export class SchemaCategoryComponent implements OnInit, OnChanges { + @Output() + public removing = new EventEmitter(); + + @Input() + public name: string; + + @Input() + public isReadonly: boolean; + + @Input() + public schemasFilter: string; + + @Input() + public schemas: ImmutableArray; + + public displayName: string; + + public schemasFiltered: ImmutableArray; + public schemasForCategory: ImmutableArray; + + public isOpen = true; + + public allowDrop = (schema: any) => { + return (Types.is(schema, SchemaDto) || Types.is(schema, SchemaDetailsDto)) && !this.isSameCategory(schema); + } + + constructor( + private readonly localStore: LocalStoreService, + private readonly schemasState: SchemasState + ) { + } + + public ngOnInit() { + this.isOpen = this.localStore.get(`schema-category.${name}`) !== 'false'; + } + + public toggle() { + this.isOpen = !this.isOpen; + + this.localStore.set(`schema-category.${name}`, this.isOpen + ''); + } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes['schemas'] || changes['schemasFilter']) { + const query = this.schemasFilter; + + this.schemasForCategory = this.schemas.filter(x => this.isSameCategory(x)); + this.schemasFiltered = this.schemasForCategory.filter(x => !query || x.name.indexOf(query) >= 0); + } + + if (changes['name']) { + if (!this.name || this.name.length === 0) { + this.displayName = 'All Schemas'; + } else { + this.displayName = this.name; + } + } + } + + private isSameCategory(schema: SchemaDto): boolean { + return (!this.name && !schema.category) || schema.category === this.name; + } + + public changeCategory(schema: SchemaDto) { + this.schemasState.changeCategory(schema, this.name).onErrorResumeNext().subscribe(); + } + + public trackBySchema(index: number, schema: SchemaDto) { + return schema.id; + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/declarations.ts b/src/Squidex/app/shared/declarations.ts index 7c157fdda..63d282884 100644 --- a/src/Squidex/app/shared/declarations.ts +++ b/src/Squidex/app/shared/declarations.ts @@ -17,5 +17,6 @@ export * from './components/language-selector.component'; export * from './components/markdown-editor.component'; export * from './components/pipes'; export * from './components/rich-editor.component'; +export * from './components/schema-category.component'; export * from './internal'; \ No newline at end of file diff --git a/src/Squidex/app/shared/module.ts b/src/Squidex/app/shared/module.ts index 18abf4ea0..a32ac6428 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -61,6 +61,7 @@ import { RuleEventsState, RulesService, RulesState, + SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemasService, @@ -99,6 +100,7 @@ import { HistoryListComponent, LanguageSelectorComponent, MarkdownEditorComponent, + SchemaCategoryComponent, UserDtoPicture, UserIdPicturePipe, UserNamePipe, @@ -122,6 +124,7 @@ import { LanguageSelectorComponent, MarkdownEditorComponent, RouterModule, + SchemaCategoryComponent, UserDtoPicture, UserIdPicturePipe, UserNamePipe, diff --git a/src/Squidex/app/shared/services/schemas.service.ts b/src/Squidex/app/shared/services/schemas.service.ts index a5bbeaeec..2b56c4a6b 100644 --- a/src/Squidex/app/shared/services/schemas.service.ts +++ b/src/Squidex/app/shared/services/schemas.service.ts @@ -603,7 +603,7 @@ export class UpdateSchemaDto { export class UpdateSchemaCategoryDto { constructor( - public readonly category?: string + public readonly name?: string ) { } } diff --git a/src/Squidex/app/shared/state/schemas.state.spec.ts b/src/Squidex/app/shared/state/schemas.state.spec.ts index 1a8df79c6..05abe8a8f 100644 --- a/src/Squidex/app/shared/state/schemas.state.spec.ts +++ b/src/Squidex/app/shared/state/schemas.state.spec.ts @@ -91,6 +91,18 @@ describe('SchemasState', () => { it('should load schemas', () => { expect(schemasState.snapshot.schemas.values).toEqual(oldSchemas); expect(schemasState.snapshot.isLoaded).toBeTruthy(); + expect(schemasState.snapshot.categories).toEqual({ 'category1': false, 'category2': false }); + + schemasService.verifyAll(); + }); + + it('should not remove custom category when loading schemas', () => { + schemasState.addCategory('category3'); + schemasState.load(true).subscribe(); + + expect(schemasState.snapshot.schemas.values).toEqual(oldSchemas); + expect(schemasState.snapshot.isLoaded).toBeTruthy(); + expect(schemasState.snapshot.categories).toEqual({ 'category1': false, 'category2': false, 'category3': true }); schemasService.verifyAll(); }); @@ -101,6 +113,18 @@ describe('SchemasState', () => { dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); }); + it('should add category', () => { + schemasState.addCategory('category3'); + + expect(schemasState.snapshot.categories).toEqual({ 'category1': false, 'category2': false, 'category3': true }); + }); + + it('should remove category', () => { + schemasState.removeCategory('category1'); + + expect(schemasState.snapshot.categories).toEqual({ 'category2': false }); + }); + it('should return schema on select and reload when already loaded', () => { schemasState.select('name2').subscribe(); schemasState.select('name2').subscribe(); @@ -174,7 +198,7 @@ describe('SchemasState', () => { it('should change category and update user info when category changed', () => { const category = 'my-new-category'; - schemasService.setup(x => x.putCategory(app, oldSchemas[0].name, It.is(i => i.category === category), version)) + schemasService.setup(x => x.putCategory(app, oldSchemas[0].name, It.is(i => i.name === category), version)) .returns(() => Observable.of(new Versioned(newVersion, {}))); schemasState.changeCategory(oldSchemas[0], category, modified).subscribe(); @@ -206,7 +230,7 @@ describe('SchemasState', () => { it('should change category and update user info when category of selected schema changed', () => { const category = 'my-new-category'; - schemasService.setup(x => x.putCategory(app, oldSchemas[0].name, It.is(i => i.category === category), version)) + schemasService.setup(x => x.putCategory(app, oldSchemas[0].name, It.is(i => i.name === category), version)) .returns(() => Observable.of(new Versioned(newVersion, {}))); schemasState.changeCategory(oldSchemas[0], category, modified).subscribe(); diff --git a/src/Squidex/app/shared/state/schemas.state.ts b/src/Squidex/app/shared/state/schemas.state.ts index bdef4175a..3d4375596 100644 --- a/src/Squidex/app/shared/state/schemas.state.ts +++ b/src/Squidex/app/shared/state/schemas.state.ts @@ -43,6 +43,14 @@ import { const FALLBACK_NAME = 'my-schema'; +export class CreateCategoryForm extends Form { + constructor(formBuilder: FormBuilder) { + super(formBuilder.group({ + name: [''] + })); + } +} + export class CreateSchemaForm extends Form { public schemaName = this.form.controls['name'].valueChanges.map(n => n || FALLBACK_NAME) @@ -150,6 +158,8 @@ export class AddFieldForm extends Form { } interface Snapshot { + categories: { [name: string]: boolean }; + schemasApp?: string; schemas: ImmutableArray; @@ -164,6 +174,10 @@ export class SchemasState extends State { this.changes.map(x => x.selectedSchema) .distinctUntilChanged(); + public categories = + this.changes.map(x => ImmutableArray.of(Object.keys(x.categories)).sortByStringAsc(s => s)) + .distinctUntilChanged(); + public schemas = this.changes.map(x => x.schemas) .distinctUntilChanged(); @@ -186,7 +200,7 @@ export class SchemasState extends State { private readonly dialogs: DialogService, private readonly schemasService: SchemasService ) { - super({ schemas: ImmutableArray.of() }); + super({ schemas: ImmutableArray.empty(), categories: {} }); } public select(idOrName: string | null): Observable { @@ -220,7 +234,9 @@ export class SchemasState extends State { return this.next(s => { const schemas = ImmutableArray.of(dtos).sortByStringAsc(x => x.displayName); - return { ...s, schemas, schemasApp: this.appName, isLoaded: true }; + const categories = buildCategories(s.categories, schemas); + + return { ...s, schemas, schemasApp: this.appName, isLoaded: true, categories }; }); }) .notify(this.dialogs); @@ -250,6 +266,22 @@ export class SchemasState extends State { .notify(this.dialogs); } + public addCategory(name: string) { + this.next(s => { + const categories = addCategory(s.categories, name); + + return { ...s, categories: categories }; + }); + } + + public removeCategory(name: string) { + this.next(s => { + const categories = removeCategory(s.categories, name); + + return { ...s, categories: categories }; + }); + } + public addField(schema: SchemaDetailsDto, request: AddFieldDto, now?: DateTime): Observable { return this.schemasService.postField(this.appName, schema.name, request, schema.version) .do(dto => { @@ -366,7 +398,9 @@ export class SchemasState extends State { const schemas = s.schemas.replaceBy('id', schema).sortByStringAsc(x => x.displayName); const selectedSchema = s.selectedSchema && s.selectedSchema.id === schema.id ? schema : s.selectedSchema; - return { ...s, schemas, selectedSchema }; + const categories = buildCategories(s.categories, schemas); + + return { ...s, schemas, selectedSchema, categories }; }); } @@ -379,6 +413,37 @@ export class SchemasState extends State { } } +function buildCategories(categories: { [name: string]: boolean }, schemas: ImmutableArray) { + categories = { ...categories }; + + for (let category in categories) { + if (!categories[category]) { + delete categories[category]; + } + } + for (let schema of schemas.values) { + categories[schema.category || ''] = false; + } + + return categories; +} + +function addCategory(categories: { [name: string]: boolean }, category: string) { + categories = { ...categories }; + + categories[category] = true; + + return categories; +} + +function removeCategory(categories: { [name: string]: boolean }, category: string) { + categories = { ...categories }; + + delete categories[category]; + + return categories; +} + const setPublished = (schema: SchemaDto | SchemaDetailsDto, publish: boolean, user: string, version: Version, now?: DateTime) => { if (Types.is(schema, SchemaDetailsDto)) { return new SchemaDetailsDto(