Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 85 additions & 102 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"lint:fix": "eslint --ext .js,.ts,.vue src --fix",
"start:nextcloud": "node playwright/start-nextcloud-server.mjs",
"stylelint": "stylelint \"src/**/*.scss\" \"src/**/*.vue\"",
"stylelint:fix": "stylelint \"src/**/*.scss\" \"src/**/*.vue\" --fix"
"stylelint:fix": "stylelint \"src/**/*.scss\" \"src/**/*.vue\" --fix",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"browserslist": [
"extends @nextcloud/browserslist-config"
Expand Down Expand Up @@ -58,12 +59,15 @@
"@nextcloud/stylelint-config": "^3.2.2",
"@nextcloud/vite-config": "^2.5.2",
"@playwright/test": "^1.62.1",
"@types/markdown-it": "^14.1.2",
"@types/node": "^26.1.2",
"@types/qrcode": "^1.5.6",
"@vue/tsconfig": "^0.9.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"prettier": "^3.9.6",
"vite": "^7.3.6"
"vite": "^7.3.6",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "^24.0.0",
Expand Down
113 changes: 78 additions & 35 deletions src/Forms.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,16 +145,20 @@
</NcContent>
</template>

<script>
<script lang="ts">
import type { FormsForm } from './models/Entities.d.ts'

import IconPlus from '@material-symbols/svg-400/outlined/add.svg?raw'
import IconArchive from '@material-symbols/svg-400/outlined/archive.svg?raw'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import { generateOcsUrl } from '@nextcloud/router'
import { useIsMobile } from '@nextcloud/vue'
import { defineComponent } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
Expand All @@ -170,14 +174,13 @@ import AppNavigationForm from './components/AppNavigationForm.vue'
import ArchivedFormsModal from './components/ArchivedFormsModal.vue'
import Sidebar from './views/Sidebar.vue'
import FormsIcon from '../img/forms-dark.svg?raw'
import PermissionTypes from './mixins/PermissionTypes.ts'
import { FormState } from './models/Constants.ts'
import logger from './utils/Logger.ts'
import OcsResponse2Data from './utils/OcsResponse2Data.ts'

const appName = 'forms'

export default {
export default defineComponent({
// eslint-disable-next-line vue/multi-word-component-names
name: 'Forms',

Expand All @@ -204,18 +207,31 @@ export default {
const loading = ref(true)
const sidebarOpened = ref(false)
const sidebarActive = ref('forms-sharing')
const forms = ref([])
const allSharedForms = ref([])
const forms = ref<FormsForm[]>([])
const allSharedForms = ref<FormsForm[]>([])
const showArchivedForms = ref(false)
const canCreateForms = ref(loadState(appName, 'appConfig').canCreateForms)
const allowComments = ref(loadState(appName, 'appConfig').allowComments)
const deletedFormHash = ref(null)
const appConfig = loadState(appName, 'appConfig') as {
canCreateForms?: boolean
allowComments?: boolean
}
const canCreateForms = ref(Boolean(appConfig?.canCreateForms))
const allowComments = ref(Boolean(appConfig?.allowComments))
const deletedFormHash = ref<string | null>(null)

const PERMISSION_TYPES = PermissionTypes.data().PERMISSION_TYPES
const PERMISSION_TYPES = {
PERMISSION_EDIT: 'edit',
PERMISSION_SUBMIT: 'submit',
}

const routeHash = computed(() => route.params.hash)
const routeHash = computed<string | undefined>(() => {
const hash = route.params.hash
if (Array.isArray(hash)) {
return hash[0]
}
return hash as string | undefined
})

const routeAllowed = computed(() => {
const routeAllowed = computed<boolean>(() => {
if (loading.value && loadState(appName, 'formId') === 'invalid') {
return false
}
Expand All @@ -239,16 +255,17 @@ export default {
}

const resultRoutes = ['results', 'results.summary', 'results.responses']
if (resultRoutes.includes(route.name)) {
if (resultRoutes.includes(String(route.name ?? ''))) {
return (
form.permissions.includes('results') || form.submissionCount > 0
form.permissions.includes('results')
|| (form.submissionCount ?? 0) > 0
)
}

return form?.permissions.includes(route.name)
return form?.permissions.includes(String(route.name ?? ''))
})

const selectedForm = computed(() => {
const selectedForm = computed<FormsForm | Record<string, never>>(() => {
if (routeAllowed.value) {
return (
[...forms.value, ...allSharedForms.value].find(
Expand All @@ -259,7 +276,7 @@ export default {
return {}
})

const updateSelectedForm = (form) => {
const updateSelectedForm = (form: FormsForm): void => {
sidebarOpened.value = false

const index = forms.value.findIndex((f) => f.hash === form.hash)
Expand Down Expand Up @@ -310,7 +327,7 @@ export default {
}
}

const openSharing = (hash) => {
const openSharing = (hash: string): void => {
if (hash !== routeHash.value) {
router.push({ name: 'edit', params: { hash } })
}
Expand Down Expand Up @@ -357,7 +374,7 @@ export default {
* Removes localStorage keys matching the pattern `nextcloud_forms_*_activeResponseView`
* where the form hash no longer exists in the current forms list.
*/
const cleanupStaleLocalStorageEntries = () => {
const cleanupStaleLocalStorageEntries = (): void => {
try {
// Get all current form hashes
const currentFormHashes = new Set(
Expand Down Expand Up @@ -402,10 +419,10 @@ export default {
/**
* Fetch a partial form by its hash after initial load completes.
*
* @param {string} hash The hash of the form to fetch.
* @param hash The hash of the form to fetch.
*/
async function fetchPartialForm(hash) {
await new Promise((resolve) => {
async function fetchPartialForm(hash: string): Promise<void> {
await new Promise<void>((resolve) => {
const wait = () => {
if (loading.value) {
window.setTimeout(wait, 250)
Expand All @@ -428,18 +445,26 @@ export default {
id: loadState(appName, 'formId'),
}),
)
const form = OcsResponse2Data(response)
const form = OcsResponse2Data<FormsForm>(response)

if (
form.permissions.includes(PERMISSION_TYPES.PERMISSION_SUBMIT)
) {
allSharedForms.value.push(form)
}
} catch (error) {
} catch (error: unknown) {
logger.error(`Form ${hash} not found`, { error })
showError(t('forms', 'Form not found'))

if ([403, 404].includes(error.response?.status)) {
if (
typeof error === 'object'
&& error !== null
&& 'response' in error
&& [403, 404].includes(
(error as { response?: { status?: number } }).response
?.status ?? 0,
)
) {
if (route.name !== 'root') {
router.push({ name: 'root' })
}
Expand All @@ -455,7 +480,7 @@ export default {
const response = await axios.post(
generateOcsUrl('apps/forms/api/v3/forms'),
)
const newForm = OcsResponse2Data(response)
const newForm = OcsResponse2Data<FormsForm>(response)
forms.value.unshift(newForm)
router.push({
name: 'edit',
Expand All @@ -468,14 +493,14 @@ export default {
}
}

const onCloneForm = async (id) => {
const onCloneForm = async (id: number): Promise<void> => {
try {
const response = await axios.post(
generateOcsUrl('apps/forms/api/v3/forms?fromId={id}', {
id,
}),
)
const newForm = OcsResponse2Data(response)
const newForm = OcsResponse2Data<FormsForm>(response)
forms.value.unshift(newForm)
router.push({
name: 'edit',
Expand All @@ -488,8 +513,11 @@ export default {
}
}

const onDeleteForm = async (id) => {
const onDeleteForm = async (id: number): Promise<void> => {
const formIndex = forms.value.findIndex((form) => form.id === id)
if (formIndex < 0) {
return
}
const deletedHash = forms.value[formIndex].hash

forms.value.splice(formIndex, 1)
Expand All @@ -515,14 +543,14 @@ export default {
// Reset deletedFormHash when navigating away from the deleted form
watch(
() => route.name,
(newRouteName) => {
(newRouteName: string | symbol | null | undefined) => {
if (newRouteName === 'root') {
deletedFormHash.value = null
}
},
)

const onLastUpdatedByEventBus = (id) => {
const onLastUpdatedByEventBus = (id: number): void => {
const formIndex = forms.value.findIndex((form) => form.id === id)
if (formIndex !== -1) {
forms.value[formIndex].lastUpdated = moment().unix()
Expand All @@ -536,19 +564,34 @@ export default {
}
}

const onLastUpdatedByEventBusEvent = (event: unknown): void => {
const id = Number(event)
if (Number.isFinite(id)) {
onLastUpdatedByEventBus(id)
}
}

const onOwnershipTransferredEvent = (event: unknown): void => {
const id = Number(event)
if (Number.isFinite(id)) {
void onDeleteForm(id)
}
}

onMounted(async () => {
await loadForms()
cleanupStaleLocalStorageEntries()
subscribe('forms:last-updated:set', onLastUpdatedByEventBus)
subscribe('forms:ownership-transfered', onDeleteForm)
subscribe('forms:last-updated:set', onLastUpdatedByEventBusEvent)
subscribe('forms:ownership-transfered', onOwnershipTransferredEvent)
})

onUnmounted(() => {
unsubscribe('forms:last-updated:set', onLastUpdatedByEventBus)
unsubscribe('forms:ownership-transfered', onDeleteForm)
unsubscribe('forms:last-updated:set', onLastUpdatedByEventBusEvent)
unsubscribe('forms:ownership-transfered', onOwnershipTransferredEvent)
})

return {
t,
loading,
sidebarOpened,
sidebarActive,
Expand Down Expand Up @@ -580,7 +623,7 @@ export default {
FormsIcon,
}
},
}
})
</script>

<style scoped lang="scss">
Expand Down
19 changes: 13 additions & 6 deletions src/FormsEmptyContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@
</NcContent>
</template>

<script>
<script lang="ts">
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 NcAppContent from '@nextcloud/vue/components/NcAppContent'
import NcContent from '@nextcloud/vue/components/NcContent'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import FormsIcon from '../img/forms-dark.svg?raw'

export default {
const appName = 'forms'

export default defineComponent({
name: 'FormsEmptyContent',

components: {
Expand Down Expand Up @@ -58,18 +62,21 @@ export default {

icon: IconCheck,
},
},
} as Record<
string,
{ title: string; description: string; icon: string }
>,

renderAs: loadState(appName, 'renderAs'),
renderAs: loadState(appName, 'renderAs') as string,
}
},

computed: {
currentModel() {
currentModel(): { title: string; description: string; icon: string } {
return this.renderModels[this.renderAs]
},
},
}
})
</script>

<style lang="scss" scoped>
Expand Down
Loading