(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
This commit is contained in:
Jarosław Sadziński 2023-08-04 14:33:33 +02:00
parent 05c15e4ec3
commit 6416994c22
23 changed files with 1316 additions and 436 deletions

View File

@ -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)
}))
];

File diff suppressed because it is too large Load Diff

View File

@ -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'))
)

View File

@ -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<DomContents>(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', `

View File

@ -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<FocusLayerOptions>): DomMethod<HTMLElement> {
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;

View File

@ -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",

View File

@ -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),

View File

@ -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<T>(selectedOptions: MutableObsArray<T>,
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;

View File

@ -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', `

View File

@ -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;

View File

@ -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;
}

View File

@ -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",

View File

@ -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;

View File

@ -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<any>|IKnockoutReadObservable<any>) => (use: UseCBOwner) => !use(obs);
/**
* Get a set of up to `count` distinct values of `values`.
*/

View File

@ -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',

View File

@ -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',

File diff suppressed because one or more lines are too long

View File

@ -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"

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMidYMid meet">
<path d="M 8.7849346,14.670753 2.7846296,8.6213225 c -0.3682084,-0.37123 -0.3682084,-0.9731 0,-1.34432 l 6.000305,-6.049424 c 0.36821,-0.37122276 0.96519,-0.37122276 1.3334004,0 0.36821,0.371223 0.36821,0.973094 0,1.344314 l -4.3907504,4.42669 h 7.7808904 v 1.90115 H 5.7275846 l 4.3907504,4.4267205 c 0.36821,0.3712 0.36821,0.9731 0,1.3443 -0.3682104,0.3712 -0.9651904,0.3712 -1.3334004,0 z" style="clip-rule:evenodd;fill:#000000;fill-rule:evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMidYMid meet">
<path d="M 7.2320137,14.670753 13.232319,8.6213225 c 0.368208,-0.37123 0.368208,-0.9731 0,-1.34432 L 7.2320137,1.2275785 c -0.36821,-0.37122276 -0.96519,-0.37122276 -1.3334004,0 -0.36821,0.371223 -0.36821,0.973094 0,1.344314 l 4.3907507,4.42669 H 2.5084733 v 1.90115 H 10.289364 L 5.8986133,13.326453 c -0.36821,0.3712 -0.36821,0.9731 0,1.3443 0.3682104,0.3712 0.9651904,0.3712 1.3334004,0 z" style="clip-rule:evenodd;fill:#000000;fill-rule:evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 1.50001L15.5 8.00001L7.5 14.5V10.5H3C2.33696 10.5 1.70107 10.2366 1.23223 9.76777C0.763392 9.29893 0.5 8.66305 0.5 8.00001C0.5 7.33697 0.763392 6.70108 1.23223 6.23224C1.70107 5.7634 2.33696 5.50001 3 5.50001H7.5V1.50001Z" stroke="#D9D9D9" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 431 B

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5 1H6.5L7 10H9L9.5 1Z" fill="#000"/>
<path d="M8 15C8.82843 15 9.5 14.3284 9.5 13.5C9.5 12.6716 8.82843 12 8 12C7.17157 12 6.5 12.6716 6.5 13.5C6.5 14.3284 7.17157 15 8 15Z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_745_18279)">
<path d="M14.9999 6.00001H8.39994L9.99994 1.30001C10.2999 0.300011 9.09995 -0.499989 8.29994 0.300011L0.299945 8.30001C-0.300055 8.90001 0.0999446 10 0.999945 10H7.59994L5.99994 14.7C5.69994 15.7 6.89994 16.5 7.69994 15.7L15.6999 7.70001C16.2999 7.10001 15.8999 6.00001 14.9999 6.00001Z" fill="#000" />
</g>
<defs>
<clipPath id="clip0_745_18279">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 630 B