Improve UX and functionally follow the mapped columns and field names.

Still buggy and the dialog seems to keep state, strangely. Still missing a checkbox to signal if the first row has data.
This commit is contained in:
Bruno Bernardino 2022-03-08 17:28:23 +00:00
parent 2ba1a590d2
commit 0b03fd0990
No known key found for this signature in database
GPG Key ID: D1B0A69ADD114ECE
3 changed files with 194 additions and 61 deletions

View File

@ -5,14 +5,14 @@ import * as imp from "../lib/import";
import { prompt, alert } from "../lib/dialog";
import { app } from "../globals";
import { Select } from "./select";
import { Input } from "./input";
import { Dialog } from "./dialog";
import "./button";
import { customElement, query, state } from "lit/decorators.js";
import { html /*, css*/ } from "lit";
import { customElement, query, state, queryAll } from "lit/decorators.js";
import { html, css } from "lit";
import { saveFile } from "@padloc/core/src/platform";
import { stringToBytes } from "@padloc/core/src/encoding";
// TODO: Force showing "Name" and "Tags" as well, as those are necessary for the import
const fieldTypeOptions = Object.keys(FIELD_DEFS).map((fieldType) => ({
label: FIELD_DEFS[fieldType].name as string,
value: fieldType,
@ -29,24 +29,60 @@ export class ImportDialog extends Dialog<File, void> {
@state()
private _itemColumns: imp.ImportCSVColumn[] = [];
@state()
private _csvHasDataOnFirstRow: boolean = false;
@query("#formatSelect")
private _formatSelect: Select<string>;
@query("#vaultSelect")
private _vaultSelect: Select<Vault>;
// static styles = [
// ...Dialog.styles,
// css`
// :host {
// --pl-dialog-max-width: 40em;
// }
// `,
// ];
@query("#nameColumnSelect")
private _nameColumnSelect: Select<number>;
@query("#tagsColumnSelect")
private _tagsColumnSelect: Select<number>;
@queryAll("pl-select.field-type-select")
private _fieldTypeSelects: Select<FieldType>[];
@queryAll("pl-input.field-name-input")
private _fieldNameInputs: Input[];
static styles = [
...Dialog.styles,
css`
:host {
--pl-dialog-max-width: 40em;
}
`,
];
renderContent() {
if (this._formatSelect?.value === imp.CSV.value) {
if (
this._nameColumnSelect &&
(this._nameColumnSelect.selectedIndex === -1 || this._nameColumnSelect.selectedIndex === undefined)
) {
const selectedNameIndex = this._itemColumns.findIndex((itemColumn) => itemColumn.type === "name");
this._nameColumnSelect.value = selectedNameIndex;
this._nameColumnSelect.selectedIndex = selectedNameIndex;
}
if (
this._tagsColumnSelect &&
(this._tagsColumnSelect.selectedIndex === -1 || this._tagsColumnSelect.selectedIndex === undefined)
) {
// +1 because we have the "none" option first
const selectedTagsIndex = this._itemColumns.findIndex((itemColumn) => itemColumn.type === "tags") + 1;
this._tagsColumnSelect.value = selectedTagsIndex - 1;
this._tagsColumnSelect.selectedIndex = selectedTagsIndex;
}
}
return html`
<div class="padded vertical spacing layout">
<div class="padded vertical spacing layout fit-vertically">
<h1 class="big text-centering margined">${$l("Import Data")}</h1>
<pl-select
@ -58,29 +94,109 @@ export class ImportDialog extends Dialog<File, void> {
></pl-select>
<div class="small padded" ?hidden=${this._formatSelect && this._formatSelect.value !== imp.CSV.value}>
${$l("Choose the field type for each column. If you are having trouble,")}
${$l("Choose the field name and type for each column below. If you are having trouble,")}
<a href="#" @click=${this._downloadCSVSampleFile}> ${$l("Download Sample File")} </a>
</div>
<div
class="vertical stretching spacing layout"
class="vertical spacing layout"
?hidden=${this._formatSelect && this._formatSelect.value !== imp.CSV.value}
>
<ul>
<pl-select
id=${"nameColumnSelect"}
.label=${$l("Name Column")}
.options=${this._itemColumns.map((itemColumn, itemColumnIndex) => ({
label: `${itemColumn.displayName} (${$l("Column {0}", itemColumnIndex.toString())})`,
value: itemColumnIndex,
}))}
.selectedIndex=${this._nameColumnSelect?.selectedIndex}
@change=${() => {
const currentNameColumnIndex = this._itemColumns.findIndex(
(itemColumn) => itemColumn.type === "name"
);
const nameColumnIndex = this._nameColumnSelect?.value || 0;
this._itemColumns[nameColumnIndex].type = "name";
if (currentNameColumnIndex !== -1) {
this._itemColumns[currentNameColumnIndex].type = FieldType.Text;
}
this._parseData();
}}
></pl-select>
<pl-select
id=${"tagsColumnSelect"}
.label=${$l("Tags Column")}
.options=${[
{ label: $l("None"), value: -1 },
...this._itemColumns.map((itemColumn, itemColumnIndex) => ({
label: `${itemColumn.displayName} (${$l("Column {0}", itemColumnIndex.toString())})`,
value: itemColumnIndex,
})),
]}
.selectedIndex=${this._tagsColumnSelect?.selectedIndex}
@change=${() => {
const currentTagsColumnIndex = this._itemColumns.findIndex(
(itemColumn) => itemColumn.type === "tags"
);
const tagsColumnIndex = this._tagsColumnSelect?.value || 0;
this._itemColumns[tagsColumnIndex].type = "tags";
if (currentTagsColumnIndex !== -1) {
this._itemColumns[currentTagsColumnIndex].type = FieldType.Text;
}
this._parseData();
}}
></pl-select>
<!-- TODO: Add checkbox/toggle for this._csvHasDataOnFirstRow -->
</div>
<pl-scroller
class="stretch"
?hidden=${this._formatSelect && this._formatSelect.value !== imp.CSV.value}
>
<ul class="vertical spacing layout">
${this._itemColumns.map(
(itemColumn, itemColumnIndex) => html`
<li class="margined">
<li
class="padded box vertical spacing layout"
?hidden=${itemColumn.type === "name" || itemColumn.type === "tags"}
>
<div class="small margined spacing horizontal layout">
<div class="stretch">${itemColumn.name}</div>
<div class="subtle">${$l("Column {0}", itemColumnIndex.toString())}</div>
</div>
<div class="tiny horizontally-margined subtle mono">
${itemColumn.exampleValues}
</div>
<pl-input
.label=${$l("Field Name")}
class="field-name-input"
.value=${itemColumn.displayName}
@change=${() => {
const newFieldNameInput = this._fieldNameInputs[itemColumnIndex];
const newFieldName = newFieldNameInput?.value;
if (newFieldName) {
this._itemColumns[itemColumnIndex].displayName = newFieldName;
this._parseData();
}
}}
></pl-input>
<pl-select
id=${`itemColumnSelect-${itemColumnIndex}`}
icon=${FIELD_DEFS[itemColumn.type].icon}
.label=${itemColumn.displayName}
class="field-type-select"
icon=${FIELD_DEFS[itemColumn.type]?.icon || "text"}
.label=${$l("Field Type")}
.options=${fieldTypeOptions}
.value=${itemColumn.type}
@change=${() => {
const thisElement = this.shadowRoot?.querySelector(
`#itemColumnSelect-${itemColumnIndex}`
) as HTMLSelectElement;
const newValue = thisElement?.value as FieldType;
const newValue = this._fieldTypeSelects[itemColumnIndex]?.value;
if (newValue) {
this._itemColumns[itemColumnIndex].type = newValue;
this._parseData();
@ -91,7 +207,7 @@ export class ImportDialog extends Dialog<File, void> {
`
)}
</ul>
</div>
</pl-scroller>
<pl-select
id="vaultSelect"
@ -164,7 +280,7 @@ Github,"work,coding",https://github.com,john.doe@gmail.com,129lskdf93`)
this._items = await imp.asLastPass(file);
break;
case imp.CSV.value:
const result = await imp.asCSV(file, this._itemColumns);
const result = await imp.asCSV(file, this._itemColumns, this._csvHasDataOnFirstRow);
this._items = result.items;
this._itemColumns = result.itemColumns;
break;

View File

@ -17,7 +17,8 @@ export interface ImportFormat {
export interface ImportCSVColumn {
name: string;
displayName: string;
type: FieldType;
type: FieldType | "name" | "tags";
exampleValues: string;
}
export const CSV: ImportFormat = {
@ -54,45 +55,37 @@ export function loadPapa(): Promise<any> {
/**
* Takes a data table (represented by a two-dimensional array) and converts it
* into an array of items
* @param Array data Two-dimensional array containing tabular item data; The first 'row'
* should contain field names. All other rows represent items, containing
* the item name, field values and optionally a list of tags.
* @param Array columnTypes Array containing the type of field per column.
* @param Integer nameColIndex Index of the column containing the item names. Defaults to 0
* @param Integer tagsColIndex Index of the column containing the item categories. If left empty
* no categories will be used
* @param Array data Two-dimensional array containing tabular item data; The first 'row'
* should contain field names. All other rows represent items, containing
* the item name, field values and optionally a list of tags.
* @param Array columnTypes Array containing the type of field per column.
* @param Boolean dataOnFirstRow Boolean, representing if there is data on the first row (instead of
* columns).
*/
async function fromTable(
data: string[][],
columnTypes: ImportCSVColumn[],
nameColIndex?: number,
tagsColIndex?: number
dataOnFirstRow: boolean
): Promise<VaultItem[]> {
// Use first row for column names
const colNames = data[0];
let nameColumnIndex = columnTypes.findIndex((columnType) => columnType.type === "name");
const tagsColumnIndex = columnTypes.findIndex((columnType) => columnType.type === "tags");
if (nameColIndex === undefined) {
const i = colNames.indexOf("name");
nameColIndex = i !== -1 ? i : 0;
if (nameColumnIndex === -1) {
nameColumnIndex = 0;
}
if (tagsColIndex === undefined) {
tagsColIndex = colNames.indexOf("tags");
if (tagsColIndex === -1) {
tagsColIndex = colNames.indexOf("category");
}
}
const dataRows = dataOnFirstRow ? data : data.slice(1);
// All subsequent rows should contain values
let items = data.slice(1).map(function (row) {
let items = dataRows.map(function (row) {
// Construct an array of field object from column names and values
let fields: Field[] = [];
for (let i = 0; i < row.length; i++) {
for (let columnIndex = 0; columnIndex < row.length; ++columnIndex) {
// Skip name column, category column (if any) and empty fields
if (i != nameColIndex && i != tagsColIndex && row[i]) {
const name = colNames[i];
const value = row[i];
const type = columnTypes[i].type || undefined;
if (columnIndex !== nameColumnIndex && columnIndex !== tagsColumnIndex && row[columnIndex]) {
const name = columnTypes[columnIndex].displayName;
const value = row[columnIndex];
const type = columnTypes[columnIndex].type || undefined;
fields.push(
new Field().fromRaw({
name,
@ -103,8 +96,8 @@ async function fromTable(
}
}
const name = row[nameColIndex!];
const tags = row[tagsColIndex!];
const name = row[nameColumnIndex!];
const tags = row[tagsColumnIndex!];
return createVaultItem({ name, fields, tags: (tags && tags.split(",")) || [] });
});
@ -119,8 +112,7 @@ export async function isCSV(data: string): Promise<Boolean> {
export async function asCSV(
file: File,
mappedItemColumns: ImportCSVColumn[],
nameColIndex?: number,
tagsColIndex?: number
dataOnFirstRow: boolean
): Promise<{ items: VaultItem[]; itemColumns: ImportCSVColumn[] }> {
const data = await readFileAsText(file);
const papa = await loadPapa();
@ -135,21 +127,42 @@ export async function asCSV(
const itemColumns =
mappedItemColumns.length > 0
? mappedItemColumns
: columnNames.map((columnName) => {
: columnNames.map((columnName, columnIndex) => {
// TODO: Use guessFieldType instead, after improving it
const type = (Object.keys(FIELD_DEFS).filter((fieldType) => columnName.includes(fieldType))[0] ||
FieldType.Text) as FieldType;
let type = (Object.keys(FIELD_DEFS).filter((fieldType) => columnName.includes(fieldType))[0] ||
FieldType.Text) as ImportCSVColumn["type"];
// TODO: Also guess "Name" and "Tags"
const lowerCaseColumnName = columnName.toLocaleLowerCase();
if (lowerCaseColumnName === "name" || lowerCaseColumnName === $l("name")) {
type = "name";
}
if (
lowerCaseColumnName === "tags" ||
lowerCaseColumnName === $l("tags") ||
lowerCaseColumnName === "category" ||
lowerCaseColumnName === $l("category")
) {
type = "tags";
}
const exampleValues = [rows[1] ? rows[1][columnIndex] : "", rows[2] ? rows[2][columnIndex] : ""]
.filter((value) => Boolean(value))
.map((value) => (value.includes(",") ? `"${value}"` : value))
.join(", ");
return {
name: columnName,
displayName: capitalize(columnName),
type,
exampleValues,
};
});
const items = await fromTable(rows, itemColumns, nameColIndex, tagsColIndex);
// TODO: Prevent itemColumns from having more than one "name" or "tags" (force those to "text")
const items = await fromTable(rows, itemColumns, dataOnFirstRow);
return { items, itemColumns };
}

View File

@ -192,6 +192,10 @@ export async function getIdFromEmail(email: string) {
export function capitalize(string: string) {
const words = string.split(" ");
const phrase = words.map((word) => `${word[0].toLocaleUpperCase()}${word.slice(1) || ""}`).join(" ");
const phrase = words
.map((word) =>
word.toLocaleLowerCase() === "url" ? "URL" : `${word[0].toLocaleUpperCase()}${word.slice(1) || ""}`
)
.join(" ");
return phrase;
}