SurveyJS Server Integration · PHP / Laravel III.5 · Edit one form together
You work in a private copy of the demo database, kept until it has been unused for 24 hours.

III.5

Edit one form together

You need this: When several people author the same form: a team building a large questionnaire, or reviewers editing alongside the author.

Several authors work in Survey Creator on the same form and see each other's changes, selection and cursors. On the client it is one plugin, CollaborationPlugin from survey-creator-core/collaboration: every edit becomes a small JSON record, and peers that apply the same records converge. Your server is a relay like the one in I.8, with one difference: it keeps each room's starting definition and an append-only log of records in arrival order, and sends both to a newcomer. Saving is unchanged: Creator's saveSurveyFunc stores the definition through III.1, and the next session starts from it.

This relay is app/Relay/EditRelay.php, run as its own process with php artisan relay:edit (port 8082).

This step on the Server Integration page Source on GitHub Commercial client licence: without SURVEYJS_LICENSE_KEY the component shows a licence banner

Try this

  1. Open a second window with the invite link: each window shows the other's selection and edits live.
  2. Make a few edits, then open a third window: it replays the saved definition plus the log and matches.
  3. Open the Collaboration menu above the tabs and choose Show Version History: it lists every change in the room, from the log the relay sent plus each record since.
  4. Press Save in either window: PUT /api/forms/support stores it (III.1). When every window closes, the room empties, and a new session starts from the saved definition.
Invite link

The link carries your sandbox id: whoever opens it works in your copy of the demo data, in the same rooms.

Code that just ran

These regions are read from the files that served this page, the same lines the Server Integration page shows.

Client · shared/client/edit-together.js · sjs:III.5.client View on GitHub
// Every edit becomes a small JSON record; the plugin also captures and draws presence
const collab = new CollaborationPlugin(creator, { roomId: formId, getInviteLink: inviteLink });
creator.addPlugin("collaboration", collab);
// The relay runs as its own process, so its URL comes from the page config (EDIT_RELAY_URL)
const ws = new WebSocket(relayUrl("edit", `/ws/forms/${formId}?name=${encodeURIComponent(userName)}`));
let ready = false;                                   // send nothing before init is applied
const sendPresence = () => ready && ws.send(JSON.stringify({ type: "presence", state: collab.getState() }));
ws.addEventListener("message", (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "init") {
    creator.JSON = msg.seed;                         // the saved definition (III.1)…
    if (msg.log.length) collab.apply(msg.log);       // …plus every edit since, in order
    ready = true;
    sendPresence();                                  // announce yourself to the others
  }
  if (msg.type === "record") collab.apply(msg.payload);
  if (msg.type === "presence-sync") collab.setPeers(msg.peers);
  if (msg.type === "presence") collab.upsertPeer(msg.peer);
  if (msg.type === "presence-leave") collab.removePeer(msg.clientId);
});
const sendRecord = (_, { record }) => ready && ws.send(JSON.stringify({ type: "append", payload: record }));
collab.onRecordAdded.add(sendRecord);
collab.onRecordChanged.add(sendRecord);              // a coalesced record re-sent as it grows
collab.onStateChanged.add(sendPresence);
Server · app/Relay/EditRelay.php · sjs:III.5.server View on GitHub
// The same kind of relay as I.8, keeping an append-only log instead of a value map:
// Creator records must be replayed in order. Run as its own process: php artisan relay:edit
private array $rooms = [];   // room key => EditRoom { seed, log, clients }

public function onConnect(TcpConnection $c, Request $request): void
{
    $user = $this->editor($request);                                     // may this user edit this form?
    $formId = $this->formId($request);
    $roomKey = $formId === null ? null : RoomKey::fromHandshake($request, $formId);
    if (! $user || $roomKey === null) {
        $c->close();

        return;
    }
    $room = $this->rooms[$roomKey] ??= new EditRoom($this->savedDefinition($formId));   // seed = saved definition (III.1)
    $room->cancelDrop();                                                  // back within 2 s: keep the room
    $c->context->roomKey = $roomKey;
    $c->context->id = bin2hex(random_bytes(8));
    $c->context->name = self::name($request->get('name'), $user->name);
    $c->context->slot = $room->freeSlot();
    $c->context->state = null;
    $room->clients[$c->id] = $c;
    $this->send($c, ['type' => 'init', 'clientId' => $c->context->id, 'colorIndex' => $c->context->slot,
        'color' => EditRoom::PALETTE[$c->context->slot], 'seed' => $room->seed, 'log' => $room->log]);   // init first
    if ($peers = $room->roster($c)) {
        $this->send($c, ['type' => 'presence-sync', 'peers' => $peers]);
    }
}

public function onMessage(TcpConnection $from, string $raw): void
{
    $msg = json_decode($raw);                                                // records stay opaque: {} is relayed as {}
    $room = $this->rooms[$from->context->roomKey ?? ''] ?? null;
    if (! $room || ! $msg instanceof stdClass) {
        return;
    }
    if (($msg->type ?? '') === 'append' && isset($msg->payload)) {          // append, then fan out
        $room->log[] = $msg->payload;
        $room->broadcast($from, ['type' => 'record', 'from' => $from->context->id, 'payload' => $msg->payload]);
    } elseif (($msg->type ?? '') === 'presence' && isset($msg->state) && strlen($raw) <= 4096) {
        $from->context->state = $msg->state;
        $room->broadcast($from, ['type' => 'presence', 'peer' => EditRoom::peer($from)]);
    }                                                                        // unknown types: ignore
}

public function onClose(TcpConnection $c): void
{
    $room = $this->rooms[$c->context->roomKey ?? ''] ?? null;
    if (! $room) {
        return;
    }
    unset($room->clients[$c->id]);
    $room->broadcast($c, ['type' => 'presence-leave', 'clientId' => $c->context->id]);
    if (count($room->clients) === 0) {                                      // the log lives until the room empties
        $room->dropTimer = Timer::add(2, fn () => count($room->clients) === 0 && $this->drop($c->context->roomKey), [], false);
    }
}

Definition: shared/definitions/creator-load-save.json