diff --git a/packages/devextreme/js/__internal/core/license/trial_panel.client.test.ts b/packages/devextreme/js/__internal/core/license/trial_panel.client.test.ts index 32124ff57767..7945db0c89a0 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.client.test.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.client.test.ts @@ -2,10 +2,11 @@ * @jest-environment jsdom */ import { - beforeEach, describe, expect, it, + afterEach, beforeEach, describe, expect, it, } from '@jest/globals'; -import { renderTrialPanel } from './trial_panel.client'; +import { showTrialPanel } from './trial_panel'; +import { registerCustomComponents, renderTrialPanel } from './trial_panel.client'; describe('trial panel client', () => { beforeEach(() => { @@ -69,3 +70,559 @@ describe('trial panel client', () => { } }); }); + +describe('trial panel client: options (trialExpired, libraries)', () => { + const buyNow = 'https://example.com/buy'; + const licensingDoc = 'https://example.com/docs'; + const version = '26.2.0'; + const closeButtonSelector = 'svg#Layer_1'; + + const getPanel = (): HTMLElement => document.querySelector('dx-license') as HTMLElement; + + const hasCloseButton = (panel: HTMLElement): boolean => ( + panel.querySelector(closeButtonSelector) != null + ); + + const getText = (panel: HTMLElement): string => Array.from(panel.querySelectorAll('span, a')) + .map((element) => (element as HTMLElement).innerText) + .join(''); + + const flushObserver = async (): Promise => { + await Promise.resolve(); + await Promise.resolve(); + }; + + const setClosed = (value: boolean): void => { + (customElements.get('dx-license') as unknown as { closed: boolean }).closed = value; + }; + + const addTrigger = (attributes: Record = {}): HTMLElement => { + const trigger = document.createElement('dx-license-trigger'); + trigger.setAttribute('buy-now', buyNow); + trigger.setAttribute('licensing-doc', licensingDoc); + trigger.setAttribute('version', version); + Object.entries(attributes).forEach(([name, value]) => trigger.setAttribute(name, value)); + document.body.appendChild(trigger); + return trigger; + }; + + const closeBanner = async (): Promise => { + const closeButtonIcon = getPanel().querySelector(closeButtonSelector); + (closeButtonIcon?.parentElement as HTMLElement).click(); + await flushObserver(); + }; + + interface PanelInternals { + _expired: boolean; + _observer: MutationObserver | null; + } + + const cleanup = (): void => { + document.querySelectorAll('dx-license').forEach((panel) => { + if (!('_expired' in panel) || !('_observer' in panel)) { + throw new Error('The private fields of the panel were renamed: update PanelInternals'); + } + + const internals = panel as unknown as PanelInternals; + + internals._expired = false; + internals._observer?.disconnect(); + }); + setClosed(true); + document.body.innerHTML = ''; + setClosed(false); + }; + + beforeEach(() => { + registerCustomComponents(); + cleanup(); + }); + + afterEach(cleanup); + + describe('contract', () => { + it.each([ + ['no options', undefined], + ['trialExpired: false', { trialExpired: false }], + ])('should not change the banner (%s)', (_, options) => { + renderTrialPanel(buyNow, licensingDoc, version, 'Universal', undefined, options); + + expect(document.querySelector('dx-license-trigger')?.hasAttribute('trial-expired')).toBe(false); + expect(getPanel().hasAttribute('trial-expired')).toBe(false); + expect(hasCloseButton(getPanel())).toBe(true); + }); + + it.each(['false', '0', 'true', 'yes', 1, 0])('should not make the overlay by the JS option trialExpired: %p (only true does)', (value) => { + renderTrialPanel(buyNow, licensingDoc, version, 'Universal', undefined, { + trialExpired: value as unknown as boolean, + }); + + expect(document.querySelector('dx-license-trigger')?.hasAttribute('trial-expired')).toBe(false); + expect(hasCloseButton(getPanel())).toBe(true); + }); + + it.each(['false', '', '1'])('should treat the markup value "%s" as a trial that is not expired', (value) => { + addTrigger({ 'trial-expired': value }); + + expect(hasCloseButton(getPanel())).toBe(true); + }); + + it.each(['true', 'True', 'TRUE', ' true ', 'tRuE'])('should treat the markup value "%s" as an expired trial', (value) => { + addTrigger({ 'trial-expired': value }); + + expect(hasCloseButton(getPanel())).toBe(false); + expect(getText(getPanel())).toContain('Your 30-day trial has expired'); + }); + + it('should switch an existing banner by a trigger with the value "True"', () => { + addTrigger(); + + addTrigger({ 'trial-expired': 'True' }); + + expect(hasCloseButton(getPanel())).toBe(false); + expect(getText(getPanel())).toContain('Your 30-day trial has expired'); + }); + + it.each(['yes', '0', 'truee', 'true false'])('should not treat the markup value "%s" as an expired trial', (value) => { + addTrigger({ 'trial-expired': value }); + + expect(hasCloseButton(getPanel())).toBe(true); + }); + + it('should not restore a closed banner', async () => { + addTrigger(); + await closeBanner(); + + getPanel().innerHTML = ''; + await flushObserver(); + + expect(getPanel().innerHTML).toBe(''); + expect(getPanel().style.getPropertyValue('display')).toBe('none'); + }); + + it('should pass the options to the trigger and the panel', () => { + showTrialPanel(buyNow, licensingDoc, version, 'Universal', undefined, { + trialExpired: true, + libraries: 'DevExtreme', + }); + + const trigger = document.querySelector('dx-license-trigger') as HTMLElement; + expect(trigger.getAttribute('trial-expired')).toBe('true'); + expect(trigger.getAttribute('libraries')).toBe('DevExtreme'); + expect(getPanel().getAttribute('trial-expired')).toBe('true'); + expect(getPanel().getAttribute('libraries')).toBe('DevExtreme'); + }); + }); + + describe('expired trial panel', () => { + it('should cover the page and have no close button', () => { + addTrigger({ 'trial-expired': 'true' }); + + expect(getPanel().style.getPropertyValue('position')).toBe('fixed'); + expect(hasCloseButton(getPanel())).toBe(false); + }); + + it('should scroll and center the content so that tall content is not cut off at the top', () => { + addTrigger({ 'trial-expired': 'true' }); + + expect(getPanel().style.getPropertyValue('overflow')).toBe('auto'); + expect((getPanel().firstElementChild as HTMLElement).style.getPropertyValue('margin')).toBe('auto'); + }); + + it('should stay an overlay when the trial-expired attribute of the panel is removed', async () => { + addTrigger({ 'trial-expired': 'true' }); + + getPanel().removeAttribute('trial-expired'); + await flushObserver(); + + expect(hasCloseButton(getPanel())).toBe(false); + expect(getPanel().style.getPropertyValue('position')).toBe('fixed'); + }); + + it('should keep the layout of the overlay against the styles of the page', () => { + addTrigger({ 'trial-expired': 'true' }); + + const { style } = getPanel(); + const pinned: Record = { + position: 'fixed', + width: 'auto', + height: 'auto', + 'max-width': 'none', + 'max-height': 'none', + 'z-index': '99999', + transform: 'none', + margin: '0px', + 'box-sizing': 'border-box', + }; + Object.entries(pinned).forEach(([property, value]) => { + expect(style.getPropertyValue(property)).toBe(value); + expect(style.getPropertyPriority(property)).toBe('important'); + }); + }); + + it('should show the title and the links', () => { + addTrigger({ 'trial-expired': 'true' }); + + const text = getText(getPanel()); + expect(text).toContain('Your 30-day trial has expired (v26.2.0).'); + expect(text).toContain('Use and Redistribution prohibited.'); + expect(text).toContain('Please register a valid license or purchase a new license to continue'); + expect(Array.from(getPanel().querySelectorAll('a')).map((link) => link.innerText)) + .toEqual(['register', 'purchase a new license']); + expect(getPanel().querySelector(`a[href="${licensingDoc}"]`)).not.toBeNull(); + expect(getPanel().querySelector(`a[href="${buyNow}"]`)).not.toBeNull(); + }); + + it.each([ + ['DevExtreme', 'Universal, DXperience', 'libraries: DevExtreme - Included in Subscriptions: Universal, DXperience.'], + ['DevExtreme', undefined, 'libraries: DevExtreme.'], + [undefined, 'Universal', 'use of DevExpress product libraries. Included in Subscriptions: Universal.'], + [undefined, undefined, 'use of DevExpress product libraries.'], + ])('should end the text with the libraries %s and the subscriptions %s', (libraries, subscriptions, expected) => { + const attributes: Record = { 'trial-expired': 'true' }; + if (libraries) attributes.libraries = libraries; + if (subscriptions) attributes.subscriptions = subscriptions; + + addTrigger(attributes); + + expect(getText(getPanel())).toContain(expected); + }); + + it('should return to the page if it was removed, even if a banner was closed before', async () => { + addTrigger({ 'trial-expired': 'true' }); + setClosed(true); + + getPanel().remove(); + await flushObserver(); + + expect(getPanel().getAttribute('trial-expired')).toBe('true'); + }); + }); + + describe('trial expired overrides trial', () => { + it('should switch the existing banner and keep a single panel', () => { + addTrigger(); + const panel = getPanel(); + + addTrigger({ 'trial-expired': 'true' }); + + expect(document.querySelectorAll('dx-license')).toHaveLength(1); + expect(getPanel()).toBe(panel); + expect(panel.getAttribute('trial-expired')).toBe('true'); + expect(hasCloseButton(panel)).toBe(false); + }); + + it('should not switch the panel back when a trigger without trial-expired arrives later', () => { + addTrigger({ 'trial-expired': 'true' }); + + addTrigger(); + + expect(getPanel().getAttribute('trial-expired')).toBe('true'); + expect(hasCloseButton(getPanel())).toBe(false); + }); + + it('should show the expired panel even if the banner was closed before', async () => { + addTrigger(); + await closeBanner(); + expect(getPanel().style.getPropertyValue('display')).toBe('none'); + + addTrigger({ 'trial-expired': 'true' }); + + expect(getPanel().getAttribute('trial-expired')).toBe('true'); + expect(getPanel().style.getPropertyValue('display')).not.toBe('none'); + }); + + it('should keep protecting the expired panel that replaced a closed banner', async () => { + addTrigger(); + await closeBanner(); + addTrigger({ 'trial-expired': 'true' }); + + getPanel().innerHTML = ''; + getPanel().style.cssText = 'display: none'; + await flushObserver(); + + expect(getText(getPanel())).toContain('Your 30-day trial has expired'); + expect(getPanel().style.getPropertyValue('display')).not.toBe('none'); + }); + + it('should take the libraries from the trigger that switches the banner', () => { + addTrigger(); + + addTrigger({ 'trial-expired': 'true', libraries: 'DevExtreme', subscriptions: 'Universal' }); + + expect(getText(getPanel())).toContain('libraries: DevExtreme - Included in Subscriptions: Universal.'); + }); + + it('should take all the data of the overlay from the trigger that shows it', () => { + addTrigger({ + 'buy-now': 'https://buy/A', + 'licensing-doc': 'https://doc/A', + version: '26.2.0', + subscriptions: 'Universal, BannerOnly', + libraries: 'InTrial', + }); + + addTrigger({ + 'trial-expired': 'true', + 'buy-now': 'https://buy/B', + 'licensing-doc': 'https://doc/B', + version: '26.2.1', + subscriptions: 'Universal, Reporting', + libraries: 'Expired', + }); + + const text = getText(getPanel()); + expect(text).toContain('(v26.2.1)'); + expect(text).toContain('libraries: Expired - Included in Subscriptions: Universal, Reporting.'); + expect(text).not.toContain('InTrial'); + expect(text).not.toContain('BannerOnly'); + expect(getPanel().querySelector('a[href="https://doc/B"]')).not.toBeNull(); + expect(getPanel().querySelector('a[href="https://buy/B"]')).not.toBeNull(); + expect(getPanel().querySelector('a[href="https://doc/A"]')).toBeNull(); + }); + + it('should not keep the libraries of the banner when the expired trigger has none', () => { + addTrigger({ libraries: 'InTrial' }); + + addTrigger({ 'trial-expired': 'true' }); + + expect(getText(getPanel())).toContain('use of DevExpress product libraries.'); + expect(getText(getPanel())).not.toContain('InTrial'); + }); + + it('should keep the libraries of the first expired trigger', () => { + addTrigger({ 'trial-expired': 'true', libraries: 'First' }); + + addTrigger({ 'trial-expired': 'true', libraries: 'Second' }); + + expect(getText(getPanel())).toContain('libraries: First'); + expect(getText(getPanel())).not.toContain('Second'); + }); + + it('should not change the overlay by later triggers', () => { + addTrigger({ 'trial-expired': 'true', libraries: 'First', subscriptions: 'Universal, DXperience' }); + + addTrigger({ subscriptions: 'Universal' }); + addTrigger({ 'trial-expired': 'true', libraries: 'Second', subscriptions: 'Other' }); + + expect(getText(getPanel())).toContain('libraries: First - Included in Subscriptions: Universal, DXperience.'); + }); + }); + describe('license id and license source', () => { + it('should pass them to the trigger and the panel and not set them when they are not passed', () => { + showTrialPanel(buyNow, licensingDoc, version, 'Universal', undefined, { + licenseId: 'ABC123', + licenseSource: 'File (c:\\devexpress_license.txt)', + }); + + const trigger = document.querySelector('dx-license-trigger') as HTMLElement; + expect(trigger.getAttribute('license-id')).toBe('ABC123'); + expect(trigger.getAttribute('license-source')).toBe('File (c:\\devexpress_license.txt)'); + expect(getPanel().getAttribute('license-id')).toBe('ABC123'); + + cleanup(); + showTrialPanel(buyNow, licensingDoc, version, 'Universal', undefined, { + licenseId: undefined, + licenseSource: null, + }); + + const plainTrigger = document.querySelector('dx-license-trigger') as HTMLElement; + expect(plainTrigger.hasAttribute('license-id')).toBe(false); + expect(plainTrigger.hasAttribute('license-source')).toBe(false); + expect(getText(getPanel())).not.toContain('License ID'); + }); + + describe.each([ + ['banner', undefined], + ['expired trial panel', { trialExpired: true }], + ])('empty values (%s)', (_, extraOptions) => { + it.each([[''], [null], [undefined]])('should not set or render the value %p', (value) => { + showTrialPanel(buyNow, licensingDoc, version, 'Universal', undefined, { + ...extraOptions, + licenseId: value, + licenseSource: value, + }); + + const trigger = document.querySelector('dx-license-trigger') as HTMLElement; + expect(trigger.hasAttribute('license-id')).toBe(false); + expect(trigger.hasAttribute('license-source')).toBe(false); + expect(getPanel().hasAttribute('license-id')).toBe(false); + expect(getPanel().hasAttribute('license-source')).toBe(false); + expect(getText(getPanel())).not.toContain('License'); + }); + + it('should not render empty attributes of the markup', () => { + addTrigger({ + ...(extraOptions ? { 'trial-expired': 'true' } : {}), + subscriptions: 'Universal', + 'license-id': '', + 'license-source': '', + }); + + expect(getText(getPanel())).toContain('Included in Subscriptions: Universal'); + expect(getText(getPanel())).not.toContain('License'); + }); + }); + + describe('banner', () => { + it('should show them after the subscriptions', () => { + addTrigger({ subscriptions: 'Universal', 'license-id': 'ABC123', 'license-source': 'File (x)' }); + + expect(getText(getPanel())).toContain('Included in Subscriptions: Universal. License ID: ABC123. License Source: File (x)'); + }); + + it('should show the license id when there are no subscriptions', () => { + addTrigger({ 'license-id': 'ABC123' }); + + expect(getText(getPanel())).toContain('(v26.2.0). License ID: ABC123.'); + expect(getText(getPanel())).not.toContain('Included in Subscriptions'); + }); + + it('should show the license source without the license id', () => { + addTrigger({ subscriptions: 'Universal', 'license-source': 'File (x)' }); + + expect(getText(getPanel())).toContain('Included in Subscriptions: Universal. License Source: File (x)'); + expect(getText(getPanel())).not.toContain('License ID'); + }); + + it('should show both when there are no subscriptions', () => { + addTrigger({ 'license-id': 'ABC123', 'license-source': 'File (x)' }); + + expect(getText(getPanel())).toContain('(v26.2.0). License ID: ABC123. License Source: File (x)'); + expect(getText(getPanel())).not.toContain('Included in Subscriptions'); + }); + + it('should follow a change of the attributes of the panel', async () => { + addTrigger({ subscriptions: 'Universal', 'license-id': 'First' }); + + getPanel().setAttribute('license-id', 'Second'); + getPanel().setAttribute('license-source', 'File (x)'); + await flushObserver(); + + expect(getText(getPanel())).toContain('Included in Subscriptions: Universal. License ID: Second. License Source: File (x)'); + expect(getText(getPanel())).not.toContain('First'); + + getPanel().removeAttribute('license-id'); + getPanel().removeAttribute('license-source'); + await flushObserver(); + + expect(getText(getPanel())).not.toContain('License'); + }); + + it('should show only the license source when there are no subscriptions', () => { + addTrigger({ 'license-source': 'File (x)' }); + + expect(getText(getPanel())).toContain('(v26.2.0). License Source: File (x)'); + expect(getText(getPanel())).not.toContain('License ID'); + }); + + it('should not change the text when they are not set', () => { + addTrigger({ subscriptions: 'Universal' }); + + expect(getText(getPanel())).toContain('Included in Subscriptions: Universal'); + expect(getText(getPanel())).not.toContain('License ID'); + expect(getText(getPanel()).endsWith('Included in Subscriptions: Universal')).toBe(true); + }); + + it('should keep the ones of the first trigger when subscriptions of later triggers are merged', () => { + addTrigger({ subscriptions: 'Universal, DXperience', 'license-id': 'First' }); + + addTrigger({ subscriptions: 'Universal', 'license-id': 'Second' }); + + expect(getText(getPanel())).toContain('Included in Subscriptions: Universal. License ID: First.'); + expect(getText(getPanel())).not.toContain('Second'); + }); + + it('should render the values as text', () => { + addTrigger({ 'license-id': 'bold' }); + + expect(getPanel().querySelector('b')).toBeNull(); + expect(getText(getPanel())).toContain('License ID: bold.'); + }); + }); + + describe('expired trial panel', () => { + it('should show them after the libraries and the subscriptions', () => { + addTrigger({ + 'trial-expired': 'true', + libraries: 'DevExtreme', + subscriptions: 'Universal', + 'license-id': 'ABC123', + 'license-source': 'Public Key (LCPv1abc)', + }); + + expect(getText(getPanel())).toContain('libraries: DevExtreme - Included in Subscriptions: Universal. License ID: ABC123. License Source: Public Key (LCPv1abc)'); + }); + + it('should show them when there are no libraries and no subscriptions', () => { + addTrigger({ 'trial-expired': 'true', 'license-id': 'ABC123' }); + + expect(getText(getPanel())).toContain('use of DevExpress product libraries. License ID: ABC123.'); + }); + + it('should show the license source without the license id', () => { + addTrigger({ 'trial-expired': 'true', 'license-source': 'File (x)' }); + + expect(getText(getPanel())).toContain('use of DevExpress product libraries. License Source: File (x)'); + expect(getText(getPanel())).not.toContain('License ID'); + }); + + it('should not show them when they are not set', () => { + addTrigger({ 'trial-expired': 'true', libraries: 'DevExtreme', subscriptions: 'Universal' }); + + expect(getText(getPanel())).not.toContain('License ID'); + expect(getText(getPanel()).endsWith('Included in Subscriptions: Universal.')).toBe(true); + }); + + it('should take them from the trigger that switches the banner', () => { + addTrigger({ 'license-id': 'InTrial', 'license-source': 'SourceOfTrial' }); + + addTrigger({ 'trial-expired': 'true', 'license-id': 'Expired' }); + + expect(getText(getPanel())).toContain('License ID: Expired.'); + expect(getText(getPanel())).not.toContain('InTrial'); + expect(getText(getPanel())).not.toContain('SourceOfTrial'); + expect(getPanel().hasAttribute('license-source')).toBe(false); + }); + + it('should not keep them of the banner when the expired trigger has none', () => { + addTrigger({ 'license-id': 'InTrial' }); + + addTrigger({ 'trial-expired': 'true' }); + + expect(getText(getPanel())).not.toContain('License ID'); + expect(getPanel().hasAttribute('license-id')).toBe(false); + }); + + it('should keep the ones of the first expired trigger', () => { + addTrigger({ 'trial-expired': 'true', 'license-id': 'First' }); + + addTrigger({ 'trial-expired': 'true', 'license-id': 'Second' }); + addTrigger({ 'license-id': 'Third' }); + + expect(getText(getPanel())).toContain('License ID: First.'); + expect(getText(getPanel())).not.toContain('Second'); + expect(getText(getPanel())).not.toContain('Third'); + }); + + it('should follow a change of the attributes of the panel', async () => { + addTrigger({ 'trial-expired': 'true', subscriptions: 'Universal', 'license-id': 'First' }); + + getPanel().setAttribute('license-id', 'Second'); + await flushObserver(); + + expect(getText(getPanel())).toContain('Included in Subscriptions: Universal. License ID: Second.'); + expect(getText(getPanel())).not.toContain('First'); + expect(hasCloseButton(getPanel())).toBe(false); + }); + + it('should wrap a long license source', () => { + addTrigger({ 'trial-expired': 'true', 'license-id': 'ABC123', 'license-source': `Public Key (LCPv1${'a'.repeat(500)})` }); + + const spans = Array.from(getPanel().querySelectorAll('span')) as HTMLElement[]; + const span = spans.find((element) => element.innerText.includes('License Source')) as HTMLElement; + expect(span.style.getPropertyValue('overflow-wrap')).toBe('anywhere'); + }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/core/license/trial_panel.client.ts b/packages/devextreme/js/__internal/core/license/trial_panel.client.ts index e3f1cfe09c5f..0fd075769b94 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.client.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.client.ts @@ -14,6 +14,13 @@ export interface CustomTrialPanelStyles { buttonStyles?: StylesMap; } +export interface TrialPanelOptions { + trialExpired?: boolean; + libraries?: string | null; + licenseId?: string | null; + licenseSource?: string | null; +} + export const isClient = (): boolean => typeof HTMLElement !== 'undefined' && typeof customElements !== 'undefined'; const SafeHTMLElement = isClient() @@ -32,7 +39,15 @@ const attributeNames = { licensingDoc: 'licensing-doc', version: 'version', subscriptions: 'subscriptions', + trialExpired: 'trial-expired', + libraries: 'libraries', + licenseId: 'license-id', + licenseSource: 'license-source', }; + +const BRAND_COLOR = '#FF7200'; +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + const commonStyles = { opacity: '1', visibility: 'visible', @@ -48,7 +63,7 @@ const contentStyles = { 'z-index': `${BASE_Z_INDEX}`, position: 'static', transform: 'translate(0px, 0px)', - 'background-color': '#FF7200', + 'background-color': BRAND_COLOR, border: 'none', margin: 'auto', 'box-sizing': 'border-box', @@ -80,6 +95,7 @@ const textStyles = { 'font-family': '-apple-system, BlinkMacSystemFont, \'avenir next\', avenir, \'helvetica neue\', \'adwaita sans\', cantarell, ubuntu, roboto, noto, helvetica, arial, sans-serif', 'font-size': '0.875rem', 'font-weight': '600', + 'overflow-wrap': 'anywhere', }; function createImportantStyles(defaultStyles: StylesMap, customStyles?: StylesMap): string { @@ -92,6 +108,75 @@ function createImportantStyles(defaultStyles: StylesMap, customStyles?: StylesMa ); } +const OVERLAY_Z_INDEX = '99999'; +const overlayStyles = createImportantStyles({ + ...commonStyles, + position: 'fixed', + top: '0px', + left: '0px', + right: '0px', + bottom: '0px', + display: 'flex', + width: 'auto', + height: 'auto', + 'max-width': 'none', + 'max-height': 'none', + margin: '0px', + transform: 'none', + border: 'none', + 'box-sizing': 'border-box', + overflow: 'auto', + 'z-index': OVERLAY_Z_INDEX, + 'background-color': 'rgba(255, 255, 255, 0.9)', + padding: '1rem', + 'padding-bottom': 'min(400px, 40vh)', +}); +const overlayContentStyles = createImportantStyles({ + ...commonStyles, + display: 'block', + margin: 'auto', + 'max-width': '60rem', + 'text-align': 'center', +}); +const overlayTextStyles = { + ...textStyles, + color: 'black', + 'font-size': '1rem', + 'font-weight': '400', + 'line-height': '2', +}; +const overlaySpanStyles = createImportantStyles(overlayTextStyles); +const overlayTitleStyles = createImportantStyles(overlayTextStyles, { display: 'block', 'font-weight': '700' }); +const overlayLinkStyles = createImportantStyles(overlayTextStyles, { 'text-decoration': 'underline' }); + +function createWarningIcon(): SVGSVGElement { + const icon = document.createElementNS(SVG_NAMESPACE, 'svg'); + const path = document.createElementNS(SVG_NAMESPACE, 'path'); + + icon.setAttribute('viewBox', '0 0 24 24'); + icon.setAttribute('aria-hidden', 'true'); + icon.style.cssText = createImportantStyles({ + display: 'block', + width: '96px', + height: '96px', + margin: '0px auto 2rem auto', + }); + path.setAttribute('d', 'M12,0C5.4,0,0,5.4,0,12s5.4,12,12,12,12-5.4,12-12S18.6,0,12,0ZM12,22c-5.5,0-10-4.5-10-10S6.5,2,12,2s10,4.5,10,10-4.5,10-10,10ZM13,6v8c0,.6-.4,1-1,1s-1-.4-1-1V6c0-.6.4-1,1-1s1,.4,1,1ZM13,18c0,.5-.4,1-1,1s-1-.5-1-1,.4-1,1-1,1,.5,1,1Z'); + path.style.cssText = createImportantStyles({ fill: BRAND_COLOR }); + icon.appendChild(path); + + return icon; +} + +function setStyles(element: T, styles: string): T { + element.style.cssText = styles; + return element; +} + +function isTrialExpired(element: Element): boolean { + return element.getAttribute(attributeNames.trialExpired)?.trim().toLowerCase() === 'true'; +} + class DxLicense extends SafeHTMLElement { public static customStyles: CustomTrialPanelStyles | undefined = undefined; @@ -101,6 +186,8 @@ class DxLicense extends SafeHTMLElement { private _inReassign = false; + private _expired = false; + private readonly _spanStyles: string; private readonly _linkStyles: string; @@ -135,6 +222,10 @@ class DxLicense extends SafeHTMLElement { ); } + public get isExpired(): boolean { + return this._expired; + } + private _getSubscriptionsArray(subscriptions: string | null): string[] { return subscriptions?.split(',').map((x) => x.trim()) ?? []; } @@ -165,12 +256,45 @@ class DxLicense extends SafeHTMLElement { } private _updateSubscriptionsText(subscriptions: string | null): void { - if (subscriptions && this._subscriptionsSpan) { + if (!this._subscriptionsSpan) return; + + if (subscriptions) { this.setAttribute(attributeNames.subscriptions, subscriptions); - this._subscriptionsSpan.innerText = ` Included in Subscriptions: ${subscriptions}`; + } + + if (this._expired) { + this._subscriptionsSpan.innerText = this._getTrialExpiredEnding(subscriptions) + + this._getLicenseText(); + return; + } + + const subscriptionsText = subscriptions ? ` Included in Subscriptions: ${subscriptions}` : ''; + const licenseText = this._getLicenseText(); + if (subscriptionsText || licenseText) { + this._subscriptionsSpan.innerText = licenseText && subscriptionsText + ? `${subscriptionsText}.${licenseText}` + : `${subscriptionsText}${licenseText}`; } } + private _getLicenseText(): string { + const licenseId = this.getAttribute(attributeNames.licenseId); + const licenseSource = this.getAttribute(attributeNames.licenseSource); + + return `${licenseId ? ` License ID: ${licenseId}.` : ''}${licenseSource ? ` License Source: ${licenseSource}` : ''}`; + } + + private _getTrialExpiredEnding(subscriptions: string | null): string { + const libraries = this.getAttribute(attributeNames.libraries); + const subscriptionsText = `Included in Subscriptions: ${subscriptions}.`; + + if (libraries) { + return ` to continue use of the following DevExpress product libraries: ${libraries}${subscriptions ? ` - ${subscriptionsText}` : '.'}`; + } + + return ` to continue use of DevExpress product libraries.${subscriptions ? ` ${subscriptionsText}` : ''}`; + } + private _createSubscriptionsSpan(): HTMLSpanElement { this._subscriptionsSpan = this._createSpan(''); this._updateSubscriptionsText(this.getAttribute(attributeNames.subscriptions)); @@ -197,8 +321,8 @@ class DxLicense extends SafeHTMLElement { const button = document.createElement('div'); button.style.cssText = this._buttonStyles; - const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const polygon = document.createElementNS(SVG_NAMESPACE, 'polygon'); + const svg = document.createElementNS(SVG_NAMESPACE, 'svg'); polygon.setAttribute('points', '13.4 12.7 8.7 8 13.4 3.4 12.6 2.6 8 7.3 3.4 2.6 2.6 3.4 7.3 8 2.6 12.6 3.4 13.4 8 8.7 12.7 13.4 13.4 12.7'); polygon.style.cssText = createImportantStyles({ @@ -227,6 +351,44 @@ class DxLicense extends SafeHTMLElement { return button; } + public setTrialExpired(trigger: Element): void { + if (this._expired) return; + + this._expired = true; + Object.values(attributeNames).forEach((attrName) => { + const attrValue = trigger.getAttribute(attrName); + + if (attrValue) { + this.setAttribute(attrName, attrValue); + } else { + this.removeAttribute(attrName); + } + }); + this._reassignComponent(); + } + + private _createTrialExpiredContent(): HTMLElement { + const licensingDoc = this.getAttribute(attributeNames.licensingDoc) as string; + const buyNow = this.getAttribute(attributeNames.buyNow) as string; + const version = this.getAttribute(attributeNames.version); + const content = setStyles(document.createElement('div'), overlayContentStyles); + + content.append( + createWarningIcon(), + setStyles( + this._createSpan(`Your 30-day trial has expired (v${version}).`), + overlayTitleStyles, + ), + setStyles(this._createSpan('Use and Redistribution prohibited. Please '), overlaySpanStyles), + setStyles(this._createLink('register', licensingDoc), overlayLinkStyles), + setStyles(this._createSpan(' a valid license or '), overlaySpanStyles), + setStyles(this._createLink('purchase a new license', buyNow), overlayLinkStyles), + setStyles(this._createSubscriptionsSpan(), overlaySpanStyles), + ); + + return content; + } + private _createContentContainer(): HTMLElement { const contentContainer = document.createElement('div'); contentContainer.style.cssText = this._contentStyles; @@ -244,6 +406,13 @@ class DxLicense extends SafeHTMLElement { private _reassignComponent(): void { this.innerHTML = ''; + + if (this._expired) { + this.style.cssText = overlayStyles; + this.append(this._createTrialExpiredContent()); + return; + } + this.style.cssText = this._containerStyles; this.append( this._createContentContainer(), @@ -252,11 +421,11 @@ class DxLicense extends SafeHTMLElement { } public connectedCallback(): void { + this._expired = this._expired || isTrialExpired(this); this._reassignComponent(); if (!this._observer) { this._observer = new MutationObserver(() => { - if (DxLicense.closed) { - this._observer?.disconnect(); + if (DxLicense.closed && !this._expired) { return; } if (this._inReassign) { @@ -275,7 +444,7 @@ class DxLicense extends SafeHTMLElement { } public disconnectedCallback(): void { - if (DxLicense.closed) { + if (DxLicense.closed && !this._expired) { return; } @@ -299,7 +468,8 @@ class DxLicenseTrigger extends SafeHTMLElement { }); const licensePanel = document.getElementsByTagName(componentNames.panel); - if (DxLicense.closed) return; + const trialExpired = isTrialExpired(this); + if (DxLicense.closed && !trialExpired) return; if (!licensePanel.length) { const license = document.createElement(componentNames.panel); @@ -315,8 +485,13 @@ class DxLicenseTrigger extends SafeHTMLElement { document.body.prepend(license); } else { - const subscriptions = this.getAttribute(attributeNames.subscriptions) as string; - (licensePanel[0] as DxLicense).updateSubscriptions(subscriptions); + const panel = licensePanel[0] as DxLicense; + + if (trialExpired) { + panel.setTrialExpired(this); + } else if (!panel.isExpired) { + panel.updateSubscriptions(this.getAttribute(attributeNames.subscriptions) as string); + } } } } @@ -341,6 +516,7 @@ export function renderTrialPanel( version: string, subscriptions: string | null | undefined, customStyles?: CustomTrialPanelStyles, + options?: TrialPanelOptions, ): void { registerCustomComponents(customStyles); @@ -351,5 +527,21 @@ export function renderTrialPanel( trialPanelTrigger.setAttribute(attributeNames.version, version); trialPanelTrigger.setAttribute(attributeNames.subscriptions, subscriptions ?? ''); + if (options?.trialExpired === true) { + trialPanelTrigger.setAttribute(attributeNames.trialExpired, 'true'); + } + + if (options?.licenseId) { + trialPanelTrigger.setAttribute(attributeNames.licenseId, options.licenseId); + } + + if (options?.licenseSource) { + trialPanelTrigger.setAttribute(attributeNames.licenseSource, options.licenseSource); + } + + if (options?.libraries) { + trialPanelTrigger.setAttribute(attributeNames.libraries, options.libraries); + } + document.body.appendChild(trialPanelTrigger); } diff --git a/packages/devextreme/js/__internal/core/license/trial_panel.server.test.ts b/packages/devextreme/js/__internal/core/license/trial_panel.server.test.ts new file mode 100644 index 000000000000..3ac6afc965c1 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/trial_panel.server.test.ts @@ -0,0 +1,51 @@ +/** + * @jest-environment node + */ +import { + afterEach, describe, expect, it, +} from '@jest/globals'; + +import { registerTrialPanelComponents, showTrialPanel } from './trial_panel'; + +describe('trial panel on the server', () => { + const show = (): void => showTrialPanel( + 'https://example.com/buy', + 'https://example.com/docs', + '26.2.0', + 'Universal', + undefined, + { + trialExpired: true, libraries: 'DevExtreme', licenseId: 'ABC123', licenseSource: 'File (x)', + }, + ); + + afterEach(() => { + // @ts-expect-error removing the mock + delete global.HTMLElement; + // @ts-expect-error removing the mock + delete global.customElements; + }); + + it('should not throw and should not use the document', () => { + expect(typeof document).toBe('undefined'); + expect(show).not.toThrow(); + expect(() => registerTrialPanelComponents()).not.toThrow(); + expect(() => registerTrialPanelComponents({ textStyles: { color: 'red' } })).not.toThrow(); + }); + + it('should not do anything in Angular, where HTMLElement is mocked and custom elements are not available', () => { + // @ts-expect-error mocking HTMLElement with a symbol + global.HTMLElement = Symbol('HTMLElement mock'); + + expect(show).not.toThrow(); + expect(() => registerTrialPanelComponents()).not.toThrow(); + }); + + it('should not do anything when only custom elements are mocked', () => { + // @ts-expect-error mocking custom elements + global.customElements = {}; + + expect(show).not.toThrow(); + expect(() => registerTrialPanelComponents()).not.toThrow(); + }); +}); diff --git a/packages/devextreme/js/__internal/core/license/trial_panel.test.ts b/packages/devextreme/js/__internal/core/license/trial_panel.test.ts new file mode 100644 index 000000000000..25025198abe0 --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/trial_panel.test.ts @@ -0,0 +1,78 @@ +/** + * @jest-environment jsdom + */ +import { + afterEach, beforeAll, beforeEach, describe, expect, it, +} from '@jest/globals'; + +import { registerTrialPanelComponents, showTrialPanel } from './trial_panel'; + +describe('trial panel public API', () => { + const buyNow = 'https://example.com/buy'; + const licensingDoc = 'https://example.com/docs'; + const version = '26.2.0'; + + interface PanelInternals { + _expired: boolean; + _observer: MutationObserver | null; + } + + const getPanel = (): HTMLElement => document.querySelector('dx-license') as HTMLElement; + + const getFirstSpan = (): HTMLElement => getPanel().querySelector('span') as HTMLElement; + + // The panel is put back after it is removed, unless the banner is closed: + // close it while the page is cleared, so that every test creates its own panel + const cleanup = (): void => { + const panelClass = customElements.get('dx-license') as unknown as { closed: boolean } | undefined; + + document.querySelectorAll('dx-license').forEach((panel) => { + if (!('_expired' in panel) || !('_observer' in panel)) { + throw new Error('The private fields of the panel were renamed: update PanelInternals'); + } + + const internals = panel as unknown as PanelInternals; + internals._expired = false; + internals._observer?.disconnect(); + }); + + if (panelClass) panelClass.closed = true; + document.body.innerHTML = ''; + if (panelClass) panelClass.closed = false; + }; + + beforeAll(() => { + expect(customElements.get('dx-license')).toBeUndefined(); + + registerTrialPanelComponents({ textStyles: { color: 'yellow' } }); + }); + + beforeEach(cleanup); + + afterEach(cleanup); + + it('registerTrialPanelComponents should define the panel and the trigger', () => { + expect(customElements.get('dx-license')).toBeDefined(); + expect(customElements.get('dx-license-trigger')).toBeDefined(); + }); + + it('should use the styles of the first registration for the banner', () => { + showTrialPanel(buyNow, licensingDoc, version, 'Universal'); + + expect(getFirstSpan().style.getPropertyValue('color')).toBe('yellow'); + }); + + it('should ignore the styles of later registrations and of later calls', () => { + registerTrialPanelComponents({ textStyles: { color: 'red' } }); + showTrialPanel(buyNow, licensingDoc, version, 'Universal', { textStyles: { color: 'blue' } }); + + expect(getFirstSpan().style.getPropertyValue('color')).toBe('yellow'); + }); + + it('should not apply the custom styles to the expired trial panel', () => { + showTrialPanel(buyNow, licensingDoc, version, 'Universal', undefined, { trialExpired: true }); + + expect(getPanel().getAttribute('trial-expired')).toBe('true'); + expect(getPanel().querySelector('span')?.style.getPropertyValue('color')).toBe('black'); + }); +}); diff --git a/packages/devextreme/js/__internal/core/license/trial_panel.ts b/packages/devextreme/js/__internal/core/license/trial_panel.ts index 77c7948fcb2a..8f2af526e804 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.ts @@ -3,6 +3,7 @@ import { isClient, registerCustomComponents, renderTrialPanel, + type TrialPanelOptions, } from './trial_panel.client'; export function showTrialPanel( @@ -11,9 +12,17 @@ export function showTrialPanel( version: string, subscriptions?: string | null, customStyles?: CustomTrialPanelStyles, + options?: TrialPanelOptions, ): void { if (isClient()) { - renderTrialPanel(buyNowUrl, licensingDocUrl, version, subscriptions, customStyles); + renderTrialPanel( + buyNowUrl, + licensingDocUrl, + version, + subscriptions, + customStyles, + options, + ); } }