feat(setup): post-link redirects to SPA so first-setup matches live UI
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:
2026-05-08 18:51:31 -04:00
parent ff1ae79824
commit 08d0968af0
14 changed files with 139 additions and 95 deletions
+41 -2
View File
@@ -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 () => {
+35 -3
View File
@@ -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>