From 6416994c229fa33211d69db2bc78720cf9bebecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Fri, 4 Aug 2023 14:33:33 +0200 Subject: [PATCH] (core) Import redesign Summary: New UI design for incremental imports. Test Plan: Updated Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3945 --- app/client/components/GristDoc.ts | 6 +- app/client/components/Importer.ts | 1537 +++++++++++++++------ app/client/components/ParseOptions.ts | 2 +- app/client/components/PluginScreen.ts | 29 +- app/client/lib/FocusLayer.ts | 13 +- app/client/ui2018/IconList.ts | 10 + app/client/ui2018/cssVars.ts | 8 + app/client/ui2018/menus.ts | 14 +- app/client/ui2018/modals.ts | 28 +- app/client/ui2018/select.ts | 2 +- app/common/ActiveDocAPI.ts | 32 +- app/common/ThemePrefs-ti.ts | 6 + app/common/ThemePrefs.ts | 6 + app/common/gutil.ts | 12 +- app/common/themes/GristDark.ts | 6 + app/common/themes/GristLight.ts | 6 + static/icons/icons.css | 5 + static/locales/en.client.json | 6 +- static/ui-icons/UI/ArrowLeft.svg | 3 + static/ui-icons/UI/ArrowRight.svg | 3 + static/ui-icons/UI/ArrowRightOutlined.svg | 3 + static/ui-icons/UI/Exclamation.svg | 4 + static/ui-icons/UI/Lighting.svg | 11 + 23 files changed, 1316 insertions(+), 436 deletions(-) create mode 100644 static/ui-icons/UI/ArrowLeft.svg create mode 100644 static/ui-icons/UI/ArrowRight.svg create mode 100644 static/ui-icons/UI/ArrowRightOutlined.svg create mode 100644 static/ui-icons/UI/Exclamation.svg create mode 100644 static/ui-icons/UI/Lighting.svg diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index a683c3a8..c615bcf6 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -16,7 +16,7 @@ import * as DocConfigTab from 'app/client/components/DocConfigTab'; import {Drafts} from "app/client/components/Drafts"; import {EditorMonitor} from "app/client/components/EditorMonitor"; import * as GridView from 'app/client/components/GridView'; -import {Importer} from 'app/client/components/Importer'; +import {importFromFile, selectAndImport} from 'app/client/components/Importer'; import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage'; import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack'; import {ViewLayout} from 'app/client/components/ViewLayout'; @@ -423,11 +423,11 @@ export class GristDoc extends DisposableWithEvents { const importMenuItems = [ { label: t("Import from file"), - action: () => Importer.selectAndImport(this, importSourceElems, null, createPreview), + action: () => importFromFile(this, createPreview), }, ...importSourceElems.map(importSourceElem => ({ label: importSourceElem.importSource.label, - action: () => Importer.selectAndImport(this, importSourceElems, importSourceElem, createPreview) + action: () => selectAndImport(this, importSourceElems, importSourceElem, createPreview) })) ]; diff --git a/app/client/components/Importer.ts b/app/client/components/Importer.ts index 5e7b41f0..b33c9f38 100644 --- a/app/client/components/Importer.ts +++ b/app/client/components/Importer.ts @@ -16,144 +16,255 @@ import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocMode 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 {testId, theme, vars} from 'app/client/ui2018/cssVars'; +import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth'; +import {cssPageIcon} from 'app/client/ui/LeftPanelCommon'; +import {hoverTooltip, overflowTooltip} from 'app/client/ui/tooltips'; +import {bigBasicButton, bigPrimaryButton, textButton} from 'app/client/ui2018/buttons'; +import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; +import {testId as baseTestId, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -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 {IOptionFull, menuDivider, menuItem, multiSelect, selectMenu, selectOption} from 'app/client/ui2018/menus'; +import {cssModalTitle} from 'app/client/ui2018/modals'; import {openFormulaEditor} from 'app/client/widgets/FormulaEditor'; -import {DataSourceTransformed, DestId, ImportResult, ImportTableResult, MergeOptions, - MergeOptionsMap, MergeStrategy, NEW_TABLE, SKIP_TABLE, - TransformColumn, TransformRule, TransformRuleMap} from 'app/common/ActiveDocAPI'; +import { + DataSourceTransformed, + DestId, + ImportResult, + ImportTableResult, + MergeOptions, + MergeOptionsMap, + MergeStrategy, + NEW_TABLE, + SKIP_TABLE, + TransformColumn, + TransformRule, + TransformRuleMap +} from 'app/common/ActiveDocAPI'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; -import {byteString} from 'app/common/gutil'; +import {byteString, not} from 'app/common/gutil'; import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI'; -import {Computed, Disposable, 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 { + BindableValue, + Computed, + Disposable, + dom, + DomContents, + fromKo, + Holder, + IDisposable, + MultiHolder, + MutableObsArray, + obsArray, + Observable, + styled, + UseCBOwner +} from 'grainjs'; import debounce = require('lodash/debounce'); const t = makeT('Importer'); +// Custom testId that can be appended conditionally. +const testId = (id: string, obs?: BindableValue) => dom.cls('test-importer-' + id, obs ?? true); // We expect a function for creating the preview GridView, to avoid the need to require the // GridView module here. That brings many dependencies, making a simple test fixture difficult. type CreatePreviewFunc = (vs: ViewSectionRec) => GridView; type GridView = IDisposable & {viewPane: HTMLElement, sortedRows: SortedRowSet, listenTo: (...args: any[]) => void}; +const TABLE_MAPPING = 1; +const COLUMN_MAPPING = 2; +type ViewType = typeof TABLE_MAPPING | typeof COLUMN_MAPPING; +/** + * Information returned by the backend of the current import state, and how the table and sections look there. + * Also contains some UI state, so it is updated with the data that comes from the backend. + */ export interface SourceInfo { - // The source table id. + /** Table id that holds the imported data. */ hiddenTableId: string; + /** Uploaded file index */ uploadFileIndex: number; + /** Table name that was figured out by the backend. File name or tab in excel name */ origTableName: string; + /** + * Section that contains only imported columns. It is not shown to the user. + * Table besides the imported data have formula columns that are used to finalize import. Those formula + * columns are not part of this section. + */ sourceSection: ViewSectionRec; - // A viewsection containing transform (formula) columns pointing to the original source columns. + /** + * A viewSection containing transform (formula) columns pointing to the original source columns. + * When user selects New table, they are basically formulas pointing to the source columns. + * When user selects Existing table, new formula columns are created that look like the selected table, and this + * section contains those formula columns. + */ transformSection: Observable; - // The destination table id. + /** The destination table id, selected by the user. Can be null for skip and empty string for `New table` */ destTableId: Observable; - // True if there is at least one request in progress to create a new transform section. + /** True if there is at least one request in progress to create a new transform section. */ isLoadingSection: Observable; - // Reference to last promise for the GenImporterView action (which creates `transformSection`). + /** Reference to last promise for the GenImporterView action (which creates `transformSection`). */ lastGenImporterViewPromise: Promise|null; + /** Selected view, can be table mapping or column mapping, used only in UI. */ + selectedView: Observable; + /** List of columns that were customized (have custom formulas) */ + customizedColumns: Observable>; } +/** Changes the customization flag for the column */ +function toggleCustomized(info: SourceInfo, colId: string, on: boolean): void { + const customizedColumns = info.customizedColumns.get(); + if (!on) { + customizedColumns.delete(colId); + } else { + customizedColumns.add(colId); + } + info.customizedColumns.set(new Set(customizedColumns)); +} + + +/** + * UI state for each imported table (file). Maps table id to the info object. + */ interface MergeOptionsStateMap { [hiddenTableId: string]: MergeOptionsState|undefined; } -// UI state of merge options for a SourceInfo. +/** + * UI state of merge options for a SourceInfo. + */ interface MergeOptionsState { + /** + * Whether to update existing records or only add new ones. If false, mergeCols is empty. + */ updateExistingRecords: Observable; + /** + * List of column ids to merge on if user set `updateExistingRecords` to true. Those are columns from the + * target table. + */ mergeCols: MutableObsArray; + /** + * Merge strategy to use, not used currently. + */ mergeStrategy: Observable; + /** + * Whether mergeCols contains invalid columns (set in the code to show error message). + */ hasInvalidMergeCols: Observable; } +/** + * Imports using the given plugin importer. + */ +export async function selectAndImport( + gristDoc: GristDoc, + imports: ImportSourceElement[], + importSourceElem: ImportSourceElement, + createPreview: CreatePreviewFunc +) { + // HACK: The url plugin does not support importing from google drive, and we don't want to + // ask a user for permission to access all his files (needed to download a single file from an URL). + // So to have a nice user experience, we will switch to the built-in google drive plugin and allow + // user to chose a file manually. + // Suggestion for the future is: + // (1) ask the user for the greater permission, + // (2) detect when the permission is not granted, and open the picker-based plugin in that case. + try { + // Importer disposes itself when its dialog is closed, so we do not take ownership of it. + await Importer.create(null, gristDoc, importSourceElem, createPreview).pickAndUploadSource(null); + } catch(err1) { + // If the url was a Google Drive Url, run the google drive plugin. + if (!(err1 instanceof GDriveUrlNotSupported)) { + reportError(err1); + } else { + const gdrivePlugin = imports.find((p) => p.plugin.definition.id === 'builtIn/gdrive' && p !== importSourceElem); + if (!gdrivePlugin) { + reportError(err1); + } else { + try { + await Importer.create(null, gristDoc, gdrivePlugin, createPreview).pickAndUploadSource(null); + } catch(err2) { + reportError(err2); + } + } + } + } +} + +/** + * Imports from file. + */ +export async function importFromFile(gristDoc: GristDoc, createPreview: CreatePreviewFunc) { + // In case of using built-in file picker we want to get upload result before instantiating Importer + // because if the user dismisses the dialog without picking a file, + // there is no good way to detect this and dispose Importer. + let uploadResult: UploadResult|null = null; + // Use the built-in file picker. On electron, it uses the native file selector (without + // actually uploading anything), which is why this requires a slightly different flow. + const files: File[] = await openFilePicker({multiple: true}); + // Important to fork first before trying to import, so we end up uploading to a + // consistent doc worker. + await gristDoc.forkIfNeeded(); + const label = files.map(f => f.name).join(', '); + const size = files.reduce((acc, f) => acc + f.size, 0); + const app = gristDoc.app.topAppModel.appObs.get(); + const progress = app ? app.notifier.createProgressIndicator(label, byteString(size)) : null; + const onProgress = (percent: number) => progress && progress.setProgress(percent); + try { + onProgress(0); + uploadResult = await uploadFiles(files, {docWorkerUrl: gristDoc.docComm.docWorkerUrl, + sizeLimit: 'import'}, onProgress); + onProgress(100); + } finally { + if (progress) { + progress.dispose(); + } + } + // Importer disposes itself when its dialog is closed, so we do not take ownership of it. + await Importer.create(null, gristDoc, null, createPreview).pickAndUploadSource(uploadResult); +} + + /** * Importer manages an import files to Grist tables and shows Preview */ export class Importer extends DisposableWithEvents { - /** - * Imports using the given plugin importer, or the built-in file-picker when null is passed in. - */ - public static async selectAndImport( - gristDoc: GristDoc, - imports: ImportSourceElement[], - importSourceElem: ImportSourceElement|null, - createPreview: CreatePreviewFunc - ) { - // In case of using built-in file picker we want to get upload result before instantiating Importer - // because if the user dismisses the dialog without picking a file, - // there is no good way to detect this and dispose Importer. - let uploadResult: UploadResult|null = null; - if (!importSourceElem) { - // Use the built-in file picker. On electron, it uses the native file selector (without - // actually uploading anything), which is why this requires a slightly different flow. - const files: File[] = await openFilePicker({multiple: true}); - // Important to fork first before trying to import, so we end up uploading to a - // consistent doc worker. - await gristDoc.forkIfNeeded(); - const label = files.map(f => f.name).join(', '); - const size = files.reduce((acc, f) => acc + f.size, 0); - const app = gristDoc.app.topAppModel.appObs.get(); - const progress = app ? app.notifier.createProgressIndicator(label, byteString(size)) : null; - const onProgress = (percent: number) => progress && progress.setProgress(percent); - try { - onProgress(0); - uploadResult = await uploadFiles(files, {docWorkerUrl: gristDoc.docComm.docWorkerUrl, - sizeLimit: 'import'}, onProgress); - onProgress(100); - } finally { - if (progress) { - progress.dispose(); - } - } - } - // HACK: The url plugin does not support importing from google drive, and we don't want to - // ask a user for permission to access all his files (needed to download a single file from an URL). - // So to have a nice user experience, we will switch to the built-in google drive plugin and allow - // user to chose a file manually. - // Suggestion for the future is: - // (1) ask the user for the greater permission, - // (2) detect when the permission is not granted, and open the picker-based plugin in that case. - try { - // Importer disposes itself when its dialog is closed, so we do not take ownership of it. - await Importer.create(null, gristDoc, importSourceElem, createPreview).pickAndUploadSource(uploadResult); - } catch(err1) { - // If the url was a Google Drive Url, run the google drive plugin. - if (!(err1 instanceof GDriveUrlNotSupported)) { - reportError(err1); - } else { - const gdrivePlugin = imports.find((p) => p.plugin.definition.id === 'builtIn/gdrive' && p !== importSourceElem); - if (!gdrivePlugin) { - reportError(err1); - } else { - try { - await Importer.create(null, gristDoc, gdrivePlugin, createPreview).pickAndUploadSource(uploadResult); - } catch(err2) { - reportError(err2); - } - } - } - } - } private _docComm = this._gristDoc.docComm; private _uploadResult?: UploadResult; private _screen: PluginScreen; + private _optionsScreenHolder = Holder.create(this); + /** + * Merge information (for updating existing rows). + */ private _mergeOptions: MergeOptionsStateMap = {}; + /** + * Parsing options (for parsing the file), passed to the backend directly. + */ private _parseOptions = Observable.create(this, {}); + /** + * Info about the data that was parsed from the imported files (or tabs in excel). + */ private _sourceInfoArray = Observable.create(this, []); + /** + * Currently selected table to import (a file or a tab in excel). + */ private _sourceInfoSelected = Observable.create(this, null); + // Owner of the observables in the _sourceInfoArray + private readonly _sourceInfoHolder = Holder.create(this); + // Holder for the column mapping formula editor. private readonly _formulaEditorHolder = Holder.create(this); + /** + * Helper for the preview section (the transformSection from the backend). The naming is misleading a bit, sorry + * about that, but this transform section is shown to the user as a Grid. + * + * We need a helper to make sure section is in good state before showing it to the user. + */ private _previewViewSection: Observable = Computed.create(this, this._sourceInfoSelected, (use, info) => { if (!info) { return null; } @@ -162,10 +273,12 @@ export class Importer extends DisposableWithEvents { if (isLoading) { return null; } const viewSection = use(info.transformSection); - return viewSection && !use(viewSection._isDeleted) ? viewSection : null; + return viewSection && !viewSection.isDisposed() && !use(viewSection._isDeleted) ? viewSection : null; }); - // True if there is at least one request in progress to generate an import diff. + /** + * True if there is at least one request in progress to generate an import diff. + */ private _isLoadingDiff = Observable.create(this, false); // Promise for the most recent generateImportDiff action. private _lastGenImportDiffPromise: Promise|null = null; @@ -181,16 +294,22 @@ export class Importer extends DisposableWithEvents { */ private _hasScheduledDiffUpdate = false; - // destTables is a list of options for import destinations, and includes all tables in the - // document, plus two values: to import as a new table, and to skip a table. + /** + * destTables is a list of tables user can choose to import data into, in the format suitable for the UI to consume. + */ private _destTables = Computed.create>>(this, (use) => [ - {value: NEW_TABLE, label: 'New Table'}, - ...(use(this._sourceInfoArray).length > 1 ? [{value: SKIP_TABLE, label: 'Skip'}] : []), ...use(this._gristDoc.docModel.visibleTableIds.getObservable()).map((id) => ({value: id, label: id})), ]); - // List of transform fields, i.e. those formula fields of the transform section whose values - // will be used to populate the destination columns. + /** + * List of transform fields, i.e. those formula fields of the transform section whose values will be used to + * populate the destination columns. + * For `New table` those fields are 1-1 with columns imported from the file. + * For `Existing table` those are fields that simulate the target table columns. + * In UI we will call it `GRIST COLUMNS`, whereas source columns will be called `SOURCE COLUMNS`. + * + * This is helper that makes sure that those fields from the transformSection are in a good state to show. + */ private _transformFields: Computed = Computed.create( this, this._sourceInfoSelected, (use, info) => { const section = info && use(info.transformSection); @@ -198,8 +317,30 @@ export class Importer extends DisposableWithEvents { return use(use(section.viewFields).getObservable()); }); - // Prepare a Map, mapping of colRef of each transform column to the set of options to offer in - // the dropdown. The options are represented as a Map too, mapping formula to label. + /** + * Prepare a Map, mapping of colRef of each transform column to the set of options to offer in + * the dropdown. The options are represented as a Map too, mapping formula to label. + * + * It only matters for importing into existing table. Transform column are perceived as GRIST COLUMNS, so those + * columns that will be updated or imported into. + * + * For each of such column, this will create a map of possible options to choose in from (except SKIP). + * The result is a map (treated as just list of Records), with a formula and label to show in the UI. + * This formula will be used to update the target helper column, when user selects it. + * + * For example: + * File has those columns: `Name`, `Age`, `City`, `Country` + * Existing table has those: `First name`, `Last name`. + * + * So for `First name` (and `Last name`) we will have a map of options: + * - `$Name` -> `Name` + * - `$City` -> `City` + * - `$Country` -> `Country` + * - `$Age` -> `Age` + * (and skip added in the UI). + * + * There are some special cases for References and column ids. + */ private _transformColImportOptions: Computed>> = Computed.create( this, this._transformFields, this._sourceInfoSelected, (use, fields, info) => { if (!fields || !info) { return new Map(); } @@ -207,18 +348,42 @@ export class Importer extends DisposableWithEvents { [use(f.colRef), this._makeImportOptionsForCol(use(f.column), info)])); }); - // List of labels of destination columns that aren't mapped to a source column, i.e. transform - // columns with empty formulas. - private _unmatchedFields: Computed = Computed.create( - this, this._transformFields, (use, fields) => { - return fields?.filter(f => (use(use(f.column).formula).trim() === '')).map(f => use(f.label)); + /** + * List of labels of destination columns that aren't mapped to a source column, i.e. transform + * columns with empty formulas. + * + * In other words, this is a list of GRIST COLUMNS that are not mapped to any SOURCE COLUMNS, so + * columns that won't be imported. + */ + private _unmatchedFieldsMap: Computed> = Computed.create(this, use => { + const sources = use(this._sourceInfoArray); + const result = new Map(); + const unmatched = (info: SourceInfo) => { + // If Skip import selected, ignore. + if (use(info.destTableId) === SKIP_TABLE) { return null; } + // If New table selected, ignore. + if (use(info.destTableId) === NEW_TABLE) { return null; } + // Otherwise, return list of labels of unmatched fields. + const section = info && use(info.transformSection); + if (!section || section.isDisposed() || use(section._isDeleted)) { return null; } + const fields = use(use(section.viewFields).getObservable()); + const labels = fields?.filter(f => (use(use(f.column).formula).trim() === '')) + .map(f => use(f.label)) ?? null; + return labels?.length ? labels : null; + }; + for (const info of sources) { + result.set(info, unmatched(info)); + } + return result; }); - // null tells to use the built-in file picker. - constructor(private _gristDoc: GristDoc, private _importSourceElem: ImportSourceElement|null, + constructor(private _gristDoc: GristDoc, + // null tells to use the built-in file picker. + private _importSourceElem: ImportSourceElement|null, private _createPreview: CreatePreviewFunc) { super(); - this._screen = PluginScreen.create(this, _importSourceElem?.importSource.label || "Import from file"); + const label = _importSourceElem?.importSource.label || "Import from file"; + this._screen = PluginScreen.create(this, label); this.onDispose(() => { this._resetImportDiffState(); @@ -226,9 +391,9 @@ export class Importer extends DisposableWithEvents { } /* - * Get new import sources and update the current one. + * Uploads file to the server using the built-in file picker or a plugin instance. */ - public async pickAndUploadSource(uploadResult: UploadResult|null) { + public async pickAndUploadSource(uploadResult: UploadResult|null = null) { try { if (!this._importSourceElem) { // Use upload result if it was passed in or the built-in file picker. @@ -237,6 +402,7 @@ export class Importer extends DisposableWithEvents { uploadResult = uploadResult || await selectFiles({docWorkerUrl: this._docComm.docWorkerUrl, multiple: true, sizeLimit: 'import'}); } else { + // Need to use plugin to get the data, and manually upload it. const plugin = this._importSourceElem.plugin; const handle = this._screen.renderPlugin(plugin); const importSource = await this._importSourceElem.importSourceStub.getImportSource(handle); @@ -316,6 +482,9 @@ export class Importer extends DisposableWithEvents { this._gristDoc.viewModel.activeSectionId(transformSectionRef); } + /** + * Reads the configuration from the temporary table and creates a configuration map for each table. + */ private _getTransformedDataSource(upload: UploadResult): DataSourceTransformed { const transforms: TransformRuleMap[] = upload.files.map((file, i) => this._createTransformRuleMap(i)); return {uploadId: upload.uploadId, transforms}; @@ -387,48 +556,82 @@ export class Importer extends DisposableWithEvents { this._screen.renderSpinner(); this._resetImportDiffState(); try { + // Initialize parsing options with NUM_ROWS=0 (a whole file). const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 0}; + + // Create the temporary tables and import the files into it. const importResult: ImportResult = await this._docComm.importFiles( this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds()); + // Update the parsing options with the actual one used by the importer (it might have changed) this._parseOptions.set(importResult.options); + this._sourceInfoHolder.clear(); + const owner = MultiHolder.create(this._sourceInfoHolder); + + // Read the information from what was imported in a better representation and some metadata, we + // will allow to change by the user. this._sourceInfoArray.set(importResult.tables.map((info: ImportTableResult) => ({ hiddenTableId: info.hiddenTableId, uploadFileIndex: info.uploadFileIndex, origTableName: info.origTableName, + // This is the section with the data imported. sourceSection: this._getPrimaryViewSection(info.hiddenTableId)!, - transformSection: Observable.create(null, this._getSectionByRef(info.transformSectionRef)), - destTableId: Observable.create(null, info.destTableId ?? NEW_TABLE), - isLoadingSection: Observable.create(null, false), - lastGenImporterViewPromise: null + // This is the section created every time user changes the configuration, used for the preview. + transformSection: Observable.create(owner, this._getSectionByRef(info.transformSectionRef)), + // This is the table where the data will be imported, either a new table or an existing one. + // If a new one, it will be hidden for a while, until the user confirms the import. + destTableId: Observable.create(owner, info.destTableId ?? NEW_TABLE), + // Helper to show the spinner. + isLoadingSection: Observable.create(owner, false), + // and another one. + lastGenImporterViewPromise: null, + // Which view to show or was shown previously. + selectedView: Observable.create(owner, TABLE_MAPPING), + // List of customized + customizedColumns: Observable.create(owner, new Set()), }))); if (this._sourceInfoArray.get().length === 0) { throw new Error("No data was imported"); } - this._mergeOptions = {}; - this._getHiddenTableIds().forEach(tableId => { - this._mergeOptions[tableId] = { - updateExistingRecords: Observable.create(null, false), - mergeCols: obsArray(), - mergeStrategy: Observable.create(null, {type: 'replace-with-nonblank-source'}), - hasInvalidMergeCols: Observable.create(null, false), - }; - }); + this._prepareMergeOptions(); // Select the first sourceInfo to show in preview. this._sourceInfoSelected.set(this._sourceInfoArray.get()[0] || null); + // And finally render the main screen. this._renderMain(upload); - } catch (e) { console.warn("Import failed", e); this._screen.renderError(e.message); } } + /** + * Create a merging options. This is an extension to the configuration above (_sourceInfoArray). + * By default, we are pointing to new tables, so it is empty. This method is used to communicate + * with the user about what they want and how they want to merge the data. + * For an existing table, it will be filled by the user with columns to merge on (how to identify + * existing rows). + */ + private _prepareMergeOptions() { + this._mergeOptions = {}; + this._getHiddenTableIds().forEach(tableId => { + this._mergeOptions[tableId] = { + // By default no, as we are importing into new tables. + updateExistingRecords: Observable.create(null, false), + // Empty, user will select it for existing table. + mergeCols: obsArray(), + // Strategy for the backend (from UI we don't care about it). + mergeStrategy: Observable.create(null, {type: 'replace-with-nonblank-source'}), + // Helper to show the validation that something is wrong with the columns selected to merge. + hasInvalidMergeCols: Observable.create(null, false), + }; + }); + } + private async _maybeFinishImport(upload: UploadResult) { const isConfigValid = this._validateImportConfiguration(); if (!isConfigValid) { return; } @@ -442,6 +645,7 @@ export class Importer extends DisposableWithEvents { const importResult: ImportResult = await this._docComm.finishImportFiles( this._getTransformedDataSource(upload), this._getHiddenTableIds(), {mergeOptionMaps, parseOptions}); + // This is not hidden table anymore, it was renamed to the name of the final table. if (importResult.tables[0]?.hiddenTableId) { const tableRowModel = this._gristDoc.docModel.dataTables[importResult.tables[0].hiddenTableId].tableMetaRow; const primaryViewId = tableRowModel.primaryViewId(); @@ -495,6 +699,10 @@ export class Importer extends DisposableWithEvents { return cssModalHeader(cssModalTitle(title), rightElement); } + private _buildStaticTitle() { + return cssStaticHeader(cssModalTitle(t('Import from file'))); + } + /** * Triggers an update of the import diff in the preview table. When called in quick succession, * only the most recent call will result in an update being made to the preview table. @@ -575,219 +783,416 @@ export class Importer extends DisposableWithEvents { // 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; - const content = cssContainer( - dom.autoDispose(this._formulaEditorHolder), - {tabIndex: '-1'}, - this._buildModalTitle( - schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options', - testId('importer-options-link'), - dom.on('click', () => this._renderParseOptions(schema, upload)) - ) : null, - ), - cssPreviewWrapper( - cssTableList( - 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; - } + const header = this._buildModalTitle(); + const options = schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options', + testId('options-link'), + dom.on('click', () => this._renderParseOptions(schema, upload)) + ) : null; - info.destTableId.set(destId); - this._resetTableMergeOptions(info.hiddenTableId); - if (destId !== SKIP_TABLE) { - await this._updateTransformSection(info); - } - }); - return cssTableInfo( - dom.autoDispose(destTableId), - cssTableLine(cssToFrom('From'), - cssTableSource(getSourceDescription(info, upload), testId('importer-from'))), - cssTableLine(cssToFrom('To'), linkSelect(destTableId, this._destTables)), - cssTableInfo.cls('-selected', (use) => use(this._sourceInfoSelected) === info), - dom.on('click', async () => { - // Ignore click if source is already selected. - if (info === this._sourceInfoSelected.get()) { return; } + const selectTab = async (info: SourceInfo) => { + // 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); + await this._updateImportDiff(info); + }; - // Prevent changing selected source if current configuration is invalid. - if (!this._validateImportConfiguration()) { return; } + const tabs = cssTableList( + dom.forEach(this._sourceInfoArray, (info) => { + const owner = MultiHolder.create(null); + const destTableId = Computed.create(owner, (use) => use(info.destTableId)); + 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); + if (destId !== SKIP_TABLE) { + await this._updateTransformSection(info); + } + }); - this._cancelPendingDiffRequests(); - this._sourceInfoSelected.set(info); - await this._updateImportDiff(info); - }), - testId('importer-source'), - ); - }), - ), - dom.maybe(this._sourceInfoSelected, (info) => { - const {mergeCols, updateExistingRecords, hasInvalidMergeCols} = this._mergeOptions[info.hiddenTableId]!; + // If this is selected source. + const isSelected = Computed.create(owner, (use) => use(this._sourceInfoSelected) === info); - return cssConfigAndPreview( - dom.maybe(info.destTableId, () => cssConfigColumn( - dom.maybe(info.transformSection, section => { - const updateRecordsListener = updateExistingRecords.addListener(async () => { - await this._updateImportDiff(info); - }); + const unmatchedCount = Computed.create(owner, use => { + const map = use(this._unmatchedFieldsMap); + return map.get(info)?.length ?? 0; + }); - return [ - cssMergeOptions( - cssMergeOptionsToggle(labeledSquareCheckbox( - updateExistingRecords, - t("Update existing records"), - dom.autoDispose(updateRecordsListener), - testId('importer-update-existing-records') - )), - 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 [ - cssMergeOptionsMessage( - t("Merge rows that match these fields:"), - testId('importer-merge-fields-message') - ), - multiSelect( - mergeCols, - section.viewFields().peek().map(f => ({label: f.label(), value: f.colId()})) ?? [], - { - placeholder: t("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', - cssAccentText( - `${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(() => this._makeImportOptionsMenu(field.origCol.peek(), info), - { placement: 'right-start' }, - ), - testId('importer-column-match-destination-settings') - ), - testId('importer-column-match-destination') - ), - dom.create(owner => this._buildColMappingFormula(owner, field, info)), - testId('importer-column-match-source-destination'), - ) - )), - testId('importer-column-match-options'), - ) - ]; - }), + return cssTabItem( + dom.autoDispose(owner), + cssBorderBottom(), + cssTabItem.cls('-not-selected', not(isSelected)), + testId('source'), + testId('source-selected', isSelected), + testId('source-not-selected', not(isSelected)), + cssTabItemContent( + cssFileTypeIcon(getSourceFileExtension(info, upload), + cssFileTypeIcon.cls('-active', isSelected), + ), + cssTabItemContent.cls('-selected', isSelected), + cssTableLine(cssTableSource( + getSourceDescription(info, upload), + testId('from'), + overflowTooltip(), )), - cssPreviewColumn( - cssSectionHeader('Preview'), - dom.domComputed(use => { - const previewSection = use(this._previewViewSection); - if (use(this._isLoadingDiff) || !previewSection) { - return cssPreviewSpinner(loadingSpinner(), testId('importer-preview-spinner')); - } - - const gridView = this._createPreview(previewSection); - return cssPreviewGrid( - dom.maybe(use1 => SKIP_TABLE === use1(info.destTableId), - () => cssOverlay(testId("importer-preview-overlay"))), - dom.autoDispose(gridView), - gridView.viewPane, - testId('importer-preview'), - ); - }) - ) - ); - }), - ), - cssModalButtons( - bigPrimaryButton('Import', - dom.on('click', () => this._maybeFinishImport(upload)), - dom.boolAttr('disabled', use => { - return use(this._previewViewSection) === null || - use(this._sourceInfoArray).every(i => use(i.destTableId) === SKIP_TABLE); - }), - testId('modal-confirm'), - ), - bigBasicButton('Cancel', - dom.on('click', () => this._cancelImport()), - testId('modal-cancel'), - ), - ), + dom.on('click', () => selectTab(info)), + ), + dom.maybe(unmatchedCount, (count) => cssError( + 'Exclamation', + testId('error'), + hoverTooltip(t('{{count}} unmatched field', {count})) + )), + ); + }), ); - this._addFocusLayer(content); - this._screen.render(content, {fullscreen: true}); + const previewAndConfig = dom.maybeOwned(this._sourceInfoSelected, (owner, info) => { + const {mergeCols, updateExistingRecords, hasInvalidMergeCols} = this._mergeOptions[info.hiddenTableId]!; + + // Computed for transform section if we have destination table selected. + const configSection = Computed.create(owner, + use => use(info.destTableId) && use(info.transformSection) ? use(info.transformSection) : null); + + // Computed to show the loader while we are waiting for the preview. + const showLoader = Computed.create(owner, use => { + return use(this._isLoadingDiff) || !use(this._previewViewSection); + }); + + // The same computed as configSection, but will evaluate to null while we are waiting for the preview + const previewSection = Computed.create(owner, use => { + return use(showLoader) ? null : use(this._previewViewSection); + }); + + // Use helper for checking if destination is selected. + const isSelected = (destId: DestId) => (use: UseCBOwner) => use(info.destTableId) === destId; + + // True if user selected `Skip import` + const isSkipTable = Computed.create(owner, isSelected(SKIP_TABLE)); + + // True if user selected a valid destination table. + const isMergeTable = Computed.create(owner, use => ![NEW_TABLE, SKIP_TABLE].includes(use(info.destTableId))); + + // Changes the class if the item is selected. Creates a dom method that can be attached to element. + const selectIfDestIs = (destId: DestId) => cssDestination.cls('-selected', isSelected(destId)); + + // Helper to toggle visibility if target is selected. + const visibleIfDestIs = (destId: DestId) => dom.show(isSelected(destId)); + + // Creates a click handler that changes the destination table to the given value. + const onClickChangeDestTo = (destId: DestId) => dom.on('click', async () => { + if (info !== this._sourceInfoSelected.get() && !this._validateImportConfiguration()) { + return; + } + info.selectedView.set(TABLE_MAPPING); + info.destTableId.set(destId); + this._resetTableMergeOptions(info.hiddenTableId); + if (destId !== SKIP_TABLE) { + await this._updateTransformSection(info); + } + }); + + // Should we show the right panel with the column mapping. + const showRightPanel = Computed.create(owner, use => { + return use(isMergeTable) && use(info.selectedView) === COLUMN_MAPPING; + }); + + // Handler to switch the view, between destination and column mapping panes. + const onClickShowView = (view: ViewType) => dom.on('click', () => { + info.selectedView.set(view); + }); + + // Pattern to create a computed value that can create and dispose objects in its callback. + Computed.create(owner, use => { + // This value must be returned for this pattern to work. + const holder = MultiHolder.create(use.owner); + // Now we can safely take ownership of things we create here - the subscriber. + if (use(configSection)) { + holder.autoDispose(updateExistingRecords.addListener(async () => { + if (holder.isDisposed()) { return; } + await this._updateImportDiff(info); + })); + } + return holder; + }); + + return cssConfigAndPreview( + cssConfigPanel( + cssConfigPanel.cls('-right', showRightPanel), + cssConfigLeft( + cssTitle('Destination table', testId('target-top')), + cssDestinationWrapper(cssDestination( + cssPageIcon('Plus'), + dom('span', 'New Table'), + selectIfDestIs(NEW_TABLE), + onClickChangeDestTo(NEW_TABLE), + testId('target'), + testId('target-new-table'), + testId('target-selected', isSelected(NEW_TABLE)), + )), + dom.maybe(use => use(this._sourceInfoArray).length > 1, () => [ + cssDestinationWrapper(cssDestination( + cssPageIcon('CrossBig'), + dom('span', t('Skip Import')), + selectIfDestIs(SKIP_TABLE), + onClickChangeDestTo(SKIP_TABLE), + testId('target'), + testId('target-skip'), + testId('target-selected', isSelected(SKIP_TABLE)), + )), + ]), + dom.forEach(this._destTables, (destTable) => { + return cssDestinationWrapper( + testId('target'), + testId('target-existing-table'), + testId('target-selected', isSelected(destTable.value)), + cssDestination( + cssPageIcon('TypeTable'), + dom('span', destTable.label), + selectIfDestIs(destTable.value), + onClickChangeDestTo(destTable.value), + onClickShowView(COLUMN_MAPPING), + ), + cssDetailsIcon('ArrowRight', + onClickShowView(COLUMN_MAPPING), + visibleIfDestIs(destTable.value), + hoverTooltip(t('Column mapping')), + testId('target-column-mapping'), + ) + ); + }), + ), + cssConfigRight( + cssNavigation( + cssFlexBaseline( + cssDestinationTableSecondary( + cssNavigationIcon('ArrowLeft'), + t('Destination table'), + onClickShowView(TABLE_MAPPING), + testId('table-mapping') + ), + cssSlash(' / '), + cssColumnMappingNav(t('Column Mapping')), + ) + ), + cssMergeOptions( + dom.maybe(isMergeTable, () => cssMergeOptionsToggle(labeledSquareCheckbox( + updateExistingRecords, + t("Update existing records"), + testId('update-existing-records') + ))), + dom.maybe(configSection, (section) => { + return dom.maybeOwned(updateExistingRecords, (owner2) => { + owner2.autoDispose(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 [ + cssMergeOptionsMessage( + t("Merge rows that match these fields:"), + testId('merge-fields-message') + ), + multiSelect( + mergeCols, + section.viewFields().peek().map(f => ({label: f.label(), value: f.colId()})) ?? [], + { + placeholder: t("Select fields to match on"), + error: hasInvalidMergeCols + }, + testId('merge-fields-select') + ) + ]; + }); + }), + ), + dom.maybeOwned(configSection, (owner1, section) => { + owner1.autoDispose(updateExistingRecords.addListener(async () => { + await this._updateImportDiff(info); + })); + return dom('div', + cssColumnMatchHeader( + dom('span', t('Grist column')), + dom('div', null), + dom('span', t('Source column')), + ), + dom.forEach(fromKo(section.viewFields().getObservable()), field => { + const owner2 = MultiHolder.create(null); + const isCustomFormula = Computed.create(owner2, use => { + return use(info.customizedColumns).has(field.colId()); + }); + return cssColumnMatchRow( + testId('column-match-source-destination'), + dom.autoDispose(owner2), + dom.domComputed(field.label, () => cssDestinationFieldLabel( + dom.text(field.label), + overflowTooltip(), + testId('column-match-destination'), + )), + cssIcon180('ArrowRightOutlined'), + dom.domComputedOwned(isCustomFormula, (owner3, isCustom) => { + if (isCustom) { + return this._buildCustomFormula(owner3, field, info); + } else { + return this._buildSourceSelector(owner3, field, info); + } + }), + dom('div', + dom.maybe(isCustomFormula, () => icon('Revert', + dom.style('cursor', 'pointer'), + hoverTooltip(t('Revert')), + dom.on('click', async () => { + toggleCustomized(info, field.colId(), false); + // Try to set the default label. + const transformCol = field.column.peek(); + const possibilities = this._transformColImportOptions.get().get(transformCol.getRowId()) + ?? new Map(); + const matched = [...possibilities.entries()].find(([, v]) => v === transformCol.label.peek()); + if (matched) { + await this._setColumnFormula(transformCol, matched[0], info); + } else { + await this._gristDoc.clearColumns([field.colRef()]); + } + }), + )), + ), + ); + }), + testId('column-match-options'), + ); + }), + ) + ), + cssPreviewColumn( + dom.maybe(showLoader, () => cssPreviewSpinner(loadingSpinner(), testId('preview-spinner'))), + dom.maybe(previewSection, () => [ + cssOptions( + dom.domComputed(info.destTableId, destId => cssTableName( + destId === NEW_TABLE ? t("New Table") : + destId === SKIP_TABLE ? t("Skip Import") : + dom.domComputed(this._destTables, list => + list.find(dt => dt.value === destId)?.label ?? t("New Table") + ) + )), + options, + ) + ]), + dom.domComputed(use => { + if (use(isSkipTable)) { + return cssOverlay(t('Skip Table on Import'), testId('preview-overlay')); + } + const section = use(previewSection); + if (!section || section.isDisposed()) { return null; } + const gridView = this._createPreview(section); + return cssPreviewGrid( + dom.autoDispose(gridView), + gridView.viewPane, + testId('preview'), + ); + }) + ) + ); + }); + const buttons = cssImportButtons(cssImportButtonsLine( + bigPrimaryButton('Import', + dom.on('click', () => this._maybeFinishImport(upload)), + dom.boolAttr('disabled', use => { + return use(this._previewViewSection) === null || + use(this._sourceInfoArray).every(i => use(i.destTableId) === SKIP_TABLE); + }), + baseTestId('modal-confirm'), + ), + bigBasicButton('Cancel', + dom.on('click', () => this._cancelImport()), + baseTestId('modal-cancel'), + ), + dom.domComputed(this._unmatchedFieldsMap, fields => { + const piles: HTMLElement[] = []; + let count = 0; + for(const [info, list] of fields) { + if (!list?.length) { continue; } + count += list.length; + piles.push(cssUnmatchedFieldsList( + list.join(', '), + dom.on('click', () => selectTab(info)), + hoverTooltip(getSourceDescription(info, upload)), + )); + } + if (!count) { return null; } + return cssUnmatchedFields( + cssUnmatchedFieldsIntro( + cssUnmatchedIcon('Exclamation'), + t('{{count}} unmatched field in import', {count}), ': ', + ), + ...piles, + testId('unmatched-fields'), + ); + }), + )); + const body = cssContainer( + {tabIndex: '-1'}, + header, + cssPreviewWrapper( + cssTabsWrapper( + tabs, + ), + previewAndConfig, + ), + buttons, + ); + this._addFocusLayer(body); + this._screen.render(body, { + fullscreen: true, + fullbody: true + }); } - private _makeImportOptionsForCol(transformCol: ColumnRec, info: SourceInfo) { + private _makeImportOptionsForCol(gristCol: ColumnRec, info: SourceInfo) { const options = new Map(); // Maps formula to label. - const importedFields = info.sourceSection.viewFields.peek().peek(); + const sourceFields = info.sourceSection.viewFields.peek().peek(); // Reference columns are populated using lookup formulas, so figure out now if this is a // reference column, and if so, its destination table and the lookup column ID. - const refTable = transformCol.refTable.peek(); + const refTable = gristCol.refTable.peek(); const refTableId = refTable ? refTable.tableId.peek() : undefined; - const visibleColId = transformCol.visibleColModel.peek().colId.peek(); - const isRefDest = Boolean(info.destTableId.get() && transformCol.pureType.peek() === 'Ref'); - for (const f of importedFields) { - const importedCol = f.column.peek(); - const colId = importedCol.colId.peek(); - const colLabel = importedCol.label.peek(); + const visibleColId = gristCol.visibleColModel.peek().colId.peek(); + const isRefDest = Boolean(info.destTableId.get() && gristCol.pureType.peek() === 'Ref'); + + for (const sourceField of sourceFields) { + const sourceCol = sourceField.column.peek(); + const sourceId = sourceCol.colId.peek(); + const sourceLabel = sourceCol.label.peek(); if (isRefDest && visibleColId) { - const formula = `${refTableId}.lookupOne(${visibleColId}=$${colId}) or ($${colId} and str($${colId}))`; - options.set(formula, colLabel); + const formula = `${refTableId}.lookupOne(${visibleColId}=$${sourceId}) or ($${sourceId} and str($${sourceId}))`; + options.set(formula, sourceLabel); } else { - options.set(`$${colId}`, colLabel); + options.set(`$${sourceId}`, sourceLabel); } - if (isRefDest && ['Numeric', 'Int'].includes(importedCol.type.peek())) { - options.set(`${refTableId}.lookupOne(id=NUM($${colId})) or ($${colId} and str(NUM($${colId})))`, - `${colLabel} (as row ID)`); + if (isRefDest && ['Numeric', 'Int'].includes(sourceCol.type.peek())) { + options.set(`${refTableId}.lookupOne(id=NUM($${sourceId})) or ($${sourceId} and str(NUM($${sourceId})))`, + `${sourceLabel} (as row ID)`); } } return options; } - private _makeImportOptionsMenu(transformCol: ColumnRec, info: SourceInfo) { - const transformColRef = transformCol.id(); - const options = this._transformColImportOptions.get().get(transformCol.getRowId()); + private _makeImportOptionsMenu(transformCol: ColumnRec, others: [string, string][], info: SourceInfo) { return [ - menuItem(() => this._setColumnFormula(transformColRef, null, info), + menuItem(() => this._setColumnFormula(transformCol, null, info), 'Skip', - testId('importer-column-match-menu-item')), - menuDivider(), - ...Array.from(options || [], ([formula, label]) => - menuItem(() => this._setColumnFormula(transformColRef, formula, info), + testId('column-match-menu-item')), + others.length ? menuDivider() : null, + ...others.map(([formula, label]) => + menuItem(() => this._setColumnFormula(transformCol, formula, info), label, - testId('importer-column-match-menu-item')) - ), - testId('importer-column-match-menu'), + testId('column-match-menu-item')) + ) ]; } @@ -800,9 +1205,13 @@ export class Importer extends DisposableWithEvents { } /** - * Updates the formula on column `colRef` to `formula`. + * Updates the formula on column `colRef` to `formula`, when user wants to match it to a source column. */ - private async _setColumnFormula(transformColRef: number, formula: string|null, info: SourceInfo) { + private async _setColumnFormula(transformCol: ColumnRec, formula: string|null, info: SourceInfo) { + const transformColRef = transformCol.id(); + const customized = info.customizedColumns.get(); + customized.delete(transformCol.colId()); + info.customizedColumns.set(customized); if (formula === null) { await this._gristDoc.clearColumns([transformColRef], {keepType: true}); } else { @@ -815,7 +1224,7 @@ export class Importer extends DisposableWithEvents { /** * Opens a formula editor for `field` over `refElem`. */ - private _activateFormulaEditor(refElem: Element, field: ViewFieldRec, onSave: () => Promise) { + private _activateFormulaEditor(refElem: Element, field: ViewFieldRec, onSave: (formula: string) => Promise) { const vsi = this._gristDoc.viewModel.activeSection().viewInstance(); const editRow = vsi?.moveEditRowToCursor(); const editorHolder = openFormulaEditor({ @@ -828,9 +1237,11 @@ export class Importer extends DisposableWithEvents { setupCleanup: this._setupFormulaEditorCleanup.bind(this), onSave: async (column, formula) => { if (formula === column.formula.peek()) { return; } - + // Sorry for this hack. We need to store somewhere an info that the formula was edited + // unfortunately, we don't have a better place to store it. So we will save this by setting + // display column to the same column. This won't break anything as this is a default value. await column.updateColValues({formula}); - await onSave(); + await onSave(formula); } }); this._formulaEditorHolder.autoDispose(editorHolder); @@ -861,34 +1272,109 @@ export class Importer extends DisposableWithEvents { * in the column mapping section of Importer. On click, opens * an editor for the formula for `column`. */ - private _buildColMappingFormula(owner: MultiHolder, field: ViewFieldRec, info: SourceInfo) { - const displayFormula = Computed.create(owner, use => { - const column = use(field.column); - const formula = use(column.formula); - const importOptions = use(this._transformColImportOptions).get(column.getRowId()); - return importOptions?.get(formula) ?? formula; + private _buildSourceSelector(owner: MultiHolder, field: ViewFieldRec, info: SourceInfo) { + const anyOtherColumns = Computed.create(owner, use => { + const transformCol = field.column.peek(); + const options = use(this._transformColImportOptions)!.get(transformCol.getRowId()) ?? new Map(); + const otherFilter = ([formula]: [string, string] ) => { + // Notice how this is only reactive to the formula value, every other observable is + // just picked without being tracked. This is because we only want to recompute this + // when the formula is changed (so the target column is changed). If anything other is + // changed, we don't care here as this whole computed will be recreated by the caller. + const myFormula = use(transformCol.formula); + const anyOther = info.transformSection.get()?.viewFields.peek().all() + .filter(f => f.column.peek() !== transformCol) + .map(f => use(f.column.peek().formula)); + // If we picked this formula thats ok. + if (formula === myFormula) { return true; } + // If any other column picked this formula, then we should not show it. + if (anyOther?.includes(formula)) { return false; } + // Otherwise, show it. + return true; + }; + const possibleSources = Array.from(options).filter(otherFilter); + + return this._makeImportOptionsMenu(transformCol, possibleSources, info); }); - return cssFieldFormula(displayFormula, - {gristTheme: this._gristDoc.currentTheme, placeholder: 'Skip', maxLines: 1}, + const selectedSource = Computed.create(owner, use => { + const column = use(field.column); + const importOptions = use(this._transformColImportOptions).get(column.getRowId()); + // Now translate the formula generated (which is unique) to the source label. + const label = importOptions?.get(use(column.formula)) || null; + return label; + }); + const selectedSourceText = Computed.create(owner, use => use(selectedSource) || t('Skip')); + + const selectedOption = cssSelected( + dom.text(selectedSourceText), + testId('column-match-formula'), + cssSelected.cls('-skip', not(selectedSource)), + overflowTooltip(), + ); + const otherColsOptions = dom.domComputed(anyOtherColumns, x => x); + const formulaOption = selectOption( + () => { + this._activateFormulaEditor(selectMenuElement, field, async (newFormula) => { + toggleCustomized(info, field.colId.peek(), !!newFormula); + await this._updateImportDiff(info); + }); + }, + "Apply Formula", + "Lighting", + testId('apply-formula'), + cssGreenIcon.cls(''), + ); + const selectMenuElement = selectMenu(selectedOption, () => [ + otherColsOptions, + menuDivider(), + formulaOption, + ], testId('column-match-source')); + return selectMenuElement; + } + + /** + * 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 _buildCustomFormula(owner: MultiHolder, field: ViewFieldRec, info: SourceInfo) { + const formula = Computed.create(owner, use => { + const column = use(field.column); + return use(column.formula); + }); + const codeOptions = {gristTheme: this._gristDoc.currentTheme, placeholder: 'Skip', maxLines: 1}; + return cssFieldFormula(formula, codeOptions, dom.cls('disabled'), + dom.cls('formula_field_sidepane'), {tabIndex: '-1'}, - dom.on('focus', (_ev, elem) => - this._activateFormulaEditor(elem, field, () => this._updateImportDiff(info))), - testId('importer-column-match-formula'), + dom.on('focus', (_ev, elem) => this._activateFormulaEditor(elem, field, async (newFormula) => { + toggleCustomized(info, field.colId.peek(), !!newFormula); + await this._updateImportDiff(info); + })), + testId('column-match-formula'), ); } // The importer state showing parse options that may be changed. private _renderParseOptions(schema: ParseOptionSchema[], upload: UploadResult) { - this._screen.render([ - this._buildModalTitle(), + const anotherScreen = PluginScreen.create(this._optionsScreenHolder, 'Import from file'); + anotherScreen.showImportDialog({ + noClickAway: false, + noEscapeKey: false, + }); + anotherScreen.render([ + this._buildStaticTitle(), dom.create(buildParseOptionsForm, schema, this._parseOptions.get() as ParseOptionValues, (p: ParseOptions) => { + anotherScreen.dispose(); this._parseOptions.set(p); this._reImport(upload).catch((err) => reportError(err)); }, - () => { this._renderMain(upload); }, + () => { + anotherScreen.dispose(); + this._renderMain(upload); + }, ) ]); } @@ -946,6 +1432,11 @@ function getSourceDescription(sourceInfo: SourceInfo, upload: UploadResult) { return sourceInfo.origTableName ? `${sourceInfo.origTableName} - ${origName}` : origName; } +function getSourceFileExtension(sourceInfo: SourceInfo, upload: UploadResult) { + const origName = upload.files[sourceInfo.uploadFileIndex].origName; + return origName.includes(".") ? origName.split('.').pop() : "file"; +} + const cssContainer = styled('div', ` height: 100%; display: flex; @@ -970,7 +1461,7 @@ const cssLinkIcon = styled(icon, ` margin-right: 4px; `); -const cssModalHeader = styled('div', ` +const cssStaticHeader = styled('div', ` display: flex; align-items: center; justify-content: space-between; @@ -980,83 +1471,161 @@ const cssModalHeader = styled('div', ` } `); +const cssModalHeader = styled(cssStaticHeader, ` + padding-left: var(--css-modal-dialog-padding-horizontal, 0px); + padding-right: var(--css-modal-dialog-padding-horizontal, 0px); + padding-top: var(--css-modal-dialog-padding-vertical, 0px); +`); + const cssPreviewWrapper = styled('div', ` display: flex; flex-direction: column; flex-grow: 1; - overflow-y: auto; `); -// This partly duplicates cssSectionHeader from HomeLeftPane.ts -const cssSectionHeader = styled('div', ` - margin-bottom: 8px; - color: ${theme.lightText}; +const cssBorderBottom = styled('div', ` + border-bottom: 1px solid ${theme.importerTableInfoBorder}; + display: none; + height: 0px; + bottom: 0px; + position: absolute; + width: 100%; +`); + + +const cssFileTypeIcon = styled('div', ` + background: ${theme.importerInactiveFileBg}; + color: ${theme.importerInactiveFileFg}; + border-radius: 4px; + height: 2em; + text-align: center; + display: flex; + align-items: center; + padding: 1em; + font-size: 15px; + font-weight: 600; text-transform: uppercase; - font-weight: 500; - font-size: ${vars.xsmallFontSize}; - letter-spacing: 1px; + &-active{ + background: ${theme.importerActiveFileBg}; + color: ${theme.importerActiveFileFg}; + } +`); + +const cssTabsWrapper = styled('div', ` + border-bottom: 1px solid ${theme.importerTableInfoBorder}; + display: flex; + flex-direction: column; `); const cssTableList = styled('div', ` - max-height: 50%; - column-gap: 32px; + align-self: flex-start; + max-width: 100%; display: flex; - flex-flow: row wrap; - margin-bottom: 16px; - align-items: flex-start; - overflow-y: auto; + padding: 0px var(--css-modal-dialog-padding-horizontal, 0px); `); -const cssTableInfo = styled('div', ` - padding: 4px 8px; - margin: 4px 0px; - width: 300px; - border-radius: 3px; - border: 1px solid ${theme.importerTableInfoBorder}; - &:hover, &-selected { - background-color: ${theme.hover}; +const cssTabItemContent = styled('div', ` + border: 1px solid transparent; + padding-left: 20px; + padding-right: 20px; + display: flex; + align-items: center; + align-content: flex-end; + overflow: hidden; + border-radius: 4px 4px 0px 0px; + height: 56px; + column-gap: 8px; + &-selected { + border: 1px solid ${theme.importerTableInfoBorder}; + border-bottom-color: ${theme.importerMainContentBg}; + background-color: ${theme.importerMainContentBg}; + } +`); + +const cssTabItem = styled('div', ` + background: ${theme.importerOutsideBg}; + position: relative; + cursor: pointer; + margin-bottom: -2px; + border-bottom: 1px solid ${theme.importerMainContentBg}; + flex: 1; + &-not-selected + &-not-selected::after{ + content: ''; + position: absolute; + left: 0px; + top: 20%; + height: 60%; + border-left: 1px solid ${theme.importerTableInfoBorder}; + } + &-not-selected .${cssBorderBottom.className} { + display: block; + } + &-not-selected .${cssFileTypeIcon.className} { + display: none; + } + &-not-selected { + min-width: 0px; + } + &-not-selected:first-child .${cssTabItemContent.className} { + padding-left: 0px; } `); const cssTableLine = styled('div', ` display: flex; align-items: center; - margin: 4px 0; -`); - -const cssToFrom = styled('span', ` - flex: none; - margin-right: 8px; - color: ${theme.lightText}; - text-transform: uppercase; - font-weight: 500; - font-size: ${vars.xsmallFontSize}; - letter-spacing: 1px; - width: 40px; - text-align: right; + overflow: hidden; + flex-shrink: 1; + height: 100%; `); const cssTableSource = styled('div', ` overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + flex-shrink: 1; `); const cssConfigAndPreview = styled('div', ` display: flex; - gap: 32px; + gap: 8px; flex-grow: 1; height: 0px; + background-color: ${theme.importerMainContentBg}; + padding-right: var(--css-modal-dialog-padding-horizontal, 0px); `); -const cssConfigColumn = styled('div', ` - width: 300px; +const cssConfigLeft = styled('div', ` padding-right: 8px; + padding-top: 16px; + position: absolute; + inset: 0; display: flex; flex-direction: column; overflow-y: auto; + width: 100%; + transition: transform 0.2s ease-in-out; `); +const cssConfigRight = styled(cssConfigLeft, ` + left: 100%; + padding-left: var(--css-modal-dialog-padding-horizontal, 0px); +`); + +const cssConfigPanel = styled('div', ` + width: 360px; + height: 100%; + position: relative; + overflow-x: hidden; + &-right .${cssConfigLeft.className} { + transform: translateX(-100%); + } + &-right .${cssConfigRight.className} { + transform: translateX(-100%); + } +`); + + const cssPreviewColumn = styled('div', ` display: flex; flex-direction: column; @@ -1074,13 +1643,10 @@ const cssPreviewSpinner = styled(cssPreview, ` `); const cssOverlay = styled('div', ` - position: absolute; - top: 0px; - left: 0px; - height: 100%; - width: 100%; - z-index: 10; background: ${theme.importerSkippedTableOverlay}; + flex: 1; + display: grid; + place-items: center; `); const cssPreviewGrid = styled(cssPreview, ` @@ -1094,6 +1660,7 @@ const cssMergeOptions = styled('div', ` const cssMergeOptionsToggle = styled('div', ` margin-bottom: 8px; + margin-top: 8px; `); const cssMergeOptionsMessage = styled('div', ` @@ -1101,70 +1668,71 @@ const cssMergeOptionsMessage = styled('div', ` margin-bottom: 8px; `); -const cssColumnMatchOptions = styled('div', ` - display: flex; - flex-direction: column; - gap: 20px; +const cssColumnMatchHeader = styled('div', ` + display: grid; + grid-template-columns: 1fr 20px 1fr; + text-transform: uppercase; + color: ${theme.lightText}; + letter-spacing: 1px; + font-size: ${vars.xsmallFontSize}; + margin-bottom: 12px; `); const cssColumnMatchRow = styled('div', ` - display: flex; + display: grid; + grid-template-columns: 1fr 20px 1fr 20px; + gap: 4px; align-items: center; + --icon-color: ${theme.iconDisabled}; + & + & { + margin-top: 16px; + } `); const cssFieldFormula = styled(buildHighlightedCode, ` flex: auto; cursor: pointer; margin-top: 1px; - padding-left: 4px; + padding-left: 24px; --icon-color: ${theme.accentIcon}; `); -const cssColumnMatchIcon = styled(icon, ` - flex-shrink: 0; - width: 20px; - height: 32px; - background-color: ${theme.importerMatchIcon}; - 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; + cursor: unset; + background-color: ${theme.pageBg}; + color: ${theme.text}; + width: 100%; + height: 30px; + line-height: 16px; + font-size: ${vars.mediumFontSize}; + padding: 5px; + border: 1px solid ${theme.selectButtonBorder}; + border-radius: 3px; + user-select: none; + outline: none; `); -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; +const cssUnmatchedIcon = styled(icon, ` + height: 12px; --icon-color: ${theme.lightText}; - - &:hover, &.weasel-popup-open { - background-color: ${theme.hover}; - } + vertical-align: bottom; + margin-bottom: 2px; `); const cssUnmatchedFields = styled('div', ` - margin-bottom: 16px; + display: flex; + flex-wrap: wrap; + row-gap: 2px; + column-gap: 4px; + align-items: flex-start; +`); + +const cssUnmatchedFieldsIntro = styled('div', ` + padding: 4px 8px; `); const cssUnmatchedFieldsList = styled('div', ` @@ -1172,9 +1740,158 @@ const cssUnmatchedFieldsList = styled('div', ` text-overflow: ellipsis; overflow: hidden; padding-right: 16px; + color: ${theme.text}; + border-radius: 8px; + padding: 4px 8px; + background-color: ${theme.pagePanelsBorder}; + max-width: 160px; + cursor: pointer; +`); + +const cssImportButtons = styled('div', ` + padding-top: 40px; + padding-left: var(--css-modal-dialog-padding-horizontal, 0px); + padding-right: var(--css-modal-dialog-padding-horizontal, 0px); + padding-bottom: calc(var(--css-modal-dialog-padding-vertical, 0px) - 12px); + background-color: ${theme.importerMainContentBg}; +`); + +const cssImportButtonsLine = styled('div', ` + height: 52px; + overflow: hidden; + display: flex; + gap: 8px; + align-items: flex-start; +`); + + +const cssTitle = styled('span._cssToFrom', ` + color: ${theme.darkText}; + text-transform: uppercase; + font-weight: 600; + font-size: ${vars.smallFontSize}; + letter-spacing: 0.5px; + padding-left: var(--css-modal-dialog-padding-horizontal, 0px); + text-align: left; + margin-bottom: 16px; +`); + +const cssDestinationWrapper = styled('div', ` + margin-bottom: 1px; + /* Reuse the modal padding but move 16px to left if possible */ + margin-left: max(0px, calc(var(--css-modal-dialog-padding-horizontal, 0px) - 16px)); + display: flex; + align-items: center; +`); + +const cssDestination = styled('div', ` + --icon-color: ${theme.lightText}; + align-items: center; + border-radius: 0 3px 3px 0; + padding-left: 16px; + color: ${theme.text}; + cursor: pointer; + display: flex; + height: 32px; + line-height: 32px; + flex: 1; + &:hover { + background-color: ${theme.pageHoverBg}; + } + &-selected, &-selected:hover { + background-color: ${theme.activePageBg}; + color: ${theme.activePageFg}; + --icon-color: ${theme.activePageFg}; + } +`); + +const cssOptions = styled('div', ` + display: flex; + align-items: flex-end; + padding-bottom: 8px; + justify-content: space-between; + height: 36px; +`); + +const cssTableName = styled('span', ` + font-weight: 600; +`); + +const cssNavigation = styled('div', ` + display: flex; + align-items: center; + margin-bottom: 8px; +`); + +const cssDetailsIcon = styled(icon, ` + flex: none; + color: ${theme.controlFg}; + --icon-color: ${theme.controlFg}; + margin-left: 4px; + margin-top: -4px; + cursor: pointer; + &:hover { + --icon-color: ${theme.controlHoverFg}; + } +`); + +const cssError = styled(icon, ` + --icon-color: ${theme.iconError}; + right: 2px; + position: absolute; + z-index: 1; + top: calc(50% - 8px); +`); + +const cssNavigationIcon = styled(icon, ` + flex: none; + color: ${theme.controlFg}; + --icon-color: ${theme.controlFg}; + margin-right: 4px; + margin-top: -3px; + width: 12px; +`); + +const cssFlexBaseline = styled('div', ` + display: flex; + align-items: baseline; +`); + +const cssSelected = styled(cssTableSource, ` + &-skip { + color: ${theme.lightText}; + } +`); + +const cssIcon180 = styled(icon, ` + transform: rotate(180deg); +`); + +const cssGreenIcon = styled(`div`, ` +--icon-color: ${theme.accentIcon}; +`); + + +const cssColumnMappingNav = styled('span', ` + text-transform: uppercase; + color: ${theme.darkText}; + text-transform: uppercase; + font-weight: 600; + font-size: ${vars.smallFontSize}; + letter-spacing: 0.5px; +`); + +const cssSlash = styled('div', ` + padding: 0px 4px; + font-size: ${vars.xsmallFontSize}; color: ${theme.lightText}; `); -const cssAccentText = styled('span', ` - color: ${theme.accentText}; +const cssDestinationTableSecondary = styled(textButton, ` + text-transform: uppercase; + font-size: ${vars.smallFontSize}; + letter-spacing: 0.5px; + text-align: left; + margin-bottom: 16px; + color: ${theme.lightText}; `); diff --git a/app/client/components/ParseOptions.ts b/app/client/components/ParseOptions.ts index 54f55394..d2905759 100644 --- a/app/client/components/ParseOptions.ts +++ b/app/client/components/ParseOptions.ts @@ -68,7 +68,7 @@ export function buildParseOptionsForm( cssModalButtons( dom.domComputed((use) => items.every((item) => use(optionsMap.get(item.name)!) === values[item.name]), (unchanged) => (unchanged ? - bigBasicButton('Back to preview', dom.on('click', doCancel), testId('parseopts-back')) : + bigBasicButton('Close', dom.on('click', doCancel), testId('parseopts-back')) : bigPrimaryButton('Update preview', dom.on('click', () => doUpdate(collectParseOptions())), testId('parseopts-update')) ) diff --git a/app/client/components/PluginScreen.ts b/app/client/components/PluginScreen.ts index 1e434287..7a8ccbad 100644 --- a/app/client/components/PluginScreen.ts +++ b/app/client/components/PluginScreen.ts @@ -1,8 +1,8 @@ import { makeT } from 'app/client/lib/localization'; import { bigBasicButton } from 'app/client/ui2018/buttons'; -import { testId } from 'app/client/ui2018/cssVars'; +import { testId, theme } from 'app/client/ui2018/cssVars'; import { loadingSpinner } from 'app/client/ui2018/loaders'; -import { cssModalButtons, cssModalTitle, IModalControl, modal } from 'app/client/ui2018/modals'; +import { cssModalButtons, cssModalTitle, IModalControl, IModalOptions, modal } from 'app/client/ui2018/modals'; import { PluginInstance } from 'app/common/PluginInstance'; import { RenderTarget } from 'app/plugin/RenderOptions'; import { Disposable, dom, DomContents, Observable, styled } from 'grainjs'; @@ -15,6 +15,7 @@ const t = makeT('PluginScreen'); export interface RenderOptions { // Maximizes modal to fill the viewport. fullscreen?: boolean; + fullbody?: boolean; } /** @@ -24,6 +25,7 @@ export class PluginScreen extends Disposable { private _openModalCtl: IModalControl | null = null; private _importerContent = Observable.create(this, null); private _fullscreen = Observable.create(this, false); + private _fullbody = Observable.create(this, false); constructor(private _title: string) { super(); @@ -46,13 +48,15 @@ export class PluginScreen extends Disposable { } public render(content: DomContents, options?: RenderOptions) { + this._fullscreen.set(Boolean(options?.fullscreen)); + this._fullbody.set(Boolean(options?.fullbody)); this.showImportDialog(); this._importerContent.set(content); - this._fullscreen.set(Boolean(options?.fullscreen)); } // The importer state showing just an error. public renderError(message: string) { + this._fullbody.set(false); this.render([ this._buildModalTitle(), cssModalBody(t("Import failed: "), message, testId('importer-error')), @@ -66,6 +70,7 @@ export class PluginScreen extends Disposable { // The importer state showing just a spinner, when the user has to wait. We don't even let the // user cancel it, because the cleanup can only happen properly once the wait completes. public renderSpinner() { + this._fullbody.set(false); this.render([this._buildModalTitle(), cssSpinner(loadingSpinner())]); } @@ -74,19 +79,28 @@ export class PluginScreen extends Disposable { this._openModalCtl = null; } - public showImportDialog() { + public showImportDialog(options?: IModalOptions) { if (this._openModalCtl) { return; } - modal((ctl) => { + modal((ctl, ctlOwner) => { this._openModalCtl = ctl; + + // Make sure we are close when parent is closed. + this.onDispose(() => { + if (ctlOwner.isDisposed()) { return; } + ctl.close(); + }); + return [ cssModalOverrides.cls(''), cssModalOverrides.cls('-fullscreen', this._fullscreen), + cssModalOverrides.cls('-fullbody', this._fullbody), dom.domComputed(this._importerContent), testId('importer-dialog'), ]; }, { noClickAway: true, noEscapeKey: true, + ...options, }); } @@ -108,6 +122,11 @@ const cssModalOverrides = styled('div', ` height: 100%; margin: 32px; } + + &-fullbody { + padding: 0px; + background-color: ${theme.importerOutsideBg}; + } `); const cssModalBody = styled('div', ` diff --git a/app/client/lib/FocusLayer.ts b/app/client/lib/FocusLayer.ts index fbce5b54..1dcc553d 100644 --- a/app/client/lib/FocusLayer.ts +++ b/app/client/lib/FocusLayer.ts @@ -10,7 +10,7 @@ import * as Mousetrap from 'app/client/lib/Mousetrap'; import {arrayRemove} from 'app/common/gutil'; import {RefCountMap} from 'app/common/RefCountMap'; -import {Disposable, dom} from 'grainjs'; +import {Disposable, dom, DomMethod} from 'grainjs'; /** * The default focus is organized into layers. A layer determines when focus should move to the @@ -136,6 +136,17 @@ export class FocusLayer extends Disposable implements FocusLayerOptions { _focusLayerManager.get(null)?.grabFocus(); } + /** + * Creates a new FocusLayer and attaches it to the given element. The layer will be disposed + * automatically when the element is removed from the DOM. + */ + public static attach(options: Partial): DomMethod { + return (element: HTMLElement) => { + const layer = FocusLayer.create(null, {defaultFocusElem: element, ...options}); + dom.autoDisposeElem(element, layer); + }; + } + public defaultFocusElem: HTMLElement; public allowFocus: (elem: Element) => boolean; public _onDefaultFocus?: () => void; diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index c10672ee..84735fa6 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -37,6 +37,9 @@ export type IconName = "ChartArea" | "GristLogo" | "ThumbPreview" | "AddUser" | + "ArrowLeft" | + "ArrowRight" | + "ArrowRightOutlined" | "BarcodeQR" | "BarcodeQR2" | "Board" | @@ -56,6 +59,7 @@ export type IconName = "ChartArea" | "Dropdown" | "DropdownUp" | "Empty" | + "Exclamation" | "Expand" | "EyeHide" | "EyeShow" | @@ -80,6 +84,7 @@ export type IconName = "ChartArea" | "ImportArrow" | "Info" | "LeftAlign" | + "Lighting" | "Lock" | "Log" | "Mail" | @@ -180,6 +185,9 @@ export const IconList: IconName[] = ["ChartArea", "GristLogo", "ThumbPreview", "AddUser", + "ArrowLeft", + "ArrowRight", + "ArrowRightOutlined", "BarcodeQR", "BarcodeQR2", "Board", @@ -199,6 +207,7 @@ export const IconList: IconName[] = ["ChartArea", "Dropdown", "DropdownUp", "Empty", + "Exclamation", "Expand", "EyeHide", "EyeShow", @@ -223,6 +232,7 @@ export const IconList: IconName[] = ["ChartArea", "ImportArrow", "Info", "LeftAlign", + "Lighting", "Lock", "Log", "Mail", diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 29a20ee3..b5db7388 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -626,6 +626,14 @@ export const theme = { importerSkippedTableOverlay: new CustomProp('theme-importer-skipped-table-overlay', undefined, colors.mediumGrey), importerMatchIcon: new CustomProp('theme-importer-match-icon', undefined, colors.darkGrey), + importerOutsideBg: new CustomProp('theme-importer-outside-bg', undefined, colors.lightGrey), + importerMainContentBg: new CustomProp('theme-importer-main-content-bg', undefined, '#FFFFFF'), + + // tabs + importerActiveFileBg: new CustomProp('theme-importer-active-file-bg', undefined, colors.lightGreen), + importerActiveFileFg: new CustomProp('theme-importer-active-file-fg', undefined, colors.light), + importerInactiveFileBg: new CustomProp('theme-importer-inactive-file-bg', undefined, colors.mediumGrey), + importerInactiveFileFg: new CustomProp('theme-importer-inactive-file-fg', undefined, colors.light), /* Menu Toggles */ menuToggleFg: new CustomProp('theme-menu-toggle-fg', undefined, colors.slate), diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index 6ae329c3..88d6f0c1 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -1,4 +1,5 @@ import { Command } from 'app/client/components/commands'; +import { FocusLayer } from 'app/client/lib/FocusLayer'; import { makeT } from 'app/client/lib/localization'; import { NeedUpgradeError, reportError } from 'app/client/models/errors'; import { textButton } from 'app/client/ui2018/buttons'; @@ -203,6 +204,7 @@ export function multiSelect(selectedOptions: MutableObsArray, return cssMultiSelectMenu( { tabindex: '-1' }, // Allow menu to be focused. dom.cls(menuCssClass), + FocusLayer.attach({pauseMousetrap: true}), dom.onKeyDown({ Enter: () => ctl.close(), Escape: () => ctl.close() @@ -508,12 +510,6 @@ const cssSelectBtnLink = styled('div', ` } `); -const cssOptionIcon = styled(icon, ` - height: 16px; - width: 16px; - background-color: ${theme.menuItemIconFg}; - margin: -3px 8px 0 2px; -`); export const cssOptionRow = styled('span', ` display: flex; @@ -521,7 +517,11 @@ export const cssOptionRow = styled('span', ` width: 100%; `); -export const cssOptionRowIcon = styled(cssOptionIcon, ` +export const cssOptionRowIcon = styled(icon, ` + height: 16px; + width: 16px; + background-color: var(--icon-color, ${theme.menuItemIconFg}); + margin: -3px 8px 0 2px; margin: 0 8px 0 0; flex: none; diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index bd2f1ce3..0a78b7c8 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -524,17 +524,35 @@ export const cssModalTooltip = styled(cssMenuElem, ` } `); +export const cssModalTopPadding = styled('div', ` + padding-top: var(--css-modal-dialog-padding-vertical); +`); + +export const cssModalBottomPadding = styled('div', ` + padding-bottom: var(--css-modal-dialog-padding-vertical); +`); + +export const cssModalHorizontalPadding = styled('div', ` + padding-left: var(--css-modal-dialog-padding-horizontal); + padding-right: var(--css-modal-dialog-padding-horizontal); +`); + // For centering, we use 'margin: auto' on the flex item instead of 'justify-content: center' on // the flex container, to ensure the full item can be scrolled in case of overflow. // See https://stackoverflow.com/a/33455342/328565 -const cssModalDialog = styled('div', ` +// +// If you want to control the padding yourself, use the cssModalTopPadding and other classes above and add -full-body +// variant to the modal. +export const cssModalDialog = styled('div', ` + --css-modal-dialog-padding-horizontal: 64px; + --css-modal-dialog-padding-vertical: 40px; background-color: ${theme.modalBg}; min-width: 428px; color: ${theme.darkText}; margin: auto; border-radius: 3px; box-shadow: 0 2px 18px 0 ${theme.modalInnerShadow}, 0 0 1px 0 ${theme.modalOuterShadow}; - padding: 40px 64px; + padding: var(--css-modal-dialog-padding-vertical) var(--css-modal-dialog-padding-horizontal); outline: none; &-normal { @@ -552,9 +570,13 @@ const cssModalDialog = styled('div', ` & { width: unset; min-width: unset; - padding: 24px 16px; + --css-modal-dialog-padding-horizontal: 16px; + --css-modal-dialog-padding-vertical: 24px; } } + &-full-body { + padding: 0; + } `); export const cssModalTitle = styled('div', ` diff --git a/app/client/ui2018/select.ts b/app/client/ui2018/select.ts index f6df18bd..ea52d1bc 100644 --- a/app/client/ui2018/select.ts +++ b/app/client/ui2018/select.ts @@ -41,7 +41,7 @@ export const cssSelectBtn = styled('div', ` box-shadow: 0px 0px 2px 2px #5E9ED6; } - &.disabled { + &.disabled, &-disabled { --icon-color: ${theme.selectButtonDisabledFg}; color: ${theme.selectButtonDisabledFg}; cursor: pointer; diff --git a/app/common/ActiveDocAPI.ts b/app/common/ActiveDocAPI.ts index 82b0319c..1b67f976 100644 --- a/app/common/ActiveDocAPI.ts +++ b/app/common/ActiveDocAPI.ts @@ -45,21 +45,51 @@ export interface TransformRuleMap { // Special values for import destinations; null means "new table", "" means skip table. // Both special options exposed as consts. -export type DestId = string | null; export const NEW_TABLE = null; export const SKIP_TABLE = ""; +export type DestId = string | typeof NEW_TABLE | typeof SKIP_TABLE; +/** + * How to import data into an existing table or a new one. + */ export interface TransformRule { + /** + * The destination table for the transformed data. If null, the data is imported into a new table. + */ destTableId: DestId; + /** + * The list of columns to update (existing or new columns). + */ destCols: TransformColumn[]; + /** + * The list of columns to read from the source table (just the headers name). + */ sourceCols: string[]; } +/** + * Existing or new column to update. It is created based on the temporary table that was imported. + */ export interface TransformColumn { + /** + * Label of the column to update. For new table it is the same name as the source column. + */ label: string; + /** + * Column id to update (null for a new table). + */ colId: string|null; + /** + * Type of the column (important for new columns). + */ type: string; + /** + * Formula to apply to the target column. + */ formula: string; + /** + * Widget options when we need to create a column (copied from the source). + */ widgetOptions: string; } diff --git a/app/common/ThemePrefs-ti.ts b/app/common/ThemePrefs-ti.ts index 833a7151..40a67566 100644 --- a/app/common/ThemePrefs-ti.ts +++ b/app/common/ThemePrefs-ti.ts @@ -300,6 +300,12 @@ export const ThemeColors = t.iface([], { "importer-preview-border": "string", "importer-skipped-table-overlay": "string", "importer-match-icon": "string", + "importer-outside-bg": "string", + "importer-main-content-bg": "string", + "importer-active-file-bg": "string", + "importer-active-file-fg": "string", + "importer-inactive-file-bg": "string", + "importer-inactive-file-fg": "string", "menu-toggle-fg": "string", "menu-toggle-hover-fg": "string", "menu-toggle-active-fg": "string", diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts index cb4ac57b..5192d7eb 100644 --- a/app/common/ThemePrefs.ts +++ b/app/common/ThemePrefs.ts @@ -392,6 +392,12 @@ export interface ThemeColors { 'importer-preview-border': string; 'importer-skipped-table-overlay': string; 'importer-match-icon': string; + 'importer-outside-bg': string; + 'importer-main-content-bg': string; + 'importer-active-file-bg': string; + 'importer-active-file-fg': string; + 'importer-inactive-file-bg': string; + 'importer-inactive-file-fg': string; /* Menu Toggles */ 'menu-toggle-fg': string; diff --git a/app/common/gutil.ts b/app/common/gutil.ts index 4f22d7b5..06cbde6e 100644 --- a/app/common/gutil.ts +++ b/app/common/gutil.ts @@ -1,5 +1,6 @@ import {delay} from 'app/common/delay'; -import {BindableValue, DomElementMethod, ISubscribable, Listener, Observable, subscribeElem, UseCB} from 'grainjs'; +import {BindableValue, DomElementMethod, IKnockoutReadObservable, ISubscribable, Listener, Observable, + subscribeElem, UseCB, UseCBOwner} from 'grainjs'; import {Observable as KoObservable} from 'knockout'; import identity = require('lodash/identity'); @@ -76,7 +77,7 @@ export function clamp(value: number, min: number, max: number): number { /** * Checks if ele is contained within the given bounds. * @param {Number} value - * @param {Number} bound1 - does not have to be less than/eqal to bound2 + * @param {Number} bound1 - does not have to be less than/equal to bound2 * @param {Number} bound2 * @returns {Boolean} - True/False */ @@ -713,7 +714,7 @@ export function cloneFunc(fn: Function): Function { // tslint:disable-line:b /** * Generates a random id using a sequence of uppercase alphanumeric characters - * preceeded by an optional prefix. + * preceded by an optional prefix. */ export function genRandomId(len: number, optPrefix?: string): string { const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -926,6 +927,11 @@ export const unwrap: UseCB = (obs: ISubscribable) => { return (obs as ko.Observable).peek(); }; +/** + * Use helper for simple boolean negation. + */ +export const not = (obs: Observable|IKnockoutReadObservable) => (use: UseCBOwner) => !use(obs); + /** * Get a set of up to `count` distinct values of `values`. */ diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts index 759459f8..1f023095 100644 --- a/app/common/themes/GristDark.ts +++ b/app/common/themes/GristDark.ts @@ -371,6 +371,12 @@ export const GristDark: ThemeColors = { 'importer-preview-border': '#69697D', 'importer-skipped-table-overlay': 'rgba(111,111,117,0.6)', 'importer-match-icon': '#69697D', + 'importer-outside-bg': '#32323F', + 'importer-main-content-bg': '#262633', + 'importer-active-file-bg': '#16B378', + 'importer-active-file-fg': '#FFFFFF', + 'importer-inactive-file-bg': '#808080', + 'importer-inactive-file-fg': '#FFFFFF', /* Menu Toggles */ 'menu-toggle-fg': '#A4A4A4', diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts index 36d12d5d..ea57f7d0 100644 --- a/app/common/themes/GristLight.ts +++ b/app/common/themes/GristLight.ts @@ -371,6 +371,12 @@ export const GristLight: ThemeColors = { 'importer-preview-border': '#D9D9D9', 'importer-skipped-table-overlay': 'rgba(217,217,217,0.6)', 'importer-match-icon': '#D9D9D9', + 'importer-outside-bg': '#F7F7F7', + 'importer-main-content-bg': '#FFFFFF', + 'importer-active-file-bg': '#16B378', + 'importer-active-file-fg': '#FFFFFF', + 'importer-inactive-file-bg': 'rgba(217,217,217,0.6)', + 'importer-inactive-file-fg': '#FFFFFF', /* Menu Toggles */ 'menu-toggle-fg': '#929299', diff --git a/static/icons/icons.css b/static/icons/icons.css index 0c9d2f81..85757bb3 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -38,6 +38,9 @@ --icon-GristLogo: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDEiIGhlaWdodD0iMzgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI4LjEzNCAxLjE2MikiIGZpbGw9IiMyQ0IwQUYiLz48cGF0aCBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDI1LjU3IDEuMTYyKSIgZmlsbD0iIzJDQjBBRiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTIuMDE1IDEzLjc0KSIgZmlsbD0iI0Y5QUU0MSIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTIuMDE1IDI2LjMxOSkiIGZpbGw9IiNGOUFFNDEiLz48cGF0aCBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDI1LjU3IDEzLjc0KSIgZmlsbD0iI0QyRDJEMiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjguMTM0IDEzLjc0KSIgZmlsbD0iI0QyRDJEMiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMjUuNTcgMjYuMzE5KSIgZmlsbD0iI0QyRDJEMiIvPjxwYXRoIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjguMTM0IDI2LjMxOSkiIGZpbGw9IiNEMkQyRDIiLz48L2c+PC9zdmc+'); --icon-ThumbPreview: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDYiIGhlaWdodD0iNDYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxkZWZzPjxwYXRoIGQ9Ik00LjA1NzYxNzE5LDYuMzY4MTY0MDYgTDQuMDU3NjE3MTksNS4xNjIxMDkzOCBMNy4xNzI4NTE1Niw1LjE2MjEwOTM4IEw3LjE3Mjg1MTU2LDguMDEzNjcxODggQzYuODcwMTE1NjcsOC4zMDY2NDIwOSA2LjQzMTQ4MDc0LDguNTY0NjE0NzcgNS44NTY5MzM1OSw4Ljc4NzU5NzY2IEM1LjI4MjM4NjQ1LDkuMDEwNTgwNTQgNC43MDA1MjM3OCw5LjEyMjA3MDMxIDQuMTExMzI4MTIsOS4xMjIwNzAzMSBDMy4zNjI2MjY0Niw5LjEyMjA3MDMxIDIuNzA5OTYzNzIsOC45NjUwMDgwOCAyLjE1MzMyMDMxLDguNjUwODc4OTEgQzEuNTk2Njc2OSw4LjMzNjc0OTczIDEuMTc4Mzg2ODIsNy44ODc1MzU0NyAwLjg5ODQzNzUsNy4zMDMyMjI2NiBDMC42MTg0ODgxODQsNi43MTg5MDk4NCAwLjQ3ODUxNTYyNSw2LjA4MzMzNjc3IDAuNDc4NTE1NjI1LDUuMzk2NDg0MzggQzAuNDc4NTE1NjI1LDQuNjUxMDM3OTQgMC42MzQ3NjQwNjIsMy45ODg2MDk2NyAwLjk0NzI2NTYyNSwzLjQwOTE3OTY5IEMxLjI1OTc2NzE5LDIuODI5NzQ5NzEgMS43MTcxMTkzOCwyLjM4NTQxODIxIDIuMzE5MzM1OTQsMi4wNzYxNzE4OCBDMi43NzgzMjI2MSwxLjgzODU0MDQ4IDMuMzQ5NjA1OTYsMS43MTk3MjY1NiA0LjAzMzIwMzEyLDEuNzE5NzI2NTYgQzQuOTIxODc5NDQsMS43MTk3MjY1NiA1LjYxNjA0NTY4LDEuOTA2MDg1MzggNi4xMTU3MjI2NiwyLjI3ODgwODU5IEM2LjYxNTM5OTYzLDIuNjUxNTMxODEgNi45MzY4NDgyNCwzLjE2NjY2MzM4IDcuMDgwMDc4MTIsMy44MjQyMTg3NSBMNS42NDQ1MzEyNSw0LjA5Mjc3MzQ0IEM1LjU0MzYxOTI5LDMuNzQxMjA5MTggNS4zNTQwMDUzLDMuNDYzNzA1NDQgNS4wNzU2ODM1OSwzLjI2MDI1MzkxIEM0Ljc5NzM2MTg5LDMuMDU2ODAyMzcgNC40NDk4NzE4NywyLjk1NTA3ODEyIDQuMDMzMjAzMTIsMi45NTUwNzgxMiBDMy40MDE2ODk1NSwyLjk1NTA3ODEyIDIuODk5NTc4NjksMy4xNTUyNzE0NCAyLjUyNjg1NTQ3LDMuNTU1NjY0MDYgQzIuMTU0MTMyMjUsMy45NTYwNTY2OSAxLjk2Nzc3MzQ0LDQuNTUwMTI2MjcgMS45Njc3NzM0NCw1LjMzNzg5MDYyIEMxLjk2Nzc3MzQ0LDYuMTg3NTA0MjUgMi4xNTY1NzM2Myw2LjgyNDcwNDkxIDIuNTM0MTc5NjksNy4yNDk1MTE3MiBDMi45MTE3ODU3NCw3LjY3NDMxODUzIDMuNDA2NTcyNDYsNy44ODY3MTg3NSA0LjAxODU1NDY5LDcuODg2NzE4NzUgQzQuMzIxMjkwNTgsNy44ODY3MTg3NSA0LjYyNDgzNTcyLDcuODI3MzExNzkgNC45MjkxOTkyMiw3LjcwODQ5NjA5IEM1LjIzMzU2MjcyLDcuNTg5NjgwNCA1LjQ5NDc5MDU4LDcuNDQ1NjM4ODcgNS43MTI4OTA2Miw3LjI3NjM2NzE5IEw1LjcxMjg5MDYyLDYuMzY4MTY0MDYgTDQuMDU3NjE3MTksNi4zNjgxNjQwNiBaIE05LjIxMDc0MjE4LDkgTDkuMjEwNzQyMTgsMS44NDE3OTY4OCBMMTIuMjUyNzM0NCwxLjg0MTc5Njg4IEMxMy4wMTc3MTIxLDEuODQxNzk2ODggMTMuNTczNTMzNCwxLjkwNjA4NjYgMTMuOTIwMjE0OCwyLjAzNDY2Nzk3IEMxNC4yNjY4OTYzLDIuMTYzMjQ5MzQgMTQuNTQ0NCwyLjM5MTkyNTQ0IDE0Ljc1MjczNDQsMi43MjA3MDMxMiBDMTQuOTYxMDY4NywzLjA0OTQ4MDgxIDE1LjA2NTIzNDQsMy40MjU0NTM2MSAxNS4wNjUyMzQ0LDMuODQ4NjMyODEgQzE1LjA2NTIzNDQsNC4zODU3NDQ4NyAxNC45MDczNTgzLDQuODI5MjYyNTcgMTQuNTkxNjAxNiw1LjE3OTE5OTIyIEMxNC4yNzU4NDQ4LDUuNTI5MTM1ODYgMTMuODAzODQ0Myw1Ljc0OTY3NDAyIDEzLjE3NTU4NTksNS44NDA4MjAzMSBDMTMuNDg4MDg3NSw2LjAyMzExMjg5IDEzLjc0NjA2MDIsNi4yMjMzMDYyIDEzLjk0OTUxMTcsNi40NDE0MDYyNSBDMTQuMTUyOTYzMiw2LjY1OTUwNjMgMTQuNDI3MjExOCw3LjA0Njg3MjIyIDE0Ljc3MjI2NTYsNy42MDM1MTU2MiBMMTUuNjQ2Mjg5MSw5IEwxMy45MTc3NzM0LDkgTDEyLjg3Mjg1MTYsNy40NDIzODI4MSBDMTIuNTAxNzU1OSw2Ljg4NTczOTQgMTIuMjQ3ODUyMiw2LjUzNDk5NDIxIDEyLjExMTEzMjgsNi4zOTAxMzY3MiBDMTEuOTc0NDEzNCw2LjI0NTI3OTIyIDExLjgyOTU1OCw2LjE0NTk5NjM2IDExLjY3NjU2MjUsNi4wOTIyODUxNiBDMTEuNTIzNTY2OSw2LjAzODU3Mzk1IDExLjI4MTA1NjMsNi4wMTE3MTg3NSAxMC45NDkwMjM0LDYuMDExNzE4NzUgTDEwLjY1NjA1NDcsNi4wMTE3MTg3NSBMMTAuNjU2MDU0Nyw5IEw5LjIxMDc0MjE4LDkgWiBNMTAuNjU2MDU0Nyw0Ljg2OTE0MDYyIEwxMS43MjUzOTA2LDQuODY5MTQwNjIgQzEyLjQxODc1MzUsNC44NjkxNDA2MiAxMi44NTE2OTE4LDQuODM5ODQ0MDQgMTMuMDI0MjE4Nyw0Ljc4MTI1IEMxMy4xOTY3NDU2LDQuNzIyNjU1OTYgMTMuMzMxODM1NCw0LjYyMTc0NTUxIDEzLjQyOTQ5MjIsNC40Nzg1MTU2MiBDMTMuNTI3MTQ4OSw0LjMzNTI4NTc0IDEzLjU3NTk3NjYsNC4xNTYyNTEwNyAxMy41NzU5NzY2LDMuOTQxNDA2MjUgQzEzLjU3NTk3NjYsMy43MDA1MTk2MyAxMy41MTE2ODY4LDMuNTA2MDIyODggMTMuMzgzMTA1NSwzLjM1NzkxMDE2IEMxMy4yNTQ1MjQxLDMuMjA5Nzk3NDQgMTMuMDczMDQ4LDMuMTE2MjExMTMgMTIuODM4NjcxOSwzLjA3NzE0ODQ0IEMxMi43MjE0ODM4LDMuMDYwODcyMzEgMTIuMzY5OTI0OCwzLjA1MjczNDM4IDExLjc4Mzk4NDQsMy4wNTI3MzQzOCBMMTAuNjU2MDU0NywzLjA1MjczNDM4IEwxMC42NTYwNTQ3LDQuODY5MTQwNjIgWiBNMTcuMDgzNTkzNyw5IEwxNy4wODM1OTM3LDEuODQxNzk2ODggTDE4LjUyODkwNjIsMS44NDE3OTY4OCBMMTguNTI4OTA2Miw5IEwxNy4wODM1OTM3LDkgWiBNMjAuMjM5NjQ4NCw2LjY3MDg5ODQ0IEwyMS42NDU4OTg0LDYuNTM0MTc5NjkgQzIxLjczMDUzNDIsNy4wMDYxODcyNiAyMS45MDIyNDQ4LDcuMzUyODYzNDggMjIuMTYxMDM1MSw3LjU3NDIxODc1IEMyMi40MTk4MjU1LDcuNzk1NTc0MDIgMjIuNzY4OTQzMSw3LjkwNjI1IDIzLjIwODM5ODQsNy45MDYyNSBDMjMuNjczODk1NSw3LjkwNjI1IDI0LjAyNDY0MDcsNy44MDc3ODA5MyAyNC4yNjA2NDQ1LDcuNjEwODM5ODQgQzI0LjQ5NjY0ODMsNy40MTM4OTg3NSAyNC42MTQ2NDg0LDcuMTgzNTk1MDcgMjQuNjE0NjQ4NCw2LjkxOTkyMTg4IEMyNC42MTQ2NDg0LDYuNzUwNjUwMiAyNC41NjUwMDcsNi42MDY2MDg2NyAyNC40NjU3MjI2LDYuNDg3NzkyOTcgQzI0LjM2NjQzODMsNi4zNjg5NzcyNyAyNC4xOTMxMDAyLDYuMjY1NjI1NDQgMjMuOTQ1NzAzMSw2LjE3NzczNDM4IEMyMy43NzY0MzE0LDYuMTE5MTQwMzMgMjMuMzkwNjkzMSw2LjAxNDk3NDcxIDIyLjc4ODQ3NjUsNS44NjUyMzQzOCBDMjIuMDEzNzMzMSw1LjY3MzE3NjEyIDIxLjQ3MDExODcsNS40MzcxNzU4OCAyMS4xNTc2MTcyLDUuMTU3MjI2NTYgQzIwLjcxODE2MTgsNC43NjMzNDQzOCAyMC40OTg0Mzc1LDQuMjgzMjA1OTYgMjAuNDk4NDM3NSwzLjcxNjc5Njg4IEMyMC40OTg0Mzc1LDMuMzUyMjExNzIgMjAuNjAxNzg5MywzLjAxMTIzMjA2IDIwLjgwODQ5NjEsMi42OTM4NDc2NiBDMjEuMDE1MjAyOCwyLjM3NjQ2MzI2IDIxLjMxMzA1MTQsMi4xMzQ3NjY0NiAyMS43MDIwNTA3LDEuOTY4NzUgQzIyLjA5MTA1MDEsMS44MDI3MzM1NCAyMi41NjA2MDkyLDEuNzE5NzI2NTYgMjMuMTEwNzQyMiwxLjcxOTcyNjU2IEMyNC4wMDkxODQxLDEuNzE5NzI2NTYgMjQuNjg1NDQ2OSwxLjkxNjY2NDcgMjUuMTM5NTUwNywyLjMxMDU0Njg4IEMyNS41OTM2NTQ2LDIuNzA0NDI5MDUgMjUuODMyMDk2MiwzLjIzMDEzOTk0IDI1Ljg1NDg4MjgsMy44ODc2OTUzMSBMMjQuNDA5NTcwMywzLjk1MTE3MTg4IEMyNC4zNDc3MjEsMy41ODMzMzE0OSAyNC4yMTUwNzI2LDMuMzE4ODQ4NDYgMjQuMDExNjIxMSwzLjE1NzcxNDg0IEMyMy44MDgxNjk1LDIuOTk2NTgxMjMgMjMuNTAyOTk2OCwyLjkxNjAxNTYyIDIzLjA5NjA5MzcsMi45MTYwMTU2MiBDMjIuNjc2MTY5NywyLjkxNjAxNTYyIDIyLjM0NzM5NywzLjAwMjI3Nzc4IDIyLjEwOTc2NTYsMy4xNzQ4MDQ2OSBDMjEuOTU2NzcsMy4yODU0ODIzMiAyMS44ODAyNzM0LDMuNDMzNTkyODIgMjEuODgwMjczNCwzLjYxOTE0MDYyIEMyMS44ODAyNzM0LDMuNzg4NDEyMyAyMS45NTE4ODczLDMuOTMzMjY3NjMgMjIuMDk1MTE3Miw0LjA1MzcxMDk0IEMyMi4yNzc0MDk3LDQuMjA2NzA2NDkgMjIuNzIwMTEzNiw0LjM2NjIxMDExIDIzLjQyMzI0MjIsNC41MzIyMjY1NiBDMjQuMTI2MzcwNyw0LjY5ODI0MzAyIDI0LjY0NjM4NSw0Ljg2OTk1MzU0IDI0Ljk4MzMwMDcsNS4wNDczNjMyOCBDMjUuMzIwMjE2NSw1LjIyNDc3MzAyIDI1LjU4Mzg4NTcsNS40NjcyODM2MiAyNS43NzQzMTY0LDUuNzc0OTAyMzQgQzI1Ljk2NDc0Nyw2LjA4MjUyMTA3IDI2LjA1OTk2MDksNi40NjI1NjI4NCAyNi4wNTk5NjA5LDYuOTE1MDM5MDYgQzI2LjA1OTk2MDksNy4zMjUxOTczNiAyNS45NDYwMjk3LDcuNzA5MzA4MTEgMjUuNzE4MTY0LDguMDY3MzgyODEgQzI1LjQ5MDI5ODMsOC40MjU0NTc1MiAyNS4xNjgwMzU5LDguNjkxNTY4MTQgMjQuNzUxMzY3Miw4Ljg2NTcyMjY2IEMyNC4zMzQ2OTg0LDkuMDM5ODc3MTcgMjMuODE1NDk3OSw5LjEyNjk1MzEyIDIzLjE5Mzc1LDkuMTI2OTUzMTIgQzIyLjI4ODc5NzUsOS4xMjY5NTMxMiAyMS41OTM4MTc1LDguOTE3ODA4MDggMjEuMTA4Nzg5LDguNDk5NTExNzIgQzIwLjYyMzc2MDYsOC4wODEyMTUzNiAyMC4zMzQwNDk5LDcuNDcxNjgzNjkgMjAuMjM5NjQ4NCw2LjY3MDg5ODQ0IFogTTI5LjU4NzEwOTMsOSBMMjkuNTg3MTA5MywzLjA1MjczNDM4IEwyNy40NjMwODU5LDMuMDUyNzM0MzggTDI3LjQ2MzA4NTksMS44NDE3OTY4OCBMMzMuMTUxNTYyNSwxLjg0MTc5Njg4IEwzMy4xNTE1NjI1LDMuMDUyNzM0MzggTDMxLjAzMjQyMTgsMy4wNTI3MzQzOCBMMzEuMDMyNDIxOCw5IEwyOS41ODcxMDkzLDkgWiIgaWQ9ImEiLz48L2RlZnM+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBvcGFjaXR5PSIuOSI+PHBhdGggZD0iTTAgMEg0OFY0OEgweiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgLTEpIi8+PHBhdGggZmlsbD0iIzBFNTEyQiIgZD0iTTQzIDI2TDMgMjYgMCAyMiA0IDE4IDQyIDE4IDQ2IDIyeiIvPjxwYXRoIGQ9Ik00MCw0NiBMNiw0NiBDNC44OTUsNDYgNCw0NS4xMDUgNCw0NCBMNCwyIEM0LDAuODk1IDQuODk1LDAgNiwwIEwzMCwwIEw0MiwxMiBMNDIsNDQgQzQyLDQ1LjEwNSA0MS4xMDUsNDYgNDAsNDYgWiIgZmlsbD0iI0U2RTZFNiIvPjxwYXRoIGQ9Ik0zMCwwIEwzMCwxMCBDMzAsMTEuMTA1IDMwLjg5NSwxMiAzMiwxMiBMNDIsMTIgTDMwLDAgWiIgZmlsbD0iI0IzQjNCMyIvPjxwYXRoIGQ9Ik00NCw0MCBMMiw0MCBDMC44OTUsNDAgMCwzOS4xMDUgMCwzOCBMMCwyMiBMNDYsMjIgTDQ2LDM4IEM0NiwzOS4xMDUgNDUuMTA1LDQwIDQ0LDQwIFoiIGZpbGw9IiMxNkIzNzgiLz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2IDI1KSI+PHVzZSBmaWxsPSIjMDAwIiB4bGluazpocmVmPSIjYSIvPjx1c2UgZmlsbD0iI0ZGRiIgeGxpbms6aHJlZj0iI2EiLz48L2c+PC9nPjwvc3ZnPg=='); --icon-AddUser: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTYiIHdpZHRoPSIxNiI+PGcgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgY2xhc3M9Im5jLWljb24td3JhcHBlciIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMjEyMTIxIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxjaXJjbGUgY3g9IjciIGN5PSI0IiByPSIzLjUiIGRhdGEtY2FwPSJidXR0Ii8+PHBhdGggZD0iTTkuODk0IDEwQTUuOTggNS45OCAwIDAgMCA3LjUgOS41aC0xYTYgNiAwIDAgMC02IDZoOU0xMi41IDkuNXY2TTkuNSAxMi41aDYiIGRhdGEtY2FwPSJidXR0Ii8+PC9nPjwvc3ZnPg=='); + --icon-ArrowLeft: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA4Ljc4NDkzNDYsMTQuNjcwNzUzIDIuNzg0NjI5Niw4LjYyMTMyMjUgYyAtMC4zNjgyMDg0LC0wLjM3MTIzIC0wLjM2ODIwODQsLTAuOTczMSAwLC0xLjM0NDMyIGwgNi4wMDAzMDUsLTYuMDQ5NDI0IGMgMC4zNjgyMSwtMC4zNzEyMjI3NiAwLjk2NTE5LC0wLjM3MTIyMjc2IDEuMzMzNDAwNCwwIDAuMzY4MjEsMC4zNzEyMjMgMC4zNjgyMSwwLjk3MzA5NCAwLDEuMzQ0MzE0IGwgLTQuMzkwNzUwNCw0LjQyNjY5IGggNy43ODA4OTA0IHYgMS45MDExNSBIIDUuNzI3NTg0NiBsIDQuMzkwNzUwNCw0LjQyNjcyMDUgYyAwLjM2ODIxLDAuMzcxMiAwLjM2ODIxLDAuOTczMSAwLDEuMzQ0MyAtMC4zNjgyMTA0LDAuMzcxMiAtMC45NjUxOTA0LDAuMzcxMiAtMS4zMzM0MDA0LDAgeiIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4='); + --icon-ArrowRight: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA3LjIzMjAxMzcsMTQuNjcwNzUzIDEzLjIzMjMxOSw4LjYyMTMyMjUgYyAwLjM2ODIwOCwtMC4zNzEyMyAwLjM2ODIwOCwtMC45NzMxIDAsLTEuMzQ0MzIgTCA3LjIzMjAxMzcsMS4yMjc1Nzg1IGMgLTAuMzY4MjEsLTAuMzcxMjIyNzYgLTAuOTY1MTksLTAuMzcxMjIyNzYgLTEuMzMzNDAwNCwwIC0wLjM2ODIxLDAuMzcxMjIzIC0wLjM2ODIxLDAuOTczMDk0IDAsMS4zNDQzMTQgbCA0LjM5MDc1MDcsNC40MjY2OSBIIDIuNTA4NDczMyB2IDEuOTAxMTUgSCAxMC4yODkzNjQgTCA1Ljg5ODYxMzMsMTMuMzI2NDUzIGMgLTAuMzY4MjEsMC4zNzEyIC0wLjM2ODIxLDAuOTczMSAwLDEuMzQ0MyAwLjM2ODIxMDQsMC4zNzEyIDAuOTY1MTkwNCwwLjM3MTIgMS4zMzM0MDA0LDAgeiIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4='); + --icon-ArrowRightOutlined: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSAxLjUwMDAxTDE1LjUgOC4wMDAwMUw3LjUgMTQuNVYxMC41SDNDMi4zMzY5NiAxMC41IDEuNzAxMDcgMTAuMjM2NiAxLjIzMjIzIDkuNzY3NzdDMC43NjMzOTIgOS4yOTg5MyAwLjUgOC42NjMwNSAwLjUgOC4wMDAwMUMwLjUgNy4zMzY5NyAwLjc2MzM5MiA2LjcwMTA4IDEuMjMyMjMgNi4yMzIyNEMxLjcwMTA3IDUuNzYzNCAyLjMzNjk2IDUuNTAwMDEgMyA1LjUwMDAxSDcuNVYxLjUwMDAxWiIgc3Ryb2tlPSIjRDlEOUQ5IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg=='); --icon-BarcodeQR: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNS41SC41VjYuNUg2LjVWLjV6TTYuNSA5LjVILjVWMTUuNUg2LjVWOS41ek0xNS41LjVIOS41VjYuNUgxNS41Vi41ek0xNS41IDE1LjVIOS41TTExLjUgOS41SDE1LjVWMTIuNSIgc3Ryb2tlPSIjMjEyMTIxIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg=='); --icon-BarcodeQR2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYgN0gxQy40IDcgMCA2LjYgMCA2VjFDMCAuNC40IDAgMSAwSDZDNi42IDAgNyAuNCA3IDFWNkM3IDYuNiA2LjYgNyA2IDd6TTIgNUg1VjJIMlY1ek02IDE2SDFDLjQgMTYgMCAxNS42IDAgMTVWMTBDMCA5LjQuNCA5IDEgOUg2QzYuNiA5IDcgOS40IDcgMTBWMTVDNyAxNS42IDYuNiAxNiA2IDE2ek0yIDE0SDVWMTFIMlYxNHpNMTUgN0gxMEM5LjQgNyA5IDYuNiA5IDZWMUM5IC40IDkuNCAwIDEwIDBIMTVDMTUuNiAwIDE2IC40IDE2IDFWNkMxNiA2LjYgMTUuNiA3IDE1IDd6TTExIDVIMTRWMkgxMVY1eiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik00IDNIM1Y0SDRWM3pNNCAxMkgzVjEzSDRWMTJ6TTE2IDE0SDlWMTZIMTZWMTR6TTE2IDEzSDE0VjExSDExVjlIMTVDMTUuNiA5IDE2IDkuNCAxNiAxMFYxM3oiIGZpbGw9IiMxNkIzNzgiLz48cGF0aCBkPSJNMTMgMTJIMTJWMTNIMTNWMTJ6TTEzIDNIMTJWNEgxM1YzeiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg=='); --icon-Board: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGcgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiPjxyZWN0IHg9Ii41IiB5PSIuNSIgd2lkdGg9IjE1IiBoZWlnaHQ9IjE1IiByeD0iMSIvPjxwYXRoIGQ9Ik0zLjUgMy41aDN2M2gtM3pNMy41IDkuNWgzdjNoLTN6TTkuNSAzLjVoM3Y5aC0zeiIvPjwvZz48L3N2Zz4='); @@ -57,6 +60,7 @@ --icon-Dropdown: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-DropdownUp: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIgdHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgMCAxNykiLz48L3N2Zz4='); --icon-Empty: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDUuMjkyIDUuMjkyIj48cGF0aCBkPSJtIDAuMDMyNjIyNjMsMjkxLjcyMjI1IDUuMjMwMDMyMjcsNS4yNzY5IiBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iLjUyOSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMjkxLjcwOCkiLz48L3N2Zz4='); + --icon-Exclamation: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuNSAxSDYuNUw3IDEwSDlMOS41IDF6TTggMTVDOC44Mjg0MyAxNSA5LjUgMTQuMzI4NCA5LjUgMTMuNSA5LjUgMTIuNjcxNiA4LjgyODQzIDEyIDggMTIgNy4xNzE1NyAxMiA2LjUgMTIuNjcxNiA2LjUgMTMuNSA2LjUgMTQuMzI4NCA3LjE3MTU3IDE1IDggMTV6IiBmaWxsPSIjMDAwIi8+PC9zdmc+'); --icon-Expand: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIgdHJhbnNmb3JtPSJyb3RhdGUoLTkwIDggOC41KSIvPjwvc3ZnPg=='); --icon-EyeHide: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzY3ODUwMDUsOC4zODc3NjE4NiBMOC40ODI0Nzg2NSwyLjk0NTI1ODQzIEM3LjY5MTM3ODkxLDIuMzM3Nzg0NzggNi44NTk1NjUyNywyIDYsMiBDNC42ODcxNTc3OSwyIDMuNDM5MDUzMDYsMi43ODc5NjU5MSAyLjMwMTg2ODgzLDQuMTIxMjgwMjIgQzEuODk1MDk5OTEsNC41OTgyMDQ1NSAxLjUzMjcyNzg0LDUuMTEwOTc5MTEgMS4yMjA1NDE3NCw1LjYyMzQxOTQ0IEMxLjEzMzk0MTAxLDUuNzY1NTcwOSAxLjA2MDI2MjE4LDUuODkyMzE1NDggMSw2IEMxLjA2MDI2MjE4LDYuMTA3Njg0NTIgMS4xMzM5NDEwMSw2LjIzNDQyOTEgMS4yMjA1NDE3NCw2LjM3NjU4MDU2IEMxLjUzMjcyNzg0LDYuODg5MDIwODkgMS44OTUwOTk5MSw3LjQwMTc5NTQ1IDIuMzAxODY4ODMsNy44Nzg3MTk3OCBDMi40NTUyMTY2Niw4LjA1ODUxNTQ5IDIuNjEwNTgxNDgsOC4yMjgzOTQ0OSAyLjc2Nzg1MDA1LDguMzg3NzYxODYgWiBNMy41MTc1MjEzNSw5LjA1NDc0MTU3IEM0LjMwODYyMTA5LDkuNjYyMjE1MjIgNS4xNDA0MzQ3MywxMCA2LDEwIEM3LjMxMjg0MjIxLDEwIDguNTYwOTQ2OTQsOS4yMTIwMzQwOSA5LjY5ODEzMTE3LDcuODc4NzE5NzggQzEwLjEwNDkwMDEsNy40MDE3OTU0NSAxMC40NjcyNzIyLDYuODg5MDIwODkgMTAuNzc5NDU4Myw2LjM3NjU4MDU2IEMxMC44NjYwNTksNi4yMzQ0MjkxIDEwLjkzOTczNzgsNi4xMDc2ODQ1MiAxMSw2IEMxMC45Mzk3Mzc4LDUuODkyMzE1NDggMTAuODY2MDU5LDUuNzY1NTcwOSAxMC43Nzk0NTgzLDUuNjIzNDE5NDQgQzEwLjQ2NzI3MjIsNS4xMTA5NzkxMSAxMC4xMDQ5MDAxLDQuNTk4MjA0NTUgOS42OTgxMzExNyw0LjEyMTI4MDIyIEM5LjU0NDc4MzM0LDMuOTQxNDg0NTEgOS4zODk0MTg1MiwzLjc3MTYwNTUxIDkuMjMyMTQ5OTUsMy42MTIyMzgxNCBMMy41MTc1MjEzNSw5LjA1NDc0MTU3IFogTTkuMTk4MDQ5MzEsMi4yNjM3NjI1NyBMMTAuOTA1MTcyNCwwLjYzNzkzMTAzNCBDMTEuMTA1MTM3NiwwLjQ0NzQ4ODAxNyAxMS40MjE2MjU5LDAuNDU1MjA3MjQ2IDExLjYxMjA2OSwwLjY1NTE3MjQxNCBDMTEuODAyNTEyLDAuODU1MTM3NTgyIDExLjc5NDc5MjgsMS4xNzE2MjU5NSAxMS41OTQ4Mjc2LDEuMzYyMDY4OTcgTDkuOTM1ODc1NzEsMi45NDIwMjMxMyBDMTAuMDcyNjIyNCwzLjA4MjQ0NjUzIDEwLjIwNzYxOTksMy4yMjk0MzM5IDEwLjM0MDg0MjYsMy4zODI2MzIwNSBDMTAuNzgzMjMyOSwzLjg5MTM1NDQ3IDExLjE3NDcyMzEsNC40MzQ2ODc1NSAxMS41MTI0MTU1LDQuOTc4MzQ0MDkgQzExLjcxNjM0MSw1LjMwNjY0NzE4IDExLjg1ODUzNjYsNS41NjUxMDg2MyAxMS45MzUyMjgsNS43MTk0NDIxMiBDMTIuMDIxNTkwNyw1Ljg5MzIzNzgyIDEyLjAyMTU5MDcsNi4xMDY3NjIxOCAxMS45MzUyMjgsNi4yODA1NTc4OCBDMTEuODU4NTM2Niw2LjQzNDg5MTM3IDExLjcxNjM0MSw2LjY5MzM1MjgyIDExLjUxMjQxNTUsNy4wMjE2NTU5MSBDMTEuMTc0NzIzMSw3LjU2NTMxMjQ1IDEwLjc4MzIzMjksOC4xMDg2NDU1MyAxMC4zNDA4NDI2LDguNjE3MzY3OTUgQzkuMDUyMTkwMjYsMTAuMDk5MjQxMiA3LjU5NzQ3MzE1LDExIDYsMTEgQzQuODU4MjE4ODYsMTEgMy43ODkzNjU1MiwxMC41Mzk4NDE5IDIuODAxOTUwNjksOS43MzYyMzc0MyBMMS4wOTQ4Mjc1OSwxMS4zNjIwNjkgQzAuODk0ODYyNDE4LDExLjU1MjUxMiAwLjU3ODM3NDA1MiwxMS41NDQ3OTI4IDAuMzg3OTMxMDM0LDExLjM0NDgyNzYgQzAuMTk3NDg4MDE3LDExLjE0NDg2MjQgMC4yMDUyMDcyNDYsMTAuODI4Mzc0MSAwLjQwNTE3MjQxNCwxMC42Mzc5MzEgTDIuMDY0MTI0MjksOS4wNTc5NzY4NyBDMS45MjczNzc2Myw4LjkxNzU1MzQ3IDEuNzkyMzgwMDcsOC43NzA1NjYxIDEuNjU5MTU3MzcsOC42MTczNjc5NSBDMS4yMTY3NjcwNyw4LjEwODY0NTUzIDAuODI1Mjc2OTI4LDcuNTY1MzEyNDUgMC40ODc1ODQ1MjEsNy4wMjE2NTU5MSBDMC4yODM2NTg5NjYsNi42OTMzNTI4MiAwLjE0MTQ2MzM3OCw2LjQzNDg5MTM3IDAuMDY0NzcxOTUwNCw2LjI4MDU1Nzg4IEMtMC4wMjE1OTA2NTAxLDYuMTA2NzYyMTggLTAuMDIxNTkwNjUwMSw1Ljg5MzIzNzgyIDAuMDY0NzcxOTUwNCw1LjcxOTQ0MjEyIEMwLjE0MTQ2MzM3OCw1LjU2NTEwODYzIDAuMjgzNjU4OTY2LDUuMzA2NjQ3MTggMC40ODc1ODQ1MjEsNC45NzgzNDQwOSBDMC44MjUyNzY5MjgsNC40MzQ2ODc1NSAxLjIxNjc2NzA3LDMuODkxMzU0NDcgMS42NTkxNTczNywzLjM4MjYzMjA1IEMyLjk0NzgwOTc0LDEuOTAwNzU4OCA0LjQwMjUyNjg1LDEgNiwxIEM3LjE0MTc4MTE0LDEgOC4yMTA2MzQ0OCwxLjQ2MDE1ODE0IDkuMTk4MDQ5MzEsMi4yNjM3NjI1NyBaIE00LjI1LDYgQzQuMjUsNi4yNzYxNDIzNyA0LjAyNjE0MjM3LDYuNSAzLjc1LDYuNSBDMy40NzM4NTc2Myw2LjUgMy4yNSw2LjI3NjE0MjM3IDMuMjUsNiBDMy4yNSw0LjUzNDY4MjU0IDQuNDg2NDE2NjcsMy4zNTcxNDI4NiA2LDMuMzU3MTQyODYgQzYuMjc2MTQyMzcsMy4zNTcxNDI4NiA2LjUsMy41ODEwMDA0OCA2LjUsMy44NTcxNDI4NiBDNi41LDQuMTMzMjg1MjMgNi4yNzYxNDIzNyw0LjM1NzE0Mjg2IDYsNC4zNTcxNDI4NiBDNS4wMjgwODMzMyw0LjM1NzE0Mjg2IDQuMjUsNS4wOTgxNzQ2IDQuMjUsNiBaIE03Ljc1LDYgQzcuNzUsNS43MjM4NTc2MyA3Ljk3Mzg1NzYzLDUuNSA4LjI1LDUuNSBDOC41MjYxNDIzNyw1LjUgOC43NSw1LjcyMzg1NzYzIDguNzUsNiBDOC43NSw3LjQ2NTMxNzQ2IDcuNTEzNTgzMzMsOC42NDI4NTcxNCA2LDguNjQyODU3MTQgQzUuNzIzODU3NjMsOC42NDI4NTcxNCA1LjUsOC40MTg5OTk1MiA1LjUsOC4xNDI4NTcxNCBDNS41LDcuODY2NzE0NzcgNS43MjM4NTc2Myw3LjY0Mjg1NzE0IDYsNy42NDI4NTcxNCBDNi45NzE5MTY2Nyw3LjY0Mjg1NzE0IDcuNzUsNi45MDE4MjU0IDcuNzUsNiBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyIDIpIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-EyeShow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuMjM1MDA0NzcsOC40MDA0MDAzNyBDMy41NDczMDQ3OSw4LjkxMDM0MTgyIDMuOTA5NzM5NSw5LjQyMDUxNzkxIDQuMzE2NDc5NzEsOS44OTQ5MDk5MyBDNS40NTE0MTkyNiwxMS4yMTg2MjA0IDYuNjk1NjEwNDMsMTIgOCwxMiBDOS4zMDQzODk1NywxMiAxMC41NDg1ODA3LDExLjIxODYyMDQgMTEuNjgzNTIwMyw5Ljg5NDkwOTkzIEMxMi4wOTAyNjA1LDkuNDIwNTE3OTEgMTIuNDUyNjk1Miw4LjkxMDM0MTgyIDEyLjc2NDk5NTIsOC40MDA0MDAzNyBDMTIuODU4NTc0Nyw4LjI0NzU5ODQ0IDEyLjkzNzExNjQsOC4xMTI2MTA2NCAxMyw4IEMxMi45MzcxMTY0LDcuODg3Mzg5MzYgMTIuODU4NTc0Nyw3Ljc1MjQwMTU2IDEyLjc2NDk5NTIsNy41OTk1OTk2MyBDMTIuNDUyNjk1Miw3LjA4OTY1ODE4IDEyLjA5MDI2MDUsNi41Nzk0ODIwOSAxMS42ODM1MjAzLDYuMTA1MDkwMDcgQzEwLjU0ODU4MDcsNC43ODEzNzk2MSA5LjMwNDM4OTU3LDQgOCw0IEM2LjY5NTYxMDQzLDQgNS40NTE0MTkyNiw0Ljc4MTM3OTYxIDQuMzE2NDc5NzEsNi4xMDUwOTAwNyBDMy45MDk3Mzk1LDYuNTc5NDgyMDkgMy41NDczMDQ3OSw3LjA4OTY1ODE4IDMuMjM1MDA0NzcsNy41OTk1OTk2MyBDMy4xNDE0MjUzMSw3Ljc1MjQwMTU2IDMuMDYyODgzNTgsNy44ODczODkzNiAzLDggQzMuMDYyODgzNTgsOC4xMTI2MTA2NCAzLjE0MTQyNTMxLDguMjQ3NTk4NDQgMy4yMzUwMDQ3Nyw4LjQwMDQwMDM3IFogTTIuMDU5ODA2MjUsNy43NDExNjEwMSBDMi4xMzYwNDgxMSw3LjU4NjkwMjg4IDIuMjc3NzU4NjcsNy4zMjc5MzA3MSAyLjQ4MTE1OTA2LDYuOTk4NzAzMDcgQzIuODE4MjI0ODgsNi40NTMxMjIwNSAzLjIwOTA1NjQ4LDUuOTA3NzcwOTIgMy42NTA4MDU0NSw1LjM5NzA0MDE0IEM0LjkzOTgwNDA1LDMuOTA2NzU2NDYgNi4zOTYzMzQxOSwzIDgsMyBDOS42MDM2NjU4MSwzIDExLjA2MDE5NTksMy45MDY3NTY0NiAxMi4zNDkxOTQ2LDUuMzk3MDQwMTQgQzEyLjc5MDk0MzUsNS45MDc3NzA5MiAxMy4xODE3NzUxLDYuNDUzMTIyMDUgMTMuNTE4ODQwOSw2Ljk5ODcwMzA3IEMxMy43MjIyNDEzLDcuMzI3OTMwNzEgMTMuODYzOTUxOSw3LjU4NjkwMjg4IDEzLjk0MDE5MzcsNy43NDExNjEwMSBDMTQuMDE5OTM1NCw3LjkwMjUwMDE5IDE0LjAxOTkzNTQsOC4wOTc0OTk4MSAxMy45NDAxOTM3LDguMjU4ODM4OTkgQzEzLjg2Mzk1MTksOC40MTMwOTcxMiAxMy43MjIyNDEzLDguNjcyMDY5MjkgMTMuNTE4ODQwOSw5LjAwMTI5NjkzIEMxMy4xODE3NzUxLDkuNTQ2ODc3OTUgMTIuNzkwOTQzNSwxMC4wOTIyMjkxIDEyLjM0OTE5NDYsMTAuNjAyOTU5OSBDMTEuMDYwMTk1OSwxMi4wOTMyNDM1IDkuNjAzNjY1ODEsMTMgOCwxMyBDNi4zOTYzMzQxOSwxMyA0LjkzOTgwNDA1LDEyLjA5MzI0MzUgMy42NTA4MDU0NSwxMC42MDI5NTk5IEMzLjIwOTA1NjQ4LDEwLjA5MjIyOTEgMi44MTgyMjQ4OCw5LjU0Njg3Nzk1IDIuNDgxMTU5MDYsOS4wMDEyOTY5MyBDMi4yNzc3NTg2Nyw4LjY3MjA2OTI5IDIuMTM2MDQ4MTEsOC40MTMwOTcxMiAyLjA1OTgwNjI1LDguMjU4ODM4OTkgQzEuOTgwMDY0NTgsOC4wOTc0OTk4MSAxLjk4MDA2NDU4LDcuOTAyNTAwMTkgMi4wNTk4MDYyNSw3Ljc0MTE2MTAxIFogTTguMDY0MjE5NjIsMTAuMjg3Mjc2NiBDNi43Mjc2MDM2MiwxMC4yODcyNzY2IDUuNjQ0MDYyMjYsOS4yNjMyMjc5NiA1LjY0NDA2MjI2LDggQzUuNjQ0MDYyMjYsNi43MzY3NzIwNCA2LjcyNzYwMzYyLDUuNzEyNzIzNDQgOC4wNjQyMTk2Miw1LjcxMjcyMzQ0IEM5LjQwMDgzNTYzLDUuNzEyNzIzNDQgMTAuNDg0Mzc3LDYuNzM2NzcyMDQgMTAuNDg0Mzc3LDggQzEwLjQ4NDM3Nyw5LjI2MzIyNzk2IDkuNDAwODM1NjMsMTAuMjg3Mjc2NiA4LjA2NDIxOTYyLDEwLjI4NzI3NjYgWiBNOC4wNjQyMTk2Miw5LjMyNDIxMjc1IEM4LjgzODA0OTk0LDkuMzI0MjEyNzUgOS40NjUzNjMzNiw4LjczMTM0MjUxIDkuNDY1MzYzMzYsOCBDOS40NjUzNjMzNiw3LjI2ODY1NzQ5IDguODM4MDQ5OTQsNi42NzU3ODcyNSA4LjA2NDIxOTYyLDYuNjc1Nzg3MjUgQzcuMjkwMzg5MzEsNi42NzU3ODcyNSA2LjY2MzA3NTg5LDcuMjY4NjU3NDkgNi42NjMwNzU4OSw4IEM2LjY2MzA3NTg5LDguNzMxMzQyNTEgNy4yOTAzODkzMSw5LjMyNDIxMjc1IDguMDY0MjE5NjIsOS4zMjQyMTI3NSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); @@ -81,6 +85,7 @@ --icon-ImportArrow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTciIGhlaWdodD0iMzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxkZWZzPjxwYXRoIGlkPSJhIiBkPSJNMCAwSDE1VjMySDB6Ii8+PC9kZWZzPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgMSkiPjxtYXNrIGlkPSJiIiBmaWxsPSIjZmZmIj48dXNlIHhsaW5rOmhyZWY9IiNhIi8+PC9tYXNrPjxjaXJjbGUgc3Ryb2tlPSIjRDlEOUQ5IiBzdHJva2Utd2lkdGg9IjQiIG1hc2s9InVybCgjYikiIGN4PSIxNSIgY3k9IjE2IiByPSIxMiIvPjwvZz48cGF0aCBzdHJva2U9IiNEOUQ5RDkiIGZpbGw9IiNEOUQ5RDkiIHRyYW5zZm9ybT0icm90YXRlKDkwIDE0IDQpIiBkPSJNMTUgMUwxOSA3IDExIDd6Ii8+PC9nPjwvc3ZnPg=='); --icon-Info: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTgsMGE4LDgsMCwxLDAsOCw4QTguMDI0LDguMDI0LDAsMCwwLDgsMFptLjUsMTNoLTFhLjUuNSwwLDAsMS0uNS0uNXYtNUEuNS41LDAsMCwxLDcuNSw3aDFhLjUuNSwwLDAsMSwuNS41djVBLjUuNSwwLDAsMSw4LjUsMTNaTTgsNUEuOTQ1Ljk0NSwwLDAsMSw3LDQsLjk0NS45NDUsMCwwLDEsOCwzLC45NDUuOTQ1LDAsMCwxLDksNCwuOTQ1Ljk0NSwwLDAsMSw4LDVaIiBmaWxsPSIjNGI4MDU2Ii8+PC9zdmc+'); --icon-LeftAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE0yLjUsMTMgQzIuMjIzODU3NjMsMTMgMiwxMi43NzYxNDI0IDIsMTIuNSBDMiwxMi4yMjM4NTc2IDIuMjIzODU3NjMsMTIgMi41LDEyIEw3LjUsMTIgQzcuNzc2MTQyMzcsMTIgOCwxMi4yMjM4NTc2IDgsMTIuNSBDOCwxMi43NzYxNDI0IDcuNzc2MTQyMzcsMTMgNy41LDEzIEwyLjUsMTMgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); + --icon-Lighting: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzc0NV8xODI3OSkiPjxwYXRoIGQ9Ik0xNC45OTk5IDYuMDAwMDFIOC4zOTk5NEw5Ljk5OTk0IDEuMzAwMDFDMTAuMjk5OSAwLjMwMDAxMSA5LjA5OTk1IC0wLjQ5OTk4OSA4LjI5OTk0IDAuMzAwMDExTDAuMjk5OTQ1IDguMzAwMDFDLTAuMzAwMDU1IDguOTAwMDEgMC4wOTk5NDQ2IDEwIDAuOTk5OTQ1IDEwSDcuNTk5OTRMNS45OTk5NCAxNC43QzUuNjk5OTQgMTUuNyA2Ljg5OTk0IDE2LjUgNy42OTk5NCAxNS43TDE1LjY5OTkgNy43MDAwMUMxNi4yOTk5IDcuMTAwMDEgMTUuODk5OSA2LjAwMDAxIDE0Ljk5OTkgNi4wMDAwMVoiIGZpbGw9IiMwMDAiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJjbGlwMF83NDVfMTgyNzkiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4='); --icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg=='); --icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Mail: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjUsMTMgQzE0Ljc3NTg1NzYsMTMgMTUsMTIuNzc1ODU3NiAxNSwxMi41IEwxNSwxLjUgQzE1LDEuMjI0MTQyMzcgMTQuNzc1ODU3NiwxIDE0LjUsMSBMMS41LDEgQzEuMjI0MTQyMzcsMSAxLDEuMjI0MTQyMzcgMSwxLjUgTDEsMTIuNSBDMSwxMi43NzU4NTc2IDEuMjI0MTQyMzcsMTMgMS41LDEzIEwxNC41LDEzIFogTTE0LjUsMTQgTDEuNSwxNCBDMC42NzE4NTc2MjUsMTQgMCwxMy4zMjgxNDI0IDAsMTIuNSBMMCwxLjUgQzAsMC42NzE4NTc2MjUgMC42NzE4NTc2MjUsOC44ODE3ODQyZS0xNiAxLjUsOC44ODE3ODQyZS0xNiBMMTQuNSw4Ljg4MTc4NDJlLTE2IEMxNS4zMjgxNDI0LDguODgxNzg0MmUtMTYgMTYsMC42NzE4NTc2MjUgMTYsMS41IEwxNiwxMi41IEMxNiwxMy4zMjgxNDI0IDE1LjMyODE0MjQsMTQgMTQuNSwxNCBaIE0xMy4xODMzODExLDMuMTEzMDIxMzUgQzEzLjM5NzEwMzUsMi45MzgxNTc1NiAxMy43MTIxMTQ5LDIuOTY5NjU4NyAxMy44ODY5Nzg2LDMuMTgzMzgxMSBDMTQuMDYxODQyNCwzLjM5NzEwMzUxIDE0LjAzMDM0MTMsMy43MTIxMTQ4NiAxMy44MTY2MTg5LDMuODg2OTc4NjUgTDguMzE2NjE4OSw4LjM4Njk3ODY1IEM4LjEzMjQzNTk1LDguNTM3NjczNzggNy44Njc1NjQwNSw4LjUzNzY3Mzc4IDcuNjgzMzgxMSw4LjM4Njk3ODY1IEwyLjE4MzM4MTEsMy44ODY5Nzg2NSBDMS45Njk2NTg3LDMuNzEyMTE0ODYgMS45MzgxNTc1NiwzLjM5NzEwMzUxIDIuMTEzMDIxMzUsMy4xODMzODExIEMyLjI4Nzg4NTE0LDIuOTY5NjU4NyAyLjYwMjg5NjQ5LDIuOTM4MTU3NTYgMi44MTY2MTg5LDMuMTEzMDIxMzUgTDgsNy4zNTM5Njk1MyBMMTMuMTgzMzgxMSwzLjExMzAyMTM1IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 6624c08d..dff180cc 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -447,7 +447,11 @@ "Importer": { "Merge rows that match these fields:": "Merge rows that match these fields:", "Select fields to match on": "Select fields to match on", - "Update existing records": "Update existing records" + "Update existing records": "Update existing records", + "{{count}} unmatched field in import_one": "{{count}} unmatched field in import", + "{{count}} unmatched field in import_other": "{{count}} unmatched fields in import", + "{{count}} unmatched field_one": "{{count}} unmatched field", + "{{count}} unmatched field_other": "{{count}} unmatched fields" }, "LeftPanelCommon": { "Help Center": "Help Center" diff --git a/static/ui-icons/UI/ArrowLeft.svg b/static/ui-icons/UI/ArrowLeft.svg new file mode 100644 index 00000000..17e1047f --- /dev/null +++ b/static/ui-icons/UI/ArrowLeft.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/ui-icons/UI/ArrowRight.svg b/static/ui-icons/UI/ArrowRight.svg new file mode 100644 index 00000000..9671c6b1 --- /dev/null +++ b/static/ui-icons/UI/ArrowRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/ui-icons/UI/ArrowRightOutlined.svg b/static/ui-icons/UI/ArrowRightOutlined.svg new file mode 100644 index 00000000..c842793d --- /dev/null +++ b/static/ui-icons/UI/ArrowRightOutlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/ui-icons/UI/Exclamation.svg b/static/ui-icons/UI/Exclamation.svg new file mode 100644 index 00000000..739772c6 --- /dev/null +++ b/static/ui-icons/UI/Exclamation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/ui-icons/UI/Lighting.svg b/static/ui-icons/UI/Lighting.svg new file mode 100644 index 00000000..43562887 --- /dev/null +++ b/static/ui-icons/UI/Lighting.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file