diff --git a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.stories.ts b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.stories.ts index dddcc107..6772afbd 100644 --- a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.stories.ts +++ b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.stories.ts @@ -11,20 +11,29 @@ const summary: Summary = { localName: 'foxy-subscription-settings-form', translatable: true, configurable: { - sections: ['timestamps', 'header'], - buttons: ['delete', 'create', 'submit', 'undo', 'header:copy-id', 'header:copy-json'], + sections: [ + 'header', + 'past-due-amount-group', + 'reattempts-group', + 'emails-group', + 'modification-group', + 'cancellation-group', + 'timestamps', + ], + buttons: ['delete', 'create', 'submit', 'undo', 'header:copy-json'], inputs: [ - 'past-due-amount-handling', - 'automatically-charge-past-due-amount', - 'clear-past-due-amounts-on-success', - 'reset-nextdate-on-makeup-payment', - 'reattempt-bypass', - 'reattempt-schedule', - 'reminder-email-schedule', - 'expiring-soon-payment-reminder-schedule', - 'send-email-receipts-for-automated-billing', - 'cancellation-schedule', - 'modification-url', + 'past-due-amount-group:past-due-amount-handling', + 'past-due-amount-group:automatically-charge-past-due-amount', + 'past-due-amount-group:reset-nextdate-on-makeup-payment', + 'past-due-amount-group:prevent-customer-cancel-with-past-due', + 'reattempts-group:reattempt-bypass-logic', + 'reattempts-group:reattempt-bypass-strings', + 'reattempts-group:reattempt-schedule', + 'emails-group:send-email-receipts-for-automated-billing', + 'emails-group:reminder-email-schedule', + 'emails-group:expiring-soon-payment-reminder-schedule', + 'cancellation-group:cancellation-schedule', + 'modification-group:modification-url', ], }, }; diff --git a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.test.ts b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.test.ts index 8448326f..1a3ff283 100644 --- a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.test.ts +++ b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.test.ts @@ -3,13 +3,13 @@ import type { Data } from './types'; import './index'; -import { InternalSubscriptionSettingsFormReattemptBypass } from './internal/InternalSubscriptionSettingsFormReattemptBypass/InternalSubscriptionSettingsFormReattemptBypass'; import { SubscriptionSettingsForm as Form } from './SubscriptionSettingsForm'; import { expect, fixture, html, waitUntil } from '@open-wc/testing'; -import { InternalCheckboxGroupControl } from '../../internal/InternalCheckboxGroupControl/InternalCheckboxGroupControl'; import { InternalEditableListControl } from '../../internal/InternalEditableListControl/InternalEditableListControl'; -import { InternalRadioGroupControl } from '../../internal/InternalRadioGroupControl/InternalRadioGroupControl'; -import { InternalIntegerControl } from '../../internal/InternalIntegerControl/InternalIntegerControl'; +import { InternalSummaryControl } from '../../internal/InternalSummaryControl/InternalSummaryControl'; +import { InternalNumberControl } from '../../internal/InternalNumberControl/InternalNumberControl'; +import { InternalSwitchControl } from '../../internal/InternalSwitchControl/InternalSwitchControl'; +import { InternalSelectControl } from '../../internal/InternalSelectControl/InternalSelectControl'; import { InternalTextControl } from '../../internal/InternalTextControl/InternalTextControl'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { createRouter } from '../../../server'; @@ -17,24 +17,29 @@ import { getTestData } from '../../../testgen/getTestData'; import { stub } from 'sinon'; describe('SubscriptionSettingsForm', () => { - it('imports and defines foxy-internal-checkbox-group-control', () => { - const constructor = customElements.get('foxy-internal-checkbox-group-control'); - expect(constructor).to.equal(InternalCheckboxGroupControl); - }); - it('imports and defines foxy-internal-editable-list-control', () => { const constructor = customElements.get('foxy-internal-editable-list-control'); expect(constructor).to.equal(InternalEditableListControl); }); - it('imports and defines foxy-internal-radio-group-control', () => { - const constructor = customElements.get('foxy-internal-radio-group-control'); - expect(constructor).to.equal(InternalRadioGroupControl); + it('imports and defines foxy-internal-summary-control', () => { + const constructor = customElements.get('foxy-internal-summary-control'); + expect(constructor).to.equal(InternalSummaryControl); + }); + + it('imports and defines foxy-internal-switch-control', () => { + const constructor = customElements.get('foxy-internal-switch-control'); + expect(constructor).to.equal(InternalSwitchControl); }); - it('imports and defines foxy-internal-integer-control', () => { - const constructor = customElements.get('foxy-internal-integer-control'); - expect(constructor).to.equal(InternalIntegerControl); + it('imports and defines foxy-internal-select-control', () => { + const constructor = customElements.get('foxy-internal-select-control'); + expect(constructor).to.equal(InternalSelectControl); + }); + + it('imports and defines foxy-internal-number-control', () => { + const constructor = customElements.get('foxy-internal-number-control'); + expect(constructor).to.equal(InternalNumberControl); }); it('imports and defines foxy-internal-text-control', () => { @@ -47,12 +52,6 @@ describe('SubscriptionSettingsForm', () => { expect(constructor).to.equal(InternalForm); }); - it('imports and defines foxy-internal-subscription-settings-form-reattempt-bypass', () => { - const tag = 'foxy-internal-subscription-settings-form-reattempt-bypass'; - const constructor = customElements.get(tag); - expect(constructor).to.equal(InternalSubscriptionSettingsFormReattemptBypass); - }); - it('imports and defines itself as foxy-subscription-settings-form', () => { const constructor = customElements.get('foxy-subscription-settings-form'); expect(constructor).to.equal(Form); @@ -131,7 +130,34 @@ describe('SubscriptionSettingsForm', () => { expect(form.hiddenSelector.matches('header:copy-id', true)).to.be.true; }); - it('renders radio group with past due amount handling options', async () => { + it('hides reattempt bypass strings control if reattempt bypass is off', async () => { + const form = new Form(); + const scope = 'reattempts-group:reattempt-bypass-strings'; + + form.data = await getTestData('./hapi/subscription_settings/0'); + form.edit({ reattempt_bypass_logic: '' }); + expect(form.hiddenSelector.matches(scope, true)).to.be.true; + + form.edit({ reattempt_bypass_logic: 'skip_if_exists' }); + expect(form.hiddenSelector.matches(scope, true)).to.be.false; + }); + + it('renders a summary control for past due amount settings', async () => { + const router = createRouter(); + const element = await fixture
(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + const control = element.renderRoot.querySelector('[infer="past-due-amount-group"]'); + expect(control).to.be.instanceOf(InternalSummaryControl); + }); + + it('renders a select control with past due amount handling options', async () => { const router = createRouter(); const element = await fixture(html` { `); await waitUntil(() => !!element.data, '', { timeout: 5000 }); - const control = element.renderRoot.querySelector('[infer="past-due-amount-handling"]'); + const control = element.renderRoot.querySelector( + '[infer="past-due-amount-group"] [infer="past-due-amount-handling"]' + ); - expect(control).to.be.instanceOf(InternalRadioGroupControl); + expect(control).to.be.instanceOf(InternalSelectControl); + expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.deep.property('options', [ { label: 'option_increment', value: 'increment' }, { label: 'option_replace', value: 'replace' }, - { label: 'option_ignore', value: 'ignore' }, ]); }); - it('renders a checkbox controlling automatic past due charging', async () => { + it('renders a switch controlling automatic past due charging', async () => { const router = createRouter(); const element = await fixture(html` { `); await waitUntil(() => !!element.data, '', { timeout: 5000 }); - const control = element.renderRoot.querySelector( - '[infer="automatically-charge-past-due-amount"]' + const control = element.renderRoot.querySelector( + '[infer="past-due-amount-group"] [infer="automatically-charge-past-due-amount"]' ); - expect(control).to.be.instanceOf(InternalCheckboxGroupControl); - expect(control).to.have.deep.property('options', [ - { label: 'option_checked', value: 'checked' }, - ]); - - element.edit({ automatically_charge_past_due_amount: false }); - await element.requestUpdate(); - expect(control?.getValue()).to.deep.equal([]); - - element.edit({ automatically_charge_past_due_amount: true }); - await element.requestUpdate(); - expect(control?.getValue()).to.deep.equal(['checked']); + expect(control).to.be.instanceOf(InternalSwitchControl); + expect(control).to.have.attribute('helper-text-as-tooltip'); }); - it('renders a checkbox controlling clearing past due amounts on success when automatic past due charging is off', async () => { + it('renders a switch controlling resetting next date on makeup payment', async () => { const router = createRouter(); const element = await fixture(html` { `); await waitUntil(() => !!element.data, '', { timeout: 5000 }); + const control = element.renderRoot.querySelector( + '[infer="past-due-amount-group"] [infer="reset-nextdate-on-makeup-payment"]' + ); - element.edit({ - automatically_charge_past_due_amount: false, - clear_past_due_amounts_on_success: false, - }); + expect(control).to.be.instanceOf(InternalSwitchControl); + expect(control).to.have.attribute('helper-text-as-tooltip'); + }); - await element.requestUpdate(); + it('renders a switch controlling preventing customer cancel with past due', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); - const control = element.renderRoot.querySelector( - '[infer="clear-past-due-amounts-on-success"]' + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + const control = element.renderRoot.querySelector( + '[infer="past-due-amount-group"] [infer="prevent-customer-cancel-with-past-due"]' ); - expect(control).to.be.instanceOf(InternalCheckboxGroupControl); - expect(control).to.have.deep.property('options', [ - { label: 'option_checked', value: 'checked' }, - ]); - - expect(control?.getValue()).to.deep.equal([]); - - element.edit({ clear_past_due_amounts_on_success: true }); - await element.requestUpdate(); - expect(control?.getValue()).to.deep.equal(['checked']); + expect(control).to.be.instanceOf(InternalSwitchControl); + expect(control).to.have.attribute('helper-text-as-tooltip'); }); - it('renders a checkbox controlling resetting next date on makeup payment when automatic past due charging is off', async () => { + it('renders a summary control for reattempt settings', async () => { const router = createRouter(); const element = await fixture(html` { `); await waitUntil(() => !!element.data, '', { timeout: 5000 }); + const control = element.renderRoot.querySelector('[infer="reattempts-group"]'); + expect(control).to.be.instanceOf(InternalSummaryControl); + }); - element.edit({ - automatically_charge_past_due_amount: false, - reset_nextdate_on_makeup_payment: false, - }); - - await element.requestUpdate(); + it('renders a select control with reattempt bypass logic options', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); - const control = element.renderRoot.querySelector( - '[infer="reset-nextdate-on-makeup-payment"]' + const control = element.renderRoot.querySelector( + '[infer="reattempts-group"] [infer="reattempt-bypass-logic"]' ); - expect(control).to.be.instanceOf(InternalCheckboxGroupControl); + expect(control).to.be.instanceOf(InternalSelectControl); + expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.deep.property('options', [ - { label: 'option_checked', value: 'checked' }, + { value: '', label: 'option_always_reattempt' }, + { value: 'skip_if_exists', label: 'option_skip_if_exists' }, + { value: 'reattempt_if_exists', label: 'option_reattempt_if_exists' }, ]); - - expect(control?.getValue()).to.deep.equal([]); - - element.edit({ reset_nextdate_on_makeup_payment: true }); - await element.requestUpdate(); - expect(control?.getValue()).to.deep.equal(['checked']); }); - it('renders reattempt bypass settings control', async () => { + it('renders a reattempt bypass strings list control', async () => { const router = createRouter(); const element = await fixture(html` router.handleEvent(evt)} > `); await waitUntil(() => !!element.data, '', { timeout: 5000 }); - const control = element.renderRoot.querySelector('[infer="reattempt-bypass"]'); + const control = element.renderRoot.querySelector( + '[infer="reattempts-group"] [infer="reattempt-bypass-strings"]' + ); + + expect(control).to.be.instanceOf(InternalEditableListControl); + expect(control).to.have.property('layout', 'summary-item'); + + element.edit({ reattempt_bypass_strings: 'foo, bar, baz' }); + await element.requestUpdate(); + expect(control?.getValue()).to.deep.equal([ + { value: 'foo' }, + { value: 'bar' }, + { value: 'baz' }, + ]); - expect(control).to.be.instanceOf(InternalSubscriptionSettingsFormReattemptBypass); + control?.setValue([{ value: 'qux' }, { value: 'quux' }]); + expect(element).to.have.nested.property('form.reattempt_bypass_strings', 'qux,quux'); }); - it('renders reattempt schedule list control', async () => { + it('renders a reattempt schedule list control', async () => { const router = createRouter(); const element = await fixture(html` { await waitUntil(() => !!element.data, '', { timeout: 5000 }); const control = element.renderRoot.querySelector( - '[infer="reattempt-schedule"]' + '[infer="reattempts-group"] [infer="reattempt-schedule"]' ); expect(control).to.be.instanceOf(InternalEditableListControl); @@ -302,7 +341,23 @@ describe('SubscriptionSettingsForm', () => { expect(element).to.have.nested.property('form.reattempt_schedule', '8,20'); }); - it('renders reminder email schedule list control', async () => { + it('renders a summary control for email settings', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + const control = element.renderRoot.querySelector('[infer="emails-group"]'); + expect(control).to.be.instanceOf(InternalSummaryControl); + }); + + it('renders a reminder email schedule list control', async () => { const router = createRouter(); const element = await fixture(html` { await waitUntil(() => !!element.data, '', { timeout: 5000 }); const control = element.renderRoot.querySelector( - '[infer="reminder-email-schedule"]' + '[infer="emails-group"] [infer="reminder-email-schedule"]' ); expect(control).to.be.instanceOf(InternalEditableListControl); + expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.deep.property('inputParams', { type: 'number', step: '1', @@ -337,7 +393,7 @@ describe('SubscriptionSettingsForm', () => { expect(element).to.have.nested.property('form.reminder_email_schedule', '8,20'); }); - it('renders payment method expiry email schedule list control', async () => { + it('renders a payment method expiry email schedule list control', async () => { const router = createRouter(); const element = await fixture(html` { await waitUntil(() => !!element.data, '', { timeout: 5000 }); const control = element.renderRoot.querySelector( - '[infer="expiring-soon-payment-reminder-schedule"]' + '[infer="emails-group"] [infer="expiring-soon-payment-reminder-schedule"]' ); expect(control).to.be.instanceOf(InternalEditableListControl); + expect(control).to.have.attribute('layout', 'summary-item'); expect(control).to.have.deep.property('inputParams', { type: 'number', step: '1', @@ -383,25 +440,31 @@ describe('SubscriptionSettingsForm', () => { `); await waitUntil(() => !!element.data, '', { timeout: 5000 }); - const control = element.renderRoot.querySelector( - '[infer="send-email-receipts-for-automated-billing"]' + const control = element.renderRoot.querySelector( + '[infer="emails-group"] [infer="send-email-receipts-for-automated-billing"]' ); - expect(control).to.be.instanceOf(InternalCheckboxGroupControl); - expect(control).to.have.deep.property('options', [ - { label: 'option_checked', value: 'checked' }, - ]); + expect(control).to.be.instanceOf(InternalSwitchControl); + expect(control).to.have.attribute('helper-text-as-tooltip'); + }); - element.edit({ send_email_receipts_for_automated_billing: false }); - await element.requestUpdate(); - expect(control?.getValue()).to.deep.equal([]); + it('renders a summary control for modification settings', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); - element.edit({ send_email_receipts_for_automated_billing: true }); - await element.requestUpdate(); - expect(control?.getValue()).to.deep.equal(['checked']); + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + const control = element.renderRoot.querySelector('[infer="modification-group"]'); + expect(control).to.be.instanceOf(InternalSummaryControl); }); - it('renders an integer input for cancellation schedule', async () => { + it('renders a text control for modification url', async () => { const router = createRouter(); const element = await fixture(html` { `); await waitUntil(() => !!element.data, '', { timeout: 5000 }); - const control = element.renderRoot.querySelector( - '[infer="cancellation-schedule"]' + const control = element.renderRoot.querySelector( + '[infer="modification-group"] [infer="modification-url"]' ); - expect(control).to.be.instanceOf(InternalIntegerControl); - expect(control).to.have.property('min', 1); + expect(control).to.be.instanceOf(InternalTextControl); + expect(control).to.have.attribute('layout', 'summary-item'); + }); - element.edit({ cancellation_schedule: 0 }); - await element.requestUpdate(); - expect(control).to.have.property('suffix', ''); + it('renders a summary control for cancellation settings', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); - element.edit({ cancellation_schedule: 7 }); - await element.requestUpdate(); - expect(control).to.have.property('suffix', 'day_suffix'); + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + const control = element.renderRoot.querySelector('[infer="cancellation-group"]'); + expect(control).to.be.instanceOf(InternalSummaryControl); }); - it('renders a text control for modification url', async () => { + it('renders a number control for cancellation schedule', async () => { const router = createRouter(); const element = await fixture(html` { `); await waitUntil(() => !!element.data, '', { timeout: 5000 }); - const control = element.renderRoot.querySelector('[infer="modification-url"]'); + const control = element.renderRoot.querySelector( + '[infer="cancellation-group"] [infer="cancellation-schedule"]' + ); - expect(control).to.be.instanceOf(InternalTextControl); + expect(control).to.be.instanceOf(InternalNumberControl); + expect(control).to.have.attribute('step', '1'); + expect(control).to.have.attribute('min', '1'); + + element.edit({ cancellation_schedule: 0 }); + await element.requestUpdate(); + expect(control).to.have.attribute('suffix', ''); + + element.edit({ cancellation_schedule: 7 }); + await element.requestUpdate(); + expect(control).to.have.attribute('suffix', 'day_suffix'); }); }); diff --git a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.ts b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.ts index 6831df85..0d39c316 100644 --- a/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.ts +++ b/src/elements/public/SubscriptionSettingsForm/SubscriptionSettingsForm.ts @@ -1,8 +1,8 @@ -import type { Data } from './types'; import type { TemplateResult } from 'lit-html'; import type { NucleonV8N } from '../NucleonElement/types'; import type { Option } from '../../internal/InternalCheckboxGroupControl/types'; import type { Item } from '../../internal/InternalEditableListControl/types'; +import type { Data } from './types'; import { TranslatableMixin } from '../../../mixins/translatable'; import { BooleanSelector } from '@foxy.io/sdk/core'; @@ -36,26 +36,9 @@ export class SubscriptionSettingsForm extends Base { ]; } - private __sendEmailReceiptsForAutomatedBillingOptions: Option[] = [ - { label: 'option_checked', value: 'checked' }, - ]; - - private __automaticallyChargePastDueAmountOptions: Option[] = [ - { label: 'option_checked', value: 'checked' }, - ]; - - private __clearPastDueAmountsOnSuccessOptions: Option[] = [ - { label: 'option_checked', value: 'checked' }, - ]; - - private __resetNextDateOnMakeUpPaymentOptions: Option[] = [ - { label: 'option_checked', value: 'checked' }, - ]; - private __pastDueAmountHandlingOptions: Option[] = [ { label: 'option_increment', value: 'increment' }, { label: 'option_replace', value: 'replace' }, - { label: 'option_ignore', value: 'ignore' }, ]; private __positiveIntegerInputParams = { @@ -64,14 +47,6 @@ export class SubscriptionSettingsForm extends Base { min: '0', }; - private __sendEmailReceiptsForAutomatedBillingGetValue = () => { - return this.form.send_email_receipts_for_automated_billing ? ['checked'] : []; - }; - - private __sendEmailReceiptsForAutomatedBillingSetValue = (newValue: string[]) => { - this.edit({ send_email_receipts_for_automated_billing: newValue.includes('checked') }); - }; - private __expiringSoonPaymentReminderScheduleGetValue = () => { const days = this.form.expiring_soon_payment_reminder_schedule?.split(',') ?? []; @@ -88,35 +63,6 @@ export class SubscriptionSettingsForm extends Base { }); }; - private __automaticallyChargePastDueAmountGetValue = () => { - return this.form.automatically_charge_past_due_amount ? ['checked'] : []; - }; - - private __automaticallyChargePastDueAmountSetValue = (newValue: string[]) => { - const isChecked = newValue.includes('checked'); - this.edit({ - automatically_charge_past_due_amount: isChecked, - clear_past_due_amounts_on_success: isChecked ? false : void 0, - reset_nextdate_on_makeup_payment: isChecked ? false : void 0, - }); - }; - - private __clearPastDueAmountsOnSuccessGetValue = () => { - return this.form.clear_past_due_amounts_on_success ? ['checked'] : []; - }; - - private __clearPastDueAmountsOnSuccessSetValue = (newValue: string[]) => { - this.edit({ clear_past_due_amounts_on_success: newValue.includes('checked') }); - }; - - private __resetNextDateOnMakeUpPaymentGetValue = () => { - return this.form.reset_nextdate_on_makeup_payment ? ['checked'] : []; - }; - - private __resetNextDateOnMakeUpPaymentSetValue = (newValue: string[]) => { - this.edit({ reset_nextdate_on_makeup_payment: newValue.includes('checked') }); - }; - private __reminderEmailScheduleGetValue = () => { const days = this.form.reminder_email_schedule?.split(',') ?? []; @@ -145,96 +91,133 @@ export class SubscriptionSettingsForm extends Base { this.edit({ reattempt_schedule: newItems.map(({ value }) => value).join() }); }; + private __getReattemptBypassStringsValue = () => { + const strings = this.form.reattempt_bypass_strings?.split(',') ?? []; + + return strings + .map(text => text.trim()) + .filter((text, index, strings) => text && strings.indexOf(text) === index) + .map(text => ({ value: text })); + }; + + private __setReattemptBypassStringsValue = (newValue: Item[]) => { + this.edit({ reattempt_bypass_strings: newValue.map(({ value }) => value).join() }); + }; + + private __reattemptBypassLogicOptions: Option[] = [ + { value: '', label: 'option_always_reattempt' }, + { value: 'skip_if_exists', label: 'option_skip_if_exists' }, + { value: 'reattempt_if_exists', label: 'option_reattempt_if_exists' }, + ]; + get hiddenSelector(): BooleanSelector { - return new BooleanSelector(`header:copy-id ${super.hiddenSelector}`.trim()); + const alwaysMatch = ['header:copy-id', super.hiddenSelector.toString()]; + + if (!this.form.reattempt_bypass_logic) { + alwaysMatch.push('reattempts-group:reattempt-bypass-strings'); + } + + return new BooleanSelector(alwaysMatch.join(' ').trim()); } renderBody(): TemplateResult { return html` ${this.renderHeader()} - - - - - - - ${this.form.automatically_charge_past_due_amount - ? '' - : html` - - - - - - `} - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${super.renderBody()} `; diff --git a/src/elements/public/SubscriptionSettingsForm/index.ts b/src/elements/public/SubscriptionSettingsForm/index.ts index c6a025dc..1d0772ae 100644 --- a/src/elements/public/SubscriptionSettingsForm/index.ts +++ b/src/elements/public/SubscriptionSettingsForm/index.ts @@ -1,12 +1,11 @@ -import '../../internal/InternalCheckboxGroupControl/index'; import '../../internal/InternalEditableListControl/index'; -import '../../internal/InternalRadioGroupControl/index'; -import '../../internal/InternalIntegerControl/index'; +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalSwitchControl/index'; +import '../../internal/InternalSelectControl/index'; +import '../../internal/InternalNumberControl/index'; import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; -import './internal/InternalSubscriptionSettingsFormReattemptBypass/index'; - import { SubscriptionSettingsForm } from './SubscriptionSettingsForm'; customElements.define('foxy-subscription-settings-form', SubscriptionSettingsForm); diff --git a/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/InternalSubscriptionSettingsFormReattemptBypass.test.ts b/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/InternalSubscriptionSettingsFormReattemptBypass.test.ts deleted file mode 100644 index f60fedbb..00000000 --- a/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/InternalSubscriptionSettingsFormReattemptBypass.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { FetchEvent } from '../../../NucleonElement/FetchEvent'; -import type { Data } from '../../types'; - -import '../../../NucleonElement/index'; -import './index'; - -import { InternalSubscriptionSettingsFormReattemptBypass as Control } from './InternalSubscriptionSettingsFormReattemptBypass'; -import { expect, fixture, html, waitUntil } from '@open-wc/testing'; -import { InternalEditableListControl } from '../../../../internal/InternalEditableListControl/InternalEditableListControl'; -import { InternalEditableControl } from '../../../../internal/InternalEditableControl/InternalEditableControl'; -import { NucleonElement } from '../../../NucleonElement/NucleonElement'; -import { createRouter } from '../../../../../server/index'; -import { I18n } from '../../../I18n/I18n'; - -describe('InternalSubscriptionSettingsFormReattemptBypass', () => { - it('imports and defines vaadin-radio-button', () => { - const element = customElements.get('vaadin-radio-button'); - expect(element).to.exist; - }); - - it('imports and defines vaadin-radio-group', () => { - const element = customElements.get('vaadin-radio-group'); - expect(element).to.exist; - }); - - it('imports and defines foxy-internal-editable-list-control', () => { - const element = customElements.get('foxy-internal-editable-list-control'); - expect(element).to.equal(InternalEditableListControl); - }); - - it('imports and defines foxy-internal-editable-control', () => { - const element = customElements.get('foxy-internal-editable-control'); - expect(element).to.equal(InternalEditableControl); - }); - - it('imports and defines foxy-i18n', () => { - const element = customElements.get('foxy-i18n'); - expect(element).to.equal(I18n); - }); - - it('imports and defines itself as foxy-internal-subscription-settings-form-reattempt-bypass', () => { - const element = customElements.get('foxy-internal-subscription-settings-form-reattempt-bypass'); - expect(element).to.equal(Control); - }); - - it('extends foxy-internal-editable-control', () => { - expect(new Control()).to.be.instanceOf(InternalEditableControl); - }); - - it('renders option group with 4 options', async () => { - const router = createRouter(); - - const element = await fixture>(html` - router.handleEvent(evt)} - > - - - - `); - - await waitUntil(() => !!element.data, '', { timeout: 5000 }); - - const control = element.firstElementChild as Control; - const group = control.renderRoot.querySelector('vaadin-radio-group'); - const buttons = control.renderRoot.querySelectorAll('vaadin-radio-button'); - const button0Label = buttons[0].querySelector('foxy-i18n'); - const button1Label = buttons[1].querySelector('foxy-i18n'); - const button2Label = buttons[2].querySelector('foxy-i18n'); - const button3Label = buttons[3].querySelector('foxy-i18n'); - - expect(group).to.exist; - expect(buttons).to.have.length(4); - - expect(buttons[0]).to.have.value('reattempt_if_exists'); - expect(button0Label).to.have.attribute('infer', ''); - expect(button0Label).to.have.attribute('key', 'option_reattempt_if_exists'); - - expect(buttons[1]).to.have.value('skip_if_exists'); - expect(button1Label).to.have.attribute('infer', ''); - expect(button1Label).to.have.attribute('key', 'option_skip_if_exists'); - - expect(buttons[2]).to.have.value('always_reattempt'); - expect(button2Label).to.have.attribute('infer', ''); - expect(button2Label).to.have.attribute('key', 'option_always_reattempt'); - - expect(buttons[3]).to.have.value('never_reattempt'); - expect(button3Label).to.have.attribute('infer', ''); - expect(button3Label).to.have.attribute('key', 'option_never_reattempt'); - }); - - it('passes host state to option group', async () => { - const router = createRouter(); - - const element = await fixture>(html` - router.handleEvent(evt)} - > - - - - `); - - await waitUntil(() => !!element.data, '', { timeout: 5000 }); - - const control = element.firstElementChild as Control; - const group = control.renderRoot.querySelector('vaadin-radio-group'); - - expect(group).to.have.attribute('helper-text', 'helper_text'); - expect(group).to.have.attribute('label', 'label'); - - expect(group).to.not.have.attribute('disabled'); - expect(group).to.not.have.attribute('readonly'); - - control.disabled = true; - control.readonly = true; - control.label = 'Test label'; - control.helperText = 'Test helper text'; - await control.requestUpdate(); - - expect(group).to.have.attribute('disabled'); - expect(group).to.have.attribute('readonly'); - expect(group).to.have.attribute('label', 'Test label'); - expect(group).to.have.attribute('helper-text', 'Test helper text'); - }); - - it('reflects nucleon form values to option group choice', async () => { - const router = createRouter(); - const element = await fixture>(html` - router.handleEvent(evt)} - > - - - - `); - - await waitUntil(() => !!element.data, '', { timeout: 5000 }); - const control = element.firstElementChild as Control; - const group = control.renderRoot.querySelector('vaadin-radio-group'); - - element.edit({ reattempt_bypass_strings: '1,2' }); - element.edit({ reattempt_bypass_logic: 'skip_if_exists' }); - await element.requestUpdate().then(() => control.requestUpdate()); - expect(group).to.have.property('value', 'skip_if_exists'); - - element.edit({ reattempt_bypass_strings: '1,2' }); - element.edit({ reattempt_bypass_logic: 'reattempt_if_exists' }); - await element.requestUpdate().then(() => control.requestUpdate()); - expect(group).to.have.property('value', 'reattempt_if_exists'); - - element.edit({ reattempt_bypass_strings: '' }); - element.edit({ reattempt_bypass_logic: 'reattempt_if_exists' }); - await element.requestUpdate().then(() => control.requestUpdate()); - expect(group).to.have.property('value', 'never_reattempt'); - - element.edit({ reattempt_bypass_strings: '' }); - element.edit({ reattempt_bypass_logic: 'skip_if_exists' }); - await element.requestUpdate().then(() => control.requestUpdate()); - expect(group).to.have.property('value', 'always_reattempt'); - }); - - it('clears bypass strings and sets bypass logic to "reattempt_if_exists" if "never_reattempt" option is chosen', async () => { - const router = createRouter(); - const element = await fixture>(html` - router.handleEvent(evt)} - > - - - - `); - - await waitUntil(() => !!element.data, '', { timeout: 5000 }); - const control = element.firstElementChild as Control; - const group = control.renderRoot.querySelector('vaadin-radio-group')!; - - group.value = 'never_reattempt'; - group.dispatchEvent(new CustomEvent('value-changed')); - expect(element).to.have.nested.property('form.reattempt_bypass_logic', 'reattempt_if_exists'); - expect(element).to.have.nested.property('form.reattempt_bypass_strings', ''); - }); - - it('clears bypass strings and sets bypass logic to "skip_if_exists" if "always_reattempt" option is chosen', async () => { - const router = createRouter(); - const element = await fixture>(html` - router.handleEvent(evt)} - > - - - - `); - - await waitUntil(() => !!element.data, '', { timeout: 5000 }); - const control = element.firstElementChild as Control; - const group = control.renderRoot.querySelector('vaadin-radio-group')!; - - group.value = 'always_reattempt'; - group.dispatchEvent(new CustomEvent('value-changed')); - expect(element).to.have.nested.property('form.reattempt_bypass_logic', 'skip_if_exists'); - expect(element).to.have.nested.property('form.reattempt_bypass_strings', ''); - }); - - it('renders editable list of bypass strings when such strings exist', async () => { - const router = createRouter(); - const element = await fixture>(html` - router.handleEvent(evt)} - > - - - - `); - - await waitUntil(() => !!element.data, '', { timeout: 5000 }); - element.edit({ reattempt_bypass_strings: '1,2,3' }); - await element.requestUpdate(); - - const control = element.firstElementChild as Control; - const list = control.renderRoot.querySelector( - 'foxy-internal-editable-list-control' - ) as InternalEditableListControl; - - expect(list).to.have.attribute('infer', 'reattempt-bypass-strings'); - expect(list.getValue()).to.deep.equal([{ value: '1' }, { value: '2' }, { value: '3' }]); - - list.setValue([{ value: '5' }, { value: '6' }]); - expect(element).to.have.nested.property('form.reattempt_bypass_strings', '5,6'); - }); -}); diff --git a/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/InternalSubscriptionSettingsFormReattemptBypass.ts b/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/InternalSubscriptionSettingsFormReattemptBypass.ts deleted file mode 100644 index f2d896fe..00000000 --- a/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/InternalSubscriptionSettingsFormReattemptBypass.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { SubscriptionSettingsForm } from '../../SubscriptionSettingsForm'; -import type { RadioGroupElement } from '@vaadin/vaadin-radio-button/vaadin-radio-group'; -import type { TemplateResult } from 'lit-html'; -import type { CSSResultArray } from 'lit-element'; -import type { Item } from '../../../../internal/InternalEditableListControl/types'; - -import { InternalEditableControl } from '../../../../internal/InternalEditableControl/InternalEditableControl'; -import { html } from 'lit-html'; -import { css } from 'lit-element'; - -export class InternalSubscriptionSettingsFormReattemptBypass extends InternalEditableControl { - static get styles(): CSSResultArray { - return [ - super.styles, - css` - .visible-if-checked { - display: none; - } - - [checked] .visible-if-checked { - display: block; - } - `, - ]; - } - - nucleon: SubscriptionSettingsForm | null = null; - - private __getReattemptBypassStringsValue = () => { - const strings = this.nucleon?.form.reattempt_bypass_strings?.split(',') ?? []; - - return strings - .map(text => text.trim()) - .filter((text, index, strings) => text && strings.indexOf(text) === index) - .map(text => ({ value: text })); - }; - - private __setReattemptBypassStringsValue = (newValue: Item[]) => { - type Logic = 'skip_if_exists' | 'reattempt_if_exists'; - - const group = this.renderRoot.querySelector('vaadin-radio-group'); - const logic = (group?.value ?? 'reattempt_if_exists') as Logic; - - this.nucleon?.edit({ - reattempt_bypass_strings: newValue.map(({ value }) => value).join(), - reattempt_bypass_logic: logic, - }); - }; - - renderControl(): TemplateResult { - const reattemptBypassStringsTemplate = html` - evt.stopPropagation()} - @mouseup=${(evt: MouseEvent) => evt.stopPropagation()} - @keydown=${(evt: MouseEvent) => evt.stopPropagation()} - @click=${(evt: MouseEvent) => (evt.preventDefault(), evt.stopPropagation())} - > - - `; - - let groupValue: string | undefined = void 0; - - const nucleon = this.nucleon; - const reattemptBypassLogic = nucleon?.form.reattempt_bypass_logic; - const reattemptBypassStrings = nucleon?.form.reattempt_bypass_strings; - - if (reattemptBypassStrings) { - groupValue = reattemptBypassLogic; - } else if (reattemptBypassLogic === 'reattempt_if_exists') { - groupValue = 'never_reattempt'; - } else if (reattemptBypassLogic === 'skip_if_exists') { - groupValue = 'always_reattempt'; - } - - return html` - - - - ${reattemptBypassStringsTemplate} - - - - - ${reattemptBypassStringsTemplate} - - - - - - - - - - - `; - } - - private __handleGroupValueChanged(evt: CustomEvent) { - const value = (evt.currentTarget as RadioGroupElement).value; - const nucleon = this.nucleon; - - if (value === 'never_reattempt' || value === 'always_reattempt') { - if (value === 'never_reattempt') { - if (nucleon?.form.reattempt_bypass_logic !== 'reattempt_if_exists') { - nucleon?.edit({ reattempt_bypass_logic: 'reattempt_if_exists' }); - } - } else { - if (nucleon?.form.reattempt_bypass_logic !== 'skip_if_exists') { - nucleon?.edit({ reattempt_bypass_logic: 'skip_if_exists' }); - } - } - - if (nucleon?.form.reattempt_bypass_strings) nucleon?.edit({ reattempt_bypass_strings: '' }); - } - } -} diff --git a/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/globalStyles.ts b/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/globalStyles.ts deleted file mode 100644 index 8d08c86b..00000000 --- a/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/globalStyles.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { css } from 'lit-element'; - -export const vaadinRadioGroupStyles = css` - :host(.foxy-internal-subscription-settings-form-reattempt-bypass) label { - padding-bottom: var(--lumo-space-xs); - } - - :host(.foxy-internal-subscription-settings-form-reattempt-bypass) [part='group-field'] { - display: flex; - border: thin solid var(--lumo-contrast-10pct); - border-radius: var(--lumo-border-radius); - transition: border-color 0.15s ease; - } - - /* prettier-ignore */ - :host(.foxy-internal-subscription-settings-form-reattempt-bypass:not([disabled]):not([readonly]):hover) - [part='group-field'] { - border-color: var(--lumo-contrast-20pct); - } -`; - -export const vaadinRadioButtonStyles = css` - :host(.foxy-internal-subscription-settings-form-reattempt-bypass) label { - display: flex; - } - - :host(.foxy-internal-subscription-settings-form-reattempt-bypass) [part='label'] { - flex: 1; - } -`; diff --git a/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/index.ts b/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/index.ts deleted file mode 100644 index 1a0ef864..00000000 --- a/src/elements/public/SubscriptionSettingsForm/internal/InternalSubscriptionSettingsFormReattemptBypass/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import '@vaadin/vaadin-radio-button/vaadin-radio-button'; -import '@vaadin/vaadin-radio-button/vaadin-radio-group'; - -import '../../../../internal/InternalEditableListControl/index'; -import '../../../../internal/InternalEditableControl/index'; - -import '../../../I18n/index'; - -import { InternalSubscriptionSettingsFormReattemptBypass as Control } from './InternalSubscriptionSettingsFormReattemptBypass'; -import { vaadinRadioButtonStyles, vaadinRadioGroupStyles } from './globalStyles'; -import { registerStyles } from '@vaadin/vaadin-themable-mixin/register-styles'; - -customElements.define('foxy-internal-subscription-settings-form-reattempt-bypass', Control); - -registerStyles('vaadin-radio-group', vaadinRadioGroupStyles); -registerStyles('vaadin-radio-button', vaadinRadioButtonStyles); - -export { Control as InternalSubscriptionSettingsFormReattemptBypass }; diff --git a/src/elements/public/SubscriptionSettingsForm/types.ts b/src/elements/public/SubscriptionSettingsForm/types.ts index 9b9638df..0cf855fd 100644 --- a/src/elements/public/SubscriptionSettingsForm/types.ts +++ b/src/elements/public/SubscriptionSettingsForm/types.ts @@ -1,4 +1,7 @@ import type { Resource } from '@foxy.io/sdk/core'; import type { Rels } from '@foxy.io/sdk/backend'; -export type Data = Resource; +export type Data = Omit, 'reattempt_bypass_logic'> & { + // TODO remove once SDK is updated + reattempt_bypass_logic: 'skip_if_exists' | 'reattempt_if_exists' | ''; +}; diff --git a/src/static/translations/subscription-settings-form/en.json b/src/static/translations/subscription-settings-form/en.json index 40f5bcda..9f5d2821 100644 --- a/src/static/translations/subscription-settings-form/en.json +++ b/src/static/translations/subscription-settings-form/en.json @@ -14,73 +14,97 @@ "day_plural": "{{ count }} days", "day_suffix": "day", "day_suffix_plural": "days", - "modification-url": { - "label": "Modification URL", - "placeholder": "https://example.com/edit-subscription", - "helper_text": "Enter a full URL or a path to the page under your Store URL where customers can modify their subscription. If present, subscriptions in the Customer Portal will show an Edit link that will send subscribers to this page." - }, - "past-due-amount-handling": { - "label": "Past due amount value", - "option_increment": "Contains the sum of all missed payments", - "option_replace": "Contains the last missed payment amount", - "option_ignore": "Doesn't change", - "helper_text": "This setting determines how you'd like to handle past due amounts when we try to process a subscription and that subscritpion fails." - }, - "automatically-charge-past-due-amount": { - "label": "", - "option_checked": "Charge past due amount with subscription payment", - "helper_text": "" - }, - "clear-past-due-amounts-on-success": { - "label": "", - "option_checked": "Clear past due amount on successful payment", - "helper_text": "" - }, - "reset-nextdate-on-makeup-payment": { - "label": "", - "option_checked": "Reset next transaction date on make-up payment", - "helper_text": "" - }, - "reattempt-schedule": { - "label": "Reattempt schedule", - "placeholder": "Period in days, e.g. 14", + "past-due-amount-group": { + "label": "Past due amount", "helper_text": "", - "v8n_too_long": "This schedule is too large. Please reduce the number of entries." - }, - "reminder-email-schedule": { - "label": "Failed subscription payment email schedule", - "placeholder": "Period in days, e.g. 14", - "helper_text": "Number of days after the initial failure that an email notification to the customer should be sent. This only happens for active subscriptions which still have a past due amount. If a reattempt is successful, no additional reminder email will be sent.", - "v8n_too_long": "This schedule is too large. Please reduce the number of entries." + "past-due-amount-handling": { + "label": "Past due amount handling", + "placeholder": "Select", + "option_increment": "Increment on each failure", + "option_replace": "Replace on each failure", + "helper_text": "" + }, + "automatically-charge-past-due-amount": { + "label": "Charge with subscription payment", + "helper_text": "If enabled, the past due amount will be included in automatic subscription processing, and will be included in the cart if a customer attempts to update their subscription. If unchecked, any successful subscription payment (automatic or customer-initiated) will clear out the past due amount.", + "checked": "Yes", + "unchecked": "No" + }, + "reset-nextdate-on-makeup-payment": { + "label": "Reset next transaction date on make-up payment", + "helper_text": "If enabled, and a past due amount is successfully paid, the next transaction date for the subscription will be reset to be one frequency ahead of the day that the transaction is processed.", + "checked": "Yes", + "unchecked": "No" + }, + "prevent-customer-cancel-with-past-due": { + "label": "Prevent modification or cancellation if past due is present", + "helper_text": "If enabled, if the customer has a past due amount and wishes to cancel their subscription using a sub_token through the cart interface, they will be prevented from doing so until they first pay their past due amount.", + "checked": "Yes", + "unchecked": "No" + } }, - "expiring-soon-payment-reminder-schedule": { - "label": "Payment method expiration email schedule", - "placeholder": "Period in days, e.g. 14", - "helper_text": "Number of days until the payment card expires that an email notification should be sent to the customer. This only happens for customers with active subscriptions.", - "v8n_too_long": "This schedule is too large. Please reduce the number of entries." + "reattempts-group": { + "label": "Reattempts", + "helper_text": "", + "reattempt-bypass-logic": { + "label": "Reattempt behavior", + "helper_text": "", + "placeholder": "Select", + "option_reattempt_if_exists": "Reattempt on certain errors", + "option_skip_if_exists": "Skip on certain errors", + "option_always_reattempt": "Always reattempt" + }, + "reattempt-bypass-strings": { + "label": "Reattempt bypass strings", + "placeholder": "Add errors...", + "helper_text": "Text strings that should prevent or allow (based on the above setting) a rebilling attempt. For example, setting the logic to \"Skip on certain errors\" with a value for the strings field of \"Code: 8\" and \"Code: 37\" would instruct Foxy to not initiate the rebilling process if the last error contained either Code: 8 or Code: 37, but to attempt the rebilling in all other cases.", + "v8n_too_long": "This list is too large. Please reduce the number of entries." + }, + "reattempt-schedule": { + "label": "Reattempt schedule", + "placeholder": "Add period in days, e.g. 14", + "helper_text": "List of numbers. Each number represents the number of days after the initial failure that a reattempt should be made. For example, a setting of 1, 3, 5, 15, 30 would direct Foxy to attempt to collect the past-due amount on the 1st, 3rd, 5th, and 15th days after the initial transaction.", + "v8n_too_long": "This schedule is too large. Please reduce the number of entries." + } }, - "cancellation-schedule": { - "label": "Cancel failed subscriptions after", - "placeholder": "Don't cancel", - "helper_text": "A single number representing the number of days after the initial failure that a subscription should be set to cancel (assuming a successful payment hasn't been made in the meantime)." + "emails-group": { + "label": "Emails", + "helper_text": "", + "send-email-receipts-for-automated-billing": { + "label": "Send email receipts for automated billing", + "helper_text": "When subscriptions run automatically to bill your customers, turning this setting off will prevent the normal receipt emails from being sent for their automated payment.", + "checked": "Yes", + "unchecked": "No" + }, + "reminder-email-schedule": { + "label": "Failed subscription payment email schedule", + "placeholder": "Add period in days, e.g. 14", + "helper_text": "Number of days after the initial failure that an email notification to the customer should be sent. This only happens for active subscriptions which still have a past due amount. If a reattempt is successful, no additional reminder email will be sent.", + "v8n_too_long": "This schedule is too large. Please reduce the number of entries." + }, + "expiring-soon-payment-reminder-schedule": { + "label": "Payment method expiration email schedule", + "placeholder": "Add period in days, e.g. 14", + "helper_text": "Number of days until the payment card expires that an email notification should be sent to the customer. This only happens for customers with active subscriptions.", + "v8n_too_long": "This schedule is too large. Please reduce the number of entries." + } }, - "send-email-receipts-for-automated-billing": { - "label": "", - "option_checked": "Send email receipts for automated billing", - "helper_text": "" + "cancellation-group": { + "label": "Cancellation", + "helper_text": "A single number representing the number of days after the initial failure that a subscription should be set to cancel (assuming a successful payment hasn't been made in the meantime).", + "cancellation-schedule": { + "label": "Cancel failed subscriptions after", + "helper_text": "", + "placeholder": "Don't cancel" + } }, - "reattempt-bypass": { - "label": "Reattempt behavior", - "helper_text": "Determines whether Foxy should reattempt the subscription charge if the transaction's previous error string does or doesn't contain specific text.", - "option_reattempt_if_exists": "Reattempt on certain errors", - "option_skip_if_exists": "Skip on certain errors", - "option_always_reattempt": "Always reattempt", - "option_never_reattempt": "Never reattempt", - "reattempt-bypass-strings": { - "label": "", - "placeholder": "Add errors...", + "modification-group": { + "label": "Modification", + "helper_text": "Enter a full URL or a path to the page under your Store URL where customers can modify their subscription. If present, subscriptions in the Customer Portal will show an Edit link that will send subscribers to this page.", + "modification-url": { + "label": "Modification URL", "helper_text": "", - "v8n_too_long": "This list is too large. Please reduce the number of entries." + "placeholder": "https://example.com/edit-subscription" } }, "timestamps": {