[web/document-updater] display preview for accepted/rejected tracked changes (#36240)

* [web] add preview payload to track-change notification types

* [doc-updater] pass accepted/rejected change data through to web for previews

* [web] snapshot change preview onto accepted/rejected notifications

* [web] render resolved change previews instead of accepted/rejected count lines

* [web] add unit coverage for accepted/rejected change preview flow

* fix: notification carries multiple previews per accept/reject batch; extract shared group helper

* fix: one preview per accept/reject action, drop adjacency grouping

* fix: move buildResolvedChangePreviews out of the email builder class

* fix: type error, make tests more explicit

* fix: render resolver below preview

* fix: overflow text, alignment

* fix: test error

* fix: resolved count

* [document-updater/web] send sparse change preview instead of full doc lines

* fix: store sparse preview, hydrate at render, drop CE gate

* fix: advance slice cursor past tracked-delete text in sparse preview

* fix: inline buildChangeGroupPreview

* fix: changePreviewBuilder -> sparseChangePreview

* fix: rm change_contributors

* fix: copilot flagged testing issues

* fix: hydrate and truncate change preview at write time

* fix: avoid materializing full doc text when slicing preview window

* fix: update reject preview test to hydrated shape

* fix: accepted delete removing context

* [document-updater/web] split resolved change previews into one per change cluster

* fix: narrow cluster userIds to string[] for typecheck

* feat: updating to use zod schema for previews

---------

Co-authored-by: Jimmy Domagala-Tang <jimmy.domagala-tang@overleaf.com>
GitOrigin-RevId: 0740713c715a61a220180f353e497b27da6a5b0f
This commit is contained in:
Kristina
2026-09-17 11:58:14 +00:00
committed by Copybot
co-authored by Jimmy Domagala-Tang
parent 3e0a54c917
commit 9c005e4e54
14 changed files with 699 additions and 41 deletions
@@ -13,6 +13,7 @@ const { extractOriginOrSource } = require('./Utils')
const { getTotalSizeOfLines } = require('./Limits')
const Settings = require('@overleaf/settings')
const RangesTracker = require('@overleaf/ranges-tracker')
const { buildSparseChangePreviews } = require('./TrackedChangePreview')
const {
StringFileData,
SetCommentStateOperation,
@@ -350,7 +351,6 @@ const DocumentManager = {
if (changeIds == null) {
changeIds = []
}
let changeContributors = []
const {
lines,
@@ -368,6 +368,19 @@ const DocumentManager = {
throw new Errors.OTTypeMismatchError('sharejs-text-ot', 'history-ot')
}
// Snapshot the accepted change objects and lines before applying the
// accept, so we can build the email-notification previews from them.
const acceptedChanges = (ranges.changes || []).filter(change =>
changeIds.includes(change.id)
)
const changeContributors = acceptedChanges
.map(change => change?.metadata?.user_id)
.filter(userId => userId)
const previews = buildSparseChangePreviews({
changes: acceptedChanges,
lines,
})
const newRanges = RangesManager.acceptChanges(
projectId,
docId,
@@ -396,20 +409,14 @@ const DocumentManager = {
projectHistoryId,
})
if (historyUpdates.length === 0) {
return changeContributors
if (historyUpdates.length > 0) {
await ProjectHistoryRedisManager.promises.queueOps(
projectId,
...historyUpdates.map(op => JSON.stringify(op))
)
}
await ProjectHistoryRedisManager.promises.queueOps(
projectId,
...historyUpdates.map(op => JSON.stringify(op))
)
}
changeContributors = (ranges.changes || [])
.filter(change => changeIds.includes(change.id))
.map(change => change?.metadata?.user_id)
.filter(userId => userId)
return changeContributors
return { changeContributors, previews }
},
async updateCommentState(projectId, docId, commentId, userId, resolved) {
@@ -716,13 +723,12 @@ const DocumentManager = {
async acceptChangesWithLock(projectId, docId, changeIds) {
const UpdateManager = require('./UpdateManager')
const changeContributors = await UpdateManager.promises.lockUpdatesAndDo(
return await UpdateManager.promises.lockUpdatesAndDo(
DocumentManager.acceptChanges,
projectId,
docId,
changeIds
)
return changeContributors
},
async updateCommentStateWithLock(
@@ -513,7 +513,7 @@ async function acceptChanges(req, res) {
`accepting ${changeIds.length} changes via http`
)
const timer = new Metrics.Timer('http.acceptChanges')
const changeContributors =
const { changeContributors, previews } =
await DocumentManager.promises.acceptChangesWithLock(
projectId,
docId,
@@ -525,7 +525,7 @@ async function acceptChanges(req, res) {
`accepted ${changeIds.length} changes via http`
)
res.status(200).json({ changeContributors })
res.status(200).json({ changeContributors, previews })
}
const commentWithUserSchema = z.object({
@@ -0,0 +1,249 @@
// @ts-check
/**
* Builds sparse previews of an accept/reject tracked-change batch. A batch can
* cover changes spread across the whole document, so the changes are first
* clustered by proximity: each cluster becomes one preview, with its own
* section path, start line and bounded slice of surrounding text. Web
* assembles the diff, before/after context and truncation from that.
*
* The clustering gap must stay in step with MERGE_MAX_GAP in
* services/web/modules/notifications/app/src/emails/changeBlock.mjs, which
* groups pending (not yet resolved) changes the same way.
*
* A small amount of logic here (section-header pattern, line lookup) is also
* present in services/web/modules/notifications/app/src/emails/previewHelpers.mjs
*/
// Matches LaTeX sectioning commands: command name (1), short title (2), full
// title (3). Handles starred variants and one level of nested braces.
const SECTION_HEADER_PATTERN =
/^\s*\\(part|chapter|section|subsection|subsubsection|paragraph|subparagraph)\*?(?:\[([^\]]*)\])?\{((?:[^{}]|\{[^{}]*\})*)\}/
const SECTION_LEVELS = {
part: 0,
chapter: 1,
section: 2,
subsection: 3,
subsubsection: 4,
paragraph: 5,
subparagraph: 6,
}
const CONTEXT_PADDING_CHARS = 500
// Maximum gap, in characters, between two changes that still read as one
// preview.
const MERGE_MAX_GAP = 50
/**
* @typedef {{ id?: string, op: { i?: string, d?: string, p: number }, metadata?: { user_id?: string } }} Change
*/
/**
* @typedef {object} SparseChangePreview
* @property {string[]} sectionPath
* @property {number} startLine
* @property {{ i?: string, d?: string, p: number }[]} changes
* @property {string} slice
* @property {number} sliceStart
* @property {string[]} userIds - authors of the changes in this cluster
*/
/**
* Length a change occupies in document-content coordinate space. Inserts are
* present in the document; deletes are zero-width (their text is no longer
* there, so it doesn't shift later offsets). See ranges-tracker/index.cjs.
* @param {{ i?: string }} op
* @returns {number}
*/
function contentLength(op) {
return op.i?.length || 0
}
/**
* Character offset of the first character of each line, so position lookups
* don't re-walk the document for every cluster.
* @param {string[]} lines
* @returns {number[]}
*/
function buildLineStarts(lines) {
const starts = new Array(lines.length)
let offset = 0
for (let i = 0; i < lines.length; i++) {
starts[i] = offset
offset += lines[i].length + 1 // +1 for \n
}
return starts
}
/**
* @param {number[]} lineStarts
* @param {number} charPosition
* @returns {number} 0-based line index
*/
function charPositionToLineIndex(lineStarts, charPosition) {
let low = 0
let high = lineStarts.length - 1
while (low < high) {
const mid = Math.ceil((low + high) / 2)
if (lineStarts[mid] > charPosition) {
high = mid - 1
} else {
low = mid
}
}
return low
}
/**
* Collects every sectioning header in one pass, so per-cluster section lookup
* doesn't rescan the document body.
* @param {string[]} lines
* @returns {{ lineIndex: number, level: number, title: string }[]}
*/
function collectSectionHeaders(lines) {
const headers = []
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(SECTION_HEADER_PATTERN)
if (!match) {
continue
}
headers.push({
lineIndex: i,
level: SECTION_LEVELS[match[1]],
title: match[2] || match[3],
})
}
return headers
}
/**
* @param {{ lineIndex: number, level: number, title: string }[]} headers
* @param {number} lineIndex
* @returns {string[]}
*/
function sectionPathAtLine(headers, lineIndex) {
const ancestors = []
let maxLevel = Infinity
for (let i = headers.length - 1; i >= 0; i--) {
const header = headers[i]
if (header.lineIndex > lineIndex) {
continue
}
if (header.level >= maxLevel) {
continue
}
ancestors.push(header.title)
maxLevel = header.level
if (header.level === 0) {
break
}
}
return ancestors.reverse()
}
/**
* Groups changes that sit close enough together to read as one preview.
* @param {Change[]} changes
* @returns {Change[][]}
*/
function clusterChanges(changes) {
const sorted = [...changes].sort((a, b) => a.op.p - b.op.p)
const clusters = [[sorted[0]]]
for (let i = 1; i < sorted.length; i++) {
const change = sorted[i]
const cluster = clusters[clusters.length - 1]
const previous = cluster[cluster.length - 1]
const previousEnd = previous.op.p + contentLength(previous.op)
if (change.op.p - previousEnd <= MERGE_MAX_GAP) {
cluster.push(change)
} else {
clusters.push([change])
}
}
return clusters
}
/**
* Builds the sparse change previews that web hydrates into stored previews,
* one per cluster of nearby changes.
*
* @param {object} opts
* @param {Change[]} opts.changes - ranges-format change records
* @param {string[]} opts.lines - pre-change doc lines snapshot
* @returns {SparseChangePreview[]}
*/
function buildSparseChangePreviews({ changes, lines }) {
if (!changes || !changes.length || !lines || !lines.length) {
return []
}
const lineStarts = buildLineStarts(lines)
const headers = collectSectionHeaders(lines)
return clusterChanges(changes).map(cluster => {
const firstPos = cluster[0].op.p
const lastChange = cluster[cluster.length - 1]
const lastEnd = lastChange.op.p + contentLength(lastChange.op)
const lineIndex = charPositionToLineIndex(lineStarts, firstPos)
const windowStart = Math.max(0, firstPos - CONTEXT_PADDING_CHARS)
const windowEnd = lastEnd + CONTEXT_PADDING_CHARS
const userIds = [
...new Set(
cluster.flatMap(change =>
change.metadata?.user_id ? [change.metadata.user_id] : []
)
),
]
return {
sectionPath: sectionPathAtLine(headers, lineIndex),
startLine: lineIndex + 1,
changes: cluster.map(change => ({
i: change.op.i,
d: change.op.d,
p: change.op.p,
})),
slice: extractWindow(lines, lineStarts, windowStart, windowEnd),
sliceStart: windowStart,
userIds,
}
})
}
/**
* Extracts `lines.join('\n').slice(windowStart, windowEnd)` without
* materializing the full joined text.
* @param {string[]} lines
* @param {number[]} lineStarts
* @param {number} windowStart
* @param {number} windowEnd
* @returns {string}
*/
function extractWindow(lines, lineStarts, windowStart, windowEnd) {
let out = ''
const firstLine = charPositionToLineIndex(lineStarts, windowStart)
for (let i = firstLine; i < lines.length; i++) {
const line = lines[i]
const lineStart = lineStarts[i]
const lineEnd = lineStart + line.length
if (lineStart >= windowEnd) {
break
}
if (lineEnd > windowStart) {
const from = Math.max(0, windowStart - lineStart)
const to = Math.min(line.length, windowEnd - lineStart)
out += line.slice(from, to)
}
const hasTrailingNewline = i < lines.length - 1
if (hasTrailingNewline && lineEnd >= windowStart && lineEnd < windowEnd) {
out += '\n'
}
}
return out
}
module.exports = { buildSparseChangePreviews }
@@ -17,6 +17,7 @@ const WebApiManager = require('./WebApiManager')
const Profiler = require('./Profiler')
const { isInsert, isDelete, getDocLength, computeDocHash } = require('./Utils')
const HistoryOTUpdateManager = require('./HistoryOTUpdateManager')
const { buildSparseChangePreviews } = require('./TrackedChangePreview')
/**
* @import { Ranges, Update, HistoryUpdate } from "./types"
@@ -214,9 +215,16 @@ const UpdateManager = {
// Look up the authors of those rejected changes from the pre-update
// ranges so we can notify web below.
if (removedChangeIds.length > 0) {
const rejectedChangeAuthorIds = (ranges?.changes || [])
.filter(change => removedChangeIds.includes(change.id))
.map(change => change.metadata.user_id)
const rejectedChanges = (ranges?.changes || []).filter(change =>
removedChangeIds.includes(change.id)
)
const rejectedChangeAuthorIds = rejectedChanges.map(
change => change.metadata.user_id
)
const previews = buildSparseChangePreviews({
changes: rejectedChanges,
lines,
})
// Fire-and-forget without awaiting because
// we hold the project lock here, and the result of the
@@ -226,7 +234,8 @@ const UpdateManager = {
projectId,
docId,
rejectedChangeAuthorIds,
update.meta?.user_id
update.meta?.user_id,
previews
)
.catch(err => {
logger.warn(
@@ -9,12 +9,14 @@ const MAX_HTTP_REQUEST_LENGTH = 5000
* @param {string} docId
* @param {string[]} rejectedChangeAuthorIds
* @param {string | undefined} userId
* @param {object[]} [previews] sparse change previews from TrackedChangePreview.buildSparseChangePreviews
*/
async function notifyTrackChangesRejected(
projectId,
docId,
rejectedChangeAuthorIds,
userId
userId,
previews
) {
const url = new URL(
`/project/${projectId}/doc/${docId}/changes/reject`,
@@ -23,7 +25,11 @@ async function notifyTrackChangesRejected(
await fetchNothing(url, {
method: 'POST',
json: { rejectedChangeAuthorIds, userId },
json: {
rejectedChangeAuthorIds,
userId,
previews,
},
basicAuth: {
user: Settings.apis.web.user,
password: Settings.apis.web.pass,
@@ -61,6 +61,7 @@ describe('DocumentManager', function () {
maxUnflushedAgeMs: 300 * 1000, // 5 minutes
}
this.buildSparseChangePreviews = sinon.stub().returns([])
this.DocumentManager = SandboxedModule.require(modulePath, {
requires: {
'./RedisManager': this.RedisManager,
@@ -73,6 +74,9 @@ describe('DocumentManager', function () {
'./HistoryOTUpdateManager': this.HistoryOTUpdateManager,
'./RangesManager': this.RangesManager,
'./Errors': Errors,
'./TrackedChangePreview': {
buildSparseChangePreviews: this.buildSparseChangePreviews,
},
'@overleaf/settings': this.Settings,
},
})
@@ -849,7 +853,7 @@ describe('DocumentManager', function () {
})
it('should return the change contributors', function () {
expect(this.result).to.deep.equal(['mock-user-id-0'])
expect(this.result.changeContributors).to.deep.equal(['mock-user-id-0'])
})
})
@@ -874,7 +878,7 @@ describe('DocumentManager', function () {
})
it('should return the change contributors', function () {
expect(this.result).to.deep.equal([
expect(this.result.changeContributors).to.deep.equal([
'mock-user-id-1',
'mock-user-id-2',
'mock-user-id-3',
@@ -883,6 +887,40 @@ describe('DocumentManager', function () {
})
})
describe('preview building', function () {
beforeEach(async function () {
this.mockPreviews = [
{
sectionPath: ['Intro'],
startLine: 1,
changes: [{ i: 'x', p: 0 }],
slice: 'x',
sliceStart: 0,
userIds: ['mock-user-id-0'],
},
]
this.buildSparseChangePreviews.returns(this.mockPreviews)
this.result = await this.DocumentManager.promises.acceptChanges(
this.project_id,
this.doc_id,
[this.change_id]
)
})
it('should call buildSparseChangePreviews with the accepted changes and doc lines', function () {
this.buildSparseChangePreviews.should.have.been.calledWith({
changes: [
{ id: 'mock-change-id', metadata: { user_id: 'mock-user-id-0' } },
],
lines: this.lines,
})
})
it('should return the previews from the helper', function () {
expect(this.result.previews).to.equal(this.mockPreviews)
})
})
describe('when the doc is not found', function () {
beforeEach(async function () {
this.DocumentManager.promises.getDoc = sinon
@@ -692,9 +692,20 @@ describe('HttpController', function () {
describe('successfully with a single change', function () {
beforeEach(async function () {
this.changeContributors = ['user-id-1', 'user-id-2']
this.DocumentManager.promises.acceptChangesWithLock.resolves(
this.changeContributors
)
this.previews = [
{
sectionPath: [],
startLine: 1,
changes: [{ i: 'x', p: 0 }],
slice: 'x',
sliceStart: 0,
userIds: ['user-id-1'],
},
]
this.DocumentManager.promises.acceptChangesWithLock.resolves({
changeContributors: this.changeContributors,
previews: this.previews,
})
await this.HttpController.acceptChanges(this.req, this.res, this.next)
})
@@ -706,10 +717,11 @@ describe('HttpController', function () {
)
})
it('should return a successful 200 with a list of the change contributors', function () {
it('should return a successful 200 with the contributors and previews', function () {
this.res.status.should.have.been.calledWith(200)
this.res.json.should.have.been.calledWith({
changeContributors: this.changeContributors,
previews: this.previews,
})
})
@@ -736,6 +748,10 @@ describe('HttpController', function () {
'ddddddddddddddddddddddd4',
]
this.req.body = { change_ids: this.change_ids }
this.DocumentManager.promises.acceptChangesWithLock.resolves({
changeContributors: [],
previews: [],
})
await this.HttpController.acceptChanges(this.req, this.res, this.next)
})
@@ -0,0 +1,206 @@
const { expect } = require('chai')
const {
buildSparseChangePreviews,
} = require('../../../app/js/TrackedChangePreview')
describe('TrackedChangePreview', function () {
describe('buildSparseChangePreviews', function () {
it('returns no previews for empty inputs', function () {
expect(
buildSparseChangePreviews({ changes: [], lines: ['a'] })
).to.deep.equal([])
expect(
buildSparseChangePreviews({ changes: null, lines: ['a'] })
).to.deep.equal([])
expect(
buildSparseChangePreviews({
changes: [{ op: { i: 'x', p: 0 } }],
lines: null,
})
).to.deep.equal([])
})
it('finds LaTeX section path from the enclosing header', function () {
const lines = [
'\\section{Intro}',
'Some intro text.',
'\\subsection{Details}',
'target line here',
]
const position = lines.slice(0, 3).join('\n').length + 5
const [preview] = buildSparseChangePreviews({
changes: [{ op: { i: 'x', p: position } }],
lines,
})
expect(preview.sectionPath).to.deep.equal(['Intro', 'Details'])
})
it('returns empty sectionPath when no header precedes the change', function () {
const lines = ['plain text', 'more text']
const [preview] = buildSparseChangePreviews({
changes: [{ op: { i: 'x', p: 5 } }],
lines,
})
expect(preview.sectionPath).to.deep.equal([])
})
it('computes 1-based startLine for the first change', function () {
const lines = ['first', 'second', 'third']
const [preview] = buildSparseChangePreviews({
changes: [{ op: { i: 'x', p: 'first\nsecond\n'.length + 1 } }],
lines,
})
expect(preview.startLine).to.equal(3)
})
it('attributes a position on a line-terminating newline to that line', function () {
const lines = ['ab', 'cd']
const [preview] = buildSparseChangePreviews({
changes: [{ op: { i: 'x', p: 2 } }],
lines,
})
expect(preview.startLine).to.equal(1)
})
it('clamps a position past the end of the document to the last line', function () {
const lines = ['ab', 'cd']
const [preview] = buildSparseChangePreviews({
changes: [{ op: { i: 'x', p: 500 } }],
lines,
})
expect(preview.startLine).to.equal(2)
})
it('projects changes to bare ranges-op shape', function () {
const [preview] = buildSparseChangePreviews({
changes: [
{
id: 'c1',
op: { i: 'hi', p: 0 },
metadata: { user_id: 'u1' },
},
{
id: 'c2',
op: { d: 'x', p: 5 },
metadata: { user_id: 'u2' },
},
],
lines: ['abcdefghij'],
})
expect(preview.changes).to.deep.equal([
{ i: 'hi', d: undefined, p: 0 },
{ i: undefined, d: 'x', p: 5 },
])
})
it('slices a bounded window of surrounding doc text', function () {
const filler = 'x'.repeat(2000)
const lines = [filler, filler, filler]
const fullText = lines.join('\n')
const changePos = filler.length + 1 + 5
const [preview] = buildSparseChangePreviews({
changes: [{ op: { i: 'y', p: changePos } }],
lines,
})
// slice should be far shorter than the full doc
expect(preview.slice.length).to.be.lessThan(fullText.length)
expect(preview.sliceStart).to.be.lessThanOrEqual(changePos)
const sliceEndPos = preview.sliceStart + preview.slice.length
expect(sliceEndPos).to.be.greaterThanOrEqual(changePos)
})
it('treats a tracked delete as zero-width when sizing the window', function () {
const lines = ['Overleaf is and features are listed below.']
const deletePos = 'Overleaf is '.length
const [preview] = buildSparseChangePreviews({
changes: [{ op: { d: 'a great tool ', p: deletePos } }],
lines,
})
// The deleted text is not in `lines`, so the window must not extend by
// its length past the deletion point.
expect(preview.slice).to.equal(lines[0])
expect(preview.sliceStart).to.equal(0)
})
describe('clustering', function () {
it('keeps nearby changes in one preview', function () {
const lines = ['one two three four five six seven eight nine ten']
const previews = buildSparseChangePreviews({
changes: [
{ op: { i: 'a', p: 0 }, metadata: { user_id: 'u1' } },
{ op: { i: 'b', p: 20 }, metadata: { user_id: 'u2' } },
],
lines,
})
expect(previews).to.have.length(1)
expect(previews[0].changes).to.have.length(2)
expect(previews[0].userIds).to.deep.equal(['u1', 'u2'])
})
it('splits distant changes into separate previews', function () {
const filler = 'x'.repeat(5000)
const lines = ['\\section{Top}', filler, '\\section{Bottom}', filler]
const topPos = '\\section{Top}\n'.length + 3
const bottomPos =
`\\section{Top}\n${filler}\n\\section{Bottom}\n`.length + 3
const previews = buildSparseChangePreviews({
changes: [
{ op: { i: 'a', p: topPos }, metadata: { user_id: 'u1' } },
{ op: { i: 'b', p: bottomPos }, metadata: { user_id: 'u2' } },
],
lines,
})
expect(previews).to.have.length(2)
expect(previews[0].sectionPath).to.deep.equal(['Top'])
expect(previews[0].startLine).to.equal(2)
expect(previews[0].userIds).to.deep.equal(['u1'])
expect(previews[1].sectionPath).to.deep.equal(['Bottom'])
expect(previews[1].startLine).to.equal(4)
expect(previews[1].userIds).to.deep.equal(['u2'])
})
it('starts the slice on a line boundary without losing the newline', function () {
const lines = ['first line', 'second line', 'third line']
const secondLineStart = 'first line\n'.length
const [preview] = buildSparseChangePreviews({
changes: [{ op: { i: 'x', p: secondLineStart } }],
lines,
})
expect(preview.sliceStart).to.equal(0)
expect(preview.slice).to.equal(lines.join('\n'))
})
it('bounds each preview slice instead of spanning the whole doc', function () {
const filler = 'x'.repeat(5000)
const lines = [filler, filler, filler]
const lastLineStart = (filler.length + 1) * 2
const previews = buildSparseChangePreviews({
changes: [
{ op: { i: 'a', p: 0 } },
{ op: { i: 'b', p: lastLineStart + 10 } },
],
lines,
})
expect(previews).to.have.length(2)
for (const preview of previews) {
expect(preview.slice.length).to.be.lessThan(1200)
}
})
it('orders previews by position regardless of input order', function () {
const lines = ['a'.repeat(100), 'b'.repeat(100), 'c'.repeat(100)]
const previews = buildSparseChangePreviews({
changes: [
{ op: { i: 'late', p: 250 } },
{ op: { i: 'early', p: 10 } },
],
lines,
})
expect(previews.map(p => p.changes[0].i)).to.deep.equal([
'early',
'late',
])
})
})
})
})
@@ -92,6 +92,7 @@ describe('UpdateManager', function () {
},
}
this.buildSparseChangePreviews = sinon.stub().returns([])
this.UpdateManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'./ProjectLockManager': this.ProjectLockManager,
@@ -107,6 +108,9 @@ describe('UpdateManager', function () {
'./WebApiManager': this.WebApiManager,
'./Profiler': this.Profiler,
'./ProjectHistoryRedisManager': this.ProjectHistoryRedisManager,
'./TrackedChangePreview': {
buildSparseChangePreviews: this.buildSparseChangePreviews,
},
},
})
})
@@ -610,6 +614,17 @@ describe('UpdateManager', function () {
historyUpdates: this.historyUpdates,
removedChangeIds: ['change-1', 'change-2'],
})
this.mockPreviews = [
{
sectionPath: ['Intro'],
startLine: 1,
changes: [{ d: 'x', p: 0 }],
slice: 'x',
sliceStart: 0,
userIds: ['author-1', 'author-2'],
},
]
this.buildSparseChangePreviews.returns(this.mockPreviews)
await this.UpdateManager.promises.applyUpdate(
this.project_id,
this.doc_id,
@@ -617,12 +632,23 @@ describe('UpdateManager', function () {
)
})
it('should notify web of the rejected tracked changes', function () {
it('should call buildSparseChangePreviews with the rejected changes and lines', function () {
this.buildSparseChangePreviews.should.have.been.calledWith({
changes: [
{ id: 'change-1', metadata: { user_id: 'author-1' } },
{ id: 'change-2', metadata: { user_id: 'author-2' } },
],
lines: this.lines,
})
})
it('should notify web of the rejected tracked changes with the previews', function () {
this.WebApiManager.promises.notifyTrackChangesRejected.should.have.been.calledWith(
this.project_id,
this.doc_id,
this.rejectedChangeAuthorIds,
this.updateMeta.user_id
this.updateMeta.user_id,
this.mockPreviews
)
})
})
@@ -208,7 +208,7 @@ async function clearProjectState(projectId) {
* @param {string} userId
*/
async function acceptChanges(projectId, docId, changeIds, userId) {
const { changeContributors } = await fetchJson(
const { changeContributors, previews } = await fetchJson(
`${BASE_URL}/project/${projectId}/doc/${docId}/change/accept`,
{
method: 'POST',
@@ -221,7 +221,8 @@ async function acceptChanges(projectId, docId, changeIds, userId) {
projectId,
docId,
userId,
changeContributors
changeContributors,
previews
)
}
@@ -214,6 +214,21 @@ async function setDocument(req, res) {
res.json(result)
}
const changePreview = z.strictObject({
sectionPath: z.array(z.string()),
startLine: z.number().int().min(1),
changes: z.array(
z.strictObject({
i: z.string().optional(),
d: z.string().optional(),
p: z.number().int().min(0),
})
),
slice: z.string(),
sliceStart: z.number().int().min(0),
userIds: z.array(zz.objectId()),
})
const trackChangesRejectedSchema = z.object({
params: z.strictObject({
Project_id: zz.objectId(),
@@ -222,6 +237,7 @@ const trackChangesRejectedSchema = z.object({
body: z.strictObject({
rejectedChangeAuthorIds: z.array(zz.objectId()),
userId: zz.objectId().optional(),
previews: z.array(changePreview).optional(),
}),
})
@@ -230,13 +246,14 @@ async function trackChangesRejected(req, res) {
logOnly: true,
})
const { Project_id: projectId, doc_id: docId } = params
const { rejectedChangeAuthorIds, userId } = body
const { rejectedChangeAuthorIds, userId, previews } = body
await Modules.promises.hooks.fire(
'trackChangesRejected',
projectId,
docId,
userId,
rejectedChangeAuthorIds
rejectedChangeAuthorIds,
previews
)
res.sendStatus(204)
}
@@ -153,6 +153,37 @@ describe('DocumentController', function () {
expect(response.statusCode).to.equal(204)
})
it('should accept the previews document-updater sends and return 204', async function () {
const response = await privateApiRequest({
method: 'post',
url: `/project/${projectId}/doc/${docId}/changes/reject`,
json: {
rejectedChangeAuthorIds: [owner._id.toString()],
userId: owner._id.toString(),
// the shape buildSparseChangePreviews emits, one entry per cluster
previews: [
{
sectionPath: ['Introduction'],
startLine: 2,
changes: [{ i: 'inserted', p: 5 }],
slice: 'first line\nsecond line inserted',
sliceStart: 0,
userIds: [owner._id.toString()],
},
{
sectionPath: [],
startLine: 404,
changes: [{ d: 'removed', p: 5000 }],
slice: 'text around the second cluster',
sliceStart: 4900,
userIds: [owner._id.toString()],
},
],
},
})
expect(response.statusCode).to.equal(204)
})
it('should reject a non-ObjectId rejectedChangeAuthorIds entry', async function () {
const response = await privateApiRequest({
method: 'post',
@@ -581,6 +581,50 @@ describe('DocumentUpdaterHandler', function () {
})
describe('successfully', function () {
beforeEach(async function (ctx) {
ctx.previews = [
{
sectionPath: ['Intro'],
startLine: 2,
changes: [{ i: 'hello', p: 5 }],
slice: 'first line\nsecond line hello',
sliceStart: 0,
userIds: ['mock-user-id-1'],
},
]
ctx.docUpdaterMock
.post(`/project/${ctx.project_id}/doc/${ctx.doc_id}/change/accept`, {
change_ids: [ctx.change_id],
})
.reply(200, {
changeContributors: ctx.change_contributors,
previews: ctx.previews,
})
await ctx.handler.promises.acceptChanges(
ctx.project_id,
ctx.doc_id,
[ctx.change_id],
ctx.user_id
)
})
it('should accept the change in the document updater', function (ctx) {
expect(ctx.docUpdaterMock.isDone()).to.be.true
})
it('should fire the changesAccepted hook with contributors and previews', function (ctx) {
expect(ctx.modulesHooksFire).to.have.been.calledWith(
'changesAccepted',
ctx.project_id,
ctx.doc_id,
ctx.user_id,
ctx.change_contributors,
ctx.previews
)
})
})
describe('when the document updater does not return preview data', function () {
beforeEach(async function (ctx) {
ctx.docUpdaterMock
.post(`/project/${ctx.project_id}/doc/${ctx.doc_id}/change/accept`, {
@@ -595,17 +639,14 @@ describe('DocumentUpdaterHandler', function () {
)
})
it('should accept the change in the document updater', function (ctx) {
expect(ctx.docUpdaterMock.isDone()).to.be.true
})
it('should fire the changesAccepted hook with change contributors', function (ctx) {
it('should fire the changesAccepted hook with undefined preview data', function (ctx) {
expect(ctx.modulesHooksFire).to.have.been.calledWith(
'changesAccepted',
ctx.project_id,
ctx.doc_id,
ctx.user_id,
ctx.change_contributors
ctx.change_contributors,
undefined
)
})
})
+12
View File
@@ -2,11 +2,22 @@
* Types for module hook events fired across the application
*/
export type SparseChangePreview = {
sectionPath: string[]
startLine: number
changes: { i?: string; d?: string; p: number }[]
slice: string
sliceStart: number
/** authors of the changes in this cluster */
userIds: string[]
}
export type TrackChangesAcceptedEvent = {
projectId: string
docId: string
userId: string
changeContributors: string[]
previews?: SparseChangePreview[] | null
}
export type TrackChangesRejectedEvent = {
@@ -14,6 +25,7 @@ export type TrackChangesRejectedEvent = {
docId: string
userId: string
changeContributors: string[]
previews?: SparseChangePreview[] | null
}
export type CommentAddedEvent = {