Repository navigation
feat(app-shell,plugin-detail): the author of a record comment can edit and delete it from the record feed (objectui#12268) - #12269
Conversation
…t and delete it from the record feed (objectui#12268) The record feed offers the author's Edit and Delete on their own comment and reply. Whose comment it is comes from one rule, `isCommentAuthor` in @object-ui/collaboration, which CommentThread now reads too; the record page asks it for each sys_comment row it maps (FeedItem.authoredByViewer) and hands the handlers over through DiscussionContext under new affordance rows (feedCommentEdit, feedCommentDelete). Delete asks through the page's own ActionConfirmDialog. The server's rule stays the authority: a refused write raises an error and the row keeps its text. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
…d Delete on their own record comment (objectui#12268) Three pins: the timeline offers the controls only on a comment row the host marked the viewer's and only with the host's handler; the real record page asks isCommentAuthor per row, edits with the comment marked edited, deletes through its own confirm, surfaces refusals, and hands each control over under its own grant, through both record:activity and record:discussion; CommentThread reads the same exported rule. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
…eset (objectui#12268) Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
…ivate (objectui#12268) Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…row of its own, so deleting a comment hides no replies (objectui#12268) With threading on, the timeline dropped every item with a parentId from its roots and nested replies only under parents it held. Once an author deleted a comment, every reply to it (another member's included) left the page, at once and after a re-read. A reply now nests only when the feed holds its parent and is otherwise a root row in its place. Who may delete stays the server's call; the client cascades nothing. Pinned through the real record page on record:activity and record:discussion, and in the timeline. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Inputs read: card #12268 (body and all five comments: triage ① Derived judgmentsEvery public-surface and accept-set change the diff implies, with the entry that carries it:
Gate verdicts on ② Semver levelThe changeset ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Generated by Claude Code |
Fixes #12268
Clause-②: yes
The record feed now offers the author of a comment Edit and Delete on their own comment and reply. Nobody else is offered either control on it. Triage
6127815908ruled fork A:sys_commentismanagedBy: 'platform', which declares the generic Edit / Delete affordances, so the feed honours that declaration and no spec key is added. TheClause-②: yesabove is the package surface (listed under "Published surface"), not the spec.What changes
isCommentAuthor(authorId, viewerId)is new in@object-ui/collaboration.CommentThreadnow reads it instead of its inline comparison, and the record page asks it for everysys_commentrow it maps. Two absent or empty ids are no match (the inline comparison read two empty ids as ownership).RecordDetailViewmarks each comment row with the new optionalFeedItem.authoredByViewer, through that rule, and handsonEditComment/onDeleteCommentto the feed throughDiscussionContext. Each handler is handed over under its own grant onsys_comment, through two new rows of the affordance-to-grant map:feedCommentEdit(update) andfeedCommentDelete(delete).RecordActivityTimeline, and the reply listThreadedReplies, offer Edit and Delete on acommentrow only when the host marked it the viewer's AND handed over that control's handler.record:activityandrecord:discussion(throughRecordChatterPanel) forward both handlers off the context. A row that offers neither renders the same body paragraph as before.body,mentions(re-derived from the new text with the composer'sextractMentions),is_edited: trueandedited_at, and changes the row only after the server answers. A refusal raises a localized error toast; the editor keeps the draft and the row keeps its text.confirmHandler, which opens the sameActionConfirmDialogthe record's own Delete uses, painted destructive (the mechanism objectui#12248 used for attachments). Cancel sends nothing. A refusal raises a localized error and the row stays. A deleted reply takes one off its parent'sreplyCount.is_editedonto the existingFeedItem.edited, so a stored edit reads as edited after a reload. A reply now shows the mark too.detail.*keys in all ten packs; docs incontent/docs/plugins/plugin-detail.mdx; changeset.changeset/12268-feed-comment-edit-delete.md(minor for every touched published package; nomajor).Measurements (the dispatch's mechanism assumptions)
is_editednoredited_at. On objectstackorigin/mainc5b43b60,git grep -cover the whole tree findsis_editedandedited_atonly inpackages/plugins/plugin-audit/src/objects/sys-comment.object.ts(the field declarations) and four generated translation files.comment-access-hooks.tshas zero hits for either; control: the string "authors govern their own words" hits that file once. ItsbeforeUpdateauthorizes (the author, or a user who can edit the parent record) and writes nothing. So the ruling's other branch applies: the client that makes the edit records it, and the next read shows the mark from the row itself.FeedItemhasactor(a display name) and no id, and the page'ssys_commentread droppedauthor_id. The route below never sends an id to the feed: the host answers ownership per row.plugin-detailandcollaborationshare only@object-ui/typesand@object-ui/i18n, neither of which carries behaviour, so (a) costs a new dependency edge or a logic export from the types package. (b) The host decides per row:app-shellalready imports@object-ui/collaboration(presence), soRecordDetailViewreads the exported rule with no new module edge, and the feed reads one boolean. Chosen: (b). It adds one export to collaboration and one optional data member toFeedItem, and the feed gains no dependency.CommentThreadreads the same export, so there is still exactly one rule.RecordDetailViewalready mountsActionConfirmDialogbehind its promise-basedconfirmHandler(the record Delete's, objectui#11001). The comment delete calls that handler: no new confirm component and no second dialog mount.Where the measured route left the suggested one: the
sys_commenttext field isbody, notcontent. And the feed'ssys_commentread does not re-run on the invalidation bus (its effect keys on the record, the reaction store and the user), so, ashandleAddComment/handleAddReplyalready do, the page writes the server-confirmed result into its own per-record feed slice once the write answers. No remount and nokey=bump (AGENTS.md rule 8).Published surface
Generic arguments are spelled in words (
PROMISE of void) because GitHub strips angle-bracket fragments from PR bodies.@object-ui/collaboration(entrydist/index.d.ts): new functionisCommentAuthor(authorId: string | null | undefined, viewerId: string | null | undefined): boolean.@object-ui/types,FeedItem: new memberauthoredByViewer?: boolean.@object-ui/react,DiscussionContextValue(and soDiscussionContextProviderProps): new membersonEditComment?: (itemId: string | number, text: string) => void | PROMISE of voidandonDeleteComment?: (itemId: string | number) => void | PROMISE of void.@object-ui/plugin-detail: the same two members, with the same types, onRecordActivityTimelineProps,RecordChatterPanelPropsandThreadedRepliesProps.CommentBody/CommentBodyPropsin the newCommentAuthorControlsmodule are not re-exported from the entry, and the package'sexportsmap names.only.@object-ui/core,AFFORDANCE_GRANTS: new rowsfeedCommentEdit: { crud: 'edit', grant: 'update' }andfeedCommentDelete: { crud: 'delete', grant: 'delete' }, soConsoleAffordancegains those two names. No exhaustive map overConsoleAffordanceexists outside core's own mapped type.@object-ui/i18n:detail.editComment,detail.deleteComment,detail.deleteCommentConfirm,detail.commentEditFailed,detail.commentDeleteFailed, in all ten packs.Every member is new and optional; no existing member's type changed.
Tests (round 0 at head
50c82f9; patch round 1 at258bfeebelow)New pins:
packages/plugin-detail/src/__tests__/RecordActivityTimeline.authorControls-12268.test.tsx(13): controls only on a comment row the host marked the viewer's and only with the handler; edit, refusal, empty/unchanged draft, Cancel/Escape; delete; edited mark on a row and a reply; a reply's own controls; both feed blocks forward the context's handlers.packages/app-shell/src/views/RecordDetailView.feedCommentEditDelete-12268.test.tsx(19), through the realRecordDetailViewunder a realI18nProviderandMePermissionsProvider, for bothrecord:activity(HotCRM's case-page shape) andrecord:discussion: the author sees both controls on their own comment and neither on another's; the page asksisCommentAuthorper row; another member sees them on their own comments and not on the author's; a storedis_editedreads as edited; the edit writesbody/is_edited/edited_at/mentionsand then reads as edited; a refused edit raises the error and keeps the text; delete through the realActionConfirmDialog(Cancel sends nothing, Delete deletes); a refused delete raises the error and keeps the row; each control under its own grant.packages/collaboration/src/__tests__/comment-authorship-12268.test.tsx(4): the rule's truth table, andCommentThreadcalls it.packages/plugin-form/src/affordanceGrantMap-12082.test.tsx(the two rows inEXPECTED_GRANT, andWRITE_SITESentries forhandleEditComment/handleDeleteComment).Runs, all through the verify lock, repo-root form,
--maxWorkers=2:packages/plugin-detail/+packages/collaboration/+ the census file:Test Files 271 passed | 1 skipped (272),Tests 2679 passed | 14 skipped (2693).packages/react/+packages/i18n/:Test Files 193 passed (193),Tests 2732 passed | 13 skipped (2745).RecordDetailView,DiscussionContext,RecordActivityTimeline,RecordChatterPanel,ThreadedReplies,DETAIL_DEFAULT_TRANSLATIONS,resolveAffordanceorAFFORDANCE_GRANTS, plus types'zod-mirror-parityandtimeline-item-element-6356and app-shell'scountLabels.ruAr-10242):76 passed (76)/1089 passed, and75 passed (75)/793 passed.RecordDetailViewwithout naming it (for example through console routes).Type-check (script
type-check, name echoed per package): types, react, core, collaboration, plugin-detail, app-shell, plugin-form, i18n joined with&&, exit 0. Downstream (consumer direction) of the widened symbols, selected by grepping the symbols: fields, plugin-calendar, plugin-gantt, plugin-grid, plugin-kanban, plugin-list, plugin-timeline, plugin-view and apps/console, allDone. Reverse check: handingonEditCommenta(id: boolean, text: number)function inRecordDetailViewturnedpnpm --filter @object-ui/app-shell type-checkred with TS2322 against(itemId: string | number, text: string) => void | PROMISE of void, so app-shell reads the rebuilt type; restored to the HEAD blob.Ablations, each through objectstack's
scripts/ablation-replace.mjs(anchor hit exactly once, blob changed on disk, restore proven blob == HEAD andgit diff HEADempty). The subjects resolve tosrcthrough the vitest alias map, so no build sits between legs:c.author_id === currentUser.idinline (a second owner rule)authoredByVieweris_editedmappingconfirmHandleris_edited: trueCommentThreadcompares inlineauthoredByViewerThe first A4 attempt was refused by the tool itself (the replacement text was already present once), so it measured nothing; it was re-run in
--deletemode, the row above.Patch round 1 (seat REWORK
6129134468):258bfee:RecordActivityTimeline.authorControls-12268.test.tsx+3 (with threading on, a reply whose parent the feed does not hold is a root row between the rows it sits between in time; CONTROL: with its parent held the same reply nests behind the toggle; the viewer's orphaned reply keeps its Edit and Delete).RecordDetailView.feedCommentEditDelete-12268.test.tsx+2, one per block (record:activity,record:discussion): Ada deletes her comment carrying Grace's reply through the real confirm; Grace's reply stays as a row of its own with no toggle and no controls for Ada, and stays on a fresh mount whosesys_commentread returns the stored rows, the orphan among them (the fake server, like the real one, keeps replies on delete).scripts/ablation-replace.mjs,rootItemsback to!i.parentId):Tests 4 failed | 33 passed (37)across the two files (both host cases, two timeline cases); restore blob == HEAD (6728eef7a2a4),git diff HEADempty.258bfee, through the verify lock: plugin-detail's whole suite in two halves,Test Files 132 passed (132)/Tests 955 passed | 6 skipped (961)andTest Files 131 passed | 1 skipped (132)/Tests 1595 passed | 8 skipped (1603); the same 151 grep-selected files as before,76 passed (76)/1089 passedand75 passed (75)/795 passed;pnpm --filter @object-ui/plugin-detail type-check && pnpm --filter @object-ui/app-shell type-checkexit 0; eslint on the 3 touched files 0 errors, warnings unchanged from50c82f9;check:eager-closureexit 0 (3131.2 KB, headroom 73.3 KB, console rebuilt at258bfee).Gates, at
50c82f9(exit 0 each):check-changeset-presence,check-changeset-no-major,check-changeset-fixed,check-changeset-overwrite,check:changeset-claims,check:pending-changeset-literals,check:new-line-citations(0 new),check:control-bytes,check:i18n-keys,check:i18n-drift,check:i18n-dead-keys,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:test-path-roots,check:readme-exports,check:unreferenced-sources,check:handler-key-reads,check:eager-closure,check:eager-locale-catalogues,check:doc-snippets,check:doc-fences,check:doc-json,check:doc-example-ids,check:doc-types,check:self-import,check-doc-links,check-doc-expression-carriage. The eager-closure gates read a console bundle built at029d951; the one later commit removes anexportkeyword from a module-private function.eslint on the 28 changed lintable files,
--format json: 0 errors. On every one of the 23 modified files the warning count at the base equals the count at head. The two new source files have 0 warnings; the new tests carryas anywarnings like their neighbours.eslint.config.jsenables no type-aware linting (noparserOptions.projectorprojectService), so this diff cannot move a verdict on an untouched file.Acceptance notes
is_editednoredited_at, so an edit through the data API (not this console) leaves the comment reading unedited, and any client may sendis_edited: false. AbeforeUpdatestamp in objectstack (set both whenbodychanges) would make the mark trustworthy; once it exists, this client stamp should go (the ruling's first branch). Inferred from source, not measured through a public door, so not filed. Carrier: the next objectstack change tocomment-access-hooks.ts.parent_idthen names no row. With threading on, the feed now nests a reply only when it holds the reply's parent; a reply whose parent the feed does not hold is a root row in its place (rootItemsinRecordActivityTimeline), so another member's reply stays on the page, at once and after the feed is read again. The same rule shows a reply whose parent lies before the loaded page as a row of its own until "Load more" brings the parent in; before, such a reply was hidden. The client cascades nothing and adds no rule about which comments may be deleted: that stays the server's call.mentionsis still rewritten so the row's list matches its text.platform-objectspath andreactionsin thesys_commentread's field list) sits in lines this change edits; it now namessys_commentin@objectstack/plugin-audit, listsis_edited/edited_at, and scopesreactionsto the column store.canStoreReaction's docblock inRecordActivityTimeline.tsxstill says reactions are stored insys_comment.reactions; untouched here. Carrier: the next change to the reaction path.Generated by Claude Code