Aller au contenu
SuperScheduler

Modules ProS’applique àSuperScheduler Pro

Annuler, rétablir et historique

Créez un historique avec createHistory() et passez-le dans la prop history : les déplacements et redimensionnements sont enregistrés, et Ctrl/Cmd+Z, Ctrl/Cmd+Maj+Z et Ctrl+Y fonctionnent tant que le focus est dans le planificateur. Avec des événements contrôlés, adoptez les modifications 'history' reçues dans onEventsChange, ou donnez à createHistory une fonction apply qui met à jour votre état. Utilisez push pour vos propres commandes, batch pour regrouper des modifications, revert pour annuler une modification refusée par votre serveur, et subscribe ou onHistoryChange pour piloter des boutons Annuler et Rétablir.

Vérifié avec la v0.1.0 · relu le 7 octobre 2026.md

La manipulation directe invite à l’erreur : un événement déposé une ligne trop bas, un redimensionnement qui va un jour trop loin. super-scheduler/history tient une pile d’annulation des modifications d’événements et de vos propres commandes, avec raccourcis clavier, libellés pour les boutons, regroupement et retour arrière pour les modifications que votre serveur rejette. Cet historique vit en mémoire le temps de la session ; enregistrer quoi que ce soit est le travail de votre application.

L’historique nécessite SuperScheduler Pro.

Attacher un historique

createHistory() renvoie un objet historique. Passez-le au planificateur dans history (ou dans extensions). Créez-le une seule fois : les props sont comparées par identité, et un historique créé pendant le rendu serait un nouvel historique vide à chaque rendu.

src/UndoablePlanner.tsxtsx
import { useEffect, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerHistoryChangeArgs, SuperScheduler } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'
import type { SchedulerHistory } from 'super-scheduler/history'
import 'super-scheduler/styles.css'

type HistoryState = Pick<
  SchedulerHistoryChangeArgs,
  'canUndo' | 'canRedo' | 'undoLabel' | 'redoLabel'
>

/** Mirrors the history in React: one render per change, never per gesture frame. */
export function useHistoryState(history: SchedulerHistory): HistoryState {
  const [state, setState] = useState<HistoryState>(() => ({
    canUndo: history.canUndo,
    canRedo: history.canRedo,
    undoLabel: history.undoLabel,
    redoLabel: history.redoLabel,
  }))
  useEffect(() => history.subscribe(setState), [history])
  return state
}

export function UndoablePlanner(props: {
  resources: SuperScheduler.ResourceData[]
  initialEvents: SuperScheduler.EventData[]
}) {
  // Created once: `history` is compared by identity, like every prop.
  const [history] = useState(() =>
    createHistory({
      limit: 100,
      // Default: moves and resizes. Mod+Z, Mod+Shift+Z and Ctrl+Y work while focus is in the grid.
      record: ['move', 'resize'],
      keys: 'root',
      labels: { move: 'move', resize: 'resize' },
    }),
  )
  const [initial] = useState(() => props.initialEvents.slice())
  const state = useHistoryState(history)

  return (
    <>
      <div role="toolbar" aria-label="History">
        <button type="button" disabled={!state.canUndo} onClick={() => history.undo()}>
          {state.undoLabel === null ? 'Undo' : `Undo ${state.undoLabel}`}
        </button>
        <button type="button" disabled={!state.canRedo} onClick={() => history.redo()}>
          {state.redoLabel === null ? 'Redo' : `Redo ${state.redoLabel}`}
        </button>
      </div>
      <SuperSchedulerComponent
        history={history}
        // Uncontrolled: the control owns the events after mount.
        defaultEvents={initial}
        resources={props.resources}
        startDate="2026-10-01"
        days={31}
        scale="Day"
      />
    </>
  )
}

Faites glisser un événement : le premier bouton affiche « Undo move ». Cliquez dessus, ou appuyez sur Cmd+Z (Ctrl+Z sous Windows et Linux) après avoir cliqué sur un événement, et l’événement revient à sa place ; « Redo move » le déplace à nouveau.

Un même historique peut servir plusieurs planificateurs, par exemple les volets d’une vue scindée. L’annulation suit alors l’ordre des modifications, tous planificateurs confondus.

Options

OptionDéfautEffet
limit50Nombre d’entrées conservées ; au-delà, la plus ancienne est supprimée. 0 n’enregistre rien.
record['move', 'resize']Modifications enregistrées automatiquement : 'move', 'resize', 'create', 'remove', 'update'
keys'root'Où les raccourcis sont écoutés : dans le planificateur, dans tout le document ('document'), ou nulle part avec false
equalsdébut, fin, ressource, texteDeux états d’un événement jugés égaux ne créent pas d’entrée
fieldsaucunChamps supplémentaires pour la comparaison par défaut
apply'control'Qui applique l’annulation et le rétablissement : le contrôle, ou votre fonction
labelsanglais ou espagnolLibellé par type : move, resize, create, remove, update, command

Ce qui est enregistré :

  • Les gestes. 'move' et 'resize' couvrent les déplacements et redimensionnements au pointeur comme au clavier. L’entrée est créée une fois la modification confirmée : un déplacement refusé par vos règles, ou annulé pendant une confirmation asynchrone, ne laisse aucune entrée.
  • Les modifications par l’API. 'create', 'remove' et 'update' enregistrent les appels à control.events.add(), remove() et update().
  • Jamais. Les chargements de données : un nouveau tableau events venu de React, et les événements du chargeur de plages. L’annulation et le rétablissement eux-mêmes ne sont pas réenregistrés.

Les raccourcis sont Cmd+Z et Cmd+Maj+Z sous macOS, et Ctrl+Z, Ctrl+Maj+Z et Ctrl+Y ailleurs. Ils sont ignorés dans les champs de saisie, les zones de texte et les éléments éditables, quand la touche Alt est maintenue, et quand un autre gestionnaire a déjà traité la touche. Avec keys: 'root', le focus doit se trouver dans le planificateur : cliquer sur un événement ou le faire glisser l’y place, tout comme Tab quand le clavier est activé. 'document' fonctionne partout dans la page ; ne l’utilisez donc que si aucune autre partie de la page n’a sa propre annulation.

Afficher l’état d’annulation

L’objet historique expose canUndo, canRedo, undoLabel et redoLabel, ainsi que undo() et redo(), qui renvoient false quand il n’y a rien à faire. Trois façons de suivre les changements :

  • history.subscribe(listener) renvoie une fonction de désabonnement, ce qui en fait un useEffect tout naturel (le useHistoryState de l’extrait) ;
  • la prop onHistoryChange reçoit le même état, avec this lié au contrôle ;
  • useScheduler({ track: ['history'] }) de super-scheduler/hooks l’expose sous forme d’état React.

Chaque notification comporte une cause : 'record', 'undo', 'redo', 'clear' ou 'revert'. Les libellés sont votre label, ou le libellé par défaut du type, en anglais, ou en espagnol quand la locale du planificateur commence par es. Passez labels pour les autres langues.

Événements contrôlés

Quand l’état React possède les événements (events plus onEventsChange), il existe deux façons d’appliquer l’annulation.

Avec la valeur par défaut apply: 'control', l’annulation modifie les événements du contrôle via control.events.*, et le résultat parvient à onEventsChange avec reason: 'history'. Si vous y adoptez déjà chaque modification, l’annulation fonctionne sans code supplémentaire.

Avec une fonction apply, l’historique vous transmet les opérations et vous mettez à jour votre état ; la nouvelle prop events parvient ensuite au contrôle. Cette approche convient aux stores, aux reducers et aux applications qui enregistrent chaque modification par un seul chemin de code.

src/ControlledUndo.tsxtsx
import { useCallback, useMemo, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'
import type { HistoryOp } from 'super-scheduler/history'

type EventData = SuperScheduler.EventData

/** Applies history operations to React state. They arrive in order (reversed for undo). */
function applyOps(
  events: EventData[],
  ops: readonly HistoryOp[],
  direction: 'undo' | 'redo',
): EventData[] {
  let next = events
  for (const op of ops) {
    const target = direction === 'undo' ? op.before : op.after
    const id = (target ?? op.before ?? op.after)?.id
    if (id === undefined) continue
    const index = next.findIndex((event) => event.id === id)
    if (target === null) next = next.filter((event) => event.id !== id)
    else if (index >= 0) next = next.map((event, i) => (i === index ? target : event))
    else next = [...next, target]
  }
  return next
}

export function ControlledUndo(props: {
  resources: SuperScheduler.ResourceData[]
  initial: EventData[]
}) {
  const [events, setEvents] = useState(props.initial)
  // The control adopts the array it receives and mutates it: give it a copy.
  const owned = useMemo(() => events.slice(), [events])

  const [history] = useState(() =>
    createHistory({
      record: ['move', 'resize'],
      // Undo and redo update React state; the new `events` prop then reaches the control.
      apply: (ops, direction) => setEvents((current) => applyOps(current, ops, direction)),
    }),
  )

  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => setEvents([...args.events]),
    [],
  )

  const onTimeRangeSelected = useCallback(
    (args: SuperScheduler.SchedulerTimeRangeSelectedArgs) => {
      if (args.origin !== 'drag') return
      args.control.clearSelection()
      const booking: EventData = {
        id: SuperScheduler.guid(),
        resource: args.resource,
        start: args.start,
        end: args.end,
        text: 'New booking',
      }
      setEvents((current) => [...current, booking])
      // A change made through state is a data load for the control, not a gesture:
      // record it explicitly so it can be undone.
      history.record({
        kind: 'create',
        label: 'new booking',
        ops: [{ before: null, after: booking }],
      })
    },
    [history],
  )

  return (
    <SuperSchedulerComponent
      history={history}
      events={owned}
      onEventsChange={onEventsChange}
      onTimeRangeSelected={onTimeRangeSelected}
      resources={props.resources}
      startDate="2026-10-01"
      days={31}
      scale="Day"
    />
  )
}

Chaque opération est de la forme { before, after }. before: null signifie que l’événement a été créé, after: null qu’il a été supprimé. Les opérations arrivent dans l’ordre où il faut les appliquer, déjà inversées pour l’annulation.

Regrouper des modifications et ajouter des commandes

history.batch(label, run) réunit en une seule entrée tout ce qui est enregistré pendant l’exécution de run. Les actions groupées s’annulent alors en une étape. Les lots peuvent s’imbriquer ; c’est le libellé du lot extérieur qui l’emporte.

history.push({ label, undo, redo }) ajoute une commande à vous, pour des modifications qui sortent des événements du planificateur : un jour gelé, un réglage de ressource, une dépendance entre événements. L’annulation et le rétablissement appellent vos fonctions.

src/BulkEditing.tsxtsx
import { useState } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'

export function BulkEditing(props: {
  resources: SuperScheduler.ResourceData[]
  initial: SuperScheduler.EventData[]
}) {
  const { controlRef, control } = useSchedulerControl()
  const [initial] = useState(() => props.initial.slice())
  const [frozenDays, setFrozenDays] = useState<ReadonlySet<string>>(new Set())
  // 'update' also records changes made through control.events.update().
  const [history] = useState(() => createHistory({ record: ['move', 'resize', 'update'] }))

  // One undo step for the whole operation.
  const shiftSelected = (days: number) => {
    if (control === null) return
    const selected = control.multiselect.get()
    history.batch(`shift ${selected.length} jobs`, () => {
      for (const e of selected) {
        // A new object: the stored one stays intact as the state undo restores.
        // (The wrapper setters e.start(...) edit the stored object in place.)
        control.events.update({
          ...e.data,
          start: e.start().addDays(days),
          end: e.end().addDays(days),
        })
      }
    })
  }

  // A change outside the scheduler's events, undone through the same history.
  const freezeDay = (day: string) => {
    const add = () => setFrozenDays((current) => new Set(current).add(day))
    const remove = () =>
      setFrozenDays((current) => new Set([...current].filter((item) => item !== day)))
    add()
    history.push({ label: `freeze ${day}`, undo: remove, redo: add })
  }

  return (
    <>
      <button type="button" onClick={() => shiftSelected(1)}>
        Move selection one day later
      </button>
      <button type="button" onClick={() => freezeDay('2026-10-12')}>
        Freeze 12 October
      </button>
      <p>Frozen days: {[...frozenDays].join(', ') || 'none'}</p>
      <SuperSchedulerComponent
        controlRef={controlRef}
        history={history}
        defaultEvents={initial}
        eventClickHandling="Select"
        resources={props.resources}
        startDate="2026-10-01"
        days={31}
        scale="Day"
      />
    </>
  )
}

Sélectionnez deux événements (un clic, puis Cmd+clic), appuyez sur « Move selection one day later », puis sur « Freeze 12 October ». La première annulation dégèle le jour ; la seconde remet les deux événements en place d’un seul coup.

Workflows confirmés par le serveur

La bibliothèque n’appelle jamais votre backend. Deux modèles couvrent la plupart des applications.

Optimiste. Laissez le déplacement se produire et s’enregistrer, sauvegardez-le dans onEventMoved et revenez en arrière si le serveur refuse. history.revert(eventId) rétablit l’événement dans l’état qui précédait sa dernière entrée et supprime cette entrée, si bien qu’une annulation ultérieure ne ressuscite pas la modification refusée.

src/OptimisticPlanner.tsxtsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'

export function OptimisticPlanner(props: {
  resources: SuperScheduler.ResourceData[]
  initial: SuperScheduler.EventData[]
}) {
  const [events, setEvents] = useState(props.initial)
  const owned = useMemo(() => events.slice(), [events])
  const [history] = useState(() => createHistory())

  const config = useMemo<SchedulerProps>(
    () => ({
      history,
      onEventsChange: ({ events: next }) => setEvents([...next]),
      // The move is already applied and recorded: save it, and roll back if the server refuses.
      onEventMoved: (args) => {
        const id = args.e.id()
        saveBooking({
          id: String(id),
          resource: String(args.newResource),
          start: args.newStart.value,
          end: args.newEnd.value,
        }).catch(() => {
          // Restores the event's previous state and drops that history entry.
          history.revert(id)
          args.control.message('The move could not be saved and was undone.')
        })
      },
    }),
    [history],
  )

  const undo = useCallback(() => history.undo(), [history])

  return (
    <>
      <button type="button" onClick={undo}>
        Undo
      </button>
      <SuperSchedulerComponent
        {...config}
        events={owned}
        resources={props.resources}
        startDate="2026-10-01"
        days={31}
        scale="Day"
      />
    </>
  )
}

Quand l’enregistrement échoue, l’événement revient à sa place et la barre de message en explique la raison.

Confirmer d’abord. Interrogez le serveur (ou l’utilisateur) avant que la modification ne s’applique : définissez args.async = true dans onEventMove, puis appelez args.loaded() pour accepter, ou args.preventDefault() puis args.loaded() pour refuser. L’historique n’enregistre le déplacement qu’une fois celui-ci accepté. Pour les flux où la modification confirmée diffère du geste (une boîte de dialogue qui modifie le résultat), définissez record: [] et ajoutez une commande avec push après la confirmation du serveur.

L’annulation et le rétablissement sont eux aussi des modifications : enregistrez-les de la même façon, soit dans votre fonction apply, soit quand onEventsChange signale reason: 'history'. Détecter qu’une autre personne a modifié l’événement entre-temps (versions, conflits) relève de votre backend.

Ce que l’historique ne fait pas

Planification de production audiovisuelleUn tournage déborde. Déplacez le montage qui en dépendait, comprenez pourquoi, puis revenez en arrière. Planification des ressources d’une agence créativeUne graphiste est surréservée. Confiez deux tâches à un collègue d’un seul glisser, vérifiez ce qu’elles débloquent, puis annulez.