Skip to content

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, targeting next/minor
  • Precedent: MR !183574 (journals live updates)

Scope

In scope, phase 1:

  • One subscription covering every issues column and every custom field
  • Broadcast from Issue after_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: true
  • field :result, Results::IssueAttributesChangedResult, null: true
  • update runs the three checks, then NO_UPDATE when the visible key set is empty
  • visible_changed_attributes filters attr keys through journalized_attribute_names and cf_* keys through CustomField#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:

field :issue_attributes_changed, subscription: Subscriptions::IssueAttributesChanged

5. Broadcast from Issue

app/models/issue.rb:

after_commit :broadcast_attributes_changed, on: :update

Two private methods, modelled on Journal#broadcast_journals_changed:

  • broadcast_changed_attribute_keys - saved_changes.keys minus journalized_options[:non_journalized_columns] minus %w[id created_on updated_on lft rgt lock_version], plus Array(changed_custom_fields)
  • broadcast_attributes_changed - early return if keys.empty?, then trigger, then rescue StandardError and 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.

  • subscribe returns the issue when visible; raises RecordNotFound when not
  • update returns NO_UPDATE for a deleted subscriber, an issue that became invisible, and an empty visible key set
  • estimated_hours is filtered out without :view_estimated_hours; when it is the whole payload the result is NO_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_id broadcasts nothing
  • a custom-field-only change broadcasts ["cf_<id>"] - the case saved_changes alone would miss
  • actor_id is the journal's user, falling back to User.current for 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:

direnv exec . bundle exec rake easyproject:graphql_generate

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.ts and its .generated.ts
  • app/frontend/src/issue_detail_v2/composables/useIssueAttributesLiveUpdates.ts, wrapping useLiveUpdates the way useJournalsLiveUpdates does

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 requestId and must apply only the newest response, the way useCommentsStore does. 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

yarn test && yarn lint && yarn tsc

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)