feat(fullstack): Replace TITLE_BODY element with rich text editor
This commit is contained in:
@@ -99,7 +99,7 @@ Application Form
|
|||||||
└── Form Elements (FormElement)
|
└── Form Elements (FormElement)
|
||||||
├── id (UUID - generated by backend)
|
├── id (UUID - generated by backend)
|
||||||
├── reference (string - custom key like "art_der_massnahme")
|
├── reference (string - custom key like "art_der_massnahme")
|
||||||
├── type (SELECT, CHECKBOX, RADIOBUTTON, TEXTFIELD, TEXTAREA, SWITCH, TITLE_BODY_TEXTFIELDS, DATE)
|
├── type (SELECT, CHECKBOX, RADIOBUTTON, TEXTFIELD, TEXTAREA, SWITCH, RICH_TEXT, DATE)
|
||||||
├── title, description
|
├── title, description
|
||||||
├── options (FormOption[])
|
├── options (FormOption[])
|
||||||
│ ├── value, label
|
│ ├── value, label
|
||||||
@@ -743,7 +743,7 @@ act -n
|
|||||||
- `GET /application-forms/{id}/html`
|
- `GET /application-forms/{id}/html`
|
||||||
- `GET /application-forms/{id}/pdf`
|
- `GET /application-forms/{id}/pdf`
|
||||||
- Verify these render correctly (with saved values):
|
- Verify these render correctly (with saved values):
|
||||||
- `TEXTFIELD`, `TEXTAREA`, `DATE`, `TITLE_BODY_TEXTFIELDS`
|
- `TEXTFIELD`, `TEXTAREA`, `DATE`, `RICH_TEXT`
|
||||||
- `SELECT`, `RADIOBUTTON`, `CHECKBOX`, `SWITCH`
|
- `SELECT`, `RADIOBUTTON`, `CHECKBOX`, `SWITCH`
|
||||||
- If you changed backend filtering or templating, ensure:
|
- If you changed backend filtering or templating, ensure:
|
||||||
- Template sections (`isTemplate=true`) remain excluded from export
|
- Template sections (`isTemplate=true`) remain excluded from export
|
||||||
@@ -757,6 +757,9 @@ act -n
|
|||||||
11. **Clean Code: order functions by abstraction (top-down, like a book)**
|
11. **Clean Code: order functions by abstraction (top-down, like a book)**
|
||||||
- Put the highest-level, most readable entry points first (public API / primary flows), then progressively lower-level helpers below.
|
- Put the highest-level, most readable entry points first (public API / primary flows), then progressively lower-level helpers below.
|
||||||
- Keep functions at the same level of abstraction grouped together; details should live “later” (below) so the file reads top-to-bottom without jumping around.
|
- Keep functions at the same level of abstraction grouped together; details should live “later” (below) so the file reads top-to-bottom without jumping around.
|
||||||
|
12. **No hardcoded UI strings** (i18n)
|
||||||
|
- Never hardcode user-facing text in Vue components/composables.
|
||||||
|
- Always add UI strings to `legalconsenthub/i18n/locales/de.json` and `legalconsenthub/i18n/locales/en.json` and reference them via `$t()` / `useI18n()`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1417,7 +1417,7 @@ components:
|
|||||||
- TEXTFIELD
|
- TEXTFIELD
|
||||||
- TEXTAREA
|
- TEXTAREA
|
||||||
- SWITCH
|
- SWITCH
|
||||||
- TITLE_BODY_TEXTFIELDS
|
- RICH_TEXT
|
||||||
- DATE
|
- DATE
|
||||||
|
|
||||||
FormElementVisibilityCondition:
|
FormElementVisibilityCondition:
|
||||||
|
|||||||
@@ -173,12 +173,9 @@
|
|||||||
<p th:if="${elem.options.isEmpty() || elem.options.?[!value.isEmpty()].isEmpty()}">Kein Datum ausgewählt</p>
|
<p th:if="${elem.options.isEmpty() || elem.options.?[!value.isEmpty()].isEmpty()}">Kein Datum ausgewählt</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div th:case="'TITLE_BODY_TEXTFIELDS'">
|
<div th:case="'RICH_TEXT'">
|
||||||
<div th:each="option : ${elem.options}">
|
<div th:each="option : ${elem.options}">
|
||||||
<div th:if="${!option.value.isEmpty()}" th:with="parts=${#strings.arraySplit(option.value, '|||')}">
|
<div th:if="${!option.value.isEmpty()}" th:utext="${option.value}"></div>
|
||||||
<strong th:text="${parts.length > 0 ? parts[0] : ''}"></strong>
|
|
||||||
<p th:text="${parts.length > 1 ? parts[1] : ''}"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p th:if="${elem.options.isEmpty() || elem.options.?[!value.isEmpty()].isEmpty()}">Keine Eingabe</p>
|
<p th:if="${elem.options.isEmpty() || elem.options.?[!value.isEmpty()].isEmpty()}">Keine Eingabe</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
:key="getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)"
|
:key="getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)"
|
||||||
>
|
>
|
||||||
<div class="group flex py-3 lg:py-4">
|
<div class="group flex py-3 lg:py-4">
|
||||||
<div class="flex-auto">
|
<div class="flex-auto min-w-0">
|
||||||
<p v-if="formElementItem.formElement.title" class="font-semibold">{{ formElementItem.formElement.title }}</p>
|
<p v-if="formElementItem.formElement.title" class="font-semibold">{{ formElementItem.formElement.title }}</p>
|
||||||
<p v-if="formElementItem.formElement.description" class="text-dimmed pb-3">
|
<p v-if="formElementItem.formElement.description" class="text-dimmed pb-3">
|
||||||
{{ formElementItem.formElement.description }}
|
{{ formElementItem.formElement.description }}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"
|
"
|
||||||
:content="{ align: 'end' }"
|
:content="{ align: 'end' }"
|
||||||
@update:open="
|
@update:open="
|
||||||
(isOpen) =>
|
(isOpen: boolean) =>
|
||||||
handleDropdownToggle(
|
handleDropdownToggle(
|
||||||
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
|
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
|
||||||
isOpen
|
isOpen
|
||||||
@@ -134,8 +134,8 @@ function getResolvedComponent(formElement: FormElementDto) {
|
|||||||
return resolveComponent('TheInput')
|
return resolveComponent('TheInput')
|
||||||
case 'TEXTAREA':
|
case 'TEXTAREA':
|
||||||
return resolveComponent('TheTextarea')
|
return resolveComponent('TheTextarea')
|
||||||
case 'TITLE_BODY_TEXTFIELDS':
|
case 'RICH_TEXT':
|
||||||
return resolveComponent('TheTitleBodyInput')
|
return resolveComponent('TheEditor')
|
||||||
case 'DATE':
|
case 'DATE':
|
||||||
return resolveComponent('TheDate')
|
return resolveComponent('TheDate')
|
||||||
default:
|
default:
|
||||||
|
|||||||
106
legalconsenthub/app/components/formelements/TheEditor.vue
Normal file
106
legalconsenthub/app/components/formelements/TheEditor.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white dark:bg-white rounded-md border border-gray-200 dark:border-gray-200 overflow-hidden">
|
||||||
|
<UEditor
|
||||||
|
v-slot="{ editor }"
|
||||||
|
v-model="htmlContent"
|
||||||
|
content-type="html"
|
||||||
|
:editable="!props.disabled"
|
||||||
|
:placeholder="t('applicationForms.formElements.richTextPlaceholder')"
|
||||||
|
:ui="{
|
||||||
|
content: 'bg-white dark:bg-white',
|
||||||
|
base: 'min-h-[200px] p-3 bg-white dark:bg-white'
|
||||||
|
}"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<UEditorToolbar
|
||||||
|
:editor="editor"
|
||||||
|
:items="toolbarItems"
|
||||||
|
class="border-b border-muted sticky top-0 inset-x-0 px-3 py-2 z-50 bg-default overflow-x-auto"
|
||||||
|
/>
|
||||||
|
<UEditorSuggestionMenu :editor="editor" :items="suggestionItems" />
|
||||||
|
<UEditorMentionMenu :editor="editor" :items="mentionItems" />
|
||||||
|
<UEditorDragHandle :editor="editor" />
|
||||||
|
</UEditor>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { EmployeeDataCategory, ProcessingPurpose, type FormOptionDto } from '~~/.api-client'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
formOptions: FormOptionDto[]
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:formOptions', value: FormOptionDto[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const htmlContent = computed({
|
||||||
|
get: () => props.formOptions[0]?.value ?? '',
|
||||||
|
set: (newValue: string) => {
|
||||||
|
const updatedOptions: FormOptionDto[] = [...props.formOptions]
|
||||||
|
if (updatedOptions.length === 0) {
|
||||||
|
const createdOption: FormOptionDto = {
|
||||||
|
value: newValue,
|
||||||
|
label: '',
|
||||||
|
processingPurpose: ProcessingPurpose.None,
|
||||||
|
employeeDataCategory: EmployeeDataCategory.None
|
||||||
|
}
|
||||||
|
updatedOptions.push(createdOption)
|
||||||
|
} else {
|
||||||
|
const firstOption = updatedOptions[0]!
|
||||||
|
updatedOptions[0] = { ...firstOption, value: newValue }
|
||||||
|
}
|
||||||
|
emit('update:formOptions', updatedOptions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toolbarItems = [
|
||||||
|
[
|
||||||
|
{ kind: 'undo', icon: 'i-lucide-undo' },
|
||||||
|
{ kind: 'redo', icon: 'i-lucide-redo' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', label: 'H1' },
|
||||||
|
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', label: 'H2' },
|
||||||
|
{ kind: 'heading', level: 3, icon: 'i-lucide-heading-3', label: 'H3' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
|
||||||
|
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
|
||||||
|
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
|
||||||
|
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'bulletList', icon: 'i-lucide-list' },
|
||||||
|
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'blockquote', icon: 'i-lucide-quote' },
|
||||||
|
{ kind: 'codeBlock', icon: 'i-lucide-code' }
|
||||||
|
],
|
||||||
|
[{ kind: 'link', icon: 'i-lucide-link' }]
|
||||||
|
]
|
||||||
|
|
||||||
|
const suggestionItems = [
|
||||||
|
[
|
||||||
|
{ kind: 'heading', level: 1, label: 'Überschrift 1', icon: 'i-lucide-heading-1' },
|
||||||
|
{ kind: 'heading', level: 2, label: 'Überschrift 2', icon: 'i-lucide-heading-2' },
|
||||||
|
{ kind: 'heading', level: 3, label: 'Überschrift 3', icon: 'i-lucide-heading-3' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'bulletList', label: 'Aufzählung', icon: 'i-lucide-list' },
|
||||||
|
{ kind: 'orderedList', label: 'Nummerierung', icon: 'i-lucide-list-ordered' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: 'blockquote', label: 'Zitat', icon: 'i-lucide-quote' },
|
||||||
|
{ kind: 'codeBlock', label: 'Code Block', icon: 'i-lucide-code' },
|
||||||
|
{ kind: 'horizontalRule', label: 'Trennlinie', icon: 'i-lucide-separator-horizontal' }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const mentionItems: Array<{ label: string; avatar?: { src: string } }> = []
|
||||||
|
</script>
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UFormField :label="$t('applicationForms.formElements.title')">
|
|
||||||
<UInput v-model="title" class="w-full" :disabled="props.disabled" />
|
|
||||||
</UFormField>
|
|
||||||
<UFormField :label="$t('applicationForms.formElements.text')">
|
|
||||||
<UTextarea v-model="body" class="w-full" autoresize :disabled="props.disabled" />
|
|
||||||
</UFormField>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { FormOptionDto } from '~~/.api-client'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
formOptions: FormOptionDto[]
|
|
||||||
disabled?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:formOptions', value: FormOptionDto[]): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const SEPARATOR = '|||'
|
|
||||||
|
|
||||||
const title = computed({
|
|
||||||
get: () => {
|
|
||||||
const currentValue = props.formOptions[0]?.value ?? ''
|
|
||||||
return splitValue(currentValue).title
|
|
||||||
},
|
|
||||||
set: (newTitle: string) => {
|
|
||||||
const firstOption = props.formOptions[0]
|
|
||||||
if (firstOption) {
|
|
||||||
const currentValue = firstOption.value ?? ''
|
|
||||||
const { body: currentBody } = splitValue(currentValue)
|
|
||||||
const combinedValue = joinValue(newTitle, currentBody)
|
|
||||||
|
|
||||||
const updatedModelValue = [...props.formOptions]
|
|
||||||
updatedModelValue[0] = { ...firstOption, value: combinedValue }
|
|
||||||
emit('update:formOptions', updatedModelValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const body = computed({
|
|
||||||
get: () => {
|
|
||||||
const currentValue = props.formOptions[0]?.value ?? ''
|
|
||||||
return splitValue(currentValue).body
|
|
||||||
},
|
|
||||||
set: (newBody: string) => {
|
|
||||||
const firstOption = props.formOptions[0]
|
|
||||||
if (firstOption) {
|
|
||||||
const currentValue = firstOption.value ?? ''
|
|
||||||
const { title: currentTitle } = splitValue(currentValue)
|
|
||||||
const combinedValue = joinValue(currentTitle, newBody)
|
|
||||||
|
|
||||||
const updatedModelValue = [...props.formOptions]
|
|
||||||
updatedModelValue[0] = { ...firstOption, value: combinedValue }
|
|
||||||
emit('update:formOptions', updatedModelValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function splitValue(value: string): { title: string; body: string } {
|
|
||||||
const parts = value.split(SEPARATOR)
|
|
||||||
return {
|
|
||||||
title: parts[0] || '',
|
|
||||||
body: parts[1] || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function joinValue(title: string, body: string): string {
|
|
||||||
return `${title}${SEPARATOR}${body}`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -14,13 +14,13 @@ export function useFormElementManagement() {
|
|||||||
description: 'Bitte fügen Sie hier Ihre Ergänzungen ein.',
|
description: 'Bitte fügen Sie hier Ihre Ergänzungen ein.',
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
value: '|||',
|
value: '',
|
||||||
label: '',
|
label: '',
|
||||||
processingPurpose: 'NONE',
|
processingPurpose: 'NONE',
|
||||||
employeeDataCategory: 'NONE'
|
employeeDataCategory: 'NONE'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
type: 'TITLE_BODY_TEXTFIELDS'
|
type: 'RICH_TEXT'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"selectDate": "Datum auswählen",
|
"selectDate": "Datum auswählen",
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
"unimplemented": "Element nicht implementiert:"
|
"unimplemented": "Element nicht implementiert:",
|
||||||
|
"richTextPlaceholder": "Schreiben Sie hier Ihre Ergänzungen..."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"draft": "Entwurf",
|
"draft": "Entwurf",
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"selectDate": "Select a date",
|
"selectDate": "Select a date",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
"unimplemented": "Element unimplemented:"
|
"unimplemented": "Element unimplemented:",
|
||||||
|
"richTextPlaceholder": "Write your additions here..."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"draft": "Draft",
|
"draft": "Draft",
|
||||||
|
|||||||
@@ -12,11 +12,12 @@
|
|||||||
"type-check": "nuxi typecheck",
|
"type-check": "nuxi typecheck",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
|
"check": "pnpm run lint && pnpm run type-check && pnpm run format",
|
||||||
"api:generate": "openapi-generator-cli generate -i ../api/legalconsenthub.yml -g typescript-fetch -o .api-client"
|
"api:generate": "openapi-generator-cli generate -i ../api/legalconsenthub.yml -g typescript-fetch -o .api-client"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@guolao/vue-monaco-editor": "^1.6.0",
|
"@guolao/vue-monaco-editor": "^1.6.0",
|
||||||
"@nuxt/ui": "^4.2.1",
|
"@nuxt/ui": "4.3.0",
|
||||||
"@nuxtjs/i18n": "10.0.3",
|
"@nuxtjs/i18n": "10.0.3",
|
||||||
"@pinia/nuxt": "0.11.2",
|
"@pinia/nuxt": "0.11.2",
|
||||||
"@vueuse/core": "^13.6.0",
|
"@vueuse/core": "^13.6.0",
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
"nuxt": "4.2.0",
|
"nuxt": "4.2.0",
|
||||||
"nuxt-auth-utils": "0.5.25",
|
"nuxt-auth-utils": "0.5.25",
|
||||||
"pinia": "3.0.3",
|
"pinia": "3.0.3",
|
||||||
"resend": "^4.3.0",
|
"resend": "4.3.0",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-router": "latest"
|
"vue-router": "latest"
|
||||||
},
|
},
|
||||||
|
|||||||
2675
legalconsenthub/pnpm-lock.yaml
generated
2675
legalconsenthub/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user