#91 - replace sidebar tree component with custom 1st-party implementation
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing

This commit is contained in:
Garrett Mills 2021-08-30 21:35:45 -05:00
parent 87b99473bd
commit f3f8578834
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
13 changed files with 484 additions and 139 deletions

View File

@ -22,7 +22,6 @@
"@angular/pwa": "^0.1001.7",
"@angular/router": "~10.1.5",
"@angular/service-worker": "~10.1.5",
"@circlon/angular-tree-component": "^10.0.0",
"@ckeditor/ckeditor5-angular": "^2.0.1",
"@ckeditor/ckeditor5-build-decoupled-document": "^27.0.0",
"@convergencelabs/monaco-collab-ext": "^0.3.2",

View File

@ -7,7 +7,6 @@ dependencies:
'@angular/pwa': 0.1001.7
'@angular/router': 10.1.6_2d3b53e7e463c932a6e73e0760b7a0a2
'@angular/service-worker': 10.1.6_04f723395c0c28a9d9b8a4ace7178ad2
'@circlon/angular-tree-component': 10.0.2_04f723395c0c28a9d9b8a4ace7178ad2
'@ckeditor/ckeditor5-angular': 2.0.1_11b9a698fa893a2ce33633beb1aae14b
'@ckeditor/ckeditor5-build-decoupled-document': 27.0.0
'@convergencelabs/monaco-collab-ext': 0.3.2
@ -1411,19 +1410,6 @@ packages:
dev: true
resolution:
integrity: sha512-tNMDjcv/4DIcHxErTgwB9q2ZcYyN0sUfgGKUK/mm1FJK7Wz+KstoEekxrl/tBiNDgLK1HGi+sppj1An/1DR4fQ==
/@circlon/angular-tree-component/10.0.2_04f723395c0c28a9d9b8a4ace7178ad2:
dependencies:
'@angular/common': 10.1.6_@angular+core@10.1.6+rxjs@6.6.3
'@angular/core': 10.1.6_rxjs@6.6.3+zone.js@0.10.3
lodash-es: 4.17.20
mobx: 4.14.1
tslib: 2.1.0
dev: false
peerDependencies:
'@angular/common': '>=10.0.0 <11.0.0'
'@angular/core': '>=10.0.0 <11.0.0'
resolution:
integrity: sha512-N8KyIQ89fGEO8OKYYgFtY/PbPhHqs4DK5kNPmpJt4KPssvQMF1gP6m+RGVDKlDVH933aOPsrHQax87Fk6dI8gA==
/@ckeditor/ckeditor5-adapter-ckfinder/27.0.0:
dependencies:
ckeditor5: 27.0.0
@ -7011,10 +6997,6 @@ packages:
node: '>=8'
resolution:
integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
/lodash-es/4.17.20:
dev: false
resolution:
integrity: sha512-JD1COMZsq8maT6mnuz1UMV0jvYD0E0aUsSOdrr1/nAG3dhqQXwRRgeW0cSqH1U43INKcqxaiVIQNOUDld7gRDA==
/lodash-es/4.17.21:
dev: false
resolution:
@ -7495,10 +7477,6 @@ packages:
hasBin: true
resolution:
integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
/mobx/4.14.1:
dev: false
resolution:
integrity: sha512-Oyg7Sr7r78b+QPYLufJyUmxTWcqeQ96S1nmtyur3QL8SeI6e0TqcKKcxbG+sVJLWANhHQkBW/mDmgG5DDC4fdw==
/moment/2.29.1:
dev: false
resolution:
@ -11611,7 +11589,6 @@ specifiers:
'@angular/pwa': ^0.1001.7
'@angular/router': ~10.1.5
'@angular/service-worker': ~10.1.5
'@circlon/angular-tree-component': ^10.0.0
'@ckeditor/ckeditor5-angular': ^2.0.1
'@ckeditor/ckeditor5-build-decoupled-document': ^27.0.0
'@convergencelabs/monaco-collab-ext': ^0.3.2

View File

@ -19,7 +19,7 @@
<ion-button fill="outline" color="light" (click)="onDeleteClick()" [disabled]="!deleteTarget">
<ion-icon color="danger" name="trash"></ion-icon>
</ion-button>
<ion-button fill="outline" color="light" (click)="onNodeMenuClick($event)" [disabled]="!menuTarget || !menuTarget.data || !menuTarget.data.id">
<ion-button fill="outline" color="light" (click)="onNodeMenuClick($event)" [disabled]="!menuTarget || !menuTarget.id">
<i class="fa fa-ellipsis-v" style="color: darkgrey"></i>
</ion-button>
<ion-button fill="outline" color="light" (click)="onVirtualRootClear($event)" *ngIf="virtualRootPageId" title="Show entire tree">
@ -30,16 +30,20 @@
</ion-list>
</ion-header>
<ion-content>
<tree-root style="font-size: 15px;" #menuTree [nodes]="nodes" [options]="options" (moveNode)="onTreeNodeMove($event)">
<ng-template #treeNodeTemplate let-node let-index="index">
<span class="tree-node-container" style="display: flex; padding: 5px; width: 100%;" [ngClass]="node.data.type">
<i class="tree-node-icon" [ngClass]="typeIcons[node.data.type]"></i>
<div class="tree-node-name">{{ node.data.name }}</div>
</span>
</ng-template>
</tree-root>
<app-tree-root
#menuTree
[items]="nodes"
iconClassField="faIconClass"
(itemSelected)="onMenuItemClick($event)"
(itemActivated)="onMenuItemActivate($event)"
(itemRightClicked)="onMenuItemRightClick($event)"
></app-tree-root>
</ion-content>
<ion-footer>
<ion-searchbar
placeholder="Quick filter"
(ionChange)="onMenuFilterChange($event)"
></ion-searchbar>
<ion-item button lines="full" (click)="showOptions($event)">
<ion-icon name="list" slot="start"></ion-icon>
<ion-label>Menu</ion-label>

View File

@ -12,8 +12,7 @@ import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';
import { ApiService } from './service/api.service';
import { Router } from '@angular/router';
import {TREE_ACTIONS, TreeComponent} from '@circlon/angular-tree-component';
import {BehaviorSubject, fromEvent, Observable} from 'rxjs';
import {BehaviorSubject, Observable} from 'rxjs';
import {OptionPickerComponent} from './components/option-picker/option-picker.component';
import {OptionMenuComponent} from './components/option-menu/option-menu.component';
import {SelectorComponent} from './components/sharing/selector/selector.component';
@ -26,6 +25,7 @@ import {EditorService} from './service/editor.service';
import {debug} from './utility';
import {AuthService} from './service/auth.service';
import {OpenerService} from './service/opener.service';
import {TreeRootComponent} from './components/tree-root/tree-root.component';
@Component({
selector: 'app-root',
@ -33,82 +33,15 @@ import {OpenerService} from './service/opener.service';
styleUrls: ['app.component.scss']
})
export class AppComponent implements OnInit {
@ViewChild('menuTree') menuTree: TreeComponent;
@ViewChild('menuTree') menuTree: TreeRootComponent;
public readonly ready$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public addChildTarget: any = false;
public deleteTarget: any = false;
public menuTarget: any = false;
public refreshingMenu = false;
public lastClickEvent: Array<any> = [];
public lastClickEvent?: {event: MouseEvent, item: any};
public nodes = [];
public currentPageId: string;
public virtualRootPageId?: string;
public options = {
isExpandedField: 'expanded',
animateExpand: false,
scrollOnActivate: false,
allowDrag: true,
allowDrop: (element, { parent, index }) => {
return (
!this.api.isOffline
&& (element.data.type === 'page' || element.data.type === 'form')
&& (parent.data.type === 'page' || parent.data.userRootPage)
&& !element.data.userRootPage
&& element.data.id !== parent.data.id
);
},
actionMapping: {
mouse: {
dblClick: (tree, node, $event) => {
this.navigateEditorToNode(node);
},
click: (tree, node, $event) => {
TREE_ACTIONS.FOCUS(tree, node, $event);
TREE_ACTIONS.EXPAND(tree, node, $event);
this.addChildTarget = false;
this.deleteTarget = false;
this.menuTarget = false;
if ( !node.data.noChildren && (!node.data.level || node.data.level === 'manage') ) {
this.addChildTarget = node;
}
if ( !node.data.noDelete && (!node.data.level || node.data.level === 'manage') ) {
this.deleteTarget = node;
}
this.menuTarget = node;
this.lastClickEvent = [tree, node, $event];
},
contextMenu: (tree, node, event) => {
event.preventDefault();
event.stopPropagation();
TREE_ACTIONS.FOCUS(tree, node, event);
TREE_ACTIONS.EXPAND(tree, node, event);
this.addChildTarget = false;
this.deleteTarget = false;
this.menuTarget = false;
if ( !node.data.noChildren && (!node.data.level || node.data.level === 'manage') ) {
this.addChildTarget = node;
}
if ( !node.data.noDelete && (!node.data.level || node.data.level === 'manage') ) {
this.deleteTarget = node;
}
this.menuTarget = node;
this.lastClickEvent = [tree, node, event];
this.onNodeMenuClick(event);
},
}
}
};
public typeIcons = NodeTypeIcons;
public get appName(): string {
@ -121,14 +54,12 @@ export class AppComponent implements OnInit {
protected versionInterval?: any;
protected showedNewVersionAlert = false;
protected showedOfflineAlert = false;
protected backbuttonSubscription: any;
constructor(
private platform: Platform,
private splashScreen: SplashScreen,
private statusBar: StatusBar,
public readonly api: ApiService,
protected navCtrl: NavController,
protected router: Router,
protected alerts: AlertController,
protected popover: PopoverController,
@ -143,6 +74,55 @@ export class AppComponent implements OnInit {
protected opener: OpenerService,
) { }
public onMenuItemClick(event: {event: MouseEvent, item: any}) {
this.addChildTarget = false;
this.deleteTarget = false;
this.menuTarget = false;
if ( !event.item.noChildren && (!event.item.level || event.item.level === 'manage') ) {
this.addChildTarget = event.item;
}
if ( !event.item.noDelete && (!event.item.level || event.item.level === 'manage') ) {
this.deleteTarget = event.item;
}
this.menuTarget = event.item;
this.lastClickEvent = event;
}
public onMenuItemActivate(event: {event: MouseEvent, item: any}) {
this.navigateEditorToNode(event.item);
}
public onMenuItemRightClick(event: {event: MouseEvent, item: any}) {
event.event.preventDefault();
event.event.stopPropagation();
this.addChildTarget = false;
this.deleteTarget = false;
this.menuTarget = false;
if ( !event.item.noChildren && (!event.item.level || event.item.level === 'manage') ) {
this.addChildTarget = event.item;
}
if ( !event.item.noDelete && (!event.item.level || event.item.level === 'manage') ) {
this.deleteTarget = event.item;
}
this.menuTarget = event.item;
this.lastClickEvent = event;
this.onNodeMenuClick(event.event, true);
}
public onMenuFilterChange(event) {
const filterValue = event?.detail?.value;
debug('Filtering tree:', filterValue);
this.menuTree?.filterTree(filterValue);
}
async checkNewVersion() {
if ( !this.showedNewVersionAlert && await this.session.newVersionAvailable() ) {
const toast = await this.toasts.create({
@ -236,15 +216,15 @@ export class AppComponent implements OnInit {
}
}
async onNodeMenuClick($event) {
let canManage = this.menuTarget.data.level === 'manage';
async onNodeMenuClick($event, fromContextMenu = false) {
let canManage = this.menuTarget.level === 'manage';
if ( !canManage ) {
if ( !this.menuTarget.data.level ) {
if ( !this.menuTarget.level ) {
canManage = true;
}
}
if ( !this.menuTarget.data.id ) {
if ( !this.menuTarget.id ) {
return;
}
@ -255,10 +235,12 @@ export class AppComponent implements OnInit {
];
const manageOptions = [
...(fromContextMenu ? this.getCreateNodeMenuItems() : []),
{name: 'Share Sub-Tree', icon: 'fa fa-share-alt', value: 'share'},
{name: 'Delete Sub-Tree', icon: 'fa fa-trash noded-danger', value: 'delete'},
];
if ( this.menuTarget.data.bookmark ) {
if ( this.menuTarget.bookmark ) {
options.push({name: 'Remove Bookmark', icon: 'fa fa-star', value: 'bookmark_remove'});
} else {
options.push({name: 'Bookmark', icon: 'fa fa-star', value: 'bookmark_add'});
@ -280,7 +262,7 @@ export class AppComponent implements OnInit {
component: SelectorComponent,
cssClass: 'modal-med',
componentProps: {
node: this.menuTarget.data,
node: this.menuTarget,
}
}).then(modal => {
const modalState = {
@ -302,6 +284,14 @@ export class AppComponent implements OnInit {
this.addBookmark();
} else if ( result.data === 'bookmark_remove' ) {
this.removeBookmark();
} else if ( result.data === 'top-level' ) {
this.onTopLevelCreate();
} else if ( result.data === 'child' ) {
this.onChildCreate();
} else if ( result.data === 'form' ) {
this.onChildCreate('form');
} else if ( result.data === 'delete' ) {
this.onDeleteClick();
}
});
@ -309,9 +299,9 @@ export class AppComponent implements OnInit {
}
async setVirtualRoot() {
if ( this.menuTarget && this.menuTarget.data?.type === 'page' ) {
if ( this.menuTarget && this.menuTarget?.type === 'page' ) {
debug('virtual root menu target', this.menuTarget);
this.virtualRootPageId = this.menuTarget.data.id;
this.virtualRootPageId = this.menuTarget.id;
this.reloadMenuItems().subscribe();
}
}
@ -325,7 +315,7 @@ export class AppComponent implements OnInit {
const exportRecord: any = await new Promise((res, rej) => {
const reqData = {
format: 'html',
PageId: this.menuTarget.data.id,
PageId: this.menuTarget.id,
};
this.api.post(`/exports/subtree`, reqData).subscribe({
@ -343,8 +333,8 @@ export class AppComponent implements OnInit {
addBookmark() {
const bookmarks = this.session.get('user.preferences.bookmark_page_ids') || [];
if ( !bookmarks.includes(this.menuTarget.data.id) ) {
bookmarks.push(this.menuTarget.data.id);
if ( !bookmarks.includes(this.menuTarget.id) ) {
bookmarks.push(this.menuTarget.id);
}
this.session.set('user.preferences.bookmark_page_ids', bookmarks);
@ -353,35 +343,39 @@ export class AppComponent implements OnInit {
removeBookmark() {
let bookmarks = this.session.get('user.preferences.bookmark_page_ids') || [];
bookmarks = bookmarks.filter(x => x !== this.menuTarget.data.id);
bookmarks = bookmarks.filter(x => x !== this.menuTarget.id);
this.session.set('user.preferences.bookmark_page_ids', bookmarks);
this.session.save().then(() => this.navService.requestSidebarRefresh({ quiet: true }));
}
async onCreateClick($event: MouseEvent) {
const menuItems = [
getCreateNodeMenuItems() {
return [
{
name: 'Top-Level Note',
name: 'Create Top-Level Note',
icon: 'fa fa-sticky-note noded-note',
value: 'top-level',
title: 'Create a new top-level note page',
},
...(this.addChildTarget ? [
{
name: 'Child Note',
name: 'Create Child Note',
icon: 'fa fa-sticky-note noded-note',
value: 'child',
title: 'Create a note page as a child of the given note',
},
{
name: 'Form',
name: 'Create Form',
icon: 'fa fa-clipboard-list noded-form',
value: 'form',
title: 'Create a new form page as a child of the given note',
},
] : []),
];
}
async onCreateClick($event: MouseEvent) {
const menuItems = this.getCreateNodeMenuItems();
const popover = await this.popover.create({
event: $event,
@ -459,16 +453,11 @@ export class AppComponent implements OnInit {
handler: async args => {
args = {
name: args.name,
parentId: this.addChildTarget.data.id,
parentId: this.addChildTarget.id,
pageType,
};
this.api.post('/page/create-child', args).subscribe(res => {
this.reloadMenuItems().subscribe(() => {
TREE_ACTIONS.EXPAND(
this.lastClickEvent[0],
this.lastClickEvent[1],
this.lastClickEvent[2]
);
this.router.navigate(['/editor', { id: res.data.UUID }]);
});
});
@ -484,7 +473,7 @@ export class AppComponent implements OnInit {
const alert = await this.alerts.create({
header: 'Delete page?',
message:
'Deleting this page will make its contents and all of its children inaccessible. Are you sure you want to continue?',
'Deleting this page will make its contents and all of its children inaccessible. Are you sure you want to continue?',
buttons: [
{
text: 'Keep It',
@ -494,9 +483,9 @@ export class AppComponent implements OnInit {
text: 'Delete It',
handler: async () => {
this.api
.post(`/page/delete/${this.deleteTarget.data.id}`)
.post(`/page/delete/${this.deleteTarget.id}`)
.subscribe(res => {
if ( this.opener.currentPageId === this.deleteTarget.data.id ) {
if ( this.opener.currentPageId === this.deleteTarget.id ) {
this.router.navigate(['/home']);
}
@ -717,7 +706,7 @@ export class AppComponent implements OnInit {
setTimeout(() => {
this.loader.dismiss();
this.menuTree?.treeModel?.expandAll();
// this.menuTree?.treeModel?.expandAll();
}, 10);
if ( !this.versionInterval ) {

View File

@ -10,7 +10,6 @@ import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { HttpClientModule } from '@angular/common/http';
import { ComponentsModule } from './components/components.module';
import { TreeModule } from '@circlon/angular-tree-component';
import {AgGridModule} from 'ag-grid-angular';
import {MonacoEditorModule} from 'ngx-monaco-editor';
import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
@ -47,7 +46,6 @@ export function getBaseHref(platformLocation: PlatformLocation): string {
AppRoutingModule,
HttpClientModule,
ComponentsModule,
TreeModule,
AgGridModule.withComponents([]),
MonacoEditorModule.forRoot(),
HighlightModule,

View File

@ -26,6 +26,7 @@ import {DatetimeRendererComponent} from './editor/database/renderers/datetime-re
import {CurrencyRendererComponent} from './editor/database/renderers/currency-renderer.component';
import {BooleanRendererComponent} from './editor/database/renderers/boolean-renderer.component';
import {SearchComponent} from './search/Search.component';
import {TreeRootComponent} from './tree-root/tree-root.component';
import {NormComponent} from './nodes/norm/norm.component';
import {MarkdownComponent as MarkdownEditorComponent} from './nodes/markdown/markdown.component';
@ -74,6 +75,7 @@ import {FileBoxPageComponent} from './nodes/file-box/file-box-page.component';
CurrencyRendererComponent,
BooleanRendererComponent,
SearchComponent,
TreeRootComponent,
NormComponent,
MarkdownEditorComponent,
@ -131,6 +133,7 @@ import {FileBoxPageComponent} from './nodes/file-box/file-box-page.component';
CurrencyRendererComponent,
BooleanRendererComponent,
SearchComponent,
TreeRootComponent,
NormComponent,
MarkdownEditorComponent,
@ -173,6 +176,7 @@ import {FileBoxPageComponent} from './nodes/file-box/file-box-page.component';
CurrencyRendererComponent,
BooleanRendererComponent,
SearchComponent,
TreeRootComponent,
NormComponent,
MarkdownEditorComponent,

View File

@ -0,0 +1,37 @@
<div [ngClass]="{container: true, dark: isDark()}">
<ng-container *ngFor="let item of items">
<div
class="item"
*ngIf="!item.__app_tree_comp_is_hidden"
(click)="onItemClick($event, item)"
(dblclick)="onItemDoubleClick($event, item)"
(contextmenu)="onItemRightClick($event, item)"
[ngClass]="{activated: selectedItem === item}"
>
<i
*ngIf="hasNesting(item) && !item.__app_tree_comp_is_collapsed"
(click)="onItemHandleClick($event, item)"
class="fa fa-chevron-down handle handle-expanded"
></i>
<i
*ngIf="hasNesting(item) && item.__app_tree_comp_is_collapsed"
(click)="onItemHandleClick($event, item)"
class="fa fa-chevron-right handle handle-collapsed"
></i>
<i *ngIf="iconClassField" [ngClass]="item[iconClassField]"></i>
{{ item[displayField] }}
</div>
<div class="nested-level" *ngIf="hasNesting(item, true) && !item.__app_tree_comp_is_collapsed">
<app-tree-root
#childComponents
[displayField]="displayField"
[childrenField]="childrenField"
[nestingLevel]="nestingLevel + 1"
[iconClassField]="iconClassField"
[items]="item[childrenField]"
[parent]="this"
[parentItem]="item"
></app-tree-root>
</div>
</ng-container>
</div>

View File

@ -0,0 +1,38 @@
.container {
display: flex;
flex-direction: column;
}
.nested-level {
margin-left: 20px;
}
.item {
padding: 10px;
border-bottom: 1px solid #cccccc;
border-left: 1px solid #cccccc;
user-select: none;
i {
padding-right: 10px;
&.handle {
color: #888888;
}
}
&:hover, &.activated {
background-color: #f0f0f0;
cursor: pointer;
}
}
.container.dark {
.item {
border-color: #555555;
&:hover, &.activated {
background-color: #333333;
}
}
}

View File

@ -0,0 +1,253 @@
import {Component, EventEmitter, Input, OnInit, Output, ViewChildren} from '@angular/core';
import {debug} from '../../utility';
/**
* A recursive tree listing component.
*/
@Component({
selector: 'app-tree-root',
templateUrl: './tree-root.component.html',
styleUrls: ['./tree-root.component.scss'],
})
export class TreeRootComponent implements OnInit {
/** The immediate children of this component. */
@ViewChildren('childComponents')
protected childComponents;
/** How deeply nested this component is in the tree. */
@Input()
public nestingLevel = 0;
/** Field on the listing record with the display string. */
@Input()
public displayField = 'name';
/** Field on the listing record that contains the children of an item. */
@Input()
public childrenField = 'children';
/** Optionally, field on the listing record that contains the <i> icon class. */
@Input()
public iconClassField?: string;
/** The nested listing items. */
@Input()
public items: any[] = [];
/** @private Set by the parent component when recursively nested. */
@Input()
public parent?: TreeRootComponent;
/** @private Item object that created this listing when recursively nested. */
@Input()
public parentItem?: any;
/** Emits the selected item, when top-level tree. */
@Output()
public itemSelected: EventEmitter<{ event: MouseEvent, item: any }> = new EventEmitter<{event: MouseEvent; item: any}>();
/** Emits the activated item, when top-level tree. */
@Output()
public itemActivated: EventEmitter<{ event: MouseEvent, item: any }> = new EventEmitter<{event: MouseEvent; item: any}>();
/** Emits the right-clicked item, when top-level tree. */
@Output()
public itemRightClicked: EventEmitter<{ event: MouseEvent, item: any }> = new EventEmitter<{event: MouseEvent; item: any}>();
/** The selected item record at this level, if one exists. */
public selectedItem?: any;
/** The current tree filter value, if one exists. */
protected filterValue?: string;
/** Called on initialization. */
ngOnInit() {
if ( !this.parent ) {
debug('Top-level tree root component:', this);
}
}
/** True if we're in dark mode. */
public isDark() {
return document.body.classList.contains('dark');
}
/** Recursively filter the tree using the given quick-filter string. */
public filterTree(filterValue?: string) {
this.setFilterRecursively(filterValue);
}
/** Emit an item selected event, or bubble up to the parent. */
public emitItemSelected(event: MouseEvent, item: any) {
if ( this.parent ) {
return this.parent.emitItemSelected(event, item);
}
this.itemSelected.emit({
event,
item,
});
}
/** Emit an item activated event, or bubble up to the parent. */
public emitItemActivated(event: MouseEvent, item: any) {
if ( this.parent ) {
return this.parent.emitItemActivated(event, item);
}
this.itemActivated.emit({
event,
item,
});
}
/** Emit an item right-clicked event, or bubble up to the parent. */
public emitItemRightClicked(event: MouseEvent, item: any) {
if ( this.parent ) {
return this.parent.emitItemRightClicked(event, item);
}
this.itemRightClicked.emit({
event,
item,
});
}
/** Wrapper for checking arrays for use in NG templates. */
public isArray(something: unknown): boolean {
return Array.isArray(something);
}
/**
* Returns true if an item has nested children.
* @param item
* @param includeHidden - if true, will include children hidden by the quick-filter
*/
public hasNesting(item: any, includeHidden = false): boolean {
const hasUnfilteredNesting = item && this.isArray(item[this.childrenField]) && item[this.childrenField].length;
if ( !this.filterValue || includeHidden ) {
return hasUnfilteredNesting;
}
return hasUnfilteredNesting && item[this.childrenField].some(child => !child.__app_tree_comp_is_hidden);
}
/** Called when an item expand/collapse arrow is clicked. */
public onItemHandleClick(event: MouseEvent, item: any) {
if ( this.hasNesting(item) ) {
this.setExpansionRecursively(item, !item.__app_tree_comp_is_collapsed);
}
}
/** Called when an item is clicked. */
public onItemClick(event: MouseEvent, item: any) {
this.clearSelection();
this.selectedItem = item;
this.emitItemSelected(event, item);
}
/** Called when an item is double-clicked. */
public onItemDoubleClick(event: MouseEvent, item: any) {
this.emitItemActivated(event, item);
}
/** Called when an item is right-clicked. */
public onItemRightClick(event: MouseEvent, item: any) {
this.clearSelection();
this.selectedItem = item;
this.emitItemRightClicked(event, item);
}
/** Recursively clear the selection from the entire tree, calling children and bubbling up to parent. */
public clearSelection(from?: TreeRootComponent) {
this.selectedItem = undefined;
if ( this.childComponents ) {
for ( const child of this.childComponents ) {
if ( child === from ) {
continue;
}
child.clearSelection(this);
}
}
if ( this.parent ) {
if ( this.parent !== from ) {
this.parent.clearSelection(this);
}
}
}
/**
* Recursively set the filter on this subtree.
* Returns true if any of the children of this level match the filter.
* @param filterValue
* @param from
* @protected
*/
protected setFilterRecursively(filterValue?: string, from?: TreeRootComponent): boolean {
this.filterValue = filterValue;
let anyChildMatchesFilter = false;
for ( const item of this.items ) {
item.__app_tree_comp_is_hidden = this.getFilterHiddenStatusForItem(item);
anyChildMatchesFilter = anyChildMatchesFilter || !item.__app_tree_comp_is_hidden;
}
return anyChildMatchesFilter;
}
/**
* Returns true if the given item should be hidden with the current quick-filter.
* @param item
* @protected
*/
protected getFilterHiddenStatusForItem(item: any): boolean {
const itemMatchesFilter = (
!this.filterValue
|| (
item[this.displayField]?.trim()?.toLowerCase()?.includes(this.filterValue?.trim()?.toLowerCase())
)
);
if ( !this.hasNesting(item, true) ) {
return !itemMatchesFilter;
}
let anyChildMatchesFilter = false;
const childTree = this.getChildComponentForItem(item);
if ( childTree ) {
anyChildMatchesFilter = childTree.setFilterRecursively(this.filterValue, this);
} else {
debug('Unable to find childTree for item:', item[this.displayField]);
}
return !(!this.filterValue || itemMatchesFilter || anyChildMatchesFilter);
}
/**
* Get the TreeRootComponent instance for some item's children, if it exists.
* @param item
* @protected
*/
protected getChildComponentForItem(item: any): TreeRootComponent | undefined {
return [...(this.childComponents || [])]?.find(child => {
return child.parentItem === item;
});
}
/**
* Expand or collapse an item recursively.
* @param item
* @param isCollapsed
*/
public setExpansionRecursively(item: any, isCollapsed: boolean) {
item.__app_tree_comp_is_collapsed = isCollapsed;
if ( this.hasNesting(item, true) && isCollapsed ) {
for ( const child of item.children ) {
this.setExpansionRecursively(child, isCollapsed);
}
}
}
}

View File

@ -15,6 +15,7 @@ import {Page} from './db/Page';
import {PageNode} from './db/PageNode';
import {debug} from '../utility';
import HostRecord from '../structures/HostRecord';
import {NodeTypeIcons} from '../structures/node-types';
export class ResourceNotAvailableOfflineError extends Error {
constructor(msg = 'This resource is not yet available offline on this device.') {
@ -558,7 +559,7 @@ export class ApiService {
const tree: any[] = await new Promise(res2 => {
this.get(loadUrl).subscribe({
next: async result => {
const nodes = result.data as any[];
const nodes = this.setMenuItemIconKeys(result.data as any[]);
const items = MenuItem.deflateTree(nodes);
// Update the locally stored nodes
@ -577,6 +578,21 @@ export class ApiService {
});
}
protected setMenuItemIconKeys(nodes: any[]): any[] {
return nodes.map(node => {
node.faIconClass = NodeTypeIcons[node.type];
if ( !node.faIconClass ) {
debug('Unable to map type icon class for menu item type:', node.faIconClass, node);
}
if ( Array.isArray(node.children) ) {
node.children = this.setMenuItemIconKeys(node.children);
}
return node;
});
}
public getSessionData(): Promise<any> {
return new Promise(async (res, rej) => {
const sessionKV = await this.db.getKeyValue('session_data');

View File

@ -13,6 +13,7 @@ export interface IMenuItem {
shared?: boolean;
needsServerUpdate?: boolean;
offlineUpdatedAt?: string;
faIconClass?: string;
}
export class MenuItem extends Model<IMenuItem> implements IMenuItem {
@ -27,13 +28,15 @@ export class MenuItem extends Model<IMenuItem> implements IMenuItem {
shared?: boolean;
needsServerUpdate?: boolean;
offlineUpdatedAt?: string;
faIconClass?: string;
public static getTableName() {
return 'menuItems';
}
public static getSchema() {
return '++id, serverId, name, childIds, noDelete, noChildren, virtual, type, shared, needsServerUpdate, offlineUpdatedAt';
// tslint:disable-next-line:max-line-length
return '++id, serverId, name, childIds, noDelete, noChildren, virtual, type, shared, needsServerUpdate, offlineUpdatedAt, faIconClass';
}
public static deflateTree(nodes: any[]): MenuItem[] {
@ -41,7 +44,17 @@ export class MenuItem extends Model<IMenuItem> implements IMenuItem {
for ( const node of nodes ) {
const childIds = node.children ? node.children.map(x => x.id) : [];
const item = new MenuItem(node.name, node.id, childIds, node.noDelete, node.noChildren, node.virtual, node.type, node.shared);
const item = new MenuItem(
node.name,
node.id,
childIds,
node.noDelete,
node.noChildren,
node.virtual,
node.type,
node.shared,
node.faIconClass
);
items.push(item);
if ( node.children ) {
@ -114,6 +127,7 @@ export class MenuItem extends Model<IMenuItem> implements IMenuItem {
shared?: boolean,
needsServerUpdate?: boolean,
offlineUpdatedAt?: string,
faIconClass?: string,
id?: number
) {
super();
@ -152,6 +166,8 @@ export class MenuItem extends Model<IMenuItem> implements IMenuItem {
this.offlineUpdatedAt = offlineUpdatedAt;
}
this.faIconClass = faIconClass;
if ( id ) {
this.id = id;
}
@ -174,6 +190,7 @@ export class MenuItem extends Model<IMenuItem> implements IMenuItem {
...(typeof this.shared !== 'undefined' ? { shared: this.shared } : {}),
...(typeof this.needsServerUpdate !== 'undefined' ? { needsServerUpdate: this.needsServerUpdate } : {}),
...(typeof this.offlineUpdatedAt !== 'undefined' ? { offlineUpdatedAt: this.offlineUpdatedAt } : {}),
...(typeof this.faIconClass !== 'undefined' ? { faIconClass: this.faIconClass } : {}),
};
}
}

View File

@ -25,7 +25,6 @@
@import "~@ionic/angular/css/text-transformation.css";
@import "~@ionic/angular/css/flex-utils.css";
@import '~@circlon/angular-tree-component/css/angular-tree-component.css';
@import "~ag-grid-community/dist/styles/ag-grid.css";
@import "~ag-grid-community/dist/styles/ag-theme-balham.css";
@import "~ag-grid-community/dist/styles/ag-theme-balham-dark.css";
@ -61,6 +60,8 @@
--noded-background-form: #F2C57C;
--noded-color-form: white;
--noded-background-form-hover: #F8DEB5;
--noded-color-danger: #dd2222
}
.noded-note {
@ -91,6 +92,10 @@
color: var(--noded-background-form);
}
.noded-danger {
color: var(--noded-color-danger);
}
div.picker-wrapper {
border: 2px solid lightgrey !important;
border-radius: 7px !important;
@ -173,6 +178,13 @@ ionic-selectable-modal {
}
}
.modal-tall {
.modal-wrapper {
min-height: calc(100vh - 30px);
min-width: calc(100vw - 200px);
}
}
.modal-med {
.modal-wrapper {
min-height: calc(100vh - 100px);

View File

@ -64,3 +64,4 @@ import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/
(window as any).global = window;