(core) Add column matching to Importer

Summary:
The Importer dialog is now maximized, showing additional column
matching options and information on the left, with the preview
table shown on the right. Columns can be mapped via a select menu
listing all source columns, or by clicking a formula field next to
the menu and directly editing the transform formula.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3096
This commit is contained in:
George Gevoian 2021-11-09 12:03:12 -08:00
parent 96fa7ad562
commit 08b1286f4f
11 changed files with 553 additions and 145 deletions

View File

@ -4,31 +4,35 @@
*/ */
// tslint:disable:no-console // tslint:disable:no-console
import {GristDoc} from "app/client/components/GristDoc"; import {GristDoc} from 'app/client/components/GristDoc';
import {buildParseOptionsForm, ParseOptionValues} from 'app/client/components/ParseOptions'; import {buildParseOptionsForm, ParseOptionValues} from 'app/client/components/ParseOptions';
import {PluginScreen} from "app/client/components/PluginScreen"; import {PluginScreen} from 'app/client/components/PluginScreen';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement'; import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
import {fetchURL, isDriveUrl, selectFiles, uploadFiles} from 'app/client/lib/uploads'; import {fetchURL, isDriveUrl, selectFiles, uploadFiles} from 'app/client/lib/uploads';
import {reportError} from 'app/client/models/AppModel'; import {reportError} from 'app/client/models/AppModel';
import {ViewSectionRec} from 'app/client/models/DocModel'; import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {SortedRowSet} from "app/client/models/rowset"; import {SortedRowSet} from 'app/client/models/rowset';
import {openFilePicker} from "app/client/ui/FileDialog"; import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
import {openFilePicker} from 'app/client/ui/FileDialog';
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, linkSelect, multiSelect} from 'app/client/ui2018/menus'; import {IOptionFull, linkSelect, menu, menuDivider, menuItem, multiSelect} from 'app/client/ui2018/menus';
import {cssModalButtons, cssModalTitle} from 'app/client/ui2018/modals'; import {cssModalButtons, cssModalTitle} from 'app/client/ui2018/modals';
import {loadingSpinner} from "app/client/ui2018/loaders"; import {loadingSpinner} from 'app/client/ui2018/loaders';
import {openFormulaEditor} from 'app/client/widgets/FieldEditor';
import {DataSourceTransformed, ImportResult, ImportTableResult, MergeOptions, import {DataSourceTransformed, ImportResult, ImportTableResult, MergeOptions,
MergeOptionsMap, MergeOptionsMap,
MergeStrategy, TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI"; MergeStrategy, TransformColumn, TransformRule, TransformRuleMap} from 'app/common/ActiveDocAPI';
import {byteString} from "app/common/gutil"; import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {byteString} from 'app/common/gutil';
import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI'; import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
import {Computed, Disposable, dom, DomContents, IDisposable, MutableObsArray, obsArray, Observable, import {Computed, dom, DomContents, fromKo, Holder, IDisposable, MultiHolder, MutableObsArray, obsArray, Observable,
styled} from 'grainjs'; styled} from 'grainjs';
import {labeledSquareCheckbox} from "app/client/ui2018/checkbox"; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from "app/client/ui/googleAuth"; import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth';
import debounce = require('lodash/debounce'); import debounce = require('lodash/debounce');
// Special values for import destinations; null means "new table". // Special values for import destinations; null means "new table".
@ -72,7 +76,7 @@ interface MergeOptionsState {
/** /**
* Importer manages an import files to Grist tables and shows Preview * Importer manages an import files to Grist tables and shows Preview
*/ */
export class Importer extends Disposable { export class Importer extends DisposableWithEvents {
/** /**
* Imports using the given plugin importer, or the built-in file-picker when null is passed in. * Imports using the given plugin importer, or the built-in file-picker when null is passed in.
*/ */
@ -147,6 +151,9 @@ export class Importer extends Disposable {
private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []); private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []);
private _sourceInfoSelected = Observable.create<SourceInfo|null>(this, null); private _sourceInfoSelected = Observable.create<SourceInfo|null>(this, null);
// Holder for the column mapping formula editor.
private readonly _formulaEditorHolder = Holder.create(this);
private _previewViewSection: Observable<ViewSectionRec|null> = private _previewViewSection: Observable<ViewSectionRec|null> =
Computed.create(this, this._sourceInfoSelected, (use, info) => { Computed.create(this, this._sourceInfoSelected, (use, info) => {
if (!info) { return null; } if (!info) { return null; }
@ -172,6 +179,27 @@ export class Importer extends Disposable {
...use(this._gristDoc.docModel.allTableIds.getObservable()).map((t) => ({value: t, label: t})), ...use(this._gristDoc.docModel.allTableIds.getObservable()).map((t) => ({value: t, label: t})),
]); ]);
// Source column labels for the selected import source, keyed by column id.
private _sourceColLabelsById = Computed.create(this, this._sourceInfoSelected, (use, info) => {
if (!info || use(info.sourceSection._isDeleted)) { return null; }
const fields = use(use(info.sourceSection.viewFields).getObservable());
return new Map(fields.map(f => [use(use(f.column).colId), use(use(f.column).label)]));
});
// List of destination fields that aren't mapped to a source column.
private _unmatchedFields = Computed.create(this, this._sourceInfoSelected, (use, info) => {
if (!info) { return null; }
const transformSection = use(info.transformSection);
if (!transformSection || use(transformSection._isDeleted)) { return null; }
const fields = use(use(transformSection.viewFields).getObservable());
return fields
.filter(f => use(use(f.column).formula).trim() === '')
.map(f => f.column().label());
});
// null tells to use the built-in file picker. // null tells to use the built-in file picker.
constructor(private _gristDoc: GristDoc, private _importSourceElem: ImportSourceElement|null, constructor(private _gristDoc: GristDoc, private _importSourceElem: ImportSourceElement|null,
private _createPreview: CreatePreviewFunc) { private _createPreview: CreatePreviewFunc) {
@ -406,6 +434,8 @@ export class Importer extends Disposable {
private async _cancelImport() { private async _cancelImport() {
this._resetImportDiffState(); this._resetImportDiffState();
// Formula editor cleanup needs to happen before the hidden tables are removed.
this._formulaEditorHolder.dispose();
if (this._uploadResult) { if (this._uploadResult) {
await this._docComm.cancelImportFiles( await this._docComm.cancelImportFiles(
@ -428,8 +458,11 @@ export class Importer extends Disposable {
const mergeOptions = this._mergeOptions[selectedSourceInfo.hiddenTableId]; const mergeOptions = this._mergeOptions[selectedSourceInfo.hiddenTableId];
if (!mergeOptions) { return isValid; } // No configuration to validate. if (!mergeOptions) { return isValid; } // No configuration to validate.
const destTableId = selectedSourceInfo.destTableId.get();
const {updateExistingRecords, mergeCols, hasInvalidMergeCols} = mergeOptions; const {updateExistingRecords, mergeCols, hasInvalidMergeCols} = mergeOptions;
if (updateExistingRecords.get() && mergeCols.get().length === 0) {
// Check that at least one merge column was selected (if merging into an existing table).
if (destTableId !== null && updateExistingRecords.get() && mergeCols.get().length === 0) {
hasInvalidMergeCols.set(true); hasInvalidMergeCols.set(true);
isValid = false; isValid = false;
} }
@ -498,7 +531,9 @@ export class Importer extends Disposable {
// The importer state showing import in progress, with a list of tables, and a preview. // The importer state showing import in progress, with a list of tables, and a preview.
private _renderMain(upload: UploadResult) { private _renderMain(upload: UploadResult) {
const schema = this._parseOptions.get().SCHEMA; const schema = this._parseOptions.get().SCHEMA;
this._screen.render([ const content = cssContainer(
dom.autoDispose(this._formulaEditorHolder),
{tabIndex: '-1'},
this._buildModalTitle( this._buildModalTitle(
schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options', schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options',
testId('importer-options-link'), testId('importer-options-link'),
@ -510,6 +545,11 @@ export class Importer extends Disposable {
dom.forEach(this._sourceInfoArray, (info) => { dom.forEach(this._sourceInfoArray, (info) => {
const destTableId = Computed.create(null, (use) => use(info.destTableId)) const destTableId = Computed.create(null, (use) => use(info.destTableId))
.onWrite(async (destId) => { .onWrite(async (destId) => {
// Prevent changing destination of un-selected sources if current configuration is invalid.
if (info !== this._sourceInfoSelected.get() && !this._validateImportConfiguration()) {
return;
}
info.destTableId.set(destId); info.destTableId.set(destId);
this._resetTableMergeOptions(info.hiddenTableId); this._resetTableMergeOptions(info.hiddenTableId);
await this._updateTransformSection(info); await this._updateTransformSection(info);
@ -521,9 +561,11 @@ export class Importer extends Disposable {
cssTableLine(cssToFrom('To'), linkSelect<DestId>(destTableId, this._destTables)), cssTableLine(cssToFrom('To'), linkSelect<DestId>(destTableId, this._destTables)),
cssTableInfo.cls('-selected', (use) => use(this._sourceInfoSelected) === info), cssTableInfo.cls('-selected', (use) => use(this._sourceInfoSelected) === info),
dom.on('click', async () => { dom.on('click', async () => {
if (info === this._sourceInfoSelected.get() || !this._validateImportConfiguration()) { // Ignore click if source is already selected.
return; if (info === this._sourceInfoSelected.get()) { return; }
}
// Prevent changing selected source if current configuration is invalid.
if (!this._validateImportConfiguration()) { return; }
this._cancelPendingDiffRequests(); this._cancelPendingDiffRequests();
this._sourceInfoSelected.set(info); this._sourceInfoSelected.set(info);
@ -536,68 +578,141 @@ export class Importer extends Disposable {
dom.maybe(this._sourceInfoSelected, (info) => { dom.maybe(this._sourceInfoSelected, (info) => {
const {mergeCols, updateExistingRecords, hasInvalidMergeCols} = this._mergeOptions[info.hiddenTableId]!; const {mergeCols, updateExistingRecords, hasInvalidMergeCols} = this._mergeOptions[info.hiddenTableId]!;
return [ return cssConfigAndPreview(
dom.maybe(info.destTableId, (_dest) => { cssConfigColumn(
const updateRecordsListener = updateExistingRecords.addListener(async () => { dom.maybe(info.transformSection, section => [
await this._updateImportDiff(info); dom.maybe(info.destTableId, () => {
}); const updateRecordsListener = updateExistingRecords.addListener(async () => {
await this._updateImportDiff(info);
});
return cssMergeOptions( return cssMergeOptions(
cssMergeOptionsToggle(labeledSquareCheckbox( cssMergeOptionsToggle(labeledSquareCheckbox(
updateExistingRecords, updateExistingRecords,
'Update existing records', 'Update existing records',
dom.autoDispose(updateRecordsListener), dom.autoDispose(updateRecordsListener),
testId('importer-update-existing-records') testId('importer-update-existing-records')
)), )),
dom.maybe(updateExistingRecords, () => [ dom.maybe(updateExistingRecords, () => {
cssMergeOptionsMessage( const mergeColsListener = mergeCols.addListener(async val => {
'Imported rows will be merged with records that have the same values for all of these fields:', // Reset the error state of the multiSelect on change.
testId('importer-merge-fields-message') if (val.length !== 0 && hasInvalidMergeCols.get()) {
), hasInvalidMergeCols.set(false);
dom.domComputed(info.transformSection, section => { }
const mergeColsListener = mergeCols.addListener(async val => { await this._updateImportDiff(info);
// Reset the error state of the multiSelect on change. });
if (val.length !== 0 && hasInvalidMergeCols.get()) {
hasInvalidMergeCols.set(false);
}
await this._updateImportDiff(info); return [
}); cssMergeOptionsMessage(
return multiSelect( 'Merge rows that match these fields:',
mergeCols, testId('importer-merge-fields-message')
section?.viewFields().peek().map(field => field.label()) ?? [], ),
{ multiSelect(
placeholder: 'Select fields to match on', mergeCols,
error: hasInvalidMergeCols section.viewFields().peek().map(f => ({label: f.label(), value: f.colId()})) ?? [],
}, {
dom.autoDispose(mergeColsListener), placeholder: 'Select fields to match on',
testId('importer-merge-fields-select') error: hasInvalidMergeCols
); },
}) dom.autoDispose(mergeColsListener),
]) testId('importer-merge-fields-select')
); )
}), ];
cssSectionHeader('Preview'), })
dom.domComputed(use => { );
const previewSection = use(this._previewViewSection); }),
if (use(this._isLoadingDiff) || !previewSection) { dom.domComputed(this._unmatchedFields, fields =>
return cssPreviewSpinner(loadingSpinner()); fields && fields.length > 0 ?
} cssUnmatchedFields(
dom('div',
cssGreenText(
`${fields.length} unmatched ${fields.length > 1 ? 'fields' : 'field'}`
),
' in import:'
),
cssUnmatchedFieldsList(fields.join(', ')),
testId('importer-unmatched-fields')
) : null
),
cssColumnMatchOptions(
dom.forEach(fromKo(section.viewFields().getObservable()), field => cssColumnMatchRow(
cssColumnMatchIcon('ImportArrow'),
cssSourceAndDestination(
cssDestinationFieldRow(
cssDestinationFieldLabel(
dom.text(field.label),
),
cssDestinationFieldSettings(
icon('Dots'),
menu(
() => {
const sourceColId = field.origCol().id();
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(
() => this._setColumnFormula(sourceColId, '$' + id),
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')
),
dom.domComputed(use => dom.create(
this._buildColMappingFormula.bind(this),
use(field.column),
(elem: Element) => this._activateFormulaEditor(elem, field),
'Skip'
)),
testId('importer-column-match-source-destination'),
)
)),
testId('importer-column-match-options'),
)
]),
),
cssPreviewColumn(
cssSectionHeader('Preview'),
dom.domComputed(use => {
const previewSection = use(this._previewViewSection);
if (use(this._isLoadingDiff) || !previewSection) {
return cssPreviewSpinner(loadingSpinner());
}
const gridView = this._createPreview(previewSection); const gridView = this._createPreview(previewSection);
// When changes are made to the preview table, update the import diff. // When changes are made to the preview table, update the import diff.
gridView.listenTo(gridView.sortedRows, 'rowNotify', async () => { gridView.listenTo(gridView.sortedRows, 'rowNotify', async () => {
await this._updateImportDiff(info); // If we aren't showing a diff, there is no need to do anything.
}); if (!info.destTableId || !updateExistingRecords.get() || mergeCols.get().length === 0) {
return;
}
return cssPreviewGrid( // Otherwise, update the diff and rebuild the preview table.
dom.autoDispose(gridView), await this._updateImportDiff(info);
gridView.viewPane, });
testId('importer-preview'),
); return cssPreviewGrid(
}) dom.autoDispose(gridView),
]; gridView.viewPane,
testId('importer-preview'),
);
})
)
);
}), }),
), ),
cssModalButtons( cssModalButtons(
@ -611,7 +726,88 @@ export class Importer extends Disposable {
testId('modal-cancel'), testId('modal-cancel'),
), ),
), ),
]); );
this._addFocusLayer(content);
this._screen.render(content, {fullscreen: true});
}
private _addFocusLayer(container: HTMLElement) {
dom.autoDisposeElem(container, new FocusLayer({
defaultFocusElem: container,
allowFocus: (elem) => (elem !== document.body),
onDefaultFocus: () => this.trigger('importer_focus'),
}));
}
/**
* Updates the formula on column `colRef` to `formula`.
*/
private async _setColumnFormula(colRef: number, formula: string): Promise<void> {
return this._gristDoc.docModel.columns.sendTableAction(
['UpdateRecord', colRef, { formula, isFormula: true }]
);
}
/**
* Opens a formula editor for `field` over `refElem`.
*/
private _activateFormulaEditor(refElem: Element, field: ViewFieldRec) {
// TODO: Set active section to hidden table section, so editor autocomplete is accurate.
const editorHolder = openFormulaEditor({
gristDoc: this._gristDoc,
field,
refElem,
setupCleanup: this._setupFormulaEditorCleanup.bind(this),
});
this._formulaEditorHolder.autoDispose(editorHolder);
}
/**
* Called by _activateFormulaEditor to initialize cleanup
* code for when the formula editor is closed. Registers and
* unregisters callbacks for saving edits when the editor loses
* focus.
*/
private _setupFormulaEditorCleanup(
owner: MultiHolder, _doc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown>
) {
const saveEdit = () => _saveEdit().catch(reportError);
// Whenever focus returns to the dialog, close the editor by saving the value.
this.on('importer_focus', saveEdit);
owner.onDispose(() => {
this.off('importer_focus', saveEdit);
field.editingFormula(false);
});
}
/**
* Builds an editable formula component that is displayed
* in the column mapping section of Importer. On click, opens
* an editor for the formula for `column`.
*/
private _buildColMappingFormula(_owner: MultiHolder, column: ColumnRec, buildEditor: (e: Element) => void,
placeholder: string) {
const formatFormula = (formula: string) => {
const sourceColLabels = this._sourceColLabelsById.get();
if (!sourceColLabels) { return formula; }
formula = formula.trim();
if (formula.startsWith('$') && sourceColLabels.has(formula.slice(1))) {
// For simple formulas that only reference a source column id, show the source column label.
return sourceColLabels.get(formula.slice(1))!;
}
return formula;
};
return cssFieldFormula(use => formatFormula(use(column.formula)), {placeholder, maxLines: 1},
dom.cls('disabled'),
{tabIndex: '-1'},
dom.on('focus', (_ev, elem) => buildEditor(elem)),
testId('importer-column-match-formula'),
);
} }
// The importer state showing parse options that may be changed. // The importer state showing parse options that may be changed.
@ -681,6 +877,13 @@ function getSourceDescription(sourceInfo: SourceInfo, upload: UploadResult) {
return sourceInfo.origTableName ? origName + ' - ' + sourceInfo.origTableName : origName; return sourceInfo.origTableName ? origName + ' - ' + sourceInfo.origTableName : origName;
} }
const cssContainer = styled('div', `
height: 100%;
display: flex;
flex-direction: column;
outline: unset;
`);
const cssActionLink = styled('div', ` const cssActionLink = styled('div', `
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -709,8 +912,9 @@ const cssModalHeader = styled('div', `
`); `);
const cssPreviewWrapper = styled('div', ` const cssPreviewWrapper = styled('div', `
width: 600px; display: flex;
padding: 8px 12px 8px 0; flex-direction: column;
flex-grow: 1;
overflow-y: auto; overflow-y: auto;
`); `);
@ -725,17 +929,19 @@ const cssSectionHeader = styled('div', `
`); `);
const cssTableList = styled('div', ` const cssTableList = styled('div', `
max-height: 50%;
column-gap: 32px;
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
justify-content: space-between;
margin-bottom: 16px; margin-bottom: 16px;
align-items: flex-start; align-items: flex-start;
overflow-y: auto;
`); `);
const cssTableInfo = styled('div', ` const cssTableInfo = styled('div', `
padding: 4px 8px; padding: 4px 8px;
margin: 4px 0px; margin: 4px 0px;
width: calc(50% - 16px); width: 300px;
border-radius: 3px; border-radius: 3px;
border: 1px solid ${colors.darkGrey}; border: 1px solid ${colors.darkGrey};
&:hover, &-selected { &:hover, &-selected {
@ -762,12 +968,35 @@ const cssToFrom = styled('span', `
`); `);
const cssTableSource = styled('div', ` const cssTableSource = styled('div', `
overflow-wrap: anywhere; overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`);
const cssConfigAndPreview = styled('div', `
display: flex;
gap: 32px;
flex-grow: 1;
height: 0px;
`);
const cssConfigColumn = styled('div', `
width: 300px;
padding-right: 8px;
display: flex;
flex-direction: column;
overflow-y: auto;
`);
const cssPreviewColumn = styled('div', `
display: flex;
flex-direction: column;
flex-grow: 1;
`); `);
const cssPreview = styled('div', ` const cssPreview = styled('div', `
display: flex; display: flex;
height: 300px; flex-grow: 1;
`); `);
const cssPreviewSpinner = styled(cssPreview, ` const cssPreviewSpinner = styled(cssPreview, `
@ -791,3 +1020,81 @@ const cssMergeOptionsMessage = styled('div', `
color: ${colors.slate}; color: ${colors.slate};
margin-bottom: 8px; margin-bottom: 8px;
`); `);
const cssColumnMatchOptions = styled('div', `
display: flex;
flex-direction: column;
gap: 20px;
`);
const cssColumnMatchRow = styled('div', `
display: flex;
align-items: center;
`);
const cssFieldFormula = styled(buildHighlightedCode, `
flex: auto;
cursor: pointer;
margin-top: 1px;
padding-left: 4px;
--icon-color: ${colors.lightGreen};
`);
const cssColumnMatchIcon = styled(icon, `
flex-shrink: 0;
width: 20px;
height: 32px;
background-color: ${colors.darkGrey};
margin-right: 4px;
`);
const cssDestinationFieldRow = styled('div', `
align-items: center;
display: flex;
`);
const cssSourceAndDestination = styled('div', `
min-width: 0;
display: flex;
flex-direction: column;
flex-grow: 1;
`);
const cssDestinationFieldLabel = styled('div', `
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-left: 4px;
`);
const cssDestinationFieldSettings = styled('div', `
flex: none;
margin: 0 4px 0 auto;
height: 24px;
width: 24px;
padding: 4px;
line-height: 0px;
border-radius: 3px;
cursor: pointer;
--icon-color: ${colors.slate};
&:hover, &.weasel-popup-open {
background-color: ${colors.mediumGrey};
}
`);
const cssUnmatchedFields = styled('div', `
margin-bottom: 16px;
`);
const cssUnmatchedFieldsList = styled('div', `
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
padding-right: 16px;
color: ${colors.slate};
`);
const cssGreenText = styled('span', `
color: ${colors.lightGreen};
`);

View File

@ -6,12 +6,21 @@ import { PluginInstance } from 'app/common/PluginInstance';
import { RenderTarget } from 'app/plugin/RenderOptions'; import { RenderTarget } from 'app/plugin/RenderOptions';
import { Disposable, dom, DomContents, Observable, styled } from 'grainjs'; import { Disposable, dom, DomContents, Observable, styled } from 'grainjs';
/**
* Rendering options for the PluginScreen modal.
*/
export interface RenderOptions {
// Maximizes modal to fill the viewport.
fullscreen?: boolean;
}
/** /**
* Helper for showing plugin components during imports. * Helper for showing plugin components during imports.
*/ */
export class PluginScreen extends Disposable { export class PluginScreen extends Disposable {
private _openModalCtl: IModalControl | null = null; private _openModalCtl: IModalControl | null = null;
private _importerContent = Observable.create<DomContents>(this, null); private _importerContent = Observable.create<DomContents>(this, null);
private _fullscreen = Observable.create(this, false);
constructor(private _title: string) { constructor(private _title: string) {
super(); super();
@ -33,9 +42,10 @@ export class PluginScreen extends Disposable {
return handle; return handle;
} }
public render(content: DomContents) { public render(content: DomContents, options?: RenderOptions) {
this.showImportDialog(); this.showImportDialog();
this._importerContent.set(content); this._importerContent.set(content);
this._fullscreen.set(Boolean(options?.fullscreen));
} }
// The importer state showing just an error. // The importer state showing just an error.
@ -67,6 +77,7 @@ export class PluginScreen extends Disposable {
this._openModalCtl = ctl; this._openModalCtl = ctl;
return [ return [
cssModalOverrides.cls(''), cssModalOverrides.cls(''),
cssModalOverrides.cls('-fullscreen', this._fullscreen),
dom.domComputed(this._importerContent), dom.domComputed(this._importerContent),
testId('importer-dialog'), testId('importer-dialog'),
]; ];
@ -89,6 +100,11 @@ const cssModalOverrides = styled('div', `
& > .${cssModalButtons.className} { & > .${cssModalButtons.className} {
margin-top: 16px; margin-top: 16px;
} }
&-fullscreen {
height: 100%;
margin: 32px;
}
`); `);
const cssModalBody = styled('div', ` const cssModalBody = styled('div', `

View File

@ -56,6 +56,7 @@ export type IconName = "ChartArea" |
"Home" | "Home" |
"Idea" | "Idea" |
"Import" | "Import" |
"ImportArrow" |
"Info" | "Info" |
"LeftAlign" | "LeftAlign" |
"Lock" | "Lock" |
@ -153,6 +154,7 @@ export const IconList: IconName[] = ["ChartArea",
"Home", "Home",
"Idea", "Idea",
"Import", "Import",
"ImportArrow",
"Info", "Info",
"LeftAlign", "LeftAlign",
"Lock", "Lock",

View File

@ -209,7 +209,10 @@ export function multiSelect<T>(selectedOptions: MutableObsArray<T>,
return cssSelectBtn( return cssSelectBtn(
dom.autoDispose(selectedOptionsSet), dom.autoDispose(selectedOptionsSet),
dom.autoDispose(selectedOptionsText), dom.autoDispose(selectedOptionsText),
cssMultiSelectSummary(dom.text(selectedOptionsText)), cssMultiSelectSummary(
dom.text(selectedOptionsText),
cssMultiSelectSummary.cls('-placeholder', use => use(selectedOptionsSet).size === 0)
),
icon('Dropdown'), icon('Dropdown'),
elem => { elem => {
weasel.setPopupToCreateDom(elem, ctl => buildMultiSelectMenu(ctl), weasel.defaultMenuOptions); weasel.setPopupToCreateDom(elem, ctl => buildMultiSelectMenu(ctl), weasel.defaultMenuOptions);
@ -535,6 +538,10 @@ const cssMultiSelectSummary = styled('div', `
flex: 1 1 0px; flex: 1 1 0px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
&-placeholder {
color: ${colors.slate}
}
`); `);
const cssMultiSelectMenu = styled(weasel.cssMenu, ` const cssMultiSelectMenu = styled(weasel.cssMenu, `

View File

@ -19,7 +19,7 @@ import { buttonSelect } from 'app/client/ui2018/buttonSelect';
import { IOptionFull, menu, select } from 'app/client/ui2018/menus'; import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
import { DiffBox } from 'app/client/widgets/DiffBox'; import { DiffBox } from 'app/client/widgets/DiffBox';
import { buildErrorDom } from 'app/client/widgets/ErrorDom'; import { buildErrorDom } from 'app/client/widgets/ErrorDom';
import { FieldEditor, openSideFormulaEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor'; import { FieldEditor, openFormulaEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget'; import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
import { NewBaseEditor } from "app/client/widgets/NewBaseEditor"; import { NewBaseEditor } from "app/client/widgets/NewBaseEditor";
import * as UserType from 'app/client/widgets/UserType'; import * as UserType from 'app/client/widgets/UserType';
@ -523,9 +523,10 @@ export class FieldBuilder extends Disposable {
editValue?: string, editValue?: string,
onSave?: (formula: string) => Promise<void>, onSave?: (formula: string) => Promise<void>,
onCancel?: () => void) { onCancel?: () => void) {
const editorHolder = openSideFormulaEditor({ const editorHolder = openFormulaEditor({
gristDoc: this.gristDoc, gristDoc: this.gristDoc,
field: this.field, field: this.field,
setupCleanup: setupEditorCleanup,
editRow, editRow,
refElem, refElem,
editValue, editValue,

View File

@ -15,7 +15,7 @@ import {isRaisedException} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil'; import * as gutil from 'app/common/gutil';
import {Disposable, Emitter, Holder, MultiHolder, Observable} from 'grainjs'; import {Disposable, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
import isEqual = require('lodash/isEqual'); import isEqual = require('lodash/isEqual');
import { CellPosition } from "app/client/components/CellPosition"; import {CellPosition} from "app/client/components/CellPosition";
type IEditorConstructor = typeof NewBaseEditor; type IEditorConstructor = typeof NewBaseEditor;
@ -373,18 +373,27 @@ export class FieldEditor extends Disposable {
} }
/** /**
* Open a formula editor in the side pane. Returns a Disposable that owns the editor. * Open a formula editor. Returns a Disposable that owns the editor.
*/ */
export function openSideFormulaEditor(options: { export function openFormulaEditor(options: {
gristDoc: GristDoc, gristDoc: GristDoc,
field: ViewFieldRec, field: ViewFieldRec,
editRow: DataRowModel, // Needed to get exception value, if any. // Needed to get exception value, if any.
refElem: Element, // Element in the side pane over which to position the editor. editRow?: DataRowModel,
// Element over which to position the editor.
refElem: Element,
editValue?: string, editValue?: string,
onSave?: (formula: string) => Promise<void>, onSave?: (formula: string) => Promise<void>,
onCancel?: () => void, onCancel?: () => void,
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
setupCleanup: (
owner: MultiHolder,
doc: GristDoc,
field: ViewFieldRec,
save: () => Promise<void>
) => void,
}): Disposable { }): Disposable {
const {gristDoc, field, editRow, refElem} = options; const {gristDoc, field, editRow, refElem, setupCleanup} = options;
const holder = MultiHolder.create(null); const holder = MultiHolder.create(null);
const column = field.column(); const column = field.column();
@ -411,7 +420,7 @@ export function openSideFormulaEditor(options: {
gristDoc, gristDoc,
field, field,
cellValue: column.formula(), cellValue: column.formula(),
formulaError: getFormulaError(gristDoc, editRow, column), formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
editValue: options.editValue, editValue: options.editValue,
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor. cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
commands: editCommands, commands: editCommands,
@ -422,7 +431,7 @@ export function openSideFormulaEditor(options: {
// Enter formula-editing mode (highlight formula icons; click on a column inserts its ID). // Enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
field.editingFormula(true); field.editingFormula(true);
setupEditorCleanup(holder, gristDoc, field, saveEdit); setupCleanup(holder, gristDoc, field, saveEdit);
return holder; return holder;
} }
@ -447,7 +456,7 @@ function setupReadonlyEditorCleanup(
* - unset field.editingFormula mode * - unset field.editingFormula mode
* - Arrange for UnsavedChange protection against leaving the page with unsaved changes. * - Arrange for UnsavedChange protection against leaving the page with unsaved changes.
*/ */
function setupEditorCleanup( export function setupEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown> owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown>
) { ) {
const saveEdit = () => _saveEdit().catch(reportError); const saveEdit = () => _saveEdit().catch(reportError);

View File

@ -18,6 +18,7 @@ import {DocSession, OptDocSession} from 'app/server/lib/DocSession';
import * as log from 'app/server/lib/log'; import * as log from 'app/server/lib/log';
import {globalUploadSet, moveUpload, UploadInfo} from 'app/server/lib/uploads'; import {globalUploadSet, moveUpload, UploadInfo} from 'app/server/lib/uploads';
import {buildComparisonQuery} from 'app/server/lib/ExpandedQuery'; import {buildComparisonQuery} from 'app/server/lib/ExpandedQuery';
import flatten = require('lodash/flatten');
const IMPORT_TRANSFORM_COLUMN_PREFIX = 'gristHelper_Import_'; const IMPORT_TRANSFORM_COLUMN_PREFIX = 'gristHelper_Import_';
@ -131,16 +132,19 @@ export class ActiveDocImport {
*/ */
public async generateImportDiff(hiddenTableId: string, {destCols, destTableId}: TransformRule, public async generateImportDiff(hiddenTableId: string, {destCols, destTableId}: TransformRule,
{mergeCols, mergeStrategy}: MergeOptions): Promise<DocStateComparison> { {mergeCols, mergeStrategy}: MergeOptions): Promise<DocStateComparison> {
// Merge column ids from client have prefixes that need to be stripped.
mergeCols = stripPrefixes(mergeCols);
// Get column differences between `hiddenTableId` and `destTableId` for rows that exist in both tables. // Get column differences between `hiddenTableId` and `destTableId` for rows that exist in both tables.
const selectColumns: [string, string][] = const srcAndDestColIds: [string, string[]][] =
destCols.map(c => [c.colId!, c.colId!.slice(IMPORT_TRANSFORM_COLUMN_PREFIX.length)]); destCols.map(c => [c.colId!, [c.colId!.slice(IMPORT_TRANSFORM_COLUMN_PREFIX.length)]]);
const selectColumnsMap = new Map(selectColumns); const srcToDestColIds = new Map(srcAndDestColIds);
const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId!, selectColumnsMap, mergeCols); const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId!, srcToDestColIds, mergeCols);
// Initialize container for updated column values in the expected format (ColumnDelta). // Initialize container for updated column values in the expected format (ColumnDelta).
const updatedRecords: {[colId: string]: ColumnDelta} = {}; const updatedRecords: {[colId: string]: ColumnDelta} = {};
const updatedRecordIds: number[] = []; const updatedRecordIds: number[] = [];
const srcColIds = selectColumns.map(([srcColId, _destColId]) => srcColId); const srcColIds = srcAndDestColIds.map(([srcColId, _destColId]) => srcColId);
for (const id of srcColIds) { for (const id of srcColIds) {
updatedRecords[id] = {}; updatedRecords[id] = {};
} }
@ -160,7 +164,7 @@ export class ActiveDocImport {
} else { } else {
// Otherwise, a match was found between source and destination tables. // Otherwise, a match was found between source and destination tables.
for (const srcColId of srcColIds) { for (const srcColId of srcColIds) {
const matchingDestColId = selectColumnsMap.get(srcColId); const matchingDestColId = srcToDestColIds.get(srcColId)![0];
const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][i]; const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][i];
const destVal = comparisonResult[`${destTableId}.${matchingDestColId}`][i]; const destVal = comparisonResult[`${destTableId}.${matchingDestColId}`][i];
@ -382,7 +386,7 @@ export class ActiveDocImport {
} }
// Transform rules from client may have prefixed column ids, so we need to strip them. // Transform rules from client may have prefixed column ids, so we need to strip them.
stripPrefixes(transformRule); stripRulePrefixes(transformRule);
if (intoNewTable) { if (intoNewTable) {
// Transform rules for new tables don't have filled in destination column ids. // Transform rules for new tables don't have filled in destination column ids.
@ -444,15 +448,25 @@ export class ActiveDocImport {
private async _mergeAndFinishImport(docSession: OptDocSession, hiddenTableId: string, destTableId: string, private async _mergeAndFinishImport(docSession: OptDocSession, hiddenTableId: string, destTableId: string,
{destCols, sourceCols}: TransformRule, {destCols, sourceCols}: TransformRule,
{mergeCols, mergeStrategy}: MergeOptions): Promise<void> { {mergeCols, mergeStrategy}: MergeOptions): Promise<void> {
// Merge column ids from client have prefixes that need to be stripped.
mergeCols = stripPrefixes(mergeCols);
// Get column differences between `hiddenTableId` and `destTableId` for rows that exist in both tables. // Get column differences between `hiddenTableId` and `destTableId` for rows that exist in both tables.
const selectColumns: [string, string][] = destCols.map(destCol => { const srcAndDestColIds: [string, string][] = destCols.map(destCol => {
const formula = destCol.formula.trim(); const formula = destCol.formula.trim();
const srcColId = formula.startsWith('$') && sourceCols.includes(formula.slice(1)) ? const srcColId = formula.startsWith('$') && sourceCols.includes(formula.slice(1)) ?
formula.slice(1) : IMPORT_TRANSFORM_COLUMN_PREFIX + destCol.colId; formula.slice(1) : IMPORT_TRANSFORM_COLUMN_PREFIX + destCol.colId;
return [srcColId, destCol.colId!]; return [srcColId, destCol.colId!];
}); });
const selectColumnsMap = new Map(selectColumns); const srcToDestColIds: Map<string, string[]> = new Map();
const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId, selectColumnsMap, mergeCols); srcAndDestColIds.forEach(([srcColId, destColId]) => {
if (!srcToDestColIds.has(srcColId)) {
srcToDestColIds.set(srcColId, [destColId]);
} else {
srcToDestColIds.get(srcColId)!.push(destColId);
}
});
const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId, srcToDestColIds, mergeCols);
// Initialize containers for new and updated records in the expected formats. // Initialize containers for new and updated records in the expected formats.
const newRecords: BulkColValues = {}; const newRecords: BulkColValues = {};
@ -460,7 +474,7 @@ export class ActiveDocImport {
const updatedRecords: BulkColValues = {}; const updatedRecords: BulkColValues = {};
const updatedRecordIds: number[] = []; const updatedRecordIds: number[] = [];
const destColIds = [...selectColumnsMap.values()]; const destColIds = flatten([...srcToDestColIds.values()]);
for (const id of destColIds) { for (const id of destColIds) {
newRecords[id] = []; newRecords[id] = [];
updatedRecords[id] = []; updatedRecords[id] = [];
@ -469,23 +483,27 @@ export class ActiveDocImport {
// Retrieve the function used to reconcile differences between source and destination. // Retrieve the function used to reconcile differences between source and destination.
const merge = getMergeFunction(mergeStrategy); const merge = getMergeFunction(mergeStrategy);
const srcColIds = [...selectColumnsMap.keys()]; const srcColIds = [...srcToDestColIds.keys()];
const numResultRows = comparisonResult[hiddenTableId + '.id'].length; const numResultRows = comparisonResult[hiddenTableId + '.id'].length;
for (let i = 0; i < numResultRows; i++) { for (let i = 0; i < numResultRows; i++) {
if (comparisonResult[destTableId + '.id'][i] === null) { if (comparisonResult[destTableId + '.id'][i] === null) {
// No match in destination table found for source row, so it must be a new record. // No match in destination table found for source row, so it must be a new record.
for (const srcColId of srcColIds) { for (const srcColId of srcColIds) {
const matchingDestColId = selectColumnsMap.get(srcColId); const matchingDestColIds = srcToDestColIds.get(srcColId);
newRecords[matchingDestColId!].push(comparisonResult[`${hiddenTableId}.${srcColId}`][i]); matchingDestColIds!.forEach(id => {
newRecords[id].push(comparisonResult[`${hiddenTableId}.${srcColId}`][i]);
});
} }
numNewRecords++; numNewRecords++;
} else { } else {
// Otherwise, a match was found between source and destination tables, so we merge their columns. // Otherwise, a match was found between source and destination tables, so we merge their columns.
for (const srcColId of srcColIds) { for (const srcColId of srcColIds) {
const matchingDestColId = selectColumnsMap.get(srcColId); const matchingDestColIds = srcToDestColIds.get(srcColId);
const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][i]; const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][i];
const destVal = comparisonResult[`${destTableId}.${matchingDestColId}`][i]; matchingDestColIds!.forEach(id => {
updatedRecords[matchingDestColId!].push(merge(srcVal, destVal)); const destVal = comparisonResult[`${destTableId}.${id}`][i];
updatedRecords[id].push(merge(srcVal, destVal));
});
} }
updatedRecordIds.push(comparisonResult[destTableId + '.id'][i] as number); updatedRecordIds.push(comparisonResult[destTableId + '.id'][i] as number);
} }
@ -515,17 +533,23 @@ export class ActiveDocImport {
* *
* @param {string} hiddenTableId Source table. * @param {string} hiddenTableId Source table.
* @param {string} destTableId Destination table. * @param {string} destTableId Destination table.
* @param {string} selectColumnsMap Map of source to destination column ids to include in the comparison results. * @param {Map<string, string[]>} srcToDestColIds Map of source to one or more destination column ids
* to include in the comparison results.
* @param {string[]} mergeCols List of (destination) column ids to use for matching. * @param {string[]} mergeCols List of (destination) column ids to use for matching.
* @returns {Promise<BulkColValues} Decoded column values from both tables that were matched, and had differences. * @returns {Promise<BulkColValues} Decoded column values from both tables that were matched, and had differences.
*/ */
private async _getTableComparison(hiddenTableId: string, destTableId: string, selectColumnsMap: Map<string, string>, private async _getTableComparison(hiddenTableId: string, destTableId: string, srcToDestColIds: Map<string, string[]>,
mergeCols: string[]): Promise<BulkColValues> { mergeCols: string[]): Promise<BulkColValues> {
const joinColumns: [string, string][] = const mergeColIds = new Set(mergeCols);
[...selectColumnsMap.entries()].filter(([_srcColId, destColId]) => mergeCols.includes(destColId)); const destToSrcMergeColIds = new Map();
const joinColumnsMap = new Map(joinColumns); srcToDestColIds.forEach((destColIds, srcColId) => {
const maybeMergeColId = destColIds.find(colId => mergeColIds.has(colId));
if (maybeMergeColId !== undefined) {
destToSrcMergeColIds.set(maybeMergeColId, srcColId);
}
});
const query = buildComparisonQuery(hiddenTableId, destTableId, selectColumnsMap, joinColumnsMap); const query = buildComparisonQuery(hiddenTableId, destTableId, srcToDestColIds, destToSrcMergeColIds);
const result = await this._activeDoc.docStorage.fetchQuery(query); const result = await this._activeDoc.docStorage.fetchQuery(query);
return this._activeDoc.docStorage.decodeMarshalledDataFromTables(result); return this._activeDoc.docStorage.decodeMarshalledDataFromTables(result);
} }
@ -636,7 +660,7 @@ function isBlank(value: CellValue): boolean {
} }
// Helper function that strips import prefixes from columns in transform rules (if ids are present). // Helper function that strips import prefixes from columns in transform rules (if ids are present).
function stripPrefixes({destCols}: TransformRule): void { function stripRulePrefixes({destCols}: TransformRule): void {
for (const col of destCols) { for (const col of destCols) {
const colId = col.colId; const colId = col.colId;
if (colId && colId.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX)) { if (colId && colId.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX)) {
@ -645,6 +669,12 @@ function stripPrefixes({destCols}: TransformRule): void {
} }
} }
// Helper function that returns new `colIds` with import prefixes stripped.
function stripPrefixes(colIds: string[]): string[] {
return colIds.map(id => id.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX) ?
id.slice(IMPORT_TRANSFORM_COLUMN_PREFIX.length) : id);
}
type MergeFunction = (srcVal: CellValue, destVal: CellValue) => CellValue; type MergeFunction = (srcVal: CellValue, destVal: CellValue) => CellValue;
/** /**

View File

@ -148,13 +148,15 @@ export function expandQuery(iquery: ServerQuery, docData: DocData, onDemandFormu
* *
* @param {string} leftTableId Name of the left table in the comparison. * @param {string} leftTableId Name of the left table in the comparison.
* @param {string} rightTableId Name of the right table in the comparison. * @param {string} rightTableId Name of the right table in the comparison.
* @param {Map<string, string>} selectColumns Map of left table column ids to their matching equivalent * @param {Map<string, string[]>} selectColumns Map of left table column ids to their matching equivalent(s)
* from the right table. All of these columns will be included in the result, aliased by table id. * from the right table. A single left column can be compared against 2 or more right columns, so the
* @param {Map<string, string>} joinColumns Map of left table column ids to their matching equivalent * values of `selectColumns` are arrays. All of these columns will be included in the result, aliased by
* from the right table. These columns are used to join `leftTableID` to `rightTableId`. * table id.
* @param {Map<string, string>} joinColumns Map of right table column ids to their matching equivalent
* from the left table. These columns are used to join `leftTableId` to `rightTableId`.
* @returns {ExpandedQuery} The constructed query. * @returns {ExpandedQuery} The constructed query.
*/ */
export function buildComparisonQuery(leftTableId: string, rightTableId: string, selectColumns: Map<string, string>, export function buildComparisonQuery(leftTableId: string, rightTableId: string, selectColumns: Map<string, string[]>,
joinColumns: Map<string, string>): ExpandedQuery { joinColumns: Map<string, string>): ExpandedQuery {
const query: ExpandedQuery = { tableId: leftTableId, filters: {} }; const query: ExpandedQuery = { tableId: leftTableId, filters: {} };
@ -169,14 +171,16 @@ export function buildComparisonQuery(leftTableId: string, rightTableId: string,
`${quoteIdent(rightTableId)}.id AS ${quoteIdent(rightTableId + '.id')}` `${quoteIdent(rightTableId)}.id AS ${quoteIdent(rightTableId + '.id')}`
); );
// Select columns from both tables using the table id as a prefix for each column name. // Select columns from both tables, using the table id as a prefix for each column name.
selectColumns.forEach((rightTableColumn, leftTableColumn) => { selectColumns.forEach((rightTableColumns, leftTableColumn) => {
const leftColumnAlias = `${leftTableId}.${leftTableColumn}`; const leftColumnAlias = `${leftTableId}.${leftTableColumn}`;
const rightColumnAlias = `${rightTableId}.${rightTableColumn}`; selects.push(`${quoteIdent(leftTableId)}.${quoteIdent(leftTableColumn)} AS ${quoteIdent(leftColumnAlias)}`);
selects.push(
`${quoteIdent(leftTableId)}.${quoteIdent(leftTableColumn)} AS ${quoteIdent(leftColumnAlias)}`, rightTableColumns.forEach(colId => {
`${quoteIdent(rightTableId)}.${quoteIdent(rightTableColumn)} AS ${quoteIdent(rightColumnAlias)}` const rightColumnAlias = `${rightTableId}.${colId}`;
); selects.push(`${quoteIdent(rightTableId)}.${quoteIdent(colId)} AS ${quoteIdent(rightColumnAlias)}`
);
});
}); });
/** /**
@ -189,14 +193,14 @@ export function buildComparisonQuery(leftTableId: string, rightTableId: string,
* the left table can only be matched with at most 1 equivalent row from the right table. * the left table can only be matched with at most 1 equivalent row from the right table.
*/ */
const dedupedRightTableQuery = const dedupedRightTableQuery =
`SELECT MIN(id) AS id, ${[...joinColumns.values()].map(v => quoteIdent(v)).join(', ')} ` + `SELECT MIN(id) AS id, ${[...joinColumns.keys()].map(v => quoteIdent(v)).join(', ')} ` +
`FROM ${quoteIdent(rightTableId)} ` + `FROM ${quoteIdent(rightTableId)} ` +
`GROUP BY ${[...joinColumns.values()].map(v => quoteIdent(v)).join(', ')}`; `GROUP BY ${[...joinColumns.keys()].map(v => quoteIdent(v)).join(', ')}`;
const dedupedRightTableAlias = quoteIdent('deduped_' + rightTableId); const dedupedRightTableAlias = quoteIdent('deduped_' + rightTableId);
// Join the left table to the (de-duplicated) right table, and include unmatched left rows. // Join the left table to the (de-duplicated) right table, and include unmatched left rows.
const joinConditions: string[] = []; const joinConditions: string[] = [];
joinColumns.forEach((rightTableColumn, leftTableColumn) => { joinColumns.forEach((leftTableColumn, rightTableColumn) => {
const leftExpression = `${quoteIdent(leftTableId)}.${quoteIdent(leftTableColumn)}`; const leftExpression = `${quoteIdent(leftTableId)}.${quoteIdent(leftTableColumn)}`;
const rightExpression = `${dedupedRightTableAlias}.${quoteIdent(rightTableColumn)}`; const rightExpression = `${dedupedRightTableAlias}.${quoteIdent(rightTableColumn)}`;
joinConditions.push(`${leftExpression} = ${rightExpression}`); joinConditions.push(`${leftExpression} = ${rightExpression}`);
@ -212,16 +216,21 @@ export function buildComparisonQuery(leftTableId: string, rightTableId: string,
// Filter out matching rows where all non-join columns from both tables are identical. // Filter out matching rows where all non-join columns from both tables are identical.
const whereConditions: string[] = []; const whereConditions: string[] = [];
for (const [leftTableColumn, rightTableColumn] of selectColumns.entries()) { for (const [leftTableColumnId, rightTableColumnIds] of selectColumns.entries()) {
if (joinColumns.has(leftTableColumn)) { continue; } const leftColumnAlias = quoteIdent(`${leftTableId}.${leftTableColumnId}`);
const leftColumnAlias = quoteIdent(`${leftTableId}.${leftTableColumn}`); for (const rightTableColId of rightTableColumnIds) {
const rightColumnAlias = quoteIdent(`${rightTableId}.${rightTableColumn}`); // If this left/right column id pair was already used for joining, skip it.
if (joinColumns.get(rightTableColId) === leftTableColumnId) { continue; }
// Only include rows that have differences in column values. // Only include rows that have differences in column values.
whereConditions.push(`${leftColumnAlias} IS NOT ${rightColumnAlias}`); const rightColumnAlias = quoteIdent(`${rightTableId}.${rightTableColId}`);
whereConditions.push(`${leftColumnAlias} IS NOT ${rightColumnAlias}`);
}
}
if (whereConditions.length > 0) {
wheres.push(`(${whereConditions.join(' OR ')})`);
} }
wheres.push(`(${whereConditions.join(' OR ')})`);
// Copy decisions to the query object, and return. // Copy decisions to the query object, and return.
query.joins = joins; query.joins = joins;

View File

@ -132,7 +132,7 @@ class ImportActions(object):
transform_rule: defines columns to make (colids must be filled in!) transform_rule: defines columns to make (colids must be filled in!)
gen_all: If true, all columns will be generated gen_all: If true, all columns will be generated
If false, formulas that just copy will be skipped, and blank formulas will be skipped If false, formulas that just copy will be skipped
returns list of newly created colrefs (rowids into _grist_Tables_column) returns list of newly created colrefs (rowids into _grist_Tables_column)
""" """
@ -151,12 +151,11 @@ class ImportActions(object):
#take formula from transform_rule #take formula from transform_rule
new_cols = [] new_cols = []
for c in dest_cols: for c in dest_cols:
# skip copy and blank columns (unless gen_all) # skip copy columns (unless gen_all)
formula = c.formula.strip() formula = c.formula.strip()
isCopyFormula = (formula.startswith("$") and formula[1:] in src_cols) isCopyFormula = (formula.startswith("$") and formula[1:] in src_cols)
isBlankFormula = not formula
if gen_all or (not isCopyFormula and not isBlankFormula): if gen_all or not isCopyFormula:
#if colId specified, use that. Else label is fine #if colId specified, use that. Else label is fine
new_col_id = _import_transform_col_prefix + (c.colId or c.label) new_col_id = _import_transform_col_prefix + (c.colId or c.label)
new_col_spec = { new_col_spec = {

View File

@ -57,6 +57,7 @@
--icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMCA3LjUsMCBDNy43NzYxNDIzNywwIDgsMC4yMjM4NTc2MjUgOCwwLjUgTDgsMi41IEM4LDIuNzc2MTQyMzcgNy43NzYxNDIzNywzIDcuNSwzIEM3LjIyMzg1NzYzLDMgNywyLjc3NjE0MjM3IDcsMi41IEw3LDAuNSBaIE0xLjQ4OTQ0NjYxLDMuMTk2NTUzMzkgQzEuMjk0MTg0NDYsMy4wMDEyOTEyNCAxLjI5NDE4NDQ2LDIuNjg0NzA4NzYgMS40ODk0NDY2MSwyLjQ4OTQ0NjYxIEMxLjY4NDcwODc2LDIuMjk0MTg0NDYgMi4wMDEyOTEyNCwyLjI5NDE4NDQ2IDIuMTk2NTUzMzksMi40ODk0NDY2MSBMMy42MTA1NTMzOSwzLjkwMzQ0NjYxIEMzLjgwNTgxNTU0LDQuMDk4NzA4NzYgMy44MDU4MTU1NCw0LjQxNTI5MTI0IDMuNjEwNTUzMzksNC42MTA1NTMzOSBDMy40MTUyOTEyNCw0LjgwNTgxNTU0IDMuMDk4NzA4NzYsNC44MDU4MTU1NCAyLjkwMzQ0NjYxLDQuNjEwNTUzMzkgTDEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBaIE0xMi44MDM0NDY2LDIuNDg5NDQ2NjEgQzEyLjk5ODcwODgsMi4yOTQxODQ0NiAxMy4zMTUyOTEyLDIuMjk0MTg0NDYgMTMuNTEwNTUzNCwyLjQ4OTQ0NjYxIEMxMy43MDU4MTU1LDIuNjg0NzA4NzYgMTMuNzA1ODE1NSwzLjAwMTI5MTI0IDEzLjUxMDU1MzQsMy4xOTY1NTMzOSBMMTIuMDk2NTUzNCw0LjYxMDU1MzM5IEMxMS45MDEyOTEyLDQuODA1ODE1NTQgMTEuNTg0NzA4OCw0LjgwNTgxNTU0IDExLjM4OTQ0NjYsNC42MTA1NTMzOSBDMTEuMTk0MTg0NSw0LjQxNTI5MTI0IDExLjE5NDE4NDUsNC4wOTg3MDg3NiAxMS4zODk0NDY2LDMuOTAzNDQ2NjEgTDEyLjgwMzQ0NjYsMi40ODk0NDY2MSBaIE0xMCwxMi4yMjMzNjY2IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDkuNzc2MTQyMzcsMTUgOS41LDE1IEw1LjUsMTUgQzUuMjIzODU3NjMsMTUgNSwxNC43NzYxNDI0IDUsMTQuNSBMNSwxMi4yMjI2NzMyIEMzLjI4NDM3MTQ2LDExLjA3Nzg0MDQgMi41NTY2OTc1Niw4Ljg5NTgwMTU4IDMuMjczODk0MTMsNi45MzUwNzcwNyBDNC4wMjUwMDkzMyw0Ljg4MTYyMzMxIDYuMTQzNDksMy42NjUyNjIzMiA4LjI5NTU2NjkyLDQuMDUxNzk5OTQgQzEwLjQ0NzQzMTksNC40MzgyOTk0OSAxMi4wMTAxMTM2LDYuMzE1NTI0NzggMTEuOTk5OTk3Myw4LjUwMTczODM2IEMxMS45OTY0OTg2LDEwLjAwNDU1NjkgMTEuMjQwNzkxOSwxMS4zOTg0NzI3IDEwLDEyLjIyMzM2NjYgWiBNOSwxMS45NDUgQzksMTEuNzY1OTAwOCA5LjA5NTc5MzU2LDExLjYwMDQ4MDUgOS4yNTExMzEyOCwxMS41MTEzMzYxIEMxMC4zMzA1NDU2LDEwLjg5MTg4NzYgMTAuOTk3MjgyNCw5Ljc0MzQzMzQ2IDExLjAwMDAwMTIsOC40OTg5MDc2OSBDMTEuMDA4MTE1Nyw2Ljc5NzAxMjQgOS43OTI2MjE5OCw1LjMzNjY5MDI0IDguMTE4Nzg0MzgsNS4wMzYwNDk4OCBDNi40NDQ5NDY3OCw0LjczNTQwOTUxIDQuNzk3MjM5NTksNS42ODE0NjgwNSA0LjIxMzAzODg4LDcuMjc4NTk4NzUgQzMuNjI4ODM4MTYsOC44NzU3Mjk0NSA0LjI3NzIzNDU1LDEwLjY2MTY2IDUuNzUwMDA1NjksMTEuNTExOTkwNiBDNS45MDQ3MDMwMiwxMS42MDEzMDc4IDYsMTEuNzY2MzY5NiA2LDExLjk0NSBMNiwxNCBMOSwxNCBMOSwxMS45NDUgWiBNMTMuNSw5IEMxMy4yMjM4NTc2LDkgMTMsOC43NzYxNDIzNyAxMyw4LjUgQzEzLDguMjIzODU3NjMgMTMuMjIzODU3Niw4IDEzLjUsOCBMMTQuNSw4IEMxNC43NzYxNDI0LDggMTUsOC4yMjM4NTc2MyAxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBMMTMuNSw5IFogTTAuNSw5IEMwLjIyMzg1NzYyNSw5IDAsOC43NzYxNDIzNyAwLDguNSBDMCw4LjIyMzg1NzYzIDAuMjIzODU3NjI1LDggMC41LDggTDEuNSw4IEMxLjc3NjE0MjM3LDggMiw4LjIyMzg1NzYzIDIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEwwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMCA3LjUsMCBDNy43NzYxNDIzNywwIDgsMC4yMjM4NTc2MjUgOCwwLjUgTDgsMi41IEM4LDIuNzc2MTQyMzcgNy43NzYxNDIzNywzIDcuNSwzIEM3LjIyMzg1NzYzLDMgNywyLjc3NjE0MjM3IDcsMi41IEw3LDAuNSBaIE0xLjQ4OTQ0NjYxLDMuMTk2NTUzMzkgQzEuMjk0MTg0NDYsMy4wMDEyOTEyNCAxLjI5NDE4NDQ2LDIuNjg0NzA4NzYgMS40ODk0NDY2MSwyLjQ4OTQ0NjYxIEMxLjY4NDcwODc2LDIuMjk0MTg0NDYgMi4wMDEyOTEyNCwyLjI5NDE4NDQ2IDIuMTk2NTUzMzksMi40ODk0NDY2MSBMMy42MTA1NTMzOSwzLjkwMzQ0NjYxIEMzLjgwNTgxNTU0LDQuMDk4NzA4NzYgMy44MDU4MTU1NCw0LjQxNTI5MTI0IDMuNjEwNTUzMzksNC42MTA1NTMzOSBDMy40MTUyOTEyNCw0LjgwNTgxNTU0IDMuMDk4NzA4NzYsNC44MDU4MTU1NCAyLjkwMzQ0NjYxLDQuNjEwNTUzMzkgTDEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBaIE0xMi44MDM0NDY2LDIuNDg5NDQ2NjEgQzEyLjk5ODcwODgsMi4yOTQxODQ0NiAxMy4zMTUyOTEyLDIuMjk0MTg0NDYgMTMuNTEwNTUzNCwyLjQ4OTQ0NjYxIEMxMy43MDU4MTU1LDIuNjg0NzA4NzYgMTMuNzA1ODE1NSwzLjAwMTI5MTI0IDEzLjUxMDU1MzQsMy4xOTY1NTMzOSBMMTIuMDk2NTUzNCw0LjYxMDU1MzM5IEMxMS45MDEyOTEyLDQuODA1ODE1NTQgMTEuNTg0NzA4OCw0LjgwNTgxNTU0IDExLjM4OTQ0NjYsNC42MTA1NTMzOSBDMTEuMTk0MTg0NSw0LjQxNTI5MTI0IDExLjE5NDE4NDUsNC4wOTg3MDg3NiAxMS4zODk0NDY2LDMuOTAzNDQ2NjEgTDEyLjgwMzQ0NjYsMi40ODk0NDY2MSBaIE0xMCwxMi4yMjMzNjY2IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDkuNzc2MTQyMzcsMTUgOS41LDE1IEw1LjUsMTUgQzUuMjIzODU3NjMsMTUgNSwxNC43NzYxNDI0IDUsMTQuNSBMNSwxMi4yMjI2NzMyIEMzLjI4NDM3MTQ2LDExLjA3Nzg0MDQgMi41NTY2OTc1Niw4Ljg5NTgwMTU4IDMuMjczODk0MTMsNi45MzUwNzcwNyBDNC4wMjUwMDkzMyw0Ljg4MTYyMzMxIDYuMTQzNDksMy42NjUyNjIzMiA4LjI5NTU2NjkyLDQuMDUxNzk5OTQgQzEwLjQ0NzQzMTksNC40MzgyOTk0OSAxMi4wMTAxMTM2LDYuMzE1NTI0NzggMTEuOTk5OTk3Myw4LjUwMTczODM2IEMxMS45OTY0OTg2LDEwLjAwNDU1NjkgMTEuMjQwNzkxOSwxMS4zOTg0NzI3IDEwLDEyLjIyMzM2NjYgWiBNOSwxMS45NDUgQzksMTEuNzY1OTAwOCA5LjA5NTc5MzU2LDExLjYwMDQ4MDUgOS4yNTExMzEyOCwxMS41MTEzMzYxIEMxMC4zMzA1NDU2LDEwLjg5MTg4NzYgMTAuOTk3MjgyNCw5Ljc0MzQzMzQ2IDExLjAwMDAwMTIsOC40OTg5MDc2OSBDMTEuMDA4MTE1Nyw2Ljc5NzAxMjQgOS43OTI2MjE5OCw1LjMzNjY5MDI0IDguMTE4Nzg0MzgsNS4wMzYwNDk4OCBDNi40NDQ5NDY3OCw0LjczNTQwOTUxIDQuNzk3MjM5NTksNS42ODE0NjgwNSA0LjIxMzAzODg4LDcuMjc4NTk4NzUgQzMuNjI4ODM4MTYsOC44NzU3Mjk0NSA0LjI3NzIzNDU1LDEwLjY2MTY2IDUuNzUwMDA1NjksMTEuNTExOTkwNiBDNS45MDQ3MDMwMiwxMS42MDEzMDc4IDYsMTEuNzY2MzY5NiA2LDExLjk0NSBMNiwxNCBMOSwxNCBMOSwxMS45NDUgWiBNMTMuNSw5IEMxMy4yMjM4NTc2LDkgMTMsOC43NzYxNDIzNyAxMyw4LjUgQzEzLDguMjIzODU3NjMgMTMuMjIzODU3Niw4IDEzLjUsOCBMMTQuNSw4IEMxNC43NzYxNDI0LDggMTUsOC4yMjM4NTc2MyAxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBMMTMuNSw5IFogTTAuNSw5IEMwLjIyMzg1NzYyNSw5IDAsOC43NzYxNDIzNyAwLDguNSBDMCw4LjIyMzg1NzYzIDAuMjIzODU3NjI1LDggMC41LDggTDEuNSw4IEMxLjc3NjE0MjM3LDggMiw4LjIyMzg1NzYzIDIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEwwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Import: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUsNyBDMTEuOTI5NTc5NCw3IDEyLjE1OTE2ODQsNy41MDU5NjA4NiAxMS44NzYyODgzLDcuODI5MjUyMyBMOC4zNzYyODgzNSwxMS44MjkyNTIzIEM4LjE3NzA4MjcsMTIuMDU2OTE1OSA3LjgyMjkxNzMsMTIuMDU2OTE1OSA3LjYyMzcxMTY1LDExLjgyOTI1MjMgTDQuMTIzNzExNjUsNy44MjkyNTIzIEMzLjg0MDgzMTY0LDcuNTA1OTYwODYgNC4wNzA0MjA2LDcgNC41LDcgTDYsNyBMNiwxLjUgQzYsMS4yMjM4NTc2MyA2LjIyMzg1NzYzLDEgNi41LDEgTDkuNSwxIEM5Ljc3NjE0MjM3LDEgMTAsMS4yMjM4NTc2MyAxMCwxLjUgTDEwLDcgTDExLjUsNyBaIE04LDEwLjc0MDcwMzkgTDEwLjM5ODExNTksOCBMOS41LDggQzkuMjIzODU3NjMsOCA5LDcuNzc2MTQyMzcgOSw3LjUgTDksMiBMNywyIEw3LDcuNSBDNyw3Ljc3NjE0MjM3IDYuNzc2MTQyMzcsOCA2LjUsOCBMNS42MDE4ODQxMSw4IEw4LDEwLjc0MDcwMzkgWiBNMTIuNSwyIEMxMi4yMjM4NTc2LDIgMTIsMS43NzYxNDIzNyAxMiwxLjUgQzEyLDEuMjIzODU3NjMgMTIuMjIzODU3NiwxIDEyLjUsMSBMMTQuNSwxIEMxNS4zMjgxNDI0LDEgMTYsMS42NzE4NTc2MyAxNiwyLjUgTDE2LDEzLjUgQzE2LDE0LjMyODE0MjQgMTUuMzI4MTQyNCwxNSAxNC41LDE1IEwxLjUsMTUgQzAuNjcxODU3NjI1LDE1IDAsMTQuMzI4MTQyNCAwLDEzLjUgTDAsMi41IEMwLDEuNjcxODU3NjMgMC42NzE4NTc2MjUsMSAxLjUsMSBMMy41LDEgQzMuNzc2MTQyMzcsMSA0LDEuMjIzODU3NjMgNCwxLjUgQzQsMS43NzYxNDIzNyAzLjc3NjE0MjM3LDIgMy41LDIgTDEuNSwyIEMxLjIyNDE0MjM3LDIgMSwyLjIyNDE0MjM3IDEsMi41IEwxLDEzLjUgQzEsMTMuNzc1ODU3NiAxLjIyNDE0MjM3LDE0IDEuNSwxNCBMMTQuNSwxNCBDMTQuNzc1ODU3NiwxNCAxNSwxMy43NzU4NTc2IDE1LDEzLjUgTDE1LDIuNSBDMTUsMi4yMjQxNDIzNyAxNC43NzU4NTc2LDIgMTQuNSwyIEwxMi41LDIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-Import: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUsNyBDMTEuOTI5NTc5NCw3IDEyLjE1OTE2ODQsNy41MDU5NjA4NiAxMS44NzYyODgzLDcuODI5MjUyMyBMOC4zNzYyODgzNSwxMS44MjkyNTIzIEM4LjE3NzA4MjcsMTIuMDU2OTE1OSA3LjgyMjkxNzMsMTIuMDU2OTE1OSA3LjYyMzcxMTY1LDExLjgyOTI1MjMgTDQuMTIzNzExNjUsNy44MjkyNTIzIEMzLjg0MDgzMTY0LDcuNTA1OTYwODYgNC4wNzA0MjA2LDcgNC41LDcgTDYsNyBMNiwxLjUgQzYsMS4yMjM4NTc2MyA2LjIyMzg1NzYzLDEgNi41LDEgTDkuNSwxIEM5Ljc3NjE0MjM3LDEgMTAsMS4yMjM4NTc2MyAxMCwxLjUgTDEwLDcgTDExLjUsNyBaIE04LDEwLjc0MDcwMzkgTDEwLjM5ODExNTksOCBMOS41LDggQzkuMjIzODU3NjMsOCA5LDcuNzc2MTQyMzcgOSw3LjUgTDksMiBMNywyIEw3LDcuNSBDNyw3Ljc3NjE0MjM3IDYuNzc2MTQyMzcsOCA2LjUsOCBMNS42MDE4ODQxMSw4IEw4LDEwLjc0MDcwMzkgWiBNMTIuNSwyIEMxMi4yMjM4NTc2LDIgMTIsMS43NzYxNDIzNyAxMiwxLjUgQzEyLDEuMjIzODU3NjMgMTIuMjIzODU3NiwxIDEyLjUsMSBMMTQuNSwxIEMxNS4zMjgxNDI0LDEgMTYsMS42NzE4NTc2MyAxNiwyLjUgTDE2LDEzLjUgQzE2LDE0LjMyODE0MjQgMTUuMzI4MTQyNCwxNSAxNC41LDE1IEwxLjUsMTUgQzAuNjcxODU3NjI1LDE1IDAsMTQuMzI4MTQyNCAwLDEzLjUgTDAsMi41IEMwLDEuNjcxODU3NjMgMC42NzE4NTc2MjUsMSAxLjUsMSBMMy41LDEgQzMuNzc2MTQyMzcsMSA0LDEuMjIzODU3NjMgNCwxLjUgQzQsMS43NzYxNDIzNyAzLjc3NjE0MjM3LDIgMy41LDIgTDEuNSwyIEMxLjIyNDE0MjM3LDIgMSwyLjIyNDE0MjM3IDEsMi41IEwxLDEzLjUgQzEsMTMuNzc1ODU3NiAxLjIyNDE0MjM3LDE0IDEuNSwxNCBMMTQuNSwxNCBDMTQuNzc1ODU3NiwxNCAxNSwxMy43NzU4NTc2IDE1LDEzLjUgTDE1LDIuNSBDMTUsMi4yMjQxNDIzNyAxNC43NzU4NTc2LDIgMTQuNSwyIEwxMi41LDIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-ImportArrow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTciIGhlaWdodD0iMzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxkZWZzPjxwYXRoIGlkPSJhIiBkPSJNMCAwSDE1VjMySDB6Ii8+PC9kZWZzPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgMSkiPjxtYXNrIGlkPSJiIiBmaWxsPSIjZmZmIj48dXNlIHhsaW5rOmhyZWY9IiNhIi8+PC9tYXNrPjxjaXJjbGUgc3Ryb2tlPSIjRDlEOUQ5IiBzdHJva2Utd2lkdGg9IjQiIG1hc2s9InVybCgjYikiIGN4PSIxNSIgY3k9IjE2IiByPSIxMiIvPjwvZz48cGF0aCBzdHJva2U9IiNEOUQ5RDkiIGZpbGw9IiNEOUQ5RDkiIHRyYW5zZm9ybT0icm90YXRlKDkwIDE0IDQpIiBkPSJNMTUgMUwxOSA3IDExIDd6Ii8+PC9nPjwvc3ZnPg==');
--icon-Info: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTYiIHdpZHRoPSIxNiI+PHBhdGggZD0iTSAxNiw4IEEgOCw4IDAgMSAxIDAsOCA4LDggMCAwIDEgMTYsOCBaIE0gOSw0IEEgMSwxIDAgMSAxIDcsNCAxLDEgMCAwIDEgOSw0IFogTSA3LDcgYSAxLDEgMCAwIDAgMCwyIHYgMyBhIDEsMSAwIDAgMCAxLDEgSCA5IEEgMSwxIDAgMSAwIDksMTEgViA4IEEgMSwxIDAgMCAwIDgsNyBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg=='); --icon-Info: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTYiIHdpZHRoPSIxNiI+PHBhdGggZD0iTSAxNiw4IEEgOCw4IDAgMSAxIDAsOCA4LDggMCAwIDEgMTYsOCBaIE0gOSw0IEEgMSwxIDAgMSAxIDcsNCAxLDEgMCAwIDEgOSw0IFogTSA3LDcgYSAxLDEgMCAwIDAgMCwyIHYgMyBhIDEsMSAwIDAgMCAxLDEgSCA5IEEgMSwxIDAgMSAwIDksMTEgViA4IEEgMSwxIDAgMCAwIDgsNyBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg==');
--icon-LeftAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE0yLjUsMTMgQzIuMjIzODU3NjMsMTMgMiwxMi43NzYxNDI0IDIsMTIuNSBDMiwxMi4yMjM4NTc2IDIuMjIzODU3NjMsMTIgMi41LDEyIEw3LjUsMTIgQzcuNzc2MTQyMzcsMTIgOCwxMi4yMjM4NTc2IDgsMTIuNSBDOCwxMi43NzYxNDI0IDcuNzc2MTQyMzcsMTMgNy41LDEzIEwyLjUsMTMgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-LeftAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE0yLjUsMTMgQzIuMjIzODU3NjMsMTMgMiwxMi43NzYxNDI0IDIsMTIuNSBDMiwxMi4yMjM4NTc2IDIuMjIzODU3NjMsMTIgMi41LDEyIEw3LjUsMTIgQzcuNzc2MTQyMzcsMTIgOCwxMi4yMjM4NTc2IDgsMTIuNSBDOCwxMi43NzYxNDI0IDcuNzc2MTQyMzcsMTMgNy41LDEzIEwyLjUsMTMgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg=='); --icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg==');

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="17px" height="31px" viewBox="0 0 17 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
<title>Group 29</title>
<desc>Created with Sketch.</desc>
<defs>
<rect id="path-1" x="0" y="0" width="15" height="32"></rect>
</defs>
<g id="Imports" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Import-Dialog-v3b" transform="translate(-89.000000, -384.000000)">
<g id="Group-24" transform="translate(88.000000, 216.000000)">
<g id="Group-13" transform="translate(0.000000, 163.000000)">
<g id="Group-29" transform="translate(0.000000, 6.000000)">
<g id="Oval">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Mask"></g>
<circle stroke="#D9D9D9" stroke-width="4" mask="url(#mask-2)" cx="15" cy="16" r="12"></circle>
</g>
<polygon id="Triangle" stroke="#D9D9D9" fill="#D9D9D9" transform="translate(15.000000, 4.000000) rotate(90.000000) translate(-15.000000, -4.000000) " points="15 1 19 7 11 7"></polygon>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB