## Summary
This PR establishes the foundation for migrating from custom Svelte 5
resource classes to TanStack Query v6 for server state management. It
adds:
**Query Options Factories** (in `src/lib/api/queries/`):
- `party.queries.ts` - Party fetching with infinite scroll support
- `job.queries.ts` - Job and skill queries with pagination
- `user.queries.ts` - User profile, parties, and favorites queries
**Mutation Configurations** (in `src/lib/api/mutations/`):
- `party.mutations.ts` - Party CRUD with cache invalidation
- `grid.mutations.ts` - Weapon/character/summon mutations with
optimistic updates
- `job.mutations.ts` - Job and skill update mutations
**Deprecation Notices**:
- Added `@deprecated` JSDoc to `search.resource.svelte.ts` and
`party.resource.svelte.ts` with migration examples
**SSR Integration** (Phase 4):
- Created `+layout.ts` to initialize QueryClient for SSR support
- Updated `+layout.svelte` to receive QueryClient from load function
- Added SSR utilities in `src/lib/query/ssr.ts`:
- `withInitialData()` - for pages using +page.server.ts
- `prefetchQuery()` / `prefetchInfiniteQuery()` - for pages using
+page.ts
- `setQueryData()` - for direct cache population
- Added documentation in `src/lib/query/README.md`
**Component Wiring Examples** (Phase 5):
- `JobSelectionSidebar.svelte` - Migrated from `createJobResource()` to
`createQuery(() => jobQueries.list())`. Demonstrates client-side query
pattern with automatic loading/error states.
- `teams/[id]/+page.svelte` - Added `withInitialData()` pattern for SSR
integration. Server-fetched party data is used as initial cache value
with background refetching support.
**Migration Guide**:
- Added `src/lib/query/MIGRATION.md` with follow-up prompts for
remaining component migrations (JobSkillSelectionSidebar, search modal,
user profile, teams explore, Party mutations, resource class removal)
## Updates Since Last Revision
Fixed TypeScript type errors in the TanStack Query integration:
- `party.queries.ts`: Made `total` and `perPage` optional in
`PartyPageResult` interface to match adapter return type
- `ssr.ts`: Fixed `withInitialData` to properly handle null values using
`NonNullable<TData>` return type
- `job.mutations.ts`: Fixed slot indexing by casting through `unknown`
to `keyof typeof updatedSkills`
Type checks now pass for all files modified in this PR (16 remaining
errors are pre-existing project issues unrelated to this PR - paraglide
modules not generated, hooks.ts implicit anys).
## Review & Testing Checklist for Human
- [ ] **Verify app loads correctly**: The `+layout.ts` and
`+layout.svelte` changes are critical path - confirm the app still
renders
- [ ] **Test JobSelectionSidebar**: Open job selection sidebar and
verify jobs load correctly, search/filter works, and retry button works
on error
- [ ] **Test teams/[id] page**: Navigate to a party detail page and
verify it renders without loading flash (SSR data should be immediate)
- [ ] **Review type casts**: Check `job.mutations.ts:135` - the `as
unknown as keyof typeof` cast for slot indexing is a workaround for
jobSkills having string literal keys ('0', '1', '2', '3') while slot is
a number
- [ ] **Verify withInitialData behavior**: The `NonNullable<TData>`
return type change in `ssr.ts` should work correctly with `data.party`
which can be `Party | null`
**Recommended test plan**:
1. Run `pnpm install` to ensure dependencies are up to date
2. Start dev server and verify the app loads without errors
3. Navigate to a party detail page (`/teams/[shortcode]`) - should
render immediately without loading state
4. Open job selection sidebar (click job icon on a party you can edit) -
verify jobs load and filtering works
5. Test error handling by temporarily breaking network - verify retry
button appears
### Notes
- Pre-existing project issues remain (paraglide modules not generated,
hooks.ts implicit anys) - these are unrelated to this PR
- Local testing could not run due to missing node_modules (vite not
found) - project setup issue
- TanStack Query devtools installation was skipped due to Storybook
version conflicts
- The existing `search.queries.ts` file was used as the pattern
reference for new query factories
- SSR approach uses hybrid pattern: existing `+page.server.ts` files
work with `withInitialData()`, while new pages can use `prefetchQuery()`
in `+page.ts`
- Migration guide includes 6 follow-up prompts for completing the
remaining component migrations
**Link to Devin run**:
https://app.devin.ai/sessions/33e97a98ae3e415aa4dc35378cad3a2b
**Requested by**: Justin Edmund (justin@jedmund.com) / @jedmund
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Justin Edmund <justin@jedmund.com>
263 lines
6.4 KiB
Svelte
263 lines
6.4 KiB
Svelte
<script lang="ts">
|
|
import { createQuery } from '@tanstack/svelte-query'
|
|
import type { Job } from '$lib/types/api/entities'
|
|
import { jobQueries } from '$lib/api/queries/job.queries'
|
|
import { getJobTierName, getJobTierOrder } from '$lib/utils/jobUtils'
|
|
import JobItem from '../job/JobItem.svelte'
|
|
import JobTierSelector from '../job/JobTierSelector.svelte'
|
|
import Input from '../ui/Input.svelte'
|
|
import Button from '../ui/Button.svelte'
|
|
import Icon from '../Icon.svelte'
|
|
import * as m from '$lib/paraglide/messages'
|
|
|
|
interface Props {
|
|
currentJobId?: string
|
|
onSelectJob?: (job: Job) => void
|
|
}
|
|
|
|
let { currentJobId, onSelectJob }: Props = $props()
|
|
|
|
// TanStack Query v6: Use createQuery with thunk pattern for reactivity
|
|
// Jobs are cached for 30 minutes and shared across all components
|
|
const jobsQuery = createQuery(() => jobQueries.list())
|
|
|
|
// State for filtering (local UI state, not server state)
|
|
let searchQuery = $state('')
|
|
let selectedTiers = $state<Set<string>>(new Set(['4', '5', 'ex2', 'o1'])) // Default to IV, V, EXII, OI
|
|
|
|
// Available tiers with short labels for display
|
|
const tiers = [
|
|
{ value: '1', label: 'Class I', shortLabel: 'I' },
|
|
{ value: '2', label: 'Class II', shortLabel: 'II' },
|
|
{ value: '3', label: 'Class III', shortLabel: 'III' },
|
|
{ value: '4', label: 'Class IV', shortLabel: 'IV' },
|
|
{ value: '5', label: 'Class V', shortLabel: 'V' },
|
|
{ value: 'ex', label: 'Extra', shortLabel: 'EXI' },
|
|
{ value: 'ex2', label: 'Extra II', shortLabel: 'EXII' },
|
|
{ value: 'o1', label: 'Origin I', shortLabel: 'OI' }
|
|
]
|
|
|
|
function toggleTier(value: string) {
|
|
const newSet = new Set(selectedTiers)
|
|
if (newSet.has(value)) {
|
|
newSet.delete(value)
|
|
} else {
|
|
newSet.add(value)
|
|
}
|
|
selectedTiers = newSet
|
|
}
|
|
|
|
// Filter jobs based on search and filters
|
|
// TanStack Query handles loading/error states, we just filter the data
|
|
const filteredJobs = $derived(
|
|
(() => {
|
|
let jobs = jobsQuery.data || []
|
|
|
|
// Filter by search query
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase()
|
|
jobs = jobs.filter(
|
|
(job) =>
|
|
job.name.en.toLowerCase().includes(query) || job.name.ja?.toLowerCase().includes(query)
|
|
)
|
|
}
|
|
|
|
// Filter by selected tiers
|
|
if (selectedTiers.size > 0) {
|
|
jobs = jobs.filter((job) => {
|
|
const jobTier = job.row.toString().toLowerCase()
|
|
return selectedTiers.has(jobTier)
|
|
})
|
|
}
|
|
|
|
// Sort by tier and then by order field (create a copy to avoid mutating state)
|
|
jobs = [...jobs].sort((a, b) => {
|
|
const tierDiff = getJobTierOrder(a.row) - getJobTierOrder(b.row)
|
|
if (tierDiff !== 0) return tierDiff
|
|
// Use the order field for sorting within the same tier
|
|
return a.order - b.order
|
|
})
|
|
|
|
// Group by tier
|
|
const grouped: Record<string, Job[]> = {}
|
|
for (const job of jobs) {
|
|
const tierName = getJobTierName(job.row)
|
|
if (!grouped[tierName]) {
|
|
grouped[tierName] = []
|
|
}
|
|
grouped[tierName].push(job)
|
|
}
|
|
|
|
return grouped
|
|
})()
|
|
)
|
|
|
|
function handleSelectJob(job: Job) {
|
|
onSelectJob?.(job)
|
|
}
|
|
|
|
function isJobSelected(job: Job): boolean {
|
|
return job.id === currentJobId
|
|
}
|
|
</script>
|
|
|
|
<div class="job-selection-content">
|
|
<div class="search-section">
|
|
<Input
|
|
type="text"
|
|
placeholder={m.job_selection_search_placeholder()}
|
|
bind:value={searchQuery}
|
|
leftIcon="search"
|
|
fullWidth={true}
|
|
contained={true}
|
|
/>
|
|
|
|
<JobTierSelector {tiers} {selectedTiers} onToggleTier={toggleTier} />
|
|
</div>
|
|
|
|
<div class="jobs-container">
|
|
{#if jobsQuery.isLoading}
|
|
<div class="loading-state">
|
|
<Icon name="loader-2" size={32} />
|
|
<p>Loading jobs...</p>
|
|
</div>
|
|
{:else if jobsQuery.isError}
|
|
<div class="error-state">
|
|
<Icon name="alert-circle" size={32} />
|
|
<p>{jobsQuery.error?.message || 'Failed to load jobs'}</p>
|
|
<Button size="small" onclick={() => jobsQuery.refetch()}>Retry</Button>
|
|
</div>
|
|
{:else if Object.keys(filteredJobs).length === 0}
|
|
<div class="empty-state">
|
|
<Icon name="briefcase" size={32} />
|
|
<p>No jobs found</p>
|
|
{#if searchQuery || selectedTiers.size > 0}
|
|
<Button
|
|
size="small"
|
|
variant="ghost"
|
|
onclick={() => {
|
|
searchQuery = ''
|
|
selectedTiers = new Set(['4', '5', 'ex2', 'o1'])
|
|
}}
|
|
>
|
|
Clear filters
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="jobs-grid">
|
|
{#each Object.entries(filteredJobs) as [tierName, jobs]}
|
|
<div class="tier-group">
|
|
<div class="tier-header">
|
|
<h4>{tierName}</h4>
|
|
</div>
|
|
<div class="jobs-list">
|
|
{#each jobs as job (job.id)}
|
|
<JobItem {job} selected={isJobSelected(job)} onClick={() => handleSelectJob(job)} />
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
@use '$src/themes/layout' as layout;
|
|
@use '$src/themes/spacing' as spacing;
|
|
@use '$src/themes/typography' as typography;
|
|
|
|
.job-selection-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.search-section {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: spacing.$unit;
|
|
padding: 0 spacing.$unit-2x spacing.$unit-2x;
|
|
}
|
|
|
|
.jobs-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: spacing.$unit-2x 0;
|
|
}
|
|
|
|
.loading-state,
|
|
.error-state,
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: spacing.$unit;
|
|
padding: spacing.$unit-4x;
|
|
color: var(--text-secondary);
|
|
|
|
:global(svg) {
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
p {
|
|
margin: 0;
|
|
font-size: typography.$font-regular;
|
|
}
|
|
}
|
|
|
|
.loading-state :global(svg) {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.jobs-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: spacing.$unit-3x;
|
|
}
|
|
|
|
.tier-group {
|
|
.tier-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: spacing.$unit;
|
|
padding: 0 spacing.$unit-2x spacing.$unit-half;
|
|
|
|
h4 {
|
|
margin: 0;
|
|
font-size: typography.$font-small;
|
|
font-weight: typography.$medium;
|
|
color: var(--text-secondary);
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.job-count {
|
|
padding: spacing.$unit-half spacing.$unit-2x;
|
|
background: var(--badge-bg);
|
|
border-radius: 12px;
|
|
font-size: typography.$font-small;
|
|
color: var(--text-secondary);
|
|
}
|
|
}
|
|
|
|
.jobs-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
padding: 0 spacing.$unit;
|
|
}
|
|
}
|
|
</style>
|