diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6c2178a9a..c075dd076 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,6 +20,8 @@ "explorer.fileNesting.expand": false, "explorer.fileNesting.patterns": { "*.cls": "${capture}.cls-meta.xml", + "*.css": "${capture}.min.css", + "*.js": "${capture}.min.js", "*.page": "${capture}.page-meta.xml", "*.trigger": "${capture}.trigger-meta.xml", "*.view": "${capture}.view-meta.xml", diff --git a/.forceignore b/.forceignore index 67d92b535..bd9708e31 100644 --- a/.forceignore +++ b/.forceignore @@ -21,3 +21,10 @@ nebula-logger/managed-package/**/*.testSuite-meta.xml **/tsconfig.json **/*.ts + + +# PrismJS - only the minified files are deployed, the unminified versions are +# only kept in source control to make it easier to see what's changed when upgrading PrismJS. +nebula-logger/core/main/log-management/staticresources/LoggerResources/Prism/prism.js +nebula-logger/core/main/log-management/staticresources/LoggerResources/Prism/themes/prism-tomorrow.css +nebula-logger/core/main/log-management/staticresources/LoggerResources/Prism/README.md diff --git a/.gitignore b/.gitignore index 26efa8d77..1119bd874 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .sf/ .sfdx/ .vscode/ +.claude/ docs/apex/Miscellaneous/ temp/ test-coverage/ diff --git a/.prettierignore b/.prettierignore index 6fb5cca87..cb5208b42 100644 --- a/.prettierignore +++ b/.prettierignore @@ -20,8 +20,8 @@ yarn.lock *.log *.xml -# Prism JS -# The CSS for Prism has specifically been modified for Nebula Logger (a bit), so it's NOT ignored. -# nebula-logger/core/main/log-management/staticresources/LoggerResources/prism.css -# The minified JS is taken as-is, so no need for prettier to format it. -nebula-logger/core/main/log-management/staticresources/LoggerResources/prism.js \ No newline at end of file +# PrismJS - these files are automatically updated & minified, +# so no need for prettier to format them. +nebula-logger/core/main/log-management/staticresources/LoggerResources/Prism/prism.js +nebula-logger/core/main/log-management/staticresources/LoggerResources/Prism/prism.min.js +nebula-logger/core/main/log-management/staticresources/LoggerResources/Prism/themes/*.* \ No newline at end of file diff --git a/README.md b/README.md index 25005e259..c4db31bfb 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ The most robust observability solution for Salesforce experts. Built 100% natively on the platform, and designed to work seamlessly with Apex, Lightning Components, Flow, OmniStudio, and integrations. -## Unlocked Package - v4.18.2 +## Unlocked Package - v4.18.3 -[![Install Unlocked Package in a Sandbox](./images/btn-install-unlocked-package-sandbox.png)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tg70000008YZBAA2) -[![Install Unlocked Package in Production](./images/btn-install-unlocked-package-production.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tg70000008YZBAA2) +[![Install Unlocked Package in a Sandbox](./images/btn-install-unlocked-package-sandbox.png)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tg70000009GaDAAU) +[![Install Unlocked Package in Production](./images/btn-install-unlocked-package-production.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tg70000009GaDAAU) [![View Documentation](./images/btn-view-documentation.png)](https://github.com/jongpie/NebulaLogger/wiki) -`sf package install --wait 20 --security-type AdminsOnly --package 04tg70000008YZBAA2` +`sf package install --wait 20 --security-type AdminsOnly --package 04tg70000009GaDAAU` --- diff --git a/nebula-logger/core/main/log-management/lwc/logEntryMetadataViewer/__tests__/logEntryMetadataViewer.test.js b/nebula-logger/core/main/log-management/lwc/logEntryMetadataViewer/__tests__/logEntryMetadataViewer.test.js index 71e4fb35d..316d56dd7 100644 --- a/nebula-logger/core/main/log-management/lwc/logEntryMetadataViewer/__tests__/logEntryMetadataViewer.test.js +++ b/nebula-logger/core/main/log-management/lwc/logEntryMetadataViewer/__tests__/logEntryMetadataViewer.test.js @@ -10,7 +10,7 @@ jest.mock( return { loadScript() { return new Promise(resolve => { - global.Prism = require('../../../staticresources/LoggerResources/prism.js'); + global.Prism = require('../../../staticresources/LoggerResources/Prism/prism.min.js'); resolve(); }); }, @@ -31,6 +31,40 @@ jest.mock( { virtual: true } ); +// Number of microtask hops to drain across getRecord, c-logger-code-viewer creation, +// and the Promise.all(map(async)) chain inside _loadPrismResources. +const flushPromises = async () => { + for (let i = 0; i < 8; i++) { + /* eslint-disable-next-line no-await-in-loop */ + await Promise.resolve(); + } +}; + +const buildSnippet = overrides => ({ + Code: 'some-code-block', + ApiVersion: '65.0', + TotalLinesOfCode: 123, + StartingLineNumber: 55, + TargetLineNumber: 65, + EndingLineNumber: 68, + ...overrides +}); + +const buildLogEntryRecord = ({ source, metadataType, snippet }) => { + const apiNameField = source === 'Exception' ? 'ExceptionSourceApiName__c' : 'OriginSourceApiName__c'; + const apiVersionField = source === 'Exception' ? 'ExceptionSourceApiVersion__c' : 'OriginSourceApiVersion__c'; + const metadataTypeField = source === 'Exception' ? 'ExceptionSourceMetadataType__c' : 'OriginSourceMetadataType__c'; + const snippetField = source === 'Exception' ? 'ExceptionSourceSnippet__c' : 'OriginSourceSnippet__c'; + return { + fields: { + [apiNameField]: { value: 'SomeApexClass' }, + [apiVersionField]: { value: '65.0' }, + [metadataTypeField]: { value: metadataType }, + [snippetField]: { value: snippet === null ? null : JSON.stringify(snippet) } + } + }; +}; + describe('LogEntryMetadataViewer LWC Tests', () => { afterEach(() => { while (document.body.firstChild) { @@ -65,9 +99,7 @@ describe('LogEntryMetadataViewer LWC Tests', () => { document.body.appendChild(element); getRecord.emit(mockLogEntryRecord); - await Promise.resolve('resolves getRecord() call'); - await Promise.resolve('resolves creating an instance of c-logger-code-viewer'); - await Promise.resolve('resolves loading & running PrismJS inside of c-logger-code-viewer'); + await flushPromises(); const sectionTitle = element.shadowRoot.querySelector('c-logger-page-section span[slot="title"]'); expect(sectionTitle).toBeTruthy(); @@ -116,9 +148,7 @@ describe('LogEntryMetadataViewer LWC Tests', () => { document.body.appendChild(element); getRecord.emit(mockLogEntryRecord); - await Promise.resolve('resolves getRecord() call'); - await Promise.resolve('resolves creating an instance of c-logger-code-viewer'); - await Promise.resolve('resolves loading & running PrismJS inside of c-logger-code-viewer'); + await flushPromises(); const sectionTitle = element.shadowRoot.querySelector('c-logger-page-section span[slot="title"]'); expect(sectionTitle).toBeTruthy(); @@ -140,4 +170,184 @@ describe('LogEntryMetadataViewer LWC Tests', () => { expect(viewFullSourceButton.label).toBe('View Full Source'); expect(viewFullSourceButton.variant).toBe('inverse'); }); + + it('should show a spinner before the wired log entry record loads', async () => { + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + + document.body.appendChild(element); + await flushPromises(); + + expect(element.shadowRoot.querySelector('lightning-spinner')).toBeTruthy(); + expect(element.shadowRoot.querySelector('c-logger-page-section')).toBeNull(); + expect(element.shadowRoot.querySelector('c-logger-code-viewer')).toBeNull(); + }); + + it('should render "No source snippet available" when the snippet field is empty', async () => { + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + + document.body.appendChild(element); + getRecord.emit(buildLogEntryRecord({ source: 'Exception', metadataType: 'ApexClass', snippet: null })); + await flushPromises(); + + expect(element.shadowRoot.querySelector('lightning-spinner')).toBeNull(); + expect(element.shadowRoot.querySelector('c-logger-page-section')).toBeNull(); + expect(element.shadowRoot.querySelector('c-logger-code-viewer')).toBeNull(); + expect(element.shadowRoot.textContent).toContain('No source snippet available'); + }); + + it('should produce a .trigger title for ApexTrigger metadata', async () => { + getMetadata.mockResolvedValue({ Code: 'full code here', HasCodeBeenModified: false }); + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + + document.body.appendChild(element); + getRecord.emit(buildLogEntryRecord({ source: 'Exception', metadataType: 'ApexTrigger', snippet: buildSnippet() })); + await flushPromises(); + + const codeViewerTitle = element.shadowRoot.querySelector('c-logger-code-viewer span[slot="title"]'); + expect(codeViewerTitle).toBeTruthy(); + expect(codeViewerTitle.textContent).toBe('SomeApexClass.trigger - 65.0'); + }); + + it('should hide the "View Full Source" button when there is no full source metadata', async () => { + // getMetadata resolves with an empty object → hasFullSourceMetadata is false. + getMetadata.mockResolvedValue({}); + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + + document.body.appendChild(element); + getRecord.emit(buildLogEntryRecord({ source: 'Exception', metadataType: 'ApexClass', snippet: buildSnippet() })); + await flushPromises(); + + const actionsSlotButtons = element.shadowRoot.querySelectorAll('c-logger-code-viewer span[slot="actions"] lightning-button'); + expect(actionsSlotButtons.length).toBe(0); + }); + + it('should open the full-source modal with success notification when code is unmodified', async () => { + getMetadata.mockResolvedValue({ Code: 'full code here', HasCodeBeenModified: false }); + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + document.body.appendChild(element); + getRecord.emit(buildLogEntryRecord({ source: 'Exception', metadataType: 'ApexClass', snippet: buildSnippet() })); + await flushPromises(); + + const viewFullSourceButton = element.shadowRoot.querySelector('c-logger-code-viewer span[slot="actions"] lightning-button'); + viewFullSourceButton.click(); + await flushPromises(); + + const modalSection = element.shadowRoot.querySelector('section.slds-modal'); + expect(modalSection).toBeTruthy(); + const modalTitle = element.shadowRoot.querySelector('section.slds-modal h2.slds-text-heading_medium'); + expect(modalTitle.textContent).toBe('Full Source: SomeApexClass.cls - 65.0'); + const notification = element.shadowRoot.querySelector('section.slds-modal div[role="alert"]'); + expect(notification.classList.contains('slds-theme_success')).toBe(true); + expect(notification.classList.contains('slds-theme_warning')).toBe(false); + const notificationIcon = notification.querySelector('lightning-icon'); + expect(notificationIcon.iconName).toBe('utility:success'); + const notificationMessage = notification.querySelector('h2'); + expect(notificationMessage.textContent).toBe('This Apex code has not been modified since this log entry was generated.'); + const modalCodeViewer = element.shadowRoot.querySelector('section.slds-modal c-logger-code-viewer'); + expect(modalCodeViewer).toBeTruthy(); + expect(modalCodeViewer.code).toBe('full code here'); + }); + + it('should open the full-source modal with warning notification when code has been modified', async () => { + getMetadata.mockResolvedValue({ Code: 'modified code', HasCodeBeenModified: true }); + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + document.body.appendChild(element); + getRecord.emit(buildLogEntryRecord({ source: 'Exception', metadataType: 'ApexClass', snippet: buildSnippet() })); + await flushPromises(); + + element.shadowRoot.querySelector('c-logger-code-viewer span[slot="actions"] lightning-button').click(); + await flushPromises(); + + const notification = element.shadowRoot.querySelector('section.slds-modal div[role="alert"]'); + expect(notification.classList.contains('slds-theme_success')).toBe(false); + expect(notification.classList.contains('slds-theme_warning')).toBe(true); + const notificationIcon = notification.querySelector('lightning-icon'); + expect(notificationIcon.iconName).toBe('utility:warning'); + const notificationMessage = notification.querySelector('h2'); + expect(notificationMessage.textContent).toBe('This Apex code has been modified since this log entry was generated.'); + }); + + it('should close the modal when the header close icon is clicked', async () => { + getMetadata.mockResolvedValue({ Code: 'full code here', HasCodeBeenModified: false }); + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + document.body.appendChild(element); + getRecord.emit(buildLogEntryRecord({ source: 'Exception', metadataType: 'ApexClass', snippet: buildSnippet() })); + await flushPromises(); + + element.shadowRoot.querySelector('c-logger-code-viewer span[slot="actions"] lightning-button').click(); + await flushPromises(); + expect(element.shadowRoot.querySelector('section.slds-modal')).toBeTruthy(); + + element.shadowRoot.querySelector('section.slds-modal button.slds-modal__close').click(); + await flushPromises(); + expect(element.shadowRoot.querySelector('section.slds-modal')).toBeNull(); + }); + + it('should close the modal when the footer Close button is clicked', async () => { + getMetadata.mockResolvedValue({ Code: 'full code here', HasCodeBeenModified: false }); + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + document.body.appendChild(element); + getRecord.emit(buildLogEntryRecord({ source: 'Exception', metadataType: 'ApexClass', snippet: buildSnippet() })); + await flushPromises(); + + element.shadowRoot.querySelector('c-logger-code-viewer span[slot="actions"] lightning-button').click(); + await flushPromises(); + + element.shadowRoot.querySelector('lightning-button[data-id="close-btn"]').click(); + await flushPromises(); + expect(element.shadowRoot.querySelector('section.slds-modal')).toBeNull(); + }); + + it('should close the modal when the Escape key is pressed', async () => { + getMetadata.mockResolvedValue({ Code: 'full code here', HasCodeBeenModified: false }); + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + document.body.appendChild(element); + getRecord.emit(buildLogEntryRecord({ source: 'Exception', metadataType: 'ApexClass', snippet: buildSnippet() })); + await flushPromises(); + + element.shadowRoot.querySelector('c-logger-code-viewer span[slot="actions"] lightning-button').click(); + await flushPromises(); + + const modalSection = element.shadowRoot.querySelector('section.slds-modal'); + modalSection.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape', bubbles: true })); + await flushPromises(); + + expect(element.shadowRoot.querySelector('section.slds-modal')).toBeNull(); + }); + + it('should leave the modal open when a non-Escape key is pressed', async () => { + getMetadata.mockResolvedValue({ Code: 'full code here', HasCodeBeenModified: false }); + const element = createElement('c-log-entry-metadata-viewer', { is: LogEntryMetadataViewer }); + element.sourceMetadata = 'Exception'; + element.recordId = 'test-log-entry-id'; + document.body.appendChild(element); + getRecord.emit(buildLogEntryRecord({ source: 'Exception', metadataType: 'ApexClass', snippet: buildSnippet() })); + await flushPromises(); + element.shadowRoot.querySelector('c-logger-code-viewer span[slot="actions"] lightning-button').click(); + await flushPromises(); + + const modalSection = element.shadowRoot.querySelector('section.slds-modal'); + modalSection.dispatchEvent(new KeyboardEvent('keydown', { code: 'Enter', bubbles: true })); + await flushPromises(); + + expect(element.shadowRoot.querySelector('section.slds-modal')).toBeTruthy(); + }); }); diff --git a/nebula-logger/core/main/log-management/lwc/logEntryMetadataViewer/logEntryMetadataViewer.html b/nebula-logger/core/main/log-management/lwc/logEntryMetadataViewer/logEntryMetadataViewer.html index a26d17dde..dbdc8b8d5 100644 --- a/nebula-logger/core/main/log-management/lwc/logEntryMetadataViewer/logEntryMetadataViewer.html +++ b/nebula-logger/core/main/log-management/lwc/logEntryMetadataViewer/logEntryMetadataViewer.html @@ -4,12 +4,12 @@ **********************************************************************************************-->