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 @@
+
\ 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(