From 5578bc55b5a7396533dba7c23e2c33114bfa60b4 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Thu, 22 Oct 2026 14:21:47 +0300 Subject: [PATCH 1/9] License: add the trial expired mode to the trial panel --- .../core/license/trial_panel.client.test.ts | 219 +++++++++++++++++- .../core/license/trial_panel.client.ts | 175 +++++++++++++- .../js/__internal/core/license/trial_panel.ts | 11 +- 3 files changed, 395 insertions(+), 10 deletions(-) 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..155be834ffdb 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 @@ -5,7 +5,8 @@ import { 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,219 @@ 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 + ); + + // jsdom does not implement innerText: it is a plain property, so textContent is empty + 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(); + // the observer reacts to the closing before anything else happens + await flushObserver(); + }; + + beforeEach(() => { + registerCustomComponents(); + // A removed panel is returned to the page (an expired one always, a trial one unless it was + // closed), so the panel of the previous test is downgraded and closed before the cleanup. + document.querySelectorAll('dx-license').forEach((panel) => panel.removeAttribute('trial-expired')); + setClosed(true); + document.body.innerHTML = ''; + setClosed(false); + }); + + 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', '', '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('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 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 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 add the subscriptions of a later trigger to the text', () => { + addTrigger({ 'trial-expired': 'true', libraries: 'DevExtreme' }); + + addTrigger({ subscriptions: 'Universal' }); + + expect(getText(getPanel())).toContain('libraries: DevExtreme - Included in Subscriptions: Universal.'); + }); + }); +}); 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..5f0bd2864714 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,11 @@ export interface CustomTrialPanelStyles { buttonStyles?: StylesMap; } +export interface TrialPanelOptions { + trialExpired?: boolean; + libraries?: string | null; +} + export const isClient = (): boolean => typeof HTMLElement !== 'undefined' && typeof customElements !== 'undefined'; const SafeHTMLElement = isClient() @@ -32,7 +37,13 @@ const attributeNames = { licensingDoc: 'licensing-doc', version: 'version', subscriptions: 'subscriptions', + trialExpired: 'trial-expired', + libraries: 'libraries', }; + +const BRAND_COLOR = '#FF7200'; +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + const commonStyles = { opacity: '1', visibility: 'visible', @@ -48,7 +59,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', @@ -92,6 +103,72 @@ 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', + 'align-items': 'center', + 'justify-content': 'center', + 'z-index': OVERLAY_Z_INDEX, + 'background-color': 'rgba(255, 255, 255, 0.9)', + margin: '0px', + padding: '1rem', + 'padding-bottom': 'min(400px, 40vh)', + 'box-sizing': 'border-box', +}); +const overlayContentStyles = createImportantStyles({ + ...commonStyles, + display: 'block', + position: 'static', + 'max-width': '60rem', + margin: '0px', + padding: '0px', + '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) === 'true'; +} + class DxLicense extends SafeHTMLElement { public static customStyles: CustomTrialPanelStyles | undefined = undefined; @@ -165,12 +242,30 @@ 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); + } + + if (isTrialExpired(this)) { + this._subscriptionsSpan.innerText = this._getTrialExpiredEnding(subscriptions); + } else if (subscriptions) { this._subscriptionsSpan.innerText = ` Included in Subscriptions: ${subscriptions}`; } } + 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 +292,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 +322,48 @@ class DxLicense extends SafeHTMLElement { return button; } + public setTrialExpired(libraries?: string | null): void { + const newLibraries = !this.getAttribute(attributeNames.libraries) && libraries; + if (isTrialExpired(this) && !newLibraries) return; + + this.setAttribute(attributeNames.trialExpired, 'true'); + if (newLibraries) { + this.setAttribute(attributeNames.libraries, newLibraries); + } + this._reassignComponent(); + } + + private _createTrialExpiredTitle(): HTMLSpanElement { + const version = this.getAttribute(attributeNames.version); + const title = setStyles( + this._createSpan(`Your 30-day trial has expired (v${version}).`), + overlayTitleStyles, + ); + + title.setAttribute('role', 'heading'); + title.setAttribute('aria-level', '2'); + + return title; + } + + private _createTrialExpiredContent(): HTMLElement { + const licensingDoc = this.getAttribute(attributeNames.licensingDoc) as string; + const buyNow = this.getAttribute(attributeNames.buyNow) as string; + const content = setStyles(document.createElement('div'), overlayContentStyles); + + content.append( + createWarningIcon(), + this._createTrialExpiredTitle(), + 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 +381,13 @@ class DxLicense extends SafeHTMLElement { private _reassignComponent(): void { this.innerHTML = ''; + + if (isTrialExpired(this)) { + this.style.cssText = overlayStyles; + this.append(this._createTrialExpiredContent()); + return; + } + this.style.cssText = this._containerStyles; this.append( this._createContentContainer(), @@ -255,8 +399,7 @@ class DxLicense extends SafeHTMLElement { this._reassignComponent(); if (!this._observer) { this._observer = new MutationObserver(() => { - if (DxLicense.closed) { - this._observer?.disconnect(); + if (DxLicense.closed && !isTrialExpired(this)) { return; } if (this._inReassign) { @@ -275,7 +418,7 @@ class DxLicense extends SafeHTMLElement { } public disconnectedCallback(): void { - if (DxLicense.closed) { + if (DxLicense.closed && !isTrialExpired(this)) { return; } @@ -299,7 +442,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); @@ -317,6 +461,12 @@ class DxLicenseTrigger extends SafeHTMLElement { } else { const subscriptions = this.getAttribute(attributeNames.subscriptions) as string; (licensePanel[0] as DxLicense).updateSubscriptions(subscriptions); + + if (trialExpired) { + (licensePanel[0] as DxLicense).setTrialExpired( + this.getAttribute(attributeNames.libraries), + ); + } } } } @@ -341,6 +491,7 @@ export function renderTrialPanel( version: string, subscriptions: string | null | undefined, customStyles?: CustomTrialPanelStyles, + options?: TrialPanelOptions, ): void { registerCustomComponents(customStyles); @@ -351,5 +502,13 @@ export function renderTrialPanel( trialPanelTrigger.setAttribute(attributeNames.version, version); trialPanelTrigger.setAttribute(attributeNames.subscriptions, subscriptions ?? ''); + if (options?.trialExpired) { + trialPanelTrigger.setAttribute(attributeNames.trialExpired, 'true'); + } + + if (options?.libraries) { + trialPanelTrigger.setAttribute(attributeNames.libraries, options.libraries); + } + document.body.appendChild(trialPanelTrigger); } 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, + ); } } From 141d8848ceac74d84d2c2a4f6470a736970b3395 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Thu, 22 Oct 2026 15:38:47 +0300 Subject: [PATCH 2/9] License: remove redundant styles from the trial expired panel --- .../core/license/trial_panel.client.ts | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) 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 5f0bd2864714..e418c6bf605a 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.client.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.client.ts @@ -116,18 +116,13 @@ const overlayStyles = createImportantStyles({ 'justify-content': 'center', 'z-index': OVERLAY_Z_INDEX, 'background-color': 'rgba(255, 255, 255, 0.9)', - margin: '0px', padding: '1rem', 'padding-bottom': 'min(400px, 40vh)', - 'box-sizing': 'border-box', }); const overlayContentStyles = createImportantStyles({ ...commonStyles, display: 'block', - position: 'static', 'max-width': '60rem', - margin: '0px', - padding: '0px', 'text-align': 'center', }); const overlayTextStyles = { @@ -333,27 +328,18 @@ class DxLicense extends SafeHTMLElement { this._reassignComponent(); } - private _createTrialExpiredTitle(): HTMLSpanElement { - const version = this.getAttribute(attributeNames.version); - const title = setStyles( - this._createSpan(`Your 30-day trial has expired (v${version}).`), - overlayTitleStyles, - ); - - title.setAttribute('role', 'heading'); - title.setAttribute('aria-level', '2'); - - return title; - } - 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(), - this._createTrialExpiredTitle(), + 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), From 08f27801c508d7ac13362b83489624c38d2595cb Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 23 Oct 2026 18:38:39 +0300 Subject: [PATCH 3/9] License: make the trial expired overlay scrollable on small screens --- .../js/__internal/core/license/trial_panel.client.test.ts | 7 +++++++ .../js/__internal/core/license/trial_panel.client.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) 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 155be834ffdb..eff7cd9ceccf 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 @@ -175,6 +175,13 @@ describe('trial panel client: options (trialExpired, libraries)', () => { 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 show the title and the links', () => { addTrigger({ 'trial-expired': 'true' }); 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 e418c6bf605a..5259113b8bc6 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.client.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.client.ts @@ -112,8 +112,7 @@ const overlayStyles = createImportantStyles({ right: '0px', bottom: '0px', display: 'flex', - 'align-items': 'center', - 'justify-content': 'center', + overflow: 'auto', 'z-index': OVERLAY_Z_INDEX, 'background-color': 'rgba(255, 255, 255, 0.9)', padding: '1rem', @@ -122,6 +121,7 @@ const overlayStyles = createImportantStyles({ const overlayContentStyles = createImportantStyles({ ...commonStyles, display: 'block', + margin: 'auto', 'max-width': '60rem', 'text-align': 'center', }); From 84b0d337e14f7a34b40f4b292453a91c3ffb208c Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Sun, 25 Oct 2026 09:54:09 +0200 Subject: [PATCH 4/9] License: take all data of the overlay from the trigger that shows it --- .../core/license/trial_panel.client.test.ts | 44 +++++++++++++++++-- .../core/license/trial_panel.client.ts | 27 +++++++----- 2 files changed, 56 insertions(+), 15 deletions(-) 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 eff7cd9ceccf..09a895f3a03b 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 @@ -275,6 +275,43 @@ describe('trial panel client: options (trialExpired, libraries)', () => { 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' }); @@ -284,12 +321,13 @@ describe('trial panel client: options (trialExpired, libraries)', () => { expect(getText(getPanel())).not.toContain('Second'); }); - it('should add the subscriptions of a later trigger to the text', () => { - addTrigger({ 'trial-expired': 'true', libraries: 'DevExtreme' }); + 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: DevExtreme - Included in Subscriptions: Universal.'); + expect(getText(getPanel())).toContain('libraries: First - Included in Subscriptions: Universal, DXperience.'); }); }); }); 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 5259113b8bc6..e11764e7b2ae 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.client.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.client.ts @@ -317,14 +317,18 @@ class DxLicense extends SafeHTMLElement { return button; } - public setTrialExpired(libraries?: string | null): void { - const newLibraries = !this.getAttribute(attributeNames.libraries) && libraries; - if (isTrialExpired(this) && !newLibraries) return; + public setTrialExpired(trigger: Element): void { + if (isTrialExpired(this)) return; - this.setAttribute(attributeNames.trialExpired, 'true'); - if (newLibraries) { - this.setAttribute(attributeNames.libraries, newLibraries); - } + Object.values(attributeNames).forEach((attrName) => { + const attrValue = trigger.getAttribute(attrName); + + if (attrValue) { + this.setAttribute(attrName, attrValue); + } else { + this.removeAttribute(attrName); + } + }); this._reassignComponent(); } @@ -445,13 +449,12 @@ 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) { - (licensePanel[0] as DxLicense).setTrialExpired( - this.getAttribute(attributeNames.libraries), - ); + panel.setTrialExpired(this); + } else if (!isTrialExpired(panel)) { + panel.updateSubscriptions(this.getAttribute(attributeNames.subscriptions) as string); } } } From 933e137f0d168e8032a9fcd792b4df8cdb59169f Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Sun, 25 Oct 2026 10:33:40 +0200 Subject: [PATCH 5/9] License: keep the expired state of the panel in a field --- .../core/license/trial_panel.client.test.ts | 14 ++++++++++++- .../core/license/trial_panel.client.ts | 20 +++++++++++++------ 2 files changed, 27 insertions(+), 7 deletions(-) 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 09a895f3a03b..2fd5695f553b 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 @@ -118,7 +118,9 @@ describe('trial panel client: options (trialExpired, libraries)', () => { registerCustomComponents(); // A removed panel is returned to the page (an expired one always, a trial one unless it was // closed), so the panel of the previous test is downgraded and closed before the cleanup. - document.querySelectorAll('dx-license').forEach((panel) => panel.removeAttribute('trial-expired')); + document.querySelectorAll('dx-license').forEach((panel) => { + (panel as unknown as { _expired: boolean })._expired = false; + }); setClosed(true); document.body.innerHTML = ''; setClosed(false); @@ -182,6 +184,16 @@ describe('trial panel client: options (trialExpired, libraries)', () => { 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 show the title and the links', () => { addTrigger({ 'trial-expired': 'true' }); 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 e11764e7b2ae..bd1494440ca3 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.client.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.client.ts @@ -173,6 +173,8 @@ class DxLicense extends SafeHTMLElement { private _inReassign = false; + private _expired = false; + private readonly _spanStyles: string; private readonly _linkStyles: string; @@ -207,6 +209,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()) ?? []; } @@ -243,7 +249,7 @@ class DxLicense extends SafeHTMLElement { this.setAttribute(attributeNames.subscriptions, subscriptions); } - if (isTrialExpired(this)) { + if (this._expired) { this._subscriptionsSpan.innerText = this._getTrialExpiredEnding(subscriptions); } else if (subscriptions) { this._subscriptionsSpan.innerText = ` Included in Subscriptions: ${subscriptions}`; @@ -318,8 +324,9 @@ class DxLicense extends SafeHTMLElement { } public setTrialExpired(trigger: Element): void { - if (isTrialExpired(this)) return; + if (this._expired) return; + this._expired = true; Object.values(attributeNames).forEach((attrName) => { const attrValue = trigger.getAttribute(attrName); @@ -372,7 +379,7 @@ class DxLicense extends SafeHTMLElement { private _reassignComponent(): void { this.innerHTML = ''; - if (isTrialExpired(this)) { + if (this._expired) { this.style.cssText = overlayStyles; this.append(this._createTrialExpiredContent()); return; @@ -386,10 +393,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 && !isTrialExpired(this)) { + if (DxLicense.closed && !this._expired) { return; } if (this._inReassign) { @@ -408,7 +416,7 @@ class DxLicense extends SafeHTMLElement { } public disconnectedCallback(): void { - if (DxLicense.closed && !isTrialExpired(this)) { + if (DxLicense.closed && !this._expired) { return; } @@ -453,7 +461,7 @@ class DxLicenseTrigger extends SafeHTMLElement { if (trialExpired) { panel.setTrialExpired(this); - } else if (!isTrialExpired(panel)) { + } else if (!panel.isExpired) { panel.updateSubscriptions(this.getAttribute(attributeNames.subscriptions) as string); } } From 6c364714e7878c2aee13d832d1e09db4a91ef038 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Sun, 25 Oct 2026 11:06:20 +0200 Subject: [PATCH 6/9] License: remove comments from the trial panel tests --- .../js/__internal/core/license/trial_panel.client.test.ts | 4 ---- 1 file changed, 4 deletions(-) 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 2fd5695f553b..374fbc5e1b4a 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 @@ -83,7 +83,6 @@ describe('trial panel client: options (trialExpired, libraries)', () => { panel.querySelector(closeButtonSelector) != null ); - // jsdom does not implement innerText: it is a plain property, so textContent is empty const getText = (panel: HTMLElement): string => Array.from(panel.querySelectorAll('span, a')) .map((element) => (element as HTMLElement).innerText) .join(''); @@ -110,14 +109,11 @@ describe('trial panel client: options (trialExpired, libraries)', () => { const closeBanner = async (): Promise => { const closeButtonIcon = getPanel().querySelector(closeButtonSelector); (closeButtonIcon?.parentElement as HTMLElement).click(); - // the observer reacts to the closing before anything else happens await flushObserver(); }; beforeEach(() => { registerCustomComponents(); - // A removed panel is returned to the page (an expired one always, a trial one unless it was - // closed), so the panel of the previous test is downgraded and closed before the cleanup. document.querySelectorAll('dx-license').forEach((panel) => { (panel as unknown as { _expired: boolean })._expired = false; }); From ae0b4cb43236282c30a68b374437adf7093f1b2b Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Sun, 25 Oct 2026 11:21:27 +0200 Subject: [PATCH 7/9] License: disconnect the panel observers in the trial panel tests --- .../core/license/trial_panel.client.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 374fbc5e1b4a..ada6e0bbdf5c 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,7 +2,7 @@ * @jest-environment jsdom */ import { - beforeEach, describe, expect, it, + afterEach, beforeEach, describe, expect, it, } from '@jest/globals'; import { showTrialPanel } from './trial_panel'; @@ -112,16 +112,30 @@ describe('trial panel client: options (trialExpired, libraries)', () => { await flushObserver(); }; - beforeEach(() => { - registerCustomComponents(); + interface PanelInternals { + _expired: boolean; + _observer: MutationObserver | null; + } + + const cleanup = (): void => { document.querySelectorAll('dx-license').forEach((panel) => { - (panel as unknown as { _expired: boolean })._expired = false; + 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], From d2c76b6a1bc8147c034e677a234ac85e66e28205 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Mon, 26 Oct 2026 10:52:31 +0200 Subject: [PATCH 8/9] License: add license id and source to the trial panel, ignore flag case --- .../core/license/trial_panel.client.test.ts | 239 ++++++++++++++++++ .../core/license/trial_panel.client.ts | 36 ++- .../core/license/trial_panel.server.test.ts | 51 ++++ .../core/license/trial_panel.test.ts | 49 ++++ 4 files changed, 371 insertions(+), 4 deletions(-) create mode 100644 packages/devextreme/js/__internal/core/license/trial_panel.server.test.ts create mode 100644 packages/devextreme/js/__internal/core/license/trial_panel.test.ts 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 ada6e0bbdf5c..96e50b64c352 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 @@ -154,6 +154,28 @@ describe('trial panel client: options (trialExpired, libraries)', () => { 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(); @@ -352,4 +374,221 @@ describe('trial panel client: options (trialExpired, libraries)', () => { 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 bd1494440ca3..3a5090397ef6 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.client.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.client.ts @@ -17,6 +17,8 @@ export interface CustomTrialPanelStyles { export interface TrialPanelOptions { trialExpired?: boolean; libraries?: string | null; + licenseId?: string | null; + licenseSource?: string | null; } export const isClient = (): boolean => typeof HTMLElement !== 'undefined' && typeof customElements !== 'undefined'; @@ -39,6 +41,8 @@ const attributeNames = { subscriptions: 'subscriptions', trialExpired: 'trial-expired', libraries: 'libraries', + licenseId: 'license-id', + licenseSource: 'license-source', }; const BRAND_COLOR = '#FF7200'; @@ -91,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 { @@ -161,7 +166,7 @@ function setStyles(element: T, styles: string): T { } function isTrialExpired(element: Element): boolean { - return element.getAttribute(attributeNames.trialExpired) === 'true'; + return element.getAttribute(attributeNames.trialExpired)?.trim().toLowerCase() === 'true'; } class DxLicense extends SafeHTMLElement { @@ -250,12 +255,27 @@ class DxLicense extends SafeHTMLElement { } if (this._expired) { - this._subscriptionsSpan.innerText = this._getTrialExpiredEnding(subscriptions); - } else if (subscriptions) { - this._subscriptionsSpan.innerText = ` Included in Subscriptions: ${subscriptions}`; + 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}.`; @@ -503,6 +523,14 @@ export function renderTrialPanel( 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); } 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..499d4ff2485a --- /dev/null +++ b/packages/devextreme/js/__internal/core/license/trial_panel.test.ts @@ -0,0 +1,49 @@ +/** + * @jest-environment jsdom + */ +import { + afterEach, beforeAll, describe, expect, it, +} from '@jest/globals'; + +import { registerTrialPanelComponents, showTrialPanel } from './trial_panel'; + +describe('trial panel public API', () => { + const getPanel = (): HTMLElement => document.querySelector('dx-license') as HTMLElement; + + const getFirstSpan = (): HTMLElement => getPanel().querySelector('span') as HTMLElement; + + beforeAll(() => { + expect(customElements.get('dx-license')).toBeUndefined(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('registerTrialPanelComponents should define the panel and the trigger', () => { + registerTrialPanelComponents({ textStyles: { color: 'yellow' } }); + + 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('https://example.com/buy', 'https://example.com/docs', '26.2.0', '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('https://example.com/buy', 'https://example.com/docs', '26.2.0', 'Universal', { textStyles: { color: 'blue' } }); + + expect(getFirstSpan().style.getPropertyValue('color')).toBe('yellow'); + }); + + it('should not apply the custom styles to the expired trial panel', () => { + showTrialPanel('https://example.com/buy', 'https://example.com/docs', '26.2.0', 'Universal', undefined, { trialExpired: true }); + + expect(getPanel().getAttribute('trial-expired')).toBe('true'); + expect(getPanel().querySelector('span')?.style.getPropertyValue('color')).toBe('black'); + }); +}); From c740a4042ab5f4d0686ec3e6bdb3486d873f1184 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Mon, 26 Oct 2026 12:35:08 +0200 Subject: [PATCH 9/9] License: pin the overlay layout, strict trialExpired, fix panel tests --- .../core/license/trial_panel.client.test.ts | 34 ++++++++++++++ .../core/license/trial_panel.client.ts | 10 +++- .../core/license/trial_panel.test.ts | 47 +++++++++++++++---- 3 files changed, 81 insertions(+), 10 deletions(-) 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 96e50b64c352..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 @@ -119,6 +119,10 @@ describe('trial panel client: options (trialExpired, libraries)', () => { 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; @@ -148,6 +152,15 @@ describe('trial panel client: options (trialExpired, libraries)', () => { 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 }); @@ -226,6 +239,27 @@ describe('trial panel client: options (trialExpired, libraries)', () => { 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' }); 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 3a5090397ef6..0fd075769b94 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.client.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.client.ts @@ -117,6 +117,14 @@ const overlayStyles = createImportantStyles({ 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)', @@ -519,7 +527,7 @@ export function renderTrialPanel( trialPanelTrigger.setAttribute(attributeNames.version, version); trialPanelTrigger.setAttribute(attributeNames.subscriptions, subscriptions ?? ''); - if (options?.trialExpired) { + if (options?.trialExpired === true) { trialPanelTrigger.setAttribute(attributeNames.trialExpired, 'true'); } diff --git a/packages/devextreme/js/__internal/core/license/trial_panel.test.ts b/packages/devextreme/js/__internal/core/license/trial_panel.test.ts index 499d4ff2485a..25025198abe0 100644 --- a/packages/devextreme/js/__internal/core/license/trial_panel.test.ts +++ b/packages/devextreme/js/__internal/core/license/trial_panel.test.ts @@ -2,46 +2,75 @@ * @jest-environment jsdom */ import { - afterEach, beforeAll, describe, expect, it, + 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(); - }); - afterEach(() => { - document.body.innerHTML = ''; + registerTrialPanelComponents({ textStyles: { color: 'yellow' } }); }); - it('registerTrialPanelComponents should define the panel and the trigger', () => { - 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('https://example.com/buy', 'https://example.com/docs', '26.2.0', 'Universal'); + 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('https://example.com/buy', 'https://example.com/docs', '26.2.0', 'Universal', { textStyles: { color: 'blue' } }); + 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('https://example.com/buy', 'https://example.com/docs', '26.2.0', 'Universal', undefined, { trialExpired: true }); + showTrialPanel(buyNow, licensingDoc, version, 'Universal', undefined, { trialExpired: true }); expect(getPanel().getAttribute('trial-expired')).toBe('true'); expect(getPanel().querySelector('span')?.style.getPropertyValue('color')).toBe('black');