(core) Revealing hidden pages with visible children.

Summary:
When a page is hidden, all its nested pages are shown as children of
a different page that happens to be before (as in pagePos) that page.

This diff shows those pages as CENSORED.

Test Plan: Updated

Reviewers: alexmojaki

Reviewed By: alexmojaki

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D3670
This commit is contained in:
Jarosław Sadziński
2022-10-28 10:04:59 +02:00
parent b263d83122
commit 7c9cb9843e
9 changed files with 295 additions and 25 deletions

View File

@@ -13,6 +13,7 @@ import {KoArray} from 'app/client/lib/koArray';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import * as ko from 'knockout';
import memoize from 'lodash/memoize';
import * as koArray from 'app/client/lib/koArray';
import * as koUtil from 'app/client/lib/koUtil';
@@ -142,6 +143,8 @@ export class DocModel {
public allTabs: KoArray<TabBarRec> = this.tabBar.createAllRowsModel('tabPos');
/** Pages that are shown in the menu. These can include censored pages if they have children. */
public menuPages: ko.Computed<PageRec[]>;
// Excludes pages hidden by ACL rules or other reasons (e.g. doc-tour)
public visibleDocPages: ko.Computed<PageRec[]>;
@@ -189,6 +192,19 @@ export class DocModel {
// Get a list of only the visible pages.
const allPages = this.pages.createAllRowsModel('pagePos');
this.menuPages = ko.computed(() => {
const pagesToShow = allPages.all().filter(p => !p.isSpecial()).sort((a, b) => a.pagePos() - b.pagePos());
// Helper to find all children of a page.
const children = memoize((page: PageRec) => {
const following = pagesToShow.slice(pagesToShow.indexOf(page) + 1);
const firstOutside = following.findIndex(p => p.indentation() <= page.indentation());
return firstOutside >= 0 ? following.slice(0, firstOutside) : following;
});
// Helper to test if the page is hidden and all its children are hidden.
// In that case, we won't show it at all.
const hide = memoize((page: PageRec): boolean => page.isCensored() && children(page).every(p => hide(p)));
return pagesToShow.filter(p => !hide(p));
});
this.visibleDocPages = ko.computed(() => allPages.all().filter(p => !p.isHidden()));
}

View File

@@ -24,6 +24,7 @@ import reverse = require("lodash/reverse");
* `TreeModel` and any item in it implements `TreeItem`.
*/
export interface TreeNode {
hidden?: boolean;
// Returns an observable array of children. Or null if the node does not accept children.
children(): ObsArray<TreeItem>|null;
@@ -61,7 +62,7 @@ export interface TreeTableData {
}
// describes a function that builds dom for a particular record
type DomBuilder = (id: number) => HTMLElement;
type DomBuilder = (id: number, hidden: boolean) => HTMLElement;
// Returns a list of the records from table that is suitable to build the tree model, ie: records
@@ -108,8 +109,9 @@ export function fromTableData(table: TreeTableData, buildDom: DomBuilder, oldMod
const children = indentations[rec.indentation + 1] || [];
delete indentations[rec.indentation + 1];
const item = oldItems[rec.id] || new TreeItemRecord();
item.hidden = rec.hidden;
item.init(storage, index, reverse(children));
item.buildDom = () => buildDom(rec.id);
item.buildDom = () => buildDom(rec.id, rec.hidden);
siblings.push(item);
});
return new TreeModelRecord(storage, reverse(indentations[0] || []));
@@ -123,7 +125,7 @@ interface Storage {
// TreeNode implementation that uses a grist table.
export class TreeNodeRecord implements TreeNode {
public hidden: boolean = false;
public storage: Storage;
public index: number|"root";
public children: () => ObsArray<TreeItemRecord>;
@@ -187,6 +189,7 @@ export class TreeNodeRecord implements TreeNode {
forEach(update[0], (val, key) => values[key] = update.map(rec => rec[key]));
const rowIds = values.id;
delete values.id;
delete values.hidden;
userActions.push(["BulkUpdateRecord", rowIds, values]);
}

View File

@@ -5,11 +5,20 @@ import * as ko from 'knockout';
export interface PageRec extends IRowModel<"_grist_Pages"> {
view: ko.Computed<ViewRec>;
isHidden: ko.Computed<boolean>;
isCensored: ko.Computed<boolean>;
isSpecial: ko.Computed<boolean>;
}
export function createPageRec(this: PageRec, docModel: DocModel): void {
this.view = refRecord(docModel.views, this.viewRef);
this.isHidden = ko.pureComputed(() => {
// Page is hidden when any of this is true:
// - It has an empty name (or no name at all)
// - It is GristDocTour (unless user wants to see it)
// - It is a page generated for a hidden table TODO: Follow up - don't create
// pages for hidden tables.
// This is used currently only the left panel, to hide pages from the user.
this.isCensored = ko.pureComputed(() => !this.view().name());
this.isSpecial = ko.pureComputed(() => {
const name = this.view().name();
const isTableHidden = () => {
const viewId = this.view().id();
@@ -17,12 +26,9 @@ export function createPageRec(this: PageRec, docModel: DocModel): void {
const primaryTable = tables.find(t => t.primaryViewId() === viewId);
return !!primaryTable && primaryTable.tableId()?.startsWith("GristHidden_");
};
// Page is hidden when any of this is true:
// - It has an empty name (or no name at all)
// - It is GristDocTour (unless user wants to see it)
// - It is a page generated for a hidden table TODO: Follow up - don't create
// pages for hidden tables.
// This is used currently only the left panel, to hide pages from the user.
return !name || (name === 'GristDocTour' && !docModel.showDocTourTable) || isTableHidden();
return (name === 'GristDocTour' && !docModel.showDocTourTable) || isTableHidden();
});
this.isHidden = ko.pureComputed(() => {
return this.isCensored() || this.isSpecial();
});
}

View File

@@ -12,7 +12,7 @@ import {labeledCircleCheckbox} from 'app/client/ui2018/checkbox';
import {theme} from 'app/client/ui2018/cssVars';
import {cssLink} from 'app/client/ui2018/links';
import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
import {buildPageDom, PageActions} from 'app/client/ui2018/pages';
import {buildCensoredPage, buildPageDom, PageActions} from 'app/client/ui2018/pages';
import {mod} from 'app/common/gutil';
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, observable, Observable, styled} from 'grainjs';
@@ -24,11 +24,12 @@ export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Ob
const buildDom = buildDomFromTable.bind(null, pagesTable, activeDoc);
const records = Computed.create<TreeRecord[]>(owner, (use) =>
use(activeDoc.docModel.visibleDocPages).map(page => ({
use(activeDoc.docModel.menuPages).map(page => ({
id: page.getRowId(),
indentation: use(page.indentation),
pagePos: use(page.pagePos),
viewRef: use(page.viewRef),
hidden: use(page.isCensored),
}))
);
const getTreeTableData = (): TreeTableData => ({
@@ -58,7 +59,12 @@ export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Ob
const testId = makeTestId('test-removepage-');
function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: GristDoc, pageId: number) {
function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: GristDoc, pageId: number, hidden: boolean) {
if (hidden) {
return buildCensoredPage();
}
const {isReadonly} = activeDoc;
const pageName = pagesTable.rowModels[pageId].view.peek().name;
const viewId = pagesTable.rowModels[pageId].view.peek().id.peek();

View File

@@ -298,7 +298,7 @@ export class TreeViewComponent extends Disposable {
let headerElement: HTMLElement;
let labelElement: HTMLElement;
let handleElement: HTMLElement;
let handleElement: HTMLElement|null = null;
let offsetElement: HTMLElement;
let arrowElement: HTMLElement;
@@ -329,13 +329,14 @@ export class TreeViewComponent extends Disposable {
),
delayedMouseDrag(this._startDrag.bind(this), this._options.dragStartDelay),
),
css.itemLabelRight(
treeItem.hidden ? null : css.itemLabelRight(
handleElement = css.centeredIcon('DragDrop',
dom.style('top', (use) => use(deltaY) + 'px'),
testId('handle'),
dom.hide(this._options.isReadonly),
),
mouseDrag((startEvent, elem) => this._startDrag(startEvent))),
mouseDrag((startEvent, elem) => this._startDrag(startEvent))
),
),
...args
);

View File

@@ -4,6 +4,7 @@ import { cssEditorInput } from "app/client/ui/HomeLeftPane";
import { itemHeader, itemHeaderWrapper, treeViewContainer } from "app/client/ui/TreeViewComponentCss";
import { theme } from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons";
import { hoverTooltip } from 'app/client/ui/tooltips';
import { menu, menuItem, menuText } from "app/client/ui2018/menus";
import { dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from "grainjs";
@@ -106,6 +107,20 @@ export function buildPageDom(name: Observable<string>, actions: PageActions, ...
));
}
export function buildCensoredPage() {
return cssPageItem(
cssPageInitial(
testId('initial'),
dom.text('C'),
),
cssCensoredPageName(
dom.text('CENSORED'),
testId('label'),
),
hoverTooltip('This page is censored due to access rules.'),
);
}
const cssPageItem = styled('a', `
display: flex;
flex-direction: row;
@@ -143,6 +158,10 @@ const cssPageName = styled('div', `
}
`);
const cssCensoredPageName = styled(cssPageName, `
color: ${theme.disabledPageFg};
`);
function onHoverSupport(yesNo: boolean) {
// On desktop, we show page menu button on hover over page link. This isn't usable on mobile,
// and interferes with clicks on iOS; so instead we show the button when the page is selected.

View File

@@ -202,7 +202,7 @@ export const GristLight: ThemeColors = {
'left-panel-page-hover-bg': 'rgba(217,217,217,0.6)',
'left-panel-active-page-fg': '#FFFFFF',
'left-panel-active-page-bg': '#262633',
'left-panel-disabled-page-fg': '#D9D9D9',
'left-panel-disabled-page-fg': '#BDBDBD',
'left-panel-page-options-fg': '#929299',
'left-panel-page-options-hover-fg': 'white',
'left-panel-page-options-hover-bg': '#D9D9D9',