hensei-web/components/character/CharacterGrid/index.tsx
Justin Edmund 2d368c32cc Implement removing job skills
We added a (...) button next to each editable job skill that opens a context menu that will allow the user to remove the job skill. An alert is presented to make sure the user is sure before proceeding.

As part of this change, some minor restyling of JobSkillItem was necessary
2023-06-19 02:35:26 -07:00

560 lines
16 KiB
TypeScript

/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { getCookie } from 'cookies-next'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import { AxiosError, AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
import Alert from '~components/common/Alert'
import JobSection from '~components/job/JobSection'
import CharacterUnit from '~components/character/CharacterUnit'
import CharacterConflictModal from '~components/character/CharacterConflictModal'
import type { DetailsObject, JobSkillObject, SearchableObject } from '~types'
import api from '~utils/api'
import { appState } from '~utils/appState'
import './index.scss'
// Props
interface Props {
new: boolean
editable: boolean
characters?: GridCharacter[]
createParty: (details?: DetailsObject) => Promise<Party>
pushHistory?: (path: string) => void
}
const CharacterGrid = (props: Props) => {
// Constants
const numCharacters: number = 5
// Localization
const { t } = useTranslation('common')
// Cookies
const cookie = getCookie('account')
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
// Set up state for error handling
const [axiosError, setAxiosError] = useState<AxiosResponse>()
const [errorAlertOpen, setErrorAlertOpen] = useState(false)
// Set up state for view management
const { party, grid } = useSnapshot(appState)
const [modalOpen, setModalOpen] = useState(false)
// Set up state for conflict management
const [incoming, setIncoming] = useState<Character>()
const [conflicts, setConflicts] = useState<GridCharacter[]>([])
const [position, setPosition] = useState(0)
// Set up state for data
const [job, setJob] = useState<Job | undefined>()
const [jobSkills, setJobSkills] = useState<JobSkillObject>({
0: undefined,
1: undefined,
2: undefined,
3: undefined,
})
const [jobAccessory, setJobAccessory] = useState<JobAccessory>()
const [errorMessage, setErrorMessage] = useState('')
// Create a temporary state to store previous weapon uncap values and transcendence stages
const [previousUncapValues, setPreviousUncapValues] = useState<{
[key: number]: number | undefined
}>({})
const [previousTranscendenceStages, setPreviousTranscendenceStages] =
useState<{
[key: number]: number | undefined
}>({})
useEffect(() => {
setJob(appState.party.job)
setJobSkills(appState.party.jobSkills)
setJobAccessory(appState.party.accessory)
}, [appState])
// Initialize an array of current uncap values for each characters
useEffect(() => {
let initialPreviousUncapValues: { [key: number]: number } = {}
Object.values(appState.grid.characters).map((o) => {
o ? (initialPreviousUncapValues[o.position] = o.uncap_level) : 0
})
setPreviousUncapValues(initialPreviousUncapValues)
}, [appState.grid.characters])
// Methods: Adding an object from search
function receiveCharacterFromSearch(
object: SearchableObject,
position: number
) {
const character = object as Character
if (!party.id) {
props.createParty().then((team) => {
saveCharacter(team.id, character, position)
.then((response) => storeGridCharacter(response.data))
.catch((error) => console.error(error))
})
} else {
if (props.editable)
saveCharacter(party.id, character, position)
.then((response) => handleCharacterResponse(response.data))
.catch((error) => {
const axiosError = error as AxiosError
const response = axiosError.response
if (response) {
setErrorAlertOpen(true)
setAxiosError(response)
}
})
}
}
async function handleCharacterResponse(data: any) {
if (data.hasOwnProperty('conflicts')) {
setIncoming(data.incoming)
setConflicts(data.conflicts)
setPosition(data.position)
setModalOpen(true)
} else {
storeGridCharacter(data)
}
}
async function saveCharacter(
partyId: string,
character: Character,
position: number
) {
return await api.endpoints.characters.create({
character: {
party_id: partyId,
character_id: character.id,
position: position,
uncap_level: characterUncapLevel(character),
},
})
}
function storeGridCharacter(gridCharacter: GridCharacter) {
appState.grid.characters[gridCharacter.position] = gridCharacter
}
async function resolveConflict() {
if (incoming && conflicts.length > 0) {
await api
.resolveConflict({
object: 'characters',
incoming: incoming.id,
conflicting: conflicts.map((c) => c.id),
position: position,
})
.then((response) => {
// Store new character in state
storeGridCharacter(response.data)
// Remove conflicting characters from state
conflicts.forEach(
(c) => (appState.grid.characters[c.position] = undefined)
)
// Reset conflict
resetConflict()
// Close modal
setModalOpen(false)
})
}
}
function resetConflict() {
setPosition(-1)
setConflicts([])
setIncoming(undefined)
}
async function removeCharacter(id: string) {
try {
const response = await api.endpoints.grid_characters.destroy({ id: id })
appState.grid.characters[response.data.position] = undefined
} catch (error) {
console.error(error)
}
}
// Methods: Saving job and job skills
async function saveJob(job?: Job) {
const payload = {
party: {
job_id: job ? job.id : -1,
},
}
if (!party.id) {
// If the party has no ID, create a new party
await props.createParty()
}
if (appState.party.id) {
const response = await api.updateJob({
partyId: appState.party.id,
params: payload,
})
const team = response.data
setJob(team.job)
appState.party.job = team.job
setJobSkills(team.job_skills)
appState.party.jobSkills = team.job_skills
}
}
function saveJobSkill(skill: JobSkill, position: number) {
if (party.id && props.editable) {
const positionedKey = `skill${position}_id`
let skillObject: {
[key: string]: string | undefined
skill0_id?: string
skill1_id?: string
skill2_id?: string
skill3_id?: string
} = {}
const payload = {
party: skillObject,
}
skillObject[positionedKey] = skill.id
api
.updateJobSkills({ partyId: party.id, params: payload })
.then((response) => {
// Update the current skills
const newSkills = response.data.job_skills
setJobSkills(newSkills)
appState.party.jobSkills = newSkills
})
.catch((error) => {
const data = error.response.data
if (data.code == 'too_many_skills_of_type') {
const message = `You can only add up to 2 ${
data.skill_type === 'emp'
? data.skill_type.toUpperCase()
: data.skill_type
} skills to your party at once.`
setErrorMessage(message)
}
})
}
}
function removeJobSkill(position: number) {
if (party.id && props.editable) {
api
.removeJobSkill({ partyId: party.id, position: position })
.then((response) => {
// Update the current skills
const newSkills = response.data.job_skills
setJobSkills(newSkills)
appState.party.jobSkills = newSkills
})
.catch((error) => {
const data = error.response.data
console.log(data)
})
}
}
async function saveAccessory(accessory: JobAccessory) {
const payload = {
party: {
accessory_id: accessory.id,
},
}
if (appState.party.id) {
const response = await api.endpoints.parties.update(
appState.party.id,
payload
)
const team = response.data.party
setJobAccessory(team.accessory)
appState.party.accessory = team.accessory
}
}
// Methods: Helpers
function characterUncapLevel(character: Character) {
let uncapLevel
if (character.special) {
uncapLevel = 3
if (character.uncap.ulb) uncapLevel = 5
else if (character.uncap.flb) uncapLevel = 4
} else {
uncapLevel = 4
if (character.uncap.ulb) uncapLevel = 6
else if (character.uncap.flb) uncapLevel = 5
}
return uncapLevel
}
// Methods: Updating uncap level
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
storePreviousTranscendenceStage(position)
try {
if (uncapLevel != previousUncapValues[position])
await api.updateUncap('character', id, uncapLevel).then((response) => {
storeGridCharacter(response.data)
})
} catch (error) {
console.error(error)
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
updateTranscendenceStage(position, previousTranscendenceStages[position])
// Remove optimistic key
let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
let newPreviousUncapValues = { ...previousUncapValues }
delete newPreviousTranscendenceStages[position]
delete newPreviousUncapValues[position]
setPreviousTranscendenceStages(newPreviousTranscendenceStages)
setPreviousUncapValues(newPreviousUncapValues)
}
}
function initiateUncapUpdate(
id: string,
position: number,
uncapLevel: number
) {
if (props.editable) {
memoizeUncapAction(id, position, uncapLevel)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
if (uncapLevel < 6) {
updateTranscendenceStage(position, 0)
}
}
}
const memoizeUncapAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedUncapAction(id, position, uncapLevel)
},
[props, previousUncapValues]
)
const debouncedUncapAction = useMemo(
() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
}, 500),
[props, saveUncap]
)
const updateUncapLevel = (
position: number,
uncapLevel: number | undefined
) => {
const character = appState.grid.characters[position]
if (character && uncapLevel) {
character.uncap_level = uncapLevel
appState.grid.characters[position] = character
}
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = { ...previousUncapValues }
if (grid.characters[position]) {
newPreviousValues[position] = grid.characters[position]?.uncap_level
setPreviousUncapValues(newPreviousValues)
}
}
// Methods: Updating transcendence stage
// Note: Saves, but debouncing is not working properly
async function saveTranscendence(
id: string,
position: number,
stage: number
) {
storePreviousUncapValue(position)
storePreviousTranscendenceStage(position)
const payload = {
character: {
uncap_level: stage > 0 ? 6 : 5,
transcendence_step: stage,
},
}
try {
if (stage != previousTranscendenceStages[position])
await api.endpoints.grid_characters
.update(id, payload)
.then((response) => {
storeGridCharacter(response.data)
})
} catch (error) {
console.error(error)
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
updateTranscendenceStage(position, previousTranscendenceStages[position])
// Remove optimistic key
let newPreviousTranscendenceStages = { ...previousTranscendenceStages }
let newPreviousUncapValues = { ...previousUncapValues }
delete newPreviousTranscendenceStages[position]
delete newPreviousUncapValues[position]
setPreviousTranscendenceStages(newPreviousTranscendenceStages)
setPreviousUncapValues(newPreviousUncapValues)
}
}
function initiateTranscendenceUpdate(
id: string,
position: number,
stage: number
) {
if (props.editable) {
memoizeTranscendenceAction(id, position, stage)
// Optimistically update UI
updateTranscendenceStage(position, stage)
if (stage > 0) {
updateUncapLevel(position, 6)
}
}
}
const memoizeTranscendenceAction = useCallback(
(id: string, position: number, stage: number) => {
debouncedTranscendenceAction(id, position, stage)
},
[props, previousTranscendenceStages]
)
const debouncedTranscendenceAction = useMemo(
() =>
debounce((id, position, number) => {
saveTranscendence(id, position, number)
}, 500),
[props, saveTranscendence]
)
const updateTranscendenceStage = (
position: number,
stage: number | undefined
) => {
const character = appState.grid.characters[position]
if (character && stage !== undefined) {
character.transcendence_step = stage
appState.grid.characters[position] = character
}
}
function storePreviousTranscendenceStage(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = { ...previousUncapValues }
if (grid.characters[position]) {
newPreviousValues[position] = grid.characters[position]?.uncap_level
setPreviousTranscendenceStages(newPreviousValues)
}
}
function cancelAlert() {
setErrorMessage('')
}
// Render: JSX components
const errorAlert = () => {
return (
<Alert
open={errorAlertOpen}
title={axiosError ? `${axiosError.status}` : 'Error'}
message={t(`errors.${axiosError?.statusText.toLowerCase()}`)}
cancelAction={() => setErrorAlertOpen(false)}
cancelActionText={t('buttons.confirm')}
/>
)
}
return (
<div>
<Alert
open={errorMessage.length > 0}
message={errorMessage}
cancelAction={cancelAlert}
cancelActionText={'Got it'}
/>
<div id="CharacterGrid">
<JobSection
job={job}
jobSkills={jobSkills}
jobAccessory={jobAccessory}
editable={props.editable}
saveJob={saveJob}
saveSkill={saveJobSkill}
removeSkill={removeJobSkill}
saveAccessory={saveAccessory}
/>
<CharacterConflictModal
open={modalOpen}
incomingCharacter={incoming}
conflictingCharacters={conflicts}
desiredPosition={position}
resolveConflict={resolveConflict}
resetConflict={resetConflict}
/>
<ul id="Characters">
{Array.from(Array(numCharacters)).map((x, i) => {
return (
<li key={`grid_unit_${i}`}>
<CharacterUnit
gridCharacter={grid.characters[i]}
editable={props.editable}
position={i}
updateObject={receiveCharacterFromSearch}
updateUncap={initiateUncapUpdate}
updateTranscendence={initiateTranscendenceUpdate}
removeCharacter={removeCharacter}
/>
</li>
)
})}
</ul>
</div>
{errorAlert()}
</div>
)
}
export default CharacterGrid