import { GristDoc } from 'app/client/components/GristDoc'; import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel'; import { cssField, cssInput, cssLabel} from 'app/client/ui/MakeCopyMenu'; import { IPageWidget, toPageWidget } from 'app/client/ui/PageWidgetPicker'; import { confirmModal } from 'app/client/ui2018/modals'; import { BulkColValues, 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'); // 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', [ "Enter name for the new page. ", "Note that this does not copy data, ", "but creates another view of the same data. ", cssField( cssLabel("Name"), inputEl = cssInput({value: pageName + ' (copy)'}), ) ]) )); } 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( `Duplicate page ${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 layout spec const viewLayoutSpec = patchLayoutSpec(sourceView.layoutSpecObj.peek(), viewSectionIdMap); await gristDoc.docData.sendAction( ['UpdateRecord', '_grist_Views', viewRef, { layoutSpec: JSON.stringify(viewLayoutSpec)}] ); // 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 the view sections await updateViewSections(gristDoc, destViewSections, viewSections, viewFieldsIdMap, viewSectionIdMap); }); // Give copy focus await gristDoc.openDocPage(viewRef); } /** * 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.getTable('_grist_Views_section')!.getRecord(srcViewSection.getRowId())!; records.push({ ...record, layoutSpec: JSON.stringify(viewSectionLayoutSpec), linkSrcSectionRef: viewSectionMap[srcViewSection.linkSrcSectionRef.peek()], }); } // transpose data const sectionsInfo = {} as BulkColValues; forEach(records[0], (val, key) => sectionsInfo[key] = records.map(rec => rec[key])); // ditch column ids and parentId delete sectionsInfo.id; 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.getTable('_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]; } /** * 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]}; } } }); }