Add Vue component for handling delimited strings.

<dougal-delimited-string-decoder/> is intended for providing a UI
for configuring text-delimited import settings (such as CSV imports).
This commit is contained in:
D. Berge
2023-11-08 19:18:53 +01:00
parent 9f1fc3d19c
commit f82f2c78c7
2 changed files with 448 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
<template>
<v-row
dense
no-gutters
align="center"
>
<v-col cols="1">
<slot name="prepend"></slot>
</v-col>
<v-col cols="2">
<v-chip outlined label small :color="colour || getHSLColourFor(key)">{{name}}</v-chip>
</v-col>
<v-col cols="4">
<v-text-field
dense
label="Column"
type="number"
min="0"
clearable
:value="value.column"
@input="$emit('input', {...value, column: Number($event)})"
>
<template v-slot:append-outer>
<dougal-field-content-dialog
:readonly="readonly"
:value="value"
@input="$emit('input', $event)"
></dougal-field-content-dialog>
</template>
</v-text-field>
</v-col>
<v-col cols="1">
<slot name="append"></slot>
</v-col>
</v-row>
</template>
<style scoped>
</style>
<script>
import { parse } from 'csv-parse/sync'
import { getHSLColourFor } from '@/lib/hsl'
import DougalFieldContentDialog from '../fields/field-content-dialog'
export default {
name: "DougalDelimitedStringDecoderField",
components: {
//DougalFixedStringDecoderField,
DougalFieldContentDialog
},
props: {
value: Object,
name: String,
colour: String,
readonly: Boolean,
},
data () {
return {
}
},
computed: {
},
watch: {
},
methods: {
getHSLColourFor: getHSLColourFor.bind(this),
},
}
</script>

View File

@@ -0,0 +1,366 @@
<template>
<v-card flat elevation="0">
<v-card-title v-if="title">{{ title }}</v-card-title>
<v-card-subtitle v-if="subtitle">{{ subtitle }}</v-card-subtitle>
<v-card-text>
<v-form>
<v-container>
<dougal-delimited-string-decoder-field v-for="(field, key) in fields" :key="key"
:colour="getHSLColourFor(key)"
:readonly="readonly"
:name="key"
:value="fields[key]"
@input="$emit('update:fields', {...fields, [key]: $event})"
>
<template v-slot:append v-if="editableFieldList && !readonly">
<v-btn
class="ml-3"
fab
text
small
title="Remove this property"
>
<v-icon
color="error"
@click="removeField(key)"
>mdi-minus</v-icon>
</v-btn>
</template>
</dougal-delimited-string-decoder-field>
<v-row dense no-gutters v-if="editableFieldList && !readonly">
<v-col cols=6 offset=1>
<v-text-field
label="Add new field"
hint="Enter the name of a new field"
:error-messages="fieldNameErrors"
v-model="fieldName"
append-outer-icon="mdi-plus-circle"
@keydown.enter.prevent="addField"
>
<template v-slot:append-outer>
<v-icon
color="primary"
:disabled="fieldName && !!fieldNameErrors"
@click="addField"
>mdi-plus</v-icon>
</template>
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="6">
<v-combobox
label="Field delimiter"
hint="How are the fields separated from each other?"
:items="delimiters"
v-model="delimiter_"
></v-combobox>
</v-col>
</v-row>
<v-row>
<v-col cols="6">
<v-text-field
class="ml-3"
label="Skip lines"
hint="This lets you to skip file headers if present"
type="number"
min="0"
:value.number="numberedLines"
@input="$emit('update:numbered-lines', Number($event))"
></v-text-field>
</v-col>
<v-col cols="6">
<v-checkbox
v-ripple
label="First non-skipped line are field names"
:value="headerRow"
@change="$emit('update:header-row', $event)"
></v-checkbox>
</v-col>
</v-row>
<v-row>
<v-col>
<v-simple-table dense>
<template v-slot:default>
<colgroup v-if="showLineNumbers">
<col class="line_no"/>
</colgroup>
<thead>
<tr>
<th class="line_no">
<v-simple-checkbox
off-icon="mdi-format-list-numbered"
title="Show line numbers"
v-model="showLineNumbers"
>
</v-simple-checkbox>
</th>
<th v-for="(header, idx) in headers" :key="idx"
:style="`color:${header.colour};`"
>
<v-select
dense
clearable
:items="fieldsAvailableFor(idx)"
:value="header.fieldName"
@input="fieldSelected(idx, $event)"
>
</v-select>
</th>
</tr>
<tr>
<th class="line_no">
<small v-if="showLineNumbers && headers.length">Line no.</small>
</th>
<th v-for="(header, idx) in headers" :key="idx"
:style="`color:${header.colour};`"
>
{{ header.text }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ridx) in rows" :key="ridx">
<td class="line_no"">
<small v-if="showLineNumbers">
{{ ridx + (typeof numberedLines == "number" ? numberedLines : 0)+1 }}
</small>
</td>
<td v-for="(cell, cidx) in row" :key="cidx"
:style="`background-color:${cell.colour};`"
>
{{ cell.text }}
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
</v-card-actions>
</v-card>
</template>
<style scoped>
/*.v-data-table table tbody tr td*/
th {
border: 1px solid hsl(0, 0%, 33.3%);
}
td {
border-inline: 1px solid hsl(0, 0%, 33.3%);
}
.line_no {
text-align: right;
width: 4ex;
border: none !important;
}
</style>
<script>
import { parse } from 'csv-parse/sync'
import { getHSLColourFor } from '@/lib/hsl'
import truncateText from '@/lib/truncate-text'
import DougalDelimitedStringDecoderField from './delimited-string-decoder-field'
export default {
name: "DougalDelimitedStringDecoder",
components: {
DougalDelimitedStringDecoderField
},
props: {
text: String,
fields: Object,
delimiter: String,
headerRow: { type: [ Boolean, Number ], default: false},
numberedLines: [ Boolean, Number ],
maxHeight: String,
editableFieldList: { type: Boolean, default: true },
readonly: Boolean,
title: String,
subtitle: String
},
data () {
return {
//< The reason for not using this.text directly is that at some point
//< we might extend this component to allow editing the sample text.
text_: "",
//< The name of a new field to add.
fieldName: "",
showLineNumbers: null,
delimiters: [
{ text: "Comma (,)", value: "," },
{ text: "Tabulator (⇥)", value: "\x09" },
{ text: "Semicolon (;)", value: ";" }
]
}
},
computed: {
/** The index of the last column.
*
* This will be the higher of the number of columns available
* in the sample text or the highest column number defined in
* this.fields.
*
* NOTE: May return NaN
*/
numberOfColumns () {
const lastIndex = Object.values(this.fields)
.reduce( (acc, cur) => Math.max(acc, cur.column), this.cells[0]?.length-1);
return isNaN(lastIndex) ? 0 : (lastIndex + 1);
},
cells () {
return parse(this.text_, {delimiter: this.delimiter, trim: true});
},
headers () {
const headerNames = typeof this.headerRow == "number"
? this.cells[this.headerRow]
: this.headerRow === true
? this.cells[0]
: Array.from(this.cells[0] ?? [], (_, ι) => `Column ${ι}`);
return headerNames?.map((c, ι) => {
const fieldName = Object.keys(this.fields).find(i => this.fields[i].column == ι);
const field = this.fields[fieldName] ?? {}
const colour = this.headerRow === false
? this.getHSLColourFor(ι*10)
: this.getHSLColourFor(c);
return {
text: c,
colour: this.getHSLColourFor(c),
fieldName,
field
} ?? {}
}) ?? [];
},
rows () {
// NOTE It doesn't matter if headerRow is boolean, it works just the same.
return [...this.cells].slice(this.headerRow).map(r =>
r.map( (c, ι) => ({
text: truncateText(c),
colour: this.headers.length
? this.getHSLColourFor(this.headers[ι]?.text, 0.2)
: this.getHSLColourFor(ι*10, 0.2)
})));
},
fieldNameErrors () {
return Object.keys(this.fields).includes(this.fieldName)
? "A field with this name already exists"
: null;
},
delimiter_: {
get () {
return this.delimiters.find(i => i.value == this.delimiter) ?? this.delimiter;
},
set (v) {
this.$emit("update:delimiter", typeof v == "object" ? v.value : v);
}
}
},
watch: {
text () {
if (this.text != this.text_) {
this.reset();
}
},
numberedLines (cur, prev) {
if (cur != prev) {
this.showLineNumbers = typeof cur == "number" || cur;
}
}
},
methods: {
fieldsAvailableFor (idx) {
return Object.keys(this.fields).filter( i =>
this.fields[i].column === idx || this.fields[i].column === null) ?? [];
},
fieldSelected (col, key) {
const fields = {};
for (const k in this.fields) {
const field = {...this.fields[k]};
if (k === key) {
field.column = col
} else {
if (field.column === col) {
field.column = null;
}
}
fields[k] = field;
}
this.$emit("update:fields", fields);
},
addField () {
if (!this.fieldNameErrors) {
this.$emit("update:fields", {
...this.fields,
[this.fieldName]: { column: null }
});
this.fieldName = "";
}
},
removeField (key) {
const fields = {...this.fields};
delete fields[key];
this.$emit("update:fields", fields);
},
getHSLColourFor: getHSLColourFor.bind(this),
numberLine (number, line) {
return `<span class="line-number">${number}</span>${line}`;
},
reset () {
this.text_ = this.text.replaceAll("\r", "");
}
},
mounted () {
this.reset();
}
}
</script>