gristlabs_grist-core/app/client/components/duplicatePage.ts
2023-01-03 15:49:26 +01:00

208 lines
8.4 KiB
TypeScript

import { GristDoc } from 'app/client/components/GristDoc';
import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel';
import { cssInput } from 'app/client/ui/cssInput';
import { cssField, cssLabel } from 'app/client/ui/MakeCopyMenu';
import { IPageWidget, toPageWidget } from 'app/client/ui/PageWidgetPicker';
import { confirmModal } from 'app/client/ui2018/modals';
import { BulkColValues, getColValues, RowRecord, UserAction } from 'app/common/DocActions';
import { arrayRepeat } from 'app/common/gutil';
import { schema } from 'app/common/schema';
import { dom } from 'grainjs';
import cloneDeepWith = require('lodash/cloneDeepWith');
import flatten = require('lodash/flatten');
import forEach = require('lodash/forEach');
import zip = require('lodash/zip');
import zipObject = require('lodash/zipObject');
import {makeT} from 'app/client/lib/localization';
const t = makeT('components.duplicatePage');
// Duplicate page with pageId. Starts by prompting user for a new name.
export async function duplicatePage(gristDoc: GristDoc, pageId: number) {
const pagesTable = gristDoc.docModel.pages;
const pageName = pagesTable.rowModels[pageId].view.peek().name.peek();
let inputEl: HTMLInputElement;
setTimeout(() => { inputEl.focus(); inputEl.select(); }, 100);
confirmModal('Duplicate page', 'Save', () => makeDuplicate(gristDoc, pageId, inputEl.value), (
dom('div', [
cssField(
cssLabel("Name"),
inputEl = cssInput({value: pageName + ' (copy)'}),
),
t("Note that this does not copy data, but creates another view of the same data."),
])
));
}
async function makeDuplicate(gristDoc: GristDoc, pageId: number, pageName: string = '') {
const sourceView = gristDoc.docModel.pages.rowModels[pageId].view.peek();
pageName = pageName || `${sourceView.name.peek()} (copy)`;
const viewSections = sourceView.viewSections.peek().peek();
let viewRef = 0;
await gristDoc.docData.bundleActions(
t("Duplicate page {{pageName}}", {pageName}),
async () => {
// create new view and new sections
const results = await createNewViewSections(gristDoc.docData, viewSections);
viewRef = results[0].viewRef;
// give it a better name
await gristDoc.docModel.views.rowModels[viewRef].name.saveOnly(pageName);
// create a map from source to target section ids
const viewSectionIdMap = zipObject(
viewSections.map(vs => vs.getRowId()),
results.map(res => res.sectionRef)
) as {[id: number]: number};
// update the view fields
const destViewSections = viewSections.map((vs) => (
gristDoc.docModel.viewSections.rowModels[viewSectionIdMap[vs.getRowId()]]
));
const newViewFieldIds = await updateViewFields(gristDoc, destViewSections, viewSections);
// create map for mapping from a src field's id to its corresponding dest field's id
const viewFieldsIdMap = zipObject(
flatten(viewSections.map((vs) => vs.viewFields.peek().peek().map((field) => field.getRowId()))),
flatten(newViewFieldIds)) as {[id: number]: number};
// update layout spec
const viewLayoutSpec = patchLayoutSpec(sourceView.layoutSpecObj.peek(), viewSectionIdMap);
await Promise.all([
gristDoc.docData.sendAction(
['UpdateRecord', '_grist_Views', viewRef, { layoutSpec: JSON.stringify(viewLayoutSpec)}]
),
updateViewSections(gristDoc, destViewSections, viewSections, viewFieldsIdMap, viewSectionIdMap),
copyFilters(gristDoc, viewSections, viewSectionIdMap)
]);
});
// Give copy focus
await gristDoc.openDocPage(viewRef);
}
/**
* Copies _grist_Filters from source sections.
*/
async function copyFilters(
gristDoc: GristDoc,
srcViewSections: ViewSectionRec[],
viewSectionMap: {[id: number]: number}) {
// Get all filters for selected sections.
const filters: RowRecord[] = [];
const table = gristDoc.docData.getMetaTable('_grist_Filters');
for (const srcViewSection of srcViewSections) {
const sectionFilters = table
.filterRecords({ viewSectionRef : srcViewSection.id.peek()})
.map(filter => ({
// Replace section ref with destination ref.
...filter, viewSectionRef : viewSectionMap[srcViewSection.id.peek()]
}));
filters.push(...sectionFilters);
}
if (filters.length) {
const filterInfo = getColValues(filters);
await gristDoc.docData.sendAction(['BulkAddRecord', '_grist_Filters',
new Array(filters.length).fill(null), filterInfo]);
}
}
/**
* Update all of destViewSections with srcViewSections, use fieldsMap to patch the section layout
* (for detail/cardlist sections), use viewSectionMap to patch the sections ids for linking.
*/
async function updateViewSections(gristDoc: GristDoc, destViewSections: ViewSectionRec[],
srcViewSections: ViewSectionRec[], fieldsMap: {[id: number]: number},
viewSectionMap: {[id: number]: number}) {
// collect all the records for the src view sections
const records: RowRecord[] = [];
for (const srcViewSection of srcViewSections) {
const viewSectionLayoutSpec = patchLayoutSpec(srcViewSection.layoutSpecObj.peek(), fieldsMap);
const record = gristDoc.docData.getMetaTable('_grist_Views_section').getRecord(srcViewSection.getRowId())!;
records.push({
...record,
layoutSpec: JSON.stringify(viewSectionLayoutSpec),
linkSrcSectionRef: viewSectionMap[srcViewSection.linkSrcSectionRef.peek()],
});
}
// transpose data
const sectionsInfo = getColValues(records);
// ditch column parentId
delete sectionsInfo.parentId;
// send action
const rowIds = destViewSections.map((vs) => vs.getRowId());
await gristDoc.docData.sendAction(['BulkUpdateRecord', '_grist_Views_section', rowIds, sectionsInfo]);
}
async function updateViewFields(gristDoc: GristDoc, destViewSections: ViewSectionRec[],
srcViewSections: ViewSectionRec[]) {
const actions: UserAction[] = [];
const docData = gristDoc.docData;
// First, remove all existing fields. Needed because `CreateViewSections` adds some by default.
const toRemove = flatten(destViewSections.map((vs) => vs.viewFields.peek().peek().map((field) => field.getRowId())));
actions.push(['BulkRemoveRecord', '_grist_Views_section_field', toRemove]);
// collect all the fields to add
const fieldsToAdd: RowRecord[] = [];
for (const [destViewSection, srcViewSection] of zip(destViewSections, srcViewSections)) {
const srcViewFields: ViewFieldRec[] = srcViewSection!.viewFields.peek().peek();
const parentId = destViewSection!.getRowId();
for (const field of srcViewFields) {
const record = docData.getMetaTable('_grist_Views_section_field').getRecord(field.getRowId())!;
fieldsToAdd.push({...record, parentId});
}
}
// transpose data
const fieldsInfo = {} as BulkColValues;
forEach(schema._grist_Views_section_field, (val, key) => fieldsInfo[key] = fieldsToAdd.map(rec => rec[key]));
const rowIds = arrayRepeat(fieldsInfo.parentId.length, null);
actions.push(['BulkAddRecord', '_grist_Views_section_field', rowIds, fieldsInfo]);
const results = await gristDoc.docData.sendActions(actions);
return results[1];
}
/**
* Create a new view containing all of the viewSections. Note that it doesn't copy view fields, for
* which you can use `updateViewFields`.
*/
async function createNewViewSections(docData: GristDoc['docData'], viewSections: ViewSectionRec[]) {
const [first, ...rest] = viewSections.map(toPageWidget);
// Passing a viewId of 0 will create a new view.
const firstResult = await docData.sendAction(newViewSectionAction(first, 0));
const otherResult = await docData.sendActions(
// other view section are added to the newly created view
rest.map((widget) => newViewSectionAction(widget, firstResult.viewRef))
);
return [firstResult, ...otherResult];
}
// Helper to create an action that add widget to the view with viewId.
function newViewSectionAction(widget: IPageWidget, viewId: number) {
return ['CreateViewSection', widget.table, viewId, widget.type, widget.summarize ? widget.columns : null, null];
}
/**
* Replaces each `leaf` id in layoutSpec by its corresponding id in mapIds. Leave unchanged if id is
* missing from mapIds.
*/
export function patchLayoutSpec(layoutSpec: any, mapIds: {[id: number]: number}) {
return cloneDeepWith(layoutSpec, (val) => {
if (typeof val === 'object') {
if (mapIds[val.leaf]) {
return {...val, leaf: mapIds[val.leaf]};
}
}
});
}