Browse Source

Nested Schema Categories for new Schema State (#784)

* Added nested categories minus proper filtering.

* Added nested schema filtering counts.

* Can now add nested categories from the UI form.

* Moved nested category separator to a const.

* Fixed schema name overflow in nested categories.

* Some new tests for nested categories and a fix.

Co-authored-by: Simon Pain <simon.pain@reedbusiness.com>
pull/786/head
Simon Pain 4 years ago
committed by GitHub
parent
commit
076d8aae72
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      frontend/app/features/content/pages/schemas/schemas-page.component.ts
  2. 2
      frontend/app/features/schemas/pages/schemas/schemas-page.component.html
  3. 4
      frontend/app/features/schemas/pages/schemas/schemas-page.component.scss
  4. 2
      frontend/app/features/schemas/pages/schemas/schemas-page.component.ts
  5. 124
      frontend/app/shared/components/schema-category.component.html
  6. 12
      frontend/app/shared/components/schema-category.component.scss
  7. 14
      frontend/app/shared/components/schema-category.component.ts
  8. 7
      frontend/app/shared/services/schemas.service.spec.ts
  9. 70
      frontend/app/shared/state/schemas.state.spec.ts
  10. 98
      frontend/app/shared/state/schemas.state.ts

2
frontend/app/features/content/pages/schemas/schemas-page.component.ts

@ -36,7 +36,7 @@ export class SchemasPageComponent {
combineLatest([
value$(this.schemasFilter),
this.schemas,
this.schemasState.categoryNames,
this.schemasState.addedCategories,
], (filter, schemas, categories) => {
return getCategoryTree(schemas, categories, filter);
});

2
frontend/app/features/schemas/pages/schemas/schemas-page.component.html

@ -22,7 +22,7 @@
<ng-container>
<div cdkDropListGroup>
<sqx-schema-category *ngFor="let category of categories | async; trackBy: trackByCategory"
[schemaCategory]="category" (remove)="removeCategory(category.displayName)">
[schemaCategory]="category" (remove)="removeCategory($event)">
</sqx-schema-category>
</div>

4
frontend/app/features/schemas/pages/schemas/schemas-page.component.scss

@ -0,0 +1,4 @@
::ng-deep sqx-schemas-page .panel2-main-inner.padded {
padding: .5rem;
padding-left: 0;
}

2
frontend/app/features/schemas/pages/schemas/schemas-page.component.ts

@ -28,7 +28,7 @@ export class SchemasPageComponent extends ResourceOwner implements OnInit {
combineLatest([
value$(this.schemasFilter),
this.schemasState.schemas,
this.schemasState.categoryNames,
this.schemasState.addedCategories,
], (filter, schemas, categories) => {
return getCategoryTree(schemas, categories, filter);
});

124
frontend/app/shared/components/schema-category.component.html

@ -1,62 +1,74 @@
<ul *ngIf="!forContent || schemas.length > 0" class="nav nav-light flex-column droppable"
cdkDropList
cdkDropListSortingDisabled
[cdkDropListData]="schemaCategory.name"
(cdkDropListDropped)="changeCategory($event)">
<li class="nav-item nav-heading">
<div class="row g-0 align-items-center mb-1">
<div class="col-auto">
<button type="button" class="btn btn-sm btn-decent btn-text-secondary btn-toggle" (click)="toggle()">
<i [class.icon-caret-right]="isCollapsed" [class.icon-caret-down]="!isCollapsed"></i>
</button>
</div>
<div class="col">
<div class="truncate">
{{schemaCategory.displayName | sqxTranslate}}
</div>
</div>
<div class="col-auto">
<ng-container *ngIf="schemas.length > 0; else noSchemas">
<span class="badge rounded-pill badge-secondary">{{schemas.length}}</span>
</ng-container>
<ng-template #noSchemas>
<button type="button" class="btn btn-sm btn-text-secondary btn-remove" (click)="remove.emit()" *ngIf="schemaCategory.name">
<i class="icon-bin2"></i>
<ul [hidden]="forContent && visibleCount == 0" id="cat_{{schemaCategory.name}}" class="nav nav-light flex-column">
<div class="droppable category"
cdkDropList
cdkDropListSortingDisabled
[cdkDropListData]="schemaCategory.name"
(cdkDropListDropped)="changeCategory($event)">
<li class="nav-item nav-heading">
<div class="row g-0 align-items-center mb-1">
<div class="col-auto">
<button type="button" class="btn btn-sm btn-decent btn-text-secondary btn-toggle" (click)="toggle()">
<i [class.icon-caret-right]="isCollapsed" [class.icon-caret-down]="!isCollapsed"></i>
</button>
</ng-template>
</div>
<div class="col">
<div class="truncate">
{{schemaCategory.displayName | sqxTranslate}}
</div>
</div>
<div class="col-auto">
<ng-container *ngIf="visibleCount > 0; else noSchemas">
<span class="badge rounded-pill badge-secondary">{{visibleCount}}</span>
</ng-container>
<ng-template #noSchemas>
<button type="button" class="btn btn-sm btn-text-secondary btn-remove" (click)="remove.emit(schemaCategory.name)" *ngIf="schemaCategory.name">
<i class="icon-bin2"></i>
</button>
</ng-template>
</div>
</div>
</li>
<div [hidden]="isCollapsed" @fade [style.height]="getContainerHeight()" class="nav-collapsed">
<ng-container *ngIf="!forContent; else simpleMode">
<li *ngFor="let schema of schemas; trackBy: trackBySchema" class="nav-item truncate" [style.height]="getItemHeight()"
cdkDrag
[cdkDragData]="schema"
(cdkDragStarted)="dragStarted($event)">
<a class="nav-link truncate" [routerLink]="schemaRoute(schema)" routerLinkActive="active" sqxStopDrag
title="{{schema.displayName}}"
titlePosition="top-left">
<i cdkDragHandle class="icon-drag2 drag-handle"></i>
<span class="item-published me-1" [class.unpublished]="!schema.isPublished" id="schema_{{schema.name}}"></span> {{schema.displayName}}
</a>
</li>
</ng-container>
<ng-template #simpleMode>
<li *ngFor="let schema of schemas; trackBy: trackBySchema" class="nav-item truncate">
<a class="nav-link truncate drag-none" [routerLink]="schemaRoute(schema)" routerLinkActive="active" sqxStopDrag
title="{{schema.displayName}}"
titlePosition="top-left"
id="schema_{{schema.name}}">
{{schema.displayName}}
</a>
</li>
</ng-template>
</div>
</li>
<div *ngIf="!isCollapsed" @fade [style.height]="getContainerHeight()" class="nav-collapsed">
<ng-container *ngIf="!forContent; else simpleMode">
<li *ngFor="let schema of schemas; trackBy: trackBySchema" class="nav-item truncate" [style.height]="getItemHeight()"
cdkDrag
cdkDragLockAxis="y"
[cdkDragData]="schema"
(cdkDragStarted)="dragStarted($event)">
<a class="nav-link truncate" [routerLink]="schemaRoute(schema)" routerLinkActive="active" sqxStopDrag
title="{{schema.displayName}}"
titlePosition="top-left">
<i cdkDragHandle class="icon-drag2 drag-handle"></i>
<span class="item-published me-1" [class.unpublished]="!schema.isPublished"></span> {{schema.displayName}}
</a>
</li>
</ng-container>
<ng-template #simpleMode>
<li *ngFor="let schema of schemas; trackBy: trackBySchema" class="nav-item truncate">
<a class="nav-link truncate drag-none" [routerLink]="schemaRoute(schema)" routerLinkActive="active" sqxStopDrag
title="{{schema.displayName}}"
titlePosition="top-left">
{{schema.displayName}}
</a>
</li>
</ng-template>
<div class="drop-indicator"></div>
</div>
<div class="drop-indicator"></div>
<div class="nav nav-panel nav-dark nav-dark-bordered flex-column" [hidden]="isCollapsed" @fade>
<sqx-schema-category *ngFor="let category of schemaCategory.categories; trackBy: trackByCategory"
[schemaCategory]="category"
[forContent]="forContent"
(remove)="remove.emit($event)">
</sqx-schema-category>
</div>
</ul>

12
frontend/app/shared/components/schema-category.component.scss

@ -84,4 +84,16 @@ $drag-margin: -8px;
.item-published {
margin-bottom: 1px;
}
ul.nav-light {
margin-left: 1rem;
margin-right: 0;
font-size: 100%;
}
.category,
.nav-panel,
sqx-schema-category {
max-width: 100%;
}

14
frontend/app/shared/components/schema-category.component.ts

@ -22,7 +22,7 @@ const ITEM_HEIGHT = 2.5;
})
export class SchemaCategoryComponent implements OnChanges {
@Output()
public remove = new EventEmitter();
public remove = new EventEmitter<string>();
@Input()
public schemaCategory: SchemaCategory;
@ -32,6 +32,8 @@ export class SchemaCategoryComponent implements OnChanges {
public isCollapsed = false;
public visibleCount = 0;
public get schemas() {
return this.schemaCategory.schemas;
}
@ -49,6 +51,7 @@ export class SchemaCategoryComponent implements OnChanges {
}
public ngOnChanges() {
this.visibleCount = this.getCount(this.schemaCategory);
if (this.schemaCategory.schemas.length < this.schemaCategory.schemaTotalCount) {
this.isCollapsed = false;
} else {
@ -56,6 +59,11 @@ export class SchemaCategoryComponent implements OnChanges {
}
}
private getCount(category: SchemaCategory): number {
const childCount = category.categories.reduce((total, child) => total + this.getCount(child), 0);
return childCount + category.schemas.length;
}
public schemaRoute(schema: SchemaDto) {
if (schema.type === 'Singleton' && this.forContent) {
return [schema.name, schema.id, 'history'];
@ -93,6 +101,10 @@ export class SchemaCategoryComponent implements OnChanges {
return schema.id;
}
public trackByCategory(_index: number, category: SchemaCategory) {
return category.name;
}
private configKey(): string {
return `squidex.schema.category.${this.schemaCategory.name}.collapsed`;
}

7
frontend/app/shared/services/schemas.service.spec.ts

@ -818,12 +818,15 @@ function createSchemaProperties(id: number, suffix = '') {
);
}
export function createSchema(id: number, suffix = '') {
export function createSchema(id: number, suffix = '', category = '') {
const links: ResourceLinks = {
update: { method: 'PUT', href: `/schemas/${id}` },
};
const key = `${id}${suffix}`;
if (category === '') {
category = `schema-category${key}`;
}
return new SchemaDto(links,
`id${id}`,
@ -831,7 +834,7 @@ export function createSchema(id: number, suffix = '') {
DateTime.parseISO(`${id % 1000 + 2000}-11-11T10:10:00Z`), `modifier${id}`,
new Version(key),
`schema-name${key}`,
`schema-category${key}`,
category,
id % 2 === 0 ? 'Default' : 'Singleton',
id % 3 === 0,
createSchemaProperties(id, suffix),

70
frontend/app/shared/state/schemas.state.spec.ts

@ -131,14 +131,14 @@ describe('SchemasState', () => {
it('should add category', () => {
schemasState.addCategory('schema-category3');
expect([...schemasState.snapshot.categories]).toEqual(['schema-category3']);
expect([...schemasState.snapshot.addedCategories]).toEqual(['schema-category3']);
});
it('should remove category', () => {
schemasState.addCategory('schema-category3');
schemasState.removeCategory('schema-category3');
expect([...schemasState.snapshot.categories]).toEqual([]);
expect([...schemasState.snapshot.addedCategories]).toEqual([]);
});
it('should return schema on select and reload if already loaded', () => {
@ -498,10 +498,10 @@ describe('SchemasState', () => {
const result = getCategoryTree([schema1, schema2], new Set<string>());
expect(result).toEqual([
{ displayName: 'i18n:common.components', schemas: [], schemaTotalCount: 0 },
{ displayName: 'i18n:common.schemas', schemas: [], schemaTotalCount: 0 },
{ displayName: 'schema-category1', name: 'schema-category1', schemas: [schema1], schemaTotalCount: 1 },
{ displayName: 'schema-category2', name: 'schema-category2', schemas: [schema2], schemaTotalCount: 1 },
{ displayName: 'i18n:common.components', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'i18n:common.schemas', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'schema-category1', name: 'schema-category1', schemas: [schema1], schemaTotalCount: 1, categories: [] },
{ displayName: 'schema-category2', name: 'schema-category2', schemas: [schema2], schemaTotalCount: 1, categories: [] },
]);
});
@ -509,11 +509,11 @@ describe('SchemasState', () => {
const result = getCategoryTree([schema1, schema2], new Set<string>(['schema-category3']));
expect(result).toEqual([
{ displayName: 'i18n:common.components', schemas: [], schemaTotalCount: 0 },
{ displayName: 'i18n:common.schemas', schemas: [], schemaTotalCount: 0 },
{ displayName: 'schema-category1', name: 'schema-category1', schemas: [schema1], schemaTotalCount: 1 },
{ displayName: 'schema-category2', name: 'schema-category2', schemas: [schema2], schemaTotalCount: 1 },
{ displayName: 'schema-category3', name: 'schema-category3', schemas: [], schemaTotalCount: 0 },
{ displayName: 'i18n:common.components', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'i18n:common.schemas', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'schema-category1', name: 'schema-category1', schemas: [schema1], schemaTotalCount: 1, categories: [] },
{ displayName: 'schema-category2', name: 'schema-category2', schemas: [schema2], schemaTotalCount: 1, categories: [] },
{ displayName: 'schema-category3', name: 'schema-category3', schemas: [], schemaTotalCount: 0, categories: [] },
]);
});
@ -521,11 +521,51 @@ describe('SchemasState', () => {
const result = getCategoryTree([schema1, schema2], new Set<string>(), '1');
expect(result).toEqual([
{ displayName: 'i18n:common.components', schemas: [], schemaTotalCount: 0 },
{ displayName: 'i18n:common.schemas', schemas: [], schemaTotalCount: 0 },
{ displayName: 'schema-category1', name: 'schema-category1', schemas: [schema1], schemaTotalCount: 1 },
{ displayName: 'schema-category2', name: 'schema-category2', schemas: [], schemaTotalCount: 1 }, // Filtered out
{ displayName: 'i18n:common.components', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'i18n:common.schemas', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'schema-category1', name: 'schema-category1', schemas: [schema1], schemaTotalCount: 1, categories: [] },
{ displayName: 'schema-category2', name: 'schema-category2', schemas: [], schemaTotalCount: 1, categories: [] }, // Filtered out
]);
});
it('should be build from schemas with nested categories', () => {
const schema3 = createSchema(3, '', 'A');
const schema4 = createSchema(4, '', 'A/B');
const result = getCategoryTree([schema1, schema2, schema3, schema4], new Set<string>());
expect(result).toEqual([
{ displayName: 'i18n:common.components', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'i18n:common.schemas', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'A', name: 'A', schemas: [schema3], schemaTotalCount: 2, categories: [{ displayName: 'B', name: 'A/B', schemas: [schema4], schemaTotalCount: 1, categories: [] }] },
{ displayName: 'schema-category1', name: 'schema-category1', schemas: [schema1], schemaTotalCount: 1, categories: [] },
{ displayName: 'schema-category2', name: 'schema-category2', schemas: [schema2], schemaTotalCount: 1, categories: [] },
]);
});
it('should be build from schemas and custom name with nested categories', () => {
const result = getCategoryTree([schema1, schema2], new Set<string>(['A/B']));
expect(result).toEqual([
{ displayName: 'i18n:common.components', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'i18n:common.schemas', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'A', name: 'A', schemas: [], schemaTotalCount: 0, categories: [{ displayName: 'B', name: 'A/B', schemas: [], schemaTotalCount: 0, categories: [] }] },
{ displayName: 'schema-category1', name: 'schema-category1', schemas: [schema1], schemaTotalCount: 1, categories: [] },
{ displayName: 'schema-category2', name: 'schema-category2', schemas: [schema2], schemaTotalCount: 1, categories: [] },
]);
});
});
it('should be build from schemas with nested categories and filter', () => {
const schema3 = createSchema(3, '', 'A');
const schema4 = createSchema(4, '', 'A/B');
const result = getCategoryTree([schema1, schema2, schema3, schema4], new Set<string>(), '4');
expect(result).toEqual([
{ displayName: 'i18n:common.components', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'i18n:common.schemas', schemas: [], schemaTotalCount: 0, categories: [] },
{ displayName: 'A', name: 'A', schemas: [], schemaTotalCount: 2, categories: [{ displayName: 'B', name: 'A/B', schemas: [schema4], schemaTotalCount: 1, categories: [] }] },
{ displayName: 'schema-category1', name: 'schema-category1', schemas: [], schemaTotalCount: 1, categories: [] },
{ displayName: 'schema-category2', name: 'schema-category2', schemas: [], schemaTotalCount: 1, categories: [] },
]);
});
});

98
frontend/app/shared/state/schemas.state.ts

@ -16,7 +16,7 @@ type AnyFieldDto = NestedFieldDto | RootFieldDto;
interface Snapshot {
// The schema categories.
categories: Set<string>;
addedCategories: Set<string>;
// The current schemas.
schemas: SchemasList;
@ -56,8 +56,8 @@ export class SchemasState extends State<Snapshot> {
public publishedSchemas =
this.projectFrom(this.schemas, x => x.filter(s => s.isPublished));
public categoryNames =
this.project(x => x.categories);
public addedCategories =
this.project(x => x.addedCategories);
public get schemaId() {
return this.snapshot.selectedSchema?.id || '';
@ -76,7 +76,7 @@ export class SchemasState extends State<Snapshot> {
private readonly dialogs: DialogService,
private readonly schemasService: SchemasService,
) {
super({ schemas: [], categories: new Set() }, 'Schemas');
super({ schemas: [], addedCategories: new Set() }, 'Schemas');
}
public select(idOrName: string | null): Observable<SchemaDto | null> {
@ -169,17 +169,17 @@ export class SchemasState extends State<Snapshot> {
public addCategory(name: string) {
this.next(s => {
const categories = new Set([...s.categories, name]);
const categories = new Set([...s.addedCategories, name]);
return { ...s, categories };
return { ...s, addedCategories: categories };
}, 'Category Added');
}
public removeCategory(name: string) {
this.next(s => {
const categories = new Set([...s.categories].remove(name));
const categories = new Set([...s.addedCategories].remove(name));
return { ...s, categories };
return { ...s, addedCategories: categories };
}, 'Category Removed');
}
@ -368,10 +368,12 @@ export type SchemaCategory = {
name?: string;
schemas: SchemaDto[];
schemaTotalCount: number;
categories: SchemaCategory[];
};
const SPECIAL_SCHEMAS = 'i18n:common.schemas';
const SPECIAL_COMPONENTS = 'i18n:common.components';
const NESTED_CATEGORY_SEPARATOR = '/';
export function getCategoryTree(allSchemas: ReadonlyArray<SchemaDto>, categories: Set<string>, filter?: string) {
let match = (_: SchemaDto) => true;
@ -393,26 +395,23 @@ export function getCategoryTree(allSchemas: ReadonlyArray<SchemaDto>, categories
displayName: SPECIAL_SCHEMAS,
schemas: [],
schemaTotalCount: 0,
categories: [],
};
const components: SchemaCategory = {
displayName: SPECIAL_COMPONENTS,
schemas: [],
schemaTotalCount: 0,
categories: [],
};
const result: SchemaCategory[] = [schemas, components];
const flatCategoryList: SchemaCategory[] = [schemas, components];
for (const name of categories) {
result.push({
displayName: name,
name,
schemas: [],
schemaTotalCount: 0,
});
getOrCreateCategory(name);
}
function add(schema: SchemaDto, category: SchemaCategory) {
function addSchemaToCategory(schema: SchemaDto, category: SchemaCategory) {
category.schemaTotalCount++;
if (match(schema)) {
@ -420,32 +419,67 @@ export function getCategoryTree(allSchemas: ReadonlyArray<SchemaDto>, categories
}
}
for (const schema of allSchemas) {
const name = schema.category;
function getOrCreateCategory(name: string): SchemaCategory {
let category = flatCategoryList.find(x => x.name === name);
if (name) {
let category = result.find(x => x.name === name);
const displayName = (name.indexOf(NESTED_CATEGORY_SEPARATOR) === -1) ? name : name.substr(name.lastIndexOf(NESTED_CATEGORY_SEPARATOR) + 1);
if (!category) {
category = {
displayName,
name,
schemas: [],
schemaTotalCount: 0,
categories: [],
};
if (!category) {
category = {
displayName: name,
name,
schemas: [],
schemaTotalCount: 0,
};
flatCategoryList.push(category);
result.push(category);
if (name.indexOf(NESTED_CATEGORY_SEPARATOR) !== -1) {
// Recurse back creating all the parents of this category
const parentName = name.substr(0, name.lastIndexOf(NESTED_CATEGORY_SEPARATOR));
getOrCreateCategory(parentName);
}
}
return category;
}
for (const schema of allSchemas) {
const name = schema.category;
add(schema, category);
if (name) {
const category = getOrCreateCategory(name);
addSchemaToCategory(schema, category);
} else if (schema.type === 'Component') {
add(schema, components);
addSchemaToCategory(schema, components);
} else {
add(schema, schemas);
addSchemaToCategory(schema, schemas);
}
}
result.sortByString(x => x.displayName);
// Sort by name and than DisplayName so that children get correctly sorted under their parents but component and schema still sort correctly
flatCategoryList.sortByString(x => `${x.name ?? ''} - ${x.displayName}`);
const result: SchemaCategory[] = [];
// Child categories by necessity come after their parents alphabetically so processing in reverse lets us roll up all categories into their parents nicely.
// Because we're processing in reverse we unshift rather than push to the results array to get everything in the right order at the end.
for (const category of flatCategoryList.reverse()) {
if (category.name) {
if (category.name?.indexOf(NESTED_CATEGORY_SEPARATOR) !== -1) {
const parentName = category.name?.substr(0, category.name.lastIndexOf(NESTED_CATEGORY_SEPARATOR));
const parentCategory = flatCategoryList.find(x => x.name === parentName);
if (parentCategory) {
parentCategory.categories.unshift(category);
parentCategory.schemaTotalCount += category.schemaTotalCount;
}
} else {
result.unshift(category);
}
} else {
result.unshift(category);
}
}
return result;
}

Loading…
Cancel
Save