Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
import { act, renderHook } from '@testing-library/react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal'
|
|
import type { ServiceResult } from '@/services/errorHandler'
|
|
|
|
const addToast = vi.fn()
|
|
|
|
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
|
|
|
interface Entity {
|
|
id: string
|
|
name: string
|
|
}
|
|
interface FormValues {
|
|
name: string
|
|
}
|
|
|
|
const emptyValues: FormValues = { name: '' }
|
|
const toFormValues = (entity: Entity): FormValues => ({ name: entity.name })
|
|
const buildPayload = (values: FormValues) => ({ name: values.name })
|
|
const resolver = vi.fn(async (values: FormValues) => ({ values, errors: {} }))
|
|
|
|
const setup = () => {
|
|
const create = vi.fn<(payload: { name: string }) => Promise<ServiceResult<unknown>>>()
|
|
const update = vi.fn<(id: string, payload: { name: string }) => Promise<ServiceResult<unknown>>>()
|
|
const onOpenChange = vi.fn()
|
|
const onSuccess = vi.fn()
|
|
|
|
const { result, rerender } = renderHook(
|
|
(props: { entity: Entity | undefined; isOpen: boolean }) =>
|
|
useAdminCrudFormModal({
|
|
entity: props.entity,
|
|
isOpen: props.isOpen,
|
|
emptyValues,
|
|
toFormValues,
|
|
|
|
resolver: resolver as any,
|
|
buildPayload,
|
|
create,
|
|
update,
|
|
getId: (entity) => entity.id,
|
|
successMessage: { create: 'ایجاد شد', edit: 'ویرایش شد' },
|
|
onOpenChange,
|
|
onSuccess,
|
|
}),
|
|
{ initialProps: { entity: undefined as Entity | undefined, isOpen: false } }
|
|
)
|
|
|
|
return { result, rerender, create, update, onOpenChange, onSuccess }
|
|
}
|
|
|
|
describe('useAdminCrudFormModal', () => {
|
|
beforeEach(() => addToast.mockClear())
|
|
|
|
it('derives isEdit from whether an entity is present', () => {
|
|
const { result: createResult } = setup()
|
|
|
|
expect(createResult.current.isEdit).toBe(false)
|
|
|
|
// `entity` is a dependency of the hook's reset-on-open effect, so it
|
|
// must be a stable reference across renders here — an inline object
|
|
// literal recreated on every render of this renderHook callback would
|
|
// make the effect refire every render (reset -> re-render -> new
|
|
// `entity` identity -> effect deps changed -> reset -> ...), an
|
|
// infinite loop. Hoisting it above avoids that footgun.
|
|
const existingEntity: Entity = { id: '1', name: 'existing' }
|
|
const { result: editResult } = renderHook(() =>
|
|
useAdminCrudFormModal({
|
|
entity: existingEntity,
|
|
isOpen: true,
|
|
emptyValues,
|
|
toFormValues,
|
|
|
|
resolver: resolver as any,
|
|
buildPayload,
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
getId: (entity) => entity.id,
|
|
successMessage: { create: 'ایجاد شد', edit: 'ویرایش شد' },
|
|
onOpenChange: vi.fn(),
|
|
onSuccess: vi.fn(),
|
|
})
|
|
)
|
|
|
|
expect(editResult.current.isEdit).toBe(true)
|
|
})
|
|
|
|
it('resets the form to the entity values only on the isOpen transition, not on every render', () => {
|
|
const { result, rerender } = setup()
|
|
|
|
// Opening with an entity resets to its mapped values.
|
|
rerender({ entity: { id: '1', name: 'Ali' }, isOpen: true })
|
|
expect(result.current.form.getValues()).toEqual({ name: 'Ali' })
|
|
|
|
// A render while still open with a *different* entity object identity
|
|
// but the effect's dependency array (`entity`) does change here, so it
|
|
// re-resets — this models the "modal re-opens for a different node"
|
|
// case the source comment documents, not a same-entity re-render.
|
|
rerender({ entity: { id: '2', name: 'Reza' }, isOpen: true })
|
|
expect(result.current.form.getValues()).toEqual({ name: 'Reza' })
|
|
|
|
// Closing does not clear the form back to empty values (no reset fires
|
|
// while isOpen is false) — the modal is expected to unmount/hide, not
|
|
// to have its draft wiped on close.
|
|
rerender({ entity: { id: '2', name: 'Reza' }, isOpen: false })
|
|
expect(result.current.form.getValues()).toEqual({ name: 'Reza' })
|
|
})
|
|
|
|
it('resets to emptyValues when opening for create (no entity)', () => {
|
|
const { result, rerender } = setup()
|
|
|
|
rerender({ entity: undefined, isOpen: true })
|
|
expect(result.current.form.getValues()).toEqual({ name: '' })
|
|
})
|
|
|
|
it('on submit success: shows the success toast, closes, then refreshes — in that order, no earlier', async () => {
|
|
const { result, rerender, create, onOpenChange, onSuccess } = setup()
|
|
|
|
create.mockResolvedValue({ ok: true, data: {} })
|
|
rerender({ entity: undefined, isOpen: true })
|
|
|
|
const callOrder: string[] = []
|
|
|
|
onOpenChange.mockImplementation(() => callOrder.push('close'))
|
|
onSuccess.mockImplementation(() => callOrder.push('success'))
|
|
addToast.mockImplementation(() => callOrder.push('toast'))
|
|
|
|
await act(async () => {
|
|
result.current.form.setValue('name', 'New item')
|
|
await result.current.handleSubmit()
|
|
})
|
|
|
|
expect(create).toHaveBeenCalledWith({ name: 'New item' })
|
|
expect(addToast).toHaveBeenCalledWith({ title: 'ایجاد شد', color: 'success' })
|
|
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
expect(onSuccess).toHaveBeenCalledOnce()
|
|
expect(callOrder).toEqual(['toast', 'close', 'success'])
|
|
expect(result.current.submitting).toBe(false)
|
|
})
|
|
|
|
it('on submit success while editing: calls update with the entity id, not create', async () => {
|
|
const entity = { id: 'entity-1', name: 'Existing' }
|
|
const { result, rerender, create, update } = setup()
|
|
|
|
update.mockResolvedValue({ ok: true, data: {} })
|
|
rerender({ entity, isOpen: true })
|
|
|
|
await act(async () => {
|
|
result.current.form.setValue('name', 'Renamed')
|
|
await result.current.handleSubmit()
|
|
})
|
|
|
|
expect(update).toHaveBeenCalledWith('entity-1', { name: 'Renamed' })
|
|
expect(create).not.toHaveBeenCalled()
|
|
expect(addToast).toHaveBeenCalledWith({ title: 'ویرایش شد', color: 'success' })
|
|
})
|
|
|
|
it('on submit failure: does not toast, does not close, and resets submitting to false', async () => {
|
|
const { result, rerender, create, onOpenChange, onSuccess } = setup()
|
|
|
|
create.mockResolvedValue({ ok: false, error: { message: 'خطا' } as never })
|
|
rerender({ entity: undefined, isOpen: true })
|
|
|
|
await act(async () => {
|
|
result.current.form.setValue('name', 'New item')
|
|
await result.current.handleSubmit()
|
|
})
|
|
|
|
expect(addToast).not.toHaveBeenCalled()
|
|
expect(onOpenChange).not.toHaveBeenCalled()
|
|
expect(onSuccess).not.toHaveBeenCalled()
|
|
expect(result.current.submitting).toBe(false)
|
|
})
|
|
})
|