(core) Polish Importer UI

Summary:
Changes include:
 * Hide the colum matching section for new destinations (for now).
 * Make the preview table read-only.
 * Don't show helper column IDs when the formula editor is open.
 * Fix the formula editor autocomplete to show suggestions
 from the active transform section.
 * Hide the formula icons in the preview table, and other unnecessary
 UI elements such as row dropdown menus.
 * Keep preview loading spinner shown if scheduled (i.e. debounced) diff updates exist.

Test Plan: Browser tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3148
This commit is contained in:
George Gevoian 2021-11-18 21:35:01 -08:00
parent 7fe4423a6f
commit 32bb89235e
4 changed files with 121 additions and 87 deletions

View File

@ -30,6 +30,7 @@ const {encodeObject} = require("app/plugin/objtypes");
/** /**
* BaseView forms the basis for ViewSection classes. * BaseView forms the basis for ViewSection classes.
* @param {Object} viewSectionModel - The model for the viewSection represented. * @param {Object} viewSectionModel - The model for the viewSection represented.
* @param {Boolean} options.isPreview - Whether the view is a read-only preview (e.g. Importer view).
* @param {Boolean} options.addNewRow - Whether to include an add row in the model. * @param {Boolean} options.addNewRow - Whether to include an add row in the model.
*/ */
function BaseView(gristDoc, viewSectionModel, options) { function BaseView(gristDoc, viewSectionModel, options) {
@ -168,6 +169,8 @@ function BaseView(gristDoc, viewSectionModel, options) {
return linking && linking.disableEditing(); return linking && linking.disableEditing();
})); }));
this.isPreview = this.options.isPreview;
this.enableAddRow = this.autoDispose(ko.computed(() => this.options.addNewRow && this.enableAddRow = this.autoDispose(ko.computed(() => this.options.addNewRow &&
!this.viewSection.disableAddRemoveRows() && !this.disableEditing())); !this.viewSection.disableAddRemoveRows() && !this.disableEditing()));
@ -198,7 +201,9 @@ function BaseView(gristDoc, viewSectionModel, options) {
// A koArray of FieldBuilder objects, one for each view-section field. // A koArray of FieldBuilder objects, one for each view-section field.
this.fieldBuilders = this.autoDispose( this.fieldBuilders = this.autoDispose(
FieldBuilder.createAllFieldWidgets(this.gristDoc, this.viewSection.viewFields, this.cursor) FieldBuilder.createAllFieldWidgets(this.gristDoc, this.viewSection.viewFields, this.cursor, {
isPreview: this.isPreview,
})
); );
// An observable evaluating to the FieldBuilder for the field where the cursor is. // An observable evaluating to the FieldBuilder for the field where the cursor is.

View File

@ -55,7 +55,7 @@ const ROW_NUMBER_WIDTH = 52;
* GridView component implements the view of a grid of cells. * GridView component implements the view of a grid of cells.
*/ */
function GridView(gristDoc, viewSectionModel, isPreview = false) { function GridView(gristDoc, viewSectionModel, isPreview = false) {
BaseView.call(this, gristDoc, viewSectionModel, { 'addNewRow': true }); BaseView.call(this, gristDoc, viewSectionModel, { isPreview, 'addNewRow': true });
this.viewSection = viewSectionModel; this.viewSection = viewSectionModel;
@ -878,7 +878,7 @@ GridView.prototype.buildDom = function() {
kd.style('borderLeftWidth', v.borderWidthPx), kd.style('borderLeftWidth', v.borderWidthPx),
kd.foreach(v.viewFields(), field => { kd.foreach(v.viewFields(), field => {
var isEditingLabel = ko.pureComputed({ var isEditingLabel = ko.pureComputed({
read: () => this.gristDoc.isReadonlyKo() ? false : editIndex() === field._index(), read: () => this.gristDoc.isReadonlyKo() || self.isPreview ? false : editIndex() === field._index(),
write: val => editIndex(val ? field._index() : -1) write: val => editIndex(val ? field._index() : -1)
}).extend({ rateLimit: 0 }); }).extend({ rateLimit: 0 });
let filterTriggerCtl; let filterTriggerCtl;
@ -899,10 +899,10 @@ GridView.prototype.buildDom = function() {
if (btn) { btn.click(); } if (btn) { btn.click(); }
}), }),
dom('div.g-column-label', dom('div.g-column-label',
kf.editableLabel(field.displayLabel, isEditingLabel, renameCommands), kf.editableLabel(self.isPreview ? field.label : field.displayLabel, isEditingLabel, renameCommands),
dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true) dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true)
), ),
this.isPreview ? null : menuToggle(null, self.isPreview ? null : menuToggle(null,
kd.cssClass('g-column-main-menu'), kd.cssClass('g-column-main-menu'),
kd.cssClass('g-column-menu-btn'), kd.cssClass('g-column-menu-btn'),
// Prevent mousedown on the dropdown triangle from initiating column drag. // Prevent mousedown on the dropdown triangle from initiating column drag.
@ -1001,7 +1001,7 @@ GridView.prototype.buildDom = function() {
ev.preventDefault(); ev.preventDefault();
ev.currentTarget.querySelector('.menu_toggle').click(); ev.currentTarget.querySelector('.menu_toggle').click();
}), }),
menuToggle(null, self.isPreview ? null : menuToggle(null,
dom.on('click', ev => self.maybeSelectRow(ev.currentTarget.parentNode, row.getRowId())), dom.on('click', ev => self.maybeSelectRow(ev.currentTarget.parentNode, row.getRowId())),
menu(() => RowContextMenu({ menu(() => RowContextMenu({
disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()), disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()),
@ -1378,7 +1378,7 @@ GridView.prototype._getColumnMenuOptions = function(copySelection) {
numColumns: copySelection.fields.length, numColumns: copySelection.fields.length,
numFrozen: this.viewSection.numFrozen.peek(), numFrozen: this.viewSection.numFrozen.peek(),
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()), disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
isReadonly: this.gristDoc.isReadonly.get(), isReadonly: this.gristDoc.isReadonly.get() || this.isPreview,
isFiltered: this.isFiltered(), isFiltered: this.isFiltered(),
isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()), isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()),
}; };

View File

@ -170,7 +170,16 @@ export class Importer extends DisposableWithEvents {
// Promise for the most recent generateImportDiff action. // Promise for the most recent generateImportDiff action.
private _lastGenImportDiffPromise: Promise<any>|null = null; private _lastGenImportDiffPromise: Promise<any>|null = null;
private _updateImportDiff = debounce(this._updateDiff, 1000, {leading: true, trailing: true}); private _debouncedUpdateDiff = debounce(this._updateDiff, 1000, {leading: true, trailing: true});
/**
* Flag that is set when _updateImportDiff is called, and unset when _debouncedUpdateDiff begins executing.
*
* This is a workaround until Lodash's next release, which supports checking if a debounced function is
* pending. We need to know if more debounced calls are pending so that we can decide to take down the
* loading spinner over the preview table, or leave it up until all scheduled calls settle.
*/
private _hasScheduledDiffUpdate = false;
// destTables is a list of options for import destinations, and includes all tables in the // destTables is a list of options for import destinations, and includes all tables in the
// document, plus two values: to import as a new table, and to skip an import table entirely. // document, plus two values: to import as a new table, and to skip an import table entirely.
@ -297,6 +306,9 @@ export class Importer extends DisposableWithEvents {
// Otherwise, update the transform section for `sourceInfo`. // Otherwise, update the transform section for `sourceInfo`.
sourceInfo.transformSection.set(this._gristDoc.docModel.viewSections.getRowModel(transformSectionRef)); sourceInfo.transformSection.set(this._gristDoc.docModel.viewSections.getRowModel(transformSectionRef));
sourceInfo.isLoadingSection.set(false); sourceInfo.isLoadingSection.set(false);
// Change the active section to the transform section, so that formula autocomplete works.
this._gristDoc.viewModel.activeSectionId(transformSectionRef);
} }
private _getTransformedDataSource(upload: UploadResult): DataSourceTransformed { private _getTransformedDataSource(upload: UploadResult): DataSourceTransformed {
@ -479,16 +491,28 @@ export class Importer extends DisposableWithEvents {
* Triggers an update of the import diff in the preview table. When called in quick succession, * Triggers an update of the import diff in the preview table. When called in quick succession,
* only the most recent call will result in an update being made to the preview table. * only the most recent call will result in an update being made to the preview table.
* *
* NOTE: This method should not be called directly. Instead, use _updateImportDiff, which * @param {SourceInfo} info The source to update the diff for.
* wraps this method and debounces it. */
private async _updateImportDiff(info: SourceInfo) {
this._hasScheduledDiffUpdate = true;
this._isLoadingDiff.set(true);
await this._debouncedUpdateDiff(info);
}
/**
* NOTE: This method should not be called directly. Instead, use _updateImportDiff above, which
* wraps this method and calls a debounced version of it.
*
* Triggers an update of the import diff in the preview table. When called in quick succession,
* only the most recent call will result in an update being made to the preview table.
* *
* @param {SourceInfo} info The source to update the diff for. * @param {SourceInfo} info The source to update the diff for.
*/ */
private async _updateDiff(info: SourceInfo) { private async _updateDiff(info: SourceInfo) {
// Reset the flag tracking scheduled updates since the debounced update has started.
this._hasScheduledDiffUpdate = false;
const mergeOptions = this._mergeOptions[info.hiddenTableId]!; const mergeOptions = this._mergeOptions[info.hiddenTableId]!;
this._isLoadingDiff.set(true);
if (!mergeOptions.updateExistingRecords.get() || mergeOptions.mergeCols.get().length === 0) { if (!mergeOptions.updateExistingRecords.get() || mergeOptions.mergeCols.get().length === 0) {
// We can simply disable document comparison mode when merging isn't configured. // We can simply disable document comparison mode when merging isn't configured.
this._gristDoc.comparison = null; this._gristDoc.comparison = null;
@ -506,7 +530,10 @@ export class Importer extends DisposableWithEvents {
this._gristDoc.comparison = diff; this._gristDoc.comparison = diff;
} }
this._isLoadingDiff.set(false); // If more updates where scheduled since we started the update, leave the loading spinner up.
if (!this._hasScheduledDiffUpdate) {
this._isLoadingDiff.set(false);
}
} }
/** /**
@ -523,8 +550,9 @@ export class Importer extends DisposableWithEvents {
* is functionally equivalent to canceling the outstanding requests. * is functionally equivalent to canceling the outstanding requests.
*/ */
private _cancelPendingDiffRequests() { private _cancelPendingDiffRequests() {
this._updateImportDiff.cancel(); this._debouncedUpdateDiff.cancel();
this._lastGenImportDiffPromise = null; this._lastGenImportDiffPromise = null;
this._hasScheduledDiffUpdate = false;
this._isLoadingDiff.set(false); this._isLoadingDiff.set(false);
} }
@ -579,14 +607,14 @@ export class Importer extends DisposableWithEvents {
const {mergeCols, updateExistingRecords, hasInvalidMergeCols} = this._mergeOptions[info.hiddenTableId]!; const {mergeCols, updateExistingRecords, hasInvalidMergeCols} = this._mergeOptions[info.hiddenTableId]!;
return cssConfigAndPreview( return cssConfigAndPreview(
cssConfigColumn( dom.maybe(info.destTableId, () => cssConfigColumn(
dom.maybe(info.transformSection, section => [ dom.maybe(info.transformSection, section => {
dom.maybe(info.destTableId, () => { const updateRecordsListener = updateExistingRecords.addListener(async () => {
const updateRecordsListener = updateExistingRecords.addListener(async () => { await this._updateImportDiff(info);
await this._updateImportDiff(info); });
});
return cssMergeOptions( return [
cssMergeOptions(
cssMergeOptionsToggle(labeledSquareCheckbox( cssMergeOptionsToggle(labeledSquareCheckbox(
updateExistingRecords, updateExistingRecords,
'Update existing records', 'Update existing records',
@ -619,77 +647,77 @@ export class Importer extends DisposableWithEvents {
) )
]; ];
}) })
); ),
}), dom.domComputed(this._unmatchedFields, fields =>
dom.domComputed(this._unmatchedFields, fields => fields && fields.length > 0 ?
fields && fields.length > 0 ? cssUnmatchedFields(
cssUnmatchedFields( dom('div',
dom('div', cssGreenText(
cssGreenText( `${fields.length} unmatched ${fields.length > 1 ? 'fields' : 'field'}`
`${fields.length} unmatched ${fields.length > 1 ? 'fields' : 'field'}` ),
' in import:'
), ),
' in import:' cssUnmatchedFieldsList(fields.join(', ')),
), testId('importer-unmatched-fields')
cssUnmatchedFieldsList(fields.join(', ')), ) : null
testId('importer-unmatched-fields') ),
) : null cssColumnMatchOptions(
), dom.forEach(fromKo(section.viewFields().getObservable()), field => cssColumnMatchRow(
cssColumnMatchOptions( cssColumnMatchIcon('ImportArrow'),
dom.forEach(fromKo(section.viewFields().getObservable()), field => cssColumnMatchRow( cssSourceAndDestination(
cssColumnMatchIcon('ImportArrow'), cssDestinationFieldRow(
cssSourceAndDestination( cssDestinationFieldLabel(
cssDestinationFieldRow( dom.text(field.label),
cssDestinationFieldLabel( ),
dom.text(field.label), cssDestinationFieldSettings(
), icon('Dots'),
cssDestinationFieldSettings( menu(
icon('Dots'), () => {
menu( const sourceColId = field.origCol().id();
() => { const sourceColIdsAndLabels = [...this._sourceColLabelsById.get()!.entries()];
const sourceColId = field.origCol().id(); return [
const sourceColIdsAndLabels = [...this._sourceColLabelsById.get()!.entries()];
return [
menuItem(
() => this._gristDoc.clearColumns([sourceColId]),
'Skip',
testId('importer-column-match-menu-item')
),
menuDivider(),
...sourceColIdsAndLabels.map(([id, label]) =>
menuItem( menuItem(
() => this._setColumnFormula(sourceColId, '$' + id), () => this._gristDoc.clearColumns([sourceColId]),
label, 'Skip',
testId('importer-column-match-menu-item') testId('importer-column-match-menu-item')
), ),
), menuDivider(),
testId('importer-column-match-menu'), ...sourceColIdsAndLabels.map(([id, label]) =>
]; menuItem(
}, () => this._setColumnFormula(sourceColId, '$' + id),
{placement: 'right-start'}, label,
testId('importer-column-match-menu-item')
),
),
testId('importer-column-match-menu'),
];
},
{ placement: 'right-start' },
),
testId('importer-column-match-destination-settings')
), ),
testId('importer-column-match-destination-settings') testId('importer-column-match-destination')
), ),
testId('importer-column-match-destination') dom.domComputed(use => dom.create(
), this._buildColMappingFormula.bind(this),
dom.domComputed(use => dom.create( use(field.column),
this._buildColMappingFormula.bind(this), (elem: Element) => this._activateFormulaEditor(elem, field),
use(field.column), 'Skip'
(elem: Element) => this._activateFormulaEditor(elem, field), )),
'Skip' testId('importer-column-match-source-destination'),
)), )
testId('importer-column-match-source-destination'), )),
) testId('importer-column-match-options'),
)), )
testId('importer-column-match-options'), ];
) }),
]), )),
),
cssPreviewColumn( cssPreviewColumn(
cssSectionHeader('Preview'), cssSectionHeader('Preview'),
dom.domComputed(use => { dom.domComputed(use => {
const previewSection = use(this._previewViewSection); const previewSection = use(this._previewViewSection);
if (use(this._isLoadingDiff) || !previewSection) { if (use(this._isLoadingDiff) || !previewSection) {
return cssPreviewSpinner(loadingSpinner()); return cssPreviewSpinner(loadingSpinner(), testId('importer-preview-spinner'));
} }
const gridView = this._createPreview(previewSection); const gridView = this._createPreview(previewSection);

View File

@ -36,10 +36,10 @@ const testId = makeTestId('test-fbuilder-');
// Creates a FieldBuilder object for each field in viewFields // Creates a FieldBuilder object for each field in viewFields
export function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed<KoArray<ViewFieldRec>>, export function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed<KoArray<ViewFieldRec>>,
cursor: Cursor) { cursor: Cursor, options: { isPreview?: boolean } = {}) {
// TODO: Handle disposal from the map when fields are removed. // TODO: Handle disposal from the map when fields are removed.
return viewFields().map(function(field) { return viewFields().map(function(field) {
return new FieldBuilder(gristDoc, field, cursor); return new FieldBuilder(gristDoc, field, cursor, options);
}).setAutoDisposeValues(); }).setAutoDisposeValues();
} }
@ -80,7 +80,7 @@ export class FieldBuilder extends Disposable {
private readonly _readonly: Computed<boolean>; private readonly _readonly: Computed<boolean>;
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec, public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
private _cursor: Cursor) { private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
super(); super();
this._docModel = gristDoc.docModel; this._docModel = gristDoc.docModel;
@ -89,7 +89,8 @@ export class FieldBuilder extends Disposable {
this._readOnlyPureType = ko.pureComputed(() => this.field.column().pureType()); this._readOnlyPureType = ko.pureComputed(() => this.field.column().pureType());
this._readonly = Computed.create(this, (use) => use(gristDoc.isReadonly) || use(field.disableEditData)); this._readonly = Computed.create(this, (use) =>
use(gristDoc.isReadonly) || use(field.disableEditData) || Boolean(this._options.isPreview));
// Observable with a list of available types. // Observable with a list of available types.
this._availableTypes = Computed.create(this, (use) => { this._availableTypes = Computed.create(this, (use) => {
@ -428,7 +429,7 @@ export class FieldBuilder extends Disposable {
this._rowMap.set(row, elem); this._rowMap.set(row, elem);
dom(elem, dom(elem,
dom.autoDispose(widgetObs), dom.autoDispose(widgetObs),
kd.cssClass(this.field.formulaCssClass), this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass),
kd.toggleClass("readonly", toKo(ko, this._readonly)), kd.toggleClass("readonly", toKo(ko, this._readonly)),
kd.maybe(isSelected, () => dom('div.selected_cursor', kd.maybe(isSelected, () => dom('div.selected_cursor',
kd.toggleClass('active_cursor', isActive) kd.toggleClass('active_cursor', isActive)