mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-26 03:25:08 +02:00
✨(collaboration) build the version history from the activity api
The version history has been dead since the migration. It listed S3 object
versions of the legacy `{pk}/file` key, and nothing writes that key any more,
so every document's list has been frozen at its migration date; restoring one
was a stub that closed the modal and did nothing, while still promising that
the document would be replaced.
It now reads the collaboration server, which is what keeps the history: the
list comes from `activity`, a selected version is previewed from `changeset`
as the document stood at that moment, and restoring one is a `rollback`.
A version is a minute of editing — changes less than a minute apart become
one, and none spans more than a minute. The collaboration server groups only
changes by the same author, so the browser merges what is left across authors:
a version is a moment in the document, not a moment in one person's editing.
Both are needed, and both use the same rule.
This grants `history.rollback` to editors, which is the first time a browser
may change the past rather than read it, and publishes the rollback route.
A reader is refused it twice over — the collaboration server treats it as a
dead grant without document write access, and the endpoint is withheld as well.
Mutations refuse where reads clamp, so a rollback reaching further back than
the history a user was granted is rejected rather than trimmed: nobody can
undo work that predates their own access, and a rollback with no bound at all
is refused outright. `prune`, which erases, stays granted to nobody. Restoring
is not destructive: it appends a change that undoes another, so what it
replaced stays in the history and can be restored again.
The backend's version endpoints are untouched and now have no caller. They are
marked deprecated with the condition for removing them, since until a document
has been replayed by `migrate_documents` they hold the only record of what it
looked like before it moved.
Also fixes the e2e helper that waited for the removed content endpoint, so it
never returned, and the three version tests that hung behind it.
Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
This commit is contained in:
@@ -4,16 +4,48 @@ import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
saveContent,
|
||||
reopenDoc,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
|
||||
/**
|
||||
* A version is at least a minute of editing: the collaboration server groups
|
||||
* changes that are less than a minute apart, and the panel merges what is left
|
||||
* across authors. That bound is the feature — a history of every few keystrokes
|
||||
* is not a history — but it does mean a test cannot produce two versions
|
||||
* without a minute of real time passing. The timestamps come from the server,
|
||||
* so no clock can be faked to shorten it.
|
||||
*
|
||||
* The tests below therefore assert one version wherever one is enough, and only
|
||||
* the restore test pays for a second one.
|
||||
*/
|
||||
const VERSION_GRANULARITY_MS = 60_000;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Version', () => {
|
||||
test('it displays an empty history', async ({ page, browserName }) => {
|
||||
// Stubbed, because a document with no history at all cannot be reached
|
||||
// through the interface: opening one writes to it, and that write is a
|
||||
// change like any other. Only a document nobody has ever opened has an
|
||||
// empty timeline.
|
||||
await page.route('**/collaboration/activity/**', (route) =>
|
||||
route.fulfill({ json: { activity: [] } }),
|
||||
);
|
||||
|
||||
await createDoc(page, 'doc-version-empty', browserName, 1);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||
|
||||
const modal = page.getByRole('dialog', { name: 'Version history' });
|
||||
await expect(modal.getByLabel('Version list')).toBeVisible();
|
||||
await expect(modal.getByText('No versions')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it displays the doc versions', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
@@ -24,20 +56,9 @@ test.describe('Doc Version', () => {
|
||||
|
||||
const modal = page.getByRole('dialog', { name: 'Version history' });
|
||||
const panel = modal.getByLabel('Version list');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(modal.getByText('No versions')).toBeVisible();
|
||||
|
||||
await modal.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
await saveContent(page, randomDoc);
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
|
||||
// Write more
|
||||
await writeInEditor({ page, text: 'It will create a version' });
|
||||
|
||||
const { suggestionMenu } = await openSuggestionMenu({ page });
|
||||
await suggestionMenu.getByText('Add a callout block').click();
|
||||
|
||||
@@ -47,21 +68,9 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await expect(calloutBlock).toBeVisible();
|
||||
|
||||
await saveContent(page, randomDoc);
|
||||
await reopenDoc(page, randomDoc);
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeHidden();
|
||||
await expect(page.getByText('It will create a version')).toBeVisible();
|
||||
|
||||
await expect(calloutBlock).toBeVisible();
|
||||
|
||||
// Write more
|
||||
await writeInEditor({ page, text: 'It will create a second version' });
|
||||
|
||||
await saveContent(page, randomDoc);
|
||||
|
||||
await expect(
|
||||
page.getByText('It will create a second version'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'History' }).click();
|
||||
@@ -69,34 +78,19 @@ test.describe('Doc Version', () => {
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('status')).toBeHidden();
|
||||
const items = panel.locator('.version-item');
|
||||
await expect(items).toHaveCount(2);
|
||||
await items.nth(1).click();
|
||||
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||
await expect(
|
||||
modal.locator('div[data-content-type="callout"]').first(),
|
||||
).toBeHidden();
|
||||
// One entry: opening the document, naming it and typing into it all
|
||||
// happened inside one minute, and a version is a minute of editing.
|
||||
const items = panel.locator('.version-item');
|
||||
await expect(items).toHaveCount(1);
|
||||
|
||||
await items.nth(0).click();
|
||||
|
||||
// the preview renders the document as it stood at the end of that version
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeVisible();
|
||||
await expect(
|
||||
modal.locator('div[data-content-type="callout"]').first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
modal.getByText('It will create a second version'),
|
||||
).toBeHidden();
|
||||
|
||||
await items.nth(1).click();
|
||||
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||
await expect(
|
||||
modal.locator('div[data-content-type="callout"]').first(),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('it does not display the doc versions if not allowed', async ({
|
||||
@@ -118,6 +112,11 @@ test.describe('Doc Version', () => {
|
||||
});
|
||||
|
||||
test('it restores the doc version', async ({ page, browserName }) => {
|
||||
// The wait below is the whole reason for this budget, and it is not
|
||||
// padding: two versions cannot exist any closer together. See the note at
|
||||
// the top of the file before trying to make this faster.
|
||||
test.setTimeout(VERSION_GRANULARITY_MS + 120_000);
|
||||
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
@@ -132,32 +131,35 @@ test.describe('Doc Version', () => {
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
await expect(thread).toBeHidden();
|
||||
|
||||
await saveContent(page, randomDoc);
|
||||
|
||||
await reopenDoc(page, randomDoc);
|
||||
await expect(editor.getByText('Hello')).toBeVisible();
|
||||
|
||||
// Let the first version close before writing the text that has to end up
|
||||
// in a second one — a minute of silence is what separates them.
|
||||
await page.waitForTimeout(VERSION_GRANULARITY_MS + 1_000);
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('World');
|
||||
|
||||
await saveContent(page, randomDoc);
|
||||
await reopenDoc(page, randomDoc);
|
||||
|
||||
await expect(page.getByText('World')).toBeVisible();
|
||||
|
||||
await editor.getByText('Hello').click();
|
||||
await thread.getByText('This is a comment').first().hover();
|
||||
await thread.locator('[data-test="resolve"]').click();
|
||||
await expect(thread).toBeHidden();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'History' }).click();
|
||||
|
||||
const modal = page.getByRole('dialog', { name: 'Version history' });
|
||||
const panel = modal.getByLabel('Version list');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
await panel.locator('.version-item').first().click();
|
||||
|
||||
// newest first: the second item is the version that predates 'World'
|
||||
const items = panel.locator('.version-item');
|
||||
await expect(items).toHaveCount(2);
|
||||
await items.nth(1).click();
|
||||
|
||||
await expect(modal.getByText('Hello')).toBeVisible();
|
||||
await expect(modal.getByText('World')).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
@@ -169,23 +171,21 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await page.getByLabel('Restore', { exact: true }).click();
|
||||
|
||||
// The collaboration server applies the rollback and pushes it back over the
|
||||
// connection this editor is already holding — nothing is reloaded here.
|
||||
const mainEditor = page.getByLabel('Document editor');
|
||||
|
||||
await expect(mainEditor.getByText('Hello')).toBeVisible();
|
||||
await expect(mainEditor.getByText('World')).toBeHidden();
|
||||
|
||||
// The old comment is not restored
|
||||
await expect(mainEditor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
|
||||
// We can add a new comment
|
||||
await mainEditor.getByText('Hello').selectText();
|
||||
await page.getByRole('button', { name: 'Add comment' }).click();
|
||||
|
||||
await thread.getByRole('paragraph').first().fill('This is a comment');
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
// The comment survives, and that is the point: a restore undoes the changes
|
||||
// made after the chosen version rather than replacing the document with an
|
||||
// old copy of it. This comment belongs to the version being restored, so
|
||||
// nothing about it was undone.
|
||||
await expect(mainEditor.getByText('Hello')).toHaveClass('bn-thread-mark');
|
||||
|
||||
// and the document is still live afterwards
|
||||
await mainEditor.getByText('Hello').click();
|
||||
await expect(thread.getByText('This is a comment').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
import { Locator, Page, expect, test } from '@playwright/test';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
import { createDoc, mockedDocument, saveContent } from './utils-common';
|
||||
import { createDoc, mockedDocument, reopenDoc } from './utils-common';
|
||||
import {
|
||||
openSuggestionMenu,
|
||||
tryFocusEditorContent,
|
||||
@@ -436,9 +436,10 @@ test.describe('Presenter Mode', () => {
|
||||
await writeMultiSlideDoc(page);
|
||||
const docId = getDocIdFromUrl(page);
|
||||
|
||||
// Ensure the typed content is persisted (awaits the PATCH /content/) before
|
||||
// reloading the page through the deep-link, instead of using a fixed sleep.
|
||||
await saveContent(page, docTitle);
|
||||
// Leave and come back before reloading through the deep-link, so what the
|
||||
// deep-link opens is what the collaboration server kept, not what this tab
|
||||
// still had in memory.
|
||||
await reopenDoc(page, docTitle);
|
||||
await page.goto(`/docs/${docId}/?view=present&slide=3`);
|
||||
|
||||
const overlay = page.getByRole('dialog', { name: 'Presenter mode' });
|
||||
|
||||
@@ -279,27 +279,21 @@ export const waitForResponseCreateDoc = (page: Page) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigates back to the homepage, waits for the PATCH /content/ request
|
||||
* triggered by the route change to complete, then navigates back to the doc.
|
||||
* Leaves the doc and comes back to it, so that what follows reads the document
|
||||
* from the collaboration server rather than from the editor that just wrote it.
|
||||
*
|
||||
* Use this instead of goToGridDoc when the test must assert on content that
|
||||
* was just written in the editor, to avoid a race condition where the GET
|
||||
* request fired on doc mount returns stale data because the server has not
|
||||
* yet processed the PATCH.
|
||||
* There is nothing to save: the collaboration server receives every change as
|
||||
* it is typed, and the backend holds no copy of the content to be pushed to.
|
||||
* (This used to wait for a `PATCH /content/`, which stopped existing with the
|
||||
* migration — and so waited forever.) What makes the round trip meaningful is
|
||||
* therefore the assertion that follows it: content still on the page after
|
||||
* leaving and returning is content the server kept.
|
||||
*/
|
||||
export const saveContent = async (page: Page, title: string) => {
|
||||
const savePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/content/') &&
|
||||
response.request().method() === 'PATCH',
|
||||
);
|
||||
|
||||
export const reopenDoc = async (page: Page, title: string) => {
|
||||
await page.getByRole('button', { name: 'Back to homepage' }).click();
|
||||
await expect(page.getByTestId('docs-grid')).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
await savePromise;
|
||||
|
||||
await goToGridDoc(page, { title });
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { APIError } from './APIError';
|
||||
|
||||
/**
|
||||
* Where the collaboration server lives and which org its rooms are under, as
|
||||
* `collaborationHttpTarget` derives it from the websocket url.
|
||||
*/
|
||||
export interface CollaborationTarget {
|
||||
serverUrl: string;
|
||||
org: string;
|
||||
}
|
||||
|
||||
export type CollaborationQuery = Record<
|
||||
string,
|
||||
string | number | boolean | undefined
|
||||
>;
|
||||
|
||||
interface FetchCollaborationInit extends Omit<RequestInit, 'body'> {
|
||||
query?: CollaborationQuery;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The collaboration server's error bodies are `{ error, code, ... }`, which is
|
||||
* not the shape `errorCauses` reads (it flattens DRF's `{ field: [...] }` and
|
||||
* would hand back the letters of a string). `code` is the part worth keeping:
|
||||
* `doc-deleted` is the only way to tell a deleted document from one that was
|
||||
* never written, since a docid nobody has ever saved answers 200 with an empty
|
||||
* document.
|
||||
*/
|
||||
interface CollaborationErrorBody {
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
const collaborationError = async (response: Response, message: string) => {
|
||||
let body: CollaborationErrorBody = {};
|
||||
|
||||
try {
|
||||
body = (await response.json()) as CollaborationErrorBody;
|
||||
} catch {
|
||||
// an error with no json body — a proxy's 502 page, an aborted response
|
||||
}
|
||||
|
||||
return new APIError(message, {
|
||||
status: response.status,
|
||||
cause: [body.error ?? response.statusText],
|
||||
data: { code: body.code },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Call one of the collaboration server's REST endpoints for a document.
|
||||
*
|
||||
* Deliberately not `fetchAPI`: that one prefixes the Django api url and attaches
|
||||
* the CSRF token, neither of which applies here. What this shares with it is the
|
||||
* credential — the session cookie, exactly as on the websocket upgrade and on
|
||||
* the http fallback's polling (see `useProviderStore`).
|
||||
*
|
||||
* `Accept: application/json` is not optional. The collaboration server
|
||||
* negotiates on that header alone and otherwise answers `application/x-lib0any`,
|
||||
* which would need a decoder; in the json path it base64-encodes binary fields
|
||||
* such as `ydoc`.
|
||||
*
|
||||
* The url is `{serverUrl}/{endpoint}/v1/{org}/{docId}`, the same shape the http
|
||||
* fallback polls for `ydoc`.
|
||||
*/
|
||||
export const fetchCollaborationAPI = async <T>(
|
||||
target: CollaborationTarget,
|
||||
endpoint: string,
|
||||
docId: string,
|
||||
{ query, body, headers, ...init }: FetchCollaborationInit = {},
|
||||
): Promise<T> => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query ?? {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
const search = params.toString();
|
||||
|
||||
const response = await fetch(
|
||||
`${target.serverUrl}/${endpoint}/v1/${target.org}/${docId}${search ? `?${search}` : ''}`,
|
||||
{
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body !== undefined && { 'Content-Type': 'application/json' }),
|
||||
...headers,
|
||||
},
|
||||
...(body !== undefined && { body: JSON.stringify(body) }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw await collaborationError(
|
||||
response,
|
||||
`Failed to reach the collaboration server (${endpoint})`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './APIError';
|
||||
export * from './config';
|
||||
export * from './fetchApi';
|
||||
export * from './fetchCollaborationApi';
|
||||
export * from './helpers';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { CollaborationTarget } from '@/api';
|
||||
|
||||
import { useConfig } from '../api';
|
||||
|
||||
export const useCollaborationUrl = (room?: string) => {
|
||||
/**
|
||||
* Where the collaboration server's rooms live, independent of which document is
|
||||
* being opened. Kept apart so the two hooks below cannot answer differently.
|
||||
*/
|
||||
const useCollaborationBaseUrl = () => {
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The room is appended to the base URL by the provider (y-websocket)
|
||||
return (
|
||||
conf?.COLLABORATION_WS_URL ||
|
||||
(typeof window !== 'undefined'
|
||||
@@ -16,6 +17,17 @@ export const useCollaborationUrl = (room?: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const useCollaborationUrl = (room?: string) => {
|
||||
const baseUrl = useCollaborationBaseUrl();
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The room is appended to the base URL by the provider (y-websocket)
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* y/hub serves the same rooms over two transports, mounted side by side under one prefix:
|
||||
* `{prefix}/ws/v1/{org}/{docid}` for the websocket and `{prefix}/ydoc/v1/{org}/{docid}` over
|
||||
@@ -41,3 +53,19 @@ export const collaborationHttpTarget = (wsUrl: string) => {
|
||||
org,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The collaboration server's http address, for the routes that are plain REST
|
||||
* rather than a transport: the editing history (`activity`, `changeset`) and
|
||||
* the restore it feeds (`rollback`).
|
||||
*
|
||||
* `undefined` while the configuration is still loading, and for an instance
|
||||
* whose collaboration url is not shaped like a room url — the same answer, and
|
||||
* the same reason, as the http fallback's: no address is better than one nobody
|
||||
* serves.
|
||||
*/
|
||||
export const useCollaborationTarget = (): CollaborationTarget | undefined => {
|
||||
const baseUrl = useCollaborationBaseUrl();
|
||||
|
||||
return baseUrl ? collaborationHttpTarget(baseUrl) : undefined;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ActivityEntry } from '../types';
|
||||
import { VERSION_GRANULARITY_MS, mergeActivityEntries } from '../utils';
|
||||
|
||||
/**
|
||||
* `mergeActivityEntries` is the only part of the history policy that lives in
|
||||
* the client, and it decides what a "version" is. The cases below are the ones
|
||||
* it can plausibly get wrong: the two bounds, each of which is a strict
|
||||
* comparison, and the author test that this deliberately does *not* apply.
|
||||
*/
|
||||
const entry = (from: number, to: number, by: string | null): ActivityEntry => ({
|
||||
from,
|
||||
to,
|
||||
by,
|
||||
});
|
||||
|
||||
const G = VERSION_GRANULARITY_MS;
|
||||
|
||||
describe('mergeActivityEntries', () => {
|
||||
it('has nothing to say about an empty timeline', () => {
|
||||
expect(mergeActivityEntries([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('merges changes closer together than the granularity', () => {
|
||||
const versions = mergeActivityEntries([
|
||||
entry(0, 0, 'alice'),
|
||||
entry(1_000, 1_000, 'alice'),
|
||||
]);
|
||||
|
||||
expect(versions).toEqual([
|
||||
{ id: '1000', from: 0, to: 1_000, by: ['alice'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges across authors, which the server will not do', () => {
|
||||
// the reason this function exists: the collaboration server breaks a run
|
||||
// wherever the author changes, and a version is a moment in the document
|
||||
// rather than a moment in one person's editing
|
||||
const versions = mergeActivityEntries([
|
||||
entry(0, 0, 'alice'),
|
||||
entry(1_000, 1_000, 'bob'),
|
||||
entry(2_000, 2_000, 'alice'),
|
||||
]);
|
||||
|
||||
expect(versions).toHaveLength(1);
|
||||
expect(versions[0]).toMatchObject({ from: 0, to: 2_000 });
|
||||
expect(versions[0].by.sort()).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
it('starts a new version after a gap of exactly the granularity', () => {
|
||||
// `<`, not `<=` — the same comparison the server makes, so the two halves
|
||||
// of the grouping cannot disagree at the boundary
|
||||
expect(
|
||||
mergeActivityEntries([entry(0, 0, 'a'), entry(G, G, 'a')]),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
mergeActivityEntries([entry(0, 0, 'a'), entry(G - 1, G - 1, 'a')]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never lets one version span more than the granularity', () => {
|
||||
// unbroken typing: every change is a millisecond after the last, so no gap
|
||||
// ever ends a version and only the span bound can
|
||||
const timeline = Array.from({ length: 5 }, (_, i) =>
|
||||
entry(i * (G / 2), i * (G / 2), 'alice'),
|
||||
);
|
||||
|
||||
const versions = mergeActivityEntries(timeline);
|
||||
|
||||
expect(versions.length).toBeGreaterThan(1);
|
||||
versions.forEach((version) => {
|
||||
expect(version.to - version.from).toBeLessThan(G);
|
||||
});
|
||||
});
|
||||
|
||||
it('identifies a version by the moment it ends', () => {
|
||||
// that id is what the preview and the restore are asked for, so it has to
|
||||
// survive merging rather than being the first entry's timestamp
|
||||
const [version] = mergeActivityEntries([
|
||||
entry(0, 0, 'alice'),
|
||||
entry(500, 900, 'alice'),
|
||||
]);
|
||||
|
||||
expect(version.id).toBe('900');
|
||||
expect(version.to).toBe(900);
|
||||
});
|
||||
|
||||
it('keeps an unattributed change without inventing an author', () => {
|
||||
const [version] = mergeActivityEntries([entry(0, 0, null)]);
|
||||
|
||||
expect(version.by).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './useDocVersions';
|
||||
export * from './useDocActivity';
|
||||
export * from './useDocVersion';
|
||||
export * from './useRestoreDocVersion';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
CollaborationTarget,
|
||||
UseQueryOptionsAPI,
|
||||
fetchCollaborationAPI,
|
||||
} from '@/api';
|
||||
import { useCollaborationTarget } from '@/core/config/hooks/useCollaborationUrl';
|
||||
|
||||
import { APIActivity, DocVersion } from '../types';
|
||||
import { VERSION_GRANULARITY_MS, mergeActivityEntries } from '../utils';
|
||||
|
||||
export type DocActivityParam = {
|
||||
docId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The whole timeline in one request, deliberately.
|
||||
*
|
||||
* `activity` takes a `limit` but no cursor, and the server groups the entire
|
||||
* filtered history before applying it — so paging costs a full regrouping per
|
||||
* page and saves only the response body, which is three numbers per version.
|
||||
* Responses are cached briefly and keyed on every parameter, so asking the same
|
||||
* question every time is worth more than asking a smaller one.
|
||||
*
|
||||
* What comes back is already bounded to what this user may see: the history
|
||||
* starts at the moment they were given access to the document, and the server
|
||||
* applies that silently rather than refusing the request.
|
||||
*/
|
||||
const getDocActivity = async (
|
||||
target: CollaborationTarget,
|
||||
{ docId }: DocActivityParam,
|
||||
): Promise<DocVersion[]> => {
|
||||
const { activity } = await fetchCollaborationAPI<APIActivity>(
|
||||
target,
|
||||
'activity',
|
||||
docId,
|
||||
{
|
||||
query: {
|
||||
group: true,
|
||||
groupMaxGap: VERSION_GRANULARITY_MS,
|
||||
groupMaxDuration: VERSION_GRANULARITY_MS,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ascending from the server, newest first for the panel
|
||||
return mergeActivityEntries(activity).reverse();
|
||||
};
|
||||
|
||||
export const KEY_DOC_ACTIVITY = 'doc-activity';
|
||||
|
||||
export function useDocActivity(
|
||||
params: DocActivityParam,
|
||||
queryConfig?: Omit<UseQueryOptionsAPI<DocVersion[]>, 'queryKey' | 'queryFn'>,
|
||||
) {
|
||||
const target = useCollaborationTarget();
|
||||
|
||||
return useQuery<DocVersion[], APIError, DocVersion[]>({
|
||||
// `target` belongs in the key: it arrives with the configuration, so a
|
||||
// query started before it resolved must not be reused after
|
||||
queryKey: [KEY_DOC_ACTIVITY, params, target],
|
||||
queryFn: () => getDocActivity(target as CollaborationTarget, params),
|
||||
enabled: !!target,
|
||||
/**
|
||||
* Against the application's three-minute default, which is wrong for this
|
||||
* one: the timeline grows while the panel is closed, and opening it is a
|
||||
* deliberate request to see the history *now*. Left at the default, a user
|
||||
* who edits and reopens the panel is shown the list as it was when they
|
||||
* last looked at it.
|
||||
*
|
||||
* There is little to save by holding it: the collaboration server caches
|
||||
* its own answer for a few seconds, so a reopen inside that window costs a
|
||||
* round trip and no computation.
|
||||
*/
|
||||
staleTime: 0,
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -1,39 +1,53 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, UseQueryOptionsAPI, errorCauses, fetchAPI } from '@/api';
|
||||
import {
|
||||
APIError,
|
||||
CollaborationTarget,
|
||||
UseQueryOptionsAPI,
|
||||
fetchCollaborationAPI,
|
||||
} from '@/api';
|
||||
import { useCollaborationTarget } from '@/core/config/hooks/useCollaborationUrl';
|
||||
|
||||
import { Version } from '../types';
|
||||
import { APIChangeset, DocVersion } from '../types';
|
||||
|
||||
export type DocVersionParam = {
|
||||
docId: string;
|
||||
versionId: string;
|
||||
versionId: DocVersion['id'];
|
||||
};
|
||||
|
||||
const getDocVersion = async ({
|
||||
versionId,
|
||||
docId,
|
||||
}: DocVersionParam): Promise<Version> => {
|
||||
const response = await fetchAPI(`documents/${docId}/versions/${versionId}/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc version',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<Version>;
|
||||
};
|
||||
/**
|
||||
* The document as it stood at the end of a version.
|
||||
*
|
||||
* `changeset` renders it from a time-zero baseline, so this is the whole
|
||||
* document at that moment and not a diff — which is what the preview needs, and
|
||||
* what makes it readable with the same base64 decoding as any other snapshot.
|
||||
*
|
||||
* `activity?ydoc=true` would answer with one document at the newest entry plus a
|
||||
* projection per entry, which is the right shape for annotating a timeline and
|
||||
* the wrong one for showing a single point in it.
|
||||
*/
|
||||
const getDocVersion = async (
|
||||
target: CollaborationTarget,
|
||||
{ docId, versionId }: DocVersionParam,
|
||||
): Promise<APIChangeset> =>
|
||||
fetchCollaborationAPI<APIChangeset>(target, 'changeset', docId, {
|
||||
query: { to: versionId, ydoc: true },
|
||||
});
|
||||
|
||||
export const KEY_DOC_VERSION = 'doc-version';
|
||||
|
||||
export function useDocVersion(
|
||||
params: DocVersionParam,
|
||||
queryConfig?: UseQueryOptionsAPI<Version>,
|
||||
queryConfig?: Omit<UseQueryOptionsAPI<APIChangeset>, 'queryKey' | 'queryFn'>,
|
||||
) {
|
||||
return useQuery<Version, APIError, Version>({
|
||||
queryKey: [KEY_DOC_VERSION, params],
|
||||
queryFn: () => getDocVersion(params),
|
||||
const target = useCollaborationTarget();
|
||||
|
||||
return useQuery<APIChangeset, APIError, APIChangeset>({
|
||||
// `target` belongs in the key: it arrives with the configuration, so a
|
||||
// query started before it resolved must not be reused after
|
||||
queryKey: [KEY_DOC_VERSION, params, target],
|
||||
queryFn: () => getDocVersion(target as CollaborationTarget, params),
|
||||
enabled: !!target,
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
DefinedInitialDataInfiniteOptionsAPI,
|
||||
UseQueryOptionsAPI,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
} from '@/api';
|
||||
|
||||
import { APIListVersions } from '../types';
|
||||
|
||||
export type DocVersionsParam = {
|
||||
docId: string;
|
||||
};
|
||||
|
||||
export type DocVersionsAPIParams = DocVersionsParam & {
|
||||
versionId: string;
|
||||
};
|
||||
|
||||
type VersionsResponse = APIListVersions;
|
||||
|
||||
const getDocVersions = async ({
|
||||
versionId,
|
||||
docId,
|
||||
}: DocVersionsAPIParams): Promise<VersionsResponse> => {
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/versions/?version_id=${versionId}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc versions',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<VersionsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_DOC_VERSIONS = 'doc-versions';
|
||||
|
||||
export function useDocVersions(
|
||||
params: DocVersionsAPIParams,
|
||||
queryConfig?: UseQueryOptionsAPI<VersionsResponse>,
|
||||
) {
|
||||
return useQuery<VersionsResponse, APIError, VersionsResponse>({
|
||||
queryKey: [KEY_LIST_DOC_VERSIONS, params],
|
||||
queryFn: () => getDocVersions(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDocVersionsInfiniteQuery(
|
||||
param: DocVersionsParam,
|
||||
queryConfig?: DefinedInitialDataInfiniteOptionsAPI<VersionsResponse, string>,
|
||||
) {
|
||||
return useInfiniteQuery<
|
||||
VersionsResponse,
|
||||
APIError,
|
||||
InfiniteData<VersionsResponse>,
|
||||
QueryKey,
|
||||
string
|
||||
>({
|
||||
initialPageParam: '',
|
||||
queryKey: [KEY_LIST_DOC_VERSIONS, param],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getDocVersions({
|
||||
...param,
|
||||
versionId: pageParam,
|
||||
}),
|
||||
getNextPageParam(lastPage) {
|
||||
return lastPage.next_version_id_marker || undefined;
|
||||
},
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, CollaborationTarget, fetchCollaborationAPI } from '@/api';
|
||||
import { useCollaborationTarget } from '@/core/config/hooks/useCollaborationUrl';
|
||||
|
||||
import { DocVersion } from '../types';
|
||||
|
||||
import { KEY_DOC_ACTIVITY } from './useDocActivity';
|
||||
|
||||
export type RestoreDocVersionParam = {
|
||||
docId: string;
|
||||
versionId: DocVersion['id'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Put the document back as it was at the end of a version, by asking the
|
||||
* collaboration server to undo everything that happened after it.
|
||||
*
|
||||
* The rollback is applied where the document lives rather than in this tab: the
|
||||
* server appends the undoing change to the room, so every open editor receives
|
||||
* it over its connection the way it receives any other change. A client-side
|
||||
* undo would have had to be applied here and pushed, which is the same thing
|
||||
* with a race in it.
|
||||
*
|
||||
* `from` is one millisecond past the end of the chosen version, so the version
|
||||
* itself survives and everything after it is undone. It is also what keeps this
|
||||
* inside what the user may touch: unlike a read, a rollback is refused rather
|
||||
* than trimmed if it reaches further back than the history they were granted —
|
||||
* and every moment they can name here is one the server showed them.
|
||||
*
|
||||
* Nothing is lost. The undo is a change like any other, so the state it replaced
|
||||
* stays in the history and can be restored again from the same panel.
|
||||
*/
|
||||
const restoreDocVersion = async (
|
||||
target: CollaborationTarget,
|
||||
{ docId, versionId }: RestoreDocVersionParam,
|
||||
) =>
|
||||
fetchCollaborationAPI(target, 'rollback', docId, {
|
||||
method: 'POST',
|
||||
body: { from: Number(versionId) + 1 },
|
||||
});
|
||||
|
||||
export function useRestoreDocVersion({
|
||||
onSuccess,
|
||||
}: { onSuccess?: () => void } = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
const target = useCollaborationTarget();
|
||||
|
||||
return useMutation<void, APIError, RestoreDocVersionParam>({
|
||||
mutationFn: async (params) => {
|
||||
await restoreDocVersion(target as CollaborationTarget, params);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: [KEY_DOC_ACTIVITY] });
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
+11
-6
@@ -9,13 +9,13 @@ import { DocEditorContainer } from '@/docs/doc-editor/components/DocEditor';
|
||||
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
|
||||
|
||||
import { useDocVersion } from '../api/useDocVersion';
|
||||
import { Versions } from '../types';
|
||||
import { DocVersion } from '../types';
|
||||
|
||||
import { DocVersionHeader } from './DocVersionHeader';
|
||||
|
||||
interface DocVersionEditorProps {
|
||||
docId: Doc['id'];
|
||||
versionId: Versions['version_id'];
|
||||
versionId: DocVersion['id'];
|
||||
}
|
||||
|
||||
export const DocVersionEditor = ({
|
||||
@@ -41,12 +41,12 @@ export const DocVersionEditor = ({
|
||||
}, [versionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!version?.content || isLoading || initialContent) {
|
||||
if (!version?.ydoc || isLoading || initialContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInitialContent(base64ToBlocknoteXmlFragment(version.content));
|
||||
}, [versionId, version?.content, isLoading, initialContent]);
|
||||
setInitialContent(base64ToBlocknoteXmlFragment(version.ydoc));
|
||||
}, [versionId, version?.ydoc, isLoading, initialContent]);
|
||||
|
||||
if (isError && error) {
|
||||
if (error.status === 404) {
|
||||
@@ -90,7 +90,12 @@ export const DocVersionEditor = ({
|
||||
>
|
||||
<BlockNoteReader
|
||||
initialContent={initialContent}
|
||||
docId={version.id}
|
||||
/**
|
||||
* The version, not the document: this identifies the thread store the
|
||||
* preview reads, and a read-only view of an older state has no business
|
||||
* sharing the live one.
|
||||
*/
|
||||
docId={versionId}
|
||||
isMainEditor={false}
|
||||
/>
|
||||
</DocEditorContainer>
|
||||
|
||||
+25
-41
@@ -1,12 +1,18 @@
|
||||
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/ui-components';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/ui-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Doc } from '@/docs/doc-management/';
|
||||
|
||||
import { useDocVersion } from '../api';
|
||||
import { Versions } from '../types';
|
||||
import { useRestoreDocVersion } from '../api';
|
||||
import { DocVersion } from '../types';
|
||||
|
||||
const ModalStyle = createGlobalStyle`
|
||||
.c__modal__title {
|
||||
@@ -18,46 +24,29 @@ interface ModalConfirmationVersionProps {
|
||||
docId: Doc['id'];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
versionId: Versions['version_id'];
|
||||
versionId: DocVersion['id'];
|
||||
}
|
||||
|
||||
export const ModalConfirmationVersion = ({
|
||||
onClose,
|
||||
onSuccess: __onSuccess,
|
||||
onSuccess,
|
||||
docId,
|
||||
versionId,
|
||||
}: ModalConfirmationVersionProps) => {
|
||||
const { data: version } = useDocVersion({
|
||||
docId,
|
||||
versionId,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
|
||||
// TODO(yhub) : Revert the doc to a previous state using Y.js / Yhub
|
||||
// const { mutate: updateDocContent } = useDocContentUpdate({
|
||||
// listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
// onSuccess: () => {
|
||||
// const onDisplaySuccess = () => {
|
||||
// toast(t('Version restored successfully'), VariantType.SUCCESS);
|
||||
// onSuccess();
|
||||
// };
|
||||
|
||||
// if (!provider || !version?.content) {
|
||||
// onDisplaySuccess();
|
||||
// return;
|
||||
// }
|
||||
|
||||
// revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content));
|
||||
|
||||
// threadStore?.refreshThreads();
|
||||
|
||||
// onDisplaySuccess();
|
||||
// },
|
||||
// });
|
||||
|
||||
if (!version) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* The collaboration server undoes everything after this version and hands the
|
||||
* result to every open editor, this one included — so there is nothing to
|
||||
* apply here and nothing to reload.
|
||||
*/
|
||||
const { mutate: restoreVersion, isPending } = useRestoreDocVersion({
|
||||
onSuccess: () => {
|
||||
toast(t('Version restored successfully'), VariantType.SUCCESS);
|
||||
onSuccess();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -80,13 +69,8 @@ export const ModalConfirmationVersion = ({
|
||||
aria-label={t('Restore')}
|
||||
color="error"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
if (!version?.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
}}
|
||||
disabled={isPending}
|
||||
onClick={() => restoreVersion({ docId, versionId })}
|
||||
>
|
||||
{t('Restore')}
|
||||
</Button>
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import { createGlobalStyle, css } from 'styled-components';
|
||||
import { Box, ButtonCloseModal, Text } from '@/components';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
|
||||
import { Versions } from '../types';
|
||||
import { DocVersion } from '../types';
|
||||
|
||||
import { DocVersionEditor } from './DocVersionEditor';
|
||||
import { VersionList } from './VersionList';
|
||||
@@ -48,7 +48,7 @@ export const ModalSelectVersion = ({
|
||||
}: ModalSelectVersionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedVersionId, setSelectedVersionId] =
|
||||
useState<Versions['version_id']>();
|
||||
useState<DocVersion['id']>();
|
||||
const canRestore = doc.abilities.partial_update;
|
||||
const restoreModal = useModal();
|
||||
|
||||
|
||||
+29
-36
@@ -3,21 +3,27 @@ import { DateTime } from 'luxon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { Box, Icon, InfiniteScroll, Text, TextErrors } from '@/components';
|
||||
import { Box, Icon, Text, TextErrors } from '@/components';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { useDate } from '@/hooks';
|
||||
|
||||
import { useDocVersionsInfiniteQuery } from '../api/useDocVersions';
|
||||
import { Versions } from '../types';
|
||||
import { useDocActivity } from '../api/useDocActivity';
|
||||
import { DocVersion } from '../types';
|
||||
|
||||
import { VersionItem } from './VersionItem';
|
||||
|
||||
/**
|
||||
* The timeline's timestamps are unix milliseconds; every date helper here reads
|
||||
* ISO strings.
|
||||
*/
|
||||
const toISO = (timestamp: number) => new Date(timestamp).toISOString();
|
||||
|
||||
interface VersionListStateProps {
|
||||
isLoading: boolean;
|
||||
error: APIError<unknown> | null;
|
||||
versions?: Versions[];
|
||||
selectedVersionId?: Versions['version_id'];
|
||||
onSelectVersion?: (versionId: Versions['version_id']) => void;
|
||||
versions?: DocVersion[];
|
||||
selectedVersionId?: DocVersion['id'];
|
||||
onSelectVersion?: (versionId: DocVersion['id']) => void;
|
||||
}
|
||||
|
||||
const VersionListState = ({
|
||||
@@ -41,16 +47,16 @@ const VersionListState = ({
|
||||
<Box $gap="xxs" $padding="xs">
|
||||
{versions?.map((version) => {
|
||||
const formattedDate = formatDateSpecial(
|
||||
version.last_modified,
|
||||
toISO(version.to),
|
||||
'dd MMMM · HH:mm',
|
||||
);
|
||||
const isSelected = version.version_id === selectedVersionId;
|
||||
const isSelected = version.id === selectedVersionId;
|
||||
return (
|
||||
<Box as="li" key={version.version_id} $css="list-style: none;">
|
||||
<Box as="li" key={version.id} $css="list-style: none;">
|
||||
<VersionItem
|
||||
text={formattedDate}
|
||||
isActive={isSelected}
|
||||
onSelect={() => onSelectVersion?.(version.version_id)}
|
||||
onSelect={() => onSelectVersion?.(version.id)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
@@ -76,8 +82,8 @@ const VersionListState = ({
|
||||
|
||||
interface VersionListProps {
|
||||
doc: Doc;
|
||||
onSelectVersion?: (versionId: Versions['version_id']) => void;
|
||||
selectedVersionId?: Versions['version_id'];
|
||||
onSelectVersion?: (versionId: DocVersion['id']) => void;
|
||||
selectedVersionId?: DocVersion['id'];
|
||||
}
|
||||
|
||||
export const VersionList = ({
|
||||
@@ -88,25 +94,22 @@ export const VersionList = ({
|
||||
const { t } = useTranslation();
|
||||
const { formatDate } = useDate();
|
||||
|
||||
/**
|
||||
* The whole list arrives at once — the collaboration server bounds it to the
|
||||
* history this user may see, and a version is at least a minute of editing —
|
||||
* so there is nothing to page through.
|
||||
*/
|
||||
const {
|
||||
data,
|
||||
data: versions,
|
||||
error,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useDocVersionsInfiniteQuery({
|
||||
docId: doc.id,
|
||||
});
|
||||
} = useDocActivity({ docId: doc.id });
|
||||
|
||||
const versions = data?.pages.reduce((acc, page) => {
|
||||
return acc.concat(page.versions);
|
||||
}, [] as Versions[]);
|
||||
const selectedVersion = versions?.find(
|
||||
(version) => version.version_id === selectedVersionId,
|
||||
(version) => version.id === selectedVersionId,
|
||||
);
|
||||
const selectedVersionDate = selectedVersion
|
||||
? formatDate(selectedVersion.last_modified, DateTime.DATETIME_MED)
|
||||
? formatDate(toISO(selectedVersion.to), DateTime.DATETIME_MED)
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -114,17 +117,7 @@ export const VersionList = ({
|
||||
$css="overflow-y: auto; overflow-x: hidden;"
|
||||
className="--docs--version-list"
|
||||
>
|
||||
<InfiniteScroll
|
||||
hasMore={hasNextPage}
|
||||
isLoading={isFetchingNextPage}
|
||||
next={() => {
|
||||
void fetchNextPage();
|
||||
}}
|
||||
as="ul"
|
||||
$padding="none"
|
||||
$margin={{ top: 'none' }}
|
||||
role="list"
|
||||
>
|
||||
<Box as="ul" $padding="none" $margin={{ top: 'none' }} role="list">
|
||||
{versions?.length === 0 && (
|
||||
<Box $align="center" $margin="large">
|
||||
<Text $size="h6" $weight="bold">
|
||||
@@ -139,7 +132,7 @@ export const VersionList = ({
|
||||
versions={versions}
|
||||
selectedVersionId={selectedVersionId}
|
||||
/>
|
||||
</InfiniteScroll>
|
||||
</Box>
|
||||
<Text className="sr-only" aria-live="polite">
|
||||
{selectedVersionDate
|
||||
? t('Selected version {{date}}', { date: selectedVersionDate })
|
||||
|
||||
@@ -1,19 +1,43 @@
|
||||
export interface APIListVersions {
|
||||
count: number;
|
||||
is_truncated: boolean;
|
||||
next_version_id_marker: string | null;
|
||||
versions: Versions[];
|
||||
/**
|
||||
* One entry of the collaboration server's `activity` timeline: a stretch of
|
||||
* editing, bounded by the first and last change in it, credited to whoever made
|
||||
* them. `from` and `to` are unix milliseconds.
|
||||
*
|
||||
* `by` is a user id — a Docs user's uuid, or the literal `anonymous` for
|
||||
* visitors editing a public document, or `system` for content the migration
|
||||
* imported. It is not displayed today; resolving ids to names is its own
|
||||
* feature.
|
||||
*/
|
||||
export interface ActivityEntry {
|
||||
from: number;
|
||||
to: number;
|
||||
by: string | null;
|
||||
}
|
||||
|
||||
export interface Versions {
|
||||
etag: string;
|
||||
is_latest: boolean;
|
||||
last_modified: string;
|
||||
version_id: string;
|
||||
export interface APIActivity {
|
||||
activity: ActivityEntry[];
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
content: string; // Base64 encoded content
|
||||
last_modified: string;
|
||||
/**
|
||||
* A version, as the history panel shows it: one entry of the timeline after
|
||||
* neighbouring entries have been merged (see `mergeActivityEntries`), with the
|
||||
* authors that contributed to it.
|
||||
*
|
||||
* `id` is `to` as a string — the moment the version ends, which is both what
|
||||
* identifies it in the list and what the changeset and the rollback are asked
|
||||
* for.
|
||||
*/
|
||||
export interface DocVersion {
|
||||
id: string;
|
||||
from: number;
|
||||
to: number;
|
||||
by: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET changeset?ydoc=true`: the document as it stood at `to`, as a base64
|
||||
* y.js update.
|
||||
*/
|
||||
export interface APIChangeset {
|
||||
ydoc: string | null;
|
||||
}
|
||||
|
||||
@@ -1,55 +1,68 @@
|
||||
import * as Y from 'yjs';
|
||||
import { ActivityEntry, DocVersion } from './types';
|
||||
|
||||
/**
|
||||
* Revert the doc to a previous state.
|
||||
* How coarse the version history is: neighbouring changes closer together than
|
||||
* this become one version, and no version spans more than this.
|
||||
*
|
||||
* We cannot simply replace a doc with another previous doc,
|
||||
* because Y.js will act as if the previous doc is a new doc and so
|
||||
* merge it with the current doc, so we need to revert the doc (undo).
|
||||
*
|
||||
* To do so we simulate a history of the doc by saving snapshots of the doc
|
||||
* and then revert the doc to a previous snapshot.
|
||||
*
|
||||
* @param doc
|
||||
* @param snapshotOrigin
|
||||
* @param snapshotUpdate
|
||||
* A minute is a deliberate choice about what a "version" means here. The
|
||||
* collaboration server records an activity entry per stretch of editing, which
|
||||
* at typing speed is far finer than anything worth listing — a history of every
|
||||
* few keystrokes is not a history.
|
||||
*/
|
||||
export function revertUpdate(
|
||||
doc: Y.Doc,
|
||||
snapshotOrigin: Y.Doc,
|
||||
snapshotUpdate: Y.Doc,
|
||||
) {
|
||||
try {
|
||||
const snapshotDoc = new Y.Doc();
|
||||
Y.applyUpdate(
|
||||
snapshotDoc,
|
||||
Y.encodeStateAsUpdate(snapshotUpdate),
|
||||
snapshotOrigin,
|
||||
);
|
||||
export const VERSION_GRANULARITY_MS = 60_000;
|
||||
|
||||
const currentStateVector = Y.encodeStateVector(doc);
|
||||
const snapshotStateVector = Y.encodeStateVector(snapshotDoc);
|
||||
/**
|
||||
* Merge an ascending activity timeline into the versions the panel lists.
|
||||
*
|
||||
* The collaboration server already groups with the same two bounds (a gap and a
|
||||
* maximum span, both a minute), but it will only ever merge changes by the
|
||||
* *same* author: it breaks a run whenever the author changes. Two people typing
|
||||
* in the same paragraph at the same time would otherwise produce two interleaved
|
||||
* columns of entries, which is not what a version is — a version is a moment in
|
||||
* the document, not a moment in someone's editing.
|
||||
*
|
||||
* So this applies the server's own rule again, minus the author test, and keeps
|
||||
* the authors instead of discarding them. Because the server sorts by `from` and
|
||||
* breaks a run only where the author changes, re-merging those adjacent runs
|
||||
* yields exactly what one pass over the whole timeline would have produced.
|
||||
*
|
||||
* Both comparisons are `<`, matching the server's, so entries exactly a minute
|
||||
* apart start a new version rather than joining the old one.
|
||||
*/
|
||||
export const mergeActivityEntries = (
|
||||
activity: ActivityEntry[],
|
||||
granularityMs: number = VERSION_GRANULARITY_MS,
|
||||
): DocVersion[] => {
|
||||
const versions: DocVersion[] = [];
|
||||
const authors: Set<string>[] = [];
|
||||
|
||||
const changesSinceSnapshotUpdate = Y.encodeStateAsUpdate(
|
||||
doc,
|
||||
snapshotStateVector,
|
||||
);
|
||||
activity.forEach((entry) => {
|
||||
const last = versions[versions.length - 1];
|
||||
|
||||
const undoManager = new Y.UndoManager(
|
||||
[snapshotDoc.getMap('document-store')],
|
||||
{
|
||||
trackedOrigins: new Set([snapshotOrigin]),
|
||||
},
|
||||
);
|
||||
if (
|
||||
last &&
|
||||
entry.from - last.to < granularityMs &&
|
||||
entry.to - last.from < granularityMs
|
||||
) {
|
||||
last.to = entry.to;
|
||||
last.id = String(entry.to);
|
||||
} else {
|
||||
versions.push({
|
||||
id: String(entry.to),
|
||||
from: entry.from,
|
||||
to: entry.to,
|
||||
by: [],
|
||||
});
|
||||
authors.push(new Set());
|
||||
}
|
||||
|
||||
Y.applyUpdate(snapshotDoc, changesSinceSnapshotUpdate, snapshotOrigin);
|
||||
undoManager.undo();
|
||||
const revertChangesSinceSnapshotUpdate = Y.encodeStateAsUpdate(
|
||||
snapshotDoc,
|
||||
currentStateVector,
|
||||
);
|
||||
Y.applyUpdate(doc, revertChangesSinceSnapshotUpdate, snapshotOrigin);
|
||||
} catch (e) {
|
||||
console.error('Failed to revert the doc to a previous state', e);
|
||||
}
|
||||
}
|
||||
if (entry.by) {
|
||||
authors[authors.length - 1].add(entry.by);
|
||||
}
|
||||
});
|
||||
|
||||
return versions.map((version, index) => ({
|
||||
...version,
|
||||
by: Array.from(authors[index]),
|
||||
}));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user