Hook headless
useSitepingInbox — toute la logique de l'inbox, aucune interface. Construisez votre propre vue de tri.
Tout ce que fait SitepingInbox — chargement, filtres, mutations optimistes, annulation, pagination — vit dans un hook que vous pouvez piloter depuis vos propres composants :
import { useSitepingInbox } from "@siteping/dashboard";
const inbox = useSitepingInbox({
projects: "mon-projet",
endpoint: "/api/siteping",
});Options
Les options forment une union sur les trois modes de source : fournissez exactement un de source, store ou endpoint. N'en fournir aucun, en fournir deux, ou associer des options propres au mode endpoint (apiKey, headers) à store/source est une erreur de compilation — pas une exception à l'exécution, et pas une option ignorée en silence.
| Option | Type | Défaut | Notes |
|---|---|---|---|
projects | string ou string[] | obligatoire | Un tableau vide lève une erreur au rendu. La première entrée est sélectionnée au départ |
endpoint | string | — | Mode HTTP |
store | SitepingStore | — | Mode store |
source | InboxSource | — | Source de données entièrement personnalisée ; voir Sources de données |
apiKey | string | — | Mode endpoint uniquement. Authorization: Bearer sur chaque requête |
headers | objet ou fonction | — | Mode endpoint uniquement. Un Authorization explicite, quelle que soit sa casse, l'emporte sur apiKey. Lu en direct — le modifier ne reconstruit pas la source |
pageSize | number | 50 | Borné entre 1 et 100 |
onStatusChange | (feedback, previousStatus) => void | — | Après confirmation du serveur |
onDelete | (feedback) => void | — | Après confirmation du serveur |
onError | (error) => void | — | Chaque chargement ou mutation en échec |
author | { name, email? } | — | Qui poste les réponses depuis cette inbox — voir Fils de discussion. Sans lui, les fils sont en lecture seule |
readOnly | boolean | false | Aucun changement de statut ni aucune suppression, de feedback comme de réponse — voir Permissions |
Chaque mode est exporté séparément — InboxEndpointOptions, InboxStoreOptions, InboxCustomSourceOptions, le socle commun InboxSharedOptions et l'union UseSitepingInboxOptions — pour qu'un composant d'enrobage n'accepte que le mode qu'il gère. En JavaScript pur, le comportement d'avant demeure : aucune source du tout lève toujours au rendu.
Ce que vous récupérez
Données — items (toutes les pages chargées), total (null jusqu'à l'arrivée de la première page), counts (totaux par statut, au mieux), loading, loadingMore, error, hasMore, loadMore(), refresh().
Filtres — project/setProject, status/setStatus (démarre sur "open"), type/setType, search/setSearch. La recherche met l'état à jour instantanément et temporise le rechargement de 250 ms ; les requêtes sont nettoyées et plafonnées à 200 caractères.
Focus et tiroir — focusedId, focus(id), focusNext(), focusPrev(), openedId, opened (le feedback ouvert — il reste disponible même si un filtre retire sa ligne), openFeedback(id), closeFeedback().
Mutations — changeStatus(id, status), deleteFeedback(id), pendingUndo, undo().
Fil de discussion — canComment, canDeleteComment, addComment(id, body, clientId?), deleteComment(id, commentId) ; voir plus bas.
Permissions — permissionsOf(record) ; voir plus bas.
view — l'algèbre des drapeaux en moins
Choisir entre un squelette, un état d'erreur, un état vide et la liste à partir de loading / error / items.length est délicat, et se tromper d'un cheveu, c'est ce qui fait qu'un rechargement vide l'écran. view est cette décision, déjà prise — exactement la valeur dont part le composant fourni :
view | Signification |
|---|---|
"loading" | La première page charge et il n'y a encore rien à afficher |
"error" | Le chargement a échoué et il n'y a rien à afficher |
"empty" | Le chargement a réussi et aucune ligne ne correspond aux filtres courants |
"ready" | Des lignes sont affichables |
if (inbox.view === "loading") return <Skeleton />;
if (inbox.view === "error") return <ErrorState error={inbox.error} onRetry={inbox.refresh} />;
if (inbox.view === "empty") return <EmptyState />;
return <List items={inbox.items} />;Les lignes l'emportent sur tout autre signal : pendant un rechargement, les lignes déjà chargées restent à l'écran et view reste "ready", donc la liste ne repasse jamais brutalement au squelette. Lisez loading quand vous voulez un indicateur plus fin par-dessus les lignes visibles.
Les mutations sont optimistes — et elles relèvent l'erreur
L'interface se met à jour immédiatement ; si le serveur refuse, le hook rétablit ce feedback (sa ligne, les compteurs, le focus, le feedback ouvert) puis relève l'erreur. Le retour arrière ne touche que le feedback refusé : les autres modifications en cours et les données rechargées entre-temps sont conservées. Attachez toujours un catch :
<button
onClick={() => {
inbox.changeStatus(item.id, "resolved").catch(() => {
// l'état est déjà revenu en arrière — affichez un toast ici
});
}}
>
Résoudre
</button>Seul le dernier changement de statut est annulable (pendingUndo + undo()) ; la suppression est définitive, alors protégez-la derrière votre propre confirmation.
Fils de discussion
Chaque feedback porte son fil dans comments (du plus ancien au plus récent). canComment indique si cette inbox peut poster des réponses : un author est fourni, la source implémente addComment, et la dernière liste n'a pas dit que l'endpoint ne garde aucun commentaire. canDeleteComment indique si elle peut aussi les supprimer : l'inbox n'est pas en readOnly, la source implémente en plus removeComment, et la dernière liste n'a pas dit que l'endpoint ne sait pas les supprimer.
Les réponses ne sont pas optimistes : addComment se résout une fois la réponse enregistrée par la source, et elle apparaît alors sur le feedback partout où le hook le détient — sa ligne, opened, et l'état vers lequel un changement de statut en cours reviendrait. Les deux actions relèvent l'erreur après onError, comme les mutations plus haut — sauf un deleteComment dont la réponse n'existe déjà plus (un 404, ou la StoreNotFoundError du store) : la réponse quitte le feedback et l'appel se résout. Une liste ou un changement de statut lu avant qu'une réponse soit enregistrée ou supprimée garde le fil que détient le hook.
const [draft, setDraft] = useState("");
// Un clientId par réponse, gardé entre ses renvois : le serveur répond au
// renvoi d'une réponse déjà arrivée par celle qu'il a enregistrée. Un
// brouillon modifié est une autre réponse, sinon ce premier texte reviendrait
// à sa place.
const sent = useRef<{ body: string; clientId: string } | null>(null);
async function send(feedbackId: string) {
if (sent.current?.body !== draft) {
// crypto.randomUUID() n'existe qu'en contexte sécurisé (HTTPS, localhost).
const clientId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
sent.current = { body: draft, clientId };
}
try {
await inbox.addComment(feedbackId, draft, sent.current.clientId);
setDraft("");
sent.current = null;
} catch {
// le brouillon reste — affichez une erreur en ligne
}
}La réponse part au nom d'author avec le rôle team, que le serveur ne garde que pour les appelants dont sa politique d'accès se porte garante — voir le rôle équipe.
Permissions
permissionsOf(record) dit ce que l'utilisateur peut faire d'un enregistrement ici — { canChangeStatus, canDelete, canComment, canDeleteComment } : ce que readOnly, canComment et canDeleteComment permettent, moins ce que les permissions de l'enregistrement refusent. La source endpoint transmet celles du serveur (voir ce que chaque appelant peut faire) ; un enregistrement sans elles ne refuse rien. Affichez vos contrôles d'après elle :
const { canChangeStatus, canDelete } = inbox.permissionsOf(item);changeStatus, undo, deleteFeedback, addComment et deleteComment ne font rien sur un enregistrement qui les refuse : un appel égaré n'affiche jamais un changement optimiste que le serveur annulerait, ni n'envoie une requête qu'il refuserait. L'inbox ne fait que masquer : c'est le serveur qui décide de ce qu'il accepte.
Pagination
loadMore() est manuel — le composant fourni affiche un bouton « Charger plus », pas un défilement infini. Les pages sont dédoublonnées par id, et le numéro de page suivant est déduit de items.length, donc les suppressions optimistes ne font jamais sauter de lignes.