From 72ff7addaa0fb0580b4c5060fd581c178db82535 Mon Sep 17 00:00:00 2001 From: nico Date: Tue, 3 Nov 2026 21:44:40 +0000 Subject: [PATCH 1/3] feat(documents): add title section movement helpers --- frontend/utils/documentTitleSections.js | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 frontend/utils/documentTitleSections.js diff --git a/frontend/utils/documentTitleSections.js b/frontend/utils/documentTitleSections.js new file mode 100644 index 00000000..bcc41bcd --- /dev/null +++ b/frontend/utils/documentTitleSections.js @@ -0,0 +1,41 @@ +const titleLevel = row => Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3) + +// A section includes every row up to the next title at the same or a higher level. +const sectionEnd = (rows, start) => { + const level = titleLevel(rows[start]) + let end = start + 1 + while (end < rows.length && (rows[end].mode !== 'title' || titleLevel(rows[end]) > level)) end += 1 + return end +} + +export const getTitleSectionMove = (rows, rowId, direction) => { + if (!['up', 'down'].includes(direction)) return null + const start = rows.findIndex(row => row.id === rowId) + if (start < 0 || rows[start].mode !== 'title') return null + + const level = titleLevel(rows[start]) + const end = sectionEnd(rows, start) + if (direction === 'down') { + // A title at a higher level ends the parent section: never cross it. + if (end === rows.length || titleLevel(rows[end]) !== level) return null + return { start, end, target: sectionEnd(rows, end) } + } + + for (let previous = start - 1; previous >= 0; previous -= 1) { + if (rows[previous].mode !== 'title' || titleLevel(rows[previous]) > level) continue + if (titleLevel(rows[previous]) < level) return null + return { start, end, target: previous } + } + return null +} + +export const moveTitleSection = (rows, rowId, direction) => { + const move = getTitleSectionMove(rows, rowId, direction) + if (!move) return rows + + const { start, end, target } = move + const result = rows.slice() + const section = result.splice(start, end - start) + result.splice(target > start ? target - section.length : target, 0, ...section) + return result +} From c46c50a2a396e53976bcfa47dbd6d7f3e22b9d40 Mon Sep 17 00:00:00 2001 From: nico Date: Tue, 3 Nov 2026 21:45:35 +0000 Subject: [PATCH 2/3] test(documents): cover nested title section moves --- .../tests/document-title-sections.test.cjs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 frontend/tests/document-title-sections.test.cjs diff --git a/frontend/tests/document-title-sections.test.cjs b/frontend/tests/document-title-sections.test.cjs new file mode 100644 index 00000000..271616fe --- /dev/null +++ b/frontend/tests/document-title-sections.test.cjs @@ -0,0 +1,119 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const vm = require('node:vm') +const ts = require('typescript') +const { parse, compileScript } = require('@vue/compiler-sfc') +const { ref, computed } = require('vue') + +const helpers = import('../utils/documentTitleSections.js') +const title = (id, titleLevel = 1) => ({ id, mode: 'title', titleLevel, text: id }) +const position = (id, mode = 'free') => ({ id, mode, quantity: 2, price: 10, linkedEntitys: [42] }) +const ids = rows => Array.from(rows, row => row.id) + +test('moving a top-level title carries every nested title and row, preserving data and order', async () => { + const { moveTitleSection } = await helpers + const rows = [position('intro', 'text'), title('a'), position('article', 'normal'), + title('a.1', 2), position('service', 'service'), title('a.1.1', 3), + position('note', 'text'), position('break', 'pagebreak'), title('b'), position('last')] + const before = JSON.stringify(rows) + const moved = moveTitleSection(rows, 'a', 'down') + assert.deepEqual(ids(moved), ['intro', 'b', 'last', 'a', 'article', 'a.1', 'service', 'a.1.1', 'note', 'break']) + assert.equal(JSON.stringify(rows), before) + for (const row of rows) assert.equal(moved.find(item => item.id === row.id), row) + assert.deepEqual(moveTitleSection(moved, 'a', 'up'), rows) +}) + +test('subtitles exchange complete sibling sections without leaving their parent', async () => { + const { moveTitleSection, getTitleSectionMove } = await helpers + const rows = [title('a'), position('parent-intro'), title('a.1', 2), title('a.1.1', 3), + position('child'), title('a.2', 2), position('sibling'), title('b'), title('b.1', 2)] + assert.deepEqual(ids(moveTitleSection(rows, 'a.2', 'up')), + ['a', 'parent-intro', 'a.2', 'sibling', 'a.1', 'a.1.1', 'child', 'b', 'b.1']) + assert.deepEqual(ids(moveTitleSection(rows, 'a.1', 'down')), + ['a', 'parent-intro', 'a.2', 'sibling', 'a.1', 'a.1.1', 'child', 'b', 'b.1']) + for (const [id, direction] of [['a.1', 'up'], ['a.2', 'down'], ['b.1', 'up']]) { + assert.equal(getTitleSectionMove(rows, id, direction), null) + assert.equal(moveTitleSection(rows, id, direction), rows) + } +}) + +test('level-three titles move within their level-two parent', async () => { + const { moveTitleSection } = await helpers + const rows = [title('a'), title('a.1', 2), title('x', 3), position('x-row'), + title('y', 3), position('y-row'), title('a.2', 2)] + const moved = moveTitleSection(rows, 'x', 'down') + assert.deepEqual(ids(moved), ['a', 'a.1', 'y', 'y-row', 'x', 'x-row', 'a.2']) + assert.equal(moveTitleSection(moved, 'x', 'down'), moved) +}) + +test('empty titles, the final section and legacy levels are supported', async () => { + const { moveTitleSection } = await helpers + const rows = [{ id: 'legacy', mode: 'title' }, title('empty', '1'), title('last', 1), position('end')] + assert.deepEqual(ids(moveTitleSection(rows, 'legacy', 'down')), ['empty', 'legacy', 'last', 'end']) + assert.deepEqual(ids(moveTitleSection(rows, 'last', 'up')), ['legacy', 'last', 'end', 'empty']) + assert.equal(moveTitleSection(rows, 'last', 'down'), rows) +}) + +test('missing intermediate levels still retain their enclosing section', async () => { + const { moveTitleSection } = await helpers + const rows = [title('a'), title('x', 3), position('x-row'), title('y', 3), title('b')] + assert.deepEqual(ids(moveTitleSection(rows, 'x', 'down')), ['a', 'y', 'x', 'x-row', 'b']) + assert.equal(moveTitleSection(rows, 'x', 'up'), rows) + assert.equal(moveTitleSection(rows, 'y', 'down'), rows) +}) + +test('invalid requests and document boundaries leave rows untouched', async () => { + const { moveTitleSection } = await helpers + const rows = [position('intro'), title('only'), position('last')] + for (const [id, direction] of [['only', 'up'], ['only', 'down'], ['intro', 'down'], + ['missing', 'up'], ['only', 'sideways']]) { + assert.equal(moveTitleSection(rows, id, direction), rows) + } + const empty = [] + assert.equal(moveTitleSection(empty, 'missing', 'up'), empty) +}) + +async function editorFixture(rows, type = 'invoices') { + const { descriptor, errors } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8')) + assert.deepEqual(errors, []) + compileScript(descriptor, { id: 'document-editor', inlineTemplate: true }) + const script = ts.createSourceFile('editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) + const names = new Set(['moveDocumentTitleSection', 'getTitleSectionMoveMenuItems', 'setPosNumbers', + 'collapsedTitleRowIds', 'toggleTitleSection', 'hiddenDocumentRowIds', 'isDocumentRowHidden']) + const source = script.statements.filter(statement => ts.isVariableStatement(statement) + && statement.declarationList.declarations.some(declaration => names.has(declaration.name.getText(script)))) + .map(statement => statement.getText(script)).join('\n') + const context = vm.createContext({ ...await helpers, ref, computed, itemInfo: ref({ type, rows }) }) + vm.runInContext(source, context) + return { context, run: code => vm.runInContext(code, context) } +} + +test('the editor menu moves collapsed sections and updates numbering and reactive visibility', async () => { + const { run, context } = await editorFixture([title('a'), title('a.1', 2), position('a-row'), + title('b'), position('b-row')]) + run("setPosNumbers(); toggleTitleSection('a'); toggleTitleSection('a.1')") + assert.equal(run("isDocumentRowHidden('a-row')"), true) + assert.equal(run("getTitleSectionMoveMenuItems('a')[0].disabled"), true) + assert.equal(run("getTitleSectionMoveMenuItems('a')[1].disabled"), false) + run("getTitleSectionMoveMenuItems('a')[1].onSelect()") + assert.deepEqual(ids(context.itemInfo.value.rows), ['b', 'b-row', 'a', 'a.1', 'a-row']) + assert.deepEqual(Array.from(context.itemInfo.value.rows, row => row.pos), ['1', '1.1', '2', '2.1', '2.1.1']) + assert.equal(run("isDocumentRowHidden('b-row')"), false) + assert.equal(run("isDocumentRowHidden('a-row')"), true) + run("toggleTitleSection('a')") + assert.equal(run("isDocumentRowHidden('a.1')"), false) + assert.equal(run("isDocumentRowHidden('a-row')"), true) + run("toggleTitleSection('a.1')") + assert.equal(run("isDocumentRowHidden('a-row')"), false) + assert.equal(run("getTitleSectionMoveMenuItems('a')[1].disabled"), true) + assert.equal(run("getTitleSectionMoveMenuItems('a')[0].disabled"), false) +}) + +test('cancellation documents reject section moves even when called directly', async () => { + const { run, context } = await editorFixture([title('a'), position('a-row'), title('b')], 'cancellationInvoices') + const before = JSON.stringify(context.itemInfo.value.rows) + assert.equal(run("getTitleSectionMoveMenuItems('a').every(item => item.disabled)"), true) + run("moveDocumentTitleSection('a', 'down')") + assert.equal(JSON.stringify(context.itemInfo.value.rows), before) +}) From 05b0c12841bd27edb6466136c5ae0283b6413d91 Mon Sep 17 00:00:00 2001 From: nico Date: Tue, 3 Nov 2026 21:47:14 +0000 Subject: [PATCH 3/3] feat(documents): expose complete title section movement in editor --- frontend/pages/createDocument/edit/[[id]].vue | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/frontend/pages/createDocument/edit/[[id]].vue b/frontend/pages/createDocument/edit/[[id]].vue index d852c043..04002206 100644 --- a/frontend/pages/createDocument/edit/[[id]].vue +++ b/frontend/pages/createDocument/edit/[[id]].vue @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import {useFunctions} from "~/composables/useFunctions.js"; import EntityModalButtons from "~/components/EntityModalButtons.vue"; import { documentTemplateHandlebars } from "~/utils/handlebars"; +import { getTitleSectionMove, moveTitleSection } from "~/utils/documentTitleSections.js"; const dataStore = useDataStore() const profileStore = useProfileStore() @@ -1017,6 +1018,24 @@ const toggleTitleSection = (rowId) => { collapsedTitleRowIds.value = nextCollapsedTitleRowIds } +const moveDocumentTitleSection = (rowId, direction) => { + if (itemInfo.value.type === 'cancellationInvoices') return + const rows = moveTitleSection(itemInfo.value.rows, rowId, direction) + if (rows === itemInfo.value.rows) return + itemInfo.value.rows = rows + setPosNumbers() +} + +const getTitleSectionMoveMenuItems = (rowId) => [ + { label: 'Titelbereich nach oben', icon: 'i-heroicons-arrow-up', direction: 'up' }, + { label: 'Titelbereich nach unten', icon: 'i-heroicons-arrow-down', direction: 'down' } +].map(({ direction, ...item }) => ({ + ...item, + disabled: itemInfo.value.type === 'cancellationInvoices' + || !getTitleSectionMove(itemInfo.value.rows, rowId, direction), + onSelect: () => moveDocumentTitleSection(rowId, direction) +})) + const hiddenDocumentRowIds = computed(() => { const hiddenRowIds = new Set() const collapsedTitleLevels = [] @@ -3116,7 +3135,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = { v-model="itemInfo.rows" handle=".handle" tag="tbody" - itemKey="pos" + itemKey="id" @end="setPosNumbers" >