Implementation plan: live issue attributes
Backend for issueAttributesChanged - the live-update subscription behind the issue detail v2 attributes panel.
Design and rationale: Live updates. This file is the execution checklist and can be deleted once the work lands.
- Owner component: core (
app/api/easy_graphql,app/models/issue.rb) - First attributes: assignee, status, priority, due date
- Branch:
feature/[TICKET_ID_]live_issue_attributes, targetingnext/minor - Precedent: MR !183574 (journals live updates)
Scope
In scope, phase 1:
- One subscription covering every
issuescolumn and every custom field - Broadcast from
Issueafter_commit - Per-subscriber filtering of the changed-key list
- Specs for both
Out of scope:
- Association-backed attributes - watchers, tags, relations, spent time. The vocabulary and the broadcast method are shaped to take them later; see the extension table in the design doc.
- The attributes panel itself, its query and its components
- An explicit "deleted" or "access lost" event. The subscription goes quiet, matching journals.
Open decision
Where the panel reads its attributes from is undecided, and does not block phase 1 - the subscription's contract is "something you can see changed", whatever the panel reads from. It only decides what refresh() calls in step 8.
| Option A - GraphQL query + store | Option B - server-rendered props + query for refetch | |
|---|---|---|
| First load | One request after mount | No request; props from show_v2.html.erb |
| Refetch | Same query | Same query |
| Risk | Slower first paint | Two representations of the same data; ERB and GraphQL gating can drift |
| Precedent | useCommentsStore |
subject / subjectEditable props; easy_issues#form_fields_v2 |
Option A is the lower-risk default. Option B is worth it only if first paint on the panel measures as a problem.
Phase 1 - backend
1. Extract the shared subscription concern
Pull the issue-scoped scaffolding out of EasyGraphql::Subscriptions::IssueJournalsChanged into app/api/easy_graphql/subscriptions/concerns/issue_scoped.rb: subscribe, subscriber, issue_visible?, payload, project.
Carry the existing comments across with the code - they explain non-obvious choices (the User.current.reload, the per-event visibility re-check) that a reader of the concern would otherwise re-litigate.
Refactor only; no behaviour change.
Verify: direnv exec . bundle exec rspec spec/easy_graphql/subscriptions/issue_journals_changed_spec.rb passes untouched.
If the team would rather not touch a just-merged file, skip this step and duplicate the scaffolding in step 3 instead. Everything downstream is unaffected.
2. Result type
app/api/easy_graphql/subscriptions/results/issue_attributes_changed_result.rb
class IssueAttributesChangedResult < Types::Base
field :event, String, null: false
field :changed_attributes, [String], null: false
field :actor_id, ID, null: true
field :journal_id, ID, null: true
end
3. Subscription class
app/api/easy_graphql/subscriptions/issue_attributes_changed.rb, including the concern from step 1.
argument :issue_id, ID, required: truefield :result, Results::IssueAttributesChangedResult, null: trueupdateruns the three checks, thenNO_UPDATEwhen the visible key set is emptyvisible_changed_attributesfiltersattrkeys throughjournalized_attribute_namesandcf_*keys throughCustomField#visible_by?
Watch: Issue#journalized_attribute_names reads User.current, while subscriber is a separately reloaded object. Bind User.current around that call, or take the user explicitly the way Journal#visible_details(user) does. Getting this wrong is silent.
4. Register the field
One line in app/api/easy_graphql/types/subscription.rb:
5. Broadcast from Issue
app/models/issue.rb:
Two private methods, modelled on Journal#broadcast_journals_changed:
broadcast_changed_attribute_keys-saved_changes.keysminusjournalized_options[:non_journalized_columns]minus%w[id created_on updated_on lft rgt lock_version], plusArray(changed_custom_fields)broadcast_attributes_changed- earlyreturn if keys.empty?, thentrigger, thenrescue StandardErrorand log
The early return is load-bearing: without it every save that touched only bookkeeping columns wakes every subscriber on that issue.
after_commit does not run for update_columns / update_all. Inside Issue the only such path is close_children (issue.rb:2238), which closes descendants and writes their journals by hand - so issueJournalsChanged fires there but the attribute broadcast would not. Decide explicitly whether closing a parent should move an open subtask's panel; if yes, add a trigger alongside the hand-written journal. The remaining bypasses are administrative (IssuePriority, IssueStatus, Tracker, IssueCategory reassignment on destroy, assignee nulling on user/group destroy) and are out of scope.
Payload: event: "updated", changed_attributes:, actor_id: current_journal&.user_id || User.current.id, journal_id: current_journal&.id, project_id:.
6. Specs
spec/easy_graphql/subscriptions/issue_attributes_changed_spec.rb - mirrors the journals spec, which builds a bare GraphQL::Query::Context and calls subscribe / update directly.
subscribereturns the issue when visible; raisesRecordNotFoundwhen notupdatereturnsNO_UPDATEfor a deleted subscriber, an issue that became invisible, and an empty visible key setestimated_hoursis filtered out without:view_estimated_hours; when it is the whole payload the result isNO_UPDATE- an invisible custom field is filtered out
- a visible and an invisible key in the same event: one survives, one is dropped
spec/models/issue_spec.rb - same shape as the trigger assertions at spec/models/journal_spec.rb:396.
- payload keys per change type: status, assignee, priority, due date
- a save touching only
updated_on/easy_last_updated_by_idbroadcasts nothing - a custom-field-only change broadcasts
["cf_<id>"]- the casesaved_changesalone would miss actor_idis the journal's user, falling back toUser.currentfor journal-less writes- the rescue path logs and does not raise
7. Checks
direnv exec . bundle exec rspec spec/easy_graphql/subscriptions spec/models/issue_spec.rb spec/models/journal_spec.rb
Then RuboCop on every changed .rb file, and regenerate the frontend schema types:
Manual check worth doing once, since no spec exercises real cable delivery: open the same issue as two different users in two browsers, change the status as one, confirm the other's subscription receives the event with the expected changedAttributes.
Phase 2 - frontend
Blocked on the attributes panel existing. Listed so phase 1 is built against the right shape.
8. Subscription document and composable
app/frontend/src/issue_detail_v2/graphql/subscriptions/issueAttributesChanged.tsand its.generated.tsapp/frontend/src/issue_detail_v2/composables/useIssueAttributesLiveUpdates.ts, wrappinguseLiveUpdatesthe wayuseJournalsLiveUpdatesdoes
Subscribe once per issue detail, in the attributes store - not inside each field component, or the design is back to one subscription per attribute and the single-topic argument is lost.
No echo suppression: the actor refetches too. Nothing to build for here - useLiveUpdates has no opt-out, so this composable gets the rule for free. The reasoning is in Live updates: the server's answer routinely differs from what the actor expects, a status change also moving done_ratio and closed_on, rolling priority and dates up to the parent, and changing which transitions are offered next.
Two consequences to build for:
- The store needs a monotonically increasing
requestIdand must apply only the newest response, the wayuseCommentsStoredoes. This is now load-bearing, not defensive: the actor can edit a second field while the first field's own broadcast is still in flight, and a late response must not clobber the newer one. - A dirty guard on an open editor is required, not optional - the actor's own refetch must not reload under a control they have open.
9. Fan out to fields
changedAttributes drives which fields highlight, and lets a field with an open editor warn instead of reloading under the user. actorId still earns its place in the payload even without echo suppression: it is how a field decides not to flash a highlight at the person who caused the change.
10. Frontend checks
Risks
| Risk | Mitigation |
|---|---|
| Broadcast fires on every issue save application-wide | Early return on an empty key set; the denylist is non_journalized_columns, which already excludes the columns that move on every save |
after_commit raising into a save |
rescue StandardError and log, as Journal#broadcast_journals_changed does |
| Stale permissions on a long-lived connection | User.current.reload in subscriber; visibility re-checked on every event, not only on subscribe |
| Custom field changes missed | changed_custom_fields, not saved_changes; covered by its own spec case |
| Bulk edit fans out one trigger per issue | Accepted - unchanged from what journal creation already does |
update_columns / update_all paths skip the hook |
Documented in step 5 and in the design doc; close_children is the one case worth a decision, the rest are administrative |
| The actor's own broadcast races their next edit | No echo suppression means this happens on every edit, not occasionally. Request-generation guarding in the store (step 8) and a dirty guard on open editors (step 9) |