mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 12:27:07 +00:00
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:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user