mirror of
https://github.com/Lissy93/dashy.git
synced 2026-06-02 06:44:51 -04:00
✨ New and improved YAML editor, with schema validation
This commit is contained in:
@@ -86,7 +86,7 @@ import { localStorageKeys, modalNames } from '@/utils/config/defaults';
|
||||
import { getUsersLanguage } from '@/utils/config/ConfigHelpers';
|
||||
import ErrorHandler from '@/utils/logging/ErrorHandler';
|
||||
import StoreKeys from '@/utils/StoreMutations';
|
||||
import JsonEditor from '@/components/Configuration/JsonEditor';
|
||||
import { defineAsyncComponent, h } from 'vue';
|
||||
import CustomCssEditor from '@/components/Configuration/CustomCss';
|
||||
import CloudBackupRestore from '@/components/Configuration/CloudBackupRestore';
|
||||
import RebuildApp from '@/components/Configuration/RebuildApp';
|
||||
@@ -105,6 +105,14 @@ import RebuildIcon from '@/assets/interface-icons/application-rebuild.svg';
|
||||
import LanguageIcon from '@/assets/interface-icons/config-language.svg';
|
||||
import IconAbout from '@/assets/interface-icons/application-about.svg';
|
||||
|
||||
const EditorLoading = {
|
||||
render: () => h('p', { class: 'editor-loading-placeholder' }, 'Loading editor…'),
|
||||
};
|
||||
const JsonEditor = defineAsyncComponent({
|
||||
loader: () => import('@/components/Configuration/JsonEditor.vue'),
|
||||
loadingComponent: EditorLoading,
|
||||
});
|
||||
|
||||
export default {
|
||||
name: 'ConfigContainer',
|
||||
data() {
|
||||
@@ -306,12 +314,11 @@ div.code-container {
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@extend .scroll-bar;
|
||||
background: var(--config-settings-background);
|
||||
&.main-tab {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-options-container {
|
||||
@@ -388,4 +395,15 @@ p.small-screen-note {
|
||||
background: var(--config-settings-background) !important;
|
||||
}
|
||||
|
||||
.editor-loading-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 55vh;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--config-settings-color);
|
||||
opacity: var(--dimming-factor);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
<template>
|
||||
<div class="json-editor-outer" v-if="allowViewConfig">
|
||||
<!-- Main JSON editor -->
|
||||
<div class="jsoneditor-container min-box">
|
||||
<textarea
|
||||
class="json-textarea"
|
||||
:value="jsonString"
|
||||
@input="onJsonInput($event.target.value)"
|
||||
/>
|
||||
<!-- Toolbar -->
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-options">
|
||||
<label><input type="checkbox" v-model="wordWrap" /> Wrap</label>
|
||||
<button type="button" class="format-btn" @click="formatDocument">Format</button>
|
||||
</div>
|
||||
<div class="editor-status" :class="isValid ? 'ok' : 'err'">
|
||||
{{ isValid ? $t('config-editor.valid-label') : errorMessages.length + ' issue(s)' }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Options raido, and save button -->
|
||||
<Radio class="save-options"
|
||||
|
||||
<!-- Editor mount point -->
|
||||
<div ref="editorEl" class="cm-container min-box"></div>
|
||||
|
||||
<!-- Save location -->
|
||||
<Radio
|
||||
class="save-options"
|
||||
v-model="saveMode"
|
||||
:label="$t('config-editor.save-location-label')"
|
||||
:options="saveOptions"
|
||||
:initialOption="initialSaveMode"
|
||||
:disabled="!allowWriteToDisk || !allowSaveLocally"
|
||||
/>
|
||||
<!-- Save Buttons -->
|
||||
/>
|
||||
|
||||
<!-- Save / Preview -->
|
||||
<div :class="`btn-container ${!isValid ? 'err' : ''}`">
|
||||
<Button :click="save" :disallow="!allowWriteToDisk && !allowSaveLocally">
|
||||
{{ $t('config-editor.save-button') }}
|
||||
@@ -25,22 +33,31 @@
|
||||
{{ $t('config-editor.preview-button') }}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- List validation warnings -->
|
||||
|
||||
<!-- Diagnostics -->
|
||||
<div class="errors">
|
||||
<ul>
|
||||
<li v-for="(error, index) in errorMessages" :key="index" :class="`type-${error.type}`">
|
||||
{{error.msg}}
|
||||
<li
|
||||
v-for="(e, i) in errorMessages"
|
||||
:key="i"
|
||||
:class="`type-${e.type}`"
|
||||
@click="jumpTo(e)"
|
||||
>
|
||||
<span class="err-loc">L{{ e.line }}</span>
|
||||
{{ e.message }}
|
||||
</li>
|
||||
<li v-if="errorMessages.length < 1" class="type-valid">
|
||||
<li v-if="!errorMessages.length" class="type-valid">
|
||||
{{ $t('config-editor.valid-label') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Information notes -->
|
||||
<p v-if="saveSuccess !== undefined"
|
||||
:class="`response-output status-${saveSuccess ? 'success' : 'fail'}`">
|
||||
{{saveSuccess
|
||||
? $t('config-editor.status-success-msg') : $t('config-editor.status-fail-msg') }}
|
||||
|
||||
<!-- Status / notes -->
|
||||
<p
|
||||
v-if="saveSuccess !== undefined"
|
||||
:class="`response-output status-${saveSuccess ? 'success' : 'fail'}`"
|
||||
>
|
||||
{{ saveSuccess ? $t('config-editor.status-success-msg') : $t('config-editor.status-fail-msg') }}
|
||||
</p>
|
||||
<p v-if="!allowWriteToDisk" class="no-permission-note">
|
||||
{{ $t('config-editor.not-admin-note') }}
|
||||
@@ -56,7 +73,23 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { shallowRef, markRaw } from 'vue';
|
||||
import jsYaml from 'js-yaml';
|
||||
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { linter, lintGutter, forEachDiagnostic } from '@codemirror/lint';
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import {
|
||||
bracketMatching, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting, HighlightStyle,
|
||||
} from '@codemirror/language';
|
||||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
|
||||
import { schemaLinter } from '@/utils/config/schemaLinter';
|
||||
import { schemaHover } from '@/utils/config/schemaHover';
|
||||
import ConfigSavingMixin from '@/mixins/ConfigSaving';
|
||||
import { InfoHandler, InfoKeys } from '@/utils/logging/ErrorHandler';
|
||||
import StoreKeys from '@/utils/StoreMutations';
|
||||
@@ -65,17 +98,129 @@ import Button from '@/components/FormElements/Button';
|
||||
import Radio from '@/components/FormElements/Radio';
|
||||
import AccessError from '@/components/Configuration/AccessError';
|
||||
|
||||
const DUMP_OPTS = { noRefs: true, lineWidth: 120 };
|
||||
|
||||
// CodeMirror theme — uses --background / --background-darker / --primary / --config-settings-color
|
||||
// so the editor tracks the user's active theme (the dedicated --code-editor-* vars are hardcoded light).
|
||||
const MONO_FONT = 'ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Liberation Mono", Menlo, Monaco, Consolas, monospace';
|
||||
|
||||
const dashyTheme = EditorView.theme({
|
||||
'&': {
|
||||
color: 'var(--config-settings-color)',
|
||||
backgroundColor: 'var(--background)',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: MONO_FONT,
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--primary)',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--primary)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'var(--primary-transparent-60)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--background-darker)',
|
||||
color: 'var(--medium-grey)',
|
||||
borderRight: '1px solid var(--transparent-white-10)',
|
||||
},
|
||||
'.cm-activeLine, .cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--transparent-white-10)',
|
||||
},
|
||||
'.cm-foldPlaceholder': {
|
||||
backgroundColor: 'var(--primary-transparent-60)',
|
||||
color: 'var(--config-settings-color)',
|
||||
border: 'none',
|
||||
padding: '0 0.4rem',
|
||||
},
|
||||
'.cm-tooltip, .cm-tooltip-autocomplete, .cm-panels': {
|
||||
backgroundColor: 'var(--background-darker)',
|
||||
color: 'var(--config-settings-color)',
|
||||
border: '1px solid var(--primary)',
|
||||
},
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: 'var(--primary-transparent-60)',
|
||||
},
|
||||
'.cm-searchMatch-selected': {
|
||||
backgroundColor: 'var(--primary)',
|
||||
},
|
||||
'.cm-lint-marker-warning': {
|
||||
color: 'var(--warning)',
|
||||
},
|
||||
'.cm-lint-marker-error': {
|
||||
color: 'var(--danger)',
|
||||
},
|
||||
'.cm-diagnostic': {
|
||||
borderLeft: '3px solid var(--primary)',
|
||||
backgroundColor: 'var(--background-darker)',
|
||||
color: 'var(--config-settings-color)',
|
||||
padding: '0.3rem 0.5rem',
|
||||
},
|
||||
'.cm-diagnostic-warning': {
|
||||
borderLeftColor: 'var(--warning)',
|
||||
},
|
||||
'.cm-diagnostic-error': {
|
||||
borderLeftColor: 'var(--danger)',
|
||||
},
|
||||
'.cm-schema-hover': {
|
||||
maxWidth: '22rem',
|
||||
padding: '0.5rem 0.7rem',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: '1.45',
|
||||
},
|
||||
'.cm-schema-hover .title': {
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--primary)',
|
||||
marginBottom: '0.25rem',
|
||||
},
|
||||
'.cm-schema-hover .desc': {
|
||||
color: 'var(--config-settings-color)',
|
||||
marginBottom: '0.4rem',
|
||||
},
|
||||
'.cm-schema-hover .meta': {
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--medium-grey)',
|
||||
marginTop: '0.15rem',
|
||||
},
|
||||
'.cm-schema-hover .meta .label': {
|
||||
color: 'var(--primary)',
|
||||
fontWeight: 'bold',
|
||||
marginRight: '0.3rem',
|
||||
},
|
||||
'.cm-schema-hover code': {
|
||||
fontFamily: MONO_FONT,
|
||||
fontSize: '0.8rem',
|
||||
padding: '0.05rem 0.25rem',
|
||||
borderRadius: 'var(--curve-factor)',
|
||||
background: 'var(--transparent-white-10)',
|
||||
color: 'var(--config-settings-color)',
|
||||
},
|
||||
}, { dark: true });
|
||||
|
||||
const dashyHighlight = HighlightStyle.define([
|
||||
{ tag: [t.propertyName, t.attributeName, t.definition(t.propertyName)], color: 'var(--info)' },
|
||||
{ tag: t.string, color: 'var(--success)' },
|
||||
{ tag: t.number, color: 'var(--warning)' },
|
||||
{ tag: t.bool, color: 'var(--info)', fontWeight: 'bold' },
|
||||
{ tag: t.null, color: 'var(--medium-grey)', fontStyle: 'italic' },
|
||||
{ tag: t.keyword, color: 'var(--primary)', fontWeight: 'bold' },
|
||||
{ tag: t.comment, color: 'var(--medium-grey)', fontStyle: 'italic' },
|
||||
{ tag: t.operator, color: 'var(--primary)' },
|
||||
{ tag: t.punctuation, color: 'var(--medium-grey)' },
|
||||
{ tag: t.meta, color: 'var(--info)' },
|
||||
{ tag: t.invalid, color: 'var(--danger)' },
|
||||
]);
|
||||
|
||||
export default {
|
||||
name: 'JsonEditor',
|
||||
mixins: [ConfigSavingMixin],
|
||||
components: {
|
||||
Button,
|
||||
Radio,
|
||||
AccessError,
|
||||
},
|
||||
components: { Button, Radio, AccessError },
|
||||
data() {
|
||||
return {
|
||||
jsonData: {},
|
||||
wordWrap: true,
|
||||
errorMessages: [],
|
||||
saveMode: '',
|
||||
saveOptions: [
|
||||
@@ -84,65 +229,149 @@ export default {
|
||||
],
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
view: shallowRef(null),
|
||||
wrapCompartment: markRaw(new Compartment()),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
config() {
|
||||
return this.$store.state.config;
|
||||
},
|
||||
jsonString() {
|
||||
return JSON.stringify(this.jsonData, null, 2);
|
||||
},
|
||||
isValid() {
|
||||
return this.errorMessages.length < 1;
|
||||
},
|
||||
permissions() {
|
||||
// Returns: { allowWriteToDisk, allowSaveLocally, allowViewConfig }
|
||||
return this.$store.getters.permissions;
|
||||
},
|
||||
allowWriteToDisk() {
|
||||
return this.permissions.allowWriteToDisk;
|
||||
},
|
||||
allowSaveLocally() {
|
||||
return this.permissions.allowSaveLocally;
|
||||
},
|
||||
allowViewConfig() {
|
||||
return this.permissions.allowViewConfig;
|
||||
},
|
||||
config() { return this.$store.state.config; },
|
||||
isValid() { return !this.errorMessages.some((e) => e.type === 'error'); },
|
||||
permissions() { return this.$store.getters.permissions; },
|
||||
allowWriteToDisk() { return this.permissions.allowWriteToDisk; },
|
||||
allowSaveLocally() { return this.permissions.allowSaveLocally; },
|
||||
allowViewConfig() { return this.permissions.allowViewConfig; },
|
||||
initialSaveMode() {
|
||||
if (this.allowWriteToDisk) return 'file';
|
||||
if (this.allowSaveLocally) return 'local';
|
||||
return '';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
wordWrap(v) {
|
||||
if (!this.view) return;
|
||||
this.view.dispatch({
|
||||
effects: this.wrapCompartment.reconfigure(v ? EditorView.lineWrapping : []),
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const jsonData = { ...this.config };
|
||||
jsonData.sections = (jsonData.sections || []).map(({ filteredItems, ...section }) => section);
|
||||
if (!jsonData.pageInfo) jsonData.pageInfo = { title: 'Dashy' };
|
||||
this.jsonData = jsonData;
|
||||
if (!this.allowWriteToDisk) this.saveMode = 'local';
|
||||
this.createEditor();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.view) {
|
||||
this.view.destroy();
|
||||
this.view = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onJsonInput(value) {
|
||||
initialText() {
|
||||
const data = { ...this.config };
|
||||
data.sections = (data.sections || []).map(({ filteredItems, ...s }) => s);
|
||||
if (!data.pageInfo) data.pageInfo = { title: 'Dashy' };
|
||||
return jsYaml.dump(data, DUMP_OPTS);
|
||||
},
|
||||
createEditor() {
|
||||
const updateListener = EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged || u.transactions.some((tr) => tr.effects.length)) {
|
||||
this.syncDiagnostics();
|
||||
}
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: this.initialText(),
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightActiveLine(),
|
||||
foldGutter(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(dashyHighlight, { fallback: true }),
|
||||
highlightSelectionMatches(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
indentWithTab,
|
||||
]),
|
||||
yaml(),
|
||||
lintGutter(),
|
||||
linter(schemaLinter, { delay: 300 }),
|
||||
schemaHover,
|
||||
this.wrapCompartment.of(this.wordWrap ? EditorView.lineWrapping : []),
|
||||
dashyTheme,
|
||||
updateListener,
|
||||
],
|
||||
});
|
||||
|
||||
this.view = markRaw(new EditorView({ state, parent: this.$refs.editorEl }));
|
||||
this.syncDiagnostics();
|
||||
},
|
||||
syncDiagnostics() {
|
||||
if (!this.view) return;
|
||||
const next = [];
|
||||
forEachDiagnostic(this.view.state, (d, from) => {
|
||||
next.push({
|
||||
type: d.severity === 'error' ? 'error' : 'warning',
|
||||
message: d.message,
|
||||
line: this.view.state.doc.lineAt(from).number,
|
||||
from,
|
||||
});
|
||||
});
|
||||
// Skip reassignment when nothing changed, to avoid Vue re-renders on
|
||||
// every keystroke while the lint result is identical.
|
||||
const same = next.length === this.errorMessages.length
|
||||
&& next.every((e, i) => e.from === this.errorMessages[i].from
|
||||
&& e.message === this.errorMessages[i].message);
|
||||
if (!same) this.errorMessages = next;
|
||||
},
|
||||
formatDocument() {
|
||||
if (!this.view) return;
|
||||
try {
|
||||
this.jsonData = JSON.parse(value);
|
||||
this.errorMessages = [];
|
||||
const data = jsYaml.load(this.view.state.doc.toString());
|
||||
const formatted = jsYaml.dump(data ?? {}, DUMP_OPTS);
|
||||
this.view.dispatch({
|
||||
changes: { from: 0, to: this.view.state.doc.length, insert: formatted },
|
||||
});
|
||||
} catch (e) {
|
||||
this.errorMessages = [{ type: 'error', msg: e.message }];
|
||||
this.$toast.error(`Cannot format: ${e.message}`);
|
||||
}
|
||||
},
|
||||
jumpTo(err) {
|
||||
if (!this.view) return;
|
||||
this.view.focus();
|
||||
this.view.dispatch({
|
||||
selection: { anchor: err.from, head: err.from },
|
||||
effects: EditorView.scrollIntoView(err.from, { y: 'center' }),
|
||||
});
|
||||
},
|
||||
parseCurrent() {
|
||||
const text = this.view?.state.doc.toString() ?? '';
|
||||
try {
|
||||
return jsYaml.load(text);
|
||||
} catch (e) {
|
||||
this.$toast.error(e.message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/* Calls appropriate save method, based on save-type radio selected */
|
||||
save() {
|
||||
if (this.saveMode === 'local' || !this.allowWriteToDisk) {
|
||||
this.saveLocally();
|
||||
} else if (this.saveMode === 'file') {
|
||||
this.writeToDisk();
|
||||
} else {
|
||||
this.$toast.error(this.$t('config-editor.error-msg-save-mode'));
|
||||
}
|
||||
const data = this.parseCurrent();
|
||||
if (data == null) return;
|
||||
if (this.saveMode === 'local' || !this.allowWriteToDisk) this.saveLocally(data);
|
||||
else if (this.saveMode === 'file') this.writeToDisk(data);
|
||||
else this.$toast.error(this.$t('config-editor.error-msg-save-mode'));
|
||||
},
|
||||
/* Applies changes to the local state, begins edit mode and closes modal */
|
||||
startPreview() {
|
||||
const data = this.parseCurrent();
|
||||
if (data == null) return;
|
||||
InfoHandler('Applying changes to local state...', InfoKeys.RAW_EDITOR);
|
||||
const data = this.jsonData;
|
||||
this.$store.commit(StoreKeys.SET_APP_CONFIG, data.appConfig);
|
||||
this.$store.commit(StoreKeys.SET_PAGE_INFO, data.pageInfo);
|
||||
this.$store.commit(StoreKeys.SET_SECTIONS, data.sections);
|
||||
@@ -150,49 +379,16 @@ export default {
|
||||
this.$store.commit(StoreKeys.SET_EDIT_MODE, true);
|
||||
this.$modal.hide(modalNames.CONF_EDITOR);
|
||||
},
|
||||
writeToDisk() {
|
||||
const newData = this.jsonData;
|
||||
this.writeConfigToDisk(newData);
|
||||
// this.$store.commit(StoreKeys.SET_APP_CONFIG, newData.appConfig);
|
||||
this.$store.commit(StoreKeys.SET_PAGE_INFO, newData.pageInfo);
|
||||
this.$store.commit(StoreKeys.SET_SECTIONS, newData.sections);
|
||||
writeToDisk(data) {
|
||||
this.writeConfigToDisk(data);
|
||||
this.$store.commit(StoreKeys.SET_PAGE_INFO, data.pageInfo);
|
||||
this.$store.commit(StoreKeys.SET_SECTIONS, data.sections);
|
||||
},
|
||||
saveLocally() {
|
||||
saveLocally(data) {
|
||||
const msg = this.$t('interactive-editor.menu.save-locally-warning');
|
||||
const youSure = confirm(msg); // eslint-disable-line no-alert, no-restricted-globals
|
||||
if (youSure) {
|
||||
this.saveConfigLocally(this.jsonData);
|
||||
}
|
||||
// eslint-disable-next-line no-alert, no-restricted-globals
|
||||
if (confirm(msg)) this.saveConfigLocally(data);
|
||||
},
|
||||
/* Convert error messages into readable format for UI */
|
||||
validationErrors(errors) {
|
||||
const errorMessages = [];
|
||||
errors.forEach((error) => {
|
||||
switch (error.type) {
|
||||
case 'validation':
|
||||
errorMessages.push({
|
||||
type: 'validation',
|
||||
msg: `${this.$t('config-editor.warning-msg-validation')}: `
|
||||
+ `${(error.error || error).dataPath} ${(error.error || error).message}`,
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
errorMessages.push({
|
||||
type: 'parse',
|
||||
msg: error.message,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
errorMessages.push({
|
||||
type: 'editor',
|
||||
msg: this.$t('config-editor.error-msg-bad-json'),
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
this.errorMessages = errorMessages;
|
||||
},
|
||||
/* Shows toast message */
|
||||
showToast(message, success) {
|
||||
this.$toast[success ? 'success' : 'error'](message);
|
||||
},
|
||||
@@ -206,12 +402,81 @@ export default {
|
||||
.json-editor-outer {
|
||||
text-align: center;
|
||||
}
|
||||
p.note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--medium-grey);
|
||||
margin: 0.2rem;
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--background-darker);
|
||||
border-bottom: 1px solid var(--config-settings-background);
|
||||
|
||||
.editor-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--config-settings-color);
|
||||
font-size: 0.85rem;
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.format-btn {
|
||||
background: transparent;
|
||||
color: var(--config-settings-color);
|
||||
border: 1px solid var(--config-settings-color);
|
||||
border-radius: var(--curve-factor);
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--config-settings-color);
|
||||
color: var(--background-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-status {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--curve-factor);
|
||||
|
||||
&.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
&.err {
|
||||
color: var(--danger);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
p.errors {
|
||||
|
||||
// Structural only — colors and font live in the CodeMirror theme.
|
||||
.cm-container.min-box {
|
||||
height: 58vh;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--curve-factor);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.errors {
|
||||
text-align: left;
|
||||
margin: 0.5rem auto;
|
||||
width: 95%;
|
||||
@@ -219,38 +484,75 @@ p.errors {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
li {
|
||||
&.type-validation {
|
||||
color: var(--warning);
|
||||
&::before { content: "⚠️"; }
|
||||
}
|
||||
li {
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: var(--curve-factor);
|
||||
|
||||
&:hover:not(.type-valid) {
|
||||
background: var(--transparent-white-10);
|
||||
}
|
||||
|
||||
.err-loc {
|
||||
display: inline-block;
|
||||
min-width: 2.5rem;
|
||||
margin-right: 0.4rem;
|
||||
color: var(--medium-grey);
|
||||
}
|
||||
|
||||
&.type-warning {
|
||||
color: var(--warning);
|
||||
|
||||
&::before {
|
||||
content: "⚠️ ";
|
||||
}
|
||||
&.type-parse {
|
||||
color: var(--danger);
|
||||
&::before { content: "❌"; }
|
||||
}
|
||||
|
||||
&.type-error {
|
||||
color: var(--danger);
|
||||
|
||||
&::before {
|
||||
content: "❌ ";
|
||||
}
|
||||
&.type-valid {
|
||||
color: var(--success);
|
||||
&::before { content: "✅"; }
|
||||
}
|
||||
|
||||
&.type-valid {
|
||||
color: var(--success);
|
||||
cursor: default;
|
||||
|
||||
&::before {
|
||||
content: "✅ ";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.response-output {
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
margin: 0.5rem auto;
|
||||
width: 95%;
|
||||
color: var(--config-settings-color);
|
||||
|
||||
&.status-success {
|
||||
font-weight: bold;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
&.status-fail {
|
||||
font-weight: bold;
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
p.note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--medium-grey);
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
p.no-permission-note {
|
||||
color: var(--warning);
|
||||
}
|
||||
@@ -260,7 +562,7 @@ p.no-permission-note {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.25rem;
|
||||
font-size: 1.2rem;
|
||||
background: var(--config-settings-background);
|
||||
@@ -291,41 +593,23 @@ div.save-options.radio-container {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 2px solid var(--config-settings-background);
|
||||
background: var(--code-editor-background);
|
||||
background: var(--background-darker);
|
||||
label.radio-label {
|
||||
font-size: 1rem;
|
||||
flex-grow: revert;
|
||||
flex-basis: revert;
|
||||
color: var(--code-editor-color);
|
||||
color: var(--config-settings-color);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.radio-wrapper {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
justify-content: space-around;
|
||||
background: var(--code-editor-background);
|
||||
color: var(--code-editor-color);
|
||||
background: var(--background-darker);
|
||||
color: var(--config-settings-color);
|
||||
.radio-option:hover:not(.wrap-disabled) {
|
||||
border: 1px solid var(--code-editor-color);
|
||||
border: 1px solid var(--config-settings-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.jsoneditor-container.min-box {
|
||||
height: 58vh;
|
||||
}
|
||||
|
||||
textarea.json-textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--curve-factor);
|
||||
background: var(--code-editor-background);
|
||||
color: var(--code-editor-color);
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem;
|
||||
resize: vertical;
|
||||
tab-size: 2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -84,7 +84,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tabs-component {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab__pagination {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--config-settings-color);
|
||||
@@ -123,6 +130,9 @@ export default {
|
||||
}
|
||||
|
||||
.tabs__content {
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
82
src/utils/config/schemaHover.js
Normal file
82
src/utils/config/schemaHover.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* An extension for CodeMirror 6,
|
||||
* Shows schema info as tooltips on attribute names
|
||||
* Like title, description, type, allowed enum values, etc.
|
||||
*/
|
||||
import { hoverTooltip } from '@codemirror/view';
|
||||
import { parseDocument } from 'yaml';
|
||||
|
||||
import schema from './ConfigSchema.json';
|
||||
import { yamlPathAtOffset, schemaAt } from './schemaPath';
|
||||
|
||||
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[c]));
|
||||
|
||||
const describeType = (s) => {
|
||||
if (Array.isArray(s.type)) return s.type.join(' | ');
|
||||
if (s.type) return s.type;
|
||||
if (s.properties) return 'object';
|
||||
if (s.items) return 'array';
|
||||
return null;
|
||||
};
|
||||
|
||||
// Returns HTML for a tooltip bod, or null if the schema has nothing worth saying
|
||||
const renderBody = (s) => {
|
||||
if (!s) return null;
|
||||
const rows = [];
|
||||
|
||||
if (s.title) {
|
||||
rows.push(`<div class="title">${escapeHtml(s.title)}</div>`);
|
||||
}
|
||||
if (s.description) {
|
||||
rows.push(`<div class="desc">${escapeHtml(s.description)}</div>`);
|
||||
}
|
||||
|
||||
const type = describeType(s);
|
||||
if (type) {
|
||||
rows.push(`<div class="meta"><span class="label">Type</span> <code>${escapeHtml(type)}</code></div>`);
|
||||
}
|
||||
if (Array.isArray(s.enum)) {
|
||||
const opts = s.enum.map((v) => `<code>${escapeHtml(v)}</code>`).join(', ');
|
||||
rows.push(`<div class="meta"><span class="label">Allowed</span> ${opts}</div>`);
|
||||
}
|
||||
if (s.default !== undefined) {
|
||||
rows.push(`<div class="meta"><span class="label">Default</span> <code>${escapeHtml(JSON.stringify(s.default))}</code></div>`);
|
||||
}
|
||||
|
||||
return rows.length ? rows.join('') : null;
|
||||
};
|
||||
|
||||
export const schemaHover = hoverTooltip((view, pos) => {
|
||||
const text = view.state.doc.toString();
|
||||
if (!text.trim()) return null;
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = parseDocument(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = yamlPathAtOffset(doc, pos);
|
||||
if (!path.length) return null;
|
||||
|
||||
const body = renderBody(schemaAt(schema, path));
|
||||
if (!body) return null;
|
||||
|
||||
return {
|
||||
pos,
|
||||
above: true,
|
||||
create() {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-schema-hover';
|
||||
dom.innerHTML = body;
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
}, { hideOnChange: true, hoverTime: 300 });
|
||||
93
src/utils/config/schemaLinter.js
Normal file
93
src/utils/config/schemaLinter.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* This is a custom linter plugin for CodeMirror (6),
|
||||
* used when user editing config as code via the config modal
|
||||
* Does:
|
||||
* 1. YAML syntax checker, vie js-yaml
|
||||
* 2. Ajv schema validator using our ConfigSchema.json
|
||||
*/
|
||||
import jsYaml from 'js-yaml';
|
||||
import { parseDocument } from 'yaml';
|
||||
import Ajv from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
|
||||
import schema from './ConfigSchema.json';
|
||||
import { pointerToPath, yamlNodeAt } from './schemaPath';
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
||||
|
||||
// Turn Ajv's raw errors into user readable strings
|
||||
const formatError = (err) => {
|
||||
const { keyword, params, message } = err;
|
||||
switch (keyword) {
|
||||
case 'enum':
|
||||
return `must be one of: ${(params.allowedValues || []).join(', ')}`;
|
||||
case 'const':
|
||||
return `must be: ${JSON.stringify(params.allowedValue)}`;
|
||||
case 'required':
|
||||
return `missing required field: ${params.missingProperty}`;
|
||||
case 'additionalProperties':
|
||||
return `unknown property: ${params.additionalProperty}`;
|
||||
case 'type':
|
||||
return `must be ${params.type}`;
|
||||
case 'minLength':
|
||||
return `must be at least ${params.limit} characters`;
|
||||
case 'maxLength':
|
||||
return `must be at most ${params.limit} characters`;
|
||||
case 'minimum':
|
||||
case 'maximum':
|
||||
return `must be ${keyword === 'minimum' ? '≥' : '≤'} ${params.limit}`;
|
||||
case 'pattern':
|
||||
return `must match pattern ${params.pattern}`;
|
||||
case 'format':
|
||||
return `must be a valid ${params.format}`;
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
};
|
||||
|
||||
const prefix = (instancePath) => (instancePath ? `${instancePath} ` : '');
|
||||
|
||||
export function schemaLinter(view) {
|
||||
const text = view.state.doc.toString();
|
||||
if (!text.trim()) return [];
|
||||
const docLen = view.state.doc.length;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = jsYaml.load(text);
|
||||
} catch (e) {
|
||||
const line = e.mark?.line ?? 0;
|
||||
const col = e.mark?.column ?? 0;
|
||||
const lineObj = view.state.doc.line(clamp(line + 1, 1, view.state.doc.lines));
|
||||
const from = clamp(lineObj.from + col, lineObj.from, lineObj.to);
|
||||
return [{
|
||||
from,
|
||||
to: clamp(from + 1, from, docLen),
|
||||
severity: 'error',
|
||||
source: 'yaml',
|
||||
message: e.reason || e.message,
|
||||
}];
|
||||
}
|
||||
|
||||
if (validate(data)) return [];
|
||||
|
||||
const doc = parseDocument(text);
|
||||
return (validate.errors || []).map((err) => {
|
||||
const path = pointerToPath(err.instancePath);
|
||||
const node = yamlNodeAt(doc.contents, path);
|
||||
const [start, end] = node?.range ?? [0, 0];
|
||||
const from = clamp(start, 0, docLen);
|
||||
const to = clamp(end || start + 1, from + 1, docLen);
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
severity: 'warning',
|
||||
source: 'schema',
|
||||
message: `${prefix(err.instancePath)}${formatError(err)}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
94
src/utils/config/schemaPath.js
Normal file
94
src/utils/config/schemaPath.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Shared YAML + JSON-Schema walkers for the linter and the config tooltip extension
|
||||
*/
|
||||
import { isMap, isSeq } from 'yaml';
|
||||
|
||||
const decodeToken = (s) => s.replace(/~1/g, '/').replace(/~0/g, '~');
|
||||
|
||||
// JSON Pointer (Ajv's instancePath, e.g. "/sections/0/items/2") -> path parts.
|
||||
export const pointerToPath = (pointer) => (pointer
|
||||
? pointer.split('/').slice(1).map(decodeToken)
|
||||
: []);
|
||||
|
||||
// Walk a parsed YAML Document by path parts, returning the target Node or null.
|
||||
export const yamlNodeAt = (root, parts) => {
|
||||
let node = root;
|
||||
for (const key of parts) {
|
||||
if (!node) return null;
|
||||
if (isMap(node)) {
|
||||
const pair = node.items.find((p) => {
|
||||
const k = p.key && 'value' in p.key ? p.key.value : p.key;
|
||||
return String(k) === String(key);
|
||||
});
|
||||
node = pair ? pair.value : null;
|
||||
} else if (isSeq(node)) {
|
||||
node = node.items[Number(key)] ?? null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
// yaml Nodes expose range as [start, valueEnd, nodeEnd]; fall back if shorter.
|
||||
const nodeEnd = (r) => (r ? (r[2] ?? r[1] ?? r[0]) : -1);
|
||||
const inside = (r, offset) => !!r && offset >= r[0] && offset <= nodeEnd(r);
|
||||
|
||||
// Given a parsed YAML Document and a cursor offset, return the path from
|
||||
// document root to the smallest containing node (e.g. ['sections', 0, 'url']).
|
||||
// Returns [] when the cursor isn't inside any addressable node.
|
||||
export const yamlPathAtOffset = (doc, offset) => {
|
||||
const path = [];
|
||||
let node = doc?.contents ?? null;
|
||||
|
||||
while (node) {
|
||||
if (isMap(node)) {
|
||||
const pair = node.items.find((p) => inside(p.key?.range, offset) || inside(p.value?.range, offset));
|
||||
if (!pair) break;
|
||||
const keyName = pair.key && 'value' in pair.key ? pair.key.value : pair.key;
|
||||
path.push(String(keyName));
|
||||
// Cursor on the key but not the value — stop here rather than descending.
|
||||
if (inside(pair.key?.range, offset) && !inside(pair.value?.range, offset)) break;
|
||||
node = pair.value;
|
||||
} else if (isSeq(node)) {
|
||||
const idx = node.items.findIndex((it) => inside(it?.range, offset));
|
||||
if (idx < 0) break;
|
||||
path.push(idx);
|
||||
node = node.items[idx];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
// Walk a JSON Schema by path parts, resolving `properties`, `items`,
|
||||
// `patternProperties` and simple `oneOf`/`anyOf`. Returns the sub-schema at
|
||||
// the path, or null if the schema doesn't describe that location.
|
||||
export const schemaAt = (schema, parts) => {
|
||||
const pickBranch = (s) => {
|
||||
if (!s) return s;
|
||||
if (Array.isArray(s.oneOf) && s.oneOf.length) return s.oneOf[0];
|
||||
if (Array.isArray(s.anyOf) && s.anyOf.length) return s.anyOf[0];
|
||||
return s;
|
||||
};
|
||||
|
||||
let cur = pickBranch(schema);
|
||||
for (const key of parts) {
|
||||
if (!cur) return null;
|
||||
if (cur.items) {
|
||||
cur = pickBranch(cur.items);
|
||||
} else if (cur.properties && Object.prototype.hasOwnProperty.call(cur.properties, key)) {
|
||||
cur = pickBranch(cur.properties[key]);
|
||||
} else if (cur.patternProperties) {
|
||||
const entry = Object.entries(cur.patternProperties)
|
||||
.find(([pat]) => new RegExp(pat).test(String(key)));
|
||||
cur = entry ? pickBranch(entry[1]) : null;
|
||||
} else if (cur.additionalProperties && typeof cur.additionalProperties === 'object') {
|
||||
cur = pickBranch(cur.additionalProperties);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
Reference in New Issue
Block a user