(core) Updating RawData views

Summary:
- Better focus on the widget title
- Adding columns only to the current view section
- New popup with options when user wants to delete a page
- New dialog to enter table name
- New table as a widget doesn't create a separate page
- Removing a table doesn't remove the primary view

Test Plan: Updated and new tests

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3410
This commit is contained in:
Jarosław Sadziński
2022-05-04 11:54:30 +02:00
parent 97f3a8805c
commit f194d6861b
24 changed files with 676 additions and 330 deletions

View File

@@ -1,15 +1,19 @@
import { createGroup } from "app/client/components/commands";
import { duplicatePage } from "app/client/components/duplicatePage";
import { GristDoc } from "app/client/components/GristDoc";
import { PageRec } from "app/client/models/DocModel";
import { urlState } from "app/client/models/gristUrlState";
import * as MetaTableModel from "app/client/models/MetaTableModel";
import { find as findInTree, fromTableData, TreeItemRecord, TreeRecord,
TreeTableData} from "app/client/models/TreeModel";
import { TreeViewComponent } from "app/client/ui/TreeViewComponent";
import { buildPageDom, PageActions } from "app/client/ui2018/pages";
import { mod } from 'app/common/gutil';
import { Computed, Disposable, dom, fromKo, observable, Observable } from "grainjs";
import {createGroup} from 'app/client/components/commands';
import {duplicatePage} from 'app/client/components/duplicatePage';
import {GristDoc} from 'app/client/components/GristDoc';
import {PageRec} from 'app/client/models/DocModel';
import {urlState} from 'app/client/models/gristUrlState';
import * as MetaTableModel from 'app/client/models/MetaTableModel';
import {find as findInTree, fromTableData, TreeItemRecord, TreeRecord,
TreeTableData} from 'app/client/models/TreeModel';
import {TreeViewComponent} from 'app/client/ui/TreeViewComponent';
import {labeledCircleCheckbox} from 'app/client/ui2018/checkbox';
import {colors} 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 {mod} from 'app/common/gutil';
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, observable, Observable, styled} from 'grainjs';
// build dom for the tree view of pages
export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Observable<boolean>) {
@@ -49,16 +53,18 @@ export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Ob
return dom('div', dom.create(TreeViewComponent, model, {isOpen, selected, isReadonly: activeDoc.isReadonly}));
}
function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: GristDoc, id: number) {
const testId = makeTestId('test-removepage-');
function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: GristDoc, pageId: number) {
const {isReadonly} = activeDoc;
const pageName = pagesTable.rowModels[id].view.peek().name;
const viewId = pagesTable.rowModels[id].view.peek().id.peek();
const docData = pagesTable.tableData.docData;
const pageName = pagesTable.rowModels[pageId].view.peek().name;
const viewId = pagesTable.rowModels[pageId].view.peek().id.peek();
const actions: PageActions = {
onRename: (newName: string) => newName.length && pageName.saveOnly(newName),
onRemove: () => docData.sendAction(['RemoveRecord', '_grist_Views', viewId]),
onRemove: () => removeView(activeDoc, viewId, pageName.peek()),
// TODO: duplicate should prompt user for confirmation
onDuplicate: () => duplicatePage(activeDoc, id),
onDuplicate: () => duplicatePage(activeDoc, pageId),
// Can't remove last visible page
isRemoveDisabled: () => activeDoc.docModel.visibleDocPages.peek().length <= 1,
isReadonly
@@ -67,6 +73,46 @@ function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: Grist
return buildPageDom(fromKo(pageName), actions, urlState().setLinkUrl({docPage: viewId}));
}
function removeView(activeDoc: GristDoc, viewId: number, pageName: string) {
const docData = activeDoc.docData;
// Create a set with tables on other pages (but not on this one).
const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels
.filter(vs => !vs.isRaw.peek() && vs.parentId.peek() !== viewId)
.map(vs => vs.tableRef.peek()));
// Check if this page is a last page for some tables.
const notVisibleTables = [...new Set(activeDoc.docModel.viewSections.rowModels
.filter(vs => vs.parentId.peek() === viewId) // Get all sections on this view
.filter(vs => !vs.table.peek().summarySourceTable.peek()) // Sections that have normal tables
.filter(vs => !tablesOnOtherViews.has(vs.tableRef.peek())) // That aren't on other views
.filter(vs => vs.table.peek().tableId.peek()) // Which we can access (has tableId)
.map(vs => vs.table.peek()))]; // Return tableRec object, and remove duplicates.
const removePage = () => [['RemoveRecord', '_grist_Views', viewId]];
const removeAll = () => [
...removePage(),
...notVisibleTables.map(t => ['RemoveTable', t.tableId.peek()])
];
if (notVisibleTables.length) {
const tableNames = notVisibleTables.map(t => t.tableNameDef.peek());
buildPrompt(tableNames, async (option) => {
// Errors are handled in the dialog.
if (option === 'data') {
await docData.sendActions(removeAll(), `Remove page ${pageName} with tables ${tableNames}`);
} else if (option === 'page') {
await docData.sendActions(removePage(), `Remove only page ${pageName}`);
} else {
// This should not happen, as save should be disabled when no option is selected.
}
});
} else {
return docData.sendActions(removePage(), `Remove only page ${pageName}`);
}
}
type RemoveOption = '' | 'data' | 'page';
// Select another page in cyclic ordering of pages. Order is downard if given a positive `delta`,
// upward otherwise.
function otherPage(currentPage: TreeItemRecord, delta: number) {
@@ -75,3 +121,94 @@ function otherPage(currentPage: TreeItemRecord, delta: number) {
const docPage = records[index].viewRef;
return urlState().pushUrl({docPage});
}
function buildPrompt(tableNames: string[], onSave: (option: RemoveOption) => Promise<any>) {
saveModal((ctl, owner): ISaveModalOptions => {
const selected = Observable.create<RemoveOption>(owner, '');
const saveDisabled = Computed.create(owner, use => use(selected) === '');
const saveFunc = () => onSave(selected.get());
return {
title: `The following table${tableNames.length > 1 ? 's' : ''} will no longer be visible`,
body: dom('div',
testId('popup'),
buildWarning(tableNames),
cssOptions(
buildOption(selected, 'data', `Delete data and this page.`),
buildOption(selected, 'page',
[
`Delete this page, but do not delete data. `,
`Table will remain available in `,
cssLink(urlState().setHref({docPage: 'data'}), 'raw data page', { target: '_blank'}),
`.`
]),
)
),
saveDisabled,
saveLabel: 'Delete',
saveFunc,
width: 'fixed-wide',
extraButtons: [],
};
});
}
function buildOption(value: Observable<RemoveOption>, id: RemoveOption, content: DomContents) {
const selected = Computed.create(null, use => use(value) === id)
.onWrite(val => val ? value.set(id) : void 0);
return dom.update(
labeledCircleCheckbox(selected, content, dom.autoDispose(selected)),
testId(`option-${id}`),
cssBlockCheckbox.cls(''),
cssBlockCheckbox.cls('-block', selected),
);
}
function buildWarning(tables: string[]) {
return cssWarning(
dom.forEach(tables, (t) => cssTableName(t, testId('table')))
);
}
const cssOptions = styled('div', `
display: flex;
flex-direction: column;
gap: 10px;
`);
// We need to reset top and left of ::before element, as it is wrongly set
// on the inline checkbox.
// To simulate radio button behavior, we will block user input after option is selected, because
// checkbox doesn't support two-way binding.
const cssBlockCheckbox = styled('div', `
display: flex;
padding: 10px 8px;
border: 1px solid ${colors.mediumGrey};
border-radius: 3px;
cursor: pointer;
& input::before, & input::after {
top: unset;
left: unset;
}
&:hover {
border-color: ${colors.lightGreen};
}
&-block {
pointer-events: none;
}
&-block a {
pointer-events: all;
}
`);
const cssWarning = styled('div', `
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
`);
const cssTableName = styled('div', `
background: #eee;
padding: 3px 6px;
border-radius: 4px;
`);