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,78 +1,32 @@
.Character.DialogContent { .mods {
gap: $unit; display: flex;
min-width: 480px; flex-direction: column;
gap: $unit-4x;
padding: 0 $unit-4x $unit-2x;
@include breakpoint(phone) { section {
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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit-4x; gap: $unit-half;
padding: 0 $unit-4x $unit-2x;
section { &.inline {
display: flex; align-items: center;
flex-direction: column; flex-direction: row;
gap: $unit-half; justify-content: space-between;
&.inline {
align-items: center;
flex-direction: row;
justify-content: space-between;
h3 {
margin: 0;
}
}
h3 { h3 {
color: $grey-55; margin: 0;
font-size: $font-small;
margin-bottom: $unit;
}
select {
background-color: $grey-90;
} }
} }
.Button { h3 {
font-size: $font-regular; color: $grey-55;
padding: ($unit * 1.5) ($unit-2x); font-size: $font-small;
width: 100%; margin-bottom: $unit;
}
&.btn-disabled { select {
background: $grey-90; background-color: $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,10 +136,80 @@ 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) {
setOpen(open) if (hasBeenModified()) {
onOpenChange(open) setAlertOpen(!open)
} else {
setOpen(open)
onOpenChange(open)
}
} }
// Methods: Receive data from components // Methods: Receive data from components
@ -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,113 +236,145 @@ const CharacterModal = ({
setFormValid(isValid) setFormValid(isValid)
} }
const ringSelect = () => { // Methods: Event handlers
return ( function handleCheckedChange(checked: boolean) {
<section> setPerpetuity(checked)
<h3>{t('modals.characters.subtitles.ring')}</h3>
<RingSelect
gridCharacter={gridCharacter}
sendValues={receiveRingValues}
/>
</section>
)
} }
const earringSelect = () => { async function handleUpdateCharacter() {
const earringData = elementalizeAetherialMastery(gridCharacter) await updateCharacter(prepareObject())
return ( setOpen(false)
<section> if (onOpenChange) onOpenChange(false)
<h3>{t('modals.characters.subtitles.earring')}</h3>
<SelectWithInput
object="earring"
dataSet={earringData}
selectValue={earring.modifier ? earring.modifier : 0}
inputValue={earring.strength ? earring.strength : 0}
sendValidity={receiveValidity}
sendValues={receiveEarringValues}
/>
</section>
)
} }
const awakeningSelect = () => { function close() {
return ( setAlertOpen(false)
<section> setOpen(false)
<h3>{t('modals.characters.subtitles.awakening')}</h3> onOpenChange(false)
<AwakeningSelectWithInput
dataSet={gridCharacter.object.awakenings}
awakening={gridCharacter.awakening.type}
level={gridCharacter.awakening.level}
defaultAwakening={
gridCharacter.object.awakenings.find(
(a) => a.slug === 'character-balanced'
)!
}
maxLevel={MAX_AWAKENING_LEVEL}
sendValidity={receiveValidity}
sendValues={receiveAwakeningValues}
/>
</section>
)
} }
const perpetuitySwitch = () => { // Constants: Rendering
return ( const confirmationAlert = (
<section className="inline"> <Alert
<h3>{t('modals.characters.subtitles.permanent')}</h3> message={
<Switch onCheckedChange={handleCheckedChange} checked={perpetuity} /> <span>
</section> 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>
<h3>{t('modals.characters.subtitles.ring')}</h3>
<RingSelect
gridCharacter={gridCharacter}
sendValues={receiveRingValues}
/>
</section>
)
const earringSelect = (
<section>
<h3>{t('modals.characters.subtitles.earring')}</h3>
<SelectWithInput
object="earring"
dataSet={elementalizeAetherialMastery(gridCharacter)}
selectValue={
gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery.modifier
: 0
}
inputValue={
gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery.strength
: 0
}
sendValidity={receiveValidity}
sendValues={receiveEarringValues}
/>
</section>
)
const awakeningSelect = (
<section>
<h3>{t('modals.characters.subtitles.awakening')}</h3>
<AwakeningSelectWithInput
dataSet={gridCharacter.object.awakenings}
awakening={gridCharacter.awakening.type}
level={gridCharacter.awakening.level}
defaultAwakening={
gridCharacter.object.awakenings.find(
(a) => a.slug === 'character-balanced'
)!
}
maxLevel={MAX_AWAKENING_LEVEL}
sendValidity={receiveValidity}
sendValues={receiveAwakeningValues}
/>
</section>
)
const perpetuitySwitch = (
<section className={styles.inline}>
<h3>{t('modals.characters.subtitles.permanent')}</h3>
<Switch onCheckedChange={handleCheckedChange} checked={perpetuity} />
</section>
)
// Methods: Rendering
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <>
<DialogTrigger asChild>{children}</DialogTrigger> {confirmationAlert}
<DialogContent <Dialog open={open} onOpenChange={handleOpenChange}>
className="Character" <DialogTrigger asChild>{children}</DialogTrigger>
headerref={headerRef} <DialogContent
footerref={footerRef} className="character"
onOpenAutoFocus={(event) => event.preventDefault()} headerref={headerRef}
onEscapeKeyDown={() => {}} footerref={footerRef}
> onOpenAutoFocus={(event) => event.preventDefault()}
<div className={headerClasses} ref={headerRef}> onEscapeKeyDown={() => {}}
<img >
alt={gridCharacter.object.name[locale]} <DialogHeader
className="DialogImage" ref={headerRef}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${gridCharacter.object.granblue_id}_01.jpg`} title={gridCharacter.object.name[locale]}
subtitle={t('modals.characters.title')}
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> <Button
<CrossIcon /> bound={true}
</span> onClick={handleUpdateCharacter}
</DialogClose> key="confirm"
</div> disabled={!formValid}
text={t('modals.characters.buttons.confirm')}
<div className="mods"> />,
{perpetuitySwitch()} ]}
{ringSelect()}
{earringSelect()}
{awakeningSelect()}
</div>
<div className="DialogFooter" ref={footerRef}>
<Button
bound={true}
onClick={handleUpdateCharacter}
disabled={!formValid}
text={t('modals.characters.buttons.confirm')}
/> />
</div> </DialogContent>
</DialogContent> </Dialog>
</Dialog> </>
) )
} }