mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 11:27:09 +00:00
Add json-builder component.
It displays a JSON object as a <v-treeview/>, with editing capabilities.
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-treeview
|
||||
dense
|
||||
activatable
|
||||
hoverable
|
||||
:multiple-active="false"
|
||||
:active.sync="active"
|
||||
:open.sync="open"
|
||||
:items="treeview"
|
||||
style="cursor:pointer;width:100%;"
|
||||
>
|
||||
<template v-slot:prepend="{item}">
|
||||
<template v-if="item.icon">
|
||||
<v-icon
|
||||
small
|
||||
left
|
||||
:title="item.leaf ? item.type : `${item.type} (${item.children.length} children)`"
|
||||
>{{item.icon}}</v-icon>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-slot:label="{item}">
|
||||
<template v-if="!('path' in item)">
|
||||
{{item.name}}
|
||||
</template>
|
||||
<template v-else-if="item.leaf">
|
||||
<v-chip
|
||||
small
|
||||
label
|
||||
outlined
|
||||
:color="item.isArrayItem ? 'secondary' : 'primary'"
|
||||
>
|
||||
{{item.name}}
|
||||
</v-chip>
|
||||
<code class="ml-4" v-if="item.type == 'bigint'">{{item.value+"n"}}</code>
|
||||
<code class="ml-4" v-else-if="item.type == 'boolean'"><b>{{item.value}}</b></code>
|
||||
<code class="ml-4" v-else>{{item.value}}</code>
|
||||
<v-icon v-if="item.type == 'string' && (/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}([0-9a-fA-F]{2})?)?$/.test(item.value) || item.name == 'colour' || item.name == 'color')"
|
||||
right
|
||||
:color="item.value"
|
||||
>mdi-square</v-icon>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-chip
|
||||
small
|
||||
label
|
||||
outlined
|
||||
:color="item.isArrayItem ? 'secondary' : 'primary'"
|
||||
>
|
||||
{{item.name}}
|
||||
</v-chip>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-slot:append="{item}">
|
||||
<template>
|
||||
<v-icon v-if="item.type == 'array'"
|
||||
small
|
||||
right
|
||||
outline
|
||||
color="primary"
|
||||
title="Add item"
|
||||
@click="itemAddDialog(item)"
|
||||
>mdi-plus</v-icon>
|
||||
<v-icon v-if="item.type == 'object'"
|
||||
small
|
||||
right
|
||||
outline
|
||||
color="primary"
|
||||
title="Add property"
|
||||
@click="itemAddDialog(item)"
|
||||
>mdi-plus</v-icon>
|
||||
<v-icon v-if="item.type == 'boolean'"
|
||||
small
|
||||
right
|
||||
outline
|
||||
color="primary"
|
||||
title="Toggle value"
|
||||
@click="itemToggle(item)"
|
||||
>{{ item.value ? "mdi-checkbox-blank-outline" : "mdi-checkbox-marked-outline" }}</v-icon>
|
||||
<v-icon v-if="item.type == 'string' || item.type == 'number'"
|
||||
small
|
||||
right
|
||||
outline
|
||||
color="primary"
|
||||
title="Edit value"
|
||||
@click="itemAddDialog(item, true)"
|
||||
>mdi-pencil-outline</v-icon>
|
||||
<v-icon
|
||||
small
|
||||
right
|
||||
outlined
|
||||
color="red"
|
||||
title="Delete"
|
||||
:disabled="item.id == rootId"
|
||||
@click="itemDelete(item)"
|
||||
>mdi-minus</v-icon>
|
||||
</template>
|
||||
</template>
|
||||
</v-treeview>
|
||||
<dougal-json-builder-property-dialog
|
||||
:open="editor"
|
||||
v-model="edit"
|
||||
v-bind="editorProperties"
|
||||
@save="editorSave"
|
||||
@close="editorClose"
|
||||
></dougal-json-builder-property-dialog>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { deepValue, deepSet } from '@/lib/utils';
|
||||
import DougalJsonBuilderPropertyDialog from './property-dialog';
|
||||
|
||||
export default {
|
||||
name: "DougalJsonBuilder",
|
||||
|
||||
components: {
|
||||
DougalJsonBuilderPropertyDialog
|
||||
},
|
||||
|
||||
props: {
|
||||
value: Object,
|
||||
name: String,
|
||||
sort: String
|
||||
},
|
||||
|
||||
data () {
|
||||
const rootId = Symbol("rootNode");
|
||||
return {
|
||||
rootId,
|
||||
active: [],
|
||||
open: [ rootId ],
|
||||
editor: false,
|
||||
editorProperties: {
|
||||
nameShown: true,
|
||||
nameEditable: true,
|
||||
typeShown: true,
|
||||
typeEditable: true,
|
||||
valueShown: true,
|
||||
serialisable: true
|
||||
},
|
||||
onEditorSave: (evt) => {},
|
||||
edit: {
|
||||
name: null,
|
||||
type: null,
|
||||
value: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
treeview () {
|
||||
|
||||
function sorter (key) {
|
||||
return function λ (a, b) {
|
||||
return a?.[key] > b?.[key]
|
||||
? 1
|
||||
: a?.[key] < b?.[key]
|
||||
? -1
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getType (value) {
|
||||
const t = typeof value;
|
||||
switch (t) {
|
||||
case "symbol":
|
||||
case "string":
|
||||
case "bigint":
|
||||
case "number":
|
||||
case "boolean":
|
||||
case "undefined":
|
||||
return t;
|
||||
case "object":
|
||||
return value === null
|
||||
? "null"
|
||||
: Array.isArray(value)
|
||||
? "array"
|
||||
: t;
|
||||
}
|
||||
}
|
||||
|
||||
function getIcon (type) {
|
||||
switch (type) {
|
||||
case "symbol":
|
||||
return "mdi-symbol";
|
||||
case "string":
|
||||
return "mdi-format-text";
|
||||
case "bigint":
|
||||
return "mdi-numeric";
|
||||
case "number":
|
||||
return "mdi-numeric";
|
||||
case "boolean":
|
||||
return "mdi-checkbox-intermediate-variant";
|
||||
case "undefined":
|
||||
return "mdi-border-none-variant";
|
||||
case "null":
|
||||
return "mdi-null";
|
||||
case "array":
|
||||
return "mdi-list-box-outline";
|
||||
case "object":
|
||||
return "mdi-format-list-bulleted-type";
|
||||
}
|
||||
return "mdi-help";
|
||||
}
|
||||
|
||||
const leaf = ([key, value], parent) => {
|
||||
const id = parent
|
||||
? parent.id+"."+key
|
||||
: key;
|
||||
const name = key;
|
||||
const type = getType(value);
|
||||
const icon = getIcon(type);
|
||||
const isArrayItem = parent?.type == "array";
|
||||
|
||||
const obj = {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
icon,
|
||||
isArrayItem,
|
||||
};
|
||||
|
||||
if (parent) {
|
||||
obj.path = [...parent.path, key];
|
||||
} else {
|
||||
obj.path = [ key ];
|
||||
}
|
||||
|
||||
if (type == "object" || type == "array") {
|
||||
const children = [];
|
||||
for (const child of Object.entries(value)) {
|
||||
children.push(leaf(child, obj));
|
||||
}
|
||||
if (this.sort) {
|
||||
children.sort(sorter(this.sort));
|
||||
}
|
||||
obj.children = children;
|
||||
} else {
|
||||
obj.leaf = true;
|
||||
obj.value = value;
|
||||
/*
|
||||
obj.children = [{
|
||||
id: id+".value",
|
||||
name: String(value)
|
||||
}]
|
||||
*/
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
const rootNode = {
|
||||
id: this.rootId,
|
||||
name: this.name,
|
||||
type: getType(this.value),
|
||||
icon: getIcon(getType(this.value)),
|
||||
children: []
|
||||
};
|
||||
const view = [rootNode];
|
||||
|
||||
if (this.value) {
|
||||
for (const child of Object.entries(this.value)) {
|
||||
rootNode.children.push(leaf(child));
|
||||
}
|
||||
if (this.sort) {
|
||||
rootNode.children.sort(sorter(this.sort));
|
||||
}
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
watch: {
|
||||
treeview () {
|
||||
if (!this.open.includes(this.rootId)) {
|
||||
this.open.push(this.rootId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
openAll (open = true) {
|
||||
const walk = (obj) => {
|
||||
if (obj?.children) {
|
||||
for (const child of obj.children) {
|
||||
walk(child);
|
||||
}
|
||||
if (obj?.id) {
|
||||
this.open.push(obj.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of this.treeview) {
|
||||
walk (item);
|
||||
}
|
||||
},
|
||||
|
||||
itemDelete (item) {
|
||||
const parents = [...item.path];
|
||||
const key = parents.pop();
|
||||
|
||||
if (key) {
|
||||
|
||||
const value = structuredClone(this.value);
|
||||
const obj = parents.length ? deepValue(value, parents) : value;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
obj.splice(key, 1);
|
||||
} else {
|
||||
delete obj[key];
|
||||
}
|
||||
|
||||
this.$emit("input", value);
|
||||
|
||||
} else {
|
||||
|
||||
this.$emit("input", {});
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
itemToggle (item, state) {
|
||||
const parents = [...item.path];
|
||||
const value = structuredClone(this.value);
|
||||
|
||||
if (parents.length) {
|
||||
deepSet(value, parents, state ?? !item.value)
|
||||
} else {
|
||||
value = state ?? !item.value;
|
||||
}
|
||||
|
||||
this.$emit("input", value);
|
||||
},
|
||||
|
||||
itemSet (path, content) {
|
||||
const parents = [...(path??[])];
|
||||
const key = parents.pop();
|
||||
|
||||
if (key !== undefined) {
|
||||
|
||||
const value = structuredClone(this.value);
|
||||
const obj = parents.length ? deepValue(value, parents) : value;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
if (key === null) {
|
||||
obj.push(content);
|
||||
} else {
|
||||
obj[key] = content;
|
||||
}
|
||||
} else {
|
||||
obj[key] = content;
|
||||
}
|
||||
|
||||
this.$emit("input", value);
|
||||
|
||||
} else {
|
||||
this.$emit("input", content);
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
itemAdd (path, content) {
|
||||
let value = structuredClone(this.value);
|
||||
let path_ = [...(path??[])];
|
||||
|
||||
if (path_.length) {
|
||||
try {
|
||||
deepSet(value, path_, content);
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
this.itemSet(path, content);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = content;
|
||||
}
|
||||
|
||||
this.$emit("input", value);
|
||||
},
|
||||
|
||||
itemAddDialog (item, edit=false) {
|
||||
|
||||
if (!this.open.includes(item.id)) {
|
||||
this.open.push(item.id);
|
||||
}
|
||||
|
||||
if (edit) {
|
||||
this.editorReset({
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
value: item.value
|
||||
}, {nameEditable: false});
|
||||
} else {
|
||||
this.editorReset({}, {
|
||||
nameShown: item.type != "array",
|
||||
nameRequired: item.type != "array"
|
||||
});
|
||||
}
|
||||
|
||||
this.onEditorSave = (evt) => {
|
||||
this.editor = false;
|
||||
|
||||
let transformer;
|
||||
switch(this.edit.type) {
|
||||
case "symbol":
|
||||
transformer = Symbol;
|
||||
break;
|
||||
case "string":
|
||||
transformer = String;
|
||||
break;
|
||||
case "bigint":
|
||||
transformer = BigInt;
|
||||
break;
|
||||
case "number":
|
||||
transformer = Number;
|
||||
break;
|
||||
case "boolean":
|
||||
transformer = Boolean;
|
||||
break;
|
||||
case "undefined":
|
||||
transformer = () => { return undefined; };
|
||||
break;
|
||||
case "object":
|
||||
transformer = (v) =>
|
||||
typeof v == "object"
|
||||
? v
|
||||
: (typeof v == "string" && v.length)
|
||||
? JSON.parse(v)
|
||||
: {};
|
||||
break;
|
||||
case "null":
|
||||
transformer = () => null;
|
||||
break;
|
||||
case "array":
|
||||
// FIXME not great
|
||||
transformer = (v) =>
|
||||
Array.isArray(v)
|
||||
? v
|
||||
: [];
|
||||
break;
|
||||
}
|
||||
|
||||
const value = transformer(this.edit.value);
|
||||
|
||||
const path = [...(item.path??[])];
|
||||
|
||||
if (!edit) {
|
||||
if (item.type == "array") {
|
||||
path.push(null);
|
||||
} else {
|
||||
path.push(this.edit.name);
|
||||
}
|
||||
}
|
||||
this.itemAdd(path, value);
|
||||
};
|
||||
this.editor = true;
|
||||
|
||||
},
|
||||
|
||||
XXitemEditDialog (item) {
|
||||
|
||||
this.editorReset({
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
value: item.value}, {nameEditable: false});
|
||||
|
||||
this.onEditorSave = (evt) => {
|
||||
this.editor = false;
|
||||
|
||||
let transformer;
|
||||
switch(this.edit.type) {
|
||||
case "symbol":
|
||||
transformer = Symbol;
|
||||
break;
|
||||
case "string":
|
||||
transformer = String;
|
||||
break;
|
||||
case "bigint":
|
||||
transformer = BigInt;
|
||||
break;
|
||||
case "number":
|
||||
transformer = Number;
|
||||
break;
|
||||
case "boolean":
|
||||
transformer = Boolean;
|
||||
break;
|
||||
case "undefined":
|
||||
transformer = () => { return undefined; };
|
||||
break;
|
||||
case "object":
|
||||
transformer = (v) =>
|
||||
typeof v == "object"
|
||||
? v
|
||||
: (typeof v == "string" && v.length)
|
||||
? JSON.parse(v)
|
||||
: {};
|
||||
break;
|
||||
case "null":
|
||||
transformer = () => null;
|
||||
break;
|
||||
case "array":
|
||||
// FIXME not great
|
||||
transformer = (v) =>
|
||||
Array.isArray(v)
|
||||
? v
|
||||
: [];
|
||||
break;
|
||||
}
|
||||
|
||||
const key = this.edit.name;
|
||||
const value = transformer(this.edit.value);
|
||||
this.itemAdd(item, key, value);
|
||||
}
|
||||
this.editor = true;
|
||||
|
||||
},
|
||||
|
||||
editorReset (values, props) {
|
||||
this.edit = {
|
||||
name: values?.name,
|
||||
type: values?.type,
|
||||
value: values?.value
|
||||
};
|
||||
|
||||
this.editorProperties = {
|
||||
nameShown: props?.nameShown ?? true,
|
||||
nameEditable: props?.nameEditable ?? true,
|
||||
nameRequired: props?.nameRequired ?? true,
|
||||
typeShown: props?.typeShown ?? true,
|
||||
typeEditable: props?.typeEditable ?? true,
|
||||
valueShown: props?.valueShown ?? true,
|
||||
serialisable: props?.serialisable ?? true
|
||||
};
|
||||
},
|
||||
|
||||
editorSave (evt) {
|
||||
this.onEditorSave?.(evt);
|
||||
},
|
||||
|
||||
editorClose () {
|
||||
this.editor = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<v-dialog :value="open" @input="$emit('close')">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
|
||||
<v-text-field v-if="nameShown"
|
||||
label="Name"
|
||||
:disabled="!nameEditable"
|
||||
v-model.sync="value.name"
|
||||
></v-text-field>
|
||||
|
||||
<v-select v-if="typeShown"
|
||||
label="Type"
|
||||
:items="types"
|
||||
:disabled="!typeEditable"
|
||||
v-model.sync="value.type"
|
||||
></v-select>
|
||||
|
||||
<template v-if="valueShown">
|
||||
<v-text-field v-if="value.type == 'number' || value.type == 'bigint'"
|
||||
label="Value"
|
||||
type="number"
|
||||
v-model.sync="value.value"
|
||||
></v-text-field>
|
||||
|
||||
<v-textarea v-else-if="value.type == 'string'"
|
||||
label="Value"
|
||||
v-model.sync="value.value"
|
||||
></v-textarea>
|
||||
|
||||
<v-radio-group v-else-if="value.type == 'boolean'"
|
||||
v-model.sync="value.value"
|
||||
>
|
||||
<v-radio
|
||||
label="true"
|
||||
:value="true"
|
||||
></v-radio>
|
||||
<v-radio
|
||||
label="false"
|
||||
:value="false"
|
||||
></v-radio>
|
||||
</v-radio-group>
|
||||
</template>
|
||||
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
:disabled="!canSave"
|
||||
@click="$emit('save')"
|
||||
>Save</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: "DougalJsonBuilderPropertyDialog",
|
||||
|
||||
props: {
|
||||
open: Boolean,
|
||||
value: Object,
|
||||
nameRequired: {type: Boolean, default: true},
|
||||
nameEditable: Boolean,
|
||||
nameShown: {type: Boolean, default: true},
|
||||
typeEditable: Boolean,
|
||||
typeShown: {type: Boolean, default: true},
|
||||
valueShown: {type: Boolean, default: true},
|
||||
serialisable: {type: Boolean, default: true},
|
||||
allowedTypes: Array
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
//key: null,
|
||||
//type: null,
|
||||
allTypes: [
|
||||
"symbol",
|
||||
"string",
|
||||
"bigint",
|
||||
"number",
|
||||
"boolean",
|
||||
"undefined",
|
||||
"object",
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
serialisableTypes: [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"object",
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
types () {
|
||||
return this.allowedTypes
|
||||
? this.allowedTypes
|
||||
: this.serialisable
|
||||
? this.serialisableTypes
|
||||
: this.allTypes;
|
||||
},
|
||||
|
||||
canSave () {
|
||||
return this.value.type && (this.value.name || this.nameRequired === false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
Reference in New Issue
Block a user