Make parties more DRY (#444)

We extracted utility functions from the Party.svelte component in order
to make things more DRY.
This commit is contained in:
Justin Edmund 2025-11-29 03:29:28 -08:00 committed by GitHub
parent f9bb43f214
commit 149f30c538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 649 additions and 333 deletions

View file

@ -23,6 +23,11 @@
import { Gender } from '$lib/utils/jobUtils'
import { openJobSelectionSidebar, openJobSkillSelectionSidebar } from '$lib/features/job/openJobSidebar.svelte'
import { partyAdapter } from '$lib/api/adapters/party.adapter'
import { extractErrorMessage } from '$lib/utils/errors'
import { transformSkillsToArray } from '$lib/utils/jobSkills'
import { findNextEmptySlot, SLOT_NOT_FOUND } from '$lib/utils/gridHelpers'
import { executeGridOperation, removeGridItem, updateGridItem } from '$lib/utils/gridOperations'
import { updateGridItemUncap } from '$lib/utils/gridStateUpdater'
interface Props {
party?: Party
@ -122,40 +127,14 @@
throw new Error('Cannot swap items in unsaved party')
}
// Both source and target should have items for swap
if (!source.itemId || !target.itemId) {
throw new Error('Invalid swap operation - missing items')
}
// Call appropriate grid service method based on type
if (source.type === 'weapon') {
await gridService.moveWeapon(party.id, source.itemId, target.position, editKey || undefined, {
shortcode: party.shortcode
})
} else if (source.type === 'character') {
await gridService.moveCharacter(
party.id,
source.itemId,
target.position,
editKey || undefined,
{
shortcode: party.shortcode
}
)
} else if (source.type === 'summon') {
await gridService.moveSummon(party.id, source.itemId, target.position, editKey || undefined, {
shortcode: party.shortcode
})
} else {
throw new Error(`Unknown item type: ${source.type}`)
}
// Clear cache and refresh party data
partyService.clearPartyCache(party.shortcode)
const updated = await partyService.getByShortcode(party.shortcode)
return updated
throw new Error(`Unknown item type: ${source.type}`)
return executeGridOperation(
'swap',
source,
target,
{ partyId: party.id, shortcode: party.shortcode, editKey },
gridService,
partyService
)
}
async function handleMove(source: any, target: any): Promise<Party> {
@ -163,36 +142,14 @@
throw new Error('Cannot move items in unsaved party')
}
// Source should have an item, target should be empty
if (!source.itemId || target.itemId) {
throw new Error('Invalid move operation')
}
// Call appropriate grid service method based on type
if (source.type === 'character') {
await gridService.moveCharacter(
party.id,
source.itemId,
target.position,
editKey || undefined,
{ shortcode: party.shortcode }
)
} else if (source.type === 'weapon') {
await gridService.moveWeapon(party.id, source.itemId, target.position, editKey || undefined, {
shortcode: party.shortcode
})
} else if (source.type === 'summon') {
await gridService.moveSummon(party.id, source.itemId, target.position, editKey || undefined, {
shortcode: party.shortcode
})
} else {
throw new Error(`Unknown item type: ${source.type}`)
}
// Clear cache and refresh party data
partyService.clearPartyCache(party.shortcode)
const updated = await partyService.getByShortcode(party.shortcode)
return updated
return executeGridOperation(
'move',
source,
target,
{ partyId: party.id, shortcode: party.shortcode, editKey },
gridService,
partyService
)
}
// Localized name helper: accepts either an object with { name: { en, ja } }
@ -405,12 +362,7 @@
console.log('[Party] New skill:', skill)
// Convert skills object to array format expected by API
const skillsArray = Object.entries(updatedSkills)
.filter(([_, skill]) => skill !== null && skill !== undefined)
.map(([slotKey, skill]) => ({
id: skill!.id,
slot: parseInt(slotKey)
}))
const skillsArray = transformSkillsToArray(updatedSkills)
console.log('[Party] Skills array to send:', skillsArray)
@ -420,33 +372,7 @@
)
party = updated
} catch (e: any) {
// Extract detailed error message from nested structure
let errorDetails = e?.details
// Navigate through nested details structure
while (errorDetails?.details) {
errorDetails = errorDetails.details
}
if (errorDetails?.errors) {
if (errorDetails.errors.message) {
// Simple message format
error = errorDetails.errors.message
} else {
// Field-based errors
const errorMessages = Object.entries(errorDetails.errors)
.map(([field, messages]) => {
if (Array.isArray(messages)) {
return messages.join(', ')
}
return String(messages)
})
.join('; ')
error = errorMessages || e.message || 'Failed to update skill'
}
} else {
error = e?.message || 'Failed to update skill'
}
error = extractErrorMessage(e, 'Failed to update skill')
console.error('Failed to update skill:', e)
} finally {
loading = false
@ -466,12 +392,7 @@
console.log('[Party] Updated jobSkills after removal:', updatedSkills)
// Convert skills object to array format expected by API
const skillsArray = Object.entries(updatedSkills)
.filter(([_, skill]) => skill !== null && skill !== undefined)
.map(([slotKey, skill]) => ({
id: skill!.id,
slot: parseInt(slotKey)
}))
const skillsArray = transformSkillsToArray(updatedSkills)
console.log('[Party] Skills array to send after removal:', skillsArray)
@ -481,33 +402,7 @@
)
party = updated
} catch (e: any) {
// Extract detailed error message from nested structure
let errorDetails = e?.details
// Navigate through nested details structure
while (errorDetails?.details) {
errorDetails = errorDetails.details
}
if (errorDetails?.errors) {
if (errorDetails.errors.message) {
// Simple message format
error = errorDetails.errors.message
} else {
// Field-based errors
const errorMessages = Object.entries(errorDetails.errors)
.map(([field, messages]) => {
if (Array.isArray(messages)) {
return messages.join(', ')
}
return String(messages)
})
.join('; ')
error = errorMessages || e.message || 'Failed to remove skill'
}
} else {
error = e?.message || 'Failed to remove skill'
}
error = extractErrorMessage(e, 'Failed to remove skill')
console.error('Failed to remove skill:', e)
} finally {
loading = false
@ -529,12 +424,7 @@
delete updatedSkills[String(slot) as keyof typeof updatedSkills]
// Convert skills object to array format expected by API
const skillsArray = Object.entries(updatedSkills)
.filter(([_, skill]) => skill !== null && skill !== undefined)
.map(([slotKey, skill]) => ({
id: skill!.id,
slot: parseInt(slotKey)
}))
const skillsArray = transformSkillsToArray(updatedSkills)
const updated = await partyAdapter.updateJobSkills(
party.shortcode,
@ -590,50 +480,8 @@
party = updated
// Find next empty slot for continuous adding
let nextEmptySlot = -999 // sentinel value meaning no empty slot found
if (activeTab === GridType.Weapon) {
// Check mainhand first (position -1)
if (!party.weapons.find((w) => w.position === -1 || w.mainhand)) {
nextEmptySlot = -1
} else {
// Check grid slots 0-8
for (let i = 0; i < 9; i++) {
if (!party.weapons.find((w) => w.position === i)) {
nextEmptySlot = i
break
}
}
}
} else if (activeTab === GridType.Summon) {
// Check main summon first (position -1)
if (!party.summons.find((s) => s.position === -1 || s.main)) {
nextEmptySlot = -1
} else {
// Check grid slots 0-5
for (let i = 0; i < 6; i++) {
if (!party.summons.find((s) => s.position === i)) {
nextEmptySlot = i
break
}
}
// Check friend summon (position 6)
if (nextEmptySlot === -999 && !party.summons.find((s) => s.position === 6 || s.friend)) {
nextEmptySlot = 6
}
}
} else if (activeTab === GridType.Character) {
// Check character slots 0-4
for (let i = 0; i < 5; i++) {
if (!party.characters.find((c) => c.position === i)) {
nextEmptySlot = i
break
}
}
}
// If there's another empty slot, update selectedSlot to it
if (nextEmptySlot !== -999) {
const nextEmptySlot = findNextEmptySlot(party, activeTab)
if (nextEmptySlot !== SLOT_NOT_FOUND) {
selectedSlot = nextEmptySlot
}
// Note: Sidebar stays open for continuous adding
@ -660,17 +508,15 @@
const clientGridService = {
async removeWeapon(partyId: string, gridWeaponId: string, _editKey?: string) {
try {
// Remove returns null, so we need to update local state
await gridService.removeWeapon(partyId, gridWeaponId, editKey || undefined, {
shortcode: party.shortcode
})
// Update local state by removing the weapon
const updatedParty = { ...party }
if (updatedParty.weapons) {
updatedParty.weapons = updatedParty.weapons.filter((w: any) => w.id !== gridWeaponId)
}
return updatedParty
return await removeGridItem(
'weapon',
partyId,
gridWeaponId,
party,
party.shortcode,
editKey,
gridService
)
} catch (err) {
console.error('Failed to remove weapon:', err)
throw err
@ -678,17 +524,15 @@
},
async removeSummon(partyId: string, gridSummonId: string, _editKey?: string) {
try {
// Remove returns null, so we need to update local state
await gridService.removeSummon(partyId, gridSummonId, editKey || undefined, {
shortcode: party.shortcode
})
// Update local state by removing the summon
const updatedParty = { ...party }
if (updatedParty.summons) {
updatedParty.summons = updatedParty.summons.filter((s: any) => s.id !== gridSummonId)
}
return updatedParty
return await removeGridItem(
'summon',
partyId,
gridSummonId,
party,
party.shortcode,
editKey,
gridService
)
} catch (err) {
console.error('Failed to remove summon:', err)
throw err
@ -696,19 +540,15 @@
},
async removeCharacter(partyId: string, gridCharacterId: string, _editKey?: string) {
try {
// Remove returns null, so we need to update local state
await gridService.removeCharacter(partyId, gridCharacterId, editKey || undefined, {
shortcode: party.shortcode
})
// Update local state by removing the character
const updatedParty = { ...party }
if (updatedParty.characters) {
updatedParty.characters = updatedParty.characters.filter(
(c: any) => c.id !== gridCharacterId
)
}
return updatedParty
return await removeGridItem(
'character',
partyId,
gridCharacterId,
party,
party.shortcode,
editKey,
gridService
)
} catch (err) {
console.error('Failed to remove character:', err)
throw err
@ -716,14 +556,7 @@
},
async updateWeapon(partyId: string, gridWeaponId: string, updates: any, _editKey?: string) {
try {
// Use the grid service to update weapon
const updated = await gridService.updateWeapon(
partyId,
gridWeaponId,
updates,
editKey || undefined
)
return updated
return await updateGridItem('weapon', partyId, gridWeaponId, updates, editKey, gridService)
} catch (err) {
console.error('Failed to update weapon:', err)
throw err
@ -731,14 +564,7 @@
},
async updateSummon(partyId: string, gridSummonId: string, updates: any, _editKey?: string) {
try {
// Use the grid service to update summon
const updated = await gridService.updateSummon(
partyId,
gridSummonId,
updates,
editKey || undefined
)
return updated
return await updateGridItem('summon', partyId, gridSummonId, updates, editKey, gridService)
} catch (err) {
console.error('Failed to update summon:', err)
throw err
@ -751,14 +577,7 @@
_editKey?: string
) {
try {
// Use the grid service to update character
const updated = await gridService.updateCharacter(
partyId,
gridCharacterId,
updates,
editKey || undefined
)
return updated
return await updateGridItem('character', partyId, gridCharacterId, updates, editKey, gridService)
} catch (err) {
console.error('Failed to update character:', err)
throw err
@ -771,40 +590,14 @@
_editKey?: string
) {
try {
const response = await gridService.updateCharacterUncap(
return await updateGridItemUncap(
'character',
{ gridItemId: gridCharacterId, uncapLevel, transcendenceStep },
party.id,
gridCharacterId,
uncapLevel,
transcendenceStep,
editKey || undefined
party,
editKey,
gridService
)
// The API returns {gridCharacter: {...}} with the updated item only (transformed to camelCase)
// We need to update just that character in the current party state
if (response.gridCharacter || response.grid_character) {
const updatedChar = response.gridCharacter || response.grid_character
const updatedParty = { ...party }
if (updatedParty.characters) {
const charIndex = updatedParty.characters.findIndex(
(c: any) => c.id === gridCharacterId
)
if (charIndex !== -1) {
// Preserve the character object reference but update uncap fields
const existingChar = updatedParty.characters[charIndex]
if (existingChar) {
updatedParty.characters[charIndex] = {
...existingChar,
id: existingChar.id,
position: existingChar.position,
character: existingChar.character,
uncapLevel: updatedChar.uncapLevel ?? updatedChar.uncap_level,
transcendenceStep: updatedChar.transcendenceStep ?? updatedChar.transcendence_step
}
}
return updatedParty
}
}
}
return party // Return unchanged party if update failed
} catch (err) {
console.error('Failed to update character uncap:', err)
throw err
@ -817,39 +610,14 @@
_editKey?: string
) {
try {
const response = await gridService.updateWeaponUncap(
return await updateGridItemUncap(
'weapon',
{ gridItemId: gridWeaponId, uncapLevel, transcendenceStep },
party.id,
gridWeaponId,
uncapLevel,
transcendenceStep,
editKey || undefined
party,
editKey,
gridService
)
// The API returns {gridWeapon: {...}} with the updated item only (transformed to camelCase)
// We need to update just that weapon in the current party state
if (response.gridWeapon || response.grid_weapon) {
const updatedWeapon = response.gridWeapon || response.grid_weapon
const updatedParty = { ...party }
if (updatedParty.weapons) {
const weaponIndex = updatedParty.weapons.findIndex((w: any) => w.id === gridWeaponId)
if (weaponIndex !== -1) {
// Preserve the weapon object reference but update uncap fields
const existingWeapon = updatedParty.weapons[weaponIndex]
if (existingWeapon) {
updatedParty.weapons[weaponIndex] = {
...existingWeapon,
id: existingWeapon.id,
position: existingWeapon.position,
weapon: existingWeapon.weapon,
uncapLevel: updatedWeapon.uncapLevel ?? updatedWeapon.uncap_level,
transcendenceStep:
updatedWeapon.transcendenceStep ?? updatedWeapon.transcendence_step
}
}
return updatedParty
}
}
}
return party // Return unchanged party if update failed
} catch (err) {
console.error('Failed to update weapon uncap:', err)
throw err
@ -862,39 +630,14 @@
_editKey?: string
) {
try {
const response = await gridService.updateSummonUncap(
return await updateGridItemUncap(
'summon',
{ gridItemId: gridSummonId, uncapLevel, transcendenceStep },
party.id,
gridSummonId,
uncapLevel,
transcendenceStep,
editKey || undefined
party,
editKey,
gridService
)
// The API returns {gridSummon: {...}} with the updated item only (transformed to camelCase)
// We need to update just that summon in the current party state
if (response.gridSummon || response.grid_summon) {
const updatedSummon = response.gridSummon || response.grid_summon
const updatedParty = { ...party }
if (updatedParty.summons) {
const summonIndex = updatedParty.summons.findIndex((s: any) => s.id === gridSummonId)
if (summonIndex !== -1) {
// Preserve the summon object reference but update uncap fields
const existingSummon = updatedParty.summons[summonIndex]
if (existingSummon) {
updatedParty.summons[summonIndex] = {
...existingSummon,
id: existingSummon.id,
position: existingSummon.position,
summon: existingSummon.summon,
uncapLevel: updatedSummon.uncapLevel ?? updatedSummon.uncap_level,
transcendenceStep:
updatedSummon.transcendenceStep ?? updatedSummon.transcendence_step
}
}
return updatedParty
}
}
}
return party // Return unchanged party if update failed
} catch (err) {
console.error('Failed to update summon uncap:', err)
throw err

64
src/lib/utils/errors.ts Normal file
View file

@ -0,0 +1,64 @@
/**
* Error message extraction utilities
* Handles complex nested error structures from API responses
*/
export interface NestedErrorDetails {
details?: NestedErrorDetails
errors?: { message?: string; [key: string]: any }
message?: string
}
/**
* Extracts user-friendly error message from nested API error structures
* Handles the pattern: error.details.details.errors.message
*
* @param error - The error object to extract from
* @param fallbackMessage - Message to return if extraction fails
* @returns Extracted error message or fallback
*
* @example
* ```typescript
* try {
* await api.updateParty(...)
* } catch (e) {
* error = extractErrorMessage(e, 'Failed to update party')
* }
* ```
*/
export function extractErrorMessage(
error: any,
fallbackMessage: string = 'An error occurred'
): string {
if (!error) return fallbackMessage
// Navigate through nested details structure
let errorDetails: NestedErrorDetails | undefined = error?.details
while (errorDetails?.details) {
errorDetails = errorDetails.details
}
// Try to extract message from various formats
if (errorDetails?.errors) {
// Simple message format
if (errorDetails.errors.message) {
return errorDetails.errors.message
}
// Field-based errors - combine all messages
const errorMessages = Object.entries(errorDetails.errors)
.map(([field, messages]) => {
if (Array.isArray(messages)) {
return messages.join(', ')
}
return String(messages)
})
.filter((msg) => msg && msg !== 'undefined')
.join('; ')
if (errorMessages) return errorMessages
}
// Fallback to error.message
return error?.message || fallbackMessage
}

View file

@ -0,0 +1,99 @@
/**
* Grid slot finding and helper utilities
* Handles finding available positions in weapon, summon, and character grids
*/
import type { Party } from '$lib/types/api/party'
import { GridType } from '$lib/types/enums'
/** Sentinel value indicating no empty slot was found */
export const SLOT_NOT_FOUND = -999
export interface SlotRange {
start: number
end: number
specialSlots?: number[] // e.g., mainhand (-1), friend summon (6)
}
/** Grid slot configuration for each grid type */
const GRID_CONFIGS: Record<GridType, SlotRange> = {
[GridType.Weapon]: { start: 0, end: 8, specialSlots: [-1] }, // mainhand + 9 grid slots
[GridType.Summon]: { start: 0, end: 5, specialSlots: [-1, 6] }, // main + 6 grid + friend
[GridType.Character]: { start: 0, end: 4, specialSlots: [] } // 5 slots (0-4)
}
/**
* Finds the next empty slot in a grid
*
* @param party - Current party state
* @param gridType - Type of grid to search (weapon, summon, or character)
* @returns Position number of next empty slot, or SLOT_NOT_FOUND if grid is full
*
* @example
* ```typescript
* const nextSlot = findNextEmptySlot(party, GridType.Weapon)
* if (nextSlot !== SLOT_NOT_FOUND) {
* selectedSlot = nextSlot
* }
* ```
*/
export function findNextEmptySlot(party: Party, gridType: GridType): number {
const config = GRID_CONFIGS[gridType]
const collection = getCollectionForType(party, gridType)
// Check special slots first (e.g., mainhand, main summon)
for (const specialSlot of config.specialSlots || []) {
if (!isSlotOccupied(collection, specialSlot, gridType)) {
return specialSlot
}
}
// Check regular grid slots
for (let i = config.start; i <= config.end; i++) {
if (!isSlotOccupied(collection, i, gridType)) {
return i
}
}
return SLOT_NOT_FOUND
}
/**
* Gets the appropriate collection array for a grid type
*/
function getCollectionForType(party: Party, gridType: GridType) {
switch (gridType) {
case GridType.Weapon:
return party.weapons
case GridType.Summon:
return party.summons
case GridType.Character:
return party.characters
}
}
/**
* Checks if a specific slot position is occupied
* Handles special cases for mainhand weapons, main/friend summons
*/
function isSlotOccupied(collection: any[], position: number, gridType: GridType): boolean {
// For weapons, check both position and mainhand flag
if (gridType === GridType.Weapon) {
return collection.some(
(item) => item.position === position || (position === -1 && item.mainhand)
)
}
// For summons, check position, main, and friend flags
if (gridType === GridType.Summon) {
return collection.some(
(item) =>
item.position === position ||
(position === -1 && item.main) ||
(position === 6 && item.friend)
)
}
// For characters, simple position check
return collection.some((item) => item.position === position)
}

View file

@ -0,0 +1,207 @@
/**
* Grid operation utilities
* Consolidates duplicated grid CRUD logic
*/
import type { Party } from '$lib/types/api/party'
import type { GridService } from '$lib/services/grid.service'
import type { PartyService } from '$lib/services/party.service'
export type GridItemType = 'character' | 'weapon' | 'summon'
export type GridCollection = 'characters' | 'weapons' | 'summons'
/**
* Maps grid item type to collection key in Party object
*
* @param type - Grid item type (character, weapon, or summon)
* @returns Collection key name
*
* @example
* ```typescript
* const key = getCollectionKey('weapon') // Returns: 'weapons'
* const items = party[key] // Access party.weapons
* ```
*/
export function getCollectionKey(type: GridItemType): GridCollection {
const map: Record<GridItemType, GridCollection> = {
character: 'characters',
weapon: 'weapons',
summon: 'summons'
}
return map[type]
}
/**
* Maps operation and grid type to service method name
*
* @param operation - CRUD operation type
* @param type - Grid item type
* @returns Method name on GridService
*
* @example
* ```typescript
* const methodName = getGridMethodName('add', 'weapon') // Returns: 'addWeapon'
* const methodName = getGridMethodName('remove', 'character') // Returns: 'removeCharacter'
* ```
*/
export function getGridMethodName(
operation: 'add' | 'move' | 'remove' | 'update',
type: GridItemType
): string {
const typeCapitalized = type.charAt(0).toUpperCase() + type.slice(1)
return `${operation}${typeCapitalized}`
}
/**
* Execute grid move/swap operation
* Consolidates handleSwap and handleMove logic
*
* @param operationType - Type of operation (move or swap)
* @param source - Source item information
* @param target - Target position information
* @param context - Party context (ID, shortcode, edit key)
* @param gridService - Grid service instance
* @param partyService - Party service instance
* @returns Updated party data
*
* @example
* ```typescript
* const updated = await executeGridOperation(
* 'swap',
* { type: 'weapon', itemId: 'abc123', position: 0 },
* { type: 'weapon', position: 1, itemId: 'def456' },
* { partyId: party.id, shortcode: party.shortcode, editKey },
* gridService,
* partyService
* )
* ```
*/
export async function executeGridOperation(
operationType: 'move' | 'swap',
source: { type: GridItemType; itemId: string; position: number },
target: { type: GridItemType; position: number; itemId?: string },
context: { partyId: string; shortcode: string; editKey?: string },
gridService: GridService,
partyService: PartyService
): Promise<Party> {
// Validation
if (operationType === 'swap' && !target.itemId) {
throw new Error('Swap operation requires target item')
}
if (operationType === 'move' && target.itemId) {
throw new Error('Move operation requires empty target')
}
// Call appropriate grid service method
const methodName = getGridMethodName('move', source.type)
const method = (gridService as any)[methodName]
if (!method) {
throw new Error(`Unknown grid method: ${methodName}`)
}
await method.call(
gridService,
context.partyId,
source.itemId,
target.position,
context.editKey,
{ shortcode: context.shortcode }
)
// Clear cache and refresh party
partyService.clearPartyCache(context.shortcode)
return await partyService.getByShortcode(context.shortcode)
}
/**
* Generic grid item remover
* Replaces three similar remove{Type} methods in clientGridService
*
* @param type - Grid item type to remove
* @param partyId - Party UUID
* @param gridItemId - Grid item UUID to remove
* @param party - Current party state
* @param shortcode - Party shortcode for cache clearing
* @param editKey - Optional edit key for authorization
* @param gridService - Grid service instance
* @returns Updated party with item removed
*
* @example
* ```typescript
* const updated = await removeGridItem(
* 'weapon',
* party.id,
* gridWeaponId,
* party,
* party.shortcode,
* editKey,
* gridService
* )
* ```
*/
export async function removeGridItem(
type: GridItemType,
partyId: string,
gridItemId: string,
party: Party,
shortcode: string,
editKey: string | undefined,
gridService: GridService
): Promise<Party> {
// Call appropriate remove method
const methodName = getGridMethodName('remove', type)
const method = (gridService as any)[methodName]
await method.call(gridService, partyId, gridItemId, editKey, { shortcode })
// Update local state by removing item
const collection = getCollectionKey(type)
const updatedParty = { ...party }
if (updatedParty[collection]) {
updatedParty[collection] = updatedParty[collection].filter(
(item: any) => item.id !== gridItemId
)
}
return updatedParty
}
/**
* Generic grid item updater
* Replaces three similar update{Type} methods
*
* @param type - Grid item type to update
* @param partyId - Party UUID
* @param gridItemId - Grid item UUID to update
* @param updates - Object containing fields to update
* @param editKey - Optional edit key for authorization
* @param gridService - Grid service instance
* @returns Updated grid item data
*
* @example
* ```typescript
* const updated = await updateGridItem(
* 'weapon',
* party.id,
* gridWeaponId,
* { ax1: 10, ax2: 5 },
* editKey,
* gridService
* )
* ```
*/
export async function updateGridItem(
type: GridItemType,
partyId: string,
gridItemId: string,
updates: any,
editKey: string | undefined,
gridService: GridService
): Promise<any> {
const methodName = getGridMethodName('update', type)
const method = (gridService as any)[methodName]
return await method.call(gridService, partyId, gridItemId, updates, editKey)
}

View file

@ -0,0 +1,137 @@
/**
* Grid state update utilities
* Handles optimistic updates for uncap levels and other grid item properties
*/
import type { Party } from '$lib/types/api/party'
import type { GridService } from '$lib/services/grid.service'
import type { GridItemType, GridCollection } from './gridOperations'
import { getCollectionKey } from './gridOperations'
export interface UncapUpdateParams {
gridItemId: string
uncapLevel?: number
transcendenceStep?: number
}
/**
* Generic function to update uncap levels for any grid item type
* Replaces updateCharacterUncap, updateWeaponUncap, updateSummonUncap
*
* @param itemType - Type of grid item (character, weapon, or summon)
* @param params - Uncap update parameters
* @param partyId - Party UUID
* @param currentParty - Current party state
* @param editKey - Optional edit key for authorization
* @param gridService - Grid service instance
* @returns Updated party with modified uncap levels
*
* @example
* ```typescript
* const updated = await updateGridItemUncap(
* 'weapon',
* { gridItemId: 'abc123', uncapLevel: 4, transcendenceStep: 1 },
* party.id,
* party,
* editKey,
* gridService
* )
* ```
*/
export async function updateGridItemUncap(
itemType: GridItemType,
params: UncapUpdateParams,
partyId: string,
currentParty: Party,
editKey: string | undefined,
gridService: GridService
): Promise<Party> {
// Get configuration for this item type
const config = getGridItemConfig(itemType)
// Call appropriate service method
const response = await config.updateMethod(
gridService,
partyId,
params.gridItemId,
params.uncapLevel,
params.transcendenceStep,
editKey
)
// Extract updated item from response (handle both camelCase and snake_case)
const updatedItem = response[config.responseKey] || response[config.snakeCaseKey]
if (!updatedItem) return currentParty
// Update party state optimistically
return mergeUpdatedGridItem(currentParty, config.collectionKey, params.gridItemId, {
uncapLevel: updatedItem.uncapLevel ?? updatedItem.uncap_level,
transcendenceStep: updatedItem.transcendenceStep ?? updatedItem.transcendence_step
})
}
/**
* Configuration map for grid item types
*/
function getGridItemConfig(itemType: GridItemType) {
const configs = {
character: {
updateMethod: (gs: GridService, ...args: any[]) => gs.updateCharacterUncap(...args),
responseKey: 'gridCharacter',
snakeCaseKey: 'grid_character',
collectionKey: 'characters' as GridCollection
},
weapon: {
updateMethod: (gs: GridService, ...args: any[]) => gs.updateWeaponUncap(...args),
responseKey: 'gridWeapon',
snakeCaseKey: 'grid_weapon',
collectionKey: 'weapons' as GridCollection
},
summon: {
updateMethod: (gs: GridService, ...args: any[]) => gs.updateSummonUncap(...args),
responseKey: 'gridSummon',
snakeCaseKey: 'grid_summon',
collectionKey: 'summons' as GridCollection
}
}
return configs[itemType]
}
/**
* Merges updates into a grid item within party state
* Preserves immutability by creating new objects
*
* @param party - Current party state
* @param collection - Collection key (characters, weapons, or summons)
* @param itemId - Grid item ID to update
* @param updates - Fields to update
* @returns New party object with updates applied
*/
function mergeUpdatedGridItem(
party: Party,
collection: GridCollection,
itemId: string,
updates: any
): Party {
const updatedParty = { ...party }
const items = updatedParty[collection]
if (!items) return party
const itemIndex = items.findIndex((item: any) => item.id === itemId)
if (itemIndex === -1) return party
const existingItem = items[itemIndex]
if (!existingItem) return party
// Merge updates while preserving essential properties
items[itemIndex] = {
...existingItem,
...updates,
id: existingItem.id,
position: existingItem.position
}
return updatedParty
}

View file

@ -0,0 +1,66 @@
/**
* Job skills transformation utilities
* Handles converting between object and array formats for job skills
*/
import type { JobSkill } from '$lib/types/api/entities'
export interface JobSkillsMap {
[slot: string]: JobSkill | null | undefined
}
export interface JobSkillPayload {
id: string
slot: number
}
/**
* Converts job skills object to API array format
* Filters out null/undefined values and adds slot numbers
*
* @param skillsMap - Object mapping slot numbers to job skills
* @returns Array of job skill payloads ready for API submission
*
* @example
* ```typescript
* const skillsMap = { '0': skill1, '1': skill2, '2': null }
* const payload = transformSkillsToArray(skillsMap)
* // Returns: [{ id: 'skill1-id', slot: 0 }, { id: 'skill2-id', slot: 1 }]
* ```
*/
export function transformSkillsToArray(skillsMap: JobSkillsMap): JobSkillPayload[] {
return Object.entries(skillsMap)
.filter(([_, skill]) => skill !== null && skill !== undefined)
.map(([slotKey, skill]) => ({
id: skill!.id,
slot: parseInt(slotKey)
}))
}
/**
* Updates a skill in a specific slot (returns new object, immutable)
*
* @param currentSkills - Current job skills map
* @param slot - Slot number to update
* @param skill - Job skill to set, or null to remove
* @returns New skills map with the update applied
*
* @example
* ```typescript
* const updated = updateSkillInSlot(currentSkills, 0, newSkill)
* const removed = updateSkillInSlot(currentSkills, 1, null)
* ```
*/
export function updateSkillInSlot(
currentSkills: JobSkillsMap,
slot: number,
skill: JobSkill | null
): JobSkillsMap {
const updated = { ...currentSkills }
if (skill === null) {
delete updated[String(slot)]
} else {
updated[String(slot)] = skill
}
return updated
}