feat(setup): post-link redirects to SPA so first-setup matches live UI
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Twig configure page replaced with a redirect: SetupController's index,
register, login, and the legacy /configure route all post-link redirect
to /?setup=<deviceId> for unconfigured devices. The SPA's HomeView
auto-opens its existing settings sheet for that id, with the same
controls everyone uses for live edits — themed to the user's choice,
pre-populated from the device record.
Fixes Matt's report:
- "every 6 hours" lost on save: the configure form posted
rotation_interval_hours but the controller read
rotation_interval_minutes, so the value silently defaulted to
1440 every time. Now the SPA's PATCH flow handles it correctly.
- "old settings still there in live settings": SPA settings sheet
pre-populates from the device's current state via onEdit.
- "uniqueness window in setup but not live settings": removed
from the (now-deleted) Twig form; both surfaces are consistent.
- "color scheme didn't match account": SPA respects the user's
theme natively (data-theme on <html>), so the first-setup screen
looks like the rest of the app.
Also adds a "Sign out of pictureFrame" link at the bottom of the
per-frame settings sheet (the existing /settings tab still has the
primary one). Easy escape hatch from a deeply-nested settings flow.
Tests:
- SetupControllerTest: S-03/04/05/06/08 updated for new redirect
targets, S-CLAIM-03 updated.
- HomeView.test.ts: useRoute now mockable per-test, two new cases
pinning the ?setup=<id> auto-open and its absence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,9 +49,13 @@ vi.mock('@/components/OrientationPicker.vue', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Stub vue-router so HomeView can call useRouter() without a real router
|
||||
// Stub vue-router so HomeView can call useRouter() without a real router.
|
||||
// `mockRoute` is mutated per-test where needed (e.g. to surface ?setup=<id>).
|
||||
const mockRoute: { query: Record<string, string | undefined> } = { query: {} }
|
||||
const routerReplace = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
useRouter: () => ({ push: routerPush, replace: routerReplace }),
|
||||
useRoute: () => mockRoute,
|
||||
}))
|
||||
|
||||
// Stub URL.createObjectURL used by upload store
|
||||
@@ -1128,6 +1132,41 @@ describe('HomeView', () => {
|
||||
expect(document.querySelector('.home-view__remove-modal')).toBeNull()
|
||||
})
|
||||
|
||||
// First-time setup: the SetupController redirects new claim/register
|
||||
// flows to /?setup=<id>. The SPA must auto-open the per-frame settings
|
||||
// sheet for that device, with the standard pre-population, so the user
|
||||
// never has to learn a separate Twig configure form.
|
||||
it('auto-opens the per-frame settings sheet when ?setup=<id> is in the URL', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 7, name: '' })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
// Simulate landing on /?setup=7 (SetupController's post-link redirect).
|
||||
mockRoute.query = { setup: '7' }
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
|
||||
expect(sheet.props('modelValue')).toBe(true)
|
||||
expect(routerReplace).toHaveBeenCalled() // query cleaned off the URL
|
||||
|
||||
mockRoute.query = {}
|
||||
})
|
||||
|
||||
it('does NOT open the sheet when no ?setup= param is present', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 7 })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
mockRoute.query = {}
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
|
||||
expect(sheet.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
// Update the existing propagation-note text test to assert the new
|
||||
// "disconnect power" hint that lets users force an immediate refresh.
|
||||
it('propagation note mentions disconnecting power as an immediate-refresh gesture', async () => {
|
||||
|
||||
@@ -231,6 +231,8 @@
|
||||
class="home-view__remove"
|
||||
@click="removeConfirmOpen = true"
|
||||
>Remove this frame</button>
|
||||
|
||||
<a href="/logout" class="home-view__logout">Sign out of pictureFrame</a>
|
||||
</BaseBottomSheet>
|
||||
|
||||
<Teleport to="body">
|
||||
@@ -279,7 +281,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import { useDeviceMercure } from '@/composables/useDeviceMercure'
|
||||
@@ -402,6 +404,7 @@ import OrientationPicker from '@/components/OrientationPicker.vue'
|
||||
import PullToRefresh from '@/components/PullToRefresh.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const devicesStore = useDevicesStore()
|
||||
const uploadStore = useUploadStore()
|
||||
|
||||
@@ -409,9 +412,22 @@ const uploadStore = useUploadStore()
|
||||
// (and on PATCH/lock/unlock); the composable splats it into the store.
|
||||
useDeviceMercure()
|
||||
|
||||
onMounted(() => {
|
||||
devicesStore.fetchDevices()
|
||||
onMounted(async () => {
|
||||
await devicesStore.fetchDevices()
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
|
||||
// First-time setup landing: SetupController redirects new users to
|
||||
// /?setup=<deviceId> after register/login, instead of a separate
|
||||
// Twig configure page. Auto-open the settings sheet for that device
|
||||
// so the user sees the same rich UI everyone else uses for live edits
|
||||
// — pre-populated, themed, with no duplicated form to maintain.
|
||||
const setupId = Number(route.query.setup)
|
||||
if (setupId) {
|
||||
onEdit(setupId)
|
||||
// Clean the query param off the URL so a refresh doesn't keep
|
||||
// re-opening the sheet, but keep the user on the home view.
|
||||
router.replace({ query: { ...route.query, setup: undefined } })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -1150,5 +1166,21 @@ async function saveSettings() {
|
||||
border-color: var(--color-danger, #c0392b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__logout {
|
||||
display: block;
|
||||
margin-top: var(--space-3);
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
padding: var(--space-2);
|
||||
|
||||
&:hover, &:focus-visible {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user