Skip to content

Commit dfdcf7a

Browse files
authored
feat: reflect response view in URL
Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
1 parent 65f7dba commit dfdcf7a

2 files changed

Lines changed: 208 additions & 26 deletions

File tree

playwright/e2e/results-view.spec.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ test.describe('Results view', () => {
5555
// from submit → results after submission causes a brief redirect loop,
5656
// so we use direct navigation instead of clicking the TopBar.
5757
await page.goto(page.url().replace(/\/submit.*$/, '/results'))
58-
await page.waitForURL(/\/results$/)
58+
await page.waitForURL(/\/results(?:\?.*)?$/)
5959
})
6060

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

85-
test('Tab switching between Summary and Responses', async ({ resultsView }) => {
86+
test('Tab switching between Summary and Responses updates the URL', async ({
87+
resultsView,
88+
}) => {
8689
// Start on Summary
8790
await expect(resultsView.summaryTab).toBeChecked()
91+
await expect(resultsView.page).toHaveURL(/\/results\?summary$/)
8892

8993
// Switch to Responses
9094
await resultsView.switchToResponses()
9195
await expect(resultsView.responsesTab).toBeChecked()
9296
await expect(resultsView.summaryTab).not.toBeChecked()
97+
await expect(resultsView.page).toHaveURL(/\/results\?responses$/)
9398

9499
// Switch back to Summary
95100
await resultsView.switchToSummary()
96101
await expect(resultsView.summaryTab).toBeChecked()
97102
await expect(resultsView.responsesTab).not.toBeChecked()
103+
await expect(resultsView.page).toHaveURL(/\/results\?summary$/)
104+
})
105+
106+
test('Explicit query route wins over remembered localStorage view', async ({
107+
page,
108+
resultsView,
109+
}) => {
110+
await page.evaluate(() => {
111+
const match = window.location.pathname.match(
112+
/\/apps\/forms\/([^/]+)\/results$/,
113+
)
114+
if (!match) {
115+
throw new Error('Expected results route before setting localStorage')
116+
}
117+
118+
localStorage.setItem(
119+
`nextcloud_forms_${match[1]}_activeResponseView`,
120+
'responses',
121+
)
122+
})
123+
124+
await page.goto(page.url().replace(/\/results$/, '/results?summary'))
125+
await page.waitForURL(/\/results\?summary$/)
126+
127+
await expect(resultsView.summaryTab).toBeChecked()
128+
await expect(resultsView.responsesTab).not.toBeChecked()
129+
})
130+
131+
test('Query-less results route restores the remembered localStorage view', async ({
132+
page,
133+
resultsView,
134+
}) => {
135+
await page.evaluate(() => {
136+
const match = window.location.pathname.match(
137+
/\/apps\/forms\/([^/]+)\/results$/,
138+
)
139+
if (!match) {
140+
throw new Error('Expected results route before setting localStorage')
141+
}
142+
143+
localStorage.setItem(
144+
`nextcloud_forms_${match[1]}_activeResponseView`,
145+
'responses',
146+
)
147+
})
148+
149+
await page.goto(page.url().replace(/\/results\?summary$/, '/results'))
150+
await page.waitForURL(/\/results\?responses$/)
151+
152+
await expect(resultsView.responsesTab).toBeChecked()
153+
await expect(resultsView.summaryTab).not.toBeChecked()
98154
})
99155
})

src/views/Results.vue

Lines changed: 150 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
:options="responseViews"
4848
:groupLabel="t('forms', 'View mode')"
4949
class="response-actions__toggle"
50-
@update:active="loadFormResults" />
50+
@update:active="onChangeResponseView" />
5151

5252
<!-- Action menu for cloud export and deletion -->
5353
<NcActions
@@ -328,6 +328,7 @@ const responseViews = [
328328
id: 'responses',
329329
},
330330
]
331+
const responseViewIds = new Set(responseViews.map((view) => view.id))
331332
332333
export default {
333334
// eslint-disable-next-line vue/multi-word-component-names
@@ -379,7 +380,7 @@ export default {
379380
380381
data() {
381382
return {
382-
activeResponseView: responseViews[0],
383+
activeResponseView: {},
383384
384385
questions: [],
385386
submissions: [],
@@ -498,11 +499,16 @@ export default {
498499
// Reload results when form changes
499500
async hash() {
500501
await this.fetchFullForm(this.form.id)
501-
this.loadActiveResponseViewFromLocalStorage()
502-
this.loadFormResults()
502+
await this.syncActiveResponseViewFromRoute()
503503
SetWindowTitle(this.formTitle)
504504
},
505505
506+
'$route.query': {
507+
handler() {
508+
this.syncActiveResponseViewFromRoute()
509+
},
510+
},
511+
506512
limit() {
507513
this.loadFormResults()
508514
},
@@ -525,40 +531,115 @@ export default {
525531
526532
// Persist active response view to localStorage when it changes
527533
activeResponseView(newView) {
528-
this.saveActiveResponseViewToLocalStorage(newView.id)
534+
if (newView?.id) {
535+
this.saveActiveResponseViewToLocalStorage(newView.id)
536+
}
529537
},
530538
},
531539
532540
async beforeMount() {
533541
await this.fetchFullForm(this.form.id)
534-
this.loadActiveResponseViewFromLocalStorage()
535-
this.loadFormResults()
542+
await this.syncActiveResponseViewFromRoute()
536543
SetWindowTitle(this.formTitle)
537544
},
538545
539546
methods: {
540547
/**
541-
* Load the active response view preference from localStorage for the current form.
542-
* Applies stored value if available and otherwise resets to default (summary)
548+
* Resolve a response view object by its ID.
549+
*
550+
* @param {string} viewId The requested response view ID
551+
* @return {object}
552+
*/
553+
getResponseViewById(viewId) {
554+
return (
555+
responseViews.find((view) => view.id === viewId) ?? responseViews[0]
556+
)
557+
},
558+
559+
/**
560+
* Read the explicit response view from the current route query.
561+
*
562+
* @return {string|null}
563+
*/
564+
getRouteResponseViewId() {
565+
const matchingView = responseViews.find((view) => {
566+
return Object.hasOwn(this.$route.query, view.id)
567+
})
568+
569+
return matchingView?.id ?? null
570+
},
571+
572+
/**
573+
* Load the stored response view preference from localStorage for the current form.
574+
*
575+
* @return {string}
543576
*/
544-
loadActiveResponseViewFromLocalStorage() {
577+
loadStoredActiveResponseViewId() {
545578
try {
546-
const storedViewId = localStorage.getItem(
547-
`nextcloud_forms_${this.form.hash}_activeResponseView`,
548-
)
549-
if (storedViewId) {
550-
const view = responseViews.find((v) => v.id === storedViewId)
551-
if (view) {
552-
this.activeResponseView = view
553-
}
554-
} else {
555-
this.activeResponseView = responseViews[0]
579+
const storageKey = this.getActiveResponseViewStorageKey()
580+
if (!storageKey) {
581+
return responseViews[0].id
582+
}
583+
584+
const storedViewId = localStorage.getItem(storageKey)
585+
if (storedViewId && responseViewIds.has(storedViewId)) {
586+
return storedViewId
556587
}
588+
589+
return responseViews[0].id
557590
} catch (err) {
558591
logger.debug('Error loading activeResponseView from localStorage', {
559592
error: err,
560593
})
594+
return responseViews[0].id
595+
}
596+
},
597+
598+
/**
599+
* Resolve the effective response view using route state first and localStorage second.
600+
*
601+
* @return {string}
602+
*/
603+
resolveActiveResponseViewId() {
604+
return (
605+
this.getRouteResponseViewId()
606+
?? this.loadStoredActiveResponseViewId()
607+
)
608+
},
609+
610+
/**
611+
* Apply the effective route/localStorage view and refresh results when needed.
612+
*/
613+
async syncActiveResponseViewFromRoute() {
614+
const routeViewId = this.getRouteResponseViewId()
615+
const nextView = this.getResponseViewById(
616+
routeViewId ?? this.loadStoredActiveResponseViewId(),
617+
)
618+
const currentViewId = this.activeResponseView?.id
619+
620+
if (currentViewId !== nextView.id) {
621+
this.activeResponseView = nextView
622+
}
623+
624+
if (!routeViewId) {
625+
try {
626+
await this.$router.replace({
627+
name: 'results',
628+
params: {
629+
hash: this.form.hash,
630+
},
631+
query: {
632+
...this.$route.query,
633+
[nextView.id]: null,
634+
},
635+
})
636+
return
637+
} catch (error) {
638+
logger.debug('Navigation cancelled', { error })
639+
}
561640
}
641+
642+
this.loadFormResults()
562643
},
563644
564645
/**
@@ -568,17 +649,62 @@ export default {
568649
*/
569650
saveActiveResponseViewToLocalStorage(viewId) {
570651
try {
571-
localStorage.setItem(
572-
`nextcloud_forms_${this.form.hash}_activeResponseView`,
573-
viewId,
574-
)
652+
const storageKey = this.getActiveResponseViewStorageKey()
653+
if (!storageKey) {
654+
return
655+
}
656+
657+
localStorage.setItem(storageKey, viewId)
575658
} catch (err) {
576659
logger.debug('Error saving activeResponseView to localStorage', {
577660
error: err,
578661
})
579662
}
580663
},
581664
665+
/**
666+
* Build the localStorage key for the active response view.
667+
*
668+
* @return {string|null}
669+
*/
670+
getActiveResponseViewStorageKey() {
671+
const formHash = this.form?.hash
672+
if (!formHash) {
673+
return null
674+
}
675+
676+
return `nextcloud_forms_${formHash}_activeResponseView`
677+
},
678+
679+
/**
680+
* Navigate to an explicit route query for the selected response view.
681+
*
682+
* @param {object} view The selected response view object
683+
*/
684+
async onChangeResponseView(view) {
685+
if (!view?.id) {
686+
return
687+
}
688+
if (this.getRouteResponseViewId() === view.id) {
689+
this.loadFormResults()
690+
return
691+
}
692+
693+
try {
694+
await this.$router.push({
695+
name: 'results',
696+
params: {
697+
hash: this.form.hash,
698+
},
699+
query: {
700+
[view.id]: null,
701+
},
702+
})
703+
} catch (error) {
704+
logger.debug('Navigation cancelled', { error })
705+
}
706+
},
707+
582708
async onUnlinkFile() {
583709
await axios.patch(
584710
generateOcsUrl('apps/forms/api/v3/forms/{formId}', {

0 commit comments

Comments
 (0)