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
36 changes: 36 additions & 0 deletions .yarn/changelogs/frontend.bb2aa909.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!-- version-type: patch -->
# frontend

<!--
FORMATTING GUIDE:

### Detailed Entry (appears first when merging)

Use h3 (###) and below for detailed entries with paragraphs, code examples, and lists.

### Simple List Items

- Simple changes can be added as list items
- They are collected together at the bottom of each section

TIP: When multiple changelog drafts are merged, heading-based entries
appear before simple list items within each section.
-->

## ⚡ Performance

- Converted route components to lazy-loaded dynamic imports using `PiRatLazyLoad`, enabling code splitting for all page-level routes (movies, series, IoT devices, dashboards, login, register)

## 🐛 Bug Fixes

- Fixed scroll behavior in chat and AI chat message lists by moving scroll container styles from shadow DOM host CSS to inner wrapper elements
- Fixed terminal initialization timing in `LogEntriesTerminal` by deferring `terminal.open()` to a microtask, ensuring the container element is mounted in the DOM before opening
- Fixed form reset in chat and AI chat message inputs by using properly typed `HTMLFormElement` refs instead of workaround `querySelector` logic

## ♻️ Refactoring

- Renamed `urlString` to `href` in `IconUrlWidget` for semantic clarity

## 🧪 Tests

- Added unit tests for `navigateToRoute` covering `pushState`, `replaceState`, route parameter compilation, and query string handling
22 changes: 22 additions & 0 deletions .yarn/changelogs/pi-rat.bb2aa909.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!-- version-type: patch -->
# pi-rat

<!--
FORMATTING GUIDE:

### Detailed Entry (appears first when merging)

Use h3 (###) and below for detailed entries with paragraphs, code examples, and lists.

### Simple List Items

- Simple changes can be added as list items
- They are collected together at the bottom of each section

TIP: When multiple changelog drafts are merged, heading-based entries
appear before simple list items within each section.
-->

## 🔧 Chores

- Upgraded to Shades 12 with lazy-loaded route components and shadow DOM compatibility fixes
3 changes: 3 additions & 0 deletions .yarn/versions/bb2aa909.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
releases:
frontend: patch
pi-rat: patch
98 changes: 77 additions & 21 deletions frontend/src/app-routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,11 @@ import {
import { AppBarLink, type AppBarLinkProps } from '@furystack/shades-common-components'
import { decode } from 'common'
import type { MatchResult } from 'path-to-regexp'
import { LoadableDashboard } from './components/dashboard/LoadableDashboard.js'
import { DefaultDashboard } from './components/dashboard/default-dashboard.js'
import { PiRatLazyLoad } from './components/pirat-lazy-load.js'
import { navigateToRoute } from './navigate-to-route.js'
import { DeviceList } from './pages/iot/device-list.js'
import { Login } from './pages/login.js'
import { MovieList } from './pages/movies/movie-list.js'
import { MovieLoader } from './pages/movies/movie-loader.js'
import { MovieOverview } from './pages/movies/movie-overview.js'
import { SeriesList } from './pages/movies/series-list.js'
import { SeriesOverview } from './pages/movies/series-overview.js'
import { Register } from './pages/register.js'

/**
* Like ExtractRoutePaths from @furystack/shades but with NestedRoute<any> constraint
* Like ExtractRoutePaths from @furystack/shades but with NestedRoute<never> constraint
* to support routes with specific match parameter types.
*/
type ConcatPaths<Parent extends string, Child extends string> = Parent extends '/' ? Child : `${Parent}${Child}`
Expand Down Expand Up @@ -107,22 +97,53 @@ const settingsChildren = {

export const appRoutes = {
'/movies': {
component: () => <MovieList />,
component: () => (
<PiRatLazyLoad
component={async () => {
const { MovieList } = await import('./pages/movies/movie-list.js')
return <MovieList />
}}
/>
),
},
'/movies/:id/watch': {
component: ({ match }: { match: MatchResult<{ id: string }> }) => <MovieLoader movieFileId={match.params.id} />,
component: ({ match }: { match: MatchResult<{ id: string }> }) => (
<PiRatLazyLoad
component={async () => {
const { MovieLoader } = await import('./pages/movies/movie-loader.js')
return <MovieLoader movieFileId={match.params.id} />
}}
/>
),
},
'/movies/:imdbId/overview': {
component: ({ match }: { match: MatchResult<{ imdbId: string }> }) => (
<MovieOverview imdbId={match.params.imdbId} />
<PiRatLazyLoad
component={async () => {
const { MovieOverview } = await import('./pages/movies/movie-overview.js')
return <MovieOverview imdbId={match.params.imdbId} />
}}
/>
),
},
'/series': {
component: () => <SeriesList />,
component: () => (
<PiRatLazyLoad
component={async () => {
const { SeriesList } = await import('./pages/movies/series-list.js')
return <SeriesList />
}}
/>
),
},
'/series/:imdbId': {
component: ({ match }: { match: MatchResult<{ imdbId: string }> }) => (
<SeriesOverview imdbId={match.params.imdbId} />
<PiRatLazyLoad
component={async () => {
const { SeriesOverview } = await import('./pages/movies/series-overview.js')
return <SeriesOverview imdbId={match.params.imdbId} />
}}
/>
),
},
'/app-settings': {
Expand Down Expand Up @@ -257,7 +278,14 @@ export const appRoutes = {
),
},
'/iot/devices': {
component: () => <DeviceList />,
component: () => (
<PiRatLazyLoad
component={async () => {
const { DeviceList } = await import('./pages/iot/device-list.js')
return <DeviceList />
}}
/>
),
},
'/iot/device/:id': {
component: ({ match }: { match: MatchResult<{ id: string }> }) => (
Expand Down Expand Up @@ -315,20 +343,48 @@ export const appRoutes = {
),
},
'/dashboards/:id': {
component: ({ match }: { match: MatchResult<{ id: string }> }) => <LoadableDashboard id={match.params.id} />,
component: ({ match }: { match: MatchResult<{ id: string }> }) => (
<PiRatLazyLoad
component={async () => {
const { LoadableDashboard } = await import('./components/dashboard/LoadableDashboard.js')
return <LoadableDashboard id={match.params.id} />
}}
/>
),
},
'/': {
routingOptions: { end: false },
component: () => <DefaultDashboard />,
component: () => (
<PiRatLazyLoad
component={async () => {
const { DefaultDashboard } = await import('./components/dashboard/default-dashboard.js')
return <DefaultDashboard />
}}
/>
),
},
}

export const authRoutes = {
'/register': {
component: () => <Register />,
component: () => (
<PiRatLazyLoad
component={async () => {
const { Register } = await import('./pages/register.js')
return <Register />
}}
/>
),
},
'': {
component: () => <Login />,
component: () => (
<PiRatLazyLoad
component={async () => {
const { Login } = await import('./pages/login.js')
return <Login />
}}
/>
),
},
}

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/dashboard/icon-url-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,10 @@ export const IconUrlWidget = Shade<IconUrlWidgetProps>({
}
})

const urlString: string = props.url
const href: string = props.url

return (
<NestedRouteLink title={props.description} href={urlString}>
<NestedRouteLink title={props.description} href={href}>
<div
ref={cardRef}
className="widget-card"
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/navigate-to-route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Injector } from '@furystack/inject'
import { LocationService } from '@furystack/shades'
import { usingAsync } from '@furystack/utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { navigateToRoute } from './navigate-to-route.js'

describe('navigateToRoute', () => {
let pushStateSpy: ReturnType<typeof vi.spyOn>
let replaceStateSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
pushStateSpy = vi.spyOn(window.history, 'pushState').mockImplementation(() => {})
replaceStateSpy = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
})

it('should call pushState with the path', async () => {
await usingAsync(new Injector(), async (injector) => {
const updateState = vi.fn()
injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService)

navigateToRoute(injector, '/movies')

expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/movies')
})
})

it('should call LocationService.updateState()', async () => {
await usingAsync(new Injector(), async (injector) => {
const updateState = vi.fn()
injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService)

navigateToRoute(injector, '/movies')

expect(updateState).toHaveBeenCalled()
})
})

it('should compile route with params', async () => {
await usingAsync(new Injector(), async (injector) => {
const updateState = vi.fn()
injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService)

navigateToRoute(injector, '/movies/:imdbId/overview', { imdbId: 'tt1234567' })

expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/movies/tt1234567/overview')
})
})

it('should append queryString when provided', async () => {
await usingAsync(new Injector(), async (injector) => {
const updateState = vi.fn()
injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService)

navigateToRoute(injector, '/entities/movies', {}, { queryString: 'gedst=%7B%22mode%22%3A%22edit%22%7D' })

expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/entities/movies?gedst=%7B%22mode%22%3A%22edit%22%7D')
})
})

it('should call replaceState when options.replace is true', async () => {
await usingAsync(new Injector(), async (injector) => {
const updateState = vi.fn()
injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService)

navigateToRoute(injector, '/app-settings/omdb', {}, { replace: true })

expect(replaceStateSpy).toHaveBeenCalledWith({}, '', '/app-settings/omdb')
expect(pushStateSpy).not.toHaveBeenCalled()
})
})

it('should call pushState by default (replace not set)', async () => {
await usingAsync(new Injector(), async (injector) => {
const updateState = vi.fn()
injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService)

navigateToRoute(injector, '/series')

expect(pushStateSpy).toHaveBeenCalled()
expect(replaceStateSpy).not.toHaveBeenCalled()
})
})

it('should navigate to path without params when none provided', async () => {
await usingAsync(new Injector(), async (injector) => {
const updateState = vi.fn()
injector.setExplicitInstance({ updateState } as unknown as LocationService, LocationService)

navigateToRoute(injector, '/chat')

expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/chat')
})
})
})
7 changes: 2 additions & 5 deletions frontend/src/pages/ai/ai-chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const AiChatInput = Shade<{ selectedChatId: string }>({
const aiChatMessageService = injector.getInstance(AiChatMessageService)
const aiChatService = injector.getInstance(AiChatService)
const sessionService = injector.getInstance(SessionService)
const formRef = useRef<HTMLElement>('form')
const formRef = useRef<HTMLFormElement>('form')

const [selectedChat] = useObservable(
'selectedChat',
Expand All @@ -41,10 +41,7 @@ export const AiChatInput = Shade<{ selectedChatId: string }>({
visibility: selectedChat?.value?.entries[0]?.visibility ?? 'private',
})
.then(() => {
const form = formRef.current?.querySelector('form') ?? formRef.current
if (form && 'reset' in form) {
;(form as HTMLFormElement).reset()
}
formRef.current?.reset()
})
}}
validate={(formData): formData is { message: string } => {
Expand Down
17 changes: 12 additions & 5 deletions frontend/src/pages/ai/ai-chat-message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@ export const AiChatMessageList = Shade<{
}>({
shadowDomName: 'pi-rat-ai-chat-message-list',
css: {
display: 'flex',
flexDirection: 'column',
display: 'block',
width: '100%',
height: 'calc(100% - 124px)',
overflowY: 'auto',
},
render: ({ useObservable, injector, props, useDisposable, useRef }) => {
const { selectedChatId } = props
Expand Down Expand Up @@ -77,7 +75,16 @@ export const AiChatMessageList = Shade<{
scrollToBottom()

return (
<>
<div
ref={containerRef}
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
overflowY: 'auto',
}}
>
{messages.value.result.entries.map((message) => {
try {
const fromJson = JSON.parse(message.content) as { content: string; thinking?: string }
Expand All @@ -103,7 +110,7 @@ export const AiChatMessageList = Shade<{
)
}
})}
</>
</div>
)
},
})
Loading
Loading