Skip to content

Commit

Permalink
Fix resetting of focus on field error
Browse files Browse the repository at this point in the history
  • Loading branch information
rassvet2 committed Apr 30, 2024
1 parent dc234e5 commit dd58f60
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 83 deletions.
17 changes: 11 additions & 6 deletions components/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {Equipment, EquipmentCompositionType} from 'model/Equipment';
import {Campaign} from 'model/Campaign';
import RecommendedCampaigns from 'components/calculationResult/RecommendedCampaigns';
import CalculationInputCard from 'components/calculationInput/CalculationInputCard';
import {CampaignsById, EquipmentsById} from 'components/calculationInput/PiecesCalculationCommonTypes';
import {
CampaignsById, EquipmentsById,
} from 'components/calculationInput/PiecesCalculationCommonTypes';
import useSWR from 'swr';
import RecommendationsSummary from 'components/calculationResult/RecommendationsSummary';
import IgnoredCampaigns from 'components/calculationResult/IgnoredCampaigns';
Expand All @@ -23,7 +25,10 @@ import {
hashTierAndCategoryKey,
} from 'components/calculationInput/equipments/EquipmentsInput';
import {PieceState} from 'components/calculationInput/equipments/inventory/PiecesInventory';
import {calculatePiecesState} from 'components/calculationInput/equipments/inventory/piecesStateCalculator';
import {calculatePiecesState}
from 'components/calculationInput/equipments/inventory/piecesStateCalculator';
import {AddToInventoryDialogContextProvider}
from './calculationInput/equipments/inventory/AddToInventoryDialog';

const Home: NextPage = observer((props) => {
const store = useStore();
Expand Down Expand Up @@ -150,10 +155,10 @@ const Home: NextPage = observer((props) => {
onSetSolution={onSetSolution}
/>

{
store.equipmentsRequirementStore.resultMode === ResultMode.LinearProgram ?
buildLinearProgrammingSolution() : buildListStageOnlyResult()
}
<AddToInventoryDialogContextProvider equipById={equipmentsById} piecesState={piecesState}>
{store.equipmentsRequirementStore.resultMode === ResultMode.LinearProgram ?
buildLinearProgrammingSolution() : buildListStageOnlyResult()}
</AddToInventoryDialogContextProvider>
</>;
});

Expand Down
22 changes: 14 additions & 8 deletions components/calculationInput/common/PositiveIntegerOnlyInput.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,35 @@
import {TextField} from '@mui/material';
import {TextField, TextFieldProps} from '@mui/material';
import {Controller, FieldValues, Path} from 'react-hook-form';
import React from 'react';
import {useTranslation} from 'next-i18next';
import {Control} from 'react-hook-form/dist/types/form';

interface PositiveIntegerOnlyInputProps<T extends FieldValues> {
interface PositiveIntegerOnlyInputProps<T extends FieldValues>
extends Omit<TextFieldProps,
| 'inputProps' | 'variant' | 'error' | 'helperText' | 'label'
| 'onChange' | 'onBlur' | 'value' | 'name' | 'ref'>
{
name: Path<T>;
control: Control<T>;
showError: boolean;
helperText: string;
min?: number;
inputLabel?: string;
focused?: boolean;
required?: boolean;
}

const PositiveIntegerOnlyInput = function<T>({
const PositiveIntegerOnlyInput = function<T extends FieldValues>({
name, control, showError, helperText,
min = 1, inputLabel, focused,
min = 1, inputLabel, required = true,
...others
}: PositiveIntegerOnlyInputProps<T>) {
const {t} = useTranslation('home');
return <Controller
name={name}
control={control}
rules={{
required: {
value: true,
value: required,
message: t('addPieceDialog.required'),
},
pattern: {
Expand All @@ -40,10 +45,11 @@ const PositiveIntegerOnlyInput = function<T>({
message: t('addPieceDialog.maximumIs', {max: 999}),
},
}}
render={({field}) => (
render={({field: {ref: fieldRef, ...field}}) => (
<TextField
{...others}
{...field}
inputRef={(elm) => elm && focused && elm.focus()}
inputRef={fieldRef}
inputProps={{pattern: '\\d*'}}
variant="outlined"
error={showError}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import styles from './AddToInventoryDialog.module.scss';
import {
Box,
Button, Dialog, DialogActions, DialogContent, DialogTitle,
Stack,
ToggleButton,
ToggleButtonGroup,
useMediaQuery, useTheme,
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
Stack, ToggleButton, ToggleButtonGroup, useMediaQuery, useTheme,
} from '@mui/material';
import React, {useEffect, useMemo, useReducer, useState} from 'react';
import React, {useContext, useEffect, useMemo, useState} from 'react';
import {useTranslation} from 'next-i18next';
import {useForm} from 'react-hook-form';
import {
Expand All @@ -21,7 +17,46 @@ import {
Comparators, SortingOrder,
buildArrayIndexComparator, buildComparator,
} from 'common/sortUtils';
import {EquipmentCategories} from 'model/Equipment';
import {EquipmentCategories, EquipmentCompositionType} from 'model/Equipment';

const AddToInventoryDialogContext = React.createContext({
open: (drops: DropPieceIdWithProbAndCount[]) => {},
close: () => {},
});

export const useAddToInventoryDialogContext = () => {
const {open, close} = useContext(AddToInventoryDialogContext);
return [open, close] as const;
};

export const AddToInventoryDialogContextProvider = ({
equipById,
piecesState,
children,
}: {
equipById: EquipmentsById,
piecesState: Map<string, PieceState>,
children?: React.ReactNode,
}) => {
const [drops, setDrops] = useState<DropPieceIdWithProbAndCount[]>([]);
const [open, setOpen] = useState(false);
const contextValue = useMemo(() => ({
open: (drops: DropPieceIdWithProbAndCount[]) => {
setDrops(drops);
setOpen(true);
},
close: () => {
setOpen(false);
},
}), [setDrops]);

return <AddToInventoryDialogContext.Provider value={contextValue}>
<AddToInventoryDialog open={open}
onUpdate={contextValue.close} onCancel={contextValue.close}
equipById={equipById} piecesState={piecesState} drops={drops} />
{children}
</AddToInventoryDialogContext.Provider>;
};

const AddToInventoryDialog = ({
open,
Expand All @@ -42,20 +77,16 @@ const AddToInventoryDialog = ({
const {t} = useTranslation('home');
const theme = useTheme();

// hack to lazy rendering
const [onceOpen, notifyOpened] = useReducer((x) => true, false);
useEffect(() => {
open && notifyOpened();
}, [open]);

const [mode, setMode] = useState<'all' | 'lack' | 'required'>('lack');

const pieces = useMemo(() => {
// 20-3: Necklace, Watch, Bag
// 20-4: Watch, Charm, Badge
// 13-1: Shoes, Gloves, Hat
// descending tier -> descending category order?
return drops.map(({id}) => (piecesState.get(id) ?? {
return drops.filter(({id}) => (
equipById.get(id)?.equipmentCompositionType === EquipmentCompositionType.Piece
)).map(({id}) => (piecesState.get(id) ?? {
pieceId: id,
needCount: 0,
inStockCount: 0,
Expand All @@ -72,45 +103,55 @@ const AddToInventoryDialog = ({
)),
));
}, [drops, piecesState, mode, equipById]);
const defaultValues = useMemo(() => {
return pieces.reduce<InventoryForm>((acc, piece) => {
acc[piece.pieceId] = '';
return acc;
}, {});
}, [pieces]);

const {
control,
formState: {isValid: isCountValid, errors: allErrors},
getValues,
reset,
control, formState,
getValues, reset,
getFieldState, setFocus,
handleSubmit,
} = useForm<InventoryForm>({
mode: 'onChange',
defaultValues,
defaultValues: Object.fromEntries(drops.map(({id}) => [id, ''])),
});

const handleCancelDialog = () => {
useEffect(() => {
if (!open) return;
reset(Object.fromEntries(drops.map(({id}) => [id, ''])));
}, [drops, open, reset]);

const handleCancel = () => {
onCancel();
reset();
};

const handleUpdateInventory = () => {
onUpdate(getValues());
const inventory = Object.entries(getValues()).reduce<InventoryForm>((acc, [id, value]) => {
const count = parseInt(value) ?? 0;
const stock = piecesState.get(id)?.inStockCount ?? 0;
acc[id] = `${count + stock}`;
return acc;
}, {});
const handleUpdate = handleSubmit((value) => {
onUpdate(value);
const inventory = Object.fromEntries(Object.entries(value).map(([id, value]) => {
const count = parseInt(value) || 0;
const stock = piecesState.get(id)?.inStockCount || 0;
return [id, `${count + stock}`];
}));
store.equipmentsRequirementStore.updateInventory(inventory);
reset();
};
}, (errors) => {
const field = Object.entries(errors).find(([, it]) => it && 'ref' in it)?.[0];
console.log(errors);
field && setFocus(field);
});

const isFullScreen = useMediaQuery(theme.breakpoints.down('md'));
const isXsOrSmallerScreen = useMediaQuery(theme.breakpoints.down('sm'));
const hasManyPieces = () => pieces.length > 3;

return !onceOpen ? null : <Dialog open={open} keepMounted fullWidth
useEffect(() => {
const id = setTimeout(() => {
const field = pieces.find(({pieceId}) => (
getValues(pieceId) === '' || getFieldState(pieceId).invalid
)) ?? pieces[0];
field && setFocus(field.pieceId, {shouldSelect: true});
}, 100);
return () => clearTimeout(id);
}, [equipById, getFieldState, getValues, open, pieces, setFocus]);

return <Dialog open={open} fullWidth
fullScreen={hasManyPieces() && isFullScreen}
maxWidth={hasManyPieces() && 'xl'}>
<Stack component={DialogTitle} direction='row' alignItems='center'>
Expand All @@ -127,21 +168,20 @@ const AddToInventoryDialog = ({
<DialogContent className={styles.dialogContentContainer}>
<div className={styles.filler}></div>
<div className={`${styles.container} ${isXsOrSmallerScreen && styles.xs}`}>
{pieces.map((piece, index) => {
return <ObtainedPieceBox key={piece.pieceId} allErrors={allErrors}
control={control}
equipmentsById={equipById}
piece={piece}
focused={index === 0}/>;
{pieces.map((piece) => {
return <ObtainedPieceBox key={piece.pieceId}
allErrors={formState.errors} control={control}
equipmentsById={equipById} piece={piece}
required={false} />;
})}
</div>
{pieces.length === 0 && <div>{t('filterResultEmpty')}</div>}
<div className={styles.filler}></div>
</DialogContent>

<DialogActions>
<Button onClick={handleCancelDialog}>{t('cancelButton')}</Button>
<Button onClick={handleUpdateInventory} disabled={!isCountValid}>{t('addButton')}</Button>
<Button onClick={handleCancel}>{t('cancelButton')}</Button>
<Button onClick={handleUpdate} disabled={!formState.isValid}>{t('addButton')}</Button>
</DialogActions>
</Dialog>;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,23 @@ import {InventoryForm}
from 'components/calculationInput/equipments/inventory/InventoryUpdateDialog';
import {useTranslation} from 'next-i18next';

interface ObtainedPieceBoxProps
extends Omit<React.ComponentProps<typeof PositiveIntegerOnlyInput>,
'control' | 'name' | 'showError' | 'helperText' | 'inputLabel'>
{
equipmentsById: EquipmentsById,
piece: PieceState,
control: Control<InventoryForm>,
allErrors: any,
}

const ObtainedPieceBox = function({
equipmentsById,
piece,
control,
allErrors,
focused,
}: {
equipmentsById: EquipmentsById,
piece: PieceState,
control: Control<InventoryForm>,
allErrors: any,
focused?: boolean,
}) {
...others
}: ObtainedPieceBoxProps) {
const {t} = useTranslation('home');

const pieceIcon = equipmentsById.get(piece.pieceId)?.icon;
Expand All @@ -40,8 +44,8 @@ const ObtainedPieceBox = function({
</CardActionArea>
</Card>
<Box sx={{mr: 2}}/>
<PositiveIntegerOnlyInput name={piece.pieceId}
min={0} focused={focused}
<PositiveIntegerOnlyInput {...others} name={piece.pieceId}
min={0}
control={control} showError={!!allErrors[piece.pieceId]}
helperText={allErrors[piece.pieceId]?.message ?? ''}
inputLabel={t('addPieceDialog.obtainedCount')} />
Expand Down
22 changes: 10 additions & 12 deletions components/calculationResult/CampaignDropItemsList.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import styles from './CampaignDropItemsList.module.scss';
import {Card, CardContent, Typography} from '@mui/material';
import React, {FunctionComponent, useState} from 'react';
import React, {FunctionComponent} from 'react';
import {Campaign} from 'model/Campaign';
import {DropPieceIdWithProbAndCount, EquipmentsById} from 'components/calculationInput/PiecesCalculationCommonTypes';
import {
DropPieceIdWithProbAndCount, EquipmentsById,
} from 'components/calculationInput/PiecesCalculationCommonTypes';
import Grid from '@mui/material/Unstable_Grid2';
import BuiBanner from '../bui/BuiBanner';
import {useTranslation} from 'next-i18next';
import EquipmentCard from 'components/bui/card/EquipmentCard';
import BuiButton from 'components/bui/BuiButton';
import AddToInventoryDialog from '../calculationInput/equipments/inventory/AddToInventoryDialog';
import {useAddToInventoryDialogContext}
from '../calculationInput/equipments/inventory/AddToInventoryDialog';
import {PieceState} from 'components/calculationInput/equipments/inventory/PiecesInventory';

type CampaignDropItemsListProps = {
Expand All @@ -35,15 +38,10 @@ const CampaignDropItemsList :
}
) => {
const {t} = useTranslation('home');
const [open, setOpen] = useState(false);

const [openDialog] = useAddToInventoryDialogContext();
return <Card variant={containerCardVariation} className={styles.cardWrapper}
elevation={containerCardVariation == 'elevation' ? 2 : undefined}>
<AddToInventoryDialog open={open}
onUpdate={() => setOpen(false)}
onCancel={() => setOpen(false)}
equipById={equipmentsById}
piecesState={piecesState}
drops={allDrops} />
<CardContent>
<Grid container>
<Grid xs={12} container className={styles.campaignHeader}>
Expand All @@ -58,7 +56,7 @@ const CampaignDropItemsList :

<div style={{flexGrow: 1}}/>

<BuiButton color={'baButtonSecondary'} onClick={() => setOpen(true)}>
<BuiButton color={'baButtonSecondary'} onClick={() => openDialog(allDrops)}>
{'結果を記入'}
</BuiButton>
</Grid>
Expand All @@ -85,4 +83,4 @@ const CampaignDropItemsList :
</Card>;
};

export default CampaignDropItemsList;
export default React.memo(CampaignDropItemsList);

0 comments on commit dd58f60

Please sign in to comment.