feat: New runner UI
This commit is contained in:
@@ -1,49 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watchEffect} from "vue";
|
||||
import {useEventListener} from "@vueuse/core";
|
||||
import {computedAsync, useEventListener} from "@vueuse/core";
|
||||
import log from "loglevel";
|
||||
import {ScriptEditor, useRuleBookStore} from "@/rules";
|
||||
import {PlusIcon, TrashIcon} from "@heroicons/vue/24/outline";
|
||||
import {isMain, Ledger, LedgerSelect, systemLedger, useLedgersStore} from "@/ledger";
|
||||
import {useCharactersStore} from "@/characters";
|
||||
import {CorporationLabel, useCorporationsStore} from "@/corporations";
|
||||
import {RuleScriptRequest} from "@/generated/mammon";
|
||||
import {toast} from "@/toast";
|
||||
|
||||
type Binding = { ref: string; ledger: Ledger };
|
||||
type LedgerScopeEntry = { ref: string; ledger: Ledger };
|
||||
type CharacterScopeEntry = { ref: string; characterId: number };
|
||||
type CorporationScopeEntry = { ref: string; corporationId: number };
|
||||
type WalletDivisionScopeEntry = { ref: string; corporationId: number; division: number };
|
||||
|
||||
interface ScriptDraft {
|
||||
name: string;
|
||||
script: string;
|
||||
ledgers: LedgerScopeEntry[];
|
||||
characters: CharacterScopeEntry[];
|
||||
corporations: CorporationScopeEntry[];
|
||||
walletDivisions: WalletDivisionScopeEntry[];
|
||||
}
|
||||
|
||||
const ruleBookStore = useRuleBookStore();
|
||||
const ledgersStore = useLedgersStore();
|
||||
const charactersStore = useCharactersStore();
|
||||
const corporationsStore = useCorporationsStore();
|
||||
|
||||
const ledgersToUse = computed(() => [systemLedger, ...ledgersStore.ledgers.filter(isMain)]);
|
||||
|
||||
const bindings = ref<Binding[]>([]);
|
||||
const script = ref<string>('');
|
||||
|
||||
const ledgerRefs = computed<string[]>(() => bindings.value.map(b => b.ref));
|
||||
const scripts = ref<ScriptDraft[]>([]);
|
||||
const selectedIndex = ref(0);
|
||||
const selected = computed<ScriptDraft | undefined>(() => scripts.value[selectedIndex.value]);
|
||||
|
||||
watchEffect(() => {
|
||||
const ruleBook = ruleBookStore.ruleBook;
|
||||
|
||||
script.value = ruleBook?.script ?? '';
|
||||
bindings.value = Object.entries(ruleBook?.bindings ?? {})
|
||||
.map(([ref, id]) => ({ref, ledger: ledgersToUse.value.find(l => l.ledgerId === id) ?? systemLedger}));
|
||||
scripts.value = (ruleBook?.scripts ?? []).map(script => ({
|
||||
name: script.name,
|
||||
script: script.script,
|
||||
ledgers: Object.entries(script.scope.ledgers).map(([ref, id]) => ({ref, ledger: ledgersToUse.value.find(l => l.ledgerId === id) ?? systemLedger})),
|
||||
characters: Object.entries(script.scope.characters).map(([ref, characterId]) => ({ref, characterId})),
|
||||
corporations: Object.entries(script.scope.corporations).map(([ref, corporationId]) => ({ref, corporationId})),
|
||||
walletDivisions: Object.entries(script.scope.walletDivisions).map(([ref, target]) => ({ref, corporationId: target.corporationId, division: target.division})),
|
||||
}));
|
||||
selectedIndex.value = 0;
|
||||
log.info('Loaded rule book:', ruleBook);
|
||||
});
|
||||
|
||||
const addBinding = () => {
|
||||
bindings.value = [...bindings.value, {ref: '', ledger: systemLedger}];
|
||||
const editorScope = computed(() => selected.value ? {
|
||||
ledgers: selected.value.ledgers.map(entry => entry.ref),
|
||||
characters: selected.value.characters.map(entry => entry.ref),
|
||||
corporations: selected.value.corporations.map(entry => entry.ref),
|
||||
walletDivisions: selected.value.walletDivisions.map(entry => entry.ref),
|
||||
} : undefined);
|
||||
|
||||
const scriptText = computed<string>({
|
||||
get: () => selected.value?.script ?? '',
|
||||
set: value => {
|
||||
if (selected.value) {
|
||||
selected.value.script = value;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const addScript = () => {
|
||||
scripts.value = [...scripts.value, {name: '', script: '', ledgers: [], characters: [], corporations: [], walletDivisions: []}];
|
||||
selectedIndex.value = scripts.value.length - 1;
|
||||
};
|
||||
|
||||
const removeBinding = (index: number) => {
|
||||
bindings.value = bindings.value.toSpliced(index, 1);
|
||||
const removeScript = (index: number) => {
|
||||
scripts.value = scripts.value.toSpliced(index, 1);
|
||||
if (selectedIndex.value >= scripts.value.length) {
|
||||
selectedIndex.value = Math.max(0, scripts.value.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => ruleBookStore.update({
|
||||
bindings: Object.fromEntries(
|
||||
bindings.value
|
||||
.filter(b => b.ref)
|
||||
.map(b => [b.ref, b.ledger.ledgerId])
|
||||
),
|
||||
script: script.value
|
||||
})
|
||||
const addLedgerScope = () => selected.value?.ledgers.push({ref: '', ledger: systemLedger});
|
||||
const removeLedgerScope = (index: number) => { if (selected.value) selected.value.ledgers = selected.value.ledgers.toSpliced(index, 1); };
|
||||
|
||||
const addCharacterScope = () => selected.value?.characters.push({ref: '', characterId: charactersStore.characters[0]?.characterId ?? 0});
|
||||
const removeCharacterScope = (index: number) => { if (selected.value) selected.value.characters = selected.value.characters.toSpliced(index, 1); };
|
||||
|
||||
const addCorporationScope = () => selected.value?.corporations.push({ref: '', corporationId: 0});
|
||||
const removeCorporationScope = (index: number) => { if (selected.value) selected.value.corporations = selected.value.corporations.toSpliced(index, 1); };
|
||||
|
||||
const addWalletDivisionScope = () => selected.value?.walletDivisions.push({ref: '', corporationId: 0, division: 1});
|
||||
const removeWalletDivisionScope = (index: number) => { if (selected.value) selected.value.walletDivisions = selected.value.walletDivisions.toSpliced(index, 1); };
|
||||
|
||||
const resolvedCorporation = (corporationId: number) => computedAsync(async () => corporationId ? await corporationsStore.findById(corporationId) : undefined, undefined);
|
||||
|
||||
const save = () => ruleBookStore.update(
|
||||
scripts.value
|
||||
.filter(s => s.name)
|
||||
.map((s): RuleScriptRequest => ({
|
||||
name: s.name,
|
||||
script: s.script,
|
||||
scope: {
|
||||
ledgers: Object.fromEntries(s.ledgers.filter(entry => entry.ref).map(entry => [entry.ref, entry.ledger.ledgerId])),
|
||||
characters: Object.fromEntries(s.characters.filter(entry => entry.ref).map(entry => [entry.ref, entry.characterId])),
|
||||
corporations: Object.fromEntries(s.corporations.filter(entry => entry.ref).map(entry => [entry.ref, entry.corporationId])),
|
||||
walletDivisions: Object.fromEntries(s.walletDivisions.filter(entry => entry.ref).map(entry => [entry.ref, {corporationId: entry.corporationId, division: entry.division}])),
|
||||
},
|
||||
}))
|
||||
)
|
||||
.then(() => toast.success('Rule book saved'));
|
||||
|
||||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
@@ -55,28 +117,103 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col mb-2 mt-4 h-[calc(100vh-4.5rem)]">
|
||||
<div class="flex flex-col grow min-h-0">
|
||||
<div class="flex mb-2 mt-4 h-[calc(100vh-4.5rem)]">
|
||||
<div class="flex flex-col me-4 w-48 shrink-0">
|
||||
<div class="grow overflow-y-auto">
|
||||
<div v-for="(script, index) in scripts" :key="index"
|
||||
class="flex items-center mb-1 px-1 py-0.5 cursor-pointer"
|
||||
:class="{'bg-neutral-700': index === selectedIndex}"
|
||||
@click="selectedIndex = index">
|
||||
<span class="grow truncate">{{ script.name || '(unnamed)' }}</span>
|
||||
<button class="btn-icon text-amber-700 hover:text-amber-600" @click.stop="removeScript(index)"><TrashIcon /></button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon mt-2" @click="addScript"><PlusIcon /> Add script</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="flex flex-col grow min-h-0">
|
||||
<div class="border-b-1 mb-2">
|
||||
Name: <input class="me-1" type="text" v-model="selected.name" />
|
||||
</div>
|
||||
|
||||
<div class="border-b-1">
|
||||
Ledger Bindings:
|
||||
<div class="flex flex-wrap items-center mt-2">
|
||||
<div class="flex items-center mb-2 me-2" v-for="(binding, index) in bindings" :key="index">
|
||||
<input class="me-1" type="text" v-model="binding.ref" />
|
||||
<span class="me-1">:</span>
|
||||
<LedgerSelect class="me-1" :ledgers="ledgersToUse" v-model="binding.ledger" />
|
||||
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeBinding(index)"><TrashIcon /></button>
|
||||
Scopes:
|
||||
<div class="ms-4 mt-2">
|
||||
Ledgers:
|
||||
<div class="flex flex-wrap items-center mt-2">
|
||||
<div class="flex items-center mb-2 me-2" v-for="(entry, index) in selected.ledgers" :key="index">
|
||||
<input class="me-1" type="text" v-model="entry.ref" />
|
||||
<span class="me-1">:</span>
|
||||
<LedgerSelect class="me-1" :ledgers="ledgersToUse" v-model="entry.ledger" />
|
||||
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeLedgerScope(index)"><TrashIcon /></button>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<button class="btn-icon" @click="addLedgerScope"><PlusIcon /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<button class="btn-icon" @click="addBinding"><PlusIcon /></button>
|
||||
</div>
|
||||
|
||||
<div class="ms-4 mt-2">
|
||||
Characters:
|
||||
<div class="flex flex-wrap items-center mt-2">
|
||||
<div class="flex items-center mb-2 me-2" v-for="(entry, index) in selected.characters" :key="index">
|
||||
<input class="me-1" type="text" v-model="entry.ref" />
|
||||
<span class="me-1">:</span>
|
||||
<select class="me-1" v-model="entry.characterId">
|
||||
<option v-for="character in charactersStore.characters" :key="character.characterId" :value="character.characterId">{{ character.name }}</option>
|
||||
</select>
|
||||
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeCharacterScope(index)"><TrashIcon /></button>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<button class="btn-icon" @click="addCharacterScope"><PlusIcon /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-4 mt-2">
|
||||
Corporations:
|
||||
<div class="flex flex-wrap items-center mt-2">
|
||||
<div class="flex items-center mb-2 me-2" v-for="(entry, index) in selected.corporations" :key="index">
|
||||
<input class="me-1" type="text" v-model="entry.ref" />
|
||||
<span class="me-1">:</span>
|
||||
<input class="me-1 w-32" type="number" v-model.number="entry.corporationId" placeholder="Corporation ID" />
|
||||
<CorporationLabel class="me-1" :corporation-id="entry.corporationId" :corporation="resolvedCorporation(entry.corporationId).value" :size="16" v-if="entry.corporationId" />
|
||||
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeCorporationScope(index)"><TrashIcon /></button>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<button class="btn-icon" @click="addCorporationScope"><PlusIcon /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-4 mt-2 mb-2">
|
||||
Wallet Divisions:
|
||||
<div class="flex flex-wrap items-center mt-2">
|
||||
<div class="flex items-center mb-2 me-2" v-for="(entry, index) in selected.walletDivisions" :key="index">
|
||||
<input class="me-1" type="text" v-model="entry.ref" />
|
||||
<span class="me-1">:</span>
|
||||
<input class="me-1 w-32" type="number" v-model.number="entry.corporationId" placeholder="Corporation ID" />
|
||||
<input class="me-1 w-16" type="number" min="1" max="7" v-model.number="entry.division" placeholder="Division" />
|
||||
<CorporationLabel class="me-1" :corporation-id="entry.corporationId" :corporation="resolvedCorporation(entry.corporationId).value" :division="entry.division" :size="16" v-if="entry.corporationId" />
|
||||
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeWalletDivisionScope(index)"><TrashIcon /></button>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<button class="btn-icon" @click="addWalletDivisionScope"><PlusIcon /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col grow min-h-0 border-b-1">
|
||||
Script:
|
||||
<ScriptEditor class="mt-2 mb-2" v-model="script" :ledgerRefs="ledgerRefs" />
|
||||
<ScriptEditor class="mt-2 mb-2" v-model="scriptText" :scope="editorScope" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 justify-end flex">
|
||||
<div v-else class="flex grow items-center justify-center text-neutral-500">
|
||||
No scripts yet
|
||||
</div>
|
||||
|
||||
<div class="mt-2 justify-end flex" v-if="selected">
|
||||
<div>
|
||||
<button @click="save">Save</button>
|
||||
</div>
|
||||
|
||||
+27
-13
@@ -22,27 +22,41 @@ const loadScriptDefinitions = async () => {
|
||||
}
|
||||
try {
|
||||
const definitions = await fetchScriptDefinitions();
|
||||
monaco.typescript.javascriptDefaults.addExtraLib(definitions, 'ts:rule-runner.d.ts');
|
||||
monaco.typescript.javascriptDefaults.addExtraLib(definitions, 'file:///node_modules/@eveal/runtime/index.d.ts');
|
||||
extraLibLoaded = true;
|
||||
} catch {
|
||||
// type definitions are optional — the editor still works without autocomplete
|
||||
}
|
||||
};
|
||||
|
||||
const props = defineProps<{ ledgerRefs?: string[] }>();
|
||||
export interface ScriptEditorScope {
|
||||
ledgers?: string[];
|
||||
characters?: string[];
|
||||
corporations?: string[];
|
||||
walletDivisions?: string[];
|
||||
}
|
||||
|
||||
let ledgersLib: monaco.IDisposable | undefined;
|
||||
const props = defineProps<{ scope?: ScriptEditorScope }>();
|
||||
|
||||
const updateLedgerRefs = (refs: readonly string[]) => {
|
||||
ledgersLib?.dispose();
|
||||
let scopeLib: monaco.IDisposable | undefined;
|
||||
|
||||
const declareInterface = (name: string, type: string, refs: readonly string[]) => {
|
||||
const members = refs
|
||||
.filter(ref => ref && ref !== 'system')
|
||||
.map(ref => ` readonly ${JSON.stringify(ref)}: Ledger;`)
|
||||
.map(ref => ` readonly ${JSON.stringify(ref)}: ${type};`)
|
||||
.join('\n');
|
||||
ledgersLib = monaco.typescript.javascriptDefaults.addExtraLib(
|
||||
`declare interface Ledgers {\n${members}\n}\n`,
|
||||
'ts:rule-runner.ledgers.d.ts'
|
||||
);
|
||||
return ` interface ${name} {\n${members}\n }\n`;
|
||||
};
|
||||
|
||||
const updateScope = (scope: ScriptEditorScope) => {
|
||||
scopeLib?.dispose();
|
||||
const source = `declare module '@eveal/runtime' {\n${[
|
||||
declareInterface('Ledgers', 'Ledger', scope.ledgers ?? []),
|
||||
declareInterface('Characters', 'Character', scope.characters ?? []),
|
||||
declareInterface('Corporations', 'Corporation', scope.corporations ?? []),
|
||||
declareInterface('WalletDivisions', 'WalletDivision', scope.walletDivisions ?? []),
|
||||
].join('\n')}}\n`;
|
||||
scopeLib = monaco.typescript.javascriptDefaults.addExtraLib(source, 'ts:rule-runner.scope.d.ts');
|
||||
};
|
||||
|
||||
const model = defineModel<string>({default: ''});
|
||||
@@ -51,7 +65,7 @@ let editor: monaco.editor.IStandaloneCodeEditor | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
await loadScriptDefinitions();
|
||||
updateLedgerRefs(props.ledgerRefs ?? []);
|
||||
updateScope(props.scope ?? {});
|
||||
|
||||
if (!container.value) {
|
||||
return;
|
||||
@@ -83,11 +97,11 @@ watch(model, value => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.ledgerRefs, refs => updateLedgerRefs(refs ?? []), {deep: true});
|
||||
watch(() => props.scope, scope => updateScope(scope ?? {}), {deep: true});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor?.dispose();
|
||||
ledgersLib?.dispose();
|
||||
scopeLib?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+16
-3
@@ -1,10 +1,19 @@
|
||||
import {ruleBookApi, ruleScriptApi} from "@/mammon";
|
||||
import {RuleBookResponse, UpdateRuleBookRequest} from "@/generated/mammon";
|
||||
import {RuleBookResponse, RuleScopeRequest, RuleScriptRequest, RuleScriptResponse, UpdateRuleBookRequest} from "@/generated/mammon";
|
||||
import {isAxiosError} from "axios";
|
||||
import {defineStore} from "pinia";
|
||||
import {ref} from "vue";
|
||||
|
||||
export type RuleBook = RuleBookResponse;
|
||||
export type RuleScript = RuleScriptResponse;
|
||||
export type RuleScope = RuleScriptResponse['scope'];
|
||||
|
||||
export const emptyScope = (): RuleScopeRequest => ({
|
||||
ledgers: {},
|
||||
characters: {},
|
||||
corporations: {},
|
||||
walletDivisions: {},
|
||||
});
|
||||
|
||||
export const useRuleBookStore = defineStore('rule-book', () => {
|
||||
const ruleBook = ref<RuleBook | null>(null);
|
||||
@@ -19,7 +28,7 @@ export const useRuleBookStore = defineStore('rule-book', () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const update = (request: UpdateRuleBookRequest) => ruleBookApi.updateRuleBook(request)
|
||||
const update = (scripts: RuleScriptRequest[]) => ruleBookApi.updateRuleBook({scripts} satisfies UpdateRuleBookRequest)
|
||||
.then(response => ruleBook.value = response.data);
|
||||
|
||||
refresh();
|
||||
@@ -28,4 +37,8 @@ export const useRuleBookStore = defineStore('rule-book', () => {
|
||||
})
|
||||
|
||||
export const fetchScriptDefinitions = (): Promise<string> =>
|
||||
ruleScriptApi.getScriptDefinitions({responseType: 'text'}).then(response => response.data);
|
||||
ruleScriptApi.listScriptDefinitions()
|
||||
.then(response => Promise.all(
|
||||
response.data.map(name => ruleScriptApi.getScriptDefinition(name, {responseType: 'text'}).then(r => r.data))
|
||||
))
|
||||
.then(definitions => definitions.join('\n'));
|
||||
|
||||
Reference in New Issue
Block a user