2022-05-04 09:54:30 +00:00
|
|
|
import {createGroup} from 'app/client/components/commands';
|
|
|
|
import {duplicatePage} from 'app/client/components/duplicatePage';
|
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
2022-10-28 16:11:08 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2022-05-04 09:54:30 +00:00
|
|
|
import {PageRec} from 'app/client/models/DocModel';
|
|
|
|
import {urlState} from 'app/client/models/gristUrlState';
|
2022-07-04 14:14:55 +00:00
|
|
|
import MetaTableModel from 'app/client/models/MetaTableModel';
|
2022-05-04 09:54:30 +00:00
|
|
|
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';
|
2022-09-06 01:51:57 +00:00
|
|
|
import {theme} from 'app/client/ui2018/cssVars';
|
2022-05-04 09:54:30 +00:00
|
|
|
import {cssLink} from 'app/client/ui2018/links';
|
|
|
|
import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
|
2022-10-28 08:04:59 +00:00
|
|
|
import {buildCensoredPage, buildPageDom, PageActions} from 'app/client/ui2018/pages';
|
2022-05-04 09:54:30 +00:00
|
|
|
import {mod} from 'app/common/gutil';
|
|
|
|
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, observable, Observable, styled} from 'grainjs';
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-10-28 16:11:08 +00:00
|
|
|
const t = makeT('Pages');
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// build dom for the tree view of pages
|
|
|
|
export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Observable<boolean>) {
|
|
|
|
const pagesTable = activeDoc.docModel.pages;
|
|
|
|
const buildDom = buildDomFromTable.bind(null, pagesTable, activeDoc);
|
|
|
|
|
2021-08-27 17:25:20 +00:00
|
|
|
const records = Computed.create<TreeRecord[]>(owner, (use) =>
|
2022-10-28 08:04:59 +00:00
|
|
|
use(activeDoc.docModel.menuPages).map(page => ({
|
2021-08-27 17:25:20 +00:00
|
|
|
id: page.getRowId(),
|
|
|
|
indentation: use(page.indentation),
|
|
|
|
pagePos: use(page.pagePos),
|
|
|
|
viewRef: use(page.viewRef),
|
2022-10-28 08:04:59 +00:00
|
|
|
hidden: use(page.isCensored),
|
2021-08-27 17:25:20 +00:00
|
|
|
}))
|
|
|
|
);
|
|
|
|
const getTreeTableData = (): TreeTableData => ({
|
|
|
|
getRecords: () => records.get(),
|
|
|
|
sendTableActions: (...args) => pagesTable.tableData.sendTableActions(...args),
|
|
|
|
});
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// create the model and keep in sync with the table
|
2021-08-27 17:25:20 +00:00
|
|
|
const model = observable(fromTableData(getTreeTableData(), buildDom));
|
|
|
|
owner.autoDispose(records.addListener(() => {
|
|
|
|
model.set(fromTableData(getTreeTableData(), buildDom, model.get()));
|
2020-10-02 15:10:00 +00:00
|
|
|
}));
|
|
|
|
|
|
|
|
// create a computed that reads the selected page from the url and return the corresponding item
|
|
|
|
const selected = Computed.create(owner, activeDoc.activeViewId, (use, viewId) =>
|
|
|
|
findInTree(model.get(), (i: TreeItemRecord) => i.record.viewRef === viewId) || null
|
|
|
|
);
|
|
|
|
|
|
|
|
owner.autoDispose(createGroup({
|
|
|
|
nextPage: () => selected.get() && otherPage(selected.get()!, +1),
|
|
|
|
prevPage: () => selected.get() && otherPage(selected.get()!, -1)
|
|
|
|
}, null, true));
|
|
|
|
|
|
|
|
// dom
|
|
|
|
return dom('div', dom.create(TreeViewComponent, model, {isOpen, selected, isReadonly: activeDoc.isReadonly}));
|
|
|
|
}
|
|
|
|
|
2022-05-04 09:54:30 +00:00
|
|
|
const testId = makeTestId('test-removepage-');
|
|
|
|
|
2022-10-28 08:04:59 +00:00
|
|
|
function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: GristDoc, pageId: number, hidden: boolean) {
|
|
|
|
|
|
|
|
if (hidden) {
|
|
|
|
return buildCensoredPage();
|
|
|
|
}
|
|
|
|
|
2022-04-27 17:46:24 +00:00
|
|
|
const {isReadonly} = activeDoc;
|
2022-05-04 09:54:30 +00:00
|
|
|
const pageName = pagesTable.rowModels[pageId].view.peek().name;
|
|
|
|
const viewId = pagesTable.rowModels[pageId].view.peek().id.peek();
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
const actions: PageActions = {
|
|
|
|
onRename: (newName: string) => newName.length && pageName.saveOnly(newName),
|
2022-05-04 09:54:30 +00:00
|
|
|
onRemove: () => removeView(activeDoc, viewId, pageName.peek()),
|
2020-10-02 15:10:00 +00:00
|
|
|
// TODO: duplicate should prompt user for confirmation
|
2022-05-04 09:54:30 +00:00
|
|
|
onDuplicate: () => duplicatePage(activeDoc, pageId),
|
2022-04-27 17:46:24 +00:00
|
|
|
// Can't remove last visible page
|
|
|
|
isRemoveDisabled: () => activeDoc.docModel.visibleDocPages.peek().length <= 1,
|
2020-10-02 15:10:00 +00:00
|
|
|
isReadonly
|
|
|
|
};
|
|
|
|
|
2021-08-27 17:25:20 +00:00
|
|
|
return buildPageDom(fromKo(pageName), actions, urlState().setLinkUrl({docPage: viewId}));
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2022-05-04 09:54:30 +00:00
|
|
|
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';
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// Select another page in cyclic ordering of pages. Order is downard if given a positive `delta`,
|
|
|
|
// upward otherwise.
|
|
|
|
function otherPage(currentPage: TreeItemRecord, delta: number) {
|
|
|
|
const records = currentPage.storage.records;
|
|
|
|
const index = mod(currentPage.index + delta, records.length);
|
|
|
|
const docPage = records[index].viewRef;
|
|
|
|
return urlState().pushUrl({docPage});
|
|
|
|
}
|
2022-05-04 09:54:30 +00:00
|
|
|
|
|
|
|
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 {
|
2022-10-28 16:11:08 +00:00
|
|
|
title: t('TableWillNoLongerBeVisible', { count: tableNames.length }),
|
2022-05-04 09:54:30 +00:00
|
|
|
body: dom('div',
|
|
|
|
testId('popup'),
|
|
|
|
buildWarning(tableNames),
|
|
|
|
cssOptions(
|
2022-12-06 13:57:29 +00:00
|
|
|
buildOption(selected, 'data', t("Delete data and this page.")),
|
2022-05-04 09:54:30 +00:00
|
|
|
buildOption(selected, 'page',
|
2022-10-28 16:11:08 +00:00
|
|
|
[ // TODO i18n
|
2022-08-31 04:51:11 +00:00
|
|
|
`Keep data and delete page. `,
|
2022-05-04 09:54:30 +00:00
|
|
|
`Table will remain available in `,
|
|
|
|
cssLink(urlState().setHref({docPage: 'data'}), 'raw data page', { target: '_blank'}),
|
|
|
|
`.`
|
|
|
|
]),
|
|
|
|
)
|
|
|
|
),
|
|
|
|
saveDisabled,
|
2022-12-06 13:57:29 +00:00
|
|
|
saveLabel: t("Delete"),
|
2022-05-04 09:54:30 +00:00
|
|
|
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;
|
2022-09-06 01:51:57 +00:00
|
|
|
border: 1px solid ${theme.modalBorder};
|
2022-05-04 09:54:30 +00:00
|
|
|
border-radius: 3px;
|
|
|
|
cursor: pointer;
|
|
|
|
& input::before, & input::after {
|
|
|
|
top: unset;
|
|
|
|
left: unset;
|
|
|
|
}
|
|
|
|
&:hover {
|
2022-09-06 01:51:57 +00:00
|
|
|
border-color: ${theme.accentBorder};
|
2022-05-04 09:54:30 +00:00
|
|
|
}
|
|
|
|
&-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', `
|
2022-09-06 01:51:57 +00:00
|
|
|
color: black;
|
|
|
|
background-color: #eee;
|
2022-05-04 09:54:30 +00:00
|
|
|
padding: 3px 6px;
|
|
|
|
border-radius: 4px;
|
|
|
|
`);
|