Update CharacterModal

* Adapts styles for CSS modules
* Adds an alert if the user tries to close a dialog with changes without saving
* Uses constants instead of functions for rendering helpers
* Fixes validation
This commit is contained in:
Justin Edmund 2023-07-02 16:27:16 -07:00
parent 2cd6513aa4
commit 0b21bf768a
2 changed files with 233 additions and 202 deletions

View file

@ -1,36 +1,3 @@
.Character.DialogContent {
gap: $unit;
min-width: 480px;
@include breakpoint(phone) {
min-width: inherit;
}
.DialogHeader {
transition: 0.18s padding-top ease-in-out;
position: sticky;
top: 0;
&.Scrolled {
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.34);
padding-top: $unit-2x;
}
img {
transition: 0.2s width ease-in-out;
width: $unit-6x !important;
}
.DialogTitle {
font-size: $font-large;
}
.SubTitle {
display: none;
}
}
.mods { .mods {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -62,17 +29,4 @@
background-color: $grey-90; background-color: $grey-90;
} }
} }
.Button {
font-size: $font-regular;
padding: ($unit * 1.5) ($unit-2x);
width: 100%;
&.btn-disabled {
background: $grey-90;
color: $grey-70;
cursor: not-allowed;
}
}
}
} }

View file

@ -2,15 +2,11 @@
import React, { PropsWithChildren, useEffect, useState } from 'react' import React, { PropsWithChildren, 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 classNames from 'classnames' import isEqual from 'lodash/isEqual'
// UI dependencies // UI dependencies
import { import Alert from '~components/common/Alert'
Dialog, import { Dialog, DialogTrigger } from '~components/common/Dialog'
DialogClose,
DialogTitle,
DialogTrigger,
} from '~components/common/Dialog'
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'
@ -29,7 +25,6 @@ const emptyExtendedMastery: ExtendedMastery = {
const MAX_AWAKENING_LEVEL = 9 const MAX_AWAKENING_LEVEL = 9
// Styles and icons // Styles and icons
import CrossIcon from '~public/icons/Cross.svg'
import styles from './index.module.scss' import styles from './index.module.scss'
// Types // Types
@ -39,6 +34,8 @@ import {
GridCharacterObject, GridCharacterObject,
} from '~types' } from '~types'
import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput' import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput'
import DialogHeader from '~components/common/DialogHeader'
import DialogFooter from '~components/common/DialogFooter'
interface Props { interface Props {
gridCharacter: GridCharacter gridCharacter: GridCharacter
@ -54,6 +51,7 @@ const CharacterModal = ({
onOpenChange, onOpenChange,
updateCharacter, updateCharacter,
}: PropsWithChildren<Props>) => { }: PropsWithChildren<Props>) => {
// Router and localization
const router = useRouter() const router = useRouter()
const locale = const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
@ -67,39 +65,27 @@ const CharacterModal = ({
const headerRef = React.createRef<HTMLDivElement>() const headerRef = React.createRef<HTMLDivElement>()
const footerRef = React.createRef<HTMLDivElement>() const footerRef = React.createRef<HTMLDivElement>()
// Classes // State: Component
const headerClasses = classNames({ const [alertOpen, setAlertOpen] = useState(false)
DialogHeader: true,
Short: true,
})
// Callbacks and Hooks // State: Data
useEffect(() => {
setOpen(modalOpen)
}, [modalOpen])
// Character properties: Perpetuity
const [perpetuity, setPerpetuity] = useState(false) const [perpetuity, setPerpetuity] = useState(false)
// Character properties: Ring
const [rings, setRings] = useState<CharacterOverMastery>({ const [rings, setRings] = useState<CharacterOverMastery>({
1: { ...emptyExtendedMastery, modifier: 1 }, 1: { ...emptyExtendedMastery, modifier: 1 },
2: { ...emptyExtendedMastery, modifier: 2 }, 2: { ...emptyExtendedMastery, modifier: 2 },
3: emptyExtendedMastery, 3: emptyExtendedMastery,
4: emptyExtendedMastery, 4: emptyExtendedMastery,
}) })
// Character properties: Earrings
const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery) const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery)
// Character properties: Awakening
const [awakening, setAwakening] = useState<Awakening>() const [awakening, setAwakening] = useState<Awakening>()
const [awakeningLevel, setAwakeningLevel] = useState(1) const [awakeningLevel, setAwakeningLevel] = useState(1)
// Character properties: Transcendence
const [transcendenceStep, setTranscendenceStep] = useState(0) const [transcendenceStep, setTranscendenceStep] = useState(0)
// Hooks // Hooks
useEffect(() => {
setOpen(modalOpen)
}, [modalOpen])
useEffect(() => { useEffect(() => {
if (gridCharacter.aetherial_mastery) { if (gridCharacter.aetherial_mastery) {
setEarring({ setEarring({
@ -150,11 +136,81 @@ const CharacterModal = ({
return object return object
} }
// Methods: Convenience
function hasBeenModified() {
const rings = ringsChanged()
const aetherialMastery = aetherialMasteryChanged()
const awakening = awakeningChanged()
return (
rings ||
aetherialMastery ||
awakening ||
gridCharacter.perpetuity !== perpetuity
)
}
function ringsChanged() {
// Create an empty ExtendedMastery object
const emptyRingset: CharacterOverMastery = {
1: { ...emptyExtendedMastery, modifier: 1 },
2: { ...emptyExtendedMastery, modifier: 2 },
3: emptyExtendedMastery,
4: emptyExtendedMastery,
}
// Check if the current ringset is empty on the current GridCharacter and our local state
const isEmptyRingset =
gridCharacter.over_mastery === undefined && isEqual(emptyRingset, rings)
// Check if the ringset in local state is different from the one on the current GridCharacter
const ringsChanged = !isEqual(gridCharacter.over_mastery, rings)
// Return true if the ringset has been modified and is not empty
return ringsChanged && !isEmptyRingset
}
function aetherialMasteryChanged() {
// Create an empty ExtendedMastery object
const emptyAetherialMastery: ExtendedMastery = {
modifier: 0,
strength: 0,
}
// Check if the current earring is empty on the current GridCharacter and our local state
const isEmptyRingset =
gridCharacter.aetherial_mastery === undefined &&
isEqual(emptyAetherialMastery, earring)
// Check if the earring in local state is different from the one on the current GridCharacter
const aetherialMasteryChanged = !isEqual(
gridCharacter.aetherial_mastery,
earring
)
// Return true if the earring has been modified and is not empty
return aetherialMasteryChanged && !isEmptyRingset
}
function awakeningChanged() {
// Check if the awakening in local state is different from the one on the current GridCharacter
const awakeningChanged =
!isEqual(gridCharacter.awakening.type, awakening) ||
gridCharacter.awakening.level !== awakeningLevel
// Return true if the awakening has been modified and is not empty
return awakeningChanged
}
// Methods: UI state management // Methods: UI state management
function handleOpenChange(open: boolean) { function handleOpenChange(open: boolean) {
if (hasBeenModified()) {
setAlertOpen(!open)
} else {
setOpen(open) setOpen(open)
onOpenChange(open) onOpenChange(open)
} }
}
// Methods: Receive data from components // Methods: Receive data from components
function receiveRingValues(overMastery: CharacterOverMastery) { function receiveRingValues(overMastery: CharacterOverMastery) {
@ -167,21 +223,10 @@ const CharacterModal = ({
) { ) {
setEarring({ setEarring({
modifier: earringModifier, modifier: earringModifier,
strength: earringStrength, strength: earringModifier > 0 ? earringStrength : 0,
}) })
} }
function handleCheckedChange(checked: boolean) {
setPerpetuity(checked)
}
async function handleUpdateCharacter() {
await updateCharacter(prepareObject())
setOpen(false)
if (onOpenChange) onOpenChange(false)
}
function receiveAwakeningValues(id: string, level: number) { function receiveAwakeningValues(id: string, level: number) {
setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id)) setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id))
setAwakeningLevel(level) setAwakeningLevel(level)
@ -191,8 +236,45 @@ const CharacterModal = ({
setFormValid(isValid) setFormValid(isValid)
} }
const ringSelect = () => { // Methods: Event handlers
return ( function handleCheckedChange(checked: boolean) {
setPerpetuity(checked)
}
async function handleUpdateCharacter() {
await updateCharacter(prepareObject())
setOpen(false)
if (onOpenChange) onOpenChange(false)
}
function close() {
setAlertOpen(false)
setOpen(false)
onOpenChange(false)
}
// Constants: Rendering
const confirmationAlert = (
<Alert
message={
<span>
You will lose all changes to{' '}
<strong>{gridCharacter.object.name[locale]}</strong> if you continue.
<br />
<br />
Are you sure you want to continue without saving?
</span>
}
open={alertOpen}
primaryActionText="Close"
primaryAction={close}
cancelActionText="Nevermind"
cancelAction={() => setAlertOpen(false)}
/>
)
const ringSelect = (
<section> <section>
<h3>{t('modals.characters.subtitles.ring')}</h3> <h3>{t('modals.characters.subtitles.ring')}</h3>
<RingSelect <RingSelect
@ -201,28 +283,30 @@ const CharacterModal = ({
/> />
</section> </section>
) )
}
const earringSelect = () => { const earringSelect = (
const earringData = elementalizeAetherialMastery(gridCharacter)
return (
<section> <section>
<h3>{t('modals.characters.subtitles.earring')}</h3> <h3>{t('modals.characters.subtitles.earring')}</h3>
<SelectWithInput <SelectWithInput
object="earring" object="earring"
dataSet={earringData} dataSet={elementalizeAetherialMastery(gridCharacter)}
selectValue={earring.modifier ? earring.modifier : 0} selectValue={
inputValue={earring.strength ? earring.strength : 0} gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery.modifier
: 0
}
inputValue={
gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery.strength
: 0
}
sendValidity={receiveValidity} sendValidity={receiveValidity}
sendValues={receiveEarringValues} sendValues={receiveEarringValues}
/> />
</section> </section>
) )
}
const awakeningSelect = () => { const awakeningSelect = (
return (
<section> <section>
<h3>{t('modals.characters.subtitles.awakening')}</h3> <h3>{t('modals.characters.subtitles.awakening')}</h3>
<AwakeningSelectWithInput <AwakeningSelectWithInput
@ -240,64 +324,57 @@ const CharacterModal = ({
/> />
</section> </section>
) )
}
const perpetuitySwitch = () => { const perpetuitySwitch = (
return ( <section className={styles.inline}>
<section className="inline">
<h3>{t('modals.characters.subtitles.permanent')}</h3> <h3>{t('modals.characters.subtitles.permanent')}</h3>
<Switch onCheckedChange={handleCheckedChange} checked={perpetuity} /> <Switch onCheckedChange={handleCheckedChange} checked={perpetuity} />
</section> </section>
) )
}
// Methods: Rendering
return ( return (
<>
{confirmationAlert}
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent <DialogContent
className="Character" className="character"
headerref={headerRef} headerref={headerRef}
footerref={footerRef} footerref={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {}} onEscapeKeyDown={() => {}}
> >
<div className={headerClasses} ref={headerRef}> <DialogHeader
<img ref={headerRef}
alt={gridCharacter.object.name[locale]} title={gridCharacter.object.name[locale]}
className="DialogImage" subtitle={t('modals.characters.title')}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${gridCharacter.object.granblue_id}_01.jpg`} image={{
src: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${gridCharacter.object.granblue_id}_01.jpg`,
alt: gridCharacter.object.name[locale],
}}
/> />
<div className="DialogTop"> <section className={styles.mods}>
<DialogTitle className="SubTitle"> {perpetuitySwitch}
{t('modals.characters.title')} {ringSelect}
</DialogTitle> {earringSelect}
<DialogTitle className="DialogTitle"> {awakeningSelect}
{gridCharacter.object.name[locale]} </section>
</DialogTitle> <DialogFooter
</div> ref={footerRef}
<DialogClose className="DialogClose" asChild> rightElements={[
<span>
<CrossIcon />
</span>
</DialogClose>
</div>
<div className="mods">
{perpetuitySwitch()}
{ringSelect()}
{earringSelect()}
{awakeningSelect()}
</div>
<div className="DialogFooter" ref={footerRef}>
<Button <Button
bound={true} bound={true}
onClick={handleUpdateCharacter} onClick={handleUpdateCharacter}
key="confirm"
disabled={!formValid} disabled={!formValid}
text={t('modals.characters.buttons.confirm')} text={t('modals.characters.buttons.confirm')}
/>,
]}
/> />
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
) )
} }