Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.
This commit is contained in:
Justin Edmund 2023-06-18 23:11:04 -07:00
parent a998413870
commit 35e3ce7b08
5 changed files with 274 additions and 163 deletions

View file

@ -1,13 +1,7 @@
// Core dependencies // Core dependencies
import React, { import React, { PropsWithChildren, useEffect, useState } from 'react'
PropsWithChildren,
useCallback,
useEffect,
useState,
} from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next' import { useTranslation } from 'next-i18next'
import { AxiosResponse } from 'axios'
import classNames from 'classnames' import classNames from 'classnames'
// UI dependencies // UI dependencies
@ -20,14 +14,10 @@ import {
import DialogContent from '~components/common/DialogContent' import DialogContent from '~components/common/DialogContent'
import Button from '~components/common/Button' import Button from '~components/common/Button'
import SelectWithInput from '~components/common/SelectWithInput' import SelectWithInput from '~components/common/SelectWithInput'
import AwakeningSelect from '~components/mastery/AwakeningSelect'
import RingSelect from '~components/mastery/RingSelect' import RingSelect from '~components/mastery/RingSelect'
import Switch from '~components/common/Switch' import Switch from '~components/common/Switch'
// Utilities // Utilities
import api from '~utils/api'
import { appState } from '~utils/appState'
import { retrieveCookies } from '~utils/retrieveCookies'
import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery' import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery'
// Data // Data
@ -36,6 +26,8 @@ const emptyExtendedMastery: ExtendedMastery = {
strength: 0, strength: 0,
} }
const MAX_AWAKENING_LEVEL = 9
// Styles and icons // Styles and icons
import CrossIcon from '~public/icons/Cross.svg' import CrossIcon from '~public/icons/Cross.svg'
import './index.scss' import './index.scss'
@ -46,6 +38,7 @@ import {
ExtendedMastery, ExtendedMastery,
GridCharacterObject, GridCharacterObject,
} from '~types' } from '~types'
import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput'
interface Props { interface Props {
gridCharacter: GridCharacter gridCharacter: GridCharacter
@ -66,9 +59,6 @@ const CharacterModal = ({
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const { t } = useTranslation('common') const { t } = useTranslation('common')
// Cookies
const cookies = retrieveCookies()
// UI state // UI state
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [formValid, setFormValid] = useState(false) const [formValid, setFormValid] = useState(false)
@ -103,8 +93,8 @@ const CharacterModal = ({
const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery) const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery)
// Character properties: Awakening // Character properties: Awakening
const [awakeningType, setAwakeningType] = useState(0) const [awakening, setAwakening] = useState<Awakening>()
const [awakeningLevel, setAwakeningLevel] = useState(0) const [awakeningLevel, setAwakeningLevel] = useState(1)
// Character properties: Transcendence // Character properties: Transcendence
const [transcendenceStep, setTranscendenceStep] = useState(0) const [transcendenceStep, setTranscendenceStep] = useState(0)
@ -118,7 +108,7 @@ const CharacterModal = ({
}) })
} }
setAwakeningType(gridCharacter.awakening.type) setAwakening(gridCharacter.awakening.type)
setAwakeningLevel(gridCharacter.awakening.level) setAwakeningLevel(gridCharacter.awakening.level)
setPerpetuity(gridCharacter.perpetuity) setPerpetuity(gridCharacter.perpetuity)
}, [gridCharacter]) }, [gridCharacter])
@ -147,15 +137,16 @@ const CharacterModal = ({
modifier: earring.modifier, modifier: earring.modifier,
strength: earring.strength, strength: earring.strength,
}, },
awakening: {
type: awakeningType,
level: awakeningLevel,
},
transcendence_step: transcendenceStep, transcendence_step: transcendenceStep,
perpetuity: perpetuity, perpetuity: perpetuity,
}, },
} }
if (awakening) {
object.character.awakening_id = awakening.id
object.character.awakening_level = awakeningLevel
}
return object return object
} }
@ -191,8 +182,8 @@ const CharacterModal = ({
if (onOpenChange) onOpenChange(false) if (onOpenChange) onOpenChange(false)
} }
function receiveAwakeningValues(type: number, level: number) { function receiveAwakeningValues(id: string, level: number) {
setAwakeningType(type) setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id))
setAwakeningLevel(level) setAwakeningLevel(level)
} }
@ -234,10 +225,16 @@ const CharacterModal = ({
return ( return (
<section> <section>
<h3>{t('modals.characters.subtitles.awakening')}</h3> <h3>{t('modals.characters.subtitles.awakening')}</h3>
<AwakeningSelect <AwakeningSelectWithInput
object="character" dataSet={gridCharacter.object.awakenings}
type={awakeningType} awakening={gridCharacter.awakening.type}
level={awakeningLevel} level={gridCharacter.awakening.level}
defaultAwakening={
gridCharacter.object.awakenings.find(
(a) => a.slug === 'character-balanced'
)!
}
maxLevel={MAX_AWAKENING_LEVEL}
sendValidity={receiveValidity} sendValidity={receiveValidity}
sendValues={receiveAwakeningValues} sendValues={receiveAwakeningValues}
/> />

View file

@ -1,97 +0,0 @@
import React, { useEffect, useState } from 'react'
import cloneDeep from 'lodash.clonedeep'
import SelectWithInput from '~components/common/SelectWithInput'
import { weaponAwakening, characterAwakening } from '~data/awakening'
import './index.scss'
interface Props {
object: 'character' | 'weapon'
type?: number
level?: number
onOpenChange?: (open: boolean) => void
sendValidity: (isValid: boolean) => void
sendValues: (type: number, level: number) => void
}
const AwakeningSelect = (props: Props) => {
// Data states
const [awakeningType, setAwakeningType] = useState(
props.object === 'weapon' ? 0 : 1
)
const [awakeningLevel, setAwakeningLevel] = useState(1)
// Data
const chooseDataset = () => {
let list: ItemSkill[] = []
switch (props.object) {
case 'character':
list = characterAwakening
break
case 'weapon':
// WARNING: Clonedeep is masking a deeper error
// which is running this method every time this component is rerendered
// causing multiple "No awakening" items to be added
const awakening = cloneDeep(weaponAwakening)
awakening.unshift({
id: 0,
name: {
en: 'No awakening',
ja: '覚醒なし',
},
granblue_id: '',
slug: 'no-awakening',
minValue: 0,
maxValue: 0,
fractional: false,
})
list = awakening
break
}
return list
}
// Set default awakening and level based on object type
useEffect(() => {
const defaultAwakening = props.object === 'weapon' ? 0 : 1
const type = props.type != undefined ? props.type : defaultAwakening
setAwakeningType(type)
setAwakeningLevel(props.level ? props.level : 1)
}, [props.object, props.type, props.level])
// Send validity of form when awakening level changes
useEffect(() => {
props.sendValidity(awakeningLevel > 0)
}, [props.sendValidity, awakeningLevel])
// Classes
function changeOpen(open: boolean) {
if (props.onOpenChange) props.onOpenChange(open)
}
function handleValueChange(type: number, level: number) {
setAwakeningType(type)
setAwakeningLevel(level)
props.sendValues(type, level)
}
return (
<div className="Awakening">
<SelectWithInput
object={`${props.object}_awakening`}
dataSet={chooseDataset()}
selectValue={awakeningType}
inputValue={awakeningLevel}
onOpenChange={changeOpen}
sendValidity={props.sendValidity}
sendValues={handleValueChange}
/>
</div>
)
}
export default AwakeningSelect

View file

@ -1,4 +1,22 @@
.AwakeningSelect .AwakeningSet { .SelectWithItem {
.InputSet {
display: flex;
flex-direction: row;
gap: $unit;
width: 100%;
.SelectTrigger {
flex-grow: 1;
width: 100%;
}
.Input {
flex-grow: 0;
text-align: right;
width: 13rem;
}
}
.errors { .errors {
color: $error; color: $error;
display: none; display: none;
@ -8,30 +26,4 @@
display: block; display: block;
} }
} }
.fields {
display: flex;
flex-direction: row;
gap: $unit;
width: 100%;
.SelectTrigger {
flex-grow: 1;
}
.Label {
display: none;
flex-grow: 0;
&.Visible {
display: block;
width: auto;
}
.Input {
min-width: $unit * 12;
width: inherit;
}
}
}
} }

View file

@ -0,0 +1,215 @@
// Core dependencies
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
// UI Dependencies
import Input from '~components/common/Input'
import Select from '~components/common/Select'
import SelectItem from '~components/common/SelectItem'
// Styles and icons
import './index.scss'
// Types
interface Props {
dataSet: Awakening[]
defaultAwakening: Awakening
awakening?: Awakening
level?: number
maxLevel: number
selectDisabled: boolean
onOpenChange?: (open: boolean) => void
sendValidity: (isValid: boolean) => void
sendValues: (type: string, level: number) => void
}
const defaultProps = {
selectDisabled: false,
}
const AwakeningSelectWithInput = ({
dataSet,
defaultAwakening,
awakening,
level,
maxLevel,
selectDisabled,
onOpenChange,
sendValidity,
sendValues,
}: Props) => {
// Set up translations
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const { t } = useTranslation('common')
// State: Component
const [open, setOpen] = useState(false)
const [error, setError] = useState('')
// State: Data
const [currentAwakening, setCurrentAwakening] = useState<Awakening>()
const [currentLevel, setCurrentLevel] = useState(1)
// Refs
const inputRef = React.createRef<HTMLInputElement>()
// Classes
const inputClasses = classNames({
Bound: true,
Hidden: currentAwakening === undefined || currentAwakening.id === '0',
})
const errorClasses = classNames({
errors: true,
visible: error !== '',
})
// Hooks
useEffect(() => {
setCurrentAwakening(awakening)
setCurrentLevel(level ? level : 1)
if (awakening) sendValidity(true)
}, [])
// Methods: UI state management
function changeOpen() {
if (!selectDisabled) {
setOpen(!open)
if (onOpenChange) onOpenChange(!open)
}
}
function onClose() {
if (onOpenChange) onOpenChange(false)
}
// Methods: Rendering
function generateOptions() {
const sortedDataSet = [...dataSet].sort((a, b) => {
return a.order - b.order
})
let options: React.ReactNode[] = sortedDataSet.map((awakening, i) => {
return generateItem(awakening)
})
if (!dataSet.includes(defaultAwakening))
options.unshift(generateItem(defaultAwakening))
return options
}
function generateItem(awakening: Awakening) {
return (
<SelectItem key={awakening.slug} value={awakening.id}>
{awakening.name[locale]}
</SelectItem>
)
}
// Methods: User input detection
function handleSelectChange(id: string) {
const input = inputRef.current
if (input && !handleInputError(parseFloat(input.value))) return
setCurrentAwakening(dataSet.find((awakening) => awakening.id === id))
sendValues(id, currentLevel)
}
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
const input = inputRef.current
if (input && !handleInputError(parseFloat(input.value))) return
setCurrentLevel(parseInt(event.target.value))
sendValues(
currentAwakening ? currentAwakening.id : '0',
parseInt(event.target.value)
)
}
// Methods: Handle error
function handleInputError(value: number) {
let error = ''
if (currentAwakening) {
if (value < 1) {
error = t(`awakening.errors.value_too_low`, {
minValue: 1,
})
} else if (value > maxLevel) {
error = t(`awakening.errors.value_too_high`, {
maxValue: maxLevel,
})
} else if (value % 1 != 0) {
error = t(`awakening.errors.value_not_whole`)
} else if (!value || value <= 0) {
error = t(`awakening.errors.value_empty`)
} else {
error = ''
}
}
setError(error)
if (error.length > 0) {
sendValidity(false)
return false
} else return true
}
const rangeString = () => {
let placeholder = ''
if (awakening) {
const minValue = 1
const maxValue = maxLevel
placeholder = `${minValue}~${maxValue}`
}
return placeholder
}
return (
<div className="SelectWithItem">
<div className="InputSet">
<Select
key="awakening-type"
value={`${awakening ? awakening.id : defaultAwakening.id}`}
open={open}
disabled={selectDisabled}
onValueChange={handleSelectChange}
onOpenChange={changeOpen}
onClose={onClose}
triggerClass="modal"
overlayVisible={false}
>
{generateOptions()}
</Select>
<Input
value={level ? level : 1}
className={inputClasses}
type="number"
placeholder={rangeString()}
min={1}
max={maxLevel}
step="1"
onChange={handleInputChange}
visible={awakening ? 'true' : 'false'}
ref={inputRef}
/>
</div>
<p className={errorClasses}>{error}</p>
</div>
)
}
AwakeningSelectWithInput.defaultProps = defaultProps
export default AwakeningSelectWithInput

View file

@ -11,14 +11,15 @@ import {
DialogTrigger, DialogTrigger,
} from '~components/common/Dialog' } from '~components/common/Dialog'
import DialogContent from '~components/common/DialogContent' import DialogContent from '~components/common/DialogContent'
import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput'
import AXSelect from '~components/mastery/AxSelect' import AXSelect from '~components/mastery/AxSelect'
import AwakeningSelect from '~components/mastery/AwakeningSelect'
import ElementToggle from '~components/ElementToggle' import ElementToggle from '~components/ElementToggle'
import WeaponKeySelect from '~components/weapon/WeaponKeySelect' import WeaponKeySelect from '~components/weapon/WeaponKeySelect'
import Button from '~components/common/Button' import Button from '~components/common/Button'
import api from '~utils/api' import api from '~utils/api'
import { appState } from '~utils/appState' import { appState } from '~utils/appState'
import { NO_AWAKENING } from '~data/awakening'
import CrossIcon from '~public/icons/Cross.svg' import CrossIcon from '~public/icons/Cross.svg'
import './index.scss' import './index.scss'
@ -33,7 +34,7 @@ interface GridWeaponObject {
ax_modifier2?: number ax_modifier2?: number
ax_strength1?: number ax_strength1?: number
ax_strength2?: number ax_strength2?: number
awakening_type?: number awakening_id?: string
awakening_level?: Number awakening_level?: Number
} }
} }
@ -70,7 +71,7 @@ const WeaponModal = ({
const [element, setElement] = useState(-1) const [element, setElement] = useState(-1)
const [awakeningType, setAwakeningType] = useState(0) const [awakening, setAwakening] = useState<Awakening>()
const [awakeningLevel, setAwakeningLevel] = useState(1) const [awakeningLevel, setAwakeningLevel] = useState(1)
const [primaryAxModifier, setPrimaryAxModifier] = useState(-1) const [primaryAxModifier, setPrimaryAxModifier] = useState(-1)
@ -136,9 +137,10 @@ const WeaponModal = ({
setFormValid(isValid) setFormValid(isValid)
} }
function receiveAwakeningValues(type: number, level: number) { function receiveAwakeningValues(id: string, level: number) {
setAwakeningType(type) setAwakening(gridWeapon.object.awakenings.find((a) => a.id === id))
setAwakeningLevel(level) setAwakeningLevel(level)
setFormValid(true)
} }
function receiveElementValue(element: string) { function receiveElementValue(element: string) {
@ -167,8 +169,8 @@ const WeaponModal = ({
object.weapon.ax_strength2 = secondaryAxValue object.weapon.ax_strength2 = secondaryAxValue
} }
if (gridWeapon.object.awakening) { if (gridWeapon.object.awakenings) {
object.weapon.awakening_type = awakeningType object.weapon.awakening_id = awakening?.id
object.weapon.awakening_level = awakeningLevel object.weapon.awakening_level = awakeningLevel
} }
@ -313,10 +315,12 @@ const WeaponModal = ({
return ( return (
<section> <section>
<h3>{t('modals.weapon.subtitles.awakening')}</h3> <h3>{t('modals.weapon.subtitles.awakening')}</h3>
<AwakeningSelect <AwakeningSelectWithInput
object="weapon" dataSet={gridWeapon.object.awakenings}
type={gridWeapon.awakening?.type} awakening={gridWeapon.awakening?.type}
level={gridWeapon.awakening?.level} level={gridWeapon.awakening?.level}
defaultAwakening={NO_AWAKENING}
maxLevel={gridWeapon.object.max_awakening_level}
onOpenChange={receiveAwakeningOpen} onOpenChange={receiveAwakeningOpen}
sendValidity={receiveValidity} sendValidity={receiveValidity}
sendValues={receiveAwakeningValues} sendValues={receiveAwakeningValues}
@ -326,7 +330,7 @@ const WeaponModal = ({
} }
function handleOpenChange(open: boolean) { function handleOpenChange(open: boolean) {
if (gridWeapon.object.ax || gridWeapon.object.awakening) { if (gridWeapon.object.ax || gridWeapon.object.awakenings) {
setFormValid(false) setFormValid(false)
} else { } else {
setFormValid(true) setFormValid(true)
@ -387,7 +391,7 @@ const WeaponModal = ({
{gridWeapon.object.element == 0 ? elementSelect() : ''} {gridWeapon.object.element == 0 ? elementSelect() : ''}
{[2, 3, 17, 24].includes(gridWeapon.object.series) ? keySelect() : ''} {[2, 3, 17, 24].includes(gridWeapon.object.series) ? keySelect() : ''}
{gridWeapon.object.ax ? axSelect() : ''} {gridWeapon.object.ax ? axSelect() : ''}
{gridWeapon.awakening ? awakeningSelect() : ''} {gridWeapon.object.awakenings ? awakeningSelect() : ''}
</div> </div>
<div className="DialogFooter" ref={footerRef}> <div className="DialogFooter" ref={footerRef}>
<Button <Button