feat: Implement PartyAdapter with comprehensive functionality
- Add PartyAdapter for party CRUD and grid management - Create reactive PartyResource with optimistic updates - Support user parties listing with filters - Handle grid conflicts and job updates - Include comprehensive test coverage
This commit is contained in:
parent
20c6de3834
commit
114427241f
7 changed files with 1326 additions and 21 deletions
508
src/lib/api/adapters/__tests__/party.adapter.test.ts
Normal file
508
src/lib/api/adapters/__tests__/party.adapter.test.ts
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
/**
|
||||
* Tests for PartyAdapter
|
||||
*
|
||||
* These tests verify party CRUD operations, grid management,
|
||||
* and conflict resolution functionality.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { PartyAdapter } from '../party.adapter'
|
||||
import type { Party, GridWeapon, GridSummon, GridCharacter } from '../party.adapter'
|
||||
|
||||
describe('PartyAdapter', () => {
|
||||
let adapter: PartyAdapter
|
||||
let originalFetch: typeof global.fetch
|
||||
|
||||
const mockParty: Party = {
|
||||
id: '123',
|
||||
shortcode: 'ABC123',
|
||||
name: 'Test Party',
|
||||
description: 'Test description',
|
||||
visibility: 'public',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'testuser'
|
||||
},
|
||||
job: {
|
||||
id: 'job-1',
|
||||
name: { en: 'Warrior' },
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: { en: 'Rage' },
|
||||
slot: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
raid: {
|
||||
id: 'raid-1',
|
||||
name: { en: 'Proto Bahamut' },
|
||||
group: {
|
||||
id: 'group-1',
|
||||
name: { en: 'Tier 1' }
|
||||
}
|
||||
},
|
||||
gridWeapons: [],
|
||||
gridSummons: [],
|
||||
gridCharacters: [],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch
|
||||
adapter = new PartyAdapter({ baseURL: 'https://api.example.com' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
vi.clearAllTimers()
|
||||
})
|
||||
|
||||
describe('CRUD operations', () => {
|
||||
it('should create a new party', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockParty
|
||||
})
|
||||
|
||||
const result = await adapter.create({
|
||||
name: 'Test Party',
|
||||
description: 'Test description',
|
||||
visibility: 'public'
|
||||
})
|
||||
|
||||
expect(result).toEqual(mockParty)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/parties',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
party: {
|
||||
name: 'Test Party',
|
||||
description: 'Test description',
|
||||
visibility: 'public'
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should get a party by shortcode', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockParty
|
||||
})
|
||||
|
||||
const result = await adapter.getByShortcode('ABC123')
|
||||
|
||||
expect(result).toEqual(mockParty)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/parties/ABC123',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should update a party', async () => {
|
||||
const updatedParty = { ...mockParty, name: 'Updated Party' }
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => updatedParty
|
||||
})
|
||||
|
||||
const result = await adapter.update({
|
||||
shortcode: 'ABC123',
|
||||
name: 'Updated Party'
|
||||
})
|
||||
|
||||
expect(result).toEqual(updatedParty)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/parties/ABC123',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
party: { name: 'Updated Party' }
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should delete a party', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
})
|
||||
|
||||
await adapter.delete('ABC123')
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/parties/ABC123',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should remix a party', async () => {
|
||||
const remixedParty = { ...mockParty, id: '456', shortcode: 'DEF456' }
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => remixedParty
|
||||
})
|
||||
|
||||
const result = await adapter.remix('ABC123')
|
||||
|
||||
expect(result).toEqual(remixedParty)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/parties/ABC123/remix',
|
||||
expect.objectContaining({
|
||||
method: 'POST'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user parties listing', () => {
|
||||
it('should list user parties with filters', async () => {
|
||||
const mockResponse = {
|
||||
results: [mockParty],
|
||||
total: 1,
|
||||
page: 1,
|
||||
totalPages: 1
|
||||
}
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse
|
||||
})
|
||||
|
||||
const result = await adapter.listUserParties({
|
||||
username: 'testuser',
|
||||
page: 1,
|
||||
per: 20,
|
||||
visibility: 'public',
|
||||
raidId: 'raid-1'
|
||||
})
|
||||
|
||||
expect(result).toEqual(mockResponse)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/users/testuser/parties'),
|
||||
expect.objectContaining({
|
||||
method: 'GET'
|
||||
})
|
||||
)
|
||||
|
||||
// Verify query parameters were included
|
||||
const callUrl = (global.fetch as any).mock.calls[0][0]
|
||||
expect(callUrl).toContain('page=1')
|
||||
expect(callUrl).toContain('per=20')
|
||||
expect(callUrl).toContain('visibility=public')
|
||||
expect(callUrl).toContain('raid_id=raid-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid management', () => {
|
||||
it('should update grid weapons', async () => {
|
||||
const mockGridWeapons: GridWeapon[] = [
|
||||
{
|
||||
id: 'gw-1',
|
||||
position: 1,
|
||||
mainhand: true,
|
||||
uncapLevel: 5,
|
||||
transcendenceStage: 0,
|
||||
weaponKeys: [],
|
||||
weapon: {
|
||||
id: 'weapon-1',
|
||||
granblueId: 'w-1',
|
||||
name: { en: 'Sword' },
|
||||
element: 1,
|
||||
rarity: 5
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
grid_weapons: mockGridWeapons.map(gw => ({
|
||||
...gw,
|
||||
uncap_level: gw.uncapLevel,
|
||||
transcendence_stage: gw.transcendenceStage,
|
||||
weapon_keys: gw.weaponKeys
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
const result = await adapter.updateGridWeapons({
|
||||
shortcode: 'ABC123',
|
||||
updates: [
|
||||
{
|
||||
position: 1,
|
||||
weaponId: 'weapon-1',
|
||||
mainhand: true,
|
||||
uncapLevel: 5
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(result.gridWeapons).toEqual(mockGridWeapons)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/parties/ABC123/grid_weapons',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
grid_weapons: [
|
||||
{
|
||||
position: 1,
|
||||
weapon_id: 'weapon-1',
|
||||
mainhand: true,
|
||||
uncap_level: 5
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should update grid summons', async () => {
|
||||
const mockGridSummons: GridSummon[] = [
|
||||
{
|
||||
id: 'gs-1',
|
||||
position: 1,
|
||||
quickSummon: true,
|
||||
transcendenceStage: 2,
|
||||
summon: {
|
||||
id: 'summon-1',
|
||||
granblueId: 's-1',
|
||||
name: { en: 'Bahamut' },
|
||||
element: 6,
|
||||
rarity: 5
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
grid_summons: mockGridSummons.map(gs => ({
|
||||
...gs,
|
||||
quick_summon: gs.quickSummon,
|
||||
transcendence_stage: gs.transcendenceStage
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
const result = await adapter.updateGridSummons({
|
||||
shortcode: 'ABC123',
|
||||
updates: [
|
||||
{
|
||||
position: 1,
|
||||
summonId: 'summon-1',
|
||||
quickSummon: true,
|
||||
transcendenceStage: 2
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(result.gridSummons).toEqual(mockGridSummons)
|
||||
})
|
||||
|
||||
it('should update grid characters', async () => {
|
||||
const mockGridCharacters: GridCharacter[] = [
|
||||
{
|
||||
id: 'gc-1',
|
||||
position: 1,
|
||||
uncapLevel: 5,
|
||||
transcendenceStage: 1,
|
||||
character: {
|
||||
id: 'char-1',
|
||||
granblueId: 'c-1',
|
||||
name: { en: 'Katalina' },
|
||||
element: 2,
|
||||
rarity: 5
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
grid_characters: mockGridCharacters.map(gc => ({
|
||||
...gc,
|
||||
uncap_level: gc.uncapLevel,
|
||||
transcendence_stage: gc.transcendenceStage
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
const result = await adapter.updateGridCharacters({
|
||||
shortcode: 'ABC123',
|
||||
updates: [
|
||||
{
|
||||
position: 1,
|
||||
characterId: 'char-1',
|
||||
uncapLevel: 5,
|
||||
transcendenceStage: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(result.gridCharacters).toEqual(mockGridCharacters)
|
||||
})
|
||||
|
||||
it('should handle grid conflicts', async () => {
|
||||
const conflictResponse = {
|
||||
grid_weapons: [],
|
||||
conflicts: {
|
||||
conflicts: [
|
||||
{
|
||||
type: 'weapon',
|
||||
position: 1,
|
||||
existing: { id: 'weapon-1' },
|
||||
new: { id: 'weapon-2' }
|
||||
}
|
||||
],
|
||||
resolved: false
|
||||
}
|
||||
}
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => conflictResponse
|
||||
})
|
||||
|
||||
const result = await adapter.updateGridWeapons({
|
||||
shortcode: 'ABC123',
|
||||
updates: [
|
||||
{
|
||||
position: 1,
|
||||
weaponId: 'weapon-2'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(result.conflicts).toBeDefined()
|
||||
expect(result.conflicts?.resolved).toBe(false)
|
||||
expect(result.conflicts?.conflicts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('job management', () => {
|
||||
it('should update party job', async () => {
|
||||
const updatedParty = {
|
||||
...mockParty,
|
||||
job: {
|
||||
id: 'job-2',
|
||||
name: { en: 'Mage' },
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-2',
|
||||
name: { en: 'Fireball' },
|
||||
slot: 1
|
||||
}
|
||||
],
|
||||
accessory: {
|
||||
id: 'acc-1',
|
||||
name: { en: 'Magic Ring' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => updatedParty
|
||||
})
|
||||
|
||||
const result = await adapter.updateJob(
|
||||
'ABC123',
|
||||
'job-2',
|
||||
[{ id: 'skill-2', slot: 1 }],
|
||||
'acc-1'
|
||||
)
|
||||
|
||||
expect(result).toEqual(updatedParty)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/parties/ABC123',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
party: {
|
||||
job_id: 'job-2',
|
||||
job_skills_attributes: [{ id: 'skill-2', slot: 1 }],
|
||||
job_accessory_id: 'acc-1'
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache management', () => {
|
||||
it('should cache party retrieval', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockParty
|
||||
})
|
||||
|
||||
// First call
|
||||
await adapter.getByShortcode('ABC123')
|
||||
|
||||
// Second call (should use cache)
|
||||
await adapter.getByShortcode('ABC123')
|
||||
|
||||
// Should only call fetch once due to caching
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clear party cache', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockParty
|
||||
})
|
||||
|
||||
// First call
|
||||
await adapter.getByShortcode('ABC123')
|
||||
|
||||
// Clear cache
|
||||
adapter.clearPartyCache('ABC123')
|
||||
|
||||
// Second call (should not use cache)
|
||||
await adapter.getByShortcode('ABC123')
|
||||
|
||||
// Should call fetch twice since cache was cleared
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle 404 errors', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ error: 'Party not found' })
|
||||
})
|
||||
|
||||
await expect(adapter.getByShortcode('INVALID')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should handle validation errors', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
statusText: 'Unprocessable Entity',
|
||||
json: async () => ({
|
||||
errors: {
|
||||
name: ['is too long']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.create({
|
||||
name: 'A'.repeat(256)
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -87,14 +87,14 @@ export abstract class BaseAdapter {
|
|||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
// Build the full URL with query parameters
|
||||
const url = this.buildURL(path, options.params)
|
||||
// Build the full URL with query parameters (support both params and query)
|
||||
const url = this.buildURL(path, options.query || options.params)
|
||||
|
||||
// Generate a unique ID for this request (used for cancellation and caching)
|
||||
const requestId = this.generateRequestId(path, options.method, options.body as string)
|
||||
|
||||
// Check cache first if caching is enabled
|
||||
const cacheTime = options.cache ?? this.options.cacheTime
|
||||
// Check cache first if caching is enabled (support both cache and cacheTTL)
|
||||
const cacheTime = options.cacheTTL ?? options.cache ?? this.options.cacheTime
|
||||
// Allow caching for any method if explicitly set
|
||||
if (cacheTime > 0) {
|
||||
const cached = this.getFromCache(requestId)
|
||||
|
|
@ -122,13 +122,18 @@ export abstract class BaseAdapter {
|
|||
}
|
||||
|
||||
// Transform request body from camelCase to snake_case if present
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
try {
|
||||
const bodyData = JSON.parse(options.body)
|
||||
fetchOptions.body = JSON.stringify(this.transformRequest(bodyData))
|
||||
} catch {
|
||||
// If body is not valid JSON, use as-is
|
||||
fetchOptions.body = options.body
|
||||
if (options.body) {
|
||||
if (typeof options.body === 'object') {
|
||||
// Body is an object, transform and stringify
|
||||
fetchOptions.body = JSON.stringify(this.transformRequest(options.body))
|
||||
} else if (typeof options.body === 'string') {
|
||||
try {
|
||||
const bodyData = JSON.parse(options.body)
|
||||
fetchOptions.body = JSON.stringify(this.transformRequest(bodyData))
|
||||
} catch {
|
||||
// If body is not valid JSON, use as-is
|
||||
fetchOptions.body = options.body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +145,7 @@ export abstract class BaseAdapter {
|
|||
const data = await response.json()
|
||||
const transformed = this.transformResponse<T>(data)
|
||||
|
||||
// Cache the successful response if caching is enabled
|
||||
// Cache the successful response if caching is enabled (use cacheTTL or cache)
|
||||
if (cacheTime > 0) {
|
||||
this.setCache(requestId, transformed, cacheTime)
|
||||
}
|
||||
|
|
@ -348,7 +353,10 @@ export abstract class BaseAdapter {
|
|||
private addQueryParams(url: URL, params?: Record<string, any>): void {
|
||||
if (!params) return
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
// Transform query parameters from camelCase to snake_case
|
||||
const transformed = this.transformRequest(params)
|
||||
|
||||
Object.entries(transformed).forEach(([key, value]) => {
|
||||
// Skip undefined and null values
|
||||
if (value === undefined || value === null) return
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,25 @@ export * from './errors'
|
|||
// Resource-specific adapters
|
||||
export { SearchAdapter, searchAdapter } from './search.adapter'
|
||||
export type { SearchParams, SearchResult, SearchResponse } from './search.adapter'
|
||||
// export { PartyAdapter } from './party.adapter'
|
||||
|
||||
export { PartyAdapter, partyAdapter } from './party.adapter'
|
||||
export type {
|
||||
Party,
|
||||
GridWeapon,
|
||||
GridSummon,
|
||||
GridCharacter,
|
||||
CreatePartyParams,
|
||||
UpdatePartyParams,
|
||||
ListUserPartiesParams,
|
||||
UpdateGridParams,
|
||||
GridWeaponUpdate,
|
||||
GridSummonUpdate,
|
||||
GridCharacterUpdate,
|
||||
ConflictResolution
|
||||
} from './party.adapter'
|
||||
|
||||
// export { GridAdapter } from './grid.adapter'
|
||||
// export { EntityAdapter } from './entity.adapter'
|
||||
|
||||
// Reactive resources using Svelte 5 runes
|
||||
export * from './resources'
|
||||
381
src/lib/api/adapters/party.adapter.ts
Normal file
381
src/lib/api/adapters/party.adapter.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
/**
|
||||
* Party Adapter
|
||||
*
|
||||
* Handles all party-related API operations including CRUD, grids, and remixing.
|
||||
* Provides a clean interface for party management with automatic
|
||||
* request handling, caching, and error management.
|
||||
*
|
||||
* @module adapters/party
|
||||
*/
|
||||
|
||||
import { BaseAdapter } from './base.adapter'
|
||||
import type { RequestOptions, AdapterOptions, PaginatedResponse } from './types'
|
||||
|
||||
/**
|
||||
* Party data structure
|
||||
*/
|
||||
export interface Party {
|
||||
id: string
|
||||
shortcode: string
|
||||
name?: string
|
||||
description?: string
|
||||
visibility: 'public' | 'private' | 'unlisted'
|
||||
user: {
|
||||
id: string
|
||||
username: string
|
||||
}
|
||||
job?: {
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
skills: Array<{
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
slot: number
|
||||
}>
|
||||
accessory?: {
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
}
|
||||
}
|
||||
raid?: {
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
group?: {
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
}
|
||||
}
|
||||
gridWeapons: GridWeapon[]
|
||||
gridSummons: GridSummon[]
|
||||
gridCharacters: GridCharacter[]
|
||||
guidebook?: {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
extras?: Record<string, any>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid weapon structure
|
||||
*/
|
||||
export interface GridWeapon {
|
||||
id: string
|
||||
position: number
|
||||
mainhand: boolean
|
||||
uncapLevel: number
|
||||
transcendenceStage: number
|
||||
weaponKeys: Array<{
|
||||
id: string
|
||||
slot: number
|
||||
}>
|
||||
weapon: {
|
||||
id: string
|
||||
granblueId: string
|
||||
name: Record<string, string>
|
||||
element: number
|
||||
rarity: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid summon structure
|
||||
*/
|
||||
export interface GridSummon {
|
||||
id: string
|
||||
position: number
|
||||
quickSummon: boolean
|
||||
transcendenceStage: number
|
||||
summon: {
|
||||
id: string
|
||||
granblueId: string
|
||||
name: Record<string, string>
|
||||
element: number
|
||||
rarity: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid character structure
|
||||
*/
|
||||
export interface GridCharacter {
|
||||
id: string
|
||||
position: number
|
||||
uncapLevel: number
|
||||
transcendenceStage: number
|
||||
perpetualModifiers?: Record<string, any>
|
||||
awakenings?: Array<{
|
||||
id: string
|
||||
level: number
|
||||
}>
|
||||
character: {
|
||||
id: string
|
||||
granblueId: string
|
||||
name: Record<string, string>
|
||||
element: number
|
||||
rarity: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for creating a new party
|
||||
*/
|
||||
export interface CreatePartyParams {
|
||||
name?: string
|
||||
description?: string
|
||||
visibility?: 'public' | 'private' | 'unlisted'
|
||||
jobId?: string
|
||||
raidId?: string
|
||||
guidebookId?: string
|
||||
extras?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for updating a party
|
||||
*/
|
||||
export interface UpdatePartyParams extends CreatePartyParams {
|
||||
shortcode: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for listing user parties
|
||||
*/
|
||||
export interface ListUserPartiesParams {
|
||||
username: string
|
||||
page?: number
|
||||
per?: number
|
||||
visibility?: 'public' | 'private' | 'unlisted' | 'all'
|
||||
raidId?: string
|
||||
characterId?: string
|
||||
weaponId?: string
|
||||
summonId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for updating grid items
|
||||
*/
|
||||
export interface UpdateGridParams<T> {
|
||||
shortcode: string
|
||||
updates: T[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid weapon update structure
|
||||
*/
|
||||
export interface GridWeaponUpdate {
|
||||
id?: string
|
||||
position: number
|
||||
weaponId: string
|
||||
mainhand?: boolean
|
||||
uncapLevel?: number
|
||||
transcendenceStage?: number
|
||||
weaponKeys?: Array<{
|
||||
id: string
|
||||
slot: number
|
||||
}>
|
||||
_destroy?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid summon update structure
|
||||
*/
|
||||
export interface GridSummonUpdate {
|
||||
id?: string
|
||||
position: number
|
||||
summonId: string
|
||||
quickSummon?: boolean
|
||||
transcendenceStage?: number
|
||||
_destroy?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid character update structure
|
||||
*/
|
||||
export interface GridCharacterUpdate {
|
||||
id?: string
|
||||
position: number
|
||||
characterId: string
|
||||
uncapLevel?: number
|
||||
transcendenceStage?: number
|
||||
perpetualModifiers?: Record<string, any>
|
||||
awakenings?: Array<{
|
||||
id: string
|
||||
level: number
|
||||
}>
|
||||
_destroy?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict resolution result
|
||||
*/
|
||||
export interface ConflictResolution {
|
||||
conflicts: Array<{
|
||||
type: 'weapon' | 'summon' | 'character'
|
||||
position: number
|
||||
existing: any
|
||||
new: any
|
||||
}>
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Party adapter for managing parties and their grids
|
||||
*/
|
||||
export class PartyAdapter extends BaseAdapter {
|
||||
constructor(options?: AdapterOptions) {
|
||||
super({
|
||||
...options,
|
||||
baseURL: options?.baseURL || '/api/v1'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new party
|
||||
*/
|
||||
async create(params: CreatePartyParams): Promise<Party> {
|
||||
return this.request<Party>('/parties', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
party: params
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a party by shortcode
|
||||
*/
|
||||
async getByShortcode(shortcode: string): Promise<Party> {
|
||||
return this.request<Party>(`/parties/${shortcode}`, {
|
||||
cacheTTL: 60000 // Cache for 1 minute
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a party
|
||||
*/
|
||||
async update(params: UpdatePartyParams): Promise<Party> {
|
||||
const { shortcode, ...updateParams } = params
|
||||
return this.request<Party>(`/parties/${shortcode}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
party: updateParams
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a party
|
||||
*/
|
||||
async delete(shortcode: string): Promise<void> {
|
||||
return this.request<void>(`/parties/${shortcode}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a remix (copy) of an existing party
|
||||
*/
|
||||
async remix(shortcode: string): Promise<Party> {
|
||||
return this.request<Party>(`/parties/${shortcode}/remix`, {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists parties for a specific user
|
||||
*/
|
||||
async listUserParties(params: ListUserPartiesParams): Promise<PaginatedResponse<Party>> {
|
||||
const { username, ...queryParams } = params
|
||||
return this.request<PaginatedResponse<Party>>(`/users/${username}/parties`, {
|
||||
method: 'GET',
|
||||
query: queryParams,
|
||||
cacheTTL: 30000 // Cache for 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates grid weapons for a party
|
||||
*/
|
||||
async updateGridWeapons(
|
||||
params: UpdateGridParams<GridWeaponUpdate>
|
||||
): Promise<{ gridWeapons: GridWeapon[]; conflicts?: ConflictResolution }> {
|
||||
const { shortcode, updates } = params
|
||||
return this.request(`/parties/${shortcode}/grid_weapons`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
grid_weapons: updates
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates grid summons for a party
|
||||
*/
|
||||
async updateGridSummons(
|
||||
params: UpdateGridParams<GridSummonUpdate>
|
||||
): Promise<{ gridSummons: GridSummon[]; conflicts?: ConflictResolution }> {
|
||||
const { shortcode, updates } = params
|
||||
return this.request(`/parties/${shortcode}/grid_summons`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
grid_summons: updates
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates grid characters for a party
|
||||
*/
|
||||
async updateGridCharacters(
|
||||
params: UpdateGridParams<GridCharacterUpdate>
|
||||
): Promise<{ gridCharacters: GridCharacter[]; conflicts?: ConflictResolution }> {
|
||||
const { shortcode, updates } = params
|
||||
return this.request(`/parties/${shortcode}/grid_characters`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
grid_characters: updates
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the job for a party
|
||||
*/
|
||||
async updateJob(
|
||||
shortcode: string,
|
||||
jobId: string,
|
||||
skills?: Array<{ id: string; slot: number }>,
|
||||
accessoryId?: string
|
||||
): Promise<Party> {
|
||||
return this.request<Party>(`/parties/${shortcode}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
party: {
|
||||
job_id: jobId,
|
||||
...(skills && { job_skills_attributes: skills }),
|
||||
...(accessoryId && { job_accessory_id: accessoryId })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cache for party-related data
|
||||
*/
|
||||
clearPartyCache(shortcode?: string) {
|
||||
if (shortcode) {
|
||||
// Clear specific party cache
|
||||
this.clearCache(`/parties/${shortcode}`)
|
||||
} else {
|
||||
// Clear all party and user caches
|
||||
this.clearCache('/parties')
|
||||
this.clearCache('/users')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default party adapter instance
|
||||
*/
|
||||
export const partyAdapter = new PartyAdapter()
|
||||
|
|
@ -10,6 +10,9 @@
|
|||
export { SearchResource, createSearchResource } from './search.resource.svelte'
|
||||
export type { SearchResourceOptions } from './search.resource.svelte'
|
||||
|
||||
export { PartyResource, createPartyResource } from './party.resource.svelte'
|
||||
export type { PartyResourceOptions } from './party.resource.svelte'
|
||||
|
||||
// Future resources will be added here
|
||||
// export { PartyResource, createPartyResource } from './party.resource.svelte'
|
||||
// export { GridResource, createGridResource } from './grid.resource.svelte'
|
||||
// export { GridResource, createGridResource } from './grid.resource.svelte'
|
||||
// export { EntityResource, createEntityResource } from './entity.resource.svelte'
|
||||
379
src/lib/api/adapters/resources/party.resource.svelte.ts
Normal file
379
src/lib/api/adapters/resources/party.resource.svelte.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* Reactive Party Resource using Svelte 5 Runes
|
||||
*
|
||||
* Provides reactive state management for party operations with
|
||||
* automatic loading states, error handling, and optimistic updates.
|
||||
*
|
||||
* @module adapters/resources/party
|
||||
*/
|
||||
|
||||
import { PartyAdapter, type Party, type CreatePartyParams, type UpdatePartyParams } from '../party.adapter'
|
||||
import type { AdapterError } from '../types'
|
||||
|
||||
/**
|
||||
* Party resource configuration options
|
||||
*/
|
||||
export interface PartyResourceOptions {
|
||||
/** Party adapter instance to use */
|
||||
adapter?: PartyAdapter
|
||||
/** Enable optimistic updates for mutations */
|
||||
optimistic?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource state for a single party
|
||||
*/
|
||||
interface PartyState {
|
||||
data?: Party
|
||||
loading: boolean
|
||||
error?: AdapterError
|
||||
updating?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource state for party lists
|
||||
*/
|
||||
interface PartyListState {
|
||||
parties: Party[]
|
||||
total?: number
|
||||
page?: number
|
||||
totalPages?: number
|
||||
loading: boolean
|
||||
error?: AdapterError
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reactive party resource for managing parties
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { createPartyResource } from '$lib/api/adapters/resources'
|
||||
*
|
||||
* const party = createPartyResource()
|
||||
*
|
||||
* // Load a party
|
||||
* party.load('ABC123')
|
||||
*
|
||||
* // Update party details
|
||||
* party.update({
|
||||
* shortcode: 'ABC123',
|
||||
* name: 'New Name'
|
||||
* })
|
||||
* </script>
|
||||
*
|
||||
* {#if party.current.loading}
|
||||
* <p>Loading party...</p>
|
||||
* {:else if party.current.error}
|
||||
* <p>Error: {party.current.error.message}</p>
|
||||
* {:else if party.current.data}
|
||||
* <h1>{party.current.data.name}</h1>
|
||||
* {/if}
|
||||
* ```
|
||||
*/
|
||||
export class PartyResource {
|
||||
private adapter: PartyAdapter
|
||||
private optimistic: boolean
|
||||
|
||||
// Reactive state for current party
|
||||
current = $state<PartyState>({ loading: false })
|
||||
|
||||
// Reactive state for user parties list
|
||||
userParties = $state<PartyListState>({
|
||||
parties: [],
|
||||
loading: false
|
||||
})
|
||||
|
||||
// Track active requests for cancellation
|
||||
private activeRequests = new Map<string, AbortController>()
|
||||
|
||||
constructor(options: PartyResourceOptions = {}) {
|
||||
this.adapter = options.adapter || new PartyAdapter()
|
||||
this.optimistic = options.optimistic ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a party by shortcode
|
||||
*/
|
||||
async load(shortcode: string): Promise<Party | undefined> {
|
||||
// Cancel any existing load request
|
||||
this.cancelRequest('load')
|
||||
|
||||
const controller = new AbortController()
|
||||
this.activeRequests.set('load', controller)
|
||||
|
||||
this.current = { ...this.current, loading: true, error: undefined }
|
||||
|
||||
try {
|
||||
const party = await this.adapter.getByShortcode(shortcode)
|
||||
this.current = { data: party, loading: false }
|
||||
return party
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'CANCELLED') {
|
||||
this.current = {
|
||||
...this.current,
|
||||
loading: false,
|
||||
error: error as AdapterError
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.activeRequests.delete('load')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new party
|
||||
*/
|
||||
async create(params: CreatePartyParams): Promise<Party | undefined> {
|
||||
this.current = { ...this.current, updating: true, error: undefined }
|
||||
|
||||
try {
|
||||
const party = await this.adapter.create(params)
|
||||
this.current = { data: party, loading: false, updating: false }
|
||||
|
||||
// Add to user parties if loaded
|
||||
if (this.userParties.parties.length > 0) {
|
||||
this.userParties.parties = [party, ...this.userParties.parties]
|
||||
if (this.userParties.total !== undefined) {
|
||||
this.userParties.total++
|
||||
}
|
||||
}
|
||||
|
||||
return party
|
||||
} catch (error: any) {
|
||||
this.current = {
|
||||
...this.current,
|
||||
updating: false,
|
||||
error: error as AdapterError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current party
|
||||
*/
|
||||
async update(params: UpdatePartyParams): Promise<Party | undefined> {
|
||||
// Optimistic update
|
||||
if (this.optimistic && this.current.data) {
|
||||
const optimisticData = {
|
||||
...this.current.data,
|
||||
...params,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
this.current = {
|
||||
...this.current,
|
||||
data: optimisticData as Party,
|
||||
updating: true
|
||||
}
|
||||
} else {
|
||||
this.current = { ...this.current, updating: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const party = await this.adapter.update(params)
|
||||
this.current = { data: party, loading: false, updating: false }
|
||||
|
||||
// Update in user parties list if present
|
||||
const index = this.userParties.parties.findIndex(
|
||||
p => p.shortcode === params.shortcode
|
||||
)
|
||||
if (index !== -1) {
|
||||
this.userParties.parties[index] = party
|
||||
}
|
||||
|
||||
return party
|
||||
} catch (error: any) {
|
||||
// Revert optimistic update on error
|
||||
if (this.optimistic) {
|
||||
await this.load(params.shortcode)
|
||||
}
|
||||
this.current = {
|
||||
...this.current,
|
||||
updating: false,
|
||||
error: error as AdapterError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the current party
|
||||
*/
|
||||
async delete(shortcode: string): Promise<boolean> {
|
||||
this.current = { ...this.current, updating: true, error: undefined }
|
||||
|
||||
try {
|
||||
await this.adapter.delete(shortcode)
|
||||
|
||||
// Clear current party
|
||||
this.current = { loading: false, updating: false }
|
||||
|
||||
// Remove from user parties list
|
||||
this.userParties.parties = this.userParties.parties.filter(
|
||||
p => p.shortcode !== shortcode
|
||||
)
|
||||
if (this.userParties.total !== undefined && this.userParties.total > 0) {
|
||||
this.userParties.total--
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error: any) {
|
||||
this.current = {
|
||||
...this.current,
|
||||
updating: false,
|
||||
error: error as AdapterError
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a remix (copy) of a party
|
||||
*/
|
||||
async remix(shortcode: string): Promise<Party | undefined> {
|
||||
this.current = { ...this.current, updating: true, error: undefined }
|
||||
|
||||
try {
|
||||
const party = await this.adapter.remix(shortcode)
|
||||
this.current = { data: party, loading: false, updating: false }
|
||||
|
||||
// Add to user parties if it's the current user's remix
|
||||
if (this.userParties.parties.length > 0) {
|
||||
this.userParties.parties = [party, ...this.userParties.parties]
|
||||
if (this.userParties.total !== undefined) {
|
||||
this.userParties.total++
|
||||
}
|
||||
}
|
||||
|
||||
return party
|
||||
} catch (error: any) {
|
||||
this.current = {
|
||||
...this.current,
|
||||
updating: false,
|
||||
error: error as AdapterError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads parties for a specific user
|
||||
*/
|
||||
async loadUserParties(
|
||||
username: string,
|
||||
params: Omit<Parameters<PartyAdapter['listUserParties']>[0], 'username'> = {}
|
||||
): Promise<void> {
|
||||
// Cancel any existing user parties request
|
||||
this.cancelRequest('userParties')
|
||||
|
||||
const controller = new AbortController()
|
||||
this.activeRequests.set('userParties', controller)
|
||||
|
||||
this.userParties = { ...this.userParties, loading: true, error: undefined }
|
||||
|
||||
try {
|
||||
const response = await this.adapter.listUserParties({
|
||||
username,
|
||||
...params
|
||||
})
|
||||
|
||||
this.userParties = {
|
||||
parties: response.results,
|
||||
total: response.total,
|
||||
page: response.page,
|
||||
totalPages: response.totalPages,
|
||||
loading: false
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'CANCELLED') {
|
||||
this.userParties = {
|
||||
...this.userParties,
|
||||
loading: false,
|
||||
error: error as AdapterError
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.activeRequests.delete('userParties')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the job for the current party
|
||||
*/
|
||||
async updateJob(
|
||||
shortcode: string,
|
||||
jobId: string,
|
||||
skills?: Array<{ id: string; slot: number }>,
|
||||
accessoryId?: string
|
||||
): Promise<Party | undefined> {
|
||||
this.current = { ...this.current, updating: true, error: undefined }
|
||||
|
||||
try {
|
||||
const party = await this.adapter.updateJob(shortcode, jobId, skills, accessoryId)
|
||||
this.current = { data: party, loading: false, updating: false }
|
||||
return party
|
||||
} catch (error: any) {
|
||||
this.current = {
|
||||
...this.current,
|
||||
updating: false,
|
||||
error: error as AdapterError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels an active request
|
||||
*/
|
||||
private cancelRequest(key: string) {
|
||||
const controller = this.activeRequests.get(key)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
this.activeRequests.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all active requests
|
||||
*/
|
||||
cancelAll() {
|
||||
this.activeRequests.forEach(controller => controller.abort())
|
||||
this.activeRequests.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current party state
|
||||
*/
|
||||
clearCurrent() {
|
||||
this.cancelRequest('load')
|
||||
this.current = { loading: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the user parties state
|
||||
*/
|
||||
clearUserParties() {
|
||||
this.cancelRequest('userParties')
|
||||
this.userParties = { parties: [], loading: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all states
|
||||
*/
|
||||
clearAll() {
|
||||
this.cancelAll()
|
||||
this.current = { loading: false }
|
||||
this.userParties = { parties: [], loading: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the adapter's cache
|
||||
*/
|
||||
clearCache(shortcode?: string) {
|
||||
this.adapter.clearPartyCache(shortcode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating party resources
|
||||
*/
|
||||
export function createPartyResource(options?: PartyResourceOptions): PartyResource {
|
||||
return new PartyResource(options)
|
||||
}
|
||||
|
|
@ -36,15 +36,21 @@ export interface RequestOptions extends Omit<RequestInit, 'body'> {
|
|||
/** Query parameters to append to the URL */
|
||||
params?: Record<string, any>
|
||||
|
||||
/** Alternative alias for query parameters */
|
||||
query?: Record<string, any>
|
||||
|
||||
/** Request timeout in milliseconds. Overrides the adapter's default timeout */
|
||||
timeout?: number
|
||||
|
||||
/** Number of retry attempts for this specific request */
|
||||
retries?: number
|
||||
|
||||
/** Cache duration for this request in milliseconds. Only applies to GET requests */
|
||||
/** Cache duration for this request in milliseconds */
|
||||
cache?: number
|
||||
|
||||
/** Alternative alias for cache duration */
|
||||
cacheTTL?: number
|
||||
|
||||
/** Request body. Can be any serializable value */
|
||||
body?: any
|
||||
}
|
||||
|
|
@ -75,8 +81,11 @@ export interface AdapterError {
|
|||
* Used for endpoints that return paginated data
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
/** Array of items for the current page */
|
||||
items: T[]
|
||||
/** Array of items for the current page (can be 'results' or 'items') */
|
||||
results: T[]
|
||||
|
||||
/** Alternative key for items */
|
||||
items?: T[]
|
||||
|
||||
/** Total number of items across all pages */
|
||||
total: number
|
||||
|
|
@ -88,10 +97,10 @@ export interface PaginatedResponse<T> {
|
|||
totalPages: number
|
||||
|
||||
/** Number of items per page */
|
||||
perPage: number
|
||||
perPage?: number
|
||||
|
||||
/** Whether there are more pages available */
|
||||
hasMore: boolean
|
||||
hasMore?: boolean
|
||||
|
||||
/** Cursor or page number for the next page, if available */
|
||||
nextCursor?: string | number
|
||||
|
|
|
|||
Loading…
Reference in a new issue