feat: add AdminTestimonials page with CRUD and reordering

This commit is contained in:
belviskhoremk
2026-08-13 09:25:42 +02:00
parent bdf8d9cc3a
commit 28b103cf2c
3 changed files with 297 additions and 0 deletions

View 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';
}

View 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')
})
})

View 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