Files
numolus/app/frontend/components/CustomFunctionsModal.vue
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

411 lines
10 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* "𝑓 Funktionen" -- the exclusive UI for creating, viewing, editing and
* deleting the document's user-defined functions (src/engine/userFunctions.ts).
* Two views in one BaseModal, toggled by `mode`: a list of existing functions,
* and a form shared by both "new" and "edit". See the plan's "Bedienung"
* section for the exact mockups this follows.
*/
import { nextTick, reactive, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import BaseModal from './BaseModal.vue';
import CodeReference from './CodeReference.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const store = useDocument();
type Mode = 'list' | 'edit';
type Kind = 'expression' | 'block';
const mode = ref<Mode>('list');
const editingId = ref<string | null>(null);
const form = reactive({ name: '', params: [''] as string[], body: '', kind: 'expression' as Kind });
const formError = ref<string | null>(null);
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
// -progress never lingers into the next open.
watch(
() => props.open,
(o) => {
if (o) {
mode.value = 'list';
formError.value = null;
}
},
);
function openNew() {
editingId.value = null;
form.name = '';
form.params = [''];
form.body = '';
form.kind = 'expression';
formError.value = null;
mode.value = 'edit';
}
function openEdit(id: string) {
const f = store.doc.customFunctions.find((x) => x.id === id);
if (!f) return;
editingId.value = id;
form.name = f.name;
form.params = f.params.length ? [...f.params] : [''];
form.body = f.body;
form.kind = f.kind;
formError.value = null;
mode.value = 'edit';
}
function addParamField() {
form.params.push('');
}
function removeParamField(i: number) {
form.params.splice(i, 1);
}
function save() {
const params = form.params.map((p) => p.trim()).filter((p) => p !== '');
const result = editingId.value
? store.updateCustomFunction(editingId.value, form.name, params, form.body, form.kind)
: store.addCustomFunction(form.name, params, form.body, form.kind);
if (result && typeof result === 'object' && 'error' in result) {
formError.value = result.error;
return;
}
formError.value = null;
mode.value = 'list';
}
function cancelEdit() {
formError.value = null;
mode.value = 'list';
}
function removeFunction(id: string, name: string) {
if (confirm(`Funktion "${name}" löschen? Formeln, die sie aufrufen, zeigen danach #NAME?.`)) {
store.deleteCustomFunction(id);
}
}
const bodyInput = ref<HTMLTextAreaElement | null>(null);
/** Put a reference snippet at the caret (replacing any selection). */
async function insertSnippet(text: string) {
const el = bodyInput.value;
const start = el?.selectionStart ?? form.body.length;
const end = el?.selectionEnd ?? form.body.length;
form.body = form.body.slice(0, start) + text + form.body.slice(end);
await nextTick();
if (!el) return;
el.focus();
el.setSelectionRange(start + text.length, start + text.length);
}
function close() {
emit('close');
}
</script>
<template>
<BaseModal
:open="open"
:wide="mode === 'edit'"
:title="mode === 'list' ? 'Funktionen' : editingId ? 'Funktion bearbeiten' : 'Neue Funktion'"
@close="close"
>
<!-- ---- list view ---- -->
<template v-if="mode === 'list'">
<p v-if="!store.doc.customFunctions.length" class="hint">
Noch keine Funktionen definiert. Mit „+ Neue Funktion" eine anlegen und danach wie
<code>SUM</code> oder <code>AVG</code> in einer Zelle aufrufen.
</p>
<ul v-else class="fn-list">
<li v-for="f in store.doc.customFunctions" :key="f.id" class="fn-row">
<code class="fn-sig">
<span v-if="f.kind === 'block'" class="fn-badge" title="Mit Schleife (Code)">↻</span>
{{ f.name }}({{ f.params.join('; ') }})
</code>
<span class="fn-actions">
<button class="mini" @click="openEdit(f.id)">Bearb.</button>
<button class="mini danger" title="Löschen" @click="removeFunction(f.id, f.name)">🗑</button>
</span>
</li>
</ul>
</template>
<!-- ---- edit / create form ---- -->
<div v-else class="edit-layout">
<div class="editor">
<label class="row">
Name
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
</label>
<div class="row">
<span class="params-label">Art</span>
<div class="kind-toggle">
<button
type="button"
:class="{ on: form.kind === 'expression' }"
@click="form.kind = 'expression'"
>
Einfache Formel
</button>
<button type="button" :class="{ on: form.kind === 'block' }" @click="form.kind = 'block'">
Mit Schleife
</button>
</div>
</div>
<div class="row params-row">
<span class="params-label">Parameter</span>
<div class="params-list">
<div v-for="(_, i) in form.params" :key="i" class="param-field">
<input v-model="form.params[i]" type="text" :placeholder="`param${i + 1}`" />
<button
class="mini remove"
title="Parameter entfernen"
:disabled="form.params.length <= 1"
@click="removeParamField(i)"
>
×
</button>
</div>
<button class="mini" @click="addParamField">+ Parameter</button>
</div>
</div>
<label class="row body-row">
{{ form.kind === 'block' ? 'Code' : 'Formel' }}
<textarea
ref="bodyInput"
v-model="form.body"
class="formula-input"
spellcheck="false"
:rows="form.kind === 'block' ? 12 : 5"
:placeholder="
form.kind === 'block'
? 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis'
: 'z. B. (revenue - cost) / revenue'
"
/>
</label>
<p class="hint">
Befehle, Funktionen und die eigenen Parameter rechts suchen, aufklappen für
Beschreibung und Beispiel, mit <strong>Einfügen</strong> an der Cursorposition
übernehmen.
</p>
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
</div>
<CodeReference
class="reference"
:context="form.kind"
:params="form.params"
@insert="insertSnippet"
/>
</div>
<template #footer>
<template v-if="mode === 'list'">
<button class="primary" @click="openNew">+ Neue Funktion</button>
</template>
<template v-else>
<button @click="cancelEdit">Abbrechen</button>
<button class="primary" @click="save">Speichern</button>
</template>
</template>
</BaseModal>
</template>
<style scoped>
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 0;
}
.hint code {
background: var(--surface-2);
border-radius: 4px;
padding: 1px 5px;
}
.fn-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fn-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
}
.fn-sig {
font-size: 12.5px;
font-family: inherit;
overflow-wrap: anywhere;
}
.fn-badge {
display: inline-block;
margin-right: 3px;
color: var(--accent);
}
.kind-toggle {
display: flex;
gap: 6px;
}
.kind-toggle button {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 5px 10px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
}
.kind-toggle button:hover {
border-color: var(--accent);
color: var(--accent);
}
.kind-toggle button.on {
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.fn-actions {
display: flex;
gap: 6px;
flex: none;
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini:disabled {
opacity: 0.4;
cursor: default;
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input[type='text'] {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.params-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.param-field {
display: flex;
align-items: center;
gap: 2px;
}
.param-field input {
width: 92px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 7px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.remove {
padding: 2px 7px;
}
.body-row .formula-input {
min-height: 30px;
max-height: 40vh;
}
.formula-input {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font: inherit;
font-variant-numeric: tabular-nums;
color: var(--text);
background: var(--surface-2);
resize: vertical;
white-space: pre-wrap;
word-break: break-word;
}
.formula-input:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
}
.edit-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
/* side by side: fill the (viewport-capped) modal body and let the
reference list scroll inside it instead of the whole body */
grid-template-rows: minmax(0, 1fr);
gap: 16px;
flex: 1 1 auto;
min-height: 0;
}
@media (max-width: 720px) {
/* stacked: let the modal body scroll as a whole */
.edit-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto;
flex: none;
}
}
.editor {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow-y: auto;
}
.editor .body-row {
flex: 1 1 auto;
min-height: 0;
}
.editor .body-row .formula-input {
flex: 1 1 auto;
min-height: 70px;
max-height: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
}
.form-error {
margin: 4px 0 0;
font-size: 12.5px;
color: #dc2626;
line-height: 1.4;
}
</style>