From 8e77c9420191777af32b60c228ce974696bb1569 Mon Sep 17 00:00:00 2001 From: Christian Hartmann Date: Fri, 21 Aug 2026 21:47:45 +0200 Subject: [PATCH] chore: migrate Question components to Composition API Signed-off-by: Christian Hartmann --- src/FormsEmptyContent.vue | 66 ++-- src/FormsSettings.vue | 210 ++++++----- src/components/ArchivedFormsModal.vue | 44 +-- src/components/OptionInputDialog.vue | 71 ++-- src/components/PaginationToolbar.vue | 43 +-- src/components/PillMenu.vue | 37 +- src/components/QRDialog.vue | 54 ++- src/components/Questions/Question.vue | 227 ++++++----- src/components/Questions/QuestionColor.vue | 67 ++-- src/components/Questions/QuestionDate.vue | 342 +++++++++-------- src/components/Questions/QuestionDropdown.vue | 143 +++---- src/components/Questions/QuestionFile.vue | 285 +++++++------- src/components/Questions/QuestionGrid.vue | 184 +++++---- .../Questions/QuestionLinearScale.vue | 265 +++++++------ src/components/Questions/QuestionLong.vue | 104 +++--- src/components/Questions/QuestionMultiple.vue | 345 +++++++++-------- src/components/Questions/QuestionRanking.vue | 351 ++++++++++-------- src/components/Questions/QuestionShort.vue | 207 ++++++----- src/components/Results/ResultsSummary.vue | 202 +++++----- src/components/Results/Submission.vue | 299 ++++++++------- src/composables/useQuestionMultiple.ts | 31 +- 21 files changed, 1893 insertions(+), 1684 deletions(-) diff --git a/src/FormsEmptyContent.vue b/src/FormsEmptyContent.vue index 60b0f4415..d9dd1e32a 100644 --- a/src/FormsEmptyContent.vue +++ b/src/FormsEmptyContent.vue @@ -21,7 +21,7 @@ import IconCheck from '@material-symbols/svg-400/outlined/check.svg?raw' import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' -import { defineComponent } from 'vue' +import { computed, defineComponent } from 'vue' import NcAppContent from '@nextcloud/vue/components/NcAppContent' import NcContent from '@nextcloud/vue/components/NcContent' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' @@ -30,6 +30,30 @@ import FormsIcon from '../img/forms-dark.svg?raw' const formsAppName = 'forms' +/** + * !! Keep Model-Names in sync with Constants EMTPY_... in lib/Constants.php !! + * Models for each EmptyContent rendering taking resp. title and subtitle + */ +const renderModels: Record< + string, + { title: string; description: string; icon: string } +> = { + notfound: { + title: t('forms', 'Form not found'), + description: t('forms', 'This form does not exist'), + icon: FormsIcon, + }, + + expired: { + title: t('forms', 'Form expired'), + description: t( + 'forms', + 'This form has expired and is no longer taking responses', + ), + icon: IconCheck, + }, +} + export default defineComponent({ name: 'FormsEmptyContent', @@ -40,42 +64,16 @@ export default defineComponent({ NcIconSvgWrapper, }, - data() { - return { - /** - * !! Keep Model-Names in sync with Constants EMTPY_... in lib/Constants.php !! - * Models for each EmptyContent rendering taking resp. title and subtitle - */ - renderModels: { - notfound: { - title: t('forms', 'Form not found'), - description: t('forms', 'This form does not exist'), - icon: FormsIcon, - }, + setup() { + const renderAs = loadState(formsAppName, 'renderAs') as string + const currentModel = computed(() => { + return renderModels[renderAs] + }) - expired: { - title: t('forms', 'Form expired'), - description: t( - 'forms', - 'This form has expired and is no longer taking responses', - ), - - icon: IconCheck, - }, - } as Record< - string, - { title: string; description: string; icon: string } - >, - - renderAs: loadState(formsAppName, 'renderAs') as string, + return { + currentModel, } }, - - computed: { - currentModel(): { title: string; description: string; icon: string } { - return this.renderModels[this.renderAs] - }, - }, }) diff --git a/src/FormsSettings.vue b/src/FormsSettings.vue index 06b63224d..6e2e1bfde 100644 --- a/src/FormsSettings.vue +++ b/src/FormsSettings.vue @@ -117,7 +117,7 @@ import { showError } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' -import { defineComponent } from 'vue' +import { defineComponent, ref } from 'vue' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcInputField from '@nextcloud/vue/components/NcInputField' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' @@ -157,34 +157,55 @@ export default defineComponent({ }, setup() { - return { - t, - } - }, - - data(): { - appConfig: AppConfig - availableGroups: GroupOption[] - confirmationEmailRateLimitInput: string - loading: Record - } { - return { - appConfig: loadState(formsAppName, 'appConfig') as AppConfig, - availableGroups: loadState( - formsAppName, - 'availableGroups', - ) as GroupOption[], - - confirmationEmailRateLimitInput: String( + const appConfig = ref( + loadState(formsAppName, 'appConfig') as AppConfig, + ) + const availableGroups = ref( + loadState(formsAppName, 'availableGroups') as GroupOption[], + ) + const confirmationEmailRateLimitInput = ref( + String( (loadState(formsAppName, 'appConfig') as AppConfig) .confirmationEmailRateLimit ?? 3, ), + ) + const loading = ref>({}) - loading: {}, + /** + * Reload the current AppConfig. Used to restore in case of saving-failure. + */ + const reloadAppConfig = async (): Promise => { + try { + const resp = await axios.get(generateUrl('apps/forms/config')) + appConfig.value = resp.data + } catch (error) { + logger.error('Error while reloading config', { error }) + showError(t('forms', 'Error while reloading config')) + } + } + + /** + * Save a key-value pair to the appConfig. + * + * @param configKey The key to store. Must be one of the used configKeys (See php-constants). + * @param configValue The value to store. + */ + const saveAppConfig = async ( + configKey: string, + configValue: unknown, + ): Promise => { + try { + await axios.patch(generateUrl('apps/forms/config'), { + configKey, + configValue, + }) + } catch (error) { + logger.error('Error while saving configuration', { error }) + showError(t('forms', 'Error while saving configuration')) + await reloadAppConfig() + } } - }, - methods: { /** * Similar procedures on**Change: * @@ -194,100 +215,91 @@ export default defineComponent({ * * @param newVal The resp. new Value to store. */ - async onRestrictCreationChange(newVal: boolean): Promise { - this.loading.restrictCreation = true - await this.saveAppConfig('restrictCreation', newVal) - this.loading.restrictCreation = false - }, + const onRestrictCreationChange = async (newVal: boolean): Promise => { + loading.value.restrictCreation = true + await saveAppConfig('restrictCreation', newVal) + loading.value.restrictCreation = false + } - async onCreationAllowedGroupsChange(newVal: GroupOption[]): Promise { - this.loading.creationAllowedGroups = true - await this.saveAppConfig( + const onCreationAllowedGroupsChange = async ( + newVal: GroupOption[], + ): Promise => { + loading.value.creationAllowedGroups = true + await saveAppConfig( 'creationAllowedGroups', newVal.map((group) => group.groupId), ) - this.loading.creationAllowedGroups = false - }, + loading.value.creationAllowedGroups = false + } - async onAllowPublicLinkChange(newVal: boolean): Promise { - this.loading.allowPublicLink = true - await this.saveAppConfig('allowPublicLink', newVal) - this.loading.allowPublicLink = false - }, + const onAllowPublicLinkChange = async (newVal: boolean): Promise => { + loading.value.allowPublicLink = true + await saveAppConfig('allowPublicLink', newVal) + loading.value.allowPublicLink = false + } - async onAllowCustomPublicShareTokensChange(newVal: boolean): Promise { - this.loading.allowCustomPublicShareTokens = true - await this.saveAppConfig('allowCustomPublicShareTokens', newVal) - this.loading.allowCustomPublicShareTokens = false - }, + const onAllowCustomPublicShareTokensChange = async ( + newVal: boolean, + ): Promise => { + loading.value.allowCustomPublicShareTokens = true + await saveAppConfig('allowCustomPublicShareTokens', newVal) + loading.value.allowCustomPublicShareTokens = false + } - async onAllowPermitAllChange(newVal: boolean): Promise { - this.loading.allowPermitAll = true - await this.saveAppConfig('allowPermitAll', newVal) - this.loading.allowPermitAll = false - }, + const onAllowPermitAllChange = async (newVal: boolean): Promise => { + loading.value.allowPermitAll = true + await saveAppConfig('allowPermitAll', newVal) + loading.value.allowPermitAll = false + } - async onAllowShowToAllChange(newVal: boolean): Promise { - this.loading.allowShowToAll = true - await this.saveAppConfig('allowShowToAll', newVal) - this.loading.allowShowToAll = false - }, + const onAllowShowToAllChange = async (newVal: boolean): Promise => { + loading.value.allowShowToAll = true + await saveAppConfig('allowShowToAll', newVal) + loading.value.allowShowToAll = false + } - async onAllowConfirmationEmailChange(newVal: boolean): Promise { - this.loading.allowConfirmationEmail = true - await this.saveAppConfig('allowConfirmationEmail', newVal) - this.loading.allowConfirmationEmail = false - }, + const onAllowConfirmationEmailChange = async ( + newVal: boolean, + ): Promise => { + loading.value.allowConfirmationEmail = true + await saveAppConfig('allowConfirmationEmail', newVal) + loading.value.allowConfirmationEmail = false + } - async onConfirmationEmailRateLimitChange(): Promise { + const onConfirmationEmailRateLimitChange = async (): Promise => { const value = Math.max( 1, Math.min( 100, - parseInt(this.confirmationEmailRateLimitInput, 10) || 3, + parseInt(confirmationEmailRateLimitInput.value, 10) || 3, ), ) - this.confirmationEmailRateLimitInput = String(value) - await this.saveAppConfig('confirmationEmailRateLimit', value) - }, - - async onAllowCommentsChange(newVal: boolean): Promise { - this.loading.allowComments = true - await this.saveAppConfig('allowComments', newVal) - this.loading.allowComments = false - }, + confirmationEmailRateLimitInput.value = String(value) + await saveAppConfig('confirmationEmailRateLimit', value) + } - /** - * Save a key-value pair to the appConfig. - * - * @param configKey The key to store. Must be one of the used configKeys (See php-constants). - * @param configValue The value to store. - */ - async saveAppConfig(configKey: string, configValue: unknown): Promise { - try { - await axios.patch(generateUrl('apps/forms/config'), { - configKey, - configValue, - }) - } catch (error) { - logger.error('Error while saving configuration', { error }) - showError(t('forms', 'Error while saving configuration')) - await this.reloadAppConfig() - } - }, + const onAllowCommentsChange = async (newVal: boolean): Promise => { + loading.value.allowComments = true + await saveAppConfig('allowComments', newVal) + loading.value.allowComments = false + } - /** - * Reload the current AppConfig. Used to restore in case of saving-failure. - */ - async reloadAppConfig(): Promise { - try { - const resp = await axios.get(generateUrl('apps/forms/config')) - this.appConfig = resp.data - } catch (error) { - logger.error('Error while reloading config', { error }) - showError(t('forms', 'Error while reloading config')) - } - }, + return { + appConfig, + availableGroups, + confirmationEmailRateLimitInput, + loading, + onRestrictCreationChange, + onCreationAllowedGroupsChange, + onAllowPublicLinkChange, + onAllowCustomPublicShareTokensChange, + onAllowPermitAllChange, + onAllowShowToAllChange, + onAllowConfirmationEmailChange, + onConfirmationEmailRateLimitChange, + onAllowCommentsChange, + t, + } }, }) diff --git a/src/components/ArchivedFormsModal.vue b/src/components/ArchivedFormsModal.vue index a21320ff3..763ee0602 100644 --- a/src/components/ArchivedFormsModal.vue +++ b/src/components/ArchivedFormsModal.vue @@ -28,7 +28,7 @@ import type { PropType } from 'vue' import type { FormsForm } from '../models/Entities.d.ts' import { t } from '@nextcloud/l10n' -import { defineComponent } from 'vue' +import { defineComponent, ref, watch } from 'vue' import NcDialog from '@nextcloud/vue/components/NcDialog' import AppNavigationForm from './AppNavigationForm.vue' @@ -54,32 +54,32 @@ export default defineComponent({ emits: ['update:open', 'clone'], - data() { - return { - shownForms: [] as FormsForm[], - } - }, + setup(props, { emit }) { + const shownForms = ref([]) - watch: { - forms: { - immediate: true, - handler() { - this.shownForms = [...this.forms] + watch( + () => props.forms, + () => { + shownForms.value = [...props.forms] }, - }, - }, + { immediate: true }, + ) - methods: { - t, + const onCloneForm = (formId: number): void => { + emit('clone', formId) + emit('update:open', false) + } - onCloneForm(formId: number): void { - this.$emit('clone', formId) - this.$emit('update:open', false) - }, + const onDelete = (form: FormsForm): void => { + shownForms.value = shownForms.value.filter(({ id }) => id !== form.id) + } - onDelete(form: FormsForm): void { - this.shownForms = this.shownForms.filter(({ id }) => id !== form.id) - }, + return { + shownForms, + onCloneForm, + onDelete, + t, + } }, }) diff --git a/src/components/OptionInputDialog.vue b/src/components/OptionInputDialog.vue index 935b5ebfc..955827e7e 100644 --- a/src/components/OptionInputDialog.vue +++ b/src/components/OptionInputDialog.vue @@ -31,7 +31,7 @@ import IconCheck from '@material-symbols/svg-400/outlined/check.svg?raw' import { showError } from '@nextcloud/dialogs' import { t } from '@nextcloud/l10n' -import { defineComponent } from 'vue' +import { computed, defineComponent, ref } from 'vue' import NcDialog from '@nextcloud/vue/components/NcDialog' import NcSelect from '@nextcloud/vue/components/NcSelect' import NcTextArea from '@nextcloud/vue/components/NcTextArea' @@ -54,59 +54,54 @@ export default defineComponent({ emits: ['update:open', 'multipleAnswers'], - data() { - return { - enteredOptions: '' as string, + setup(props, { emit }) { + const enteredOptions = ref('') + + const multipleOptions = computed(() => { + const allOptions = enteredOptions.value.split(/\r?\n/g) + return allOptions.filter((answer: string) => { + return answer.trim().length > 0 + }) + }) + + const onMultipleOptions = (): void => { + emit('update:open', false) + if (multipleOptions.value.length > 1) { + // extract all options entries to parent + emit('multipleAnswers', multipleOptions.value) + enteredOptions.value = '' + return + } + // in case of only one option, just show an error message because it is probably missuse of the feature + showError(t('forms', 'Options should be separated by new line!')) } - }, - computed: { - buttons(): Array<{ - label: string - callback: () => void - type?: 'primary' - icon?: string - }> { + const buttons = computed(() => { return [ { label: t('forms', 'Cancel'), callback: () => { - this.$emit('update:open', false) + emit('update:open', false) }, }, { label: t('forms', 'Add options'), - type: 'primary', + type: 'primary' as const, icon: IconCheck, callback: () => { - this.onMultipleOptions() + onMultipleOptions() }, }, ] - }, - - multipleOptions(): string[] { - const allOptions = this.enteredOptions.split(/\r?\n/g) - return allOptions.filter((answer: string) => { - return answer.trim().length > 0 - }) - }, - }, - - methods: { - t, + }) - onMultipleOptions(): void { - this.$emit('update:open', false) - if (this.multipleOptions.length > 1) { - // extract all options entries to parent - this.$emit('multipleAnswers', this.multipleOptions) - this.enteredOptions = '' - return - } - // in case of only one option, just show an error message because it is probably missuse of the feature - showError(t('forms', 'Options should be separated by new line!')) - }, + return { + enteredOptions, + buttons, + multipleOptions, + onMultipleOptions, + t, + } }, }) diff --git a/src/components/PaginationToolbar.vue b/src/components/PaginationToolbar.vue index 7027c2621..80ed16d30 100644 --- a/src/components/PaginationToolbar.vue +++ b/src/components/PaginationToolbar.vue @@ -69,7 +69,7 @@ import IconChevronRight from '@material-symbols/svg-400/outlined/chevron_right.s import PageFirstIcon from '@material-symbols/svg-400/outlined/first_page.svg?raw' import PageLastIcon from '@material-symbols/svg-400/outlined/last_page.svg?raw' import { t } from '@nextcloud/l10n' -import { defineComponent } from 'vue' +import { computed, defineComponent } from 'vue' import NcButton from '@nextcloud/vue/components/NcButton' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcSelect from '@nextcloud/vue/components/NcSelect' @@ -101,38 +101,31 @@ export default defineComponent({ emits: ['update:offset'], - setup() { + setup(props, { emit }) { + const totalPages = computed(() => { + return Math.max(1, Math.ceil(props.totalItemsCount / props.limit)) + }) + const allPageNumbersArray = computed(() => { + return Array.from({ length: totalPages.value }, (_, index) => 1 + index) + }) + const pageNumber = computed({ + get: () => Math.floor(props.offset / props.limit) + 1, + set: (pageNumberValue: number) => { + emit('update:offset', (Number(pageNumberValue) - 1) * props.limit) + }, + }) + return { IconChevronLeft, IconChevronRight, PageFirstIcon, PageLastIcon, + allPageNumbersArray, + totalPages, + pageNumber, t, } }, - - computed: { - allPageNumbersArray(): number[] { - return Array.from( - { length: this.totalPages }, - (value, index) => 1 + index, - ) - }, - - totalPages(): number { - return Math.max(1, Math.ceil(this.totalItemsCount / this.limit)) - }, - - pageNumber: { - get(): number { - return Math.floor(this.offset / this.limit) + 1 - }, - - set(pageNumber: number) { - this.$emit('update:offset', (Number(pageNumber) - 1) * this.limit) - }, - }, - }, }) diff --git a/src/components/PillMenu.vue b/src/components/PillMenu.vue index e05b34805..9087ed279 100644 --- a/src/components/PillMenu.vue +++ b/src/components/PillMenu.vue @@ -31,7 +31,7 @@ import type { PropType } from 'vue' import { useIsSmallMobile } from '@nextcloud/vue' -import { defineComponent } from 'vue' +import { computed, defineComponent } from 'vue' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcRadioGroup from '@nextcloud/vue/components/NcRadioGroup' import NcRadioGroupButton from '@nextcloud/vue/components/NcRadioGroupButton' @@ -90,34 +90,29 @@ export default defineComponent({ emits: ['update:active'], - setup() { - return { - isMobile: useIsSmallMobile(), - } - }, + setup(props, { emit }) { + const isMobile = useIsSmallMobile() + const pillOptions = computed(() => props.options as PillOption[]) + const activeId = computed(() => String(props.active.id)) - computed: { - pillOptions(): PillOption[] { - return this.options as PillOption[] - }, - - activeId(): string { - return String(this.active.id) - }, - }, - - methods: { /** * Emit the full selected option to keep PillMenu API stable * * @param optionId The selected option id */ - onUpdateActive(optionId: string): void { - const option = this.pillOptions.find( + const onUpdateActive = (optionId: string): void => { + const option = pillOptions.value.find( (entry) => String(entry.id) === optionId, ) - if (option) this.$emit('update:active', option) - }, + if (option) emit('update:active', option) + } + + return { + activeId, + isMobile, + pillOptions, + onUpdateActive, + } }, }) diff --git a/src/components/QRDialog.vue b/src/components/QRDialog.vue index d0040c998..8ffd44b70 100644 --- a/src/components/QRDialog.vue +++ b/src/components/QRDialog.vue @@ -26,7 +26,7 @@ diff --git a/src/components/Questions/Question.vue b/src/components/Questions/Question.vue index 6a6a493ab..431d0ff96 100644 --- a/src/components/Questions/Question.vue +++ b/src/components/Questions/Question.vue @@ -127,7 +127,7 @@ class="question__header__description">