diff --git a/frontend/app/features/content/pages/schemas/schemas-page.component.ts b/frontend/app/features/content/pages/schemas/schemas-page.component.ts index 5d540f22b..38e65d50a 100644 --- a/frontend/app/features/content/pages/schemas/schemas-page.component.ts +++ b/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); }); diff --git a/frontend/app/features/schemas/pages/schemas/schemas-page.component.html b/frontend/app/features/schemas/pages/schemas/schemas-page.component.html index 2a77525bc..e0293ec92 100644 --- a/frontend/app/features/schemas/pages/schemas/schemas-page.component.html +++ b/frontend/app/features/schemas/pages/schemas/schemas-page.component.html @@ -22,7 +22,7 @@
+ [schemaCategory]="category" (remove)="removeCategory($event)">
diff --git a/frontend/app/features/schemas/pages/schemas/schemas-page.component.scss b/frontend/app/features/schemas/pages/schemas/schemas-page.component.scss index e69de29bb..9679c5ee0 100644 --- a/frontend/app/features/schemas/pages/schemas/schemas-page.component.scss +++ b/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; +} \ No newline at end of file diff --git a/frontend/app/features/schemas/pages/schemas/schemas-page.component.ts b/frontend/app/features/schemas/pages/schemas/schemas-page.component.ts index bbaf98258..7e6912f12 100644 --- a/frontend/app/features/schemas/pages/schemas/schemas-page.component.ts +++ b/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); }); diff --git a/frontend/app/shared/components/schema-category.component.html b/frontend/app/shared/components/schema-category.component.html index 88a20ac38..e69b7100e 100644 --- a/frontend/app/shared/components/schema-category.component.html +++ b/frontend/app/shared/components/schema-category.component.html @@ -1,62 +1,74 @@ - diff --git a/frontend/app/shared/components/schema-category.component.scss b/frontend/app/shared/components/schema-category.component.scss index 50dd89380..9429da6dc 100644 --- a/frontend/app/shared/components/schema-category.component.scss +++ b/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%; } \ No newline at end of file diff --git a/frontend/app/shared/components/schema-category.component.ts b/frontend/app/shared/components/schema-category.component.ts index aa55d5a77..262c1586c 100644 --- a/frontend/app/shared/components/schema-category.component.ts +++ b/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(); @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`; } diff --git a/frontend/app/shared/services/schemas.service.spec.ts b/frontend/app/shared/services/schemas.service.spec.ts index 585765878..0277c1bc3 100644 --- a/frontend/app/shared/services/schemas.service.spec.ts +++ b/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), diff --git a/frontend/app/shared/state/schemas.state.spec.ts b/frontend/app/shared/state/schemas.state.spec.ts index eab36c595..2fc8a8144 100644 --- a/frontend/app/shared/state/schemas.state.spec.ts +++ b/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()); 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(['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(), '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()); + + 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(['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(), '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: [] }, + ]); }); }); diff --git a/frontend/app/shared/state/schemas.state.ts b/frontend/app/shared/state/schemas.state.ts index 0dc95a88f..ba4ef3c3a 100644 --- a/frontend/app/shared/state/schemas.state.ts +++ b/frontend/app/shared/state/schemas.state.ts @@ -16,7 +16,7 @@ type AnyFieldDto = NestedFieldDto | RootFieldDto; interface Snapshot { // The schema categories. - categories: Set; + addedCategories: Set; // The current schemas. schemas: SchemasList; @@ -56,8 +56,8 @@ export class SchemasState extends State { 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 { 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 { @@ -169,17 +169,17 @@ export class SchemasState extends State { 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, categories: Set, filter?: string) { let match = (_: SchemaDto) => true; @@ -393,26 +395,23 @@ export function getCategoryTree(allSchemas: ReadonlyArray, 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, 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; }