diff --git a/.changeset/12314-comment-edit-stamp.md b/.changeset/12314-comment-edit-stamp.md new file mode 100644 index 0000000000..751fe348c8 --- /dev/null +++ b/.changeset/12314-comment-edit-stamp.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +A comment edit on the record feed sends only the new text and its mentions (objectui#12314, the objectui half of ruling A on objectstack-ai/objectstack#23124). The console used to write `is_edited: true` and a client-clock `edited_at` with every edit. The platform now stamps both fields itself when a comment's body changes, and both are read-only to data entry, so a server on the 18 line dropped the console's copies and logged a warning on every edit. + +The row's "edited" mark after a save is read from the stored record the update answers with, the same `is_edited` field the next read shows it from. A server that stamps nothing (a `@objectstack/plugin-audit` release before the stamp) answers an unstamped row, and the edited comment shows no mark until a release that stamps it. + +Nothing is added to or removed from the package entry. diff --git a/packages/app-shell/src/views/RecordDetailView.feedCommentEditDelete-12268.test.tsx b/packages/app-shell/src/views/RecordDetailView.feedCommentEditDelete-12268.test.tsx index 2b5a385895..e5d66a0212 100644 --- a/packages/app-shell/src/views/RecordDetailView.feedCommentEditDelete-12268.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.feedCommentEditDelete-12268.test.tsx @@ -16,9 +16,13 @@ * `CommentThread` gates its own Edit and Delete on) for each row it reads, * and the feed shows the controls on the rows it marked. The collaboration * double below wraps the real predicate, so the page is pinned on the call. - * - The author edits: the write is a `sys_comment` update of the text that - * marks the comment edited, and the row then shows the new text and reads - * as edited. A comment stored as edited reads as edited from the read. + * - The author edits: the write is a `sys_comment` update of the text and + * carries neither `is_edited` nor `edited_at` (objectui#12314): the platform + * stamps both when the body changes and refuses them from a client (ruling + * A on objectstack-ai/objectstack#23124). The row then shows the new text + * and reads as edited from the record the server answers with, so a server + * that stamps nothing shows no mark. A comment stored as edited reads as + * edited from the read. * - The author deletes: the page asks through its own `ActionConfirmDialog` * (the real one, not a double), painted destructive; Cancel sends nothing; * Delete deletes the row from `sys_comment` and from the feed. @@ -199,7 +203,9 @@ function authoredPage(block: Block) { /** * The server's `sys_comment` rows for the record. A delete removes the row from * them and nothing else (the server keeps a deleted comment's replies), so a - * later read answers what the server would. + * later read answers what the server would. An update answers with the stored + * record, stamped the way the platform's `beforeUpdate` hook stamps a change + * of the body (`is_edited`, `edited_at`; objectstack-ai/objectstack#23124). */ function makeDataSource(rows: Array> = ROWS) { let stored = rows.map((c) => ({ ...c })); @@ -211,7 +217,16 @@ function makeDataSource(rows: Array> = ROWS) { }), findOne: vi.fn(async (_o: string, id: string) => ({ id, subject: `Case ${id}` })), create: vi.fn(async (_o: string, data: any) => ({ ...data })), - update: vi.fn(async (_o: string, id: string, patch: any) => ({ id, ...patch })), + update: vi.fn(async (objectName: string, id: string, patch: any) => { + const row = stored.find((c) => c.id === id) ?? { id }; + const next: Record = { ...row, ...patch }; + if (objectName === COMMENT && 'body' in patch && patch.body !== row.body) { + next.is_edited = true; + next.edited_at = '2026-10-14T12:00:00.000Z'; + } + stored = stored.map((c) => (c.id === id ? next : c)); + return { ...next }; + }), delete: vi.fn(async (objectName: string, id: string) => { if (objectName === COMMENT) stored = stored.filter((c) => c.id !== id); return true; @@ -350,7 +365,7 @@ describe.each(['record:activity', 'record:discussion'])( expect(within(rowOf(view, ADA_BODY)).queryByText(EN.detail.edited)).toBeNull(); }); - it('the author edits: the text is stored with the comment marked edited, and the row then shows it and reads as edited', async () => { + it('the author edits: the write sends the text and no edit mark, and the row then shows it and reads as edited from the server\'s answer', async () => { const { view, dataSource } = await mount(block); fireEvent.click(editIn(rowOf(view, ADA_BODY))!); fireEvent.change(view.getByRole('textbox', { name: EN.detail.editComment }), { target: { value: NEW_TEXT } }); @@ -361,14 +376,34 @@ describe.each(['record:activity', 'record:discussion'])( const [objectName, id, patch] = dataSource.update.mock.calls[0]; expect(objectName).toBe(COMMENT); expect(id).toBe('c-ada'); - expect(patch).toMatchObject({ body: NEW_TEXT, is_edited: true, mentions: '[]' }); - expect(Number.isNaN(Date.parse(patch.edited_at))).toBe(false); + expect(patch).toEqual({ body: NEW_TEXT, mentions: '[]' }); + expect(patch).not.toHaveProperty('is_edited'); + expect(patch).not.toHaveProperty('edited_at'); expect(view.queryByText(ADA_BODY)).toBeNull(); const row = rowOf(view, NEW_TEXT); expect(within(row).getByText(EN.detail.edited)).toBeInTheDocument(); expect(toast.error).not.toHaveBeenCalled(); }); + it('the mark is the server\'s: an answer that is not stamped edited leaves the row unmarked', async () => { + const { view, dataSource } = await mount(block); + dataSource.update.mockImplementationOnce(async (_o: string, id: string, patch: Record) => ({ + ...ADA_ROW, + id, + ...patch, + is_edited: false, + })); + fireEvent.click(editIn(rowOf(view, ADA_BODY))!); + fireEvent.change(view.getByRole('textbox', { name: EN.detail.editComment }), { target: { value: NEW_TEXT } }); + await act(async () => { + fireEvent.click(view.getByRole('button', { name: EN.detail.save })); + }); + expect(dataSource.update).toHaveBeenCalledTimes(1); + const row = rowOf(view, NEW_TEXT); + expect(within(row).queryByText(EN.detail.edited)).toBeNull(); + expect(toast.error).not.toHaveBeenCalled(); + }); + it('a refused edit raises the error, and the row keeps its old text with the draft still open', async () => { const { view, dataSource } = await mount(block); dataSource.update.mockRejectedValueOnce(Object.assign(new Error('Forbidden'), { status: 403 })); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index a0741f1841..0294433f76 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -2468,10 +2468,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri * error and REJECTS back to the editor, which keeps the draft, and the row * keeps its old text. * - * The write marks the comment edited (`is_edited`, `edited_at`): the server - * stamps neither on update, so the client that makes the edit records it, - * and the next read shows the mark from the row itself. `mentions` is - * rewritten from the new text, so the row's id list stays the text's. + * The write sends the new text and nothing that marks the comment edited: + * the platform stamps `is_edited` and `edited_at` itself when the body + * changes, and both fields are read-only to the client (ruling A on + * objectstack-ai/objectstack#23124). The row reads as edited from the + * stored record the update answers with, the same field the next read + * shows it from, so the mark never says what the row does not. `mentions` + * is rewritten from the new text, so the row's id list stays the text's. */ const handleEditComment = useCallback( async (itemId: string | number, text: string) => { @@ -2491,20 +2494,20 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // the reaction handler refuses one (objectui#11035). const row = (feedItemsByRecord[key] ?? EMPTY_FEED).find(item => item.id === itemId); if (!row || row.type !== 'comment') return; + let stored: { is_edited?: unknown } | null | undefined; try { - await dataSource.update(COMMENT_OBJECT, String(itemId), { + stored = await dataSource.update(COMMENT_OBJECT, String(itemId), { body: text, mentions: JSON.stringify(extractMentions(text, mentionSuggestions)), - is_edited: true, - edited_at: new Date().toISOString(), }); } catch (err) { throw editFailed(err); } + const edited = stored?.is_edited === true; setFeedItemsByRecord(prev => ({ ...prev, [key]: (prev[key] ?? EMPTY_FEED).map(item => - item.id === itemId ? { ...item, body: text, edited: true } : item, + item.id === itemId ? { ...item, body: text, edited } : item, ), })); },