Implement state management for Weapon grid

Summon and Character will be next. I didn't really pay attention to code cleanliness, so I'll try to do a pass before merging the PR
This commit is contained in:
Justin Edmund 2022-02-23 01:51:58 -08:00
parent 2e36a0455d
commit 9b505f5e20
8 changed files with 255 additions and 247 deletions

View file

@ -10,6 +10,7 @@ interface Props {
found?: boolean
offset: number
onClick: (position: number) => void
updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
@ -30,6 +31,7 @@ const ExtraWeapons = (props: Props) => {
unitType={1}
gridWeapon={props.grid[props.offset + i]}
onClick={() => { props.onClick(props.offset + i)}}
updateObject={props.updateObject}
updateUncap={props.updateUncap}
/>
</li>

View file

@ -22,9 +22,9 @@
overflow-y: auto;
padding: $unit * 3;
position: relative;
z-index: 10;
z-index: 21;
#ModalTop {
#ModalHeader {
display: flex;
flex-direction: row;
align-items: center;

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import { useCookies } from 'react-cookie'
import PartyContext from '~context/PartyContext'
import PartySegmentedControl from '~components/PartySegmentedControl'
import WeaponGrid from '~components/WeaponGrid'
@ -9,6 +9,7 @@ import SummonGrid from '~components/SummonGrid'
import CharacterGrid from '~components/CharacterGrid'
import api from '~utils/api'
import state from '~utils/state'
import { GridType, TeamElement } from '~utils/enums'
import './index.scss'
@ -29,12 +30,8 @@ const Party = (props: Props) => {
} : {}
// Set up states
const { party } = useSnapshot(state)
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
const [id, setId] = useState('')
const [slug, setSlug] = useState('')
const [element, setElement] = useState<TeamElement>(TeamElement.Any)
const [editable, setEditable] = useState(false)
const [hasExtra, setHasExtra] = useState(false)
// Methods: Creating a new party
async function createParty(extra: boolean = false) {
@ -50,10 +47,12 @@ const Party = (props: Props) => {
// Methods: Updating the party's extra flag
function checkboxChanged(event: React.ChangeEvent<HTMLInputElement>) {
setHasExtra(event.target.checked)
api.endpoints.parties.update(id, {
'party': { 'is_extra': event.target.checked }
}, headers)
if (party.id) {
state.party.extra = event.target.checked
api.endpoints.parties.update(party.id, {
'party': { 'is_extra': event.target.checked }
}, headers)
}
}
// Methods: Navigating with segmented control
@ -122,10 +121,8 @@ const Party = (props: Props) => {
return (
<div>
<PartyContext.Provider value={{ id, setId, slug, setSlug, element, setElement, editable, setEditable, hasExtra, setHasExtra }}>
{ navigation }
{ currentGrid() }
</PartyContext.Provider>
{ navigation }
{ currentGrid() }
</div>
)
}

View file

@ -1,13 +1,14 @@
import React, { useContext } from 'react'
import './index.scss'
import PartyContext from '~context/PartyContext'
import state from '~utils/state'
import SegmentedControl from '~components/SegmentedControl'
import Segment from '~components/Segment'
import ToggleSwitch from '~components/ToggleSwitch'
import { GridType } from '~utils/enums'
import { useSnapshot } from 'valtio'
interface Props {
selectedTab: GridType
@ -16,10 +17,10 @@ interface Props {
}
const PartySegmentedControl = (props: Props) => {
const { editable, element, hasExtra } = useContext(PartyContext)
const { party } = useSnapshot(state)
function getElement() {
switch(element) {
switch(party.element) {
case 1: return "wind"; break
case 2: return "fire"; break
case 3: return "water"; break
@ -34,8 +35,8 @@ const PartySegmentedControl = (props: Props) => {
Extra
<ToggleSwitch
name="ExtraSwitch"
editable={editable}
checked={hasExtra}
editable={party.editable}
checked={party.extra}
onChange={props.onCheckboxChange}
/>
</div>
@ -74,7 +75,7 @@ const PartySegmentedControl = (props: Props) => {
{
(() => {
if (editable && props.selectedTab == GridType.Weapon) {
if (party.editable && props.selectedTab == GridType.Weapon) {
return extraToggle
}
})()

View file

@ -1,11 +1,11 @@
.ModalContainer .Modal.SearchModal {
.Modal.Search {
display: flex;
flex-direction: column;
min-height: 420px;
min-width: 600px;
padding: 0;
#ModalTop {
#ModalHeader {
background: $grey-90;
gap: $unit;
margin: 0;
@ -13,12 +13,28 @@
position: sticky;
top: 0;
button {
background: transparent;
border: none;
height: 42px;
padding: 0;
svg {
height: 24px;
width: 24px;
vertical-align: middle;
}
}
label {
width: 100%;
.Input {
border: 1px solid $grey-70;
border-radius: $unit / 2;
box-sizing: border-box;
padding: 12px 8px;
font-size: $font-regular;
padding: $unit * 1.5;
text-align: left;
width: 100%;
}
@ -26,13 +42,13 @@
}
}
.SearchModal #results_container {
.Search.Modal #results_container {
margin: 0;
max-height: 330px;
padding: 0 12px 12px 12px;
}
.SearchModal #NoResults {
.Search.Modal #NoResults {
display: flex;
flex-direction: column;
align-items: center;
@ -40,7 +56,7 @@
flex-grow: 1;
}
.SearchModal #NoResults h2 {
.Search.Modal #NoResults h2 {
color: #ccc;
font-size: $font-large;
font-weight: 500;

View file

@ -1,10 +1,11 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import { createPortal } from 'react-dom'
import state from '~utils/state'
import api from '~utils/api'
import Modal from '~components/Modal'
import Overlay from '~components/Overlay'
import * as Dialog from '@radix-ui/react-dialog'
import CharacterResult from '~components/CharacterResult'
import WeaponResult from '~components/WeaponResult'
import SummonResult from '~components/SummonResult'
@ -13,138 +14,145 @@ import './index.scss'
import PlusIcon from '~public/icons/Add.svg'
interface Props {
close: () => void
send: (object: Character | Weapon | Summon, position: number) => any
grid: GridArray<Character|Weapon|Summon>
placeholderText: string
fromPosition: number
object: 'weapons' | 'characters' | 'summons'
object: 'weapons' | 'characters' | 'summons',
children: React.ReactNode
}
interface State {
query: string,
results: { [key: string]: any }
loading: boolean
message: string
totalResults: number
}
const SearchModal = (props: Props) => {
let { grid } = useSnapshot(state)
class SearchModal extends React.Component<Props, State> {
searchInput: React.RefObject<HTMLInputElement>
let searchInput = React.createRef<HTMLInputElement>()
constructor(props: Props) {
super(props)
this.state = {
query: '',
results: {},
loading: false,
message: '',
totalResults: 0
const [pool, setPool] = useState(Array<Character | Weapon | Summon>())
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState({})
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [totalResults, setTotalResults] = useState(0)
useEffect(() => {
if (props.object === 'characters') {
setPool(Object.values(grid.characters).map(o => o.character))
} else if (props.object === 'weapons') {
setPool(Object.values(grid.weapons.allWeapons).map(o => o.weapon))
} else if (props.object === 'summons') {
setPool(Object.values(grid.summons.allSummons).map(o => o.summon))
}
this.searchInput = React.createRef<HTMLInputElement>()
}, [grid, props.object])
useEffect(() => {
if (searchInput.current)
searchInput.current.focus()
}, [searchInput])
function filterExclusions(object: Character | Weapon | Summon) {
if (pool[props.fromPosition] &&
object.granblue_id === pool[props.fromPosition].granblue_id)
return null
else return object
}
componentDidMount() {
if (this.searchInput.current) {
this.searchInput.current.focus()
}
}
function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
const text = event.target.value
if (text.length) {
setQuery(text)
setLoading(true)
setMessage('')
filterExclusions = (o: Character | Weapon | Summon) => {
if (this.props.grid[this.props.fromPosition] &&
o.granblue_id == this.props.grid[this.props.fromPosition].granblue_id) {
return null
} else return o
}
fetchResults = (query: string) => {
const excludes = Object.values(this.props.grid).filter(this.filterExclusions).map((o) => { return o.name.en }).join(',')
api.search(this.props.object, query, excludes)
.then((response) => {
const data = response.data
const totalResults = data.length
this.setState({
results: data,
totalResults: totalResults,
loading: false
})
}, (error) => {
this.setState({
loading: false,
message: error
})
})
}
inputChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
const query = event.target.value
if (query.length) {
this.setState({ query, loading: true, message: '' }, () => {
this.fetchResults(query)
})
if (text.length > 2)
fetchResults()
} else {
this.setState({ query, results: {}, message: '' })
setQuery('')
setResults({})
setMessage('')
}
}
function fetchResults() {
const excludes = Object.values(pool)
.filter(filterExclusions)
.map((o) => { return o.name.en }).join(',')
sendData = (result: Character | Weapon | Summon) => {
this.props.send(result, this.props.fromPosition)
this.props.close()
api.search(props.object, query, excludes)
.then(response => {
setResults(response.data)
setTotalResults(response.data.length)
setLoading(false)
})
.catch(error => {
setMessage(error)
setLoading(false)
})
}
renderSearchResults = () => {
const { results } = this.state
switch(this.props.object) {
function sendData(result: Character | Weapon | Summon) {
props.send(result, props.fromPosition)
setOpen(false)
}
function renderResults() {
switch(props.object) {
case 'weapons':
return this.renderWeaponSearchResults(results)
return renderWeaponSearchResults(results)
break
case 'summons':
return this.renderSummonSearchResults(results)
return renderSummonSearchResults(results)
break
case 'characters':
return this.renderCharacterSearchResults(results)
return renderCharacterSearchResults(results)
break
}
}
renderWeaponSearchResults = (results: { [key: string]: any }) => {
return (
<ul id="results_container">
{ results.map( (result: Weapon) => {
return <WeaponResult key={result.id} data={result} onClick={() => { this.sendData(result) }} />
})}
</ul>
)
function renderWeaponSearchResults(results: { [key: string]: any }) {
const elements = results.map((result: Weapon) => {
return <WeaponResult
key={result.id}
data={result}
onClick={() => { sendData(result) }}
/>
})
return (<ul id="results_container">{elements}</ul>)
}
renderSummonSearchResults = (results: { [key: string]: any }) => {
return (
<ul id="results_container">
{ results.map( (result: Summon) => {
return <SummonResult key={result.id} data={result} onClick={() => { this.sendData(result) }} />
})}
</ul>
)
function renderSummonSearchResults(results: { [key: string]: any }) {
const elements = results.map((result: Summon) => {
return <SummonResult
key={result.id}
data={result}
onClick={() => { sendData(result) }}
/>
})
return (<ul id="results_container">{elements}</ul>)
}
renderCharacterSearchResults = (results: { [key: string]: any }) => {
return (
<ul id="results_container">
{ results.map( (result: Character) => {
return <CharacterResult key={result.id} data={result} onClick={() => { this.sendData(result) }} />
})}
</ul>
)
function renderCharacterSearchResults(results: { [key: string]: any }) {
const elements = results.map((result: Character) => {
return <CharacterResult
key={result.id}
data={result}
onClick={() => { sendData(result) }}
/>
})
return (<ul id="results_container">{elements}</ul>)
}
renderEmptyState = () => {
function renderEmptyState() {
let string = ''
if (this.state.query === '') {
string = `No ${this.props.object}`
if (query === '') {
string = `No ${props.object}`
} else if (query.length < 3) {
string = `Type at least 3 characters`
} else {
string = `No results found for '${this.state.query}'`
string = `No results found for '${query}'`
}
return (
@ -153,48 +161,46 @@ class SearchModal extends React.Component<Props, State> {
</div>
)
}
render() {
const { query, loading } = this.state
let content: JSX.Element
if (Object.entries(this.state.results).length == 0) {
content = this.renderEmptyState()
} else {
content = this.renderSearchResults()
}
return (
createPortal(
<div>
<div className="ModalContainer">
<div className="Modal SearchModal" key="search_modal">
<div id="ModalTop">
<label className="search_label" htmlFor="search_input">
<input
autoComplete="off"
type="text"
name="query"
className="Input"
id="search_input"
ref={this.searchInput}
value={query}
placeholder={this.props.placeholderText}
onChange={this.inputChanged}
/>
</label>
<PlusIcon onClick={this.props.close} />
</div>
{content}
</div>
</div>
<Overlay onClick={this.props.close} />
</div>,
document.body
)
)
function resetAndClose() {
setQuery('')
setResults({})
setOpen(true)
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
{props.children}
</Dialog.Trigger>
<Dialog.Portal>
<div className="ModalContainer">
<Dialog.Content className="Search Modal">
<div id="ModalHeader">
<label className="search_label" htmlFor="search_input">
<input
autoComplete="off"
type="text"
name="query"
className="Input"
id="search_input"
ref={searchInput}
value={query}
placeholder={props.placeholderText}
onChange={inputChanged}
/>
</label>
<Dialog.Close onClick={resetAndClose}>
<PlusIcon />
</Dialog.Close>
</div>
{ ((Object.entries(results).length == 0) ? renderEmptyState() : renderResults()) }
</Dialog.Content>
</div>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
}
export default SearchModal

View file

@ -1,15 +1,14 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useSnapshot } from 'valtio'
import { useModal as useModal } from '~utils/useModal'
import state from '~utils/state'
import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
import AppContext from '~context/AppContext'
import PartyContext from '~context/PartyContext'
import SearchModal from '~components/SearchModal'
import WeaponUnit from '~components/WeaponUnit'
import ExtraWeapons from '~components/ExtraWeapons'
@ -36,20 +35,11 @@ const WeaponGrid = (props: Props) => {
} : {}
// Set up state for view management
const { party, grid } = useSnapshot(state)
const [slug, setSlug] = useState()
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
// Set up the party context
const { setEditable: setAppEditable } = useContext(AppContext)
const { id, setId } = useContext(PartyContext)
const { slug, setSlug } = useContext(PartyContext)
const { editable, setEditable } = useContext(PartyContext)
const { hasExtra, setHasExtra } = useContext(PartyContext)
const { setElement } = useContext(PartyContext)
// Set up states for Grid data
const [weapons, setWeapons] = useState<GridArray<GridWeapon>>({})
const [mainWeapon, setMainWeapon] = useState<GridWeapon>()
// Set up states for Search
const { open, openModal, closeModal } = useModal()
@ -66,28 +56,27 @@ const WeaponGrid = (props: Props) => {
const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode)
else {
setEditable(true)
setAppEditable(true)
state.party.editable = true
}
}, [slug, props.slug])
// Initialize an array of current uncap values for each weapon
useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {}
if (mainWeapon) initialPreviousUncapValues[-1] = mainWeapon.uncap_level
Object.values(weapons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
if (state.grid.weapons.mainWeapon) initialPreviousUncapValues[-1] = state.grid.weapons.mainWeapon.uncap_level
Object.values(state.grid.weapons.allWeapons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues)
}, [mainWeapon, weapons])
}, [state.grid.weapons.mainWeapon, state.grid.weapons.allWeapons])
// Update search grid whenever weapons or the mainhand are updated
useEffect(() => {
let newSearchGrid = Object.values(weapons).map((o) => o.weapon)
let newSearchGrid = Object.values(grid.weapons.allWeapons).map((o) => o.weapon)
if (mainWeapon)
newSearchGrid.unshift(mainWeapon.weapon)
if (state.grid.weapons.mainWeapon)
newSearchGrid.unshift(state.grid.weapons.mainWeapon.weapon)
setSearchGrid(newSearchGrid)
}, [weapons, mainWeapon])
}, [state.grid.weapons.mainWeapon, state.grid.weapons.allWeapons])
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
@ -105,13 +94,13 @@ const WeaponGrid = (props: Props) => {
const loggedInUser = (cookies.user) ? cookies.user.user_id : ''
if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) {
setEditable(true)
setAppEditable(true)
state.party.editable = true
}
// Store the important party and state-keeping values
setId(party.id)
setHasExtra(party.is_extra)
state.party.id = party.id
state.party.extra = party.is_extra
setFound(true)
setLoading(false)
@ -135,14 +124,12 @@ const WeaponGrid = (props: Props) => {
list.forEach((object: GridWeapon) => {
if (object.mainhand) {
setMainWeapon(object)
setElement(object.weapon.element)
state.grid.weapons.mainWeapon = object
state.party.element = object.weapon.element
} else if (!object.mainhand && object.position != null) {
weapons[object.position] = object
state.grid.weapons.allWeapons[object.position] = object
}
})
setWeapons(weapons)
}
// Methods: Adding an object from search
@ -153,21 +140,23 @@ const WeaponGrid = (props: Props) => {
function receiveWeaponFromSearch(object: Character | Weapon | Summon, position: number) {
const weapon = object as Weapon
setElement(weapon.element)
if (position == 1)
state.party.element = weapon.element
if (!id) {
props.createParty(hasExtra)
if (!party.id) {
props.createParty(party.extra)
.then(response => {
const party = response.data.party
setId(party.id)
state.party.id = party.id
setSlug(party.shortcode)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveWeapon(party.id, weapon, position)
.then(response => storeGridWeapon(response.data.grid_weapon))
})
} else {
saveWeapon(id, weapon, position)
saveWeapon(party.id, weapon, position)
.then(response => storeGridWeapon(response.data.grid_weapon))
}
}
@ -190,12 +179,11 @@ const WeaponGrid = (props: Props) => {
function storeGridWeapon(gridWeapon: GridWeapon) {
if (gridWeapon.position == -1) {
setMainWeapon(gridWeapon)
state.grid.weapons.mainWeapon = gridWeapon
state.party.element = gridWeapon.weapon.element
} else {
// Store the grid unit at the correct position
let newWeapons = Object.assign({}, weapons)
newWeapons[gridWeapon.position] = gridWeapon
setWeapons(newWeapons)
state.grid.weapons.allWeapons[gridWeapon.position] = gridWeapon
}
}
@ -241,32 +229,30 @@ const WeaponGrid = (props: Props) => {
)
const updateUncapLevel = (position: number, uncapLevel: number) => {
if (mainWeapon && position == -1) {
mainWeapon.uncap_level = uncapLevel
setMainWeapon(mainWeapon)
} else {
let newWeapons = Object.assign({}, weapons)
newWeapons[position].uncap_level = uncapLevel
setWeapons(newWeapons)
}
if (state.grid.weapons.mainWeapon && position == -1)
state.grid.weapons.mainWeapon.uncap_level = uncapLevel
else
state.grid.weapons.allWeapons[position].uncap_level = uncapLevel
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues}
newPreviousValues[position] = (mainWeapon && position == -1) ? mainWeapon.uncap_level : weapons[position].uncap_level
newPreviousValues[position] = (state.grid.weapons.mainWeapon && position == -1) ?
state.grid.weapons.mainWeapon.uncap_level : state.grid.weapons.allWeapons[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
// Render: JSX components
const mainhandElement = (
<WeaponUnit
gridWeapon={mainWeapon}
editable={editable}
gridWeapon={grid.weapons.mainWeapon}
editable={party.editable}
key="grid_mainhand"
position={-1}
unitType={0}
onClick={() => { openSearchModal(-1) }}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
@ -276,11 +262,12 @@ const WeaponGrid = (props: Props) => {
return (
<li key={`grid_unit_${i}`} >
<WeaponUnit
gridWeapon={weapons[i]}
editable={editable}
gridWeapon={grid.weapons.allWeapons[i]}
editable={party.editable}
position={i}
unitType={1}
onClick={() => { openSearchModal(i) }}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
</li>
@ -290,10 +277,11 @@ const WeaponGrid = (props: Props) => {
const extraGridElement = (
<ExtraWeapons
grid={weapons}
editable={editable}
grid={state.grid.weapons.allWeapons}
editable={party.editable}
offset={numWeapons}
onClick={openSearchModal}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
@ -305,18 +293,7 @@ const WeaponGrid = (props: Props) => {
<ul className="grid_weapons">{ weaponGridElement }</ul>
</div>
{ (() => { return (hasExtra) ? extraGridElement : '' })() }
{open ? (
<SearchModal
grid={searchGrid}
close={closeModal}
send={receiveWeaponFromSearch}
fromPosition={itemPositionForSearch}
object="weapons"
placeholderText="Search for a weapon..."
/>
) : null}
{ (() => { return (party.extra) ? extraGridElement : '' })() }
</div>
)
}

View file

@ -5,6 +5,7 @@ import UncapIndicator from '~components/UncapIndicator'
import PlusIcon from '~public/icons/Add.svg'
import './index.scss'
import SearchModal from '~components/SearchModal'
interface Props {
gridWeapon: GridWeapon | undefined
@ -12,6 +13,7 @@ interface Props {
position: number
editable: boolean
onClick: () => void
updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
@ -55,10 +57,17 @@ const WeaponUnit = (props: Props) => {
return (
<div>
<div className={classes}>
<div className="WeaponImage" onClick={ (props.editable) ? props.onClick : () => {} }>
<img alt={weapon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
<SearchModal
placeholderText="Search for a weapon..."
fromPosition={props.position}
object="weapons"
send={props.updateObject}>
<div className="WeaponImage" onClick={ (props.editable) ? props.onClick : () => {} }>
<img alt={weapon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
</SearchModal>
{ (gridWeapon) ?
<UncapIndicator
type="weapon"