fix: create custom hook and stepper to make multi-step modal functional

- Tambah custom hook 'use-query-state' dan implementasi stepper pada modal dengan tahapan lebih dari 1
- Hapus component ModalBackoffice karena sudah menggunakan component umum Modal
This commit is contained in:
Hafid Nur
2025-03-31 22:36:37 +07:00
parent a575e534b5
commit 984a31c587
9 changed files with 168 additions and 447 deletions
@@ -7,36 +7,36 @@ interface IModalEditAccount {
onClose: () => void; onClose: () => void;
handleEditAccount?: () => void; handleEditAccount?: () => void;
currentStep?: number; currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
} }
const ModalEditAccount = ({ const ModalEditAccount = ({
isOpen, isOpen,
onClose, onClose,
currentStep,
nextStep,
prevStep,
resetStep,
handleEditAccount, handleEditAccount,
currentStep = 1,
}: IModalEditAccount) => { }: IModalEditAccount) => {
return ( return (
<Modal <Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center" className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={() => {
onClose();
resetStep();
}}
disableEscapeKeyDown={true} disableEscapeKeyDown={true}
> >
{currentStep === 1 && ( {currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
<StepOne
nextStep={() => {
console.log('Next step');
}}
onClose={onClose}
/>
)}
{currentStep === 2 && ( {currentStep === 2 && (
<StepTwo <StepTwo
onClose={onClose} onClose={onClose}
handleEditAccount={handleEditAccount} handleEditAccount={handleEditAccount}
resetStep={() => { resetStep={resetStep}
console.log('Reset step');
}}
/> />
)} )}
</Modal> </Modal>
@@ -48,7 +48,7 @@ interface IStepOneProps {
onClose: () => void; onClose: () => void;
} }
const StepOne = ({ nextStep, onClose }: IStepOneProps) => { const StepOne = ({ nextStep }: IStepOneProps) => {
const [fullName, setFullName] = useState('Ahmad Wiyana'); const [fullName, setFullName] = useState('Ahmad Wiyana');
const [email, setEmail] = useState('fullname23@gmail.com'); const [email, setEmail] = useState('fullname23@gmail.com');
const [phoneNumber, setPhoneNumber] = useState('081904423804'); const [phoneNumber, setPhoneNumber] = useState('081904423804');
@@ -136,7 +136,10 @@ const StepTwo = ({ onClose, handleEditAccount, resetStep }: IStepTwoProps) => (
variant="bordered" variant="bordered"
size="lg" size="lg"
className="w-full" className="w-full"
onClick={resetStep} onClick={() => {
onClose();
resetStep();
}}
> >
Batal Batal
</Button> </Button>
@@ -146,6 +149,8 @@ const StepTwo = ({ onClose, handleEditAccount, resetStep }: IStepTwoProps) => (
className="w-full" className="w-full"
onClick={() => { onClick={() => {
handleEditAccount && handleEditAccount(); handleEditAccount && handleEditAccount();
onClose();
resetStep();
}} }}
> >
Update Update
+16
View File
@@ -18,6 +18,7 @@ import {
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import ModalEditAccount from './_components/modal-edit-account'; import ModalEditAccount from './_components/modal-edit-account';
import { useQueryState } from '../../hook/use-query-state';
interface Account { interface Account {
id: number; id: number;
@@ -39,6 +40,17 @@ const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const [showModalEditAccount, setShowModalEditAccount] = useState(false); const [showModalEditAccount, setShowModalEditAccount] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 9,
@@ -166,11 +178,15 @@ export const Components: FC = (): ReactElement => {
{/* Modal Edit Account */} {/* Modal Edit Account */}
<ModalEditAccount <ModalEditAccount
currentStep={currentStep}
isOpen={showModalEditAccount} isOpen={showModalEditAccount}
onClose={() => setShowModalEditAccount(false)} onClose={() => setShowModalEditAccount(false)}
handleEditAccount={() => { handleEditAccount={() => {
console.log('Account updated'); console.log('Account updated');
}} }}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/> />
</Fragment> </Fragment>
); );
@@ -6,49 +6,47 @@ interface IModalAddItem {
onClose: () => void; onClose: () => void;
handleAddItem: () => void; handleAddItem: () => void;
currentStep?: number; currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
} }
const ModalAddItem = ({ const ModalAddItem = ({
isOpen, isOpen,
onClose, onClose,
currentStep,
nextStep,
resetStep,
handleAddItem, handleAddItem,
currentStep = 1,
}: IModalAddItem) => { }: IModalAddItem) => {
return ( return (
<Modal <Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center" className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={() => {
onClose();
resetStep();
}}
disableEscapeKeyDown={true} disableEscapeKeyDown={true}
> >
{currentStep === 1 && ( {currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
<StepOne
nextStep={() => {
console.log('Next step');
}}
onClose={onClose}
/>
)}
{currentStep === 2 && ( {currentStep === 2 && (
<StepTwo <StepTwo
onClose={onClose} onClose={onClose}
handleAddItem={handleAddItem} handleAddItem={handleAddItem}
resetStep={() => { resetStep={resetStep}
console.log('Reset step');
}}
/> />
)} )}
</Modal> </Modal>
); );
}; };
// TODO: Functional stepper
interface IStepOneProps { interface IStepOneProps {
nextStep: () => void; nextStep: () => void;
onClose: () => void; onClose: () => void;
} }
const StepOne = ({ nextStep, onClose }: IStepOneProps) => ( const StepOne = ({ nextStep }: IStepOneProps) => (
<> <>
<Modal.Header> <Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500 mb-3"> <h2 className="text-p1 font-semibold text-primary-500 mb-3">
@@ -83,7 +81,7 @@ const StepOne = ({ nextStep, onClose }: IStepOneProps) => (
/> />
</div> </div>
<Button variant="primary" size="lg" className="w-full"> <Button variant="primary" size="lg" className="w-full" onClick={nextStep}>
Tambahkan Item Tambahkan Item
</Button> </Button>
</Modal.Content> </Modal.Content>
@@ -92,8 +90,8 @@ const StepOne = ({ nextStep, onClose }: IStepOneProps) => (
interface IStepTwoProps { interface IStepTwoProps {
onClose: () => void; onClose: () => void;
resetStep: () => void;
handleAddItem?: () => void; handleAddItem?: () => void;
resetStep: () => void;
} }
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => ( const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => (
@@ -112,7 +110,10 @@ const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => (
variant="bordered" variant="bordered"
size="lg" size="lg"
className="w-full" className="w-full"
onClick={resetStep} onClick={() => {
onClose();
resetStep();
}}
> >
Batal Batal
</Button> </Button>
@@ -122,6 +123,8 @@ const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => (
className="w-full" className="w-full"
onClick={() => { onClick={() => {
handleAddItem && handleAddItem(); handleAddItem && handleAddItem();
onClose();
resetStep();
}} }}
> >
Tambahkan Tambahkan
@@ -6,35 +6,35 @@ interface IModalEditItem {
onClose: () => void; onClose: () => void;
handleEditItem?: () => void; handleEditItem?: () => void;
currentStep?: number; currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
} }
const ModalEditItem = ({ const ModalEditItem = ({
isOpen, isOpen,
onClose, onClose,
currentStep,
nextStep,
resetStep,
handleEditItem, handleEditItem,
currentStep = 1,
}: IModalEditItem) => { }: IModalEditItem) => {
return ( return (
<Modal <Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center" className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={() => {
onClose();
resetStep();
}}
disableEscapeKeyDown={true} disableEscapeKeyDown={true}
> >
{currentStep === 1 && ( {currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
<StepOne
nextStep={() => {
console.log('Next step');
}}
onClose={onClose}
/>
)}
{currentStep === 2 && ( {currentStep === 2 && (
<StepTwo <StepTwo
onClose={onClose} onClose={onClose}
resetStep={() => { handleEditItem={handleEditItem}
console.log('Reset step'); resetStep={resetStep}
}}
/> />
)} )}
</Modal> </Modal>
@@ -46,7 +46,7 @@ interface IStepOneProps {
onClose: () => void; onClose: () => void;
} }
const StepOne = ({ nextStep, onClose }: IStepOneProps) => ( const StepOne = ({ nextStep }: IStepOneProps) => (
<> <>
<Modal.Header> <Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500 mb-3"> <h2 className="text-p1 font-semibold text-primary-500 mb-3">
@@ -110,7 +110,10 @@ const StepTwo = ({ onClose, handleEditItem, resetStep }: IStepTwoProps) => (
variant="bordered" variant="bordered"
size="lg" size="lg"
className="w-full" className="w-full"
onClick={resetStep} onClick={() => {
onClose();
resetStep();
}}
> >
Batal Batal
</Button> </Button>
@@ -120,6 +123,8 @@ const StepTwo = ({ onClose, handleEditItem, resetStep }: IStepTwoProps) => (
className="w-full" className="w-full"
onClick={() => { onClick={() => {
handleEditItem && handleEditItem(); handleEditItem && handleEditItem();
onClose();
resetStep();
}} }}
> >
Update Update
@@ -10,12 +10,24 @@ import { FC, Fragment, ReactElement, useState } from 'react';
import ModalAddItem from './_components/modal-add-item'; import ModalAddItem from './_components/modal-add-item';
import ModalEditItem from './_components/modal-edit-item'; import ModalEditItem from './_components/modal-edit-item';
import ModalDeleteItem from './_components/modal-delete-item'; import ModalDeleteItem from './_components/modal-delete-item';
import { useQueryState } from '../../hook/use-query-state';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false); const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalEditItem, setShowModalEditItem] = useState(false); const [showModalEditItem, setShowModalEditItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false); const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
return ( return (
<Fragment> <Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> <main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
@@ -159,20 +171,28 @@ export const Components: FC = (): ReactElement => {
{/* Modal Add Item */} {/* Modal Add Item */}
<ModalAddItem <ModalAddItem
currentStep={currentStep}
isOpen={showModalAddItem} isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)} onClose={() => setShowModalAddItem(false)}
handleAddItem={() => { handleAddItem={() => {
console.log('Item added'); console.log('Item added');
}} }}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/> />
{/* Modal Edit Item */} {/* Modal Edit Item */}
<ModalEditItem <ModalEditItem
currentStep={currentStep}
isOpen={showModalEditItem} isOpen={showModalEditItem}
onClose={() => setShowModalEditItem(false)} onClose={() => setShowModalEditItem(false)}
handleEditItem={() => { handleEditItem={() => {
console.log('Item edited'); console.log('Item edited');
}} }}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/> />
{/* Modal Delete Item */} {/* Modal Delete Item */}
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from 'react';
interface UseQueryStateOptions {
defaultValue: number;
maxValue?: number;
minValue?: number;
}
export const useQueryState = (key: string, options: UseQueryStateOptions) => {
const { defaultValue, maxValue = Infinity, minValue = 1 } = options;
const getQueryParam = (param: string): string | null => {
if (typeof window === 'undefined') return null;
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get(param);
};
const setQueryParam = (param: string, value: string) => {
const searchParams = new URLSearchParams(window.location.search);
searchParams.set(param, value);
const newUrl = `${window.location.pathname}?${searchParams.toString()}`;
window.history.replaceState(null, '', newUrl);
};
const initialValue = () => {
const queryValue = getQueryParam(key);
const parsedValue = queryValue ? parseInt(queryValue, 10) : defaultValue;
return Math.max(minValue, Math.min(maxValue, parsedValue));
};
const [value, setValue] = useState<number>(initialValue);
useEffect(() => {
const handlePopState = () => {
const queryValue = getQueryParam(key);
const newValue = queryValue ? parseInt(queryValue, 10) : defaultValue;
setValue(Math.max(minValue, Math.min(maxValue, newValue)));
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [key, defaultValue, minValue, maxValue]);
const updateValue = useCallback(
(newValue: number) => {
const constrainedValue = Math.max(minValue, Math.min(maxValue, newValue));
setValue(constrainedValue);
setQueryParam(key, constrainedValue.toString());
},
[key, minValue, maxValue]
);
const nextStep = useCallback(() => {
updateValue(value + 1);
}, [value, updateValue]);
const prevStep = useCallback(() => {
updateValue(value - 1);
}, [value, updateValue]);
const resetStep = useCallback(() => {
updateValue(defaultValue);
}, [defaultValue, updateValue]);
return {
step: value,
nextStep,
prevStep,
resetStep,
};
};
@@ -1 +0,0 @@
export * from './modal-backoffice';
@@ -1,25 +0,0 @@
import { ReactElement, ReactNode } from 'react';
interface ModalBackofficeProps {
children: ReactNode;
className?: string;
}
export const ModalBackoffice = ({
children,
className,
...rest
}: ModalBackofficeProps): ReactElement => {
return (
<div
className={`w-[400px] bg-primary-50 rounded-lg p-[40px] gap-[32px] ${
className || ''
}`}
{...rest}
>
{children}
</div>
);
};
export default ModalBackoffice;
@@ -1,373 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ModalBackoffice } from './modal-backoffice';
import { InputForm } from '../../molecules';
import { Button } from '../../atoms';
const meta: Meta<typeof ModalBackoffice> = {
title: 'Organisms/Modal Backoffice',
component: ModalBackoffice,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ModalBackoffice>;
export default meta;
type Story = StoryObj<typeof ModalBackoffice>;
export const Default: Story = {
args: {
// isOpen: true,
// onClose: () => console.log('Modal closed'),
// title: 'Sample Modal',
children: (
<>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Modal Title
</h2>
<p className="text-p3 text-neutral-400">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quas repellat
rerum doloremque dolorem fugit architecto eos blanditiis ratione
facere? Esse dolores inventore deleniti. Dicta voluptatem dolore vel
quia amet dolorem?
</p>
<div className="flex flex-col gap-4"></div>
</>
),
},
};
export const TambahItemGacha: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<div>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Item Gacha
</h2>
<p className="text-p3 text-neutral-400">
Lengkapi detail di bawah ini untuk menambahkan item gacha
</p>
</div>
<div className="flex flex-col gap-4">
<InputForm
label="Nama Hadiah"
type="text"
placeholder="Masukkan Nama Hadiah"
size="lg"
className="w-full"
/>
<InputForm
label="Chance Rate"
type="text"
placeholder="Masukkan Chance Rate"
size="lg"
className="w-full"
/>
<InputForm
label="Foto Barang"
type="text"
placeholder=".jpg, .jpeg, atau .png"
size="lg"
className="w-full"
/>
</div>
<Button variant="primary" size="lg" className="w-full">
Tambahkan Item
</Button>
</div>
),
},
};
export const TambahItem: Story = {
args: {
children: (
<div className="flex flex-col gap-8 text-center">
<div>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Item
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin ingin
<br /> menambahkan item ini?
</p>
</div>
<div className="flex gap-4">
<Button variant="bordered" size="lg" className="w-full">
Batal
</Button>
<Button variant="primary" size="lg" className="w-full">
Tambahkan
</Button>
</div>
</div>
),
},
};
export const EditItemGacha: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<div>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Edit Item Gacha
</h2>
<p className="text-p3 text-neutral-400">
Silakan mengubah detail dari item yang diperlukan
</p>
</div>
<div className="flex flex-col gap-4">
<InputForm
label="Nama Hadiah"
type="text"
placeholder="Masukkan Nama Hadiah"
size="lg"
className="w-full"
/>
<InputForm
label="Chance Rate"
type="text"
placeholder="Masukkan Chance Rate"
size="lg"
className="w-full"
/>
<InputForm
label="Foto Barang"
type="text"
placeholder=".jpg, .jpeg, atau .png"
size="lg"
className="w-full"
/>
</div>
<Button variant="primary" size="lg" className="w-full">
Perbarui Item
</Button>
</div>
),
},
};
export const UpdateItem: Story = {
args: {
children: (
<div className="flex flex-col gap-8 text-center">
<div>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Update Item
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin dengan
<br /> perubahan yang dilakukan?
</p>
</div>
<div className="flex gap-4">
<Button variant="bordered" size="lg" className="w-full">
Batal
</Button>
<Button variant="primary" size="lg" className="w-full">
Update
</Button>
</div>
</div>
),
},
};
export const DeleteItem: Story = {
args: {
children: (
<div className="flex flex-col gap-8 text-center">
<img
src="/chibi-delete.webp"
alt="Delete item?"
width={148}
className="self-center"
/>
<div>
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
Delete Item
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus item ini?
</p>
</div>
<div className="flex gap-4">
<Button variant="secondary" size="lg" className="w-full">
Batal Hapus
</Button>
<Button variant="danger" size="lg" className="w-full">
Hapus Item
</Button>
</div>
</div>
),
},
};
export const EditDataAkun: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Edit Data Akun
</h2>
<div className="flex flex-col gap-4">
<InputForm
label="Nama Lengkap"
type="text"
placeholder="Masukkan Nama Lengkap"
value="Ahmad Wiyana"
size="lg"
className="w-full"
/>
<InputForm
label="Email"
type="text"
placeholder="Masukkan Email"
value="fullname23@gmail.com"
size="lg"
className="w-full"
/>
<InputForm
label="Nomor Telepon"
type="text"
placeholder="Masukkan Nomor Telepon"
value="081904423804"
size="lg"
className="w-full"
/>
<InputForm
label="Alamat"
type="text"
placeholder="Masukkan Alamat"
value="Jl. Pantai Cibaduyut Indah"
size="lg"
className="w-full"
/>
</div>
<Button variant="primary" size="lg" className="w-full">
Perbarui Data
</Button>
</div>
),
},
};
export const UpdateDataAkun: Story = {
args: {
children: (
<div className="flex flex-col gap-8 text-center">
<div>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Update Data
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin dengan
<br /> perubahan yang dilakukan?
</p>
</div>
<div className="flex gap-4">
<Button variant="bordered" size="lg" className="w-full">
Batal
</Button>
<Button variant="primary" size="lg" className="w-full">
Update
</Button>
</div>
</div>
),
},
};
export const ValidasiTransaksi: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<h2 className="text-p1 font-semibold text-primary-500 ">
Validasi Transaksi
</h2>
<InputForm
label="Nomor Transaksi"
type="text"
placeholder="Masukkan Nomor Transaksi"
value="2502133Y9AFVBO"
size="lg"
className="w-full"
/>
<div className="flex gap-4">
<Button
variant="bordered"
size="lg"
className="w-full border-danger-500 text-danger-500 hover:border-danger-700 hover:text-danger-700"
>
Tidak Valid
</Button>
<Button variant="primary" size="lg" className="w-full">
Valid
</Button>
</div>
</div>
),
},
};
export const ProsesDelivery: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<div>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Delivery Process
</h2>
<p className="text-p3 text-neutral-400">
Lakukan pengiriman hadiah gacha untuk pengguna di bawah ini, jika
sudah ubah status menjadi Delivered.
</p>
</div>
<div className="flex flex-col gap-4">
<InputForm
label="Nama Lengkap"
type="text"
placeholder="Masukkan Nama Lengkap"
value="Ahmad Wiyana"
size="lg"
className="w-full"
/>
<InputForm
label="Item yang didapatkan"
type="text"
placeholder="Masukkan Nama Item"
value="Lanyard + ID Card"
size="lg"
className="w-full"
/>
<InputForm
label="Alamat Pengiriman"
type="text"
placeholder="Masukkan Alamat Pengiriman"
value="Jl. Pantai Cibaduyut Indah"
size="lg"
className="w-full"
/>
<InputForm
label="Status"
type="text"
placeholder="Isi Status Pengiriman"
value="Lanyard + ID Card"
size="lg"
className="w-full"
/>
</div>
<Button variant="primary" size="lg" className="w-full">
Tambahkan Item
</Button>
</div>
),
},
};