feat: add AdminTestimonials page with CRUD and reordering
This commit is contained in:
29
src/pages/admin/AdminTestimonials.module.css
Normal file
29
src/pages/admin/AdminTestimonials.module.css
Normal file
@@ -0,0 +1,29 @@
|
||||
/* src/pages/admin/AdminTestimonials.module.css */
|
||||
.page {
|
||||
composes: page from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.table {
|
||||
composes: table from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.form {
|
||||
composes: form from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: button from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.buttonSecondary {
|
||||
composes: buttonSecondary from './adminShared.module.css';
|
||||
margin-right: var(--admin-space-xs);
|
||||
}
|
||||
|
||||
.buttonDanger {
|
||||
composes: buttonDanger from './adminShared.module.css';
|
||||
}
|
||||
|
||||
.error {
|
||||
composes: error from './adminShared.module.css';
|
||||
}
|
||||
90
src/pages/admin/AdminTestimonials.test.tsx
Normal file
90
src/pages/admin/AdminTestimonials.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
// src/pages/admin/AdminTestimonials.test.tsx
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const orderMock = vi.fn()
|
||||
const selectMock = vi.fn(() => ({ order: orderMock }))
|
||||
const insertMock = vi.fn()
|
||||
const eqUpdateMock = vi.fn()
|
||||
const updateMock = vi.fn(() => ({ eq: eqUpdateMock }))
|
||||
const eqDeleteMock = vi.fn()
|
||||
const deleteMock = vi.fn(() => ({ eq: eqDeleteMock }))
|
||||
const fromMock = vi.fn<(table: string) => {
|
||||
select: typeof selectMock
|
||||
insert: typeof insertMock
|
||||
update: typeof updateMock
|
||||
delete: typeof deleteMock
|
||||
}>(() => ({ select: selectMock, insert: insertMock, update: updateMock, delete: deleteMock }))
|
||||
const uploadSalonImageMock = vi.fn()
|
||||
|
||||
vi.mock('../../lib/supabase', () => ({
|
||||
supabase: { from: (table: string) => fromMock(table) },
|
||||
}))
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadSalonImage: (...args: unknown[]) => uploadSalonImageMock(...args),
|
||||
}))
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{ id: 't1', quote: 'Fantastique !', author: 'Amara T.', avatar_image: 'testimonials/amara.jpg', sort_order: 0 },
|
||||
{ id: 't2', quote: 'Excellent service.', author: 'Keisha R.', avatar_image: 'testimonials/keisha.jpg', sort_order: 1 },
|
||||
]
|
||||
|
||||
import AdminTestimonials from './AdminTestimonials'
|
||||
|
||||
describe('AdminTestimonials', () => {
|
||||
beforeEach(() => {
|
||||
orderMock.mockReset().mockResolvedValue({ data: TESTIMONIALS, error: null })
|
||||
selectMock.mockClear()
|
||||
insertMock.mockReset().mockResolvedValue({ error: null })
|
||||
eqUpdateMock.mockReset().mockResolvedValue({ error: null })
|
||||
updateMock.mockClear()
|
||||
eqDeleteMock.mockReset().mockResolvedValue({ error: null })
|
||||
deleteMock.mockClear()
|
||||
fromMock.mockClear()
|
||||
uploadSalonImageMock.mockReset()
|
||||
})
|
||||
|
||||
it('lists existing testimonials', async () => {
|
||||
render(<AdminTestimonials />)
|
||||
expect(await screen.findByText('Amara T.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Keisha R.')).toBeInTheDocument()
|
||||
expect(fromMock).toHaveBeenCalledWith('testimonials')
|
||||
})
|
||||
|
||||
it('creates a new testimonial from the form', async () => {
|
||||
render(<AdminTestimonials />)
|
||||
await screen.findByText('Amara T.')
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/citation/i), 'Superbe expérience.')
|
||||
await userEvent.type(screen.getByLabelText(/^auteur$/i), 'Nadia F.')
|
||||
await userEvent.click(screen.getByRole('button', { name: /ajouter le témoignage/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(insertMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ quote: 'Superbe expérience.', author: 'Nadia F.', sort_order: 2 }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes a testimonial with confirmation', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
render(<AdminTestimonials />)
|
||||
await screen.findByText('Amara T.')
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /supprimer/i })[0])
|
||||
|
||||
expect(deleteMock).toHaveBeenCalled()
|
||||
expect(eqDeleteMock).toHaveBeenCalledWith('id', 't1')
|
||||
})
|
||||
|
||||
it('moves a testimonial down by swapping sort_order with the next row', async () => {
|
||||
render(<AdminTestimonials />)
|
||||
await screen.findByText('Amara T.')
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /descendre/i })[0])
|
||||
|
||||
await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ sort_order: 1 }))
|
||||
expect(eqUpdateMock).toHaveBeenCalledWith('id', 't1')
|
||||
})
|
||||
})
|
||||
178
src/pages/admin/AdminTestimonials.tsx
Normal file
178
src/pages/admin/AdminTestimonials.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
// src/pages/admin/AdminTestimonials.tsx
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
import { uploadSalonImage } from '../../lib/storage'
|
||||
import { friendlyError } from '../../lib/friendlyError'
|
||||
import styles from './AdminTestimonials.module.css'
|
||||
|
||||
interface AdminTestimonial {
|
||||
id: string
|
||||
quote: string
|
||||
author: string
|
||||
avatar_image: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
function emptyForm(): Omit<AdminTestimonial, 'id' | 'sort_order'> & { id: string } {
|
||||
return { id: '', quote: '', author: '', avatar_image: '' }
|
||||
}
|
||||
|
||||
function AdminTestimonials() {
|
||||
const [testimonials, setTestimonials] = useState<AdminTestimonial[]>([])
|
||||
const [form, setForm] = useState(emptyForm())
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function loadTestimonials() {
|
||||
const { data, error: fetchError } = await supabase.from('testimonials').select('*').order('sort_order')
|
||||
if (fetchError) {
|
||||
setError(fetchError.message)
|
||||
return
|
||||
}
|
||||
setTestimonials((data ?? []) as AdminTestimonial[])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadTestimonials()
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
|
||||
let avatar_image = form.avatar_image
|
||||
if (file) {
|
||||
try {
|
||||
avatar_image = await uploadSalonImage(file, `testimonials/${Date.now()}-${file.name}`)
|
||||
} catch {
|
||||
setError("Échec du téléversement de l'image.")
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (form.id) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('testimonials')
|
||||
.update({ quote: form.quote, author: form.author, avatar_image })
|
||||
.eq('id', form.id)
|
||||
setSaving(false)
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const maxOrder = testimonials.reduce((max, t) => Math.max(max, t.sort_order), -1)
|
||||
const { error: insertError } = await supabase
|
||||
.from('testimonials')
|
||||
.insert({ quote: form.quote, author: form.author, avatar_image, sort_order: maxOrder + 1 })
|
||||
setSaving(false)
|
||||
if (insertError) {
|
||||
setError(insertError.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
setForm(emptyForm())
|
||||
setFile(null)
|
||||
await loadTestimonials()
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!window.confirm('Supprimer ce témoignage ?')) return
|
||||
const { error: deleteError } = await supabase.from('testimonials').delete().eq('id', id)
|
||||
if (deleteError) {
|
||||
setError(friendlyError(deleteError))
|
||||
return
|
||||
}
|
||||
await loadTestimonials()
|
||||
}
|
||||
|
||||
async function handleMove(index: number, direction: -1 | 1) {
|
||||
const targetIndex = index + direction
|
||||
if (targetIndex < 0 || targetIndex >= testimonials.length) return
|
||||
const current = testimonials[index]
|
||||
const target = testimonials[targetIndex]
|
||||
setError('')
|
||||
const { error: err1 } = await supabase.from('testimonials').update({ sort_order: target.sort_order }).eq('id', current.id)
|
||||
if (err1) {
|
||||
setError(err1.message)
|
||||
return
|
||||
}
|
||||
const { error: err2 } = await supabase.from('testimonials').update({ sort_order: current.sort_order }).eq('id', target.id)
|
||||
if (err2) {
|
||||
setError(err2.message)
|
||||
return
|
||||
}
|
||||
await loadTestimonials()
|
||||
}
|
||||
|
||||
const isEditing = Boolean(form.id)
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1>Témoignages</h1>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Auteur</th>
|
||||
<th>Citation</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{testimonials.map((t, index) => (
|
||||
<tr key={t.id}>
|
||||
<td>{t.author}</td>
|
||||
<td>{t.quote}</td>
|
||||
<td>
|
||||
<button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, -1)} disabled={index === 0}>Monter</button>
|
||||
<button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, 1)} disabled={index === testimonials.length - 1}>Descendre</button>
|
||||
<button type="button" className={styles.buttonSecondary} onClick={() => setForm(t)}>Modifier</button>
|
||||
<button type="button" className={styles.buttonDanger} onClick={() => handleDelete(t.id)}>Supprimer</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<h2>{isEditing ? 'Modifier le témoignage' : 'Nouveau témoignage'}</h2>
|
||||
<label htmlFor="testimonial-author">
|
||||
Auteur
|
||||
<input
|
||||
id="testimonial-author"
|
||||
value={form.author}
|
||||
onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor="testimonial-quote">
|
||||
Citation
|
||||
<textarea
|
||||
id="testimonial-quote"
|
||||
value={form.quote}
|
||||
onChange={(e) => setForm((f) => ({ ...f, quote: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor="testimonial-avatar">
|
||||
Photo
|
||||
<input
|
||||
id="testimonial-avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<button type="submit" className={styles.button} disabled={saving}>
|
||||
{isEditing ? 'Enregistrer le témoignage' : 'Ajouter le témoignage'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminTestimonials
|
||||
Reference in New Issue
Block a user