diff --git a/angular.json b/angular.json index cb68815798..785161f651 100644 --- a/angular.json +++ b/angular.json @@ -778,7 +778,7 @@ "test": { "builder": "@angular-devkit/build-angular:karma", "options": { - "main": "libs/design/src/test.ts", + "main": "libs/design/test.ts", "codeCoverage": true, "tsConfig": "libs/design/tsconfig.spec.json", "karmaConfig": "libs/design/karma.conf.js", diff --git a/libs/design/scss/theme.scss b/libs/design/scss/theme.scss index 342b9b2464..cb40b8764e 100644 --- a/libs/design/scss/theme.scss +++ b/libs/design/scss/theme.scss @@ -39,6 +39,7 @@ @use '../src/molecules/sidebar/sidebar/sidebar-theme' as sidebar; @use '../src/molecules/sidebar/sidebar-viewport/sidebar-viewport-theme' as sidebar-viewport; @use '../scss/state/skeleton/mixins' as skeleton; +@use '../tree/src/tree-theme' as tree; // // Generates the styles of a @daffodil/design theme. @@ -79,4 +80,5 @@ @include paginator.daff-paginator-theme($theme); @include sidebar.daff-sidebar-theme($theme); @include sidebar-viewport.daff-sidebar-viewport-theme($theme); + @include tree.daff-tree-theme($theme); } diff --git a/libs/design/src/test.ts b/libs/design/test.ts similarity index 100% rename from libs/design/src/test.ts rename to libs/design/test.ts diff --git a/libs/design/tree/README.md b/libs/design/tree/README.md new file mode 100644 index 0000000000..3721952361 --- /dev/null +++ b/libs/design/tree/README.md @@ -0,0 +1,38 @@ +# Tree + +Trees are used to visualize hierarchial information. They are often used to display navigational structures like nested lists of links. + +## Overview + +The `DaffTreeComponent` renders a tree structure. Typically, this is a structure of `` and ` + + + + {{ node.title }} + +``` + +## Usage + +### Basic Tree + + + + +## Accessibility + +The `DaffTreeComponent` follows the specification for a [disclosure navigation menu](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/) instead of a [tree view](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). \ No newline at end of file diff --git a/libs/design/tree/examples/ng-package.json b/libs/design/tree/examples/ng-package.json new file mode 100644 index 0000000000..1c1d4695be --- /dev/null +++ b/libs/design/tree/examples/ng-package.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/design/examples", + "deleteDestPath": false, + "lib": { + "entryFile": "src/index.ts", + "styleIncludePaths": ["../../src/scss"] + } +} \ No newline at end of file diff --git a/libs/design/tree/examples/package.json b/libs/design/tree/examples/package.json new file mode 100644 index 0000000000..c9c08733e7 --- /dev/null +++ b/libs/design/tree/examples/package.json @@ -0,0 +1,3 @@ +{ + "name": "@daffodil/design/tree/examples" +} diff --git a/libs/design/tree/examples/src/basic-tree/basic-tree.component.html b/libs/design/tree/examples/src/basic-tree/basic-tree.component.html new file mode 100644 index 0000000000..d77c0f9fb9 --- /dev/null +++ b/libs/design/tree/examples/src/basic-tree/basic-tree.component.html @@ -0,0 +1,10 @@ + + diff --git a/libs/design/tree/examples/src/basic-tree/basic-tree.component.ts b/libs/design/tree/examples/src/basic-tree/basic-tree.component.ts new file mode 100644 index 0000000000..7c7159a4c9 --- /dev/null +++ b/libs/design/tree/examples/src/basic-tree/basic-tree.component.ts @@ -0,0 +1,39 @@ +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; + +import { DaffTreeData } from '@daffodil/design/tree'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'basic-tree', + templateUrl: './basic-tree.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BasicTreeComponent { + tree: DaffTreeData = { + title: 'Root', + items: [ + { + title: 'Example Children', + items: [ + { title: 'Example Child', url: '#', id: '', items: [], data: {}}, + ], + url: '#', + id: '', + data: {}, + }, + { + title: 'Example Link', + items: [], + url: '#', + id: '', + data: {}, + }, + ], + url: '', + id: '', + data: {}, + }; +} diff --git a/libs/design/tree/examples/src/basic-tree/basic-tree.module.ts b/libs/design/tree/examples/src/basic-tree/basic-tree.module.ts new file mode 100644 index 0000000000..1a9bd99919 --- /dev/null +++ b/libs/design/tree/examples/src/basic-tree/basic-tree.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; + +import { DaffTreeModule } from '@daffodil/design/tree'; + +import { BasicTreeComponent } from './basic-tree.component'; + +@NgModule({ + declarations: [ + BasicTreeComponent, + ], + exports: [ + BasicTreeComponent, + ], + imports: [ + RouterModule, + DaffTreeModule, + FontAwesomeModule, + ], +}) +export class BasicTreeModule { } diff --git a/libs/design/tree/examples/src/index.ts b/libs/design/tree/examples/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/design/tree/examples/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/design/tree/examples/src/public_api.ts b/libs/design/tree/examples/src/public_api.ts new file mode 100644 index 0000000000..4a911e43f8 --- /dev/null +++ b/libs/design/tree/examples/src/public_api.ts @@ -0,0 +1,6 @@ +import { BasicTreeComponent } from './basic-tree/basic-tree.component'; +export { BasicTreeModule } from './basic-tree/basic-tree.module'; +export { BasicTreeComponent }; +export const TREE_EXAMPLES = [ + BasicTreeComponent, +]; diff --git a/libs/design/tree/ng-package.json b/libs/design/tree/ng-package.json new file mode 100644 index 0000000000..95ab59542e --- /dev/null +++ b/libs/design/tree/ng-package.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/design/tree", + "deleteDestPath": false, + "lib": { + "entryFile": "src/index.ts", + "styleIncludePaths": ["../src/scss"] + } +} \ No newline at end of file diff --git a/libs/design/tree/package.json b/libs/design/tree/package.json new file mode 100644 index 0000000000..15ff4011ab --- /dev/null +++ b/libs/design/tree/package.json @@ -0,0 +1,3 @@ +{ + "name": "@daffodil/design/tree" +} diff --git a/libs/design/tree/src/index.ts b/libs/design/tree/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/design/tree/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/design/tree/src/interfaces/recursive-key.ts b/libs/design/tree/src/interfaces/recursive-key.ts new file mode 100644 index 0000000000..d7731cf751 --- /dev/null +++ b/libs/design/tree/src/interfaces/recursive-key.ts @@ -0,0 +1,3 @@ +export type RecursiveTreeKeyOfType = keyof { + [P in keyof T as T[P] extends T[]? P: never]: T[] +}; diff --git a/libs/design/tree/src/interfaces/tree-data.ts b/libs/design/tree/src/interfaces/tree-data.ts new file mode 100644 index 0000000000..a452f99f19 --- /dev/null +++ b/libs/design/tree/src/interfaces/tree-data.ts @@ -0,0 +1,13 @@ +/** + * A basic tree type supporting supplemental data on a tree node. + * + * Tree elements are often slightly more than just basic titles and child items. + * There may be other important data that needs to be available at render time. + */ +export interface DaffTreeData { + title: string; + url: string; + id: string; + items: DaffTreeData[]; + data: T; +} diff --git a/libs/design/tree/src/interfaces/tree-ui.ts b/libs/design/tree/src/interfaces/tree-ui.ts new file mode 100644 index 0000000000..3dd2380644 --- /dev/null +++ b/libs/design/tree/src/interfaces/tree-ui.ts @@ -0,0 +1,12 @@ +import { DaffTreeData } from './tree-data'; + +/** + * A DaffTreeUi is the internal data structure used during tree rendering. + * + * This is an internal implementation detail type that. + */ +export interface DaffTreeUi extends DaffTreeData { + open: boolean; + items: DaffTreeUi[]; + parent: DaffTreeUi; +} diff --git a/libs/design/tree/src/public_api.ts b/libs/design/tree/src/public_api.ts new file mode 100644 index 0000000000..7bbafa2b13 --- /dev/null +++ b/libs/design/tree/src/public_api.ts @@ -0,0 +1,6 @@ +export { DaffTreeModule } from './tree.module'; +export { DaffTreeComponent } from './tree/tree.component'; +export { DaffTreeItemDirective } from './tree-item/tree-item.directive'; +export { DaffTreeData } from './interfaces/tree-data'; +export { DaffTreeUi } from './interfaces/tree-ui'; +export { daffTransformTreeInPlace } from './utils/transform-in-place'; diff --git a/libs/design/tree/src/tree-item/tree-item.directive.spec.ts b/libs/design/tree/src/tree-item/tree-item.directive.spec.ts new file mode 100644 index 0000000000..a2fc170d3a --- /dev/null +++ b/libs/design/tree/src/tree-item/tree-item.directive.spec.ts @@ -0,0 +1,8 @@ +import { DaffTreeItemDirective } from './tree-item.directive'; + +describe('DaffTreeItemDirective', () => { + it('should create an instance', () => { + // const directive = new DaffTreeItemDirective(); + // expect(directive).toBeTruthy(); + }); +}); diff --git a/libs/design/tree/src/tree-item/tree-item.directive.ts b/libs/design/tree/src/tree-item/tree-item.directive.ts new file mode 100644 index 0000000000..294fb05b39 --- /dev/null +++ b/libs/design/tree/src/tree-item/tree-item.directive.ts @@ -0,0 +1,161 @@ +import { DOCUMENT } from '@angular/common'; +import { + Directive, + HostBinding, + HostListener, + Inject, + Input, +} from '@angular/core'; + +import { DaffTreeNotifierService } from '../tree/tree-notifier.service'; +import { DaffTreeFlatNode } from '../utils/flatten-tree'; + +/** + * The `DaffTreeItemDirective` allows you to demarcate the elements which are + * tree-children that interact with the parent tree. + * + * They can be used like: + * + * ```html + * + * ``` + * + * where `tree` is a {@link DaffTreeData} and `daff-tree` is a {@link DaffTreeComponent}. + * + */ +@Directive({ + selector: '[daffTreeItem]', +}) +export class DaffTreeItemDirective { + + /** + * The css class of the daff-tree. + * + * @docs-private + */ + @HostBinding('class.daff-tree-item') class = true; + + /** + * The css class of a DaffTreeItemDirective that has children. + * + * @docs-private + */ + @HostBinding('class.daff-tree-item__parent') classParent = false; + + /** + * The html `id` of the tree item. This is derived from the {@link DaffTreeData}. + * + * @docs-private + */ + @HostBinding('attr.id') id; + + /** + * Accessibility property, notifying users about whether + * or not the tree item is open. + * + * @docs-private + */ + @HostBinding('attr.aria-expanded') ariaExpanded: string; + + /** + * A css variable indicating the depth of the tree. + * You can use this to style your templates if you want to + * use different designs at different depths. + */ + @HostBinding('style.--depth') depth: number; + + /** + * The CSS class indicating whether or not the tree is `selected`. + */ + @HostBinding('class.selected') get selectedClass() { + return this.selected; + }; + + /** + * The CSS class indicating whether or not the tree is `open`. + */ + @HostBinding('class.open') openClass = false; + + /** + * The {@link DaffTreeFlatNode} associated with this specific tree item. + * + * @docs-private + */ + private _node: DaffTreeFlatNode; + + /** + * The {@link DaffTreeFlatNode} associated with this specific tree item. + */ + @Input() + get node() { + return this._node; + }; + set node(val: DaffTreeFlatNode) { + this._node = val; + this.id = 'tree-' + this._node.id; + this.ariaExpanded = this._node._treeRef.open ? 'true' : 'false'; + this.depth = this._node.level; + this.classParent = this._node.hasChildren; + this.openClass = this._node._treeRef.open; + } + + /** + * Whether or not the tree item is the currently active item. + * Note that there is no requirement there there only be one active item at a time. + */ + @Input() selected = false; + + constructor( + @Inject(DOCUMENT) private document: any, + private treeNotifier: DaffTreeNotifierService, + ) {} + + /** + * @docs-private + */ + @HostListener('keydown.escape') + onEscape() { + this.toggleParent(this.node); + } + + /** + * @docs-private + */ + @HostListener('click') + onClick() { + if(this.node.hasChildren) { + this.toggleTree(this.node); + } + this.treeNotifier.notify(); + } + + /** + * Toggle the open state of the tree's parent. + */ + toggleParent(node: DaffTreeFlatNode) { + if(node._treeRef?.parent.parent === undefined) { + return; + } + node._treeRef.parent.open = !node._treeRef.parent.open; + (this.document).getElementById('tree-' + node._treeRef.parent.id).focus(); + } + + /** + * Toggle the open state of this specific subtree tree. + */ + toggleTree(node: DaffTreeFlatNode) { + if(node._treeRef.open === false) { + node._treeRef.open = true; + } else { + node._treeRef.open = false; + } + } +} diff --git a/libs/design/tree/src/tree-theme.scss b/libs/design/tree/src/tree-theme.scss new file mode 100644 index 0000000000..ae57562966 --- /dev/null +++ b/libs/design/tree/src/tree-theme.scss @@ -0,0 +1,38 @@ +@use 'sass:map'; +@use '../../scss/theming'; +@use '../../scss/core'; + +@mixin daff-tree-theme($theme) { + $primary: map.get($theme, primary); + $secondary: map.get($theme, secondary); + $tertiary: map.get($theme, tertiary); + $base: core.daff-map-deep-get($theme, 'core.base'); + $base-contrast: core.daff-map-deep-get($theme, 'core.base-contrast'); + $white: core.daff-map-deep-get($theme, 'core.white'); + $black: core.daff-map-deep-get($theme, 'core.black'); + $gray: core.daff-map-deep-get($theme, 'core.gray'); + + .daff-tree-item { + $root: &; + + background-color: $base; + color: theming.daff-illuminate($base-contrast, $gray, 2); + + &:hover { + background-color: theming.daff-illuminate($base, $gray, 2); + } + + &:after { + border-color: currentColor; + } + + &.selected { + background-color: theming.daff-illuminate($base, $gray, 2); + color: $base-contrast; + + &:before { + background-color: theming.daff-color($primary); + } + } + } +} diff --git a/libs/design/tree/src/tree.module.ts b/libs/design/tree/src/tree.module.ts new file mode 100644 index 0000000000..3fc6382aba --- /dev/null +++ b/libs/design/tree/src/tree.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { DaffTreeItemDirective } from './tree-item/tree-item.directive'; +import { DaffTreeComponent } from './tree/tree.component'; + +@NgModule({ + declarations: [ + DaffTreeComponent, + DaffTreeItemDirective, + ], + imports: [ + CommonModule, + ], + exports: [ + DaffTreeComponent, + DaffTreeItemDirective, + ], +}) +export class DaffTreeModule { } diff --git a/libs/design/tree/src/tree/specs/defaults.spec.ts b/libs/design/tree/src/tree/specs/defaults.spec.ts new file mode 100644 index 0000000000..0c7acfc37b --- /dev/null +++ b/libs/design/tree/src/tree/specs/defaults.spec.ts @@ -0,0 +1,35 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; + +import { DaffTreeComponent } from '../tree.component'; + +describe('@daffodil/design/tree - DaffTreeComponent | Defaults', () => { + let component: DaffTreeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + DaffTreeComponent, + ], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DaffTreeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have sane defaults', () => { + expect(component.flatTree).toEqual([]); + expect(component.dataTree).toEqual(undefined); + }); +}); diff --git a/libs/design/tree/src/tree/specs/simple.spec.ts b/libs/design/tree/src/tree/specs/simple.spec.ts new file mode 100644 index 0000000000..f01524ea7a --- /dev/null +++ b/libs/design/tree/src/tree/specs/simple.spec.ts @@ -0,0 +1,56 @@ +import { + Component, + Input, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; + +import { DaffTreeData } from '@daffodil/design/tree'; + +import { DaffTreeComponent } from '../tree.component'; + +@Component({ + template: ` +
    + `, +}) +class WrapperComponent { + @Input() data: DaffTreeData; +} + +describe('@daffodil/design/tree - DaffTreeComponent | Simple', () => { + let wrapper: WrapperComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + DaffTreeComponent, + WrapperComponent, + ], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + it('should render nothing', () => { + expect(fixture.debugElement.nativeElement.innerHTML).toContain(`
      { + wrapper.data = { title: '', url: '', id: '', items: [], data: {}}; + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement.innerHTML).toContain(` + `, +}) +class WrapperComponent { + @Input() data: DaffTreeData; +} + + +describe('@daffodil/design/tree - DaffTreeComponent | withTemplate', () => { + let wrapper: WrapperComponent; + let component: DaffTreeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DaffTreeModule, CommonModule], + declarations: [WrapperComponent], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + component = fixture.debugElement.query(By.css('ul[daff-tree]')).componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + it('should render something when data and templates are provided', () => { + wrapper.data = { title: 'Root', url: '', id: '', items: [ + { title: 'Child A', url: '', id: '', items: [ + { title: 'Child Aa', url: '', id: '', items: [], data: {}}, + ], data: {}}, + { title: 'Child B', url: '', id: '', items: [], data: {}}, + ], data: {}}; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('li')).componentInstance instanceof DaffTreeComponent).toBeTrue(); + }); + + it('should render the same number of items as there are tree branches', () => { + wrapper.data = { title: 'Root', url: '', id: '', items: [ + { title: 'Child A', url: '', id: '', items: [ + { title: 'Child Aa', url: '', id: '', items: [], data: {}}, + ], data: {}}, + { title: 'Child B', url: '', id: '', items: [], data: {}}, + ], data: {}}; + fixture.detectChanges(); + expect(fixture.debugElement.queryAll(By.css('li')).length).toEqual(2); + }); +}); diff --git a/libs/design/tree/src/tree/tree-notifier.service.ts b/libs/design/tree/src/tree/tree-notifier.service.ts new file mode 100644 index 0000000000..b80e774959 --- /dev/null +++ b/libs/design/tree/src/tree/tree-notifier.service.ts @@ -0,0 +1,46 @@ +import { + Inject, + OnDestroy, +} from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +/** + * This service is used by tree-items to notify their parent + * that the tree has to be re-computed. + * + * This service is a multiton associated with each tree instance. + * It follows the same lifecycle has the tree it is associated with. + */ +@Inject({}) +export class DaffTreeNotifierService implements OnDestroy { + + /** + * @docs-private + */ + private _notice: BehaviorSubject = new BehaviorSubject(true); + + /** + * An observable that emits when the tree needs to be re-computed. + */ + notice$ = this._notice.asObservable(); + + /** + * `notify` can be called to trigger a re-computation of the tree + * if data has changed unexpectedly and a re-render did not occur. + * + * This should be used sparingly. Instead, prefer updating `data` on the tree + * itself for performance reasons. + */ + notify() { + this._notice.next(true); + } + + /** + * Cleanup when the tree is destroyed. + * + * @docs-private + */ + ngOnDestroy(): void { + this._notice.complete(); + } +} diff --git a/libs/design/tree/src/tree/tree.component.html b/libs/design/tree/src/tree/tree.component.html new file mode 100644 index 0000000000..9feaee3429 --- /dev/null +++ b/libs/design/tree/src/tree/tree.component.html @@ -0,0 +1,7 @@ + +
    • + + +
    • +
      \ No newline at end of file diff --git a/libs/design/tree/src/tree/tree.component.scss b/libs/design/tree/src/tree/tree.component.scss new file mode 100644 index 0000000000..608e79fe44 --- /dev/null +++ b/libs/design/tree/src/tree/tree.component.scss @@ -0,0 +1,72 @@ +@use '../../../scss/interactions'; +@use '../../../scss/typography' as t; + +@mixin level-padding() { + padding-left: calc(var(--tree-padding) * (var(--depth))); +} + +.daff-tree { + margin: 0; + padding: 0; + list-style: none; + --tree-padding: 16px; +} + +.daff-tree-item { + @include interactions.clickable(); + @include t.single-line-ellipsis(); + display: block; + position: relative; + background: none; + border: 0; + padding: 8px 16px 8px 0; + line-height: 1.5rem; + font-weight: 400; + text-align: left; + text-decoration: none; + width: 100%; + @include level-padding(); + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 4px; + } + + &:focus, + &:focus-visible { + z-index: 1; + } + + &.selected { + font-weight: 600; + } + + &__parent { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 48%; + right: 16px; + display: inline-block; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + width: 8px; + height: 8px; + transform: translateY(-50%) rotate(45deg); + transition: transform 150ms; + } + + &.open { + &:after { + top: 56%; + transform: translateY(-50%) rotate(225deg); + } + } + } +} \ No newline at end of file diff --git a/libs/design/tree/src/tree/tree.component.spec.ts b/libs/design/tree/src/tree/tree.component.spec.ts new file mode 100644 index 0000000000..2e39ba3c12 --- /dev/null +++ b/libs/design/tree/src/tree/tree.component.spec.ts @@ -0,0 +1,30 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; + +import { DaffTreeComponent } from './tree.component'; + +describe('@daffodil/design/tree - DaffTreeComponent', () => { + let component: DaffTreeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + DaffTreeComponent, + ], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DaffTreeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/design/tree/src/tree/tree.component.ts b/libs/design/tree/src/tree/tree.component.ts new file mode 100644 index 0000000000..adaf2a82fd --- /dev/null +++ b/libs/design/tree/src/tree/tree.component.ts @@ -0,0 +1,131 @@ +import { Location } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, + HostBinding, + Input, + OnInit, + TemplateRef, + ViewEncapsulation, +} from '@angular/core'; + +import { DaffTreeData } from '../interfaces/tree-data'; +import { DaffTreeUi } from '../interfaces/tree-ui'; +import { + DaffTreeFlatNode, + flattenTree, +} from '../utils/flatten-tree'; +import { hydrateTree } from '../utils/hydrate-tree'; +import { DaffTreeNotifierService } from './tree-notifier.service'; + +/** + * The `DaffTreeComponent` allows you to render tree structures as interactable ui. + * + * They can be used like: + * + * ```html + * + * ``` + * + * where `tree` is a {@link DaffTreeData}. + * + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'ul[daff-tree]', + templateUrl: './tree.component.html', + styleUrls: ['./tree.component.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + DaffTreeNotifierService, + ], +}) +export class DaffTreeComponent implements OnInit { + + /** + * The css class of the daff-tree. + * + * @docs-private + */ + @HostBinding('class.daff-tree') class = true; + + /** + * The internal tree element. + */ + private tree: DaffTreeUi = undefined; + + /** + * The flattened tree data. You can iterate through this if you want to inspect + * the resulting array structure we computed to render the tree. + */ + public flatTree: DaffTreeFlatNode[] = []; + + /** + * @docs-private + */ + private _dataTree: DaffTreeData = undefined; + + /** + * The tree data you would like to render. + */ + @Input('tree') + get dataTree() { + return this._dataTree; + } + set dataTree(dataTree: DaffTreeData){ + if(!dataTree) { + this._dataTree = undefined; + this.tree = undefined; + this.flatTree = []; + return; + } + this._dataTree = dataTree; + this.tree = hydrateTree(this.dataTree); + this.flatTree = flattenTree(this.tree); + }; + + /** + * The template used to render tree-nodes that themselves have children. + * + * @docs-private + */ + @ContentChild('daffTreeItemWithChildrenTpl', { static: true }) + withChildrenTemplate: TemplateRef; + + /** + * The template used to render tree-nodes that have no children. + * + * @docs-private + */ + @ContentChild('daffTreeItemTpl', { static: true }) treeItemTemplate: TemplateRef; + + constructor( + private notifier: DaffTreeNotifierService, + ) {} + + /** + * The track-by function used to reduce tree-item re-renders + */ + trackByTreeElement(index: number, el: any): any { + return el.title; + } + + /** + * @docs-private + */ + ngOnInit(): void { + this.notifier.notice$.subscribe(() => { + this.flatTree = flattenTree(this.tree); + }); + } +} diff --git a/libs/design/tree/src/utils/collapse-tree.spec.ts b/libs/design/tree/src/utils/collapse-tree.spec.ts new file mode 100644 index 0000000000..b0610fb6ff --- /dev/null +++ b/libs/design/tree/src/utils/collapse-tree.spec.ts @@ -0,0 +1,39 @@ +import { collapseTree } from './collapse-tree'; + + +describe('@daffodil/design/tree - hydrateTree', () => { + it('should collapse ui trees', () => { + const dataProvider = [ + { + data: { title: '', url: '', id: '', items: [], data: {}, open: true, parent: undefined }, + expected: { title: '', url: '', id: '', items: [], data: {}, open: false, parent: undefined }, + }, + { + data: { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [], data: {}, open: true, parent: undefined }, + ], data: {}, open: true, parent: undefined }, + expected: { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [], data: {}, open: false, parent: undefined }, + ], data: {}, open: false, parent: undefined }, + }, + ]; + + dataProvider.forEach((d) => { + expect(collapseTree(d.data)).toEqual(d.expected); + }); + }); + + it('should collapse ui subtrees if called on a subtree (mutably)', () => { + const tree = { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [], data: {}, open: true, parent: undefined }, + ], data: {}, open: true, parent: undefined }; + + const result = { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [], data: {}, open: false, parent: undefined }, + ], data: {}, open: true, parent: undefined }; + + collapseTree(tree.items[0]); + + expect(tree).toEqual(result); + }); +}); diff --git a/libs/design/tree/src/utils/collapse-tree.ts b/libs/design/tree/src/utils/collapse-tree.ts new file mode 100644 index 0000000000..eb6500fcf5 --- /dev/null +++ b/libs/design/tree/src/utils/collapse-tree.ts @@ -0,0 +1,10 @@ +import { DaffTreeUi } from '../interfaces/tree-ui'; +import { traverse } from './traverse-tree'; + +/** + * Collapse the tree and its subtrees. + */ +export const collapseTree = (tree: DaffTreeUi): DaffTreeUi => traverse(tree, (node) => { + node.open = false; + return node; +}, 'items'); diff --git a/libs/design/tree/src/utils/expand-tree.spec.ts b/libs/design/tree/src/utils/expand-tree.spec.ts new file mode 100644 index 0000000000..ec1c48fafc --- /dev/null +++ b/libs/design/tree/src/utils/expand-tree.spec.ts @@ -0,0 +1,39 @@ +import { expandTree } from './expand-tree'; + + +describe('@daffodil/design/tree - hydrateTree', () => { + it('should expand ui trees', () => { + const dataProvider = [ + { + data: { title: '', url: '', id: '', items: [], data: {}, open: false, parent: undefined }, + expected: { title: '', url: '', id: '', items: [], data: {}, open: true, parent: undefined }, + }, + { + data: { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [], data: {}, open: false, parent: undefined }, + ], data: {}, open: false, parent: undefined }, + expected: { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [], data: {}, open: true, parent: undefined }, + ], data: {}, open: true, parent: undefined }, + }, + ]; + + dataProvider.forEach((d) => { + expect(expandTree(d.data)).toEqual(d.expected); + }); + }); + + it('should expand all ui subtrees if called on a subtree (mutably)', () => { + const tree = { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [], data: {}, open: true, parent: undefined }, + ], data: {}, open: false, parent: undefined }; + + const result = { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [], data: {}, open: true, parent: undefined }, + ], data: {}, open: false, parent: undefined }; + + expandTree(tree.items[0]); + + expect(tree).toEqual(result); + }); +}); diff --git a/libs/design/tree/src/utils/expand-tree.ts b/libs/design/tree/src/utils/expand-tree.ts new file mode 100644 index 0000000000..ea44925a6e --- /dev/null +++ b/libs/design/tree/src/utils/expand-tree.ts @@ -0,0 +1,10 @@ +import { DaffTreeUi } from '../interfaces/tree-ui'; +import { traverse } from './traverse-tree'; + +/** + * Expand the tree and its subtrees. + */ +export const expandTree = (tree: DaffTreeUi): DaffTreeUi => traverse(tree, (node) => { + node.open = true; + return node; +}, 'items'); diff --git a/libs/design/tree/src/utils/flatten-tree.spec.ts b/libs/design/tree/src/utils/flatten-tree.spec.ts new file mode 100644 index 0000000000..f49b81ca81 --- /dev/null +++ b/libs/design/tree/src/utils/flatten-tree.spec.ts @@ -0,0 +1,227 @@ +import { DaffTreeUi } from '../interfaces/tree-ui'; +import { flattenTree } from './flatten-tree'; +import { hydrateTree } from './hydrate-tree'; +import { traverse } from './traverse-tree'; + +describe('@daffodil/design/tree - flattenTree', () => { + it('should flatten a root into an empty array', () => { + const data = { title: '', url: '', id: '', items: [], data: {}}; + const flat = []; + + expect(flattenTree(hydrateTree(data))).toEqual(flat); + }); + + it('should flatten a data tree into a tree with an open first layer and closed lower layers', () => { + const data = { title: 'Root', url: '', id: '', items: [ + { title: 'Child A', url: '', id: '', items: [ + { title: 'Child Aa', url: '', id: '', items: [], data: {}}, + ], data: {}}, + { title: 'Child B', url: '', id: '', items: [ + { title: 'Child Bb', url: '', id: '', items: [], data: {}}, + ], data: {}}, + ], data: {}}; + + const flat = flattenTree(hydrateTree(data)); + + expect(flat[0].title).toEqual('Child A'); + expect(flat[1].title).toEqual('Child B'); + }); + + it('should flatten an open ui tree', () => { + const root: DaffTreeUi = { + title: 'Root', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + + const childA = { + title: 'Child A', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + + const childAa = { + title: 'Child Aa', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + const childB = { + title: 'Child B', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + const childBb = { + title: 'Child Bb', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + + root.items = [childA, childB]; + childA.parent = root; + childB.parent = root; + childA.items = [childAa]; + childAa.parent = childA; + childB.items = [childBb]; + childBb.parent = childB; + + + const flat = flattenTree(root); + + expect(flat[0].title).toEqual('Child A'); + expect(flat[1].title).toEqual('Child Aa'); + expect(flat[2].title).toEqual('Child B'); + expect(flat[3].title).toEqual('Child Bb'); + }); + + it('should clip closed branches', () => { + const root: DaffTreeUi = { + title: 'Root', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + + const childA = { + title: 'Child A', + url: '', + id: '', + items: [], + parent: undefined, + open: false, + data: {}, + }; + + const childAa = { + title: 'Child Aa', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + const childB = { + title: 'Child B', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + const childBb = { + title: 'Child Bb', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + + root.items = [childA, childB]; + childA.parent = root; + childB.parent = root; + childA.items = [childAa]; + childAa.parent = childA; + childB.items = [childBb]; + childBb.parent = childB; + + + const flat = flattenTree(root); + + expect(flat[0].title).toEqual('Child A'); + expect(flat[1].title).toEqual('Child B'); + expect(flat[2].title).toEqual('Child Bb'); + }); + + it('should handle deep trees correctly', () => { + const root: DaffTreeUi = { + title: 'Root', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + + const childA = { + title: 'Child A', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + + const childAa = { + title: 'Child Aa', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + const childAaA = { + title: 'Child AaA', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + const childAaAa = { + title: 'Child AaAa', + url: '', + id: '', + items: [], + parent: undefined, + open: true, + data: {}, + }; + + root.items = [childA]; + childA.parent = root; + childA.items = [childAa]; + childAa.parent = childA; + childAa.items = [childAaA]; + childAaA.parent = childAa; + childAaA.items = [childAaAa]; + childAaAa.parent = childAaA; + + + const flat = flattenTree(root); + + expect(flat[0].title).toEqual('Child A'); + expect(flat[1].title).toEqual('Child Aa'); + expect(flat[2].title).toEqual('Child AaA'); + expect(flat[3].title).toEqual('Child AaAa'); + }); +}); diff --git a/libs/design/tree/src/utils/flatten-tree.ts b/libs/design/tree/src/utils/flatten-tree.ts new file mode 100644 index 0000000000..1c7c36a7ed --- /dev/null +++ b/libs/design/tree/src/utils/flatten-tree.ts @@ -0,0 +1,69 @@ +import { DaffTreeUi } from '../interfaces/tree-ui'; + +/** + * A flattened node of a tree. This is used when translating the tree data + * structure into an array. + */ +export interface DaffTreeFlatNode { + id: number | string; + title: string; + url: string; + level: number; + hasChildren: boolean; + data: unknown; + _treeRef: DaffTreeUi; +} + +/** + * Flatten a DaffTreeUi into an array, removing elements from the array + * below nodes in the tree that are not open. + */ +export const flattenTree = (daffUiTree: DaffTreeUi): DaffTreeFlatNode[] => { + const tree: DaffTreeFlatNode[] = []; + + let items = [ + { + ...daffUiTree, + title: 'Root', + level: 0, + url: '/', + data: undefined, + open: true, + _treeRef: daffUiTree, + }, + ]; + + + while(items) { + const el = items.pop(); + if(!el) { + break; + } + + if(el.open) { + items = [ + ...items, + ...el.items.map((i) => ({ + ...i, + level: + el.level + 1, + _treeRef: i, + })).reverse(), + ]; + } + + if(el._treeRef.parent?.open) { + tree.push({ + id: el.id, + title: el.title, + level: el.level, + url : el.url, + hasChildren: el.items.length > 0, + data: undefined, + _treeRef: el._treeRef, + }); + } + } + + return tree; +}; diff --git a/libs/design/tree/src/utils/hydrate-tree.spec.ts b/libs/design/tree/src/utils/hydrate-tree.spec.ts new file mode 100644 index 0000000000..4b59ab027e --- /dev/null +++ b/libs/design/tree/src/utils/hydrate-tree.spec.ts @@ -0,0 +1,50 @@ +import { hydrateTree } from './hydrate-tree'; +import { traverse } from './traverse-tree'; + +describe('@daffodil/design/tree - hydrateTree', () => { + it('should hydrate data trees into ui trees', () => { + const dataProvider = [ + { data: { title: '', url: '', id: '', items: [], data: {}}, ui: { title: '', url: '', id: '', items: [], data: {}, open: true, parent: undefined }}, + ]; + + dataProvider.forEach((d) => { + const uiTree = hydrateTree(d.data); + expect(uiTree).toEqual(d.ui); + }); + }); + + it('should have the same number of elements in the tree', () => { + const dataProvider = [ + { data: { title: '', url: '', id: '', data: {}, items: []}}, + { data: { title: '', url: '', id: '', data: {}, items: [ + { title: '', url: '', id: '', items: [], data: {}}, + { title: '', url: '', id: '', items: [], data: {}}, + ]}}, + { data: { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [], data: {}}, + ], data: {}}, + ], data: {}}}, + { data: { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', items: [ + { title: '', url: '', id: '', data: {}, items: []}, + ], data: {}}, + ], data: {}}, + ], data: {}}}, + ]; + + dataProvider.forEach((d) => { + const uiTree = hydrateTree(d.data); + let uiCount = 0; + let dataCount = 0; + traverse(d.data, (el) => { + dataCount++; return el; + }, 'items'); + traverse(uiTree, (el) => { + uiCount++; return el; + }, 'items'); + expect(uiCount).toEqual(dataCount); + }); + }); +}); diff --git a/libs/design/tree/src/utils/hydrate-tree.ts b/libs/design/tree/src/utils/hydrate-tree.ts new file mode 100644 index 0000000000..b1a27716cc --- /dev/null +++ b/libs/design/tree/src/utils/hydrate-tree.ts @@ -0,0 +1,37 @@ +import { DaffTreeData } from '../interfaces/tree-data'; +import { DaffTreeUi } from '../interfaces/tree-ui'; +import { traverse } from './traverse-tree'; + +export const daffDataTreeToUiTree = (data: DaffTreeData, parent: DaffTreeUi, open: boolean = false): DaffTreeUi => ({ + id: data.id ?? data.title, + title: data.title, + url: data.url, + data: data.data, + open, + parent, + items: [], +}); + +/** + * This function translates the original data given to us by the client + * to the internal representation of the tree used by the {@link DaffTreeComponent} + */ +export const hydrateTree = (data: DaffTreeData): DaffTreeUi => { + const tree = daffDataTreeToUiTree(data, undefined, true); + + let treeStack = [ + tree, + ]; + + traverse(data, (el) => { + const treeEl = treeStack.pop(); + treeEl.items = el.items.map((i) => daffDataTreeToUiTree(i, treeEl, false)); + treeStack = [ + ...treeStack, + ...treeEl.items, + ]; + return el; + }, 'items'); + + return tree; +}; diff --git a/libs/design/tree/src/utils/transform-in-place.spec.ts b/libs/design/tree/src/utils/transform-in-place.spec.ts new file mode 100644 index 0000000000..28a644f872 --- /dev/null +++ b/libs/design/tree/src/utils/transform-in-place.spec.ts @@ -0,0 +1,74 @@ +import { daffTransformTreeInPlace } from './transform-in-place'; + +describe('@daffodil/design/tree - traverse', () => { + it('should transforms trees (mutably)', () => { + const dataProvider = [ + { + data: { title: '', url: '', id: 'a', data: {}, items: []}, + count: 1, + path: 'a1', + }, + { + data: { title: '', url: '', id: 'a', data: {}, items: [ + { title: '', url: '', id: 'b', items: [], data: {}}, + { title: '', url: '', id: 'c', items: [], data: {}}, + ]}, + count: 3, + path: 'a1c2b3', + }, + { + data: { title: '', url: '', id: 'a', items: [ + { title: '', url: '', id: 'b', items: [ + { title: '', url: '', id: 'c', items: [], data: {}}, + ], data: {}}, + ], data: {}}, + count: 3, + path: 'a1b2c3', + }, + { + data: { title: '', url: '', id: 'a', items: [ + { title: '', url: '', id: 'b', items: [ + { title: '', url: '', id: 'c', items: [ + { title: '', url: '', id: 'd', data: {}, items: []}, + ], data: {}}, + ], data: {}}, + ], data: {}}, + count: 4, + path: 'a1b2c3d4', + }, + { + data: { title: '', url: '', id: 'a', items: [ + { title: '', url: '', id: 'b', items: [ + { title: '', url: '', id: 'c', items: [ + { title: '', url: '', id: 'd', data: {}, items: []}, + ], data: {}}, + ], data: {}}, + { title: '', url: '', id: 'e', items: [ + { title: '', url: '', id: 'f', items: [ + { title: '', url: '', id: 'g', data: {}, items: []}, + ], data: {}}, + ], data: {}}, + ], data: {}}, + count: 7, + path: 'a1e2f3g4b5c6d7', + }, + ]; + + dataProvider.forEach((d) => { + let count = 0; + let path = ''; + + const result = daffTransformTreeInPlace(d.data, (node) => { + count++; + node.id = node.id + count; + path = path + node.id; + return node; + }, 'items'); + + expect(d.data.id).toEqual(result.id); + + expect(count).toEqual(d.count); + expect(path).toEqual(d.path); + }); + }); +}); diff --git a/libs/design/tree/src/utils/transform-in-place.ts b/libs/design/tree/src/utils/transform-in-place.ts new file mode 100644 index 0000000000..fcb9717366 --- /dev/null +++ b/libs/design/tree/src/utils/transform-in-place.ts @@ -0,0 +1,35 @@ +import { RecursiveTreeKeyOfType } from '../interfaces/recursive-key'; +import { DaffTreeData } from '../interfaces/tree-data'; +import { traverse } from './traverse-tree'; + +/** + * Transform a tree-like structure in-place into a {@link DaffTreeData}. + * + * This will mutate the original object, hydrating with additional properties. + * + * @param tree - The data structure representing tree-like data. + * @param transformFn - A user-supplied function that will transform the user + * type into a {@link DaffTreeData} + * @param key - The property of the your tree that indicates which + * key contains the "children" of your tree structure. + * + */ +export const daffTransformTreeInPlace = < + // eslint-disable-next-line @typescript-eslint/ban-types + T extends Record, +>( + tree: T, + transformFn: (type: T) => T & DaffTreeData, + key: RecursiveTreeKeyOfType, +): DaffTreeData => traverse>( + tree, + (el) => { + const r = Object.assign(el, transformFn(el)); + r.items = el[key]; + el = r; + return >el; + }, + // This type is confusing. I don't understand why it has to be here, + // the associated error message is incomprehensible. + key, +); diff --git a/libs/design/tree/src/utils/traverse-tree.spec.ts b/libs/design/tree/src/utils/traverse-tree.spec.ts new file mode 100644 index 0000000000..87d4f988d5 --- /dev/null +++ b/libs/design/tree/src/utils/traverse-tree.spec.ts @@ -0,0 +1,71 @@ +import { traverse } from './traverse-tree'; + +describe('@daffodil/design/tree - traverse', () => { + it('should traverse trees pre-order, right-to-left', () => { + const dataProvider = [ + { + data: { title: '', url: '', id: 'a', data: {}, items: []}, + count: 1, + path: 'a', + }, + { + data: { title: '', url: '', id: 'a', data: {}, items: [ + { title: '', url: '', id: 'b', items: [], data: {}}, + { title: '', url: '', id: 'c', items: [], data: {}}, + ]}, + count: 3, + path: 'acb', + }, + { + data: { title: '', url: '', id: 'a', items: [ + { title: '', url: '', id: 'b', items: [ + { title: '', url: '', id: 'c', items: [], data: {}}, + ], data: {}}, + ], data: {}}, + count: 3, + path: 'abc', + }, + { + data: { title: '', url: '', id: 'a', items: [ + { title: '', url: '', id: 'b', items: [ + { title: '', url: '', id: 'c', items: [ + { title: '', url: '', id: 'd', data: {}, items: []}, + ], data: {}}, + ], data: {}}, + ], data: {}}, + count: 4, + path: 'abcd', + }, + { + data: { title: '', url: '', id: 'a', items: [ + { title: '', url: '', id: 'b', items: [ + { title: '', url: '', id: 'c', items: [ + { title: '', url: '', id: 'd', data: {}, items: []}, + ], data: {}}, + ], data: {}}, + { title: '', url: '', id: 'e', items: [ + { title: '', url: '', id: 'f', items: [ + { title: '', url: '', id: 'g', data: {}, items: []}, + ], data: {}}, + ], data: {}}, + ], data: {}}, + count: 7, + path: 'aefgbcd', + }, + ]; + + dataProvider.forEach((d) => { + let count = 0; + let path = ''; + + traverse(d.data, (node) => { + count++; + path = path + node.id; + return node; + }, 'items'); + + expect(count).toEqual(d.count); + expect(path).toEqual(d.path); + }); + }); +}); diff --git a/libs/design/tree/src/utils/traverse-tree.ts b/libs/design/tree/src/utils/traverse-tree.ts new file mode 100644 index 0000000000..cfece20708 --- /dev/null +++ b/libs/design/tree/src/utils/traverse-tree.ts @@ -0,0 +1,30 @@ +import { RecursiveTreeKeyOfType } from '../interfaces/recursive-key'; + +/** + * Traverse the tree, pre-order, right-to-left + */ +export const traverse = , V extends Record = T>( + tree: T, + visit: (tree: T) => V, + key: RecursiveTreeKeyOfType, +): V => { + let stack = [ + tree, + ]; + + while(stack) { + const el = stack.pop(); + if(!el) { + break; + } + + visit(el); + + stack = [ + ...stack, + ...el[key], + ]; + } + + return tree; +}; diff --git a/libs/design/tree/src/utils/walk-up.spec.ts b/libs/design/tree/src/utils/walk-up.spec.ts new file mode 100644 index 0000000000..b463fca28a --- /dev/null +++ b/libs/design/tree/src/utils/walk-up.spec.ts @@ -0,0 +1,92 @@ +import { DaffTreeUi } from '../public_api'; +import { walkUp } from './walk-up'; + +describe('@daffodil/design/tree - walkUp', () => { + it('should walkup a tree, applying the visit function', () => { + const root: DaffTreeUi = { + title: 'Root', + url: '', + id: 'root', + items: [], + parent: undefined, + open: true, + data: {}, + }; + + const childA = { + title: 'Child A', + url: '', + id: 'A', + items: [], + parent: undefined, + open: false, + data: {}, + }; + + const childAa = { + title: 'Child Aa', + url: '', + id: 'Aa', + items: [], + parent: undefined, + open: false, + data: {}, + }; + const childAaA = { + title: 'Child AaA', + url: '', + id: 'AaA', + items: [], + parent: undefined, + open: false, + data: {}, + }; + const childAaAa = { + title: 'Child AaAa', + url: '', + id: 'AaAa', + items: [], + parent: undefined, + open: false, + data: {}, + }; + + const childB = { + title: 'Child B', + url: '', + id: 'B', + items: [], + parent: undefined, + open: false, + data: {}, + }; + + root.items = [childA]; + childA.parent = root; + childA.items = [childAa]; + childAa.parent = childA; + childAa.items = [childAaA]; + childAaA.parent = childAa; + childAaA.items = [childAaAa]; + childAaAa.parent = childAaA; + childB.parent = root; + + let count = 0; + let path = ''; + + walkUp(childAaAa,(node) => { + count++; + path = path + node.id; + node.open = true; + return node; + }); + + expect(count).toEqual(4); + expect(path).toEqual('AaAaAaAAaA'); + expect(childAaAa.open).toEqual(true); + expect(childAaA.open).toEqual(true); + expect(childAa.open).toEqual(true); + expect(childA.open).toEqual(true); + expect(childB.open).toEqual(false); + }); +}); diff --git a/libs/design/tree/src/utils/walk-up.ts b/libs/design/tree/src/utils/walk-up.ts new file mode 100644 index 0000000000..bf34af6cad --- /dev/null +++ b/libs/design/tree/src/utils/walk-up.ts @@ -0,0 +1,17 @@ +import { DaffTreeUi } from '../interfaces/tree-ui'; + +/** + * Walk up the tree from a leaf to the root applying the + * visit function at each node along the way. + */ +export const walkUp = ( + tree: DaffTreeUi, + visit: (tree: DaffTreeUi) => DaffTreeUi, +): DaffTreeUi => { + while(tree.parent) { + visit(tree); + tree = tree.parent; + } + + return tree; +}; diff --git a/libs/design/tsconfig.lib.json b/libs/design/tsconfig.lib.json index d3bea8faed..66511b0b0a 100644 --- a/libs/design/tsconfig.lib.json +++ b/libs/design/tsconfig.lib.json @@ -21,7 +21,7 @@ "enableResourceInlining": true }, "exclude": [ - "src/test.ts", + "test.ts", "**/*.spec.ts" ], } diff --git a/libs/design/tsconfig.spec.json b/libs/design/tsconfig.spec.json index 16da33db07..cdfd093087 100644 --- a/libs/design/tsconfig.spec.json +++ b/libs/design/tsconfig.spec.json @@ -8,7 +8,7 @@ ] }, "files": [ - "src/test.ts" + "test.ts" ], "include": [ "**/*.spec.ts",