#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/pwa": "^0.1001.7",
"@angular/router": "~10.1.5", "@angular/router": "~10.1.5",
"@angular/service-worker": "~10.1.5", "@angular/service-worker": "~10.1.5",
"@circlon/angular-tree-component": "^10.0.0",
"@ckeditor/ckeditor5-angular": "^2.0.1", "@ckeditor/ckeditor5-angular": "^2.0.1",
"@ckeditor/ckeditor5-build-decoupled-document": "^27.0.0", "@ckeditor/ckeditor5-build-decoupled-document": "^27.0.0",
"@convergencelabs/monaco-collab-ext": "^0.3.2", "@convergencelabs/monaco-collab-ext": "^0.3.2",

View File

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

View File

@ -19,7 +19,7 @@
<ion-button fill="outline" color="light" (click)="onDeleteClick()" [disabled]="!deleteTarget"> <ion-button fill="outline" color="light" (click)="onDeleteClick()" [disabled]="!deleteTarget">
<ion-icon color="danger" name="trash"></ion-icon> <ion-icon color="danger" name="trash"></ion-icon>
</ion-button> </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> <i class="fa fa-ellipsis-v" style="color: darkgrey"></i>
</ion-button> </ion-button>
<ion-button fill="outline" color="light" (click)="onVirtualRootClear($event)" *ngIf="virtualRootPageId" title="Show entire tree"> <ion-button fill="outline" color="light" (click)="onVirtualRootClear($event)" *ngIf="virtualRootPageId" title="Show entire tree">
@ -30,16 +30,20 @@
</ion-list> </ion-list>
</ion-header> </ion-header>
<ion-content> <ion-content>
<tree-root style="font-size: 15px;" #menuTree [nodes]="nodes" [options]="options" (moveNode)="onTreeNodeMove($event)"> <app-tree-root
<ng-template #treeNodeTemplate let-node let-index="index"> #menuTree
<span class="tree-node-container" style="display: flex; padding: 5px; width: 100%;" [ngClass]="node.data.type"> [items]="nodes"
<i class="tree-node-icon" [ngClass]="typeIcons[node.data.type]"></i> iconClassField="faIconClass"
<div class="tree-node-name">{{ node.data.name }}</div> (itemSelected)="onMenuItemClick($event)"
</span> (itemActivated)="onMenuItemActivate($event)"
</ng-template> (itemRightClicked)="onMenuItemRightClick($event)"
</tree-root> ></app-tree-root>
</ion-content> </ion-content>
<ion-footer> <ion-footer>
<ion-searchbar
placeholder="Quick filter"
(ionChange)="onMenuFilterChange($event)"
></ion-searchbar>
<ion-item button lines="full" (click)="showOptions($event)"> <ion-item button lines="full" (click)="showOptions($event)">
<ion-icon name="list" slot="start"></ion-icon> <ion-icon name="list" slot="start"></ion-icon>
<ion-label>Menu</ion-label> <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 { StatusBar } from '@ionic-native/status-bar/ngx';
import { ApiService } from './service/api.service'; import { ApiService } from './service/api.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import {TREE_ACTIONS, TreeComponent} from '@circlon/angular-tree-component'; import {BehaviorSubject, Observable} from 'rxjs';
import {BehaviorSubject, fromEvent, Observable} from 'rxjs';
import {OptionPickerComponent} from './components/option-picker/option-picker.component'; import {OptionPickerComponent} from './components/option-picker/option-picker.component';
import {OptionMenuComponent} from './components/option-menu/option-menu.component'; import {OptionMenuComponent} from './components/option-menu/option-menu.component';
import {SelectorComponent} from './components/sharing/selector/selector.component'; import {SelectorComponent} from './components/sharing/selector/selector.component';
@ -26,6 +25,7 @@ import {EditorService} from './service/editor.service';
import {debug} from './utility'; import {debug} from './utility';
import {AuthService} from './service/auth.service'; import {AuthService} from './service/auth.service';
import {OpenerService} from './service/opener.service'; import {OpenerService} from './service/opener.service';
import {TreeRootComponent} from './components/tree-root/tree-root.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -33,82 +33,15 @@ import {OpenerService} from './service/opener.service';
styleUrls: ['app.component.scss'] styleUrls: ['app.component.scss']
}) })
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
@ViewChild('menuTree') menuTree: TreeComponent; @ViewChild('menuTree') menuTree: TreeRootComponent;
public readonly ready$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false); public readonly ready$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public addChildTarget: any = false; public addChildTarget: any = false;
public deleteTarget: any = false; public deleteTarget: any = false;
public menuTarget: any = false; public menuTarget: any = false;
public refreshingMenu = false; public refreshingMenu = false;
public lastClickEvent: Array<any> = []; public lastClickEvent?: {event: MouseEvent, item: any};
public nodes = []; public nodes = [];
public currentPageId: string;
public virtualRootPageId?: 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 typeIcons = NodeTypeIcons;
public get appName(): string { public get appName(): string {
@ -121,14 +54,12 @@ export class AppComponent implements OnInit {
protected versionInterval?: any; protected versionInterval?: any;
protected showedNewVersionAlert = false; protected showedNewVersionAlert = false;
protected showedOfflineAlert = false; protected showedOfflineAlert = false;
protected backbuttonSubscription: any;
constructor( constructor(
private platform: Platform, private platform: Platform,
private splashScreen: SplashScreen, private splashScreen: SplashScreen,
private statusBar: StatusBar, private statusBar: StatusBar,
public readonly api: ApiService, public readonly api: ApiService,
protected navCtrl: NavController,
protected router: Router, protected router: Router,
protected alerts: AlertController, protected alerts: AlertController,
protected popover: PopoverController, protected popover: PopoverController,
@ -143,6 +74,55 @@ export class AppComponent implements OnInit {
protected opener: OpenerService, 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() { async checkNewVersion() {
if ( !this.showedNewVersionAlert && await this.session.newVersionAvailable() ) { if ( !this.showedNewVersionAlert && await this.session.newVersionAvailable() ) {
const toast = await this.toasts.create({ const toast = await this.toasts.create({
@ -236,15 +216,15 @@ export class AppComponent implements OnInit {
} }
} }
async onNodeMenuClick($event) { async onNodeMenuClick($event, fromContextMenu = false) {
let canManage = this.menuTarget.data.level === 'manage'; let canManage = this.menuTarget.level === 'manage';
if ( !canManage ) { if ( !canManage ) {
if ( !this.menuTarget.data.level ) { if ( !this.menuTarget.level ) {
canManage = true; canManage = true;
} }
} }
if ( !this.menuTarget.data.id ) { if ( !this.menuTarget.id ) {
return; return;
} }
@ -255,10 +235,12 @@ export class AppComponent implements OnInit {
]; ];
const manageOptions = [ const manageOptions = [
...(fromContextMenu ? this.getCreateNodeMenuItems() : []),
{name: 'Share Sub-Tree', icon: 'fa fa-share-alt', value: 'share'}, {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'}); options.push({name: 'Remove Bookmark', icon: 'fa fa-star', value: 'bookmark_remove'});
} else { } else {
options.push({name: 'Bookmark', icon: 'fa fa-star', value: 'bookmark_add'}); options.push({name: 'Bookmark', icon: 'fa fa-star', value: 'bookmark_add'});
@ -280,7 +262,7 @@ export class AppComponent implements OnInit {
component: SelectorComponent, component: SelectorComponent,
cssClass: 'modal-med', cssClass: 'modal-med',
componentProps: { componentProps: {
node: this.menuTarget.data, node: this.menuTarget,
} }
}).then(modal => { }).then(modal => {
const modalState = { const modalState = {
@ -302,6 +284,14 @@ export class AppComponent implements OnInit {
this.addBookmark(); this.addBookmark();
} else if ( result.data === 'bookmark_remove' ) { } else if ( result.data === 'bookmark_remove' ) {
this.removeBookmark(); 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() { async setVirtualRoot() {
if ( this.menuTarget && this.menuTarget.data?.type === 'page' ) { if ( this.menuTarget && this.menuTarget?.type === 'page' ) {
debug('virtual root menu target', this.menuTarget); debug('virtual root menu target', this.menuTarget);
this.virtualRootPageId = this.menuTarget.data.id; this.virtualRootPageId = this.menuTarget.id;
this.reloadMenuItems().subscribe(); this.reloadMenuItems().subscribe();
} }
} }
@ -325,7 +315,7 @@ export class AppComponent implements OnInit {
const exportRecord: any = await new Promise((res, rej) => { const exportRecord: any = await new Promise((res, rej) => {
const reqData = { const reqData = {
format: 'html', format: 'html',
PageId: this.menuTarget.data.id, PageId: this.menuTarget.id,
}; };
this.api.post(`/exports/subtree`, reqData).subscribe({ this.api.post(`/exports/subtree`, reqData).subscribe({
@ -343,8 +333,8 @@ export class AppComponent implements OnInit {
addBookmark() { addBookmark() {
const bookmarks = this.session.get('user.preferences.bookmark_page_ids') || []; const bookmarks = this.session.get('user.preferences.bookmark_page_ids') || [];
if ( !bookmarks.includes(this.menuTarget.data.id) ) { if ( !bookmarks.includes(this.menuTarget.id) ) {
bookmarks.push(this.menuTarget.data.id); bookmarks.push(this.menuTarget.id);
} }
this.session.set('user.preferences.bookmark_page_ids', bookmarks); this.session.set('user.preferences.bookmark_page_ids', bookmarks);
@ -353,35 +343,39 @@ export class AppComponent implements OnInit {
removeBookmark() { removeBookmark() {
let bookmarks = this.session.get('user.preferences.bookmark_page_ids') || []; 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.set('user.preferences.bookmark_page_ids', bookmarks);
this.session.save().then(() => this.navService.requestSidebarRefresh({ quiet: true })); this.session.save().then(() => this.navService.requestSidebarRefresh({ quiet: true }));
} }
async onCreateClick($event: MouseEvent) { getCreateNodeMenuItems() {
const menuItems = [ return [
{ {
name: 'Top-Level Note', name: 'Create Top-Level Note',
icon: 'fa fa-sticky-note noded-note', icon: 'fa fa-sticky-note noded-note',
value: 'top-level', value: 'top-level',
title: 'Create a new top-level note page', title: 'Create a new top-level note page',
}, },
...(this.addChildTarget ? [ ...(this.addChildTarget ? [
{ {
name: 'Child Note', name: 'Create Child Note',
icon: 'fa fa-sticky-note noded-note', icon: 'fa fa-sticky-note noded-note',
value: 'child', value: 'child',
title: 'Create a note page as a child of the given note', title: 'Create a note page as a child of the given note',
}, },
{ {
name: 'Form', name: 'Create Form',
icon: 'fa fa-clipboard-list noded-form', icon: 'fa fa-clipboard-list noded-form',
value: 'form', value: 'form',
title: 'Create a new form page as a child of the given note', 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({ const popover = await this.popover.create({
event: $event, event: $event,
@ -459,16 +453,11 @@ export class AppComponent implements OnInit {
handler: async args => { handler: async args => {
args = { args = {
name: args.name, name: args.name,
parentId: this.addChildTarget.data.id, parentId: this.addChildTarget.id,
pageType, pageType,
}; };
this.api.post('/page/create-child', args).subscribe(res => { this.api.post('/page/create-child', args).subscribe(res => {
this.reloadMenuItems().subscribe(() => { this.reloadMenuItems().subscribe(() => {
TREE_ACTIONS.EXPAND(
this.lastClickEvent[0],
this.lastClickEvent[1],
this.lastClickEvent[2]
);
this.router.navigate(['/editor', { id: res.data.UUID }]); this.router.navigate(['/editor', { id: res.data.UUID }]);
}); });
}); });
@ -484,7 +473,7 @@ export class AppComponent implements OnInit {
const alert = await this.alerts.create({ const alert = await this.alerts.create({
header: 'Delete page?', header: 'Delete page?',
message: 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: [ buttons: [
{ {
text: 'Keep It', text: 'Keep It',
@ -494,9 +483,9 @@ export class AppComponent implements OnInit {
text: 'Delete It', text: 'Delete It',
handler: async () => { handler: async () => {
this.api this.api
.post(`/page/delete/${this.deleteTarget.data.id}`) .post(`/page/delete/${this.deleteTarget.id}`)
.subscribe(res => { .subscribe(res => {
if ( this.opener.currentPageId === this.deleteTarget.data.id ) { if ( this.opener.currentPageId === this.deleteTarget.id ) {
this.router.navigate(['/home']); this.router.navigate(['/home']);
} }
@ -717,7 +706,7 @@ export class AppComponent implements OnInit {
setTimeout(() => { setTimeout(() => {
this.loader.dismiss(); this.loader.dismiss();
this.menuTree?.treeModel?.expandAll(); // this.menuTree?.treeModel?.expandAll();
}, 10); }, 10);
if ( !this.versionInterval ) { if ( !this.versionInterval ) {

View File

@ -10,7 +10,6 @@ import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { ComponentsModule } from './components/components.module'; import { ComponentsModule } from './components/components.module';
import { TreeModule } from '@circlon/angular-tree-component';
import {AgGridModule} from 'ag-grid-angular'; import {AgGridModule} from 'ag-grid-angular';
import {MonacoEditorModule} from 'ngx-monaco-editor'; import {MonacoEditorModule} from 'ngx-monaco-editor';
import { APP_BASE_HREF, PlatformLocation } from '@angular/common'; import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
@ -47,7 +46,6 @@ export function getBaseHref(platformLocation: PlatformLocation): string {
AppRoutingModule, AppRoutingModule,
HttpClientModule, HttpClientModule,
ComponentsModule, ComponentsModule,
TreeModule,
AgGridModule.withComponents([]), AgGridModule.withComponents([]),
MonacoEditorModule.forRoot(), MonacoEditorModule.forRoot(),
HighlightModule, 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 {CurrencyRendererComponent} from './editor/database/renderers/currency-renderer.component';
import {BooleanRendererComponent} from './editor/database/renderers/boolean-renderer.component'; import {BooleanRendererComponent} from './editor/database/renderers/boolean-renderer.component';
import {SearchComponent} from './search/Search.component'; import {SearchComponent} from './search/Search.component';
import {TreeRootComponent} from './tree-root/tree-root.component';
import {NormComponent} from './nodes/norm/norm.component'; import {NormComponent} from './nodes/norm/norm.component';
import {MarkdownComponent as MarkdownEditorComponent} from './nodes/markdown/markdown.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, CurrencyRendererComponent,
BooleanRendererComponent, BooleanRendererComponent,
SearchComponent, SearchComponent,
TreeRootComponent,
NormComponent, NormComponent,
MarkdownEditorComponent, MarkdownEditorComponent,
@ -131,6 +133,7 @@ import {FileBoxPageComponent} from './nodes/file-box/file-box-page.component';
CurrencyRendererComponent, CurrencyRendererComponent,
BooleanRendererComponent, BooleanRendererComponent,
SearchComponent, SearchComponent,
TreeRootComponent,
NormComponent, NormComponent,
MarkdownEditorComponent, MarkdownEditorComponent,
@ -173,6 +176,7 @@ import {FileBoxPageComponent} from './nodes/file-box/file-box-page.component';
CurrencyRendererComponent, CurrencyRendererComponent,
BooleanRendererComponent, BooleanRendererComponent,
SearchComponent, SearchComponent,
TreeRootComponent,
NormComponent, NormComponent,
MarkdownEditorComponent, 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 {PageNode} from './db/PageNode';
import {debug} from '../utility'; import {debug} from '../utility';
import HostRecord from '../structures/HostRecord'; import HostRecord from '../structures/HostRecord';
import {NodeTypeIcons} from '../structures/node-types';
export class ResourceNotAvailableOfflineError extends Error { export class ResourceNotAvailableOfflineError extends Error {
constructor(msg = 'This resource is not yet available offline on this device.') { 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 => { const tree: any[] = await new Promise(res2 => {
this.get(loadUrl).subscribe({ this.get(loadUrl).subscribe({
next: async result => { next: async result => {
const nodes = result.data as any[]; const nodes = this.setMenuItemIconKeys(result.data as any[]);
const items = MenuItem.deflateTree(nodes); const items = MenuItem.deflateTree(nodes);
// Update the locally stored 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> { public getSessionData(): Promise<any> {
return new Promise(async (res, rej) => { return new Promise(async (res, rej) => {
const sessionKV = await this.db.getKeyValue('session_data'); const sessionKV = await this.db.getKeyValue('session_data');

View File

@ -13,6 +13,7 @@ export interface IMenuItem {
shared?: boolean; shared?: boolean;
needsServerUpdate?: boolean; needsServerUpdate?: boolean;
offlineUpdatedAt?: string; offlineUpdatedAt?: string;
faIconClass?: string;
} }
export class MenuItem extends Model<IMenuItem> implements IMenuItem { export class MenuItem extends Model<IMenuItem> implements IMenuItem {
@ -27,13 +28,15 @@ export class MenuItem extends Model<IMenuItem> implements IMenuItem {
shared?: boolean; shared?: boolean;
needsServerUpdate?: boolean; needsServerUpdate?: boolean;
offlineUpdatedAt?: string; offlineUpdatedAt?: string;
faIconClass?: string;
public static getTableName() { public static getTableName() {
return 'menuItems'; return 'menuItems';
} }
public static getSchema() { 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[] { public static deflateTree(nodes: any[]): MenuItem[] {
@ -41,7 +44,17 @@ export class MenuItem extends Model<IMenuItem> implements IMenuItem {
for ( const node of nodes ) { for ( const node of nodes ) {
const childIds = node.children ? node.children.map(x => x.id) : []; 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); items.push(item);
if ( node.children ) { if ( node.children ) {
@ -114,6 +127,7 @@ export class MenuItem extends Model<IMenuItem> implements IMenuItem {
shared?: boolean, shared?: boolean,
needsServerUpdate?: boolean, needsServerUpdate?: boolean,
offlineUpdatedAt?: string, offlineUpdatedAt?: string,
faIconClass?: string,
id?: number id?: number
) { ) {
super(); super();
@ -152,6 +166,8 @@ export class MenuItem extends Model<IMenuItem> implements IMenuItem {
this.offlineUpdatedAt = offlineUpdatedAt; this.offlineUpdatedAt = offlineUpdatedAt;
} }
this.faIconClass = faIconClass;
if ( id ) { if ( id ) {
this.id = 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.shared !== 'undefined' ? { shared: this.shared } : {}),
...(typeof this.needsServerUpdate !== 'undefined' ? { needsServerUpdate: this.needsServerUpdate } : {}), ...(typeof this.needsServerUpdate !== 'undefined' ? { needsServerUpdate: this.needsServerUpdate } : {}),
...(typeof this.offlineUpdatedAt !== 'undefined' ? { offlineUpdatedAt: this.offlineUpdatedAt } : {}), ...(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/text-transformation.css";
@import "~@ionic/angular/css/flex-utils.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-grid.css";
@import "~ag-grid-community/dist/styles/ag-theme-balham.css"; @import "~ag-grid-community/dist/styles/ag-theme-balham.css";
@import "~ag-grid-community/dist/styles/ag-theme-balham-dark.css"; @import "~ag-grid-community/dist/styles/ag-theme-balham-dark.css";
@ -61,6 +60,8 @@
--noded-background-form: #F2C57C; --noded-background-form: #F2C57C;
--noded-color-form: white; --noded-color-form: white;
--noded-background-form-hover: #F8DEB5; --noded-background-form-hover: #F8DEB5;
--noded-color-danger: #dd2222
} }
.noded-note { .noded-note {
@ -91,6 +92,10 @@
color: var(--noded-background-form); color: var(--noded-background-form);
} }
.noded-danger {
color: var(--noded-color-danger);
}
div.picker-wrapper { div.picker-wrapper {
border: 2px solid lightgrey !important; border: 2px solid lightgrey !important;
border-radius: 7px !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-med {
.modal-wrapper { .modal-wrapper {
min-height: calc(100vh - 100px); min-height: calc(100vh - 100px);

View File

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