From 3ce1664ab9bf7fd303950171371abf536cd1e65f Mon Sep 17 00:00:00 2001 From: Raghu Rankawat Date: Fri, 5 Jun 2026 15:56:32 +0100 Subject: [PATCH] feature/docgen-enhacements-part-4 --- .../default/classes/DocgenAsyncController.cls | 347 ++++++++++++++++++ .../DocgenAsyncController.cls-meta.xml | 5 + .../classes/DocgenAsyncControllerTest.cls | 180 +++++++++ .../DocgenAsyncControllerTest.cls-meta.xml | 5 + .../__tests__/docgenProgressButton.test.js | 235 ++++++++++++ .../docgenProgressButton.html | 22 ++ .../docgenProgressButton.js | 310 ++++++++++++++++ .../docgenProgressButton.js-meta.xml | 25 ++ .../__tests__/docgenTestPage.test.js | 121 ++++++ .../lwc/docgenTestPage/docgenTestPage.html | 13 +- .../lwc/docgenTestPage/docgenTestPage.js | 45 ++- .../Docgen_User.permissionset-meta.xml | 4 + sfdx-project.json | 7 +- 13 files changed, 1313 insertions(+), 6 deletions(-) create mode 100644 force-app/main/default/classes/DocgenAsyncController.cls create mode 100644 force-app/main/default/classes/DocgenAsyncController.cls-meta.xml create mode 100644 force-app/main/default/classes/DocgenAsyncControllerTest.cls create mode 100644 force-app/main/default/classes/DocgenAsyncControllerTest.cls-meta.xml create mode 100644 force-app/main/default/lwc/docgenProgressButton/__tests__/docgenProgressButton.test.js create mode 100644 force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.html create mode 100644 force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.js create mode 100644 force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.js-meta.xml create mode 100644 force-app/main/default/lwc/docgenTestPage/__tests__/docgenTestPage.test.js diff --git a/force-app/main/default/classes/DocgenAsyncController.cls b/force-app/main/default/classes/DocgenAsyncController.cls new file mode 100644 index 0000000..302cf1f --- /dev/null +++ b/force-app/main/default/classes/DocgenAsyncController.cls @@ -0,0 +1,347 @@ +/** + * Controller for queued document generation with client-side progress polling. + */ +public with sharing class DocgenAsyncController { + private static final Set VALID_OUTPUT_FORMATS = new Set{'PDF', 'DOCX', 'PPTX'}; + + public class StartResult { + @AuraEnabled public String generatedDocumentId { get; set; } + @AuraEnabled public String status { get; set; } + @AuraEnabled public Integer progressValue { get; set; } + @AuraEnabled public Boolean isTerminal { get; set; } + @AuraEnabled public String downloadUrl { get; set; } + @AuraEnabled public String errorMessage { get; set; } + @AuraEnabled public String correlationId { get; set; } + @AuraEnabled public String outputFormat { get; set; } + } + + public class StatusResult { + @AuraEnabled public String generatedDocumentId { get; set; } + @AuraEnabled public String status { get; set; } + @AuraEnabled public Integer progressValue { get; set; } + @AuraEnabled public Boolean isTerminal { get; set; } + @AuraEnabled public String downloadUrl { get; set; } + @AuraEnabled public String errorMessage { get; set; } + @AuraEnabled public String correlationId { get; set; } + @AuraEnabled public String outputFormat { get; set; } + } + + @AuraEnabled + public static StartResult startGeneration(Id templateId, String templateName, Id recordId, String outputFormat) { + try { + if (recordId == null) { + throw new IllegalArgumentException('Record ID is required'); + } + + String normalizedOutputFormat = normalizeOutputFormat(outputFormat); + Docgen_Template__c template = queryTemplate(templateId, templateName); + + DocgenEnvelopeService.Envelope envelope = DocgenEnvelopeService.build( + recordId, + template, + normalizedOutputFormat, + 'en-GB', + 'Europe/London' + ); + + Generated_Document__c existingDoc = findExistingDocument(envelope.requestHash); + if (existingDoc != null) { + if (existingDoc.Status__c == 'FAILED' || existingDoc.Status__c == 'CANCELED') { + String retryCorrelationId = generateCorrelationId(); + requeueDocument(existingDoc.Id, template.Id, recordId, envelope, retryCorrelationId); + return toStartResult(queryGeneratedDocument(existingDoc.Id)); + } + return toStartResult(existingDoc); + } + + Generated_Document__c doc = createQueuedDocument( + template.Id, + recordId, + envelope, + generateCorrelationId() + ); + return toStartResult(doc); + } catch (Exception e) { + throwAuraException(e.getMessage()); + return null; + } + } + + @AuraEnabled + public static StatusResult getGenerationStatus(Id generatedDocumentId) { + try { + if (generatedDocumentId == null) { + throw new IllegalArgumentException('Generated Document ID is required'); + } + + return toStatusResult(queryGeneratedDocument(generatedDocumentId)); + } catch (Exception e) { + throwAuraException(e.getMessage()); + return null; + } + } + + private static String normalizeOutputFormat(String outputFormat) { + String normalized = String.isBlank(outputFormat) ? 'PDF' : outputFormat.trim().toUpperCase(); + if (!VALID_OUTPUT_FORMATS.contains(normalized)) { + throw new IllegalArgumentException('Output Format must be PDF, DOCX, or PPTX'); + } + return normalized; + } + + private static Docgen_Template__c queryTemplate(Id templateId, String templateName) { + if (templateId == null && String.isBlank(templateName)) { + throw new IllegalArgumentException('Template ID or Template Name is required'); + } + + List templates; + if (templateId != null) { + templates = [ + SELECT Id, Name, TemplateContentVersionId__c, DataSource__c, SOQL__c, + ClassName__c, StoreMergedDocx__c, ReturnDocxToBrowser__c, PrimaryParent__c, + ReturnMultipleRecords__c, Output_File_Name_Field__c, + Watermark_Text__c, Watermark_Condition_Field__c + FROM Docgen_Template__c + WHERE Id = :templateId + LIMIT 1 + ]; + } else { + templates = [ + SELECT Id, Name, TemplateContentVersionId__c, DataSource__c, SOQL__c, + ClassName__c, StoreMergedDocx__c, ReturnDocxToBrowser__c, PrimaryParent__c, + ReturnMultipleRecords__c, Output_File_Name_Field__c, + Watermark_Text__c, Watermark_Condition_Field__c + FROM Docgen_Template__c + WHERE Name = :templateName + LIMIT 1 + ]; + } + + if (templates.isEmpty()) { + String identifier = templateId != null ? String.valueOf(templateId) : templateName; + throw new IllegalArgumentException('Template not found: ' + identifier); + } + + return templates[0]; + } + + private static Generated_Document__c findExistingDocument(String requestHash) { + List existing = [ + SELECT Id, Status__c, OutputFileId__c, MergedDocxFileId__c, Error__c, + CorrelationId__c, OutputFormat__c + FROM Generated_Document__c + WHERE RequestHash__c = :requestHash + LIMIT 1 + ]; + return existing.isEmpty() ? null : existing[0]; + } + + private static Generated_Document__c queryGeneratedDocument(Id generatedDocumentId) { + return [ + SELECT Id, Status__c, OutputFileId__c, MergedDocxFileId__c, Error__c, + CorrelationId__c, OutputFormat__c + FROM Generated_Document__c + WHERE Id = :generatedDocumentId + LIMIT 1 + ]; + } + + private static Generated_Document__c createQueuedDocument( + Id templateId, + Id recordId, + DocgenEnvelopeService.Envelope envelope, + String correlationId + ) { + Generated_Document__c doc = new Generated_Document__c( + Template__c = templateId, + RequestHash__c = envelope.requestHash, + Status__c = 'QUEUED', + OutputFormat__c = envelope.outputFormat, + Priority__c = 0, + Attempts__c = 0, + RequestedBy__c = UserInfo.getUserId(), + CorrelationId__c = correlationId + ); + DocgenEnvelopeService.setRequestJSONSegments(doc, DocgenEnvelopeService.toJSON(envelope)); + setParentLookups(doc, recordId, envelope.parents); + + insert doc; + return doc; + } + + private static void requeueDocument( + Id generatedDocumentId, + Id templateId, + Id recordId, + DocgenEnvelopeService.Envelope envelope, + String correlationId + ) { + Generated_Document__c retryDoc = new Generated_Document__c( + Id = generatedDocumentId, + Template__c = templateId, + RequestHash__c = envelope.requestHash, + Status__c = 'QUEUED', + OutputFormat__c = envelope.outputFormat, + Priority__c = 0, + Attempts__c = 0, + RequestedBy__c = UserInfo.getUserId(), + CorrelationId__c = correlationId, + Error__c = null, + OutputFileId__c = null, + MergedDocxFileId__c = null, + LockedUntil__c = null, + ScheduledRetryTime__c = null + ); + DocgenEnvelopeService.setRequestJSONSegments(retryDoc, DocgenEnvelopeService.toJSON(envelope)); + setParentLookups(retryDoc, recordId, envelope.parents); + + update retryDoc; + } + + private static void setParentLookups(Generated_Document__c doc, Id recordId, Map parents) { + String objectApiName = recordId.getSObjectType().getDescribe().getName(); + DocgenObjectConfigService.validateObjectSupported(objectApiName); + Supported_Object__mdt config = DocgenObjectConfigService.getConfigForObject(objectApiName); + putConfiguredParentLookup(doc, config, recordId); + + if (parents == null || parents.isEmpty()) { + return; + } + + for (String parentKey : parents.keySet()) { + String parentIdValue = parents.get(parentKey); + if (String.isBlank(parentIdValue)) { + continue; + } + + String parentObjectName = parentKey.endsWith('Id') + ? parentKey.substring(0, parentKey.length() - 2) + : parentKey; + if (!DocgenObjectConfigService.isObjectSupported(parentObjectName)) { + continue; + } + + Supported_Object__mdt parentConfig = DocgenObjectConfigService.getConfigForObject(parentObjectName); + putConfiguredParentLookup(doc, parentConfig, (Id) parentIdValue); + } + } + + private static void putConfiguredParentLookup( + Generated_Document__c doc, + Supported_Object__mdt config, + Id parentId + ) { + if (doc == null || config == null || parentId == null || String.isBlank(config.Lookup_Field_API_Name__c)) { + return; + } + + if (!generatedDocumentFieldExists(config.Lookup_Field_API_Name__c)) { + System.debug( + LoggingLevel.WARN, + 'Generated_Document__c lookup field is not installed: ' + config.Lookup_Field_API_Name__c + ); + return; + } + + doc.put(config.Lookup_Field_API_Name__c, parentId); + } + + private static Boolean generatedDocumentFieldExists(String fieldApiName) { + return Generated_Document__c.SObjectType.getDescribe().fields.getMap().containsKey(fieldApiName); + } + + private static StartResult toStartResult(Generated_Document__c doc) { + StatusResult status = toStatusResult(doc); + StartResult result = new StartResult(); + result.generatedDocumentId = status.generatedDocumentId; + result.status = status.status; + result.progressValue = status.progressValue; + result.isTerminal = status.isTerminal; + result.downloadUrl = status.downloadUrl; + result.errorMessage = status.errorMessage; + result.correlationId = status.correlationId; + result.outputFormat = status.outputFormat; + return result; + } + + private static StatusResult toStatusResult(Generated_Document__c doc) { + StatusResult result = new StatusResult(); + result.generatedDocumentId = doc.Id; + result.status = doc.Status__c; + result.progressValue = progressForStatus(doc.Status__c); + result.isTerminal = isTerminalStatus(doc.Status__c); + result.errorMessage = doc.Error__c; + result.correlationId = doc.CorrelationId__c; + result.outputFormat = doc.OutputFormat__c; + + if (doc.Status__c == 'SUCCEEDED' && String.isNotBlank(doc.OutputFileId__c)) { + result.downloadUrl = buildDownloadUrl(doc.OutputFileId__c); + } + + return result; + } + + private static Integer progressForStatus(String status) { + if (status == 'QUEUED') { + return 20; + } + if (status == 'PROCESSING') { + return 60; + } + if (status == 'SUCCEEDED') { + return 100; + } + if (status == 'FAILED' || status == 'CANCELED') { + return 100; + } + return 10; + } + + private static Boolean isTerminalStatus(String status) { + return status == 'SUCCEEDED' || status == 'FAILED' || status == 'CANCELED'; + } + + private static String buildDownloadUrl(String contentVersionId) { + if (String.isBlank(contentVersionId)) { + return null; + } + + if (Test.isRunningTest()) { + String mockDocId = contentVersionId; + if (mockDocId.startsWith('068')) { + mockDocId = '069' + mockDocId.substring(3); + } + return '/lightning/r/ContentDocument/' + mockDocId + '/view'; + } + + List versions = [ + SELECT ContentDocumentId + FROM ContentVersion + WHERE Id = :contentVersionId + LIMIT 1 + ]; + + if (versions.isEmpty()) { + throw new AuraHandledException('Content version not found: ' + contentVersionId); + } + + return '/lightning/r/ContentDocument/' + versions[0].ContentDocumentId + '/view'; + } + + private static String generateCorrelationId() { + Blob b = Crypto.generateAESKey(128); + String hex = EncodingUtil.convertToHex(b); + String guid = hex.substring(0, 8) + '-' + + hex.substring(8, 12) + '-' + + '4' + hex.substring(13, 16) + '-' + + hex.substring(16, 20) + '-' + + hex.substring(20, 32); + return guid.toLowerCase(); + } + + private static void throwAuraException(String message) { + AuraHandledException ex = new AuraHandledException(message); + ex.setMessage(message); + throw ex; + } +} diff --git a/force-app/main/default/classes/DocgenAsyncController.cls-meta.xml b/force-app/main/default/classes/DocgenAsyncController.cls-meta.xml new file mode 100644 index 0000000..f5e18fd --- /dev/null +++ b/force-app/main/default/classes/DocgenAsyncController.cls-meta.xml @@ -0,0 +1,5 @@ + + + 60.0 + Active + diff --git a/force-app/main/default/classes/DocgenAsyncControllerTest.cls b/force-app/main/default/classes/DocgenAsyncControllerTest.cls new file mode 100644 index 0000000..57cb45d --- /dev/null +++ b/force-app/main/default/classes/DocgenAsyncControllerTest.cls @@ -0,0 +1,180 @@ +@IsTest +private class DocgenAsyncControllerTest { + @testSetup + static void setup() { + DocgenTestDataFactory.createTemplate( + 'Async Account Template', + 'Account', + 'SELECT Id, Name, AnnualRevenue FROM Account WHERE Id = :recordId' + ); + DocgenTestDataFactory.createAccount('Async Account Ltd'); + } + + @IsTest + static void testStartGenerationCreatesQueuedDocument() { + Docgen_Template__c template = [SELECT Id FROM Docgen_Template__c WHERE Name = 'Async Account Template' LIMIT 1]; + Account account = [SELECT Id FROM Account LIMIT 1]; + + Test.startTest(); + DocgenAsyncController.StartResult result = DocgenAsyncController.startGeneration( + template.Id, + null, + account.Id, + 'DOCX' + ); + Test.stopTest(); + + Assert.isNotNull(result.generatedDocumentId, 'Generated document ID should be returned'); + Assert.areEqual('QUEUED', result.status, 'Status should be queued for worker processing'); + Assert.areEqual(20, result.progressValue, 'Queued documents should show queued progress'); + Assert.isFalse(result.isTerminal, 'Queued status should not be terminal'); + + Generated_Document__c doc = [ + SELECT Id, Status__c, Account__c, Template__c, OutputFormat__c, RequestJSON__c, + RequestedBy__c, Attempts__c, Priority__c + FROM Generated_Document__c + WHERE Id = :result.generatedDocumentId + ]; + Assert.areEqual('QUEUED', doc.Status__c, 'Document should be queued'); + Assert.areEqual(account.Id, doc.Account__c, 'Parent lookup should be set'); + Assert.areEqual(template.Id, doc.Template__c, 'Template lookup should be set'); + Assert.areEqual('DOCX', doc.OutputFormat__c, 'Output format should be stored'); + Assert.isNotNull(doc.RequestJSON__c, 'Request JSON should be stored for the worker'); + Assert.areEqual(UserInfo.getUserId(), doc.RequestedBy__c, 'Requester should be tracked'); + Assert.areEqual(0, Integer.valueOf(doc.Attempts__c), 'Attempts should start at 0'); + Assert.areEqual(0, Integer.valueOf(doc.Priority__c), 'Priority should default to 0'); + } + + @IsTest + static void testStartGenerationByTemplateNameReusesExistingQueuedDocument() { + Account account = [SELECT Id FROM Account LIMIT 1]; + + Test.startTest(); + DocgenAsyncController.StartResult firstResult = DocgenAsyncController.startGeneration( + null, + 'Async Account Template', + account.Id, + 'PDF' + ); + DocgenAsyncController.StartResult secondResult = DocgenAsyncController.startGeneration( + null, + 'Async Account Template', + account.Id, + 'PDF' + ); + Test.stopTest(); + + Assert.areEqual(firstResult.generatedDocumentId, secondResult.generatedDocumentId, 'Duplicate request should reuse existing row'); + Assert.areEqual(1, [SELECT COUNT() FROM Generated_Document__c], 'Only one generated document should exist'); + } + + @IsTest + static void testStartGenerationRequeuesFailedExistingDocument() { + Account account = [SELECT Id FROM Account LIMIT 1]; + + DocgenAsyncController.StartResult firstResult = DocgenAsyncController.startGeneration( + null, + 'Async Account Template', + account.Id, + 'PDF' + ); + + update new Generated_Document__c( + Id = firstResult.generatedDocumentId, + Status__c = 'FAILED', + Error__c = 'Previous failure', + OutputFileId__c = '068000000000000AAA', + Attempts__c = 3 + ); + + Test.startTest(); + DocgenAsyncController.StartResult retryResult = DocgenAsyncController.startGeneration( + null, + 'Async Account Template', + account.Id, + 'PDF' + ); + Test.stopTest(); + + Assert.areEqual(firstResult.generatedDocumentId, retryResult.generatedDocumentId, 'Retry should reuse the same row'); + Assert.areEqual('QUEUED', retryResult.status, 'Failed document should be queued again'); + + Generated_Document__c doc = [ + SELECT Status__c, Error__c, OutputFileId__c, Attempts__c + FROM Generated_Document__c + WHERE Id = :retryResult.generatedDocumentId + ]; + Assert.areEqual('QUEUED', doc.Status__c, 'Status should be reset to queued'); + Assert.isNull(doc.Error__c, 'Previous error should be cleared'); + Assert.isNull(doc.OutputFileId__c, 'Previous output file should be cleared'); + Assert.areEqual(0, Integer.valueOf(doc.Attempts__c), 'Attempts should reset for manual retry'); + } + + @IsTest + static void testStartGenerationDoesNotRequireConfiguredLookupFieldToExist() { + Supported_Object__mdt accountConfig = new Supported_Object__mdt(); + accountConfig.DeveloperName = 'Account'; + accountConfig.Label = 'Account'; + accountConfig.Object_API_Name__c = 'Account'; + accountConfig.Lookup_Field_API_Name__c = 'Missing_Lookup__c'; + accountConfig.Is_Active__c = true; + DocgenObjectConfigService.configCache = new Map{ + 'Account' => accountConfig + }; + + Account account = [SELECT Id FROM Account LIMIT 1]; + + Test.startTest(); + DocgenAsyncController.StartResult result = DocgenAsyncController.startGeneration( + null, + 'Async Account Template', + account.Id, + 'PDF' + ); + Test.stopTest(); + + Generated_Document__c doc = [ + SELECT Status__c, Account__c, RequestJSON__c + FROM Generated_Document__c + WHERE Id = :result.generatedDocumentId + ]; + Assert.areEqual('QUEUED', doc.Status__c, 'Document should still be queued'); + Assert.isNull(doc.Account__c, 'Existing lookup field should not be populated when metadata points to a missing field'); + Assert.isTrue(doc.RequestJSON__c.contains(String.valueOf(account.Id)), 'Parent ID should still be stored for ContentDocumentLink creation'); + + DocgenObjectConfigService.clearCache(); + } + + @IsTest + static void testGetGenerationStatusReturnsDownloadUrlForSucceededDocument() { + Docgen_Template__c template = [SELECT Id FROM Docgen_Template__c WHERE Name = 'Async Account Template' LIMIT 1]; + Account account = [SELECT Id FROM Account LIMIT 1]; + Generated_Document__c doc = DocgenTestDataFactory.createGeneratedDocument(template.Id, account.Id, 'SUCCEEDED'); + doc.OutputFileId__c = '068000000000001AAA'; + update doc; + + Test.startTest(); + DocgenAsyncController.StatusResult result = DocgenAsyncController.getGenerationStatus(doc.Id); + Test.stopTest(); + + Assert.areEqual('SUCCEEDED', result.status, 'Status should be returned'); + Assert.areEqual(100, result.progressValue, 'Succeeded documents should show full progress'); + Assert.isTrue(result.isTerminal, 'Succeeded status should be terminal'); + Assert.areEqual('/lightning/r/ContentDocument/069000000000001AAA/view', result.downloadUrl, 'Download URL should target ContentDocument view'); + } + + @IsTest + static void testStartGenerationRejectsInvalidOutputFormat() { + Docgen_Template__c template = [SELECT Id FROM Docgen_Template__c WHERE Name = 'Async Account Template' LIMIT 1]; + Account account = [SELECT Id FROM Account LIMIT 1]; + + Test.startTest(); + try { + DocgenAsyncController.startGeneration(template.Id, null, account.Id, 'XLSX'); + Assert.fail('Invalid output format should throw an AuraHandledException'); + } catch (AuraHandledException e) { + Assert.isTrue(e.getMessage().contains('Output Format must be PDF, DOCX, or PPTX'), 'Error should explain allowed formats'); + } + Test.stopTest(); + } +} diff --git a/force-app/main/default/classes/DocgenAsyncControllerTest.cls-meta.xml b/force-app/main/default/classes/DocgenAsyncControllerTest.cls-meta.xml new file mode 100644 index 0000000..f5e18fd --- /dev/null +++ b/force-app/main/default/classes/DocgenAsyncControllerTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 60.0 + Active + diff --git a/force-app/main/default/lwc/docgenProgressButton/__tests__/docgenProgressButton.test.js b/force-app/main/default/lwc/docgenProgressButton/__tests__/docgenProgressButton.test.js new file mode 100644 index 0000000..2343776 --- /dev/null +++ b/force-app/main/default/lwc/docgenProgressButton/__tests__/docgenProgressButton.test.js @@ -0,0 +1,235 @@ +import { createElement } from 'lwc'; +import DocgenProgressButton from 'c/docgenProgressButton'; +import startGeneration from '@salesforce/apex/DocgenAsyncController.startGeneration'; +import getGenerationStatus from '@salesforce/apex/DocgenAsyncController.getGenerationStatus'; + +jest.mock( + '@salesforce/apex/DocgenAsyncController.startGeneration', + () => { + return { + default: jest.fn() + }; + }, + { virtual: true } +); + +jest.mock( + '@salesforce/apex/DocgenAsyncController.getGenerationStatus', + () => { + return { + default: jest.fn() + }; + }, + { virtual: true } +); + +global.window.open = jest.fn(); + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('c-docgen-progress-button', () => { + afterEach(() => { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + jest.clearAllMocks(); + }); + + it('renders the configured button label', () => { + const element = createElement('c-docgen-progress-button', { + is: DocgenProgressButton + }); + element.buttonLabel = 'Generate Account PDF'; + element.templateName = 'Account Template'; + element.recordId = '0011234567890ABC'; + + document.body.appendChild(element); + + const button = element.shadowRoot.querySelector('lightning-button'); + expect(button).not.toBeNull(); + expect(button.label).toBe('Generate Account PDF'); + }); + + it('starts generation with configured template and output format', async () => { + const element = createElement('c-docgen-progress-button', { + is: DocgenProgressButton + }); + element.templateName = 'Account Template'; + element.recordId = '0011234567890ABC'; + element.outputFormat = 'DOCX'; + + startGeneration.mockResolvedValue({ + generatedDocumentId: 'a0G123', + status: 'SUCCEEDED', + progressValue: 100, + isTerminal: true, + downloadUrl: '/lightning/r/ContentDocument/069123/view' + }); + + document.body.appendChild(element); + + const button = element.shadowRoot.querySelector('lightning-button'); + button.click(); + await flushPromises(); + + expect(startGeneration).toHaveBeenCalledWith({ + templateId: null, + templateName: 'Account Template', + recordId: '0011234567890ABC', + outputFormat: 'DOCX' + }); + }); + + it('polls status and opens the generated file when configured', async () => { + const element = createElement('c-docgen-progress-button', { + is: DocgenProgressButton + }); + element.templateId = 'a0T1234567890ABC'; + element.recordId = '0011234567890ABC'; + element.outputFormat = 'PDF'; + element.openOnSuccess = true; + + startGeneration.mockResolvedValue({ + generatedDocumentId: 'a0G123', + status: 'QUEUED', + progressValue: 20, + isTerminal: false + }); + getGenerationStatus.mockResolvedValue({ + generatedDocumentId: 'a0G123', + status: 'SUCCEEDED', + progressValue: 100, + isTerminal: true, + downloadUrl: '/lightning/r/ContentDocument/069123/view' + }); + + document.body.appendChild(element); + + const successHandler = jest.fn(); + element.addEventListener('docgensuccess', successHandler); + + const button = element.shadowRoot.querySelector('lightning-button'); + button.click(); + await flushPromises(); + await flushPromises(); + + expect(getGenerationStatus).toHaveBeenCalledWith({ + generatedDocumentId: 'a0G123' + }); + expect(window.open).toHaveBeenCalledWith('/lightning/r/ContentDocument/069123/view', '_blank'); + expect(successHandler).toHaveBeenCalledTimes(1); + }); + + it('shows progress state while the generated document is processing', async () => { + const element = createElement('c-docgen-progress-button', { + is: DocgenProgressButton + }); + element.templateId = 'a0T1234567890ABC'; + element.recordId = '0011234567890ABC'; + + startGeneration.mockResolvedValue({ + generatedDocumentId: 'a0G123', + status: 'QUEUED', + progressValue: 20, + isTerminal: false + }); + getGenerationStatus.mockResolvedValue({ + generatedDocumentId: 'a0G123', + status: 'PROCESSING', + progressValue: 60, + isTerminal: false + }); + + document.body.appendChild(element); + + const button = element.shadowRoot.querySelector('lightning-button'); + button.click(); + await flushPromises(); + await flushPromises(); + + const progressBar = element.shadowRoot.querySelector('lightning-progress-bar'); + expect(progressBar).not.toBeNull(); + expect(progressBar.value).toBe(60); + expect(button.disabled).toBe(true); + }); + + it('can be called imperatively from a parent custom action', async () => { + const element = createElement('c-docgen-progress-button', { + is: DocgenProgressButton + }); + element.recordId = '0011234567890ABC'; + + startGeneration.mockResolvedValue({ + generatedDocumentId: 'a0G123', + status: 'SUCCEEDED', + progressValue: 100, + isTerminal: true, + downloadUrl: '/lightning/r/ContentDocument/069123/view' + }); + + document.body.appendChild(element); + + await element.generate({ + templateId: 'a0T1234567890ABC', + outputFormat: 'PPTX' + }); + + expect(startGeneration).toHaveBeenCalledWith({ + templateId: 'a0T1234567890ABC', + templateName: null, + recordId: '0011234567890ABC', + outputFormat: 'PPTX' + }); + }); + + it('can hide its own button for a quick action wrapper', async () => { + const element = createElement('c-docgen-progress-button', { + is: DocgenProgressButton + }); + element.recordId = '0011234567890ABC'; + element.hideButton = true; + + startGeneration.mockResolvedValue({ + generatedDocumentId: 'a0G123', + status: 'SUCCEEDED', + progressValue: 100, + isTerminal: true, + downloadUrl: '/lightning/r/ContentDocument/069123/view' + }); + + document.body.appendChild(element); + + expect(element.shadowRoot.querySelector('lightning-button')).toBeNull(); + + await element.generate({ + templateName: 'Account Template', + outputFormat: 'PDF' + }); + + expect(startGeneration).toHaveBeenCalledWith({ + templateId: null, + templateName: 'Account Template', + recordId: '0011234567890ABC', + outputFormat: 'PDF' + }); + }); + + it('does not call Apex when required configuration is missing', async () => { + const element = createElement('c-docgen-progress-button', { + is: DocgenProgressButton + }); + element.recordId = '0011234567890ABC'; + + document.body.appendChild(element); + + const errorHandler = jest.fn(); + element.addEventListener('docgenerror', errorHandler); + + const button = element.shadowRoot.querySelector('lightning-button'); + button.click(); + await flushPromises(); + + expect(startGeneration).not.toHaveBeenCalled(); + expect(errorHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.html b/force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.html new file mode 100644 index 0000000..63b4059 --- /dev/null +++ b/force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.html @@ -0,0 +1,22 @@ + diff --git a/force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.js b/force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.js new file mode 100644 index 0000000..845d74a --- /dev/null +++ b/force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.js @@ -0,0 +1,310 @@ +import { LightningElement, api } from 'lwc'; +import startGeneration from '@salesforce/apex/DocgenAsyncController.startGeneration'; +import getGenerationStatus from '@salesforce/apex/DocgenAsyncController.getGenerationStatus'; +import { ShowToastEvent } from 'lightning/platformShowToastEvent'; + +const DEFAULT_POLL_INTERVAL_MS = 2000; +const DEFAULT_MAX_POLL_SECONDS = 180; + +export default class DocgenProgressButton extends LightningElement { + @api templateId; + @api templateName; + @api outputFormat = 'PDF'; + @api recordId; + @api buttonLabel = 'Generate Document'; + @api buttonVariant = 'brand'; + @api successMessage = 'Document generated successfully!'; + @api maxPollSeconds = DEFAULT_MAX_POLL_SECONDS; + @api pollIntervalMs = DEFAULT_POLL_INTERVAL_MS; + + _openOnSuccess = true; + _hideButton = false; + isProcessing = false; + progressValue = 0; + status = null; + generatedDocumentId = null; + pollTimer = null; + currentRunPromise = null; + pollStartTime = 0; + + get showProgressPanel() { + return this.isProcessing || this.status; + } + + get showButton() { + return !this.hideButton; + } + + @api + get openOnSuccess() { + return this._openOnSuccess; + } + + set openOnSuccess(value) { + this._openOnSuccess = this.normalizeBoolean(value, true); + } + + @api + get hideButton() { + return this._hideButton; + } + + set hideButton(value) { + this._hideButton = this.normalizeBoolean(value, false); + } + + get displayStatus() { + if (this.status === 'QUEUED') { + return 'Queued'; + } + if (this.status === 'PROCESSING') { + return 'Processing'; + } + if (this.status === 'SUCCEEDED') { + return 'Complete'; + } + if (this.status === 'FAILED') { + return 'Failed'; + } + if (this.status === 'CANCELED') { + return 'Canceled'; + } + return this.isProcessing ? 'Starting' : ''; + } + + disconnectedCallback() { + this.clearPollTimer(); + } + + async handleGenerateClick() { + await this.generate(); + } + + @api + async generate(config = {}) { + if (this.isProcessing && this.currentRunPromise) { + return this.currentRunPromise; + } + + this.currentRunPromise = this.runGeneration(config); + return this.currentRunPromise; + } + + async runGeneration(config) { + const request = this.buildRequest(config); + if (!this.validateRequest(request)) { + this.currentRunPromise = null; + return null; + } + + this.clearPollTimer(); + this.isProcessing = true; + this.progressValue = 10; + this.status = null; + this.generatedDocumentId = null; + this.pollStartTime = Date.now(); + + try { + const startResult = await startGeneration(request); + this.applyStatus(startResult); + this.generatedDocumentId = startResult.generatedDocumentId; + this.dispatchDocgenEvent('docgenstart', startResult); + + if (startResult.isTerminal) { + return this.finish(startResult); + } + + return await this.waitForTerminalStatus(); + } catch (error) { + const errorMessage = this.extractErrorMessage(error); + this.handleError(errorMessage); + return null; + } finally { + this.currentRunPromise = null; + } + } + + buildRequest(config) { + return { + templateId: config.templateId || this.templateId || null, + templateName: config.templateName || this.templateName || null, + recordId: config.recordId || this.recordId || null, + outputFormat: (config.outputFormat || this.outputFormat || 'PDF').toUpperCase() + }; + } + + validateRequest(request) { + if (!request.templateId && !request.templateName) { + this.handleError('Template ID or Template Name is required.'); + return false; + } + + if (!request.recordId) { + this.handleError('Record ID is required.'); + return false; + } + + if (!['PDF', 'DOCX', 'PPTX'].includes(request.outputFormat)) { + this.handleError('Output Format must be PDF, DOCX, or PPTX.'); + return false; + } + + return true; + } + + waitForTerminalStatus() { + return new Promise((resolve) => { + const poll = async () => { + if (!this.generatedDocumentId) { + this.handleError('Generated Document ID was not returned.'); + resolve(null); + return; + } + + if (this.hasTimedOut()) { + this.handleError('Document generation is still running. Open Generated Documents to check the latest status.'); + resolve(null); + return; + } + + try { + const statusResult = await getGenerationStatus({ + generatedDocumentId: this.generatedDocumentId + }); + + this.applyStatus(statusResult); + this.dispatchDocgenEvent('docgenprogress', statusResult); + + if (statusResult.isTerminal) { + resolve(this.finish(statusResult)); + } else { + this.pollTimer = window.setTimeout(poll, this.effectivePollIntervalMs()); + } + } catch (error) { + const errorMessage = this.extractErrorMessage(error); + this.handleError(errorMessage); + resolve(null); + } + }; + + poll(); + }); + } + + finish(statusResult) { + this.clearPollTimer(); + this.applyStatus(statusResult); + this.isProcessing = false; + + if (statusResult.status === 'SUCCEEDED') { + if (this.openOnSuccess && statusResult.downloadUrl) { + window.open(statusResult.downloadUrl, '_blank'); + } + this.showToast('Success', this.successMessage, 'success'); + this.dispatchDocgenEvent('docgensuccess', statusResult); + } else { + const errorMessage = statusResult.errorMessage || `Document generation ${statusResult.status.toLowerCase()}.`; + this.showToast('Error Generating Document', errorMessage, 'error'); + this.dispatchDocgenEvent('docgenerror', { + ...statusResult, + errorMessage + }); + } + + return statusResult; + } + + applyStatus(result) { + this.status = result?.status || null; + this.progressValue = result?.progressValue || 10; + this.generatedDocumentId = result?.generatedDocumentId || this.generatedDocumentId; + } + + handleError(errorMessage) { + this.clearPollTimer(); + this.isProcessing = false; + this.progressValue = 100; + this.status = 'FAILED'; + this.showToast('Error Generating Document', errorMessage, 'error'); + this.dispatchDocgenEvent('docgenerror', { + generatedDocumentId: this.generatedDocumentId, + status: this.status, + progressValue: this.progressValue, + errorMessage + }); + } + + hasTimedOut() { + const maxMs = Number(this.maxPollSeconds || DEFAULT_MAX_POLL_SECONDS) * 1000; + return Date.now() - this.pollStartTime > maxMs; + } + + effectivePollIntervalMs() { + return Number(this.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS); + } + + normalizeBoolean(value, defaultValue) { + if (value === undefined || value === null || value === '') { + return defaultValue; + } + if (typeof value === 'boolean') { + return value; + } + return String(value).toLowerCase() === 'true'; + } + + clearPollTimer() { + if (this.pollTimer) { + window.clearTimeout(this.pollTimer); + this.pollTimer = null; + } + } + + showToast(title, message, variant) { + this.dispatchEvent( + new ShowToastEvent({ + title, + message, + variant + }) + ); + } + + dispatchDocgenEvent(name, detail) { + this.dispatchEvent( + new CustomEvent(name, { + detail, + bubbles: true, + composed: true + }) + ); + } + + extractErrorMessage(error) { + if (error?.body?.message) { + return error.body.message; + } + + if (error?.message) { + return error.message; + } + + if (error?.body?.pageErrors && error.body.pageErrors.length > 0) { + return error.body.pageErrors[0].message; + } + + if (error?.body?.fieldErrors) { + const fieldErrorMessages = []; + Object.keys(error.body.fieldErrors).forEach((field) => { + error.body.fieldErrors[field].forEach((fieldError) => { + fieldErrorMessages.push(fieldError.message); + }); + }); + if (fieldErrorMessages.length > 0) { + return fieldErrorMessages.join(', '); + } + } + + return 'An unexpected error occurred. Please try again or contact your administrator.'; + } +} diff --git a/force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.js-meta.xml b/force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.js-meta.xml new file mode 100644 index 0000000..8cc0168 --- /dev/null +++ b/force-app/main/default/lwc/docgenProgressButton/docgenProgressButton.js-meta.xml @@ -0,0 +1,25 @@ + + + 60.0 + Queued document generation button with Generated Document status progress + true + Document Generation Progress Button + + lightning__RecordPage + lightning__AppPage + lightning__HomePage + + + + + + + + + + + + + + + diff --git a/force-app/main/default/lwc/docgenTestPage/__tests__/docgenTestPage.test.js b/force-app/main/default/lwc/docgenTestPage/__tests__/docgenTestPage.test.js new file mode 100644 index 0000000..6dbdae8 --- /dev/null +++ b/force-app/main/default/lwc/docgenTestPage/__tests__/docgenTestPage.test.js @@ -0,0 +1,121 @@ +import { createElement } from 'lwc'; +import { CurrentPageReference } from 'lightning/navigation'; +import DocgenTestPage from 'c/docgenTestPage'; +import getSupportedObjects from '@salesforce/apex/DocgenTestPageController.getSupportedObjects'; +import getObjectTypeFromRecordId from '@salesforce/apex/DocgenTestPageController.getObjectTypeFromRecordId'; +import getGeneratedDocuments from '@salesforce/apex/DocgenTestPageController.getGeneratedDocuments'; + +jest.mock( + '@salesforce/apex/DocgenTestPageController.getSupportedObjects', + () => { + const { createApexTestWireAdapter } = require('@salesforce/wire-service-jest-util'); + return { + default: createApexTestWireAdapter(jest.fn()) + }; + }, + { virtual: true } +); + +jest.mock( + '@salesforce/apex/DocgenTestPageController.getObjectTypeFromRecordId', + () => { + return { + default: jest.fn() + }; + }, + { virtual: true } +); + +jest.mock( + '@salesforce/apex/DocgenTestPageController.getGeneratedDocuments', + () => { + return { + default: jest.fn() + }; + }, + { virtual: true } +); + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const SUPPORTED_OBJECTS = [ + { + Label: 'Account', + Object_API_Name__c: 'Account', + Lookup_Field_API_Name__c: 'Account__c' + } +]; + +function createTestPage() { + const element = createElement('c-docgen-test-page', { + is: DocgenTestPage + }); + return element; +} + +async function renderConfiguredPage(outputFormat) { + getObjectTypeFromRecordId.mockResolvedValue('Account'); + getGeneratedDocuments.mockResolvedValue([]); + + const element = createTestPage(); + document.body.appendChild(element); + + getSupportedObjects.emit(SUPPORTED_OBJECTS); + CurrentPageReference.emit({ + attributes: { + apiName: 'Docgen_Test_Page' + }, + state: { + c__objectType: 'Account', + c__recordId: '001000000000001AAA', + c__templateId: 'a0G000000000001AAA', + ...(outputFormat ? { c__outputFormat: outputFormat } : {}) + } + }); + + await flushPromises(); + await flushPromises(); + + return element; +} + +describe('c-docgen-test-page', () => { + afterEach(() => { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + jest.clearAllMocks(); + }); + + it('passes the selected output format to the generation button', async () => { + const element = await renderConfiguredPage(); + + const outputFormatCombobox = [...element.shadowRoot.querySelectorAll('lightning-combobox')] + .find((combobox) => combobox.label === 'Output Format'); + expect(outputFormatCombobox).not.toBeUndefined(); + expect(outputFormatCombobox.value).toBe('PDF'); + expect(outputFormatCombobox.options).toEqual([ + { label: 'PDF', value: 'PDF' }, + { label: 'DOCX', value: 'DOCX' }, + { label: 'PPTX', value: 'PPTX' } + ]); + + outputFormatCombobox.dispatchEvent(new CustomEvent('change', { + detail: { + value: 'PPTX' + } + })); + await flushPromises(); + + const docgenButton = element.shadowRoot.querySelector('c-docgen-button'); + expect(docgenButton.outputFormat).toBe('PPTX'); + }); + + it('restores PPT URL shorthand as PPTX', async () => { + const element = await renderConfiguredPage('PPT'); + + const outputFormatCombobox = [...element.shadowRoot.querySelectorAll('lightning-combobox')] + .find((combobox) => combobox.label === 'Output Format'); + expect(outputFormatCombobox.value).toBe('PPTX'); + }); +}); diff --git a/force-app/main/default/lwc/docgenTestPage/docgenTestPage.html b/force-app/main/default/lwc/docgenTestPage/docgenTestPage.html index ba06bf7..f101a39 100644 --- a/force-app/main/default/lwc/docgenTestPage/docgenTestPage.html +++ b/force-app/main/default/lwc/docgenTestPage/docgenTestPage.html @@ -75,12 +75,23 @@ + +
+ + +
+