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
60 changes: 58 additions & 2 deletions playwright/e2e/results-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ test.describe('Results view', () => {
// from submit → results after submission causes a brief redirect loop,
// so we use direct navigation instead of clicking the TopBar.
await page.goto(page.url().replace(/\/submit.*$/, '/results'))
await page.waitForURL(/\/results$/)
await page.waitForURL(/\/results(?:\?.*)?$/)
})

test('Summary tab shows submitted data', async ({ resultsView }) => {
Expand All @@ -80,20 +80,76 @@ test.describe('Results view', () => {
// Should show the individual submission with the answers
await expect(resultsView.responsesTab).toBeChecked()
await expect(resultsView.responseCount).toBeVisible()
await expect(resultsView.page).toHaveURL(/\/results\?responses$/)
})

test('Tab switching between Summary and Responses', async ({ resultsView }) => {
test('Tab switching between Summary and Responses updates the URL', async ({
resultsView,
}) => {
// Start on Summary
await expect(resultsView.summaryTab).toBeChecked()
await expect(resultsView.page).toHaveURL(/\/results\?summary$/)

// Switch to Responses
await resultsView.switchToResponses()
await expect(resultsView.responsesTab).toBeChecked()
await expect(resultsView.summaryTab).not.toBeChecked()
await expect(resultsView.page).toHaveURL(/\/results\?responses$/)

// Switch back to Summary
await resultsView.switchToSummary()
await expect(resultsView.summaryTab).toBeChecked()
await expect(resultsView.responsesTab).not.toBeChecked()
await expect(resultsView.page).toHaveURL(/\/results\?summary$/)
})

test('Explicit query route wins over remembered localStorage view', async ({
page,
resultsView,
}) => {
await page.evaluate(() => {
const match = window.location.pathname.match(
/\/apps\/forms\/([^/]+)\/results$/,
)
if (!match) {
throw new Error('Expected results route before setting localStorage')
}

localStorage.setItem(
`nextcloud_forms_${match[1]}_activeResponseView`,
'responses',
)
})

await page.goto(page.url().replace(/\/results$/, '/results?summary'))
await page.waitForURL(/\/results\?summary$/)

await expect(resultsView.summaryTab).toBeChecked()
await expect(resultsView.responsesTab).not.toBeChecked()
})

test('Query-less results route restores the remembered localStorage view', async ({
page,
resultsView,
}) => {
await page.evaluate(() => {
const match = window.location.pathname.match(
/\/apps\/forms\/([^/]+)\/results$/,
)
if (!match) {
throw new Error('Expected results route before setting localStorage')
}

localStorage.setItem(
`nextcloud_forms_${match[1]}_activeResponseView`,
'responses',
)
})

await page.goto(page.url().replace(/\/results\?summary$/, '/results'))
await page.waitForURL(/\/results\?responses$/)

await expect(resultsView.responsesTab).toBeChecked()
await expect(resultsView.summaryTab).not.toBeChecked()
})
})
63 changes: 61 additions & 2 deletions src/Forms.vue
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,53 @@ export default {
loading.value = false
}

/**
* Clean up stale localStorage entries for forms that are no longer available.
* Removes localStorage keys matching the pattern `nextcloud_forms_*_activeResponseView`
* where the form hash no longer exists in the current forms list.
*/
const cleanupStaleLocalStorageEntries = () => {
try {
// Get all current form hashes
const currentFormHashes = new Set(
[...forms.value, ...allSharedForms.value].map(
(form) => form.hash,
),
)

// Iterate through all localStorage keys
const keysToRemove = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (
key
&& key.startsWith('nextcloud_forms_')
&& key.endsWith('_activeResponseView')
) {
// Extract hash from key: nextcloud_forms_<hash>_activeResponseView
const hash = key.substring(
'nextcloud_forms_'.length,
key.length - '_activeResponseView'.length,
)
// If form hash is not in current forms, mark for removal
if (!currentFormHashes.has(hash)) {
keysToRemove.push(key)
}
}
}

// Remove stale entries
keysToRemove.forEach((key) => {
localStorage.removeItem(key)
logger.debug(`Removed stale localStorage entry: ${key}`)
})
} catch (err) {
logger.debug('Error cleaning up stale localStorage entries', {
error: err,
})
}
}

/**
* Fetch a partial form by its hash after initial load completes.
*
Expand Down Expand Up @@ -447,6 +494,17 @@ export default {
forms.value.splice(formIndex, 1)
deletedFormHash.value = deletedHash

// Remove localStorage entry for this form's active response view
try {
localStorage.removeItem(
`nextcloud_forms_${deletedHash}_activeResponseView`,
)
} catch (err) {
logger.debug('Error removing localStorage entry for deleted form', {
error: err,
})
}

if (deletedHash === routeHash.value && route.name !== 'root') {
// Navigate to root without triggering route guards
router.replace({ name: 'root' })
Expand Down Expand Up @@ -477,8 +535,9 @@ export default {
}
}

onMounted(() => {
loadForms()
onMounted(async () => {
await loadForms()
cleanupStaleLocalStorageEntries()
subscribe('forms:last-updated:set', onLastUpdatedByEventBus)
subscribe('forms:ownership-transfered', onDeleteForm)
})
Expand Down
183 changes: 179 additions & 4 deletions src/views/Results.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
:options="responseViews"
:groupLabel="t('forms', 'View mode')"
class="response-actions__toggle"
@update:active="loadFormResults" />
@update:active="onChangeResponseView" />

<!-- Action menu for cloud export and deletion -->
<NcActions
Expand Down Expand Up @@ -328,6 +328,7 @@ const responseViews = [
id: 'responses',
},
]
const responseViewIds = new Set(responseViews.map((view) => view.id))

export default {
// eslint-disable-next-line vue/multi-word-component-names
Expand Down Expand Up @@ -379,7 +380,7 @@ export default {

data() {
return {
activeResponseView: responseViews[0],
activeResponseView: {},

questions: [],
submissions: [],
Expand Down Expand Up @@ -498,10 +499,16 @@ export default {
// Reload results when form changes
async hash() {
await this.fetchFullForm(this.form.id)
this.loadFormResults()
await this.syncActiveResponseViewFromRoute()
SetWindowTitle(this.formTitle)
},

'$route.query': {
handler() {
this.syncActiveResponseViewFromRoute()
},
},

limit() {
this.loadFormResults()
},
Expand All @@ -521,15 +528,183 @@ export default {
})
this.loadFormResults()
}, INPUT_DEBOUNCE_MS),

// Persist active response view to localStorage when it changes
activeResponseView(newView) {
if (newView?.id) {
this.saveActiveResponseViewToLocalStorage(newView.id)
}
},
},

async beforeMount() {
await this.fetchFullForm(this.form.id)
this.loadFormResults()
await this.syncActiveResponseViewFromRoute()
SetWindowTitle(this.formTitle)
},

methods: {
/**
* Resolve a response view object by its ID.
*
* @param {string} viewId The requested response view ID
* @return {object}
*/
getResponseViewById(viewId) {
return (
responseViews.find((view) => view.id === viewId) ?? responseViews[0]
)
},

/**
* Read the explicit response view from the current route query.
*
* @return {string|null}
*/
getRouteResponseViewId() {
const matchingView = responseViews.find((view) => {
return Object.hasOwn(this.$route.query, view.id)
})

return matchingView?.id ?? null
},

/**
* Load the stored response view preference from localStorage for the current form.
*
* @return {string}
*/
loadStoredActiveResponseViewId() {
try {
const storageKey = this.getActiveResponseViewStorageKey()
if (!storageKey) {
return responseViews[0].id
}

const storedViewId = localStorage.getItem(storageKey)
if (storedViewId && responseViewIds.has(storedViewId)) {
return storedViewId
}

return responseViews[0].id
} catch (err) {
logger.debug('Error loading activeResponseView from localStorage', {
error: err,
})
return responseViews[0].id
}
},

/**
* Resolve the effective response view using route state first and localStorage second.
*
* @return {string}
*/
resolveActiveResponseViewId() {
return (
this.getRouteResponseViewId()
?? this.loadStoredActiveResponseViewId()
)
},

/**
* Apply the effective route/localStorage view and refresh results when needed.
*/
async syncActiveResponseViewFromRoute() {
const routeViewId = this.getRouteResponseViewId()
const nextView = this.getResponseViewById(
routeViewId ?? this.loadStoredActiveResponseViewId(),
)
const currentViewId = this.activeResponseView?.id

if (currentViewId !== nextView.id) {
this.activeResponseView = nextView
}

if (!routeViewId) {
try {
await this.$router.replace({
name: 'results',
params: {
hash: this.form.hash,
},
query: {
...this.$route.query,
[nextView.id]: null,
},
})
return
} catch (error) {
logger.debug('Navigation cancelled', { error })
}
}

this.loadFormResults()
},

/**
* Save the active response view preference to localStorage for the current form.
*
* @param {string} viewId - The ID of the view ('summary' or 'responses')
*/
saveActiveResponseViewToLocalStorage(viewId) {
try {
const storageKey = this.getActiveResponseViewStorageKey()
if (!storageKey) {
return
}

localStorage.setItem(storageKey, viewId)
} catch (err) {
logger.debug('Error saving activeResponseView to localStorage', {
error: err,
})
}
},

/**
* Build the localStorage key for the active response view.
*
* @return {string|null}
*/
getActiveResponseViewStorageKey() {
const formHash = this.form?.hash
if (!formHash) {
return null
}

return `nextcloud_forms_${formHash}_activeResponseView`
},

/**
* Navigate to an explicit route query for the selected response view.
*
* @param {object} view The selected response view object
*/
async onChangeResponseView(view) {
if (!view?.id) {
return
}
if (this.getRouteResponseViewId() === view.id) {
this.loadFormResults()
return
}

try {
await this.$router.push({
name: 'results',
params: {
hash: this.form.hash,
},
query: {
[view.id]: null,
},
})
} catch (error) {
logger.debug('Navigation cancelled', { error })
}
},

async onUnlinkFile() {
await axios.patch(
generateOcsUrl('apps/forms/api/v3/forms/{formId}', {
Expand Down
Loading