Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/12314-comment-edit-stamp.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Record<string, unknown>> = ROWS) {
let stored = rows.map((c) => ({ ...c }));
Expand All @@ -211,7 +217,16 @@ function makeDataSource(rows: Array<Record<string, unknown>> = 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<string, unknown> = { ...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;
Expand Down Expand Up @@ -350,7 +365,7 @@ describe.each<Block>(['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 } });
Expand All @@ -361,14 +376,34 @@ describe.each<Block>(['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<string, unknown>) => ({
...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 }));
Expand Down
19 changes: 11 additions & 8 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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,
),
}));
},
Expand Down
Loading