From 0c129c01314acc295122f1833d722f2a4c747ecc Mon Sep 17 00:00:00 2001 From: Christian Hartmann Date: Sat, 22 Aug 2026 11:58:48 +0200 Subject: [PATCH] chore: migrate route/navigation batch to Composition API Signed-off-by: Christian Hartmann --- src/Forms.vue | 1 + src/components/AppNavigationForm.vue | 216 +++--- src/components/Questions/AnswerInput.vue | 388 +++++----- .../SidebarTabs/SettingsSidebarTab.vue | 678 +++++++++--------- .../SidebarTabs/SharingSearchDiv.vue | 37 +- .../SidebarTabs/SharingShareDiv.vue | 157 ++-- .../SidebarTabs/SharingSidebarTab.vue | 436 ++++++----- .../SidebarTabs/TransferOwnership.vue | 127 ++-- src/components/TopBar.vue | 136 ++-- 9 files changed, 1066 insertions(+), 1110 deletions(-) diff --git a/src/Forms.vue b/src/Forms.vue index 794543e85..10fd066d6 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -133,6 +133,7 @@ :form="selectedForm" :sidebarOpened="sidebarOpened" :active="sidebarActive" + @update:form="updateSelectedForm" @update:sidebarOpened="sidebarOpened = $event" @update:active="sidebarActive = $event" /> diff --git a/src/components/AppNavigationForm.vue b/src/components/AppNavigationForm.vue index 9e83edc02..00913a525 100644 --- a/src/components/AppNavigationForm.vue +++ b/src/components/AppNavigationForm.vue @@ -115,7 +115,8 @@ import { showConfirmation, showError } from '@nextcloud/dialogs' import { t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import { generateOcsUrl } from '@nextcloud/router' -import { defineComponent } from 'vue' +import { computed, defineComponent, ref } from 'vue' +import { useRoute } from 'vue-router' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcActionRouter from '@nextcloud/vue/components/NcActionRouter' import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' @@ -161,98 +162,77 @@ export default defineComponent({ emits: ['mobileCloseNavigation', 'openSharing', 'clone', 'delete'], - setup() { - return { - t, - FormsIcon, - IconArchive, - IconArchiveOff, - IconCheck, - IconContentCopy, - IconDelete, - IconPencil, - IconPoll, - IconShareVariant, - } - }, - - data() { - return { - loading: false as boolean, - } - }, + setup(props, { emit }) { + const route = useRoute() + const loading = ref(false) - computed: { - canEdit(): boolean { - return this.form.permissions.includes(PERMISSION_TYPES.PERMISSION_EDIT) - }, - - canSeeResults(): boolean { - return ( - this.form.permissions.includes(PERMISSION_TYPES.PERMISSION_RESULTS) - || (this.form.submissionCount ?? 0) > 0 - ) - }, + const canEdit = computed(() => { + return props.form.permissions.includes(PERMISSION_TYPES.PERMISSION_EDIT) + }) + const canSeeResults = computed( + () => + props.form.permissions.includes(PERMISSION_TYPES.PERMISSION_RESULTS) + || (props.form.submissionCount ?? 0) > 0, + ) /** * Check if form is current form and set active */ - isActive(): boolean { - return this.form.hash === String(this.$route.params.hash) - }, + const isActive = computed( + () => props.form.hash === String(route.params.hash), + ) /** * Check if the form is archived */ - isArchived(): boolean { - return this.form.state === FormState.FormArchived - }, + const isArchived = computed( + () => props.form.state === FormState.FormArchived, + ) /** * Check if form is expired */ - isExpired(): boolean { - return Boolean(this.form.expires && moment().unix() > this.form.expires) - }, + const isExpired = computed(() => + Boolean(props.form.expires && moment().unix() > props.form.expires), + ) /** * Check if form is locked */ - isFormLocked(): boolean { + const isFormLocked = computed(() => { const currentUserUid = getCurrentUser()?.uid ?? '' - const lockedUntil = this.form.lockedUntil ?? -1 + const lockedUntil = props.form.lockedUntil ?? -1 return ( lockedUntil === 0 || (lockedUntil > moment().unix() - && this.form.lockedBy !== currentUserUid) + && props.form.lockedBy !== currentUserUid) ) - }, + }) /** * Return form title, or placeholder if not set * * @return */ - formTitle(): string { - if (this.form.title) { - return this.form.title + const formTitle = computed(() => { + if (props.form.title) { + return props.form.title } return t('forms', 'New form') - }, + }) /** * Return expiration details for subtitle */ - formSubtitle(): string { - if (this.form.state === FormState.FormClosed) { - // TRANSLATORS: The form was closed manually so it does not take new submissions + const formSubtitle = computed(() => { + if (props.form.state === FormState.FormClosed) { return t('forms', 'Form closed') } - if (this.form.expires) { - const relativeDate = moment(this.form.expires, 'X') + if (props.form.expires) { + const relativeDate = moment(props.form.expires, 'X') .locale(window.OC.getLanguage()) .fromNow() - if (this.isExpired) { + if (isExpired.value) { return t('forms', 'Expired {relativeDate}', { relativeDate, }) @@ -260,77 +240,97 @@ export default defineComponent({ return t('forms', 'Expires {relativeDate}', { relativeDate }) } return '' - }, + }) /** * Return, if form has Subtitle */ - hasSubtitle(): boolean { - return this.formSubtitle !== '' - }, + const hasSubtitle = computed(() => formSubtitle.value !== '') /** * Route to use, depending on readOnly * * @return Route to 'submit' or 'formRoot' */ - routerTarget(): NavigationTarget { - if (this.readOnly) { + const routerTarget = computed(() => { + if (props.readOnly) { return 'submit' } return 'formRoot' - }, - }, + }) - methods: { /** * Closes the App-Navigation on mobile-devices */ - mobileCloseNavigation(): void { - this.$emit('mobileCloseNavigation') - }, + const mobileCloseNavigation = (): void => { + emit('mobileCloseNavigation') + } - onShareForm(): void { - this.$emit('openSharing', this.form.hash) - }, + const onShareForm = (): void => { + emit('openSharing', props.form.hash) + } - onCloneForm(): void { - this.$emit('clone', this.form.id) - }, + const onCloneForm = (): void => { + emit('clone', props.form.id) + } + + const onDeleteForm = async (): Promise => { + loading.value = true + try { + await axios.delete( + generateOcsUrl('apps/forms/api/v3/forms/{id}', { + id: props.form.id, + }), + ) + emit('delete', props.form.id) + } catch (error) { + const response = (error as { response?: unknown }).response + logger.error(`Error while deleting ${formTitle.value}`, { + error: response, + }) + showError( + t('forms', 'Error while deleting {title}', { + title: formTitle.value, + }), + ) + } finally { + loading.value = false + } + } - async onConfirmDelete(): Promise { + const onConfirmDelete = async (): Promise => { const shouldDelete = await showConfirmation({ name: t('forms', 'Delete form'), text: t('forms', 'Are you sure you want to delete {title}?', { - title: this.formTitle, + title: formTitle.value, }), labelConfirm: t('forms', 'Delete form'), labelReject: t('forms', 'Cancel'), }) if (shouldDelete) { - await this.onDeleteForm() + await onDeleteForm() } - }, + } - async onToggleArchive(): Promise { + const onToggleArchive = async (): Promise => { try { // TODO: add loading status feedback ? await axios.patch( generateOcsUrl('apps/forms/api/v3/forms/{id}', { - id: this.form.id, + id: props.form.id, }), { keyValuePairs: { - state: this.isArchived + state: isArchived.value ? FormState.FormClosed : FormState.FormArchived, }, }, ) - ;(this.form as FormsForm).state = this.isArchived + ;(props.form as FormsForm).state = isArchived.value ? FormState.FormClosed : FormState.FormArchived } catch (error) { @@ -339,31 +339,37 @@ export default defineComponent({ }) showError(t('forms', 'Error changing archived state of form')) } - }, + } - async onDeleteForm(): Promise { - this.loading = true - try { - await axios.delete( - generateOcsUrl('apps/forms/api/v3/forms/{id}', { - id: this.form.id, - }), - ) - this.$emit('delete', this.form.id) - } catch (error) { - const response = (error as { response?: unknown }).response - logger.error(`Error while deleting ${this.formTitle}`, { - error: response, - }) - showError( - t('forms', 'Error while deleting {title}', { - title: this.formTitle, - }), - ) - } finally { - this.loading = false - } - }, + return { + t, + FormsIcon, + IconArchive, + IconArchiveOff, + IconCheck, + IconContentCopy, + IconDelete, + IconPencil, + IconPoll, + IconShareVariant, + loading, + canEdit, + canSeeResults, + isActive, + isArchived, + isExpired, + isFormLocked, + formTitle, + formSubtitle, + hasSubtitle, + routerTarget, + mobileCloseNavigation, + onShareForm, + onCloneForm, + onConfirmDelete, + onToggleArchive, + onDeleteForm, + } }, }) diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue index f8ce9a6b2..dad64dcb5 100644 --- a/src/components/Questions/AnswerInput.vue +++ b/src/components/Questions/AnswerInput.vue @@ -97,8 +97,7 @@ import { t } from '@nextcloud/l10n' import { generateOcsUrl } from '@nextcloud/router' import debounce from 'debounce' import PQueue from 'p-queue' -import { markRaw } from 'vue' -import { defineComponent } from 'vue' +import { computed, defineComponent, markRaw, nextTick, ref, watch } from 'vue' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcActions from '@nextcloud/vue/components/NcActions' import NcButton from '@nextcloud/vue/components/NcButton' @@ -174,159 +173,136 @@ export default defineComponent({ 'moveUp', ], - setup() { - return { - IconArrowDown, - IconArrowUp, - IconDelete, - IconDragIndicator, - IconPlus, - t, - } - }, - - data() { - return { - queue: null as PQueue | null, - debounceOnInput: undefined as ((event: InputEvent) => void) | undefined, - isIMEComposing: false as boolean, - localText: (this.answer as FormsOption | undefined)?.text ?? '', - } - }, - - computed: { - canCreateLocalAnswer(): boolean { - if ((this.answer as FormsOption).local) { - return !!this.localText?.trim() + setup(props, { emit }) { + // markRaw: PQueue relies on private class fields, which break when Vue wraps it in a reactive proxy + const queue = ref(markRaw(new PQueue({ concurrency: 1 }))) + const input = ref(null) + const buttonOptionDown = ref<{ $el?: HTMLElement } | null>(null) + const buttonOptionUp = ref<{ $el?: HTMLElement } | null>(null) + const isIMEComposing = ref(false) + const localText = ref((props.answer as FormsOption | undefined)?.text ?? '') + + const canCreateLocalAnswer = computed(() => { + if ((props.answer as FormsOption).local) { + return !!localText.value.trim() } - return !!(this.answer as FormsOption).text?.trim() - }, + return !!(props.answer as FormsOption).text?.trim() + }) - ariaLabel(): string { - const answer = this.answer as FormsOption + const ariaLabel = computed(() => { + const answer = props.answer as FormsOption if (answer.local) { - if (this.optionType === OptionType.Column) { + if (props.optionType === OptionType.Column) { return t('forms', 'Add a new column') } - if (this.optionType === OptionType.Row) { + if (props.optionType === OptionType.Row) { return t('forms', 'Add a new row') } return t('forms', 'Add a new answer option') } - if (this.optionType === OptionType.Column) { + if (props.optionType === OptionType.Column) { return t('forms', 'The text of column {index}', { - index: this.index + 1, + index: props.index + 1, }) } - if (this.optionType === OptionType.Row) { + if (props.optionType === OptionType.Row) { return t('forms', 'The text of row {index}', { - index: this.index + 1, + index: props.index + 1, }) } return t('forms', 'The text of option {index}', { - index: this.index + 1, + index: props.index + 1, }) - }, + }) - optionDragMenuId(): string { - const answer = this.answer as FormsOption - return `q${answer.questionId}o${answer.id}o${this.optionType}__drag_menu` - }, + const optionDragMenuId = computed(() => { + const answer = props.answer as FormsOption + return `q${answer.questionId}o${answer.id}o${props.optionType}__drag_menu` + }) - placeholder(): string { - const answer = this.answer as FormsOption + const placeholder = computed(() => { + const answer = props.answer as FormsOption if (answer.local) { - if (this.optionType === OptionType.Column) { + if (props.optionType === OptionType.Column) { return t('forms', 'Add a new column') } - if (this.optionType === OptionType.Row) { + if (props.optionType === OptionType.Row) { return t('forms', 'Add a new row') } return t('forms', 'Add a new answer option') } - if (this.optionType === OptionType.Column) { - return t('forms', 'Column number {index}', { index: this.index + 1 }) + if (props.optionType === OptionType.Column) { + return t('forms', 'Column number {index}', { + index: props.index + 1, + }) } - if (this.optionType === OptionType.Row) { - return t('forms', 'Row number {index}', { index: this.index + 1 }) + if (props.optionType === OptionType.Row) { + return t('forms', 'Row number {index}', { index: props.index + 1 }) } - return t('forms', 'Answer number {index}', { index: this.index + 1 }) - }, + return t('forms', 'Answer number {index}', { index: props.index + 1 }) + }) - pseudoIcon(): string { - const answer = this.answer as FormsOption + const pseudoIcon = computed(() => { + const answer = props.answer as FormsOption if (answer.local) { return IconPlus } - if (this.optionType === OptionType.Column) { + if (props.optionType === OptionType.Column) { return IconTableColumn } - if (this.optionType === OptionType.Row) { + if (props.optionType === OptionType.Row) { return IconTableRow } - if (this.isRanking) { + if (props.isRanking) { return IconDragIndicator } - return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline - }, - }, + return props.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline + }) - watch: { // Keep localText in sync when the parent replaces/updates the answer prop - answer: { - handler(newVal: FormsOption) { - this.localText = newVal?.text ?? '' + watch( + () => props.answer, + (newVal: FormsOption) => { + localText.value = newVal?.text ?? '' }, - - deep: true, - }, - }, - - created(): void { - this.queue = markRaw(new PQueue({ concurrency: 1 })) + { deep: true }, + ) // As data instead of method, to have a separate debounce per AnswerInput - this.debounceOnInput = debounce((event: InputEvent) => { - const queue = this.queue - if (!queue) { - return - } - return queue.add(() => this.onInput(event)) + const debounceOnInput = debounce((event: InputEvent) => { + void queue.value.add(() => onInput(event)) }, INPUT_DEBOUNCE_MS) - }, - methods: { - handleTabbing(): void { - this.$emit('tabbedOut', this.optionType) - }, + const handleTabbing = (): void => { + emit('tabbedOut', props.optionType) + } /** - * Focus the input + * Focus the current answer input. */ - focus(): void { - const input = this.$refs.input as unknown as HTMLInputElement | undefined - input?.focus() - }, + const focus = (): void => { + input.value?.focus() + } /** - * Option changed, processing the data + * Handle text input change from the form field. * - * @param event The input event that triggered adding a new entry + * @param event The input event that triggered the save. */ - async onInput( + async function onInput( event: InputEvent | { target: HTMLInputElement; isComposing?: boolean }, ): Promise { const target = event.target as HTMLInputElement | null @@ -334,159 +310,139 @@ export default defineComponent({ return } - const answer = this.answer as FormsOption + const answer = props.answer as FormsOption if (answer.local) { - this.localText = target.value + localText.value = target.value return } - if (!event.isComposing && !this.isIMEComposing && target.value !== '') { + if (!event.isComposing && !isIMEComposing.value && target.value !== '') { // clone answer const answerCopy = { ...answer } - const input = this.$refs.input as unknown as - HTMLInputElement | undefined - if (!input) { + if (!input.value) { return } - answerCopy.text = input.value + answerCopy.text = input.value.value - await this.updateAnswer(answerCopy) + await updateAnswer(answerCopy) // Forward changes, but use current answer.text to avoid erasing // any in-between changes while updating the answer - answerCopy.text = input.value - this.$emit('update:answer', this.index, answerCopy) + answerCopy.text = input.value.value + emit('update:answer', props.index, answerCopy) } - }, + } /** * Handle Enter key: create local answer or move focus * - * @param e the keydown event + * @param e The keyboard event. */ - onEnter(e: KeyboardEvent): void { - if ((this.answer as FormsOption).local) { - this.createLocalAnswer(e) + const onEnter = (e: KeyboardEvent): void => { + if ((props.answer as FormsOption).local) { + void createLocalAnswer(e) return } - this.focusNextInput(e) - }, + focusNextInput(e) + } /** - * Create a new local answer option from the current input + * Create a new local answer option from the current input value. * - * @param e the triggering event + * @param e The triggering event, if any. */ - async createLocalAnswer( + async function createLocalAnswer( e?: Event & { isComposing?: boolean }, ): Promise { - if (this.isIMEComposing || e?.isComposing) { + if (isIMEComposing.value || e?.isComposing) { return } - const value = this.localText ?? '' + const value = localText.value ?? '' if (!value.trim()) { return } const answer = { - ...(this.answer as FormsOption), + ...(props.answer as FormsOption), text: value, local: false, } - // Prevent any queued debounced PATCHes from running while creating - const queue = this.queue - if (!queue) { - return - } - queue.pause() + queue.value.pause() try { - const newAnswer = await this.createAnswer(answer) - // Forward changes, but use current answer.text to avoid erasing // any in-between changes while creating the answer - const input = this.$refs.input as unknown as - HTMLInputElement | undefined - if (!input) { + const newAnswer = await createAnswer(answer) + if (!input.value) { return } - newAnswer.text = input.value - this.localText = '' - - this.$emit('createAnswer', this.index, newAnswer) + newAnswer.text = input.value.value + localText.value = '' + emit('createAnswer', props.index, newAnswer) } finally { // Clear pending update tasks (stale PATCHes) before resuming processing - queue.clear() - queue.start() + queue.value.clear() + queue.value.start() } - }, + } /** - * Request a new answer + * Move focus to the next answer input when Enter is pressed. * - * @param e the triggering event + * @param e The keyboard event. */ - focusNextInput(e: Event & { isComposing?: boolean }): void { - if (this.isIMEComposing || e?.isComposing) { + function focusNextInput(e: Event & { isComposing?: boolean }): void { + if (isIMEComposing.value || e?.isComposing) { return } - if (this.index <= this.maxIndex) { - this.$emit('focusNext', this.index, this.optionType) + if (props.index <= props.maxIndex) { + emit('focusNext', props.index, props.optionType) } - }, + } /** - * Emit a delete request for this answer - * when pressing the delete key on an empty input + * Remove the current option when the delete action is triggered. * - * @param e the event + * @param e The input or button event. */ - async deleteEntry( + const deleteEntry = async ( e: Event & { isComposing?: boolean; type: string }, - ): Promise { - if (this.isIMEComposing || e?.isComposing) { + ): Promise => { + if (isIMEComposing.value || e?.isComposing) { return } - if ((this.answer as FormsOption).local) { + if ((props.answer as FormsOption).local) { return } - const input = this.$refs.input as unknown as HTMLInputElement | undefined - if (e.type !== 'click' && (input?.value.length ?? 0) !== 0) { + if (e.type !== 'click' && (input.value?.value.length ?? 0) !== 0) { return } // Dismiss delete key action e.preventDefault() - - // do this in queue to prevent race conditions between PATCH and DELETE - const queue = this.queue - if (!queue) { - return - } - queue.add(() => { - this.$emit('delete', this.answer as FormsOption) - // Prevent any patch requests - queue.pause() - queue.clear() + void queue.value.add(() => { + emit('delete', props.answer as FormsOption) + queue.value.pause() + queue.value.clear() }) - }, + } /** - * Create an unsynced answer to the server + * Save a newly created option to the server. * - * @param answer the answer to sync - * @return answer + * @param answer The answer payload to create. + * @return The saved server response item. */ - async createAnswer(answer: FormsOption): Promise { + async function createAnswer(answer: FormsOption): Promise { try { const response = await axios.post( generateOcsUrl( 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options', { - id: this.formId, + id: props.formId, questionId: answer.questionId, }, ), @@ -506,21 +462,20 @@ export default defineComponent({ } return answer - }, + } /** - * Save to the server, only do it after 500ms - * of no change + * Persist a changed answer text to the server after debounce. * - * @param answer the answer to sync + * @param answer The updated answer payload. */ - async updateAnswer(answer: FormsOption): Promise { + async function updateAnswer(answer: FormsOption): Promise { try { await axios.patch( generateOcsUrl( 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options/{optionId}', { - id: this.formId, + id: props.formId, questionId: answer.questionId, optionId: answer.id, }, @@ -536,52 +491,93 @@ export default defineComponent({ logger.error('Error while saving answer', { answer, error }) showError(t('forms', 'Error while saving the answer')) } - }, + } /** - * Reorder option but keep focus on the button + * Move the current answer down in the list. */ - onMoveDown(): void { - this.$emit('moveDown') - this.focusButton( - this.index < this.maxIndex - 1 + const onMoveDown = (): void => { + emit('moveDown') + focusButton( + props.index < props.maxIndex - 1 ? 'buttonOptionDown' : 'buttonOptionUp', ) - }, + } - onMoveUp(): void { - this.$emit('moveUp') - this.focusButton(this.index > 1 ? 'buttonOptionUp' : 'buttonOptionDown') - }, + /** + * Move the current answer up in the list. + */ + const onMoveUp = (): void => { + emit('moveUp') + focusButton(props.index > 1 ? 'buttonOptionUp' : 'buttonOptionDown') + } - focusButton(refName: 'buttonOptionDown' | 'buttonOptionUp'): void { - this.$nextTick(() => { - const button = this.$refs[refName] as unknown as - { $el?: HTMLElement } | undefined + /** + * Restore focus to the move button after reordering. + * + * @param refName The target button reference name. + */ + function focusButton(refName: 'buttonOptionDown' | 'buttonOptionUp'): void { + nextTick(() => { + const button = + refName === 'buttonOptionDown' + ? buttonOptionDown.value + : buttonOptionUp.value button?.$el?.focus() }) - }, + } /** - * Handle composition start event for IME inputs + * Track the start of an IME composition sequence. */ - onCompositionStart(): void { - this.isIMEComposing = true - }, + const onCompositionStart = (): void => { + isIMEComposing.value = true + } /** - * Handle composition end event for IME inputs + * Flush a pending input after IME composition ends. * - * @param event The input event that triggered adding a new entry + * @param event The composition event. */ - onCompositionEnd(event: CompositionEvent & { isComposing?: boolean }): void { + const onCompositionEnd = ( + event: CompositionEvent & { isComposing?: boolean }, + ): void => { const target = event.target as HTMLInputElement | null - this.isIMEComposing = false + isIMEComposing.value = false if (!event.isComposing && target) { - this.onInput({ target, isComposing: event.isComposing }) + void onInput({ target, isComposing: event.isComposing }) } - }, + } + + return { + IconArrowDown, + IconArrowUp, + IconDelete, + IconDragIndicator, + IconPlus, + buttonOptionDown, + buttonOptionUp, + canCreateLocalAnswer, + ariaLabel, + optionDragMenuId, + placeholder, + pseudoIcon, + input, + localText, + isIMEComposing, + debounceOnInput, + handleTabbing, + focus, + onEnter, + createLocalAnswer, + deleteEntry, + onMoveDown, + onMoveUp, + onCompositionStart, + onCompositionEnd, + t, + } }, }) diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index b16e2f85d..2faad8007 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -281,7 +281,7 @@ import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import { vOnClickOutside as ClickOutside } from '@vueuse/components' -import { defineComponent } from 'vue' +import { computed, defineComponent, inject, ref, watch } from 'vue' import NcButton from '@nextcloud/vue/components/NcButton' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker' @@ -329,8 +329,6 @@ export default defineComponent({ ClickOutside, }, - inject: ['$markdownit'], - props: { form: { type: Object, @@ -350,210 +348,162 @@ export default defineComponent({ emits: ['update:formProp'], - setup() { + setup(props, { emit }) { const { SHARE_TYPES } = useShareTypes() - - return { - t, - SHARE_TYPES, - } - }, - - data(): { - formatter: { - stringify: (datetime: Date | [Date, Date] | null) => string - parse: (value: number) => Date - } - appConfig: SettingsAppConfig - maxStringLengths: Record - editMessage: boolean - svgLockOpen: string - confirmationEmailSubject: string - confirmationEmailBody: string - } { - return { - formatter: { - stringify: (datetime: Date | [Date, Date] | null) => { - if (datetime instanceof Date) { - return this.stringifyDate(datetime) - } - return this.stringifyDate(new Date()) - }, - - parse: this.parseTimestampToDate, - }, - - appConfig: loadState(formsAppName, 'appConfig') as SettingsAppConfig, - maxStringLengths: loadState(formsAppName, 'maxStringLengths'), - /** If custom submission message is shown as input or rendered markdown */ - editMessage: false, - svgLockOpen, - confirmationEmailSubject: '', - confirmationEmailBody: '', - } - }, - - computed: { - isCurrentUserOwner(): boolean { - return getCurrentUser()?.uid === this.form.ownerId - }, - - isFormLockedPermanently(): boolean { - return this.locked && this.form.lockedUntil === 0 - }, + const appConfig = ref( + loadState(formsAppName, 'appConfig') as SettingsAppConfig, + ) + const maxStringLengths = ref>( + loadState(formsAppName, 'maxStringLengths'), + ) + const editMessage = ref(false) + const confirmationEmailSubject = ref('') + const confirmationEmailBody = ref('') + const isCurrentUserOwner = computed( + () => getCurrentUser()?.uid === props.form.ownerId, + ) + const isFormLockedPermanently = computed( + () => props.locked && props.form.lockedUntil === 0, + ) /** * If the form has a custom submission message or the user wants to add one (settings switch) */ - hasCustomSubmissionMessage(): boolean { - return ( - this.form?.submissionMessage !== undefined - && this.form?.submissionMessage !== null - ) - }, + const hasCustomSubmissionMessage = computed( + () => + props.form?.submissionMessage !== undefined + && props.form?.submissionMessage !== null, + ) + const hasPublicLink = computed( + () => + props.form.shares.filter( + (share) => share.shareType === SHARE_TYPES.SHARE_TYPE_LINK, + ).length !== 0, + ) /** * Submit Multiple is disabled, if it cannot be controlled. */ - disableSubmitMultiple(): boolean { - return this.hasPublicLink || this.form.isAnonymous - }, - - disableSubmitMultipleExplanation(): string { - if (this.disableSubmitMultiple) { + const disableSubmitMultiple = computed( + () => hasPublicLink.value || props.form.isAnonymous, + ) + const disableSubmitMultipleExplanation = computed(() => { + if (disableSubmitMultiple.value) { return t( 'forms', 'This can not be controlled, if the form has a public link or stores responses anonymously.', ) } return '' - }, - - hasPublicLink(): boolean { - return ( - this.form.shares.filter( - (share) => share.shareType === this.SHARE_TYPES.SHARE_TYPE_LINK, - ).length !== 0 - ) - }, + }) // If disabled, submitMultiple will be casted to true - submitMultiple(): boolean { - return this.disableSubmitMultiple || this.form.submitMultiple - }, - - formExpires(): boolean { - return this.form.expires !== 0 - }, - - formArchived(): boolean { - return this.form.state === FormState.FormArchived - }, - - formClosed(): boolean { - return this.form.state !== FormState.FormActive - }, - - hasMaxSubmissions(): boolean { + const submitMultiple = computed( + () => disableSubmitMultiple.value || props.form.submitMultiple, + ) + const formExpires = computed(() => props.form.expires !== 0) + const formArchived = computed( + () => props.form.state === FormState.FormArchived, + ) + const formClosed = computed(() => props.form.state !== FormState.FormActive) + const hasMaxSubmissions = computed( + () => + props.form.maxSubmissions !== null + && props.form.maxSubmissions !== undefined, + ) + const maxSubmissionsValue = computed(() => props.form.maxSubmissions ?? 1) + const isExpired = computed( + () => props.form.expires && moment().unix() > props.form.expires, + ) + const expirationDate = computed(() => + moment(props.form.expires, 'X').toDate(), + ) + const injectMarkdownit = (): MarkdownRenderer => { return ( - this.form.maxSubmissions !== null - && this.form.maxSubmissions !== undefined + (inject('$markdownit', { + render: (input: string) => input, + }) as MarkdownRenderer) ?? { + render: (input: string) => input, + } ) - }, - - maxSubmissionsValue(): number { - return this.form.maxSubmissions ?? 1 - }, - - isExpired(): boolean { - return this.form.expires && moment().unix() > this.form.expires - }, - - expirationDate(): Date { - return moment(this.form.expires, 'X').toDate() - }, + } /** * The submission message rendered as HTML */ - submissionMessageHTML(): string { - return (this.$markdownit as MarkdownRenderer).render( - this.form.submissionMessage || '', - ) - }, - - emailBodyPlaceholder(): string { - return t( + const submissionMessageHTML = computed(() => { + const markdownit = injectMarkdownit() + return markdownit.render(props.form.submissionMessage || '') + }) + const emailBodyPlaceholder = computed(() => + t( 'forms', 'Hello,\n\nThank you for submitting the form "{formTitle}".\n\nBest regards', - ) - }, - - emailQuestionCount(): number { - return this.confirmationEmailQuestions.length - }, - - confirmationEmailQuestions(): FormsQuestion[] { - const questions = this.form?.questions || [] + ), + ) + const confirmationEmailQuestions = computed(() => { + const questions = props.form?.questions || [] return questions.filter( (question: FormsQuestion) => question.type === 'short' && question.extraSettings?.validationType === 'email', ) - }, - - selectedConfirmationEmailQuestion(): FormsQuestion | null { - const selectedQuestion = this.confirmationEmailQuestions.find( + }) + const emailQuestionCount = computed( + () => confirmationEmailQuestions.value.length, + ) + const selectedConfirmationEmailQuestion = computed(() => { + const selectedQuestion = confirmationEmailQuestions.value.find( (question: FormsQuestion) => - question.id === this.form.confirmationEmailQuestionId, + question.id === props.form.confirmationEmailQuestionId, ) if (selectedQuestion) { return selectedQuestion } if ( - this.form.confirmationEmailQuestionId === null - && this.emailQuestionCount === 1 + props.form.confirmationEmailQuestionId === null + && emailQuestionCount.value === 1 ) { - return this.confirmationEmailQuestions[0] + return confirmationEmailQuestions.value[0] } - return null - }, - - selectedConfirmationEmailQuestionId(): number | string { - return ( - this.form.confirmationEmailQuestionId - ?? this.selectedConfirmationEmailQuestion?.id - ?? '' - ) - }, - - confirmationEmailQuestionOptions(): ConfirmationEmailQuestionOption[] { - return this.confirmationEmailQuestions.map((question) => ({ + }) + const selectedConfirmationEmailQuestionId = computed( + () => + props.form.confirmationEmailQuestionId + ?? selectedConfirmationEmailQuestion.value?.id + ?? '', + ) + const confirmationEmailQuestionLabel = (question: FormsQuestion): string => + question.text || t('forms', 'Untitled question') + const confirmationEmailQuestionOptions = computed(() => + confirmationEmailQuestions.value.map((question) => ({ id: question.id, - label: this.confirmationEmailQuestionLabel(question), - })) - }, - - selectedConfirmationEmailQuestionOption(): ConfirmationEmailQuestionOption | null { - return ( - this.confirmationEmailQuestionOptions.find( + label: confirmationEmailQuestionLabel(question), + })), + ) + const selectedConfirmationEmailQuestionOption = computed( + () => + confirmationEmailQuestionOptions.value.find( (question) => - question.id === this.selectedConfirmationEmailQuestionId, - ) || null - ) - }, - - confirmationEmailErrorText(): string { - if (this.emailQuestionCount === 0) { + question.id === selectedConfirmationEmailQuestionId.value, + ) || null, + ) + const requiresConfirmationEmailQuestionIdSelection = computed( + () => + emailQuestionCount.value > 1 + && !selectedConfirmationEmailQuestion.value, + ) + const confirmationEmailErrorText = computed(() => { + if (emailQuestionCount.value === 0) { return t( 'forms', 'Add at least one email field before confirmation emails can be used.', ) } - if (this.requiresConfirmationEmailQuestionIdSelection) { + if (requiresConfirmationEmailQuestionIdSelection.value) { return t( 'forms', 'Select which email field should receive confirmation emails before finishing this setup.', @@ -561,289 +511,307 @@ export default defineComponent({ } return '' - }, - - confirmationEmailNoteCardType(): 'warning' | 'info' { - if (this.requiresConfirmationEmailQuestionIdSelection) { + }) + const confirmationEmailNoteCardType = computed<'warning' | 'info'>(() => { + if (requiresConfirmationEmailQuestionIdSelection.value) { return 'warning' } return 'info' - }, + }) + const isConfirmationEmailConfigurationBlocked = computed( + () => + props.form.confirmationEmailEnabled + && (emailQuestionCount.value === 0 + || requiresConfirmationEmailQuestionIdSelection.value), + ) - requiresConfirmationEmailQuestionIdSelection(): boolean { - return ( - this.emailQuestionCount > 1 - && !this.selectedConfirmationEmailQuestion - ) - }, + /** + * Datepicker timestamp to string + * + * @param datetime the datepicker Date + * @return + */ + const stringifyDate = (datetime: Date): string => { + const date = moment(datetime).format('LLL') + if (isExpired.value) { + return t('forms', 'Expired on {date}', { date }) + } + return t('forms', 'Expires on {date}', { date }) + } - isConfirmationEmailConfigurationBlocked(): boolean { - return ( - this.form.confirmationEmailEnabled - && (this.emailQuestionCount === 0 - || this.requiresConfirmationEmailQuestionIdSelection) - ) - }, - }, + /** + * Form expires timestamp to Date of the datepicker + * + * @param value the expires timestamp + * @return + */ + const parseTimestampToDate = (value: number): Date => + moment(value, 'X').toDate() - watch: { - 'form.confirmationEmailSubject': { - handler(val: string | null | undefined) { - this.confirmationEmailSubject = val || '' - }, + /** + * Prevent selecting a day before today + * + * @param datetime the datepicker Date + * @return + */ + const notBeforeToday = (datetime: Date): boolean => + datetime < moment().add(-1, 'day').toDate() - immediate: true, - }, + /** + * Prevent selecting a time before the current one + * + * @param datetime the datepicker Date + * @return + */ + const notBeforeNow = (datetime: Date): boolean => + datetime < moment().toDate() - 'form.confirmationEmailBody': { - handler(val: string | null | undefined) { - this.confirmationEmailBody = val || '' + const saveConfirmationEmailQuestionId = ( + selectedQuestionId: number | null, + ): void => { + if (props.form.confirmationEmailQuestionId === selectedQuestionId) { + return + } + emit( + 'update:formProp', + 'confirmationEmailQuestionId', + selectedQuestionId, + ) + } + watch( + () => props.form.confirmationEmailSubject, + (val) => { + confirmationEmailSubject.value = val || '' }, - - immediate: true, - }, - - confirmationEmailQuestions: { - handler() { - const selectedRecipientId = this.form.confirmationEmailQuestionId + { immediate: true }, + ) + watch( + () => props.form.confirmationEmailBody, + (val) => { + confirmationEmailBody.value = val || '' + }, + { immediate: true }, + ) + watch( + confirmationEmailQuestions, + () => { + const selectedRecipientId = props.form.confirmationEmailQuestionId const hasValidSelectedRecipient = selectedRecipientId !== null - && this.confirmationEmailQuestions.some( + && confirmationEmailQuestions.value.some( (question) => question.id === selectedRecipientId, ) if (selectedRecipientId !== null && !hasValidSelectedRecipient) { - if (this.emailQuestionCount === 1) { - this.saveConfirmationEmailQuestionId( - this.confirmationEmailQuestions[0].id, + if (emailQuestionCount.value === 1) { + saveConfirmationEmailQuestionId( + confirmationEmailQuestions.value[0].id, ) } else { - this.saveConfirmationEmailQuestionId(null) + saveConfirmationEmailQuestionId(null) } return } if ( - this.form.confirmationEmailEnabled - && this.emailQuestionCount === 1 - && this.form.confirmationEmailQuestionId === null + props.form.confirmationEmailEnabled + && emailQuestionCount.value === 1 + && props.form.confirmationEmailQuestionId === null ) { - this.saveConfirmationEmailQuestionId( - this.confirmationEmailQuestions[0].id, + saveConfirmationEmailQuestionId( + confirmationEmailQuestions.value[0].id, ) } }, - - deep: true, - }, - }, - - methods: { - confirmationEmailQuestionLabel(question: FormsQuestion): string { - return question.text || t('forms', 'Untitled question') - }, + { deep: true }, + ) /** * Save Form-Properties * * @param checked New Checkbox/Switch Value to use */ - onAnonChange(checked: boolean): void { - this.$emit('update:formProp', 'isAnonymous', checked) - }, - - onSubmitMultipleChange(checked: boolean): void { - this.$emit('update:formProp', 'submitMultiple', checked) - }, - - onAllowEditSubmissionsChange(checked: boolean): void { - this.$emit('update:formProp', 'allowEditSubmissions', checked) - }, - - onAllowCommentsChange(checked: boolean): void { - this.$emit('update:formProp', 'allowComments', checked) - }, - - onFormExpiresChange(checked: boolean): void { + const onAnonChange = (checked: boolean): void => { + emit('update:formProp', 'isAnonymous', checked) + } + const onSubmitMultipleChange = (checked: boolean): void => { + emit('update:formProp', 'submitMultiple', checked) + } + const onAllowEditSubmissionsChange = (checked: boolean): void => { + emit('update:formProp', 'allowEditSubmissions', checked) + } + const onAllowCommentsChange = (checked: boolean): void => { + emit('update:formProp', 'allowComments', checked) + } + const onFormExpiresChange = (checked: boolean): void => { if (checked) { - this.$emit( - 'update:formProp', - 'expires', - moment().add(1, 'hour').unix(), - ) // Expires in one hour. + emit('update:formProp', 'expires', moment().add(1, 'hour').unix()) } else { - this.$emit('update:formProp', 'expires', 0) + emit('update:formProp', 'expires', 0) } - }, - - onShowExpirationChange(checked: boolean): void { - this.$emit('update:formProp', 'showExpiration', checked) - }, + } + const onShowExpirationChange = (checked: boolean): void => { + emit('update:formProp', 'showExpiration', checked) + } /** * On date picker change * * @param datetime the expiration Date */ - onExpirationDateChange(datetime: Date | [Date, Date] | null): void { + const onExpirationDateChange = ( + datetime: Date | [Date, Date] | null, + ): void => { if (!(datetime instanceof Date)) { return } - this.$emit( + emit( 'update:formProp', 'expires', parseInt(moment(datetime).format('X')), ) - }, - - onMaxSubmissionsChange(checked: boolean): void { - this.$emit('update:formProp', 'maxSubmissions', checked ? 1 : null) - }, - - onMaxSubmissionsValueChange(value: string | number): void { + } + const onMaxSubmissionsChange = (checked: boolean): void => { + emit('update:formProp', 'maxSubmissions', checked ? 1 : null) + } + const onMaxSubmissionsValueChange = (value: string | number): void => { const parsedValue = Number(value) if (parsedValue > 0) { - this.$emit('update:formProp', 'maxSubmissions', parsedValue) + emit('update:formProp', 'maxSubmissions', parsedValue) } - }, - - onFormClosedChange(isClosed: boolean): void { - this.$emit( + } + const onFormClosedChange = (isClosed: boolean): void => { + emit( 'update:formProp', 'state', isClosed ? FormState.FormClosed : FormState.FormActive, ) - }, - - onFormLockChange(locked: boolean): void { - this.$emit('update:formProp', 'lockedUntil', locked ? 0 : null) - }, - - onFormArchivedChange(isArchived: boolean): void { - this.$emit( + } + const onFormLockChange = (locked: boolean): void => { + emit('update:formProp', 'lockedUntil', locked ? 0 : null) + } + const onFormArchivedChange = (isArchived: boolean): void => { + emit( 'update:formProp', 'state', isArchived ? FormState.FormArchived : FormState.FormClosed, ) - }, - - onSubmissionMessageChange(event: Event): void { - this.$emit( + } + const onSubmissionMessageChange = (event: Event): void => { + emit( 'update:formProp', 'submissionMessage', (event.target as HTMLTextAreaElement).value, ) - }, + } /** * Enable or disable the whole custom submission message * Disabled means the value is set to null. */ - onUpdateHasCustomSubmissionMessage(): void { - if (this.hasCustomSubmissionMessage) { - this.$emit('update:formProp', 'submissionMessage', null) + const onUpdateHasCustomSubmissionMessage = (): void => { + if (hasCustomSubmissionMessage.value) { + emit('update:formProp', 'submissionMessage', null) } else { - this.$emit('update:formProp', 'submissionMessage', '') + emit('update:formProp', 'submissionMessage', '') } - }, - - onConfirmationEmailEnabledChange(checked: boolean): void { + } + const onConfirmationEmailEnabledChange = (checked: boolean): void => { if ( checked - && this.form.confirmationEmailQuestionId === null - && this.emailQuestionCount === 1 + && props.form.confirmationEmailQuestionId === null + && emailQuestionCount.value === 1 ) { - this.saveConfirmationEmailQuestionId( - this.confirmationEmailQuestions[0].id, + saveConfirmationEmailQuestionId( + confirmationEmailQuestions.value[0].id, ) } - - this.$emit('update:formProp', 'confirmationEmailEnabled', checked) - }, - - onConfirmationEmailSubjectChange(): void { - this.$emit( + emit('update:formProp', 'confirmationEmailEnabled', checked) + } + const onConfirmationEmailSubjectChange = (): void => { + emit( 'update:formProp', 'confirmationEmailSubject', - this.confirmationEmailSubject, + confirmationEmailSubject.value, ) - }, - - onConfirmationEmailBodyChange(): void { - this.$emit( + } + const onConfirmationEmailBodyChange = (): void => { + emit( 'update:formProp', 'confirmationEmailBody', - this.confirmationEmailBody, + confirmationEmailBody.value, ) - }, - - onConfirmationEmailQuestionIdSelectionChange( + } + const onConfirmationEmailQuestionIdSelectionChange = ( option: ConfirmationEmailQuestionOption | null, - ): void { + ): void => { const questionId = option?.id ?? null if (questionId === null) { return } + saveConfirmationEmailQuestionId(questionId) + } - this.saveConfirmationEmailQuestionId(questionId) - }, - - saveConfirmationEmailQuestionId(selectedQuestionId: number | null): void { - if (this.form.confirmationEmailQuestionId === selectedQuestionId) { - return - } - - this.$emit( - 'update:formProp', - 'confirmationEmailQuestionId', - selectedQuestionId, - ) - }, - - /** - * Datepicker timestamp to string - * - * @param datetime the datepicker Date - * @return - */ - stringifyDate(datetime: Date): string { - const date = moment(datetime).format('LLL') - - if (this.isExpired) { - return t('forms', 'Expired on {date}', { date }) - } - return t('forms', 'Expires on {date}', { date }) - }, - - /** - * Form expires timestamp to Date of the datepicker - * - * @param value the expires timestamp - * @return - */ - parseTimestampToDate(value: number): Date { - return moment(value, 'X').toDate() - }, - - /** - * Prevent selecting a day before today - * - * @param datetime the datepicker Date - * @return - */ - notBeforeToday(datetime: Date): boolean { - return datetime < moment().add(-1, 'day').toDate() - }, - - /** - * Prevent selecting a time before the current one - * - * @param datetime the datepicker Date - * @return - */ - notBeforeNow(datetime: Date): boolean { - return datetime < moment().toDate() - }, + return { + t, + SHARE_TYPES, + appConfig, + maxStringLengths, + editMessage, + svgLockOpen, + confirmationEmailSubject, + confirmationEmailBody, + isCurrentUserOwner, + isFormLockedPermanently, + hasCustomSubmissionMessage, + disableSubmitMultiple, + disableSubmitMultipleExplanation, + hasPublicLink, + submitMultiple, + formExpires, + formArchived, + formClosed, + hasMaxSubmissions, + maxSubmissionsValue, + isExpired, + expirationDate, + submissionMessageHTML, + emailBodyPlaceholder, + emailQuestionCount, + confirmationEmailQuestions, + selectedConfirmationEmailQuestion, + selectedConfirmationEmailQuestionId, + confirmationEmailQuestionOptions, + selectedConfirmationEmailQuestionOption, + confirmationEmailErrorText, + confirmationEmailNoteCardType, + requiresConfirmationEmailQuestionIdSelection, + isConfirmationEmailConfigurationBlocked, + confirmationEmailQuestionLabel, + stringifyDate, + parseTimestampToDate, + notBeforeToday, + notBeforeNow, + onAnonChange, + onSubmitMultipleChange, + onAllowEditSubmissionsChange, + onAllowCommentsChange, + onFormExpiresChange, + onShowExpirationChange, + onExpirationDateChange, + onMaxSubmissionsChange, + onMaxSubmissionsValueChange, + onFormClosedChange, + onFormLockChange, + onFormArchivedChange, + onSubmissionMessageChange, + onUpdateHasCustomSubmissionMessage, + onConfirmationEmailEnabledChange, + onConfirmationEmailSubjectChange, + onConfirmationEmailBodyChange, + onConfirmationEmailQuestionIdSelectionChange, + saveConfirmationEmailQuestionId, + } }, }) diff --git a/src/components/SidebarTabs/SharingSearchDiv.vue b/src/components/SidebarTabs/SharingSearchDiv.vue index 3a6389df1..ed187fb20 100644 --- a/src/components/SidebarTabs/SharingSearchDiv.vue +++ b/src/components/SidebarTabs/SharingSearchDiv.vue @@ -23,7 +23,7 @@ diff --git a/src/components/SidebarTabs/SharingShareDiv.vue b/src/components/SidebarTabs/SharingShareDiv.vue index 6b3c4dea4..95cc690b5 100644 --- a/src/components/SidebarTabs/SharingShareDiv.vue +++ b/src/components/SidebarTabs/SharingShareDiv.vue @@ -48,7 +48,7 @@ diff --git a/src/components/SidebarTabs/SharingSidebarTab.vue b/src/components/SidebarTabs/SharingSidebarTab.vue index d4e4eed6c..140dfe706 100644 --- a/src/components/SidebarTabs/SharingSidebarTab.vue +++ b/src/components/SidebarTabs/SharingSidebarTab.vue @@ -46,18 +46,17 @@