From ca7f99a23822fac05e995cf4a995f7198a123f31 Mon Sep 17 00:00:00 2001 From: Christian Hartmann Date: Sun, 23 Aug 2026 00:38:57 +0200 Subject: [PATCH] chore: migrate views to Composition API Signed-off-by: Christian Hartmann --- src/FormsSubmit.vue | 90 +++--- src/views/Create.vue | 646 ++++++++++++++++++++++-------------------- src/views/Results.vue | 610 ++++++++++++++++++++++----------------- src/views/Sidebar.vue | 335 +++++++++++----------- src/views/Submit.vue | 633 ++++++++++++++++++++++------------------- 5 files changed, 1260 insertions(+), 1054 deletions(-) diff --git a/src/FormsSubmit.vue b/src/FormsSubmit.vue index b47beb5a7..db486c8e4 100644 --- a/src/FormsSubmit.vue +++ b/src/FormsSubmit.vue @@ -20,6 +20,7 @@ import type { FormsForm } from './models/Entities.d.ts' import { subscribe, unsubscribe } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { defineComponent } from 'vue' +import { nextTick, onMounted, onUnmounted } from 'vue' import NcContent from '@nextcloud/vue/components/NcContent' import Submit from './views/Submit.vue' @@ -33,45 +34,14 @@ export default defineComponent({ Submit, }, - data() { - return { - form: loadState(formsAppName, 'form') as FormsForm, - isLoggedIn: loadState(formsAppName, 'isLoggedIn') as boolean, - isEmbedded: loadState(formsAppName, 'isEmbedded', false) as boolean, - shareHash: loadState(formsAppName, 'shareHash') as string, - } - }, - - unmounted() { - unsubscribe('forms:last-updated:set', this.onSubmitMessageEvent) - }, - - mounted() { - if (this.isEmbedded) { - subscribe('forms:last-updated:set', this.onSubmitMessageEvent) - - // Communicate window size to parent window in iframes - const resizeObserver = new ResizeObserver((entries) => { - this.emitResizeMessage(entries[0].target as HTMLElement) - }) - this.$nextTick(() => { - const formEl = document.querySelector('.app-forms-embedded form') - if (formEl) { - resizeObserver.observe(formEl) - } - }) - } - }, + setup() { + const form = loadState(formsAppName, 'form') as FormsForm + const isLoggedIn = loadState(formsAppName, 'isLoggedIn') as boolean + const isEmbedded = loadState(formsAppName, 'isEmbedded', false) as boolean + const shareHash = loadState(formsAppName, 'shareHash') as string + let resizeObserver: ResizeObserver | undefined - methods: { - onSubmitMessageEvent(event: unknown): void { - const id = Number(event) - if (Number.isFinite(id)) { - this.emitSubmitMessage(id) - } - }, - - emitSubmitMessage(id: number): void { + const emitSubmitMessage = (id: number): void => { window.parent?.postMessage( { type: 'form-saved', @@ -81,12 +51,19 @@ export default defineComponent({ }, '*', ) - }, + } + + const onSubmitMessageEvent = (event: unknown): void => { + const id = Number(event) + if (Number.isFinite(id)) { + emitSubmitMessage(id) + } + } /** * @param target Target of which the size should be communicated */ - emitResizeMessage(target: HTMLElement): void { + const emitResizeMessage = (target: HTMLElement): void => { const rect = target.getBoundingClientRect() let height = rect.top + target.scrollHeight let width = target.scrollWidth @@ -117,7 +94,38 @@ export default defineComponent({ }, '*', ) - }, + } + + onMounted(() => { + if (!isEmbedded) { + return + } + + subscribe('forms:last-updated:set', onSubmitMessageEvent) + + // Communicate window size to parent window in iframes + resizeObserver = new ResizeObserver((entries) => { + emitResizeMessage(entries[0].target as HTMLElement) + }) + void nextTick(() => { + const formEl = document.querySelector('.app-forms-embedded form') + if (formEl) { + resizeObserver?.observe(formEl) + } + }) + }) + + onUnmounted(() => { + unsubscribe('forms:last-updated:set', onSubmitMessageEvent) + resizeObserver?.disconnect() + }) + + return { + form, + isLoggedIn, + isEmbedded, + shareHash, + } }, }) diff --git a/src/views/Create.vue b/src/views/Create.vue index 50ebf9a75..4d8293c01 100644 --- a/src/views/Create.vue +++ b/src/views/Create.vue @@ -214,21 +214,20 @@ diff --git a/src/views/Results.vue b/src/views/Results.vue index 20ba0e4d9..2c5c653f4 100644 --- a/src/views/Results.vue +++ b/src/views/Results.vue @@ -285,14 +285,15 @@ import IconTable from '@material-symbols/svg-400/outlined/table_chart.svg?raw' import { getCurrentUser, getRequestToken } from '@nextcloud/auth' import axios from '@nextcloud/axios' import { getFilePickerBuilder, showError, showSuccess } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' +import { emit as emitEvent } from '@nextcloud/event-bus' import { FileType } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import { generateOcsUrl, generateUrl } from '@nextcloud/router' import { useIsSmallMobile } from '@nextcloud/vue' import debounce from 'debounce' -import { defineComponent } from 'vue' +import { computed, defineComponent, nextTick, onBeforeMount, ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcActions from '@nextcloud/vue/components/NcActions' import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' @@ -419,201 +420,120 @@ export default defineComponent({ emits: ['update:form', 'open-sharing'], setup(props, { emit }) { + const route = useRoute() + const router = useRouter() const viewForm = useViewForm({ form: () => props.form, emit }) - return { - ...viewForm, - isMobile: useIsSmallMobile(), - t, - responseViews, - IconChevronLeft, - IconDelete, - IconDownload, - IconFileDelimited, - IconFileExcelOutline, - IconFolder, - IconLink, - IconLinkVariantOff, - IconPoll, - IconRefresh, - IconShareVariant, - IconTable, - IconMagnify, - } - }, - - data(): ResultsViewData { - return { - questions: [] as FormsQuestion[], - submissions: [] as SubmissionRecord[], - filteredSubmissionsCount: 0 as number, - isDownloadActionOpened: false as boolean, - loadingResults: true as boolean, - skipReloadOnOffsetChange: false as boolean, - picker: null as PickerLike | null, - showConfirmDeleteDialog: false as boolean, - submissionSearch: '' as string, - limit: 20 as number, - offset: 0 as number, - linkedFileNotAvailableButtons: [ - { - label: t('forms', 'Unlink spreadsheet'), - icon: IconLinkVariantOff, - variant: 'error', - callback: () => { - this.onUnlinkFile() - }, + const questions = ref([]) + const submissions = ref([]) + const filteredSubmissionsCount = ref(0) + const isDownloadActionOpened = ref(false) + const loadingResults = ref(true) + const skipReloadOnOffsetChange = ref(false) + const picker = ref(null) + const showConfirmDeleteDialog = ref(false) + const submissionSearch = ref('') + const limit = ref(20) + const offset = ref(0) + const isMobile = useIsSmallMobile() + + const linkedFileNotAvailableButtons = computed(() => [ + { + label: t('forms', 'Unlink spreadsheet'), + icon: IconLinkVariantOff, + variant: 'error', + callback: () => { + void onUnlinkFile() }, - { - label: t('forms', 'Create spreadsheet'), - icon: IconLink, - variant: 'primary', - callback: () => { - this.onLinkFile() - }, + }, + { + label: t('forms', 'Create spreadsheet'), + icon: IconLink, + variant: 'primary', + callback: () => { + void onLinkFile() }, - ] as DialogButton[], - - confirmDeleteButtons: [ - { - label: t('forms', 'Cancel'), - icon: IconCancel, - variant: 'tertiary', - callback: () => { - this.closeDeleteConfirmation() - }, + }, + ]) + + const confirmDeleteButtons = computed(() => [ + { + label: t('forms', 'Cancel'), + icon: IconCancel, + variant: 'tertiary', + callback: () => { + closeDeleteConfirmation() }, - { - label: t('forms', 'Delete responses'), - icon: IconDelete, - variant: 'error', - callback: () => { - this.deleteAllSubmissionsConfirmed() - }, + }, + { + label: t('forms', 'Delete responses'), + icon: IconDelete, + variant: 'error', + callback: () => { + void deleteAllSubmissionsConfirmed() }, - ] as DialogButton[], - } - }, - - computed: { - isSummaryView(): boolean { - return this.$route.name === 'results.summary' - }, - - activeResponseView(): ResponseView { - return this.isSummaryView ? summaryResponseView : responsesResponseView - }, - - isFormArchived(): boolean { - return this.form.state === FormState.FormArchived - }, - - canExportSubmissions(): boolean { - return this.form.permissions.includes( + }, + ]) + + const isSummaryView = computed( + () => route.name === 'results.summary', + ) + const activeResponseView = computed(() => { + return isSummaryView.value ? summaryResponseView : responsesResponseView + }) + const isFormArchived = computed(() => { + return props.form.state === FormState.FormArchived + }) + const canExportSubmissions = computed(() => { + return props.form.permissions.includes( PERMISSION_TYPES.PERMISSION_RESULTS, ) - }, - - canDeleteSubmissions(): boolean { + }) + const canDeleteSubmissions = computed(() => { return ( - this.form.permissions.includes( + props.form.permissions.includes( PERMISSION_TYPES.PERMISSION_RESULTS_DELETE, - ) && !this.isFormArchived + ) && !isFormArchived.value ) - }, - - canEditForm(): boolean { - return this.form.permissions.includes(PERMISSION_TYPES.PERMISSION_EDIT) - }, - - noSubmissions(): boolean { - return this.form.submissionCount === 0 - }, - - noFilteredSubmissions(): boolean { - return this.submissions.length === 0 - }, - - fileUrl(): string { - if (this.form.fileId) { - return generateUrl('/f/{fileId}', { fileId: this.form.fileId }) + }) + const canEditForm = computed(() => { + return props.form.permissions.includes(PERMISSION_TYPES.PERMISSION_EDIT) + }) + const noSubmissions = computed(() => { + return props.form.submissionCount === 0 + }) + const noFilteredSubmissions = computed(() => { + return submissions.value.length === 0 + }) + const fileUrl = computed(() => { + if (props.form.fileId) { + return generateUrl('/f/{fileId}', { fileId: props.form.fileId }) } return window.location.href - }, - - showLinkedFileNotAvailableDialog(): boolean { - if (this.form.partial) { + }) + const showLinkedFileNotAvailableDialog = computed(() => { + if (props.form.partial) { return false } return ( - this.canEditForm - && Boolean(this.form.fileId) - && !this.form.filePath - && !this.isFormLocked + canEditForm.value + && Boolean(props.form.fileId) + && !props.form.filePath + && !viewForm.isFormLocked.value ) - }, - }, - - watch: { - async hash(): Promise { - await this.fetchFullForm(this.form.id) - await this.loadFormResults() - SetWindowTitle(this.formTitle) - }, - - '$route.name': { - handler(): void { - const viewId = this.isSummaryView ? 'summary' : 'responses' - this.saveActiveResponseViewToLocalStorage(viewId) - this.loadFormResults() - }, - }, - - limit(): void { - this.loadFormResults() - }, + }) - offset(): void { - if (!this.skipReloadOnOffsetChange) { - this.loadFormResults() - } - }, - - submissionSearch: debounce(function ( - this: ResultsViewData & { - $nextTick: (callback: () => void) => void - loadFormResults: () => void - }, - ) { - this.skipReloadOnOffsetChange = true - this.offset = 0 - this.$nextTick(() => { - this.skipReloadOnOffsetChange = false - }) - this.loadFormResults() - }, INPUT_DEBOUNCE_MS), - }, - - async beforeMount(): Promise { - // Determine the initial viewId based on the route - const viewId = this.isSummaryView ? 'summary' : 'responses' - this.saveActiveResponseViewToLocalStorage(viewId) - - await this.fetchFullForm(this.form.id) - this.loadFormResults() - SetWindowTitle(this.formTitle) - }, - - methods: { /** * Save the active response view preference to localStorage for the current form. * * @param viewId - The ID of the view ('summary' or 'responses') */ - saveActiveResponseViewToLocalStorage(viewId: ResponseView['id']): void { + function saveActiveResponseViewToLocalStorage( + viewId: ResponseView['id'], + ): void { try { - const storageKey = this.getActiveResponseViewStorageKey() + const storageKey = getActiveResponseViewStorageKey() if (!storageKey) { return } @@ -624,48 +544,54 @@ export default defineComponent({ error: err, }) } - }, + } - getActiveResponseViewStorageKey(): string | null { - const formHash = this.form.hash + /** + * Get storage key for active response view + */ + function getActiveResponseViewStorageKey(): string | null { + const formHash = props.form.hash if (!formHash) { return null } return `nextcloud_forms_${formHash}_activeResponseView` - }, + } /** * Navigate to an explicit route for the selected response view. * * @param view The selected response view object */ - async onViewChange(view: ResponseView): Promise { + async function onViewChange(view: ResponseView): Promise { if (!view?.id) { return } const targetName = `results.${view.id}` - if (this.$route.name === targetName) { - this.loadFormResults() + if (route.name === targetName) { + await loadFormResults() return } try { - await this.$router.push({ + await router.push({ name: targetName, params: { - hash: this.form.hash, + hash: props.form.hash, }, }) } catch (error) { logger.debug('Navigation cancelled', { error }) } - }, + } - async onUnlinkFile(): Promise { + /** + * Unlink the results file + */ + async function onUnlinkFile(): Promise { await axios.patch( generateOcsUrl('apps/forms/api/v3/forms/{formId}', { - formId: this.form.id, + formId: props.form.id, }), { keyValuePairs: { @@ -676,25 +602,28 @@ export default defineComponent({ ) const updatedForm = { - ...this.form, + ...props.form, fileFormat: null, fileId: null, filePath: null, } - this.$emit('update:form', updatedForm) - emit('forms:last-updated:set', this.form.id) - }, + emit('update:form', updatedForm) + emitEvent('forms:last-updated:set', props.form.id) + } - async loadFormResults(): Promise { - this.loadingResults = true - logger.debug(`Loading responses for form ${this.form.hash}`) + /** + * Load form results + */ + async function loadFormResults(): Promise { + loadingResults.value = true + logger.debug(`Loading responses for form ${props.form.hash}`) try { let response = null - if (this.isSummaryView) { + if (isSummaryView.value) { response = await axios.get( generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { - id: this.form.id, + id: props.form.id, }), ) } else { @@ -702,34 +631,41 @@ export default defineComponent({ generateOcsUrl( 'apps/forms/api/v3/forms/{id}/submissions?limit={limit}&offset={offset}&query={query}', { - id: this.form.id, - limit: this.limit, - offset: this.offset, - query: this.submissionSearch, + id: props.form.id, + limit: limit.value, + offset: offset.value, + query: submissionSearch.value, }, ), ) } const data = OcsResponse2Data(response) - this.submissions = this.formatDateAnswers( + submissions.value = formatDateAnswers( data.submissions, data.questions, ) - this.questions = data.questions - this.filteredSubmissionsCount = data.filteredSubmissionsCount + questions.value = data.questions + filteredSubmissionsCount.value = data.filteredSubmissionsCount } catch (error) { logger.error('Error while loading responses', { error }) showError(t('forms', 'An error occurred while loading responses')) } finally { - this.loadingResults = false + loadingResults.value = false } - }, + } - async onDownloadFile(nextFileFormat: SupportedFileFormat): Promise { + /** + * Download the results file + * + * @param nextFileFormat the file format + */ + async function onDownloadFile( + nextFileFormat: SupportedFileFormat, + ): Promise { const exportUrl = generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { - id: this.form.id, + id: props.form.id, }) + '?requesttoken=' + encodeURIComponent(getRequestToken() ?? '') @@ -737,15 +673,18 @@ export default defineComponent({ + nextFileFormat window.open(exportUrl, '_self') - }, + } - async onLinkFile(): Promise { + /** + * Link a file for exporting submissions + */ + async function onLinkFile(): Promise { try { - const path = await this.getPicker().pick() + const path = await getPicker().pick() try { await axios.patch( generateOcsUrl('apps/forms/api/v3/forms/{id}', { - id: this.form.id, + id: props.form.id, }), { keyValuePairs: { @@ -754,15 +693,15 @@ export default defineComponent({ }, }, ) - await this.fetchFullForm(this.form.id) - await this.loadFormResults() + await viewForm.fetchFullForm(props.form.id) + await loadFormResults() showSuccess( t('forms', 'File {file} successfully linked', { - file: this.form.filePath?.split('/').pop() ?? '', + file: props.form.filePath?.split('/').pop() ?? '', }), ) - emit('forms:last-updated:set', this.form.id) + emitEvent('forms:last-updated:set', props.form.id) } catch (error) { logger.error('Error while exporting to Files and linking', { error, @@ -774,17 +713,20 @@ export default defineComponent({ } catch (error) { logger.debug('No file selected', { error }) } - }, + } - async onStoreToFiles(): Promise { + /** + * Store results to files + */ + async function onStoreToFiles(): Promise { try { - const path = await this.getPicker().pick() + const path = await getPicker().pick() try { const response = await axios.post( generateOcsUrl( 'apps/forms/api/v3/forms/{id}/submissions/export', { - id: this.form.id, + id: props.form.id, }, ), { @@ -807,10 +749,13 @@ export default defineComponent({ } catch (error) { logger.debug('No file selected', { error }) } - }, + } - async onReExport(): Promise { - if (!this.form.fileId) { + /** + * Re-export the results + */ + async function onReExport(): Promise { + if (!props.form.fileId) { showError(t('forms', 'File is not linked')) return } @@ -820,12 +765,12 @@ export default defineComponent({ generateOcsUrl( 'apps/forms/api/v3/forms/{id}/submissions/export', { - id: this.form.id, + id: props.form.id, }, ), { - path: this.form.filePath, - fileFormat: this.form.fileFormat, + path: props.form.filePath, + fileFormat: props.form.fileFormat, }, ) @@ -838,16 +783,21 @@ export default defineComponent({ logger.error('Error while exporting to Files', { error }) showError(t('forms', 'There was an error, while exporting to Files')) } - }, + } - canDeleteSubmission(submissionUser: string): boolean { + /** + * If user can delete a submission + * + * @param submissionUser the user who submitted the response + */ + function canDeleteSubmission(submissionUser: string): boolean { const currentUser = getCurrentUser() return ( - this.canDeleteSubmissions - || (this.form.allowEditSubmissions + canDeleteSubmissions.value + || (props.form.allowEditSubmissions && currentUser?.uid === submissionUser) ) - }, + } /** * Determines if a submission can be edited. @@ -858,77 +808,99 @@ export default defineComponent({ * - The user has the `canDeleteSubmissions` permission, or * - The form allows editing (`form.allowEditSubmissions`) and the current user is the owner of the submission. */ - canEditSubmission(submissionUser: string): boolean { + function canEditSubmission(submissionUser: string): boolean { const currentUser = getCurrentUser() return ( - this.canDeleteSubmissions - || (this.form.allowEditSubmissions + canDeleteSubmissions.value + || (props.form.allowEditSubmissions && currentUser?.uid === submissionUser) ) - }, + } - async deleteSubmission(id: number): Promise { - this.loadingResults = true + /** + * Delete the submission + * + * @param id the id of the submission + */ + async function deleteSubmission(id: number): Promise { + loadingResults.value = true try { await axios.delete( generateOcsUrl( 'apps/forms/api/v3/forms/{id}/submissions/{submissionId}', { - id: this.form.id, + id: props.form.id, submissionId: id, }, ), ) showSuccess(t('forms', 'Response deleted')) - const index = this.submissions.findIndex( + const index = submissions.value.findIndex( (search: SubmissionRecord) => search.id === id, ) - this.submissions.splice(index, 1) - emit('forms:last-updated:set', this.form.id) + if (index >= 0) { + submissions.value.splice(index, 1) + } + emitEvent('forms:last-updated:set', props.form.id) } catch (error) { logger.error(`Error while deleting response ${id}`, { error }) showError( t('forms', 'An error occurred while deleting this response'), ) } finally { - this.loadingResults = false + loadingResults.value = false } - }, + } - deleteAllSubmissions(): void { - this.showConfirmDeleteDialog = true - }, + /** + * Show confirmation dialog for deletion + */ + function deleteAllSubmissions(): void { + showConfirmDeleteDialog.value = true + } - closeDeleteConfirmation(): void { - this.showConfirmDeleteDialog = false - }, + /** + * Close the confirmation dialog for deletion + */ + function closeDeleteConfirmation(): void { + showConfirmDeleteDialog.value = false + } - async deleteAllSubmissionsConfirmed(): Promise { - this.showConfirmDeleteDialog = false - this.loadingResults = true + /** + * Deletion confirmed, delete all submissions + */ + async function deleteAllSubmissionsConfirmed(): Promise { + showConfirmDeleteDialog.value = false + loadingResults.value = true try { await axios.delete( generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { - id: this.form.id, + id: props.form.id, }), ) - this.submissions = [] - const updatedForm = { ...this.form, submissionCount: 0 } - this.$emit('update:form', updatedForm) - emit('forms:last-updated:set', this.form.id) + submissions.value = [] + const updatedForm = { ...props.form, submissionCount: 0 } + emit('update:form', updatedForm) + emitEvent('forms:last-updated:set', props.form.id) } catch (error) { logger.error('Error while deleting responses', { error }) showError(t('forms', 'An error occurred while deleting responses')) } finally { - this.loadingResults = false + loadingResults.value = false } - }, + } - formatDateAnswers( + /** + * Format date answers + * + * @param submissions array of responses + * @param questions array of questions + */ + function formatDateAnswers( submissions: SubmissionRecord[], questions: FormsQuestion[], ): SubmissionRecord[] { @@ -958,14 +930,17 @@ export default defineComponent({ }) return submissions - }, + } - getPicker(): PickerLike { - if (this.picker !== null) { - return this.picker + /** + * Get a file picker + */ + function getPicker(): PickerLike { + if (picker.value !== null) { + return picker.value } - this.picker = getFilePickerBuilder( + picker.value = getFilePickerBuilder( t('forms', 'Choose spreadsheet location'), ) .setMultiSelect(false) @@ -1035,8 +1010,117 @@ export default defineComponent({ }) .build() as PickerLike - return this.picker - }, + return picker.value + } + + watch( + () => props.hash, + async () => { + await viewForm.fetchFullForm(props.form.id) + await loadFormResults() + SetWindowTitle(viewForm.formTitle.value) + }, + ) + + watch( + () => route.name, + () => { + const viewId = isSummaryView.value ? 'summary' : 'responses' + saveActiveResponseViewToLocalStorage(viewId) + void loadFormResults() + }, + ) + + watch(limit, () => { + void loadFormResults() + }) + + watch(offset, () => { + if (!skipReloadOnOffsetChange.value) { + void loadFormResults() + } + }) + + watch( + submissionSearch, + debounce(() => { + skipReloadOnOffsetChange.value = true + offset.value = 0 + nextTick(() => { + skipReloadOnOffsetChange.value = false + }) + void loadFormResults() + }, INPUT_DEBOUNCE_MS), + ) + + onBeforeMount(async (): Promise => { + // Determine the initial viewId based on the route + const viewId = isSummaryView.value ? 'summary' : 'responses' + saveActiveResponseViewToLocalStorage(viewId) + + await viewForm.fetchFullForm(props.form.id) + await loadFormResults() + SetWindowTitle(viewForm.formTitle.value) + }) + + return { + ...viewForm, + isMobile, + t, + responseViews, + questions, + submissions, + filteredSubmissionsCount, + isDownloadActionOpened, + loadingResults, + skipReloadOnOffsetChange, + showConfirmDeleteDialog, + submissionSearch, + limit, + offset, + linkedFileNotAvailableButtons, + confirmDeleteButtons, + isSummaryView, + activeResponseView, + isFormArchived, + canExportSubmissions, + canDeleteSubmissions, + canEditForm, + noSubmissions, + noFilteredSubmissions, + fileUrl, + showLinkedFileNotAvailableDialog, + saveActiveResponseViewToLocalStorage, + getActiveResponseViewStorageKey, + onViewChange, + onUnlinkFile, + loadFormResults, + onDownloadFile, + onLinkFile, + onStoreToFiles, + onReExport, + canDeleteSubmission, + canEditSubmission, + deleteSubmission, + deleteAllSubmissions, + closeDeleteConfirmation, + deleteAllSubmissionsConfirmed, + formatDateAnswers, + getPicker, + IconChevronLeft, + IconDelete, + IconDownload, + IconFileDelimited, + IconFileExcelOutline, + IconFolder, + IconLink, + IconLinkVariantOff, + IconPoll, + IconRefresh, + IconShareVariant, + IconTable, + IconMagnify, + } }, }) diff --git a/src/views/Sidebar.vue b/src/views/Sidebar.vue index 6ef7f0218..0d6df054e 100644 --- a/src/views/Sidebar.vue +++ b/src/views/Sidebar.vue @@ -58,16 +58,17 @@ diff --git a/src/views/Submit.vue b/src/views/Submit.vue index e5b3f60a0..3478d892b 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -126,12 +126,12 @@ -
+
    (null) + const questionRefs = ref< + QuestionComponentRef[] | QuestionComponentRef | null + >(null) const viewForm = useViewForm({ form: () => props.form, emit, titleRef: title, }) - // Non reactive properties - return { - ...viewForm, - title, - IconCheckSvg: IconCheck, - IconRefreshSvg: IconRefresh, - IconSendSvg: IconSend, - t, - - maxStringLengths: loadState(formsAppName, 'maxStringLengths') as Record< - string, - number - >, - } - }, - - data() { - return { - answerTypes, - answers: {} as AnswersMap, - loading: false as boolean, - success: false as boolean, - successAnnouncement: '' as string, - submitForm: false as boolean, - showConfirmEmptyModal: false as boolean, - showConfirmLeaveDialog: false as boolean, - showClearFormDialog: false as boolean, - showClearFormDueToChangeDialog: false as boolean, - confirmButtonCallback: () => {}, - } - }, - - computed: { - validQuestions(): SubmitQuestion[] { - return this.form.questions.filter((question) => { + const answers = ref({}) + const loading = ref(false) + const success = ref(false) + const successAnnouncement = ref('') + const submitForm = ref(false) + const showConfirmEmptyModal = ref(false) + const showConfirmLeaveDialog = ref(false) + const showClearFormDialog = ref(false) + const showClearFormDueToChangeDialog = ref(false) + const confirmButtonCallback = ref<(val: boolean) => void>(() => {}) + + const validQuestions = computed(() => { + return props.form.questions.filter((question) => { // All questions must have a valid title if (question.text?.trim() === '') { return false @@ -445,90 +407,90 @@ export default defineComponent({ } return true }) as SubmitQuestion[] - }, + }) - validQuestionsIds(): Set { - return new Set(this.validQuestions.map((question) => question.id)) - }, + const validQuestionsIds = computed>(() => { + return new Set(validQuestions.value.map((question) => question.id)) + }) - isRequiredUsed(): boolean { - return this.form.questions.some((question) => + const isRequiredUsed = computed(() => { + return props.form.questions.some((question) => Boolean(question.isRequired), ) - }, + }) /** * Check if form is expired */ - isExpired(): boolean { - return this.form.expires > 0 && moment().unix() > this.form.expires - }, + const isExpired = computed(() => { + return props.form.expires > 0 && moment().unix() > props.form.expires + }) - isArchived(): boolean { - return this.form.state === FormState.FormArchived - }, + const isArchived = computed(() => { + return props.form.state === FormState.FormArchived + }) - isClosed(): boolean { - return this.form.state === FormState.FormClosed - }, + const isClosed = computed(() => { + return props.form.state === FormState.FormClosed + }) - isMaxSubmissionsReached(): boolean { - return this.form.isMaxSubmissionsReached === true - }, + const isMaxSubmissionsReached = computed(() => { + return props.form.isMaxSubmissionsReached === true + }) /** * Checks if the current state is active. * * @return - Returns true if active, otherwise false. */ - isActive(): boolean { - return !this.isArchived && !this.isClosed && !this.isExpired - }, + const isActive = computed(() => { + return !isArchived.value && !isClosed.value && !isExpired.value + }) - infoMessage(): string { + const infoMessage = computed(() => { let message = '' - if (this.form.isAnonymous) { + if (props.form.isAnonymous) { message += t('forms', 'Responses are anonymous.') } - if (!this.form.isAnonymous && this.isLoggedIn) { + if (!props.form.isAnonymous && props.isLoggedIn) { message += t('forms', 'Responses are connected to your account.') } - if (this.isRequiredUsed) { + if (isRequiredUsed.value) { message += ' ' + t('forms', 'An asterisk (*) indicates mandatory questions.') } return message - }, + }) /** * Rendered HTML of the custom submission message */ - submissionMessageHTML(): string { + const submissionMessageHTML = computed(() => { if ( - this.form.submissionMessage - && (this.success || !this.form.canSubmit) + props.form.submissionMessage + && (success.value || !props.form.canSubmit) ) { - return this.markdownit.render(this.form.submissionMessage) + return viewForm.markdownit.render(props.form.submissionMessage) } return '' - }, + }) - expirationMessage(): string { - const relativeDate = moment(this.form.expires, 'X') + const expirationMessage = computed(() => { + const relativeDate = moment(props.form.expires, 'X') .locale(window.OC.getLanguage()) .fromNow() - if (this.isExpired) { + if (isExpired.value) { return t('forms', 'Expired {relativeDate}.', { relativeDate }) } return t('forms', 'Expires {relativeDate}.', { relativeDate }) - }, + }) /** * Buttons for the "confirm submit empty form" dialog */ - confirmEmptyModalButtons(): DialogButton[] { + const confirmEmptyModalButtons = computed(() => { return [ { label: t('forms', 'Abort'), @@ -539,34 +501,36 @@ export default defineComponent({ label: t('forms', 'Submit'), icon: IconCheck, variant: 'primary', - callback: () => this.onConfirmedSubmit(), + callback: () => { + void onConfirmedSubmit() + }, }, ] - }, + }) /** * Buttons for the "confirm leave unsubmitted form" dialog */ - confirmLeaveFormButtons(): DialogButton[] { + const confirmLeaveFormButtons = computed(() => { return [ { label: t('forms', 'Abort'), icon: IconCancel, - callback: () => this.confirmButtonCallback(false), + callback: () => confirmButtonCallback.value(false), }, { label: t('forms', 'Leave'), icon: IconCheck, variant: 'primary', - callback: () => this.confirmButtonCallback(true), + callback: () => confirmButtonCallback.value(true), }, ] - }, + }) /** * Buttons for the "confirm clear form" dialog */ - confirmClearFormButtons(): DialogButton[] { + const confirmClearFormButtons = computed(() => { return [ { label: t('forms', 'Abort'), @@ -577,181 +541,123 @@ export default defineComponent({ label: t('forms', 'Clear'), icon: IconCheck, variant: 'primary', - callback: () => this.onResetSubmission(), + callback: () => onResetSubmission(), }, ] - }, + }) - hasAnswers(): boolean { - return Object.keys(this.answers).length > 0 - }, + const hasAnswers = computed(() => { + return Object.keys(answers.value).length > 0 + }) - submissionId(): number | null { + const submissionId = computed(() => { + const routeSubmissionId = Array.isArray(route.params.submissionId) + ? route.params.submissionId[0] + : route.params.submissionId const id = - this.$route?.params.submissionId - || loadState(formsAppName, 'submissionId', null) + routeSubmissionId || loadState(formsAppName, 'submissionId', null) return id ? parseInt(String(id), 10) : null - }, - }, - - watch: { - success(newVal: boolean): void { - if (newVal) { - // Delay populating the live region to avoid the announcement being - // swallowed by the simultaneous large DOM change (form replaced by - // success view). Screen readers need a moment to process the new DOM - // before a polite live region update registers. - setTimeout(() => { - this.successAnnouncement = - this.form.submissionMessage - || t('forms', 'Thank you for completing the form!') - }, 100) - } else { - this.successAnnouncement = '' - } - }, - - hash(): void { - // If public view, abort. Should normally not occur. - if (this.publicView) { - logger.error('Hash changed on public view. Aborting.') - return - } - this.resetData() - // Fetch full form on change - this.fetchFullForm(this.form.id) - this.initFromLocalStorage() - SetWindowTitle(this.formTitle) - }, - }, - - beforeUnmount(): void { - window.removeEventListener('beforeunload', this.beforeWindowUnload) - }, - - created(): void { - window.addEventListener('beforeunload', this.beforeWindowUnload) - }, - - async beforeMount(): Promise { - // Public Views get their form by initial-state from parent. No fetch necessary. - if (this.publicView) { - this.isLoadingForm = false - } else { - await this.fetchFullForm(this.form.id) - } - - if (this.isLoggedIn) { - if ( - this.submissionId - && (this.form.allowEditSubmissions - || this.form.permissions.includes( - PERMISSION_TYPES.PERMISSION_RESULTS_DELETE, - )) - ) { - this.fetchSubmission() - } else { - this.initFromLocalStorage() - } - } - - SetWindowTitle(this.formTitle) - }, + }) - methods: { /** * Load saved values for current form from LocalStorage * * @return */ - getFormValuesFromLocalStorage(): StoredAnswersMap | null { + function getFormValuesFromLocalStorage(): StoredAnswersMap | null { const fromLocalStorage = localStorage.getItem( - `nextcloud_forms_${this.publicView ? this.shareHash : this.hash}`, + `nextcloud_forms_${props.publicView ? props.shareHash : props.hash}`, ) if (fromLocalStorage) { return JSON.parse(fromLocalStorage) } return null - }, + } /** * Initialize answers from saved state in LocalStorage */ - initFromLocalStorage(): void { - const savedState = this.getFormValuesFromLocalStorage() + function initFromLocalStorage(): void { + const savedState = getFormValuesFromLocalStorage() if (!savedState) { return } - const answers: AnswersMap = {} + const localAnswers: AnswersMap = {} for (const [questionId, answer] of Object.entries(savedState)) { // Clean up answers for questions that do not exist anymore - if (!this.validQuestionsIds.has(parseInt(questionId, 10))) { - this.showClearFormDueToChangeDialog = true + if (!validQuestionsIds.value.has(parseInt(questionId, 10))) { + showClearFormDueToChangeDialog.value = true logger.debug('Question does not exist anymore', { questionId, }) continue } - answers[parseInt(questionId, 10)] = [ + localAnswers[parseInt(questionId, 10)] = [ 'QuestionMultiple', 'QuestionRanking', ].includes(answer.type) ? answer.value.map(String) : answer.value } - this.answers = answers - }, + answers.value = localAnswers + } /** * Save updated answers for question to LocalStorage in case of browser crash / closes / etc * * @param question Question to update */ - addFormFieldToLocalStorage(question: SubmitQuestion): void { - if (!this.isLoggedIn) { + function addFormFieldToLocalStorage(question: SubmitQuestion): void { + if (!props.isLoggedIn) { return } // We make sure the values are updated by the `values.sync` handler const state = { - ...(this.getFormValuesFromLocalStorage() ?? {}), + ...(getFormValuesFromLocalStorage() ?? {}), [`${question.id}`]: { - value: this.answers[question.id], + value: answers.value[question.id], type: answerTypes[question.type].component.name, }, } const stringified = JSON.stringify(state) localStorage.setItem( - `nextcloud_forms_${this.publicView ? this.shareHash : this.hash}`, + `nextcloud_forms_${props.publicView ? props.shareHash : props.hash}`, stringified, ) - }, + } - deleteFormFieldFromLocalStorage(): void { - if (!this.isLoggedIn) { + /** + * Deletes a non-existing field from local storage + */ + function deleteFormFieldFromLocalStorage(): void { + if (!props.isLoggedIn) { return } localStorage.removeItem( - `nextcloud_forms_${this.publicView ? this.shareHash : this.hash}`, + `nextcloud_forms_${props.publicView ? props.shareHash : props.hash}`, ) - }, + } - async fetchSubmission(): Promise { - logger.debug(`Loading response ${this.submissionId}`) + /** + * Fetches the submission data for the given id from the server + */ + async function fetchSubmission(): Promise { + logger.debug(`Loading response ${submissionId.value}`) try { const response = await axios.get( generateOcsUrl( 'apps/forms/api/v3/forms/{id}/submissions/{submissionId}', { - id: this.form.id, - submissionId: this.submissionId, + id: props.form.id, + submissionId: submissionId.value, }, ), ) - const answers: AnswersMap = {} + const loaded: AnswersMap = {} const loadedAnswers = OcsResponse2Data(response).answers for (const answer of loadedAnswers) { @@ -759,21 +665,21 @@ export default defineComponent({ const text = answer.text // Only initialize once, don't overwrite previous answers - if (!answers[questionId]) { - answers[questionId] = [] + if (!loaded[questionId]) { + loaded[questionId] = [] } logger.debug(`questionId: ${questionId}, answerId: ${answer.id}`) // Clean up answers for questions that do not exist anymore - if (!this.validQuestionsIds.has(questionId)) { - this.showClearFormDueToChangeDialog = true + if (!validQuestionsIds.value.has(questionId)) { + showClearFormDueToChangeDialog.value = true logger.debug('Question does not exist anymore', { questionId, }) continue } - const question = this.form.questions.find( + const question = props.form.questions.find( (question) => question.id === questionId, ) as SubmitQuestion | undefined if (!question) { @@ -781,7 +687,7 @@ export default defineComponent({ } if (question.type === 'ranking') { try { - answers[questionId].push(...JSON.parse(text).map(String)) + loaded[questionId].push(...JSON.parse(text).map(String)) } catch (error) { logger.debug( `Could not parse ranking answer ${text} for question ${questionId}`, @@ -797,16 +703,16 @@ export default defineComponent({ (option) => option.text === text, ) if (option.length > 0) { - answers[questionId].push(String(option[0].id)) + loaded[questionId].push(String(option[0].id)) } else if ( question.extraSettings?.allowOtherAnswer - && !answers[questionId].some((answer) => - String(answer).startsWith( + && !loaded[questionId].some((localAnswer) => + String(localAnswer).startsWith( QUESTION_EXTRASETTINGS_OTHER_PREFIX, ), ) ) { - answers[questionId].push( + loaded[questionId].push( QUESTION_EXTRASETTINGS_OTHER_PREFIX + text, ) } else { @@ -824,18 +730,18 @@ export default defineComponent({ `Skipping file answer for question ${questionId} — cannot restore uploaded files`, ) } else { - answers[questionId].push(text) + loaded[questionId].push(text) } } - this.answers = answers + answers.value = loaded } catch (error) { logger.error('Error while loading response', { error }) showError( t('forms', 'There was an error while loading the response'), ) } - }, + } /** * Update answers of a give value @@ -843,14 +749,26 @@ export default defineComponent({ * @param question The question to answer * @param values The new values */ - onUpdate(question: SubmitQuestion, values: AnswerValue): void { - this.answers = { ...this.answers, [question.id]: values } - this.addFormFieldToLocalStorage(question) - }, + function onUpdate(question: SubmitQuestion, values: unknown[]): void { + answers.value = { + ...answers.value, + [question.id]: values as AnswerValue, + } + addFormFieldToLocalStorage(question) + } - updateQuestionValues(question: SubmitQuestion, values: AnswerValue): void { - this.onUpdate(question, values) - }, + /** + * Proxy for update events emitted by question components. + * + * @param question The question being updated. + * @param values The updated question values. + */ + function updateQuestionValues( + question: SubmitQuestion, + values: unknown[], + ): void { + onUpdate(question, values) + } /** * On Enter, focus next form-element @@ -858,11 +776,11 @@ export default defineComponent({ * * @param event The fired event. */ - onKeydownEnter( + function onKeydownEnter( event: KeyboardEvent & { originalTarget?: EventTarget | null }, ): void { const formInputs = Array.from( - (this.$refs.form as HTMLFormElement).elements, + formElement.value?.elements ?? [], ) as HTMLElement[] const sourceInputIndex = formInputs.findIndex( (input) => input === (event.originalTarget ?? event.target), @@ -870,30 +788,35 @@ export default defineComponent({ // Focus next form element formInputs[sourceInputIndex + 1]?.focus() - }, + } /** * Ctrl+Enter typically fires submit on forms. * Some inputs do automatically, while some need explicit handling */ - onKeydownCtrlEnter(): void { - ;(this.$refs.form as HTMLFormElement | undefined)?.requestSubmit() - }, + function onKeydownCtrlEnter(): void { + formElement.value?.requestSubmit() + } /* * Methods for catching unwanted unload events */ - beforeWindowUnload(e: BeforeUnloadEvent): void { + /** + * Block closing or reloading while there are unsaved answers. + * + * @param e The beforeunload browser event. + */ + function beforeWindowUnload(e: BeforeUnloadEvent): void { if ( - this.isActive - && !this.submitForm - && Object.keys(this.answers).length !== 0 + isActive.value + && !submitForm.value + && Object.keys(answers.value).length !== 0 ) { // Cancel the window unload event e.preventDefault() e.returnValue = '' } - }, + } /** * Checks if the user is attempting to leave the form under certain conditions @@ -910,34 +833,32 @@ export default defineComponent({ * @return - Returns a promise that resolves with the value * passed to the confirm button callback if the dialog is shown, otherwise returns true. */ - confirmLeaveForm(): Promise | boolean { + function confirmLeaveForm(): Promise | boolean { if ( - this.isActive - && !this.submitForm - && Object.keys(this.answers).length !== 0 + isActive.value + && !submitForm.value + && Object.keys(answers.value).length !== 0 ) { - this.showConfirmLeaveDialog = true + showConfirmLeaveDialog.value = true return new Promise((resolve) => { - this.confirmButtonCallback = (val: boolean) => { - this.showConfirmLeaveDialog = false + confirmButtonCallback.value = (val: boolean) => { + showConfirmLeaveDialog.value = false resolve(val) } }) } return true - }, + } /** * Submit the form after the browser validated it 🚀 or show confirmation modal if empty */ - async onSubmit(): Promise { - const questionRefs = ( - Array.isArray(this.$refs.questions) - ? this.$refs.questions - : [this.$refs.questions].filter(Boolean) - ) as QuestionComponentRef[] - const validation = questionRefs.map( + async function onSubmit(): Promise { + const rawQuestionRefs = Array.isArray(questionRefs.value) + ? questionRefs.value + : [questionRefs.value].filter(Boolean) + const validation = (rawQuestionRefs as QuestionComponentRef[]).map( async (question) => await question.validate(), ) @@ -950,58 +871,58 @@ export default defineComponent({ // in case no answer is set or all are empty show the confirmation dialog if ( - Object.keys(this.answers).length === 0 - || Object.values(this.answers).every( - (answers) => answers.length === 0, + Object.keys(answers.value).length === 0 + || Object.values(answers.value).every( + (localAnswers) => localAnswers.length === 0, ) ) { - this.showConfirmEmptyModal = true + showConfirmEmptyModal.value = true } else { // otherwise do the real submit - this.onConfirmedSubmit() + await onConfirmedSubmit() } } catch (error) { logger.debug('One question is not valid', { error }) showError(t('forms', 'Some answers are not valid')) } - }, + } /** * Handle the real submit of the form, this is only called if the form is not empty or user confirmed to submit */ - async onConfirmedSubmit(): Promise { - this.showConfirmEmptyModal = false - this.loading = true + async function onConfirmedSubmit(): Promise { + showConfirmEmptyModal.value = false + loading.value = true try { - if (this.submissionId) { + if (submissionId.value) { await axios.put( generateOcsUrl( 'apps/forms/api/v3/forms/{id}/submissions/{submissionId}', { - id: this.form.id, - submissionId: this.submissionId, + id: props.form.id, + submissionId: submissionId.value, }, ), { - answers: this.answers, + answers: answers.value, }, ) } else { await axios.post( generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { - id: this.form.id, + id: props.form.id, }), { - answers: this.answers, - shareHash: this.shareHash, + answers: answers.value, + shareHash: props.shareHash, }, ) } - this.submitForm = true - this.success = true - this.deleteFormFieldFromLocalStorage() - emit('forms:last-updated:set', this.form.id) + submitForm.value = true + success.value = true + deleteFormFieldFromLocalStorage() + emitEvent('forms:last-updated:set', props.form.id) } catch (error) { const errorMessage = ( error as { @@ -1025,30 +946,164 @@ export default defineComponent({ showError(t('forms', 'There was an error submitting the form')) } } finally { - this.loading = false - if (!this.publicView) { - this.fetchFullForm(this.form.id) + loading.value = false + if (!props.publicView) { + await viewForm.fetchFullForm(props.form.id) } } - }, + } - onResetSubmission(): void { - this.deleteFormFieldFromLocalStorage() - this.resetData() - }, + /** + * + */ + function onResetSubmission(): void { + deleteFormFieldFromLocalStorage() + resetData() + } /** * Reset View-Data */ - resetData(): void { - this.answers = {} - this.loading = false - this.showConfirmLeaveDialog = false - this.showClearFormDialog = false - this.showClearFormDueToChangeDialog = false - this.success = false - this.submitForm = false - }, + function resetData(): void { + answers.value = {} + loading.value = false + showConfirmLeaveDialog.value = false + showClearFormDialog.value = false + showClearFormDueToChangeDialog.value = false + success.value = false + submitForm.value = false + } + + watch(success, (newVal: boolean): void => { + if (newVal) { + // Delay populating the live region to avoid the announcement being + // swallowed by the simultaneous large DOM change (form replaced by + // success view). Screen readers need a moment to process the new DOM + // before a polite live region update registers. + setTimeout(() => { + successAnnouncement.value = + props.form.submissionMessage + || t('forms', 'Thank you for completing the form!') + }, 100) + } else { + successAnnouncement.value = '' + } + }) + + watch( + () => props.hash, + (): void => { + // If public view, abort. Should normally not occur. + if (props.publicView) { + logger.error('Hash changed on public view. Aborting.') + return + } + resetData() + // Fetch full form on change + void viewForm.fetchFullForm(props.form.id) + initFromLocalStorage() + SetWindowTitle(viewForm.formTitle.value) + }, + ) + + onBeforeRouteUpdate(async () => { + // This navigation guard is called when the route parameters changed (e.g. form hash) + // continue with the navigation if there are no changes or the user confirms to leave the form + if (await confirmLeaveForm()) { + return + } else { + // Otherwise cancel the navigation + return false + } + }) + + onBeforeRouteLeave(async () => { + // This navigation guard is called when the route changed and a new view should be shown + // continue with the navigation if there are no changes or the user confirms to leave the form + if (await confirmLeaveForm()) { + return + } else { + // Otherwise cancel the navigation + return false + } + }) + + onMounted((): void => { + window.addEventListener('beforeunload', beforeWindowUnload) + }) + + onUnmounted((): void => { + window.removeEventListener('beforeunload', beforeWindowUnload) + }) + + onBeforeMount(async (): Promise => { + // Public Views get their form by initial-state from parent. No fetch necessary. + if (props.publicView) { + viewForm.isLoadingForm.value = false + } else { + await viewForm.fetchFullForm(props.form.id) + } + + if (props.isLoggedIn) { + if ( + submissionId.value + && (props.form.allowEditSubmissions + || props.form.permissions.includes( + PERMISSION_TYPES.PERMISSION_RESULTS_DELETE, + )) + ) { + await fetchSubmission() + } else { + initFromLocalStorage() + } + } + + SetWindowTitle(viewForm.formTitle.value) + }) + + // Non reactive properties + return { + ...viewForm, + answerTypes, + answers, + confirmClearFormButtons, + confirmEmptyModalButtons, + confirmLeaveFormButtons, + expirationMessage, + formElement, + hasAnswers, + infoMessage, + isArchived, + isClosed, + isExpired, + isMaxSubmissionsReached, + loading, + onKeydownCtrlEnter, + onKeydownEnter, + onSubmit, + onUpdate, + questionRefs, + showClearFormDialog, + showClearFormDueToChangeDialog, + showConfirmEmptyModal, + showConfirmLeaveDialog, + submissionId, + submissionMessageHTML, + success, + successAnnouncement, + updateQuestionValues, + validQuestions, + title, + IconCheckSvg: IconCheck, + IconRefreshSvg: IconRefresh, + IconSendSvg: IconSend, + t, + + maxStringLengths: loadState(formsAppName, 'maxStringLengths') as Record< + string, + number + >, + } }, })