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