From 3e3d08f06196a613feb7cd639f7857b03179ea54 Mon Sep 17 00:00:00 2001 From: Mateusz7410 Date: Sat, 7 Nov 2026 13:08:28 +0000 Subject: [PATCH] fix: keep timeline chip counts in step with the rows shown The card read chain statuses first and the rows later, so a job that finished in between left In flight counted next to a completed row, and polling stopped with the stale count. Counts for the page chains now come from the same rows, and the fingerprint is read before them. Experience Cloud targets are dropped from the card until access is scoped to the record. The docs say AsyncResultAccess is View All. Refs #77 --- .../timeline/AsyncJobTimelineChains.cls | 18 +++++++++ .../AsyncJobTimelineControllerTest.cls | 31 +++++++++++++++ .../timeline/AsyncJobTimelineLoader.cls | 12 ++++-- .../asyncJobTimeline.js-meta.xml | 38 ------------------- release-notes/v3.2.0.md | 2 +- website/ai-usage.md | 3 +- website/features/job-timeline.md | 4 +- website/introduction/packaged-install.md | 4 +- 8 files changed, 65 insertions(+), 47 deletions(-) diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls b/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls index 55a2e50..1e54732 100644 --- a/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineChains.cls @@ -102,6 +102,24 @@ public with sharing class AsyncJobTimelineChains { return chainIds; } + public void refreshFrom(List rows) { + Map chainById = new Map(); + for (ChainOrder chain : chains) { + chainById.put(chain.chainId, chain); + } + Set refreshed = new Set(); + for (AsyncResult__c row : rows) { + ChainOrder chain = chainById.get(row.ChainId__c); + if (chain == null) { + continue; + } + if (refreshed.add(chain.chainId)) { + chain.status = STATUS_COMPLETED; + } + chain.absorb(chainStatusFor(row.Status__c)); + } + } + public Map counts() { Map counts = new Map{ 'all' => chains.size(), diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls b/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls index f8d9203..cb2836c 100644 --- a/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineControllerTest.cls @@ -203,6 +203,37 @@ private class AsyncJobTimelineControllerTest { Assert.areEqual(new List{ 'done' }, chainIdsOf(onlyDone)); } + @IsTest + private static void shouldCountAPageChainByTheRowsReadForThePage() { + Account order = new Account(Name = 'Order'); + insert order; + List live = seedChain( + order, + 'live', + 'Live run', + 2, + System.now().addHours(-1) + ); + live[1].Status__c = QueueableManager.STATUS_ENQUEUED; + update live[1]; + Account otherOrder = new Account(Name = 'Other order'); + insert otherOrder; + seedChain(otherOrder, 'elsewhere', 'Elsewhere run', 1, System.now().addHours(-1)); + AsyncJobTimelineChains chains = new AsyncJobTimelineChains(order.Id, 7); + live[1].Status__c = QueueableManager.STATUS_COMPLETED; + update live[1]; + + chains.refreshFrom([SELECT ChainId__c, Status__c FROM AsyncResult__c]); + + Assert.areEqual( + 0, + chains.counts().get('inflight'), + 'The step finished after the snapshot.' + ); + Assert.areEqual(1, chains.counts().get('completed')); + Assert.areEqual(1, chains.counts().get('all'), 'Rows of another record are ignored.'); + } + @IsTest private static void shouldLinkARequeuedRowToItsReplay() { Account order = new Account(Name = 'Order'); diff --git a/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls b/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls index 7d6c0b4..cd1820d 100644 --- a/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls +++ b/force-app/main/default/classes/timeline/AsyncJobTimelineLoader.cls @@ -21,15 +21,20 @@ public with sharing class AsyncJobTimelineLoader { AsyncJobTimelineController.TimelinePage page = new AsyncJobTimelineController.TimelinePage(); page.hasMore = chainIds.size() > chainOffset + chainLimit; page.totalChainCount = chainIds.size(); + page.fingerprint = fingerprintOf(recordId, pageChainIds); + List rows = rowsOf(pageChainIds); + chains.refreshFrom(rows); page.counts = chains.counts(); page.olderChainCount = chains.olderCount(); - page.sequences = sequencesOf(pageChainIds); - page.fingerprint = fingerprintOf(recordId, pageChainIds); + page.sequences = sequencesOf(pageChainIds, rows); page.loadedAt = System.now(); return page; } - private static List sequencesOf(List chainIds) { + private static List sequencesOf( + List chainIds, + List rows + ) { Map byChain = new Map(); for (String chainId : chainIds) { byChain.put(chainId, new AsyncJobTimelineController.Sequence(chainId)); @@ -37,7 +42,6 @@ public with sharing class AsyncJobTimelineLoader { if (chainIds.isEmpty()) { return byChain.values(); } - List rows = rowsOf(chainIds); Map apexJobStatusById = apexJobStatusesFor(enqueuedJobIdsOf(rows)); Map nameById = AsyncJobTimelineNames.of(linkedRecordIdsOf(rows)); Map replayBySourceId = replaysOf(rows); diff --git a/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js-meta.xml b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js-meta.xml index 7e550f4..5664aeb 100644 --- a/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js-meta.xml +++ b/force-app/main/default/lwc/asyncJobTimeline/asyncJobTimeline.js-meta.xml @@ -6,8 +6,6 @@ Every async job that ran, runs or will run for this record, with requeue for failed steps. lightning__RecordPage - lightningCommunity__Page - lightningCommunity__Default @@ -49,41 +47,5 @@ default="false" description="When on, a chain that enters in flight opens by itself and the others collapse, for a card left open on a screen. Off: the new chain is only flagged. The viewer can switch it for the session."/> - - - - - - - - - diff --git a/release-notes/v3.2.0.md b/release-notes/v3.2.0.md index c416959..4557f31 100644 --- a/release-notes/v3.2.0.md +++ b/release-notes/v3.2.0.md @@ -78,7 +78,7 @@ Finalizers of the current job still run first. Closes #68. requeue, and flags an idle card that has not checked for ten minutes. - The card has a range menu, counts and filters chains across the whole range, pages in memory through blocks of 50 chains, scrolls inside a configurable height, asks before a requeue, opens - linked records and the replay of a requeued step, and targets Experience Cloud record pages. + linked records and the replay of a requeued step. - The chain map is a pipeline of nodes with elbow connectors for dependencies, opened on the first failed node. - The card keeps a fixed height, fits its page size to it, refreshes in place, opens one chain by diff --git a/website/ai-usage.md b/website/ai-usage.md index 73d9d5b..99b5c63 100644 --- a/website/ai-usage.md +++ b/website/ai-usage.md @@ -219,7 +219,8 @@ In tests, inject them: `AsyncMock.jobSettings(new List One row per job, written after its last attempt, when `CreateResult__c` is on, a payload is stored, or the job is linked to a record; written at enqueue instead when `TrackJobProgress__c` is `Yes`. A batch writes one row per failed scope and nothing for a successful one. Read access through -the `AsyncResultAccess` permission set, which also covers `AsyncResultLink__c` (one row per result +the `AsyncResultAccess` permission set (View All: every result in the org, the record page card is +not a sharing boundary), which also covers `AsyncResultLink__c` (one row per result and linked record: `Key__c` indexed, `Source__c` `Salesforce` or whatever `linkExternalRecord` was given, `ObjectName__c`). The requeue actions on the record page need the `AsyncRequeue` custom permission from the `AsyncRequeueAccess` permission set. diff --git a/website/features/job-timeline.md b/website/features/job-timeline.md index 1153d0c..567fb19 100644 --- a/website/features/job-timeline.md +++ b/website/features/job-timeline.md @@ -124,7 +124,7 @@ A chunk run that still misses pages but has been quiet for ten minutes counts as ## The record page component -`Async Jobs` targets any record page, in Lightning and in Aura-based Experience sites. One card per +`Async Jobs` targets any Lightning record page. One card per chain, newest first by the time the chain was created. A chain never moves when it finishes, so the row you are watching stays where it is, and only a new run shifts the list. The `In flight` chip turns blue with a pulsing dot while something runs: click it to see only those chains. A chain that finishes @@ -232,6 +232,8 @@ What the card refuses to render without a way out: The viewer needs read on `AsyncResult__c` and `AsyncResultLink__c` plus access to the controller class. The `AsyncResultAccess` permission set grants all of it. Without it the card says so by name. +It is read on every result in the org, with View All, so the card is not a sharing boundary: give +it to people who may see every job, not only the ones for the record in front of them. ## Requeue from the timeline diff --git a/website/introduction/packaged-install.md b/website/introduction/packaged-install.md index 4ba54e0..a6c9cc4 100644 --- a/website/introduction/packaged-install.md +++ b/website/introduction/packaged-install.md @@ -97,8 +97,8 @@ actions on the `Async Jobs` record page component. Assign it to operators only. ### 6. Place the record page component -`Async Jobs` is in App Builder under custom components, for Lightning record pages and Aura-based -Experience site record pages. Drop it on any record page; the viewer needs +`Async Jobs` is in App Builder under custom components, for Lightning record pages. Drop it on any +record page; the viewer needs `btcdev__AsyncResultAccess`. See [Job Timeline](/features/job-timeline). ## Feature by feature