Implement sub-tree sharing; read-only pages

This commit is contained in:
garrettmills
2020-02-14 00:14:09 -06:00
parent 1eda3d0b30
commit 9f361896ee
21 changed files with 394 additions and 95 deletions

View File

@@ -12,6 +12,8 @@ import {MonacoEditorModule} from 'ngx-monaco-editor';
import {FilesComponent} from './editor/files/files.component';
import {OptionPickerComponent} from './option-picker/option-picker.component';
import {HostOptionsComponent} from './editor/host-options/host-options.component';
import {OptionMenuComponent} from './option-menu/option-menu.component';
import {SelectorComponent} from './sharing/selector/selector.component';
@NgModule({
declarations: [
@@ -22,7 +24,9 @@ import {HostOptionsComponent} from './editor/host-options/host-options.component
CodeComponent,
FilesComponent,
OptionPickerComponent,
HostOptionsComponent
HostOptionsComponent,
OptionMenuComponent,
SelectorComponent,
],
imports: [
CommonModule,
@@ -39,7 +43,9 @@ import {HostOptionsComponent} from './editor/host-options/host-options.component
CodeComponent,
FilesComponent,
OptionPickerComponent,
HostOptionsComponent
HostOptionsComponent,
OptionMenuComponent,
SelectorComponent,
],
exports: [
HostComponent,
@@ -49,7 +55,9 @@ import {HostOptionsComponent} from './editor/host-options/host-options.component
CodeComponent,
FilesComponent,
OptionPickerComponent,
HostOptionsComponent
HostOptionsComponent,
OptionMenuComponent,
SelectorComponent,
]
})
export class ComponentsModule {}

View File

@@ -2,7 +2,7 @@
<ion-toolbar>
<ion-item>
<ion-label position="floating">Language</ion-label>
<ion-select style="min-width: 40px;" [(ngModel)]="editorOptions.language" (ionChange)="onSelectChange()">
<ion-select style="min-width: 40px;" [(ngModel)]="editorOptions.language" (ionChange)="onSelectChange()" [disabled]="readonly">
<ion-select-option *ngFor="let lang of languageOptions" [value]="lang.toLowerCase()">{{lang}}</ion-select-option>
</ion-select>
</ion-item>
@@ -11,11 +11,11 @@
<ngx-monaco-editor style="width: 100%; height: 100%;"
[options]="editorOptions"
[(ngModel)]="editorValue"
(ngModelChange)="onEditorModelChange($event)"
#theEditor
(ngModelChange)="onEditorModelChange($event)"
#theEditor
></ngx-monaco-editor>
</div>
<ion-toolbar>
<ion-toolbar *ngIf="!readonly">
<ion-buttons slot="end">
<ion-button (click)="onDropClick()"><ion-icon name="alert" color="danger"></ion-icon>&nbsp;Drop Editor</ion-button>
<ion-button (click)="onSaveClick()"><ion-icon name="save" [color]="dirty ? 'warning' : 'success'"></ion-icon>&nbsp;Save Changes</ion-button>

View File

@@ -11,6 +11,7 @@ import {AlertController, LoadingController} from '@ionic/angular';
styleUrls: ['./code.component.scss'],
})
export class CodeComponent implements OnInit {
@Input() readonly = false;
@Input() hostRecord: HostRecord;
@Output() hostRecordChange = new EventEmitter<HostRecord>();
@Output() requestParentSave = new EventEmitter<CodeComponent>();
@@ -90,6 +91,7 @@ export class CodeComponent implements OnInit {
public editorOptions = {
language: 'javascript',
uri: v4(),
readOnly: this.readonly,
};
public editorValue = '';
@@ -104,6 +106,7 @@ export class CodeComponent implements OnInit {
loader.present().then(() => {
this.getInitObservable().subscribe(() => {
this.editorOptions.language = this.dbRecord.language;
this.editorOptions.readOnly = this.readonly;
this.onSelectChange(false);
loader.dismiss();
});
@@ -118,7 +121,7 @@ export class CodeComponent implements OnInit {
this.hostRecord.Value = {};
}
if ( !this.hostRecord.Value.Value ) {
if ( !this.hostRecord.Value.Value && !this.readonly ) {
this.api.post(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/create`).subscribe(res => {
this.dbRecord = res.data;
this.hostRecord.Value.Mode = 'code';
@@ -146,6 +149,10 @@ export class CodeComponent implements OnInit {
}
onSaveClick() {
if ( this.readonly ) {
return;
}
this.dbRecord.code = this.editorValue;
this.dbRecord.language = this.editorOptions.language;
this.api.post(`/code/${this.hostRecord.PageId}/${this.hostRecord.UUID}/set/${this.hostRecord.Value.Value}`, this.dbRecord)
@@ -158,6 +165,10 @@ export class CodeComponent implements OnInit {
}
async onDropClick() {
if ( this.readonly ) {
return;
}
const alert = await this.alerts.create({
header: 'Are you sure?',
message: `You are about to delete this code. This action cannot be undone.`,

View File

@@ -1,5 +1,5 @@
<div class="database-wrapper">
<ion-toolbar>
<ion-toolbar *ngIf="!readonly">
<ion-buttons>
<ion-button (click)="onManageColumns()"><ion-icon name="build" color="primary"></ion-icon>&nbsp;Manage Columns</ion-button>
<ion-button (click)="onInsertRow()"><ion-icon name="add-circle" color="success"></ion-icon>&nbsp;Insert Row</ion-button>
@@ -14,6 +14,7 @@
class="ag-theme-balham"
[rowData]="rowData"
[columnDefs]="columnDefs"
suppressMovableColumns="true"
(rowClicked)="onRowClicked($event)"
(cellValueChanged)="onCellValueChanged()"
#agGridElement

View File

@@ -13,6 +13,7 @@ import {AgGridAngular} from 'ag-grid-angular';
})
export class DatabaseComponent implements OnInit {
@Input() hostRecord: HostRecord;
@Input() readonly = false;
@Output() hostRecordChange = new EventEmitter<HostRecord>();
@Output() requestParentSave = new EventEmitter<DatabaseComponent>();
@Output() requestParentDelete = new EventEmitter<DatabaseComponent>();
@@ -55,6 +56,10 @@ export class DatabaseComponent implements OnInit {
}
async onManageColumns() {
if ( this.readonly ) {
return;
}
const modal = await this.modals.create({
component: ColumnsComponent,
componentProps: {columnSets: this.columnDefs},
@@ -91,12 +96,20 @@ export class DatabaseComponent implements OnInit {
}
onInsertRow() {
if ( this.readonly ) {
return;
}
this.rowData.push({});
this.agGridElement.api.setRowData(this.rowData);
this.dirty = true;
}
async onRemoveRow() {
if ( this.readonly ) {
return;
}
const alert = await this.alerts.create({
header: 'Are you sure?',
message: `You are about to delete row ${this.lastClickRow + 1}. This cannot be undone.`,
@@ -125,6 +138,10 @@ export class DatabaseComponent implements OnInit {
}
async onDropDatabase() {
if ( this.readonly ) {
return;
}
const alert = await this.alerts.create({
header: 'Are you sure?',
message: `You are about to delete this database and all its entries. This action cannot be undone.`,
@@ -162,6 +179,10 @@ export class DatabaseComponent implements OnInit {
}
onSyncRecords() {
if ( this.readonly ) {
return;
}
this.loader.create({message: 'Syncing the database...'}).then(loader => {
loader.present().then(() => {
this.api.post(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/set/${this.hostRecord.Value.Value}/data`, this.rowData)
@@ -179,7 +200,7 @@ export class DatabaseComponent implements OnInit {
return new Observable<any>(sub => {
this.api.get(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/get/${this.hostRecord.Value.Value}/columns`).subscribe(res => {
this.columnDefs = res.data.map(x => {
x.editable = true;
x.editable = !this.readonly;
if ( x.Type === 'text' ) {
x.editor = 'agTextCellEditor';
} else if ( x.Type === 'number' ) {
@@ -208,7 +229,7 @@ export class DatabaseComponent implements OnInit {
if ( !this.hostRecord.Value ) {
this.hostRecord.Value = {};
}
if ( !this.hostRecord.Value.Value ) {
if ( !this.hostRecord.Value.Value && !this.readonly ) {
this.api.post(`/db/${this.hostRecord.PageId}/${this.hostRecord.UUID}/create`).subscribe(res => {
this.dbRecord = res.data;
this.hostRecord.Value.Mode = 'database';

View File

@@ -1,5 +1,5 @@
<div class="files-wrapper">
<div class="new-uploads" style="margin: 20px;">
<div class="new-uploads" style="margin: 20px;" *ngIf="!readonly">
<form #uploadForm [action]="getApiSubmit()" enctype="multipart/form-data" method="post">
<input style="margin-top: 10px;" type="file" id="file" name="uploaded_file">
<input type="hidden" name="redirectTo" [value]="getReturnUrl()">

View File

@@ -11,6 +11,7 @@ import { APP_BASE_HREF } from '@angular/common';
styleUrls: ['./files.component.scss'],
})
export class FilesComponent implements OnInit {
@Input() readonly = false;
@Input() hostRecord: HostRecord;
@Output() hostRecordChange = new EventEmitter<HostRecord>();
@Output() requestParentSave = new EventEmitter<FilesComponent>();
@@ -38,6 +39,10 @@ export class FilesComponent implements OnInit {
}
onSubmitClick() {
if ( this.readonly ) {
return;
}
this.uploadForm.nativeElement.submit();
}
@@ -51,6 +56,10 @@ export class FilesComponent implements OnInit {
}
async onDestroyClick() {
if ( this.readonly ) {
return;
}
const alert = await this.alerts.create({
header: 'Are you sure?',
message: 'You are about to delete these files. This action cannot be undone.',
@@ -81,7 +90,7 @@ export class FilesComponent implements OnInit {
this.hostRecord.Value = {};
}
if ( !this.hostRecord.Value.Value ) {
if ( !this.hostRecord.Value.Value && !this.readonly ) {
this.api.post(`/files/${this.hostRecord.PageId}/${this.hostRecord.UUID}/create`).subscribe(res => {
this.dbRecord = res.data;
this.fileRecords = res.data.files;

View File

@@ -1,6 +1,12 @@
<ng-container>
<div
*ngIf="record.type === 'paragraph' || record.type === 'header1' || record.type === 'header2' || record.type === 'header3' || record.type === 'header4' || record.type === 'block_code' || record.type === 'click_link'"
*ngIf="!page.isViewOnly() && ( record.type === 'paragraph'
|| record.type === 'header1'
|| record.type === 'header2'
|| record.type === 'header3'
|| record.type === 'header4'
|| record.type === 'block_code'
|| record.type === 'click_link' )"
class="host-host ion-padding"
contenteditable="true"
(keyup)="onKeyUp($event)"
@@ -10,8 +16,22 @@
[ngClass]="{'paragraph': record.type === 'paragraph', 'header1': record.type === 'header1', 'header2': record.type === 'header2', 'header3': record.type === 'header3', 'header4': record.type === 'header4', 'block_code': record.type === 'block_code', 'click_link': record.type === 'click_link'}"
[innerHTML]="record.value.replace('\n', '<br>')"
></div>
<div
*ngIf="page.isViewOnly() && ( record.type === 'paragraph'
|| record.type === 'header1'
|| record.type === 'header2'
|| record.type === 'header3'
|| record.type === 'header4'
|| record.type === 'block_code'
|| record.type === 'click_link' )"
(click)="onHostDblClick()"
class="host-host ion-padding"
#hostContainer
[ngClass]="{'paragraph': record.type === 'paragraph', 'header1': record.type === 'header1', 'header2': record.type === 'header2', 'header3': record.type === 'header3', 'header4': record.type === 'header4', 'block_code': record.type === 'block_code', 'click_link': record.type === 'click_link'}"
[innerHTML]="record.value.replace('\n', '<br>')"
></div>
<ul
*ngIf="record.type === 'ul'"
*ngIf="record.type === 'ul' && !page.isViewOnly()"
class="host-host ion-padding"
>
<li
@@ -23,6 +43,16 @@
[innerHTML]="listLines[i]"
></li>
</ul>
<ul
*ngIf="record.type === 'ul' && page.isViewOnly()"
class="host-host ion-padding"
>
<li
#liItems
*ngFor="let line of listLines; let i = index"
[innerHTML]="listLines[i]"
></li>
</ul>
<div *ngIf="record.type === 'page_sep'" class="hr-wrapper">
<hr>
</div>
@@ -31,6 +61,7 @@
class="db-wrapper"
>
<editor-database
[readonly]="page.isViewOnly()"
[hostRecord]="record"
(hostRecordChange)="onRecordChange($event)"
(requestParentSave)="onRequestParentSave($event)"
@@ -39,6 +70,7 @@
</div>
<div class="code-wrapper" *ngIf="record.type === 'code_ref'">
<editor-code
[readonly]="page.isViewOnly()"
[hostRecord]="record"
(hostRecordChange)="onRecordChange($event)"
(requestParentSave)="onRequestParentSave($event)"
@@ -47,6 +79,7 @@
</div>
<div class="files-wrapper" *ngIf="record.type === 'file_ref'">
<editor-files
[readonly]="page.isViewOnly()"
[hostRecord]="record"
(hostRecordChange)="onRecordChange($event)"
(requestParentSave)="onRequestParentSave($event)"

View File

@@ -1,5 +1,6 @@
import {Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild, ViewChildren} from '@angular/core';
import HostRecord from '../../../structures/HostRecord';
import PageRecord from '../../../structures/PageRecord';
@Component({
selector: 'editor-host',
@@ -7,6 +8,7 @@ import HostRecord from '../../../structures/HostRecord';
styleUrls: ['./host.component.scss'],
})
export class HostComponent implements OnInit {
@Input() page: PageRecord;
@Input() record: HostRecord;
@Output() recordChange = new EventEmitter<HostRecord>();
@Output() newHostRequested = new EventEmitter<HostComponent>();

View File

@@ -0,0 +1,6 @@
<ion-list>
<ion-item *ngFor="let menuItem of menuItems; let i = index" button (click)="onSelect(menuItems[i].value)">
<ion-icon slot="start" [name]="menuItems[i].icon"></ion-icon>
<ion-label>{{ menuItems[i].name }}</ion-label>
</ion-item>
</ion-list>

View File

@@ -0,0 +1,22 @@
import {Component, Input, OnInit} from '@angular/core';
import {PopoverController} from '@ionic/angular';
@Component({
selector: 'common-option-menu',
templateUrl: './option-menu.component.html',
styleUrls: ['./option-menu.component.scss'],
})
export class OptionMenuComponent implements OnInit {
@Input() menuItems: Array<{name: string, icon: string, value: string, type?: string}> = [];
constructor(
protected popover: PopoverController,
) { }
ngOnInit() {}
async onSelect(value) {
await this.popover.dismiss(value);
}
}

View File

@@ -0,0 +1,98 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ title }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismissModal(false)">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-grid>
<ion-row>
<ion-col size="12">
<h4>Create Sharing Link</h4>
<p>You can share this sub-tree of your notes by generating a sharing link. You can choose to allow the recipient to have view, edit, or manage access to this sub-tree. Sharing links are one time use only and require a Noded account.</p>
<ion-buttons>
<ion-button fill="outline" color="medium" (click)="getShareLink('view')">
<ion-icon name="link" color="dark"></ion-icon>&nbsp;&nbsp;View
</ion-button>
<ion-button fill="outline" color="medium" (click)="getShareLink('update')">
<ion-icon name="link" color="dark"></ion-icon>&nbsp;&nbsp;Edit
</ion-button>
<ion-button fill="outline" color="medium" (click)="getShareLink('manage')">
<ion-icon name="link" color="dark"></ion-icon>&nbsp;&nbsp;Manage
</ion-button>
</ion-buttons>
</ion-col>
</ion-row>
<ion-row *ngIf="generatedLink">
<ion-col size="12">
<ion-item>
<ion-label position="floating">Share this link to give access:</ion-label>
<ion-input [(ngModel)]="generatedLink" [readonly]="true"></ion-input>
</ion-item>
</ion-col>
</ion-row>
<ion-row *ngIf="isShared">
<ion-col size="12">
<h4>Shared With</h4>
<ion-list>
<ion-item *ngFor="let group of sharingInfo.view; let i = index">
<ion-icon slot="start" name="contact"></ion-icon>
<ion-label>
{{ sharingInfo.view[i].username }}&nbsp;&nbsp;<span class="share-token">VIEW</span>
</ion-label>
<ion-buttons slot="end">
<ion-button fill="invisible" (click)="setShareLevel(sharingInfo.view[i], 'update')">
<ion-icon name="create" color="medium"></ion-icon>
</ion-button>
<ion-button fill="invisible" (click)="setShareLevel(sharingInfo.view[i], 'manage')">
<ion-icon name="build" color="medium"></ion-icon>
</ion-button>
<ion-button fill="invisible" (click)="unsharePage(sharingInfo.view[i])">
<ion-icon name="close" color="medium"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
<ion-item *ngFor="let group of sharingInfo.update; let i = index">
<ion-icon slot="start" name="contact"></ion-icon>
<ion-label>
{{ sharingInfo.update[i].username }}&nbsp;&nbsp;<span class="share-token">VIEW</span>&nbsp;&nbsp;<span class="share-token">EDIT</span>
</ion-label>
<ion-buttons slot="end">
<ion-button fill="invisible" (click)="setShareLevel(sharingInfo.update[i], 'view')">
<ion-icon name="eye" color="medium"></ion-icon>
</ion-button>
<ion-button fill="invisible" (click)="setShareLevel(sharingInfo.update[i], 'manage')">
<ion-icon name="build" color="medium"></ion-icon>
</ion-button>
<ion-button fill="invisible" (click)="unsharePage(sharingInfo.update[i])">
<ion-icon name="close" color="medium"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
<ion-item *ngFor="let group of sharingInfo.manage; let i = index">
<ion-icon slot="start" name="contact"></ion-icon>
<ion-label>
{{ sharingInfo.manage[i].username }}&nbsp;&nbsp;<span class="share-token">VIEW</span>&nbsp;&nbsp;<span class="share-token">EDIT</span>&nbsp;&nbsp;<span class="share-token">MANAGE</span>
</ion-label>
<ion-buttons slot="end">
<ion-button fill="invisible" (click)="setShareLevel(sharingInfo.manage[i], 'view')">
<ion-icon name="eye" color="medium"></ion-icon>
</ion-button>
<ion-button fill="invisible" (click)="setShareLevel(sharingInfo.manage[i], 'update')">
<ion-icon name="create" color="medium"></ion-icon>
</ion-button>
<ion-button fill="invisible" (click)="unsharePage(sharingInfo.manage[i])">
<ion-icon name="close" color="medium"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
</ion-list>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,8 @@
.share-token {
background-color: #dedede;
color: #555;
padding: 5px;
font-size: 10pt;
font-style: italic;
font-family: monospace;
}

View File

@@ -0,0 +1,77 @@
import {Component, Input, OnInit} from '@angular/core';
import {ModalController} from '@ionic/angular';
import {ApiService} from '../../../service/api.service';
import {Observable} from 'rxjs';
@Component({
selector: 'app-selector',
templateUrl: './selector.component.html',
styleUrls: ['./selector.component.scss'],
})
export class SelectorComponent implements OnInit {
@Input() node: any;
public sharingInfo: {
view: Array<{username: string, id: string, level: 'view'|'update'|'manage'}>,
update: Array<{username: string, id: string, level: 'view'|'update'|'manage'}>,
manage: Array<{username: string, id: string, level: 'view'|'update'|'manage'}>,
} = {view: [], update: [], manage: []};
public generatedLink = '';
constructor(
protected modals: ModalController,
protected api: ApiService,
) {}
public get title() {
return this.node ? `Share ${this.node.name}` : 'Manage Sharing';
}
public get isShared() {
return (this.sharingInfo.view.length > 0) || (this.sharingInfo.update.length > 0) || (this.sharingInfo.manage.length > 0);
}
ngOnInit() {
this.loadShareInfo().subscribe(data => {
this.sharingInfo = data;
});
}
dismissModal(success: boolean) {
this.modals.dismiss();
}
loadShareInfo(): Observable<{
view: Array<{username: string, id: string, level: 'view'|'update'|'manage'}>,
update: Array<{username: string, id: string, level: 'view'|'update'|'manage'}>,
manage: Array<{username: string, id: string, level: 'view'|'update'|'manage'}>,
}> {
return new Observable(sub => {
this.api.get(`/share/page/${this.node.id}/info`).subscribe(result => {
sub.next(result.data);
sub.complete();
});
});
}
setShareLevel(group, level) {
this.api.post(`/share/page/${this.node.id}/share`, { user_id: group.id, level }).subscribe(result => {
this.loadShareInfo().subscribe(data => {
this.sharingInfo = data;
});
});
}
unsharePage(group) {
this.api.post(`/share/page/${this.node.id}/revoke`, { user_id: group.id }).subscribe(result => {
this.loadShareInfo().subscribe(data => {
this.sharingInfo = data;
});
});
}
getShareLink(level) {
this.api.get(`/share/page/${this.node.id}/link/${level}`).subscribe(result => {
this.generatedLink = result.data.link;
});
}
}