mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
96fa7ad562
commit
08b1286f4f
@ -4,31 +4,35 @@
|
||||
*/
|
||||
// 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 {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 {fetchURL, isDriveUrl, selectFiles, uploadFiles} from 'app/client/lib/uploads';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {SortedRowSet} from "app/client/models/rowset";
|
||||
import {openFilePicker} from "app/client/ui/FileDialog";
|
||||
import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {SortedRowSet} from 'app/client/models/rowset';
|
||||
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
|
||||
import {openFilePicker} from 'app/client/ui/FileDialog';
|
||||
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
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 {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,
|
||||
MergeOptionsMap,
|
||||
MergeStrategy, TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI";
|
||||
import {byteString} from "app/common/gutil";
|
||||
MergeStrategy, TransformColumn, TransformRule, TransformRuleMap} from 'app/common/ActiveDocAPI';
|
||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||
import {byteString} from 'app/common/gutil';
|
||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||
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';
|
||||
import {labeledSquareCheckbox} from "app/client/ui2018/checkbox";
|
||||
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from "app/client/ui/googleAuth";
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth';
|
||||
import debounce = require('lodash/debounce');
|
||||
|
||||
// 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
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@ -147,6 +151,9 @@ export class Importer extends Disposable {
|
||||
private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []);
|
||||
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> =
|
||||
Computed.create(this, this._sourceInfoSelected, (use, info) => {
|
||||
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})),
|
||||
]);
|
||||
|
||||
// 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.
|
||||
constructor(private _gristDoc: GristDoc, private _importSourceElem: ImportSourceElement|null,
|
||||
private _createPreview: CreatePreviewFunc) {
|
||||
@ -406,6 +434,8 @@ export class Importer extends Disposable {
|
||||
|
||||
private async _cancelImport() {
|
||||
this._resetImportDiffState();
|
||||
// Formula editor cleanup needs to happen before the hidden tables are removed.
|
||||
this._formulaEditorHolder.dispose();
|
||||
|
||||
if (this._uploadResult) {
|
||||
await this._docComm.cancelImportFiles(
|
||||
@ -428,8 +458,11 @@ export class Importer extends Disposable {
|
||||
const mergeOptions = this._mergeOptions[selectedSourceInfo.hiddenTableId];
|
||||
if (!mergeOptions) { return isValid; } // No configuration to validate.
|
||||
|
||||
const destTableId = selectedSourceInfo.destTableId.get();
|
||||
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);
|
||||
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.
|
||||
private _renderMain(upload: UploadResult) {
|
||||
const schema = this._parseOptions.get().SCHEMA;
|
||||
this._screen.render([
|
||||
const content = cssContainer(
|
||||
dom.autoDispose(this._formulaEditorHolder),
|
||||
{tabIndex: '-1'},
|
||||
this._buildModalTitle(
|
||||
schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options',
|
||||
testId('importer-options-link'),
|
||||
@ -510,6 +545,11 @@ export class Importer extends Disposable {
|
||||
dom.forEach(this._sourceInfoArray, (info) => {
|
||||
const destTableId = Computed.create(null, (use) => use(info.destTableId))
|
||||
.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);
|
||||
this._resetTableMergeOptions(info.hiddenTableId);
|
||||
await this._updateTransformSection(info);
|
||||
@ -521,9 +561,11 @@ export class Importer extends Disposable {
|
||||
cssTableLine(cssToFrom('To'), linkSelect<DestId>(destTableId, this._destTables)),
|
||||
cssTableInfo.cls('-selected', (use) => use(this._sourceInfoSelected) === info),
|
||||
dom.on('click', async () => {
|
||||
if (info === this._sourceInfoSelected.get() || !this._validateImportConfiguration()) {
|
||||
return;
|
||||
}
|
||||
// Ignore click if source is already selected.
|
||||
if (info === this._sourceInfoSelected.get()) { return; }
|
||||
|
||||
// Prevent changing selected source if current configuration is invalid.
|
||||
if (!this._validateImportConfiguration()) { return; }
|
||||
|
||||
this._cancelPendingDiffRequests();
|
||||
this._sourceInfoSelected.set(info);
|
||||
@ -536,8 +578,10 @@ export class Importer extends Disposable {
|
||||
dom.maybe(this._sourceInfoSelected, (info) => {
|
||||
const {mergeCols, updateExistingRecords, hasInvalidMergeCols} = this._mergeOptions[info.hiddenTableId]!;
|
||||
|
||||
return [
|
||||
dom.maybe(info.destTableId, (_dest) => {
|
||||
return cssConfigAndPreview(
|
||||
cssConfigColumn(
|
||||
dom.maybe(info.transformSection, section => [
|
||||
dom.maybe(info.destTableId, () => {
|
||||
const updateRecordsListener = updateExistingRecords.addListener(async () => {
|
||||
await this._updateImportDiff(info);
|
||||
});
|
||||
@ -549,34 +593,98 @@ export class Importer extends Disposable {
|
||||
dom.autoDispose(updateRecordsListener),
|
||||
testId('importer-update-existing-records')
|
||||
)),
|
||||
dom.maybe(updateExistingRecords, () => [
|
||||
cssMergeOptionsMessage(
|
||||
'Imported rows will be merged with records that have the same values for all of these fields:',
|
||||
testId('importer-merge-fields-message')
|
||||
),
|
||||
dom.domComputed(info.transformSection, section => {
|
||||
dom.maybe(updateExistingRecords, () => {
|
||||
const mergeColsListener = mergeCols.addListener(async val => {
|
||||
// Reset the error state of the multiSelect on change.
|
||||
if (val.length !== 0 && hasInvalidMergeCols.get()) {
|
||||
hasInvalidMergeCols.set(false);
|
||||
}
|
||||
|
||||
await this._updateImportDiff(info);
|
||||
});
|
||||
return multiSelect(
|
||||
|
||||
return [
|
||||
cssMergeOptionsMessage(
|
||||
'Merge rows that match these fields:',
|
||||
testId('importer-merge-fields-message')
|
||||
),
|
||||
multiSelect(
|
||||
mergeCols,
|
||||
section?.viewFields().peek().map(field => field.label()) ?? [],
|
||||
section.viewFields().peek().map(f => ({label: f.label(), value: f.colId()})) ?? [],
|
||||
{
|
||||
placeholder: 'Select fields to match on',
|
||||
error: hasInvalidMergeCols
|
||||
},
|
||||
dom.autoDispose(mergeColsListener),
|
||||
testId('importer-merge-fields-select')
|
||||
);
|
||||
)
|
||||
];
|
||||
})
|
||||
])
|
||||
);
|
||||
}),
|
||||
dom.domComputed(this._unmatchedFields, fields =>
|
||||
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);
|
||||
@ -588,6 +696,12 @@ export class Importer extends Disposable {
|
||||
|
||||
// When changes are made to the preview table, update the import diff.
|
||||
gridView.listenTo(gridView.sortedRows, 'rowNotify', async () => {
|
||||
// If we aren't showing a diff, there is no need to do anything.
|
||||
if (!info.destTableId || !updateExistingRecords.get() || mergeCols.get().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, update the diff and rebuild the preview table.
|
||||
await this._updateImportDiff(info);
|
||||
});
|
||||
|
||||
@ -597,7 +711,8 @@ export class Importer extends Disposable {
|
||||
testId('importer-preview'),
|
||||
);
|
||||
})
|
||||
];
|
||||
)
|
||||
);
|
||||
}),
|
||||
),
|
||||
cssModalButtons(
|
||||
@ -611,7 +726,88 @@ export class Importer extends Disposable {
|
||||
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.
|
||||
@ -681,6 +877,13 @@ function getSourceDescription(sourceInfo: SourceInfo, upload: UploadResult) {
|
||||
return sourceInfo.origTableName ? origName + ' - ' + sourceInfo.origTableName : origName;
|
||||
}
|
||||
|
||||
const cssContainer = styled('div', `
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: unset;
|
||||
`);
|
||||
|
||||
const cssActionLink = styled('div', `
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -709,8 +912,9 @@ const cssModalHeader = styled('div', `
|
||||
`);
|
||||
|
||||
const cssPreviewWrapper = styled('div', `
|
||||
width: 600px;
|
||||
padding: 8px 12px 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
`);
|
||||
|
||||
@ -725,17 +929,19 @@ const cssSectionHeader = styled('div', `
|
||||
`);
|
||||
|
||||
const cssTableList = styled('div', `
|
||||
max-height: 50%;
|
||||
column-gap: 32px;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
`);
|
||||
|
||||
const cssTableInfo = styled('div', `
|
||||
padding: 4px 8px;
|
||||
margin: 4px 0px;
|
||||
width: calc(50% - 16px);
|
||||
width: 300px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
&:hover, &-selected {
|
||||
@ -762,12 +968,35 @@ const cssToFrom = styled('span', `
|
||||
`);
|
||||
|
||||
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', `
|
||||
display: flex;
|
||||
height: 300px;
|
||||
flex-grow: 1;
|
||||
`);
|
||||
|
||||
const cssPreviewSpinner = styled(cssPreview, `
|
||||
@ -791,3 +1020,81 @@ const cssMergeOptionsMessage = styled('div', `
|
||||
color: ${colors.slate};
|
||||
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};
|
||||
`);
|
||||
|
@ -6,12 +6,21 @@ import { PluginInstance } from 'app/common/PluginInstance';
|
||||
import { RenderTarget } from 'app/plugin/RenderOptions';
|
||||
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.
|
||||
*/
|
||||
export class PluginScreen extends Disposable {
|
||||
private _openModalCtl: IModalControl | null = null;
|
||||
private _importerContent = Observable.create<DomContents>(this, null);
|
||||
private _fullscreen = Observable.create(this, false);
|
||||
|
||||
constructor(private _title: string) {
|
||||
super();
|
||||
@ -33,9 +42,10 @@ export class PluginScreen extends Disposable {
|
||||
return handle;
|
||||
}
|
||||
|
||||
public render(content: DomContents) {
|
||||
public render(content: DomContents, options?: RenderOptions) {
|
||||
this.showImportDialog();
|
||||
this._importerContent.set(content);
|
||||
this._fullscreen.set(Boolean(options?.fullscreen));
|
||||
}
|
||||
|
||||
// The importer state showing just an error.
|
||||
@ -67,6 +77,7 @@ export class PluginScreen extends Disposable {
|
||||
this._openModalCtl = ctl;
|
||||
return [
|
||||
cssModalOverrides.cls(''),
|
||||
cssModalOverrides.cls('-fullscreen', this._fullscreen),
|
||||
dom.domComputed(this._importerContent),
|
||||
testId('importer-dialog'),
|
||||
];
|
||||
@ -89,6 +100,11 @@ const cssModalOverrides = styled('div', `
|
||||
& > .${cssModalButtons.className} {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&-fullscreen {
|
||||
height: 100%;
|
||||
margin: 32px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssModalBody = styled('div', `
|
||||
|
@ -56,6 +56,7 @@ export type IconName = "ChartArea" |
|
||||
"Home" |
|
||||
"Idea" |
|
||||
"Import" |
|
||||
"ImportArrow" |
|
||||
"Info" |
|
||||
"LeftAlign" |
|
||||
"Lock" |
|
||||
@ -153,6 +154,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Home",
|
||||
"Idea",
|
||||
"Import",
|
||||
"ImportArrow",
|
||||
"Info",
|
||||
"LeftAlign",
|
||||
"Lock",
|
||||
|
@ -209,7 +209,10 @@ export function multiSelect<T>(selectedOptions: MutableObsArray<T>,
|
||||
return cssSelectBtn(
|
||||
dom.autoDispose(selectedOptionsSet),
|
||||
dom.autoDispose(selectedOptionsText),
|
||||
cssMultiSelectSummary(dom.text(selectedOptionsText)),
|
||||
cssMultiSelectSummary(
|
||||
dom.text(selectedOptionsText),
|
||||
cssMultiSelectSummary.cls('-placeholder', use => use(selectedOptionsSet).size === 0)
|
||||
),
|
||||
icon('Dropdown'),
|
||||
elem => {
|
||||
weasel.setPopupToCreateDom(elem, ctl => buildMultiSelectMenu(ctl), weasel.defaultMenuOptions);
|
||||
@ -535,6 +538,10 @@ const cssMultiSelectSummary = styled('div', `
|
||||
flex: 1 1 0px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&-placeholder {
|
||||
color: ${colors.slate}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssMultiSelectMenu = styled(weasel.cssMenu, `
|
||||
|
@ -19,7 +19,7 @@ import { buttonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||
import { DiffBox } from 'app/client/widgets/DiffBox';
|
||||
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 { NewBaseEditor } from "app/client/widgets/NewBaseEditor";
|
||||
import * as UserType from 'app/client/widgets/UserType';
|
||||
@ -523,9 +523,10 @@ export class FieldBuilder extends Disposable {
|
||||
editValue?: string,
|
||||
onSave?: (formula: string) => Promise<void>,
|
||||
onCancel?: () => void) {
|
||||
const editorHolder = openSideFormulaEditor({
|
||||
const editorHolder = openFormulaEditor({
|
||||
gristDoc: this.gristDoc,
|
||||
field: this.field,
|
||||
setupCleanup: setupEditorCleanup,
|
||||
editRow,
|
||||
refElem,
|
||||
editValue,
|
||||
|
@ -15,7 +15,7 @@ import {isRaisedException} from 'app/common/gristTypes';
|
||||
import * as gutil from 'app/common/gutil';
|
||||
import {Disposable, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import { CellPosition } from "app/client/components/CellPosition";
|
||||
import {CellPosition} from "app/client/components/CellPosition";
|
||||
|
||||
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,
|
||||
field: ViewFieldRec,
|
||||
editRow: DataRowModel, // Needed to get exception value, if any.
|
||||
refElem: Element, // Element in the side pane over which to position the editor.
|
||||
// Needed to get exception value, if any.
|
||||
editRow?: DataRowModel,
|
||||
// Element over which to position the editor.
|
||||
refElem: Element,
|
||||
editValue?: string,
|
||||
onSave?: (formula: string) => Promise<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 {
|
||||
const {gristDoc, field, editRow, refElem} = options;
|
||||
const {gristDoc, field, editRow, refElem, setupCleanup} = options;
|
||||
const holder = MultiHolder.create(null);
|
||||
const column = field.column();
|
||||
|
||||
@ -411,7 +420,7 @@ export function openSideFormulaEditor(options: {
|
||||
gristDoc,
|
||||
field,
|
||||
cellValue: column.formula(),
|
||||
formulaError: getFormulaError(gristDoc, editRow, column),
|
||||
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
||||
editValue: options.editValue,
|
||||
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
||||
commands: editCommands,
|
||||
@ -422,7 +431,7 @@ export function openSideFormulaEditor(options: {
|
||||
|
||||
// Enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
|
||||
field.editingFormula(true);
|
||||
setupEditorCleanup(holder, gristDoc, field, saveEdit);
|
||||
setupCleanup(holder, gristDoc, field, saveEdit);
|
||||
return holder;
|
||||
}
|
||||
|
||||
@ -447,7 +456,7 @@ function setupReadonlyEditorCleanup(
|
||||
* - unset field.editingFormula mode
|
||||
* - 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>
|
||||
) {
|
||||
const saveEdit = () => _saveEdit().catch(reportError);
|
||||
|
@ -18,6 +18,7 @@ import {DocSession, OptDocSession} from 'app/server/lib/DocSession';
|
||||
import * as log from 'app/server/lib/log';
|
||||
import {globalUploadSet, moveUpload, UploadInfo} from 'app/server/lib/uploads';
|
||||
import {buildComparisonQuery} from 'app/server/lib/ExpandedQuery';
|
||||
import flatten = require('lodash/flatten');
|
||||
|
||||
const IMPORT_TRANSFORM_COLUMN_PREFIX = 'gristHelper_Import_';
|
||||
|
||||
@ -131,16 +132,19 @@ export class ActiveDocImport {
|
||||
*/
|
||||
public async generateImportDiff(hiddenTableId: string, {destCols, destTableId}: TransformRule,
|
||||
{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.
|
||||
const selectColumns: [string, string][] =
|
||||
destCols.map(c => [c.colId!, c.colId!.slice(IMPORT_TRANSFORM_COLUMN_PREFIX.length)]);
|
||||
const selectColumnsMap = new Map(selectColumns);
|
||||
const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId!, selectColumnsMap, mergeCols);
|
||||
const srcAndDestColIds: [string, string[]][] =
|
||||
destCols.map(c => [c.colId!, [c.colId!.slice(IMPORT_TRANSFORM_COLUMN_PREFIX.length)]]);
|
||||
const srcToDestColIds = new Map(srcAndDestColIds);
|
||||
const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId!, srcToDestColIds, mergeCols);
|
||||
|
||||
// Initialize container for updated column values in the expected format (ColumnDelta).
|
||||
const updatedRecords: {[colId: string]: ColumnDelta} = {};
|
||||
const updatedRecordIds: number[] = [];
|
||||
const srcColIds = selectColumns.map(([srcColId, _destColId]) => srcColId);
|
||||
const srcColIds = srcAndDestColIds.map(([srcColId, _destColId]) => srcColId);
|
||||
for (const id of srcColIds) {
|
||||
updatedRecords[id] = {};
|
||||
}
|
||||
@ -160,7 +164,7 @@ export class ActiveDocImport {
|
||||
} else {
|
||||
// Otherwise, a match was found between source and destination tables.
|
||||
for (const srcColId of srcColIds) {
|
||||
const matchingDestColId = selectColumnsMap.get(srcColId);
|
||||
const matchingDestColId = srcToDestColIds.get(srcColId)![0];
|
||||
const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][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.
|
||||
stripPrefixes(transformRule);
|
||||
stripRulePrefixes(transformRule);
|
||||
|
||||
if (intoNewTable) {
|
||||
// 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,
|
||||
{destCols, sourceCols}: TransformRule,
|
||||
{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.
|
||||
const selectColumns: [string, string][] = destCols.map(destCol => {
|
||||
const srcAndDestColIds: [string, string][] = destCols.map(destCol => {
|
||||
const formula = destCol.formula.trim();
|
||||
const srcColId = formula.startsWith('$') && sourceCols.includes(formula.slice(1)) ?
|
||||
formula.slice(1) : IMPORT_TRANSFORM_COLUMN_PREFIX + destCol.colId;
|
||||
return [srcColId, destCol.colId!];
|
||||
});
|
||||
const selectColumnsMap = new Map(selectColumns);
|
||||
const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId, selectColumnsMap, mergeCols);
|
||||
const srcToDestColIds: Map<string, string[]> = new Map();
|
||||
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.
|
||||
const newRecords: BulkColValues = {};
|
||||
@ -460,7 +474,7 @@ export class ActiveDocImport {
|
||||
const updatedRecords: BulkColValues = {};
|
||||
const updatedRecordIds: number[] = [];
|
||||
|
||||
const destColIds = [...selectColumnsMap.values()];
|
||||
const destColIds = flatten([...srcToDestColIds.values()]);
|
||||
for (const id of destColIds) {
|
||||
newRecords[id] = [];
|
||||
updatedRecords[id] = [];
|
||||
@ -469,23 +483,27 @@ export class ActiveDocImport {
|
||||
// Retrieve the function used to reconcile differences between source and destination.
|
||||
const merge = getMergeFunction(mergeStrategy);
|
||||
|
||||
const srcColIds = [...selectColumnsMap.keys()];
|
||||
const srcColIds = [...srcToDestColIds.keys()];
|
||||
const numResultRows = comparisonResult[hiddenTableId + '.id'].length;
|
||||
for (let i = 0; i < numResultRows; i++) {
|
||||
if (comparisonResult[destTableId + '.id'][i] === null) {
|
||||
// No match in destination table found for source row, so it must be a new record.
|
||||
for (const srcColId of srcColIds) {
|
||||
const matchingDestColId = selectColumnsMap.get(srcColId);
|
||||
newRecords[matchingDestColId!].push(comparisonResult[`${hiddenTableId}.${srcColId}`][i]);
|
||||
const matchingDestColIds = srcToDestColIds.get(srcColId);
|
||||
matchingDestColIds!.forEach(id => {
|
||||
newRecords[id].push(comparisonResult[`${hiddenTableId}.${srcColId}`][i]);
|
||||
});
|
||||
}
|
||||
numNewRecords++;
|
||||
} else {
|
||||
// Otherwise, a match was found between source and destination tables, so we merge their columns.
|
||||
for (const srcColId of srcColIds) {
|
||||
const matchingDestColId = selectColumnsMap.get(srcColId);
|
||||
const matchingDestColIds = srcToDestColIds.get(srcColId);
|
||||
const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][i];
|
||||
const destVal = comparisonResult[`${destTableId}.${matchingDestColId}`][i];
|
||||
updatedRecords[matchingDestColId!].push(merge(srcVal, destVal));
|
||||
matchingDestColIds!.forEach(id => {
|
||||
const destVal = comparisonResult[`${destTableId}.${id}`][i];
|
||||
updatedRecords[id].push(merge(srcVal, destVal));
|
||||
});
|
||||
}
|
||||
updatedRecordIds.push(comparisonResult[destTableId + '.id'][i] as number);
|
||||
}
|
||||
@ -515,17 +533,23 @@ export class ActiveDocImport {
|
||||
*
|
||||
* @param {string} hiddenTableId Source 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.
|
||||
* @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> {
|
||||
const joinColumns: [string, string][] =
|
||||
[...selectColumnsMap.entries()].filter(([_srcColId, destColId]) => mergeCols.includes(destColId));
|
||||
const joinColumnsMap = new Map(joinColumns);
|
||||
const mergeColIds = new Set(mergeCols);
|
||||
const destToSrcMergeColIds = new Map();
|
||||
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);
|
||||
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).
|
||||
function stripPrefixes({destCols}: TransformRule): void {
|
||||
function stripRulePrefixes({destCols}: TransformRule): void {
|
||||
for (const col of destCols) {
|
||||
const colId = col.colId;
|
||||
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;
|
||||
|
||||
/**
|
||||
|
@ -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} rightTableId Name of the right table in the comparison.
|
||||
* @param {Map<string, string>} selectColumns Map of left table column ids to their matching equivalent
|
||||
* from the right table. All of these columns will be included in the result, aliased by table id.
|
||||
* @param {Map<string, string>} joinColumns Map of left table column ids to their matching equivalent
|
||||
* from the right table. These columns are used to join `leftTableID` to `rightTableId`.
|
||||
* @param {Map<string, string[]>} selectColumns Map of left table column ids to their matching equivalent(s)
|
||||
* from the right table. A single left column can be compared against 2 or more right columns, so the
|
||||
* values of `selectColumns` are arrays. All of these columns will be included in the result, aliased by
|
||||
* 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.
|
||||
*/
|
||||
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 {
|
||||
const query: ExpandedQuery = { tableId: leftTableId, filters: {} };
|
||||
|
||||
@ -169,15 +171,17 @@ export function buildComparisonQuery(leftTableId: string, rightTableId: string,
|
||||
`${quoteIdent(rightTableId)}.id AS ${quoteIdent(rightTableId + '.id')}`
|
||||
);
|
||||
|
||||
// Select columns from both tables using the table id as a prefix for each column name.
|
||||
selectColumns.forEach((rightTableColumn, leftTableColumn) => {
|
||||
// Select columns from both tables, using the table id as a prefix for each column name.
|
||||
selectColumns.forEach((rightTableColumns, leftTableColumn) => {
|
||||
const leftColumnAlias = `${leftTableId}.${leftTableColumn}`;
|
||||
const rightColumnAlias = `${rightTableId}.${rightTableColumn}`;
|
||||
selects.push(
|
||||
`${quoteIdent(leftTableId)}.${quoteIdent(leftTableColumn)} AS ${quoteIdent(leftColumnAlias)}`,
|
||||
`${quoteIdent(rightTableId)}.${quoteIdent(rightTableColumn)} AS ${quoteIdent(rightColumnAlias)}`
|
||||
selects.push(`${quoteIdent(leftTableId)}.${quoteIdent(leftTableColumn)} AS ${quoteIdent(leftColumnAlias)}`);
|
||||
|
||||
rightTableColumns.forEach(colId => {
|
||||
const rightColumnAlias = `${rightTableId}.${colId}`;
|
||||
selects.push(`${quoteIdent(rightTableId)}.${quoteIdent(colId)} AS ${quoteIdent(rightColumnAlias)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Performance can suffer when large (right) tables have many duplicates for their join columns.
|
||||
@ -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.
|
||||
*/
|
||||
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)} ` +
|
||||
`GROUP BY ${[...joinColumns.values()].map(v => quoteIdent(v)).join(', ')}`;
|
||||
`GROUP BY ${[...joinColumns.keys()].map(v => quoteIdent(v)).join(', ')}`;
|
||||
const dedupedRightTableAlias = quoteIdent('deduped_' + rightTableId);
|
||||
|
||||
// Join the left table to the (de-duplicated) right table, and include unmatched left rows.
|
||||
const joinConditions: string[] = [];
|
||||
joinColumns.forEach((rightTableColumn, leftTableColumn) => {
|
||||
joinColumns.forEach((leftTableColumn, rightTableColumn) => {
|
||||
const leftExpression = `${quoteIdent(leftTableId)}.${quoteIdent(leftTableColumn)}`;
|
||||
const rightExpression = `${dedupedRightTableAlias}.${quoteIdent(rightTableColumn)}`;
|
||||
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.
|
||||
const whereConditions: string[] = [];
|
||||
for (const [leftTableColumn, rightTableColumn] of selectColumns.entries()) {
|
||||
if (joinColumns.has(leftTableColumn)) { continue; }
|
||||
for (const [leftTableColumnId, rightTableColumnIds] of selectColumns.entries()) {
|
||||
const leftColumnAlias = quoteIdent(`${leftTableId}.${leftTableColumnId}`);
|
||||
|
||||
const leftColumnAlias = quoteIdent(`${leftTableId}.${leftTableColumn}`);
|
||||
const rightColumnAlias = quoteIdent(`${rightTableId}.${rightTableColumn}`);
|
||||
for (const rightTableColId of rightTableColumnIds) {
|
||||
// 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.
|
||||
const rightColumnAlias = quoteIdent(`${rightTableId}.${rightTableColId}`);
|
||||
whereConditions.push(`${leftColumnAlias} IS NOT ${rightColumnAlias}`);
|
||||
}
|
||||
}
|
||||
if (whereConditions.length > 0) {
|
||||
wheres.push(`(${whereConditions.join(' OR ')})`);
|
||||
}
|
||||
|
||||
// Copy decisions to the query object, and return.
|
||||
query.joins = joins;
|
||||
|
@ -132,7 +132,7 @@ class ImportActions(object):
|
||||
transform_rule: defines columns to make (colids must be filled in!)
|
||||
|
||||
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)
|
||||
"""
|
||||
@ -151,12 +151,11 @@ class ImportActions(object):
|
||||
#take formula from transform_rule
|
||||
new_cols = []
|
||||
for c in dest_cols:
|
||||
# skip copy and blank columns (unless gen_all)
|
||||
# skip copy columns (unless gen_all)
|
||||
formula = c.formula.strip()
|
||||
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
|
||||
new_col_id = _import_transform_col_prefix + (c.colId or c.label)
|
||||
new_col_spec = {
|
||||
|
@ -57,6 +57,7 @@
|
||||
--icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||
--icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMCA3LjUsMCBDNy43NzYxNDIzNywwIDgsMC4yMjM4NTc2MjUgOCwwLjUgTDgsMi41IEM4LDIuNzc2MTQyMzcgNy43NzYxNDIzNywzIDcuNSwzIEM3LjIyMzg1NzYzLDMgNywyLjc3NjE0MjM3IDcsMi41IEw3LDAuNSBaIE0xLjQ4OTQ0NjYxLDMuMTk2NTUzMzkgQzEuMjk0MTg0NDYsMy4wMDEyOTEyNCAxLjI5NDE4NDQ2LDIuNjg0NzA4NzYgMS40ODk0NDY2MSwyLjQ4OTQ0NjYxIEMxLjY4NDcwODc2LDIuMjk0MTg0NDYgMi4wMDEyOTEyNCwyLjI5NDE4NDQ2IDIuMTk2NTUzMzksMi40ODk0NDY2MSBMMy42MTA1NTMzOSwzLjkwMzQ0NjYxIEMzLjgwNTgxNTU0LDQuMDk4NzA4NzYgMy44MDU4MTU1NCw0LjQxNTI5MTI0IDMuNjEwNTUzMzksNC42MTA1NTMzOSBDMy40MTUyOTEyNCw0LjgwNTgxNTU0IDMuMDk4NzA4NzYsNC44MDU4MTU1NCAyLjkwMzQ0NjYxLDQuNjEwNTUzMzkgTDEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBaIE0xMi44MDM0NDY2LDIuNDg5NDQ2NjEgQzEyLjk5ODcwODgsMi4yOTQxODQ0NiAxMy4zMTUyOTEyLDIuMjk0MTg0NDYgMTMuNTEwNTUzNCwyLjQ4OTQ0NjYxIEMxMy43MDU4MTU1LDIuNjg0NzA4NzYgMTMuNzA1ODE1NSwzLjAwMTI5MTI0IDEzLjUxMDU1MzQsMy4xOTY1NTMzOSBMMTIuMDk2NTUzNCw0LjYxMDU1MzM5IEMxMS45MDEyOTEyLDQuODA1ODE1NTQgMTEuNTg0NzA4OCw0LjgwNTgxNTU0IDExLjM4OTQ0NjYsNC42MTA1NTMzOSBDMTEuMTk0MTg0NSw0LjQxNTI5MTI0IDExLjE5NDE4NDUsNC4wOTg3MDg3NiAxMS4zODk0NDY2LDMuOTAzNDQ2NjEgTDEyLjgwMzQ0NjYsMi40ODk0NDY2MSBaIE0xMCwxMi4yMjMzNjY2IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDkuNzc2MTQyMzcsMTUgOS41LDE1IEw1LjUsMTUgQzUuMjIzODU3NjMsMTUgNSwxNC43NzYxNDI0IDUsMTQuNSBMNSwxMi4yMjI2NzMyIEMzLjI4NDM3MTQ2LDExLjA3Nzg0MDQgMi41NTY2OTc1Niw4Ljg5NTgwMTU4IDMuMjczODk0MTMsNi45MzUwNzcwNyBDNC4wMjUwMDkzMyw0Ljg4MTYyMzMxIDYuMTQzNDksMy42NjUyNjIzMiA4LjI5NTU2NjkyLDQuMDUxNzk5OTQgQzEwLjQ0NzQzMTksNC40MzgyOTk0OSAxMi4wMTAxMTM2LDYuMzE1NTI0NzggMTEuOTk5OTk3Myw4LjUwMTczODM2IEMxMS45OTY0OTg2LDEwLjAwNDU1NjkgMTEuMjQwNzkxOSwxMS4zOTg0NzI3IDEwLDEyLjIyMzM2NjYgWiBNOSwxMS45NDUgQzksMTEuNzY1OTAwOCA5LjA5NTc5MzU2LDExLjYwMDQ4MDUgOS4yNTExMzEyOCwxMS41MTEzMzYxIEMxMC4zMzA1NDU2LDEwLjg5MTg4NzYgMTAuOTk3MjgyNCw5Ljc0MzQzMzQ2IDExLjAwMDAwMTIsOC40OTg5MDc2OSBDMTEuMDA4MTE1Nyw2Ljc5NzAxMjQgOS43OTI2MjE5OCw1LjMzNjY5MDI0IDguMTE4Nzg0MzgsNS4wMzYwNDk4OCBDNi40NDQ5NDY3OCw0LjczNTQwOTUxIDQuNzk3MjM5NTksNS42ODE0NjgwNSA0LjIxMzAzODg4LDcuMjc4NTk4NzUgQzMuNjI4ODM4MTYsOC44NzU3Mjk0NSA0LjI3NzIzNDU1LDEwLjY2MTY2IDUuNzUwMDA1NjksMTEuNTExOTkwNiBDNS45MDQ3MDMwMiwxMS42MDEzMDc4IDYsMTEuNzY2MzY5NiA2LDExLjk0NSBMNiwxNCBMOSwxNCBMOSwxMS45NDUgWiBNMTMuNSw5IEMxMy4yMjM4NTc2LDkgMTMsOC43NzYxNDIzNyAxMyw4LjUgQzEzLDguMjIzODU3NjMgMTMuMjIzODU3Niw4IDEzLjUsOCBMMTQuNSw4IEMxNC43NzYxNDI0LDggMTUsOC4yMjM4NTc2MyAxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBMMTMuNSw5IFogTTAuNSw5IEMwLjIyMzg1NzYyNSw5IDAsOC43NzYxNDIzNyAwLDguNSBDMCw4LjIyMzg1NzYzIDAuMjIzODU3NjI1LDggMC41LDggTDEuNSw4IEMxLjc3NjE0MjM3LDggMiw4LjIyMzg1NzYzIDIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEwwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
||||
--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-LeftAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE0yLjUsMTMgQzIuMjIzODU3NjMsMTMgMiwxMi43NzYxNDI0IDIsMTIuNSBDMiwxMi4yMjM4NTc2IDIuMjIzODU3NjMsMTIgMi41LDEyIEw3LjUsMTIgQzcuNzc2MTQyMzcsMTIgOCwxMi4yMjM4NTc2IDgsMTIuNSBDOCwxMi43NzYxNDI0IDcuNzc2MTQyMzcsMTMgNy41LDEzIEwyLjUsMTMgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
|
||||
--icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg==');
|
||||
|
27
static/ui-icons/UI/ImportArrow.svg
Normal file
27
static/ui-icons/UI/ImportArrow.svg
Normal 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 |
Loading…
Reference in New Issue
Block a user