diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 21cc657d..cf2c6e75 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -128,6 +128,19 @@ jobs: - name: Run PHPUnit (Bootstrap Admin Ui) run: (cd src/BootstrapAdminUi/ && vendor/bin/phpunit) + - name: Setup Node.js for Bootstrap Admin UI tests + if: matrix.php == '8.3' && matrix.symfony == '^7.4' + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Run JavaScript tests (Bootstrap Admin UI) + if: matrix.php == '8.3' && matrix.symfony == '^7.4' + working-directory: src/BootstrapAdminUi + run: | + yarn install --frozen-lockfile --non-interactive + yarn test + - name: "Restrict packages' versions (Twig Hooks)" run: | (cd src/TwigHooks/ && composer config --no-plugins allow-plugins.symfony/runtime true) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b69a604a..84362db0 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -67,6 +67,8 @@ ## 🎨 Bootstrap Admin UI * [Getting started](bootstrap-admin-ui/getting-started.md) +* Components + * [Copy to clipboard](bootstrap-admin-ui/components/copy-to-clipboard.md) ## 🍀 Twig Extra diff --git a/docs/bootstrap-admin-ui/components/copy-to-clipboard.md b/docs/bootstrap-admin-ui/components/copy-to-clipboard.md new file mode 100644 index 00000000..a840d939 --- /dev/null +++ b/docs/bootstrap-admin-ui/components/copy-to-clipboard.md @@ -0,0 +1,189 @@ +# Copy to clipboard + +Use `copy_to_clipboard` for a standalone copy button, or `copyable` to display a value or custom inline content next to the button. Both copy the explicit `value` prop, never the rendered content. + +## Installation + +Install Bootstrap Admin UI before using the component: + +```bash +composer require sylius/bootstrap-admin-ui +``` + +The components use Symfony UX Twig Component, Stimulus, UX Icons and the Bootstrap Admin UI/Tabler styles. Bootstrap CSS alone does not provide all the default button styles. + +With the standard Bootstrap Admin UI layout and compiled assets, the controller is registered by the package's `symfony_ux` entrypoint. If your application starts its own Stimulus application, follow [JavaScript integration](#javascript-integration) below instead. + +## Usage + +Pass a literal value with the `value` prop: + +```twig +ABC-123 + + +``` + +For a Twig expression, prefix the prop with `:`: + +```twig +{{ item.code }} + + +``` + +### Display a value with its copy button + +```twig + +``` + +For a badge, a formatted ID, a truncated label or an email link, provide custom content: + +```twig + + {{ customer.email }} + +``` + +The body overrides the `content` block. The copy button remains a sibling of the content, not a child of the link. Only `customer.email` is copied, independently of the displayed text. Use inline content and do not place either component inside another button or link. + +The default displayed value is escaped and is not automatically translated. Translate or format the display in the content block when needed; explicitly pass a translated `value` if that is also what should be copied. + +### Missing values and disabling copying + +Omitting `value`, passing `null`, or passing an empty string disables the button. Custom content remains visible. Both numeric `0` and string `"0"` are copyable. Pass IDs with significant leading zeros as strings. + +Both components also accept `disabled` to explicitly disable the button. They do not read from inputs or watch your application data: update the component's props when re-rendering dynamic values. + +### Twig Hooks + +Both components can be rendered directly as Twig Hook components. They consume the injected hook metadata without rendering it as an HTML attribute. Pass the value explicitly through the props: + +```yaml +sylius_twig_hooks: + hooks: + 'app.example': + copy: + component: 'sylius_bootstrap_admin_ui:copy_to_clipboard' + props: + value: '@=_context.item.getCode()' +``` + +Use `sylius_bootstrap_admin_ui:copyable` instead to include the default text display. For custom markup, use a hook template containing the component and its body. + +### Button attributes + +Additional component attributes are forwarded to the button. Default button classes are preserved when adding custom classes: + +```twig + +``` + +For `copyable`, ordinary attributes apply to the outer container. Prefix attributes with `button:` to apply them to the copy button: + +```twig + +``` + +Both components accept `button_class` to **replace** the default classes, while `class` on the standalone button (or `button:class` on `copyable`) adds classes. For example, use `button_class="btn btn-sm btn-outline-secondary"` for standard Bootstrap button styling without Tabler's ghost-button classes. + +### Translations + +The component uses these translation keys by default: + +- `sylius.ui.copy_to_clipboard` +- `sylius.ui.copied_to_clipboard` +- `sylius.ui.unable_to_copy_to_clipboard` + +Override those keys in your application's translation files, or pass alternative translation keys with the label props: + +```twig + +``` + +### Feedback and dynamic content + +The button uses a native `title`, not a Bootstrap tooltip, so no tooltip initialization is needed when content is inserted dynamically. The controller connects through Stimulus as usual. + +During a copy, repeated clicks are ignored and the button exposes `aria-busy` without disabling it or taking away keyboard focus. Success changes the icon and announces the translated message through a live region. Failure also displays the translated error text. Feedback resets after two seconds; a new copy replaces previous feedback. Disconnecting cancels pending UI updates and timers, not the browser's clipboard operation itself. + +## JavaScript integration + +Register the controller **once in the application's existing Stimulus application**. Do not start a second application just for this component. Both Twig components use the identifier `copy-to-clipboard`. + +### AssetMapper + +Follow the [AssetMapper setup](../../getting-started.md#using-assetmapper) to disable the package's `symfony_ux` entrypoint when starting your own Stimulus application. Expose the controller source directory to AssetMapper: + +```yaml +# config/packages/asset_mapper.yaml +framework: + asset_mapper: + paths: + '%kernel.project_dir%/vendor/sylius/bootstrap-admin-ui/assets/controllers': 'sylius/bootstrap-admin-ui/controllers' +``` + +Add a local importmap entry (this does not install another JavaScript package): + +```bash +php bin/console importmap:require '@sylius/bootstrap-admin-ui/copy-to-clipboard' --path=./vendor/sylius/bootstrap-admin-ui/assets/controllers/copy-to-clipboard-controller.js +``` + +In the module where your application already starts Stimulus: + +```js +import { startStimulusApp } from '@symfony/stimulus-bundle'; +import CopyToClipboardController from '@sylius/bootstrap-admin-ui/copy-to-clipboard'; + +const app = startStimulusApp(); +app.register('copy-to-clipboard', CopyToClipboardController); +``` + +If `app` is already exported by another module, import and reuse it instead of calling `startStimulusApp()` again. The application's importmap must also provide `@hotwired/stimulus`, as in a normal Symfony Stimulus installation. + +### Webpack Encore / an existing Sylius Stimulus application + +Import the controller source and register it on your existing application. From a project-level `assets/bootstrap.js`, the import is: + +```js +import CopyToClipboardController from '../vendor/sylius/bootstrap-admin-ui/assets/controllers/copy-to-clipboard-controller.js'; + +app.register('copy-to-clipboard', CopyToClipboardController); +``` + +Here `app` is your existing Stimulus application. Adjust the relative import path to your entrypoint location and rebuild the application's assets. Do not import the package's entire `symfony_ux` entrypoint into an application that already starts Stimulus. + +### Sylius compatibility + +Match the Bootstrap Admin UI package version to the target Sylius version before installing it. This branch requires `sylius/twig-hooks ^0.12`, whereas the Sylius 2.1 and 2.2 AdminBundle branches require `^0.8` and `^0.9` respectively. These constraints do not overlap; controller registration alone does not solve that Composer incompatibility. + +Reusing this functionality in those versions requires a compatible backport or dependency alignment. It does not require replacing the application's admin layout. When integrating the templates selectively, also configure the anonymous component prefix, UX Icons and the translations rather than importing the whole Bootstrap Admin UI layout configuration. + +## Browser requirements + +Copying uses the browser's Clipboard API. This API requires a secure context, which normally means HTTPS in production or localhost during development. + +Clipboard permissions and embedding policies can still prevent copying. The components report failure rather than falling back to deprecated clipboard APIs. Values passed to the component are present in the page's HTML; do not pass secrets or data the current user is not authorized to see. + +## Tests + +From `src/BootstrapAdminUi`, run `vendor/bin/phpunit` for the rendering and Twig Hooks tests, and `yarn test` for the controller unit tests using Node.js 24 and the existing JavaScript dependencies. The latter use DOM doubles; actual browser permissions and assistive-technology behavior still require browser-level testing. diff --git a/src/BootstrapAdminUi/assets/controllers/copy-to-clipboard-controller.js b/src/BootstrapAdminUi/assets/controllers/copy-to-clipboard-controller.js new file mode 100644 index 00000000..120ec7fd --- /dev/null +++ b/src/BootstrapAdminUi/assets/controllers/copy-to-clipboard-controller.js @@ -0,0 +1,74 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['button', 'copyIcon', 'successIcon', 'errorIcon', 'status']; + + static values = { + value: String, + copiedLabel: String, + errorLabel: String, + }; + + connect() { + this.pendingCopy = null; + this.reset(); + } + + async copy() { + if (this.pendingCopy || this.buttonTarget.disabled || !this.hasValueValue || this.valueValue === '') { + return; + } + + this.reset(); + const operation = {}; + this.pendingCopy = operation; + this.buttonTarget.setAttribute('aria-busy', 'true'); + + try { + await navigator.clipboard.writeText(this.valueValue); + + if (this.pendingCopy === operation) { + this.showFeedback(this.successIconTarget, this.copiedLabelValue); + } + } catch (error) { + if (this.pendingCopy === operation) { + this.showFeedback(this.errorIconTarget, this.errorLabelValue, true); + } + } finally { + if (this.pendingCopy === operation) { + this.pendingCopy = null; + this.buttonTarget.removeAttribute('aria-busy'); + } + } + } + + disconnect() { + this.pendingCopy = null; + clearTimeout(this.resetTimer); + + if (this.hasButtonTarget) { + this.buttonTarget.removeAttribute('aria-busy'); + } + } + + showFeedback(icon, message, isError = false) { + this.reset(); + this.copyIconTarget.classList.add('d-none'); + icon.classList.remove('d-none'); + this.statusTarget.classList.toggle('visually-hidden', !isError); + this.statusTarget.textContent = message; + + this.resetTimer = setTimeout(() => this.reset(), 2000); + } + + reset() { + clearTimeout(this.resetTimer); + this.resetTimer = null; + + this.copyIconTarget.classList.remove('d-none'); + this.successIconTarget.classList.add('d-none'); + this.errorIconTarget.classList.add('d-none'); + this.statusTarget.classList.add('visually-hidden'); + this.statusTarget.textContent = ''; + } +} diff --git a/src/BootstrapAdminUi/package.json b/src/BootstrapAdminUi/package.json index 4a334773..38b17fe4 100644 --- a/src/BootstrapAdminUi/package.json +++ b/src/BootstrapAdminUi/package.json @@ -37,6 +37,7 @@ "dev-server": "encore dev-server", "dev": "encore dev", "watch": "encore dev --watch", - "build": "encore production" + "build": "encore production", + "test": "node --test tests/JavaScript/*.test.mjs" } } diff --git a/src/BootstrapAdminUi/templates/shared/components/copy_to_clipboard.html.twig b/src/BootstrapAdminUi/templates/shared/components/copy_to_clipboard.html.twig new file mode 100644 index 00000000..bf85005e --- /dev/null +++ b/src/BootstrapAdminUi/templates/shared/components/copy_to_clipboard.html.twig @@ -0,0 +1,48 @@ +{% props + value = null, + hookableMetadata = null, + disabled = false, + button_class = 'btn btn-icon btn-sm btn-ghost-secondary', + copy_label = 'sylius.ui.copy_to_clipboard', + copied_label = 'sylius.ui.copied_to_clipboard', + error_label = 'sylius.ui.unable_to_copy_to_clipboard' +%} + + + + + + diff --git a/src/BootstrapAdminUi/templates/shared/components/copyable.html.twig b/src/BootstrapAdminUi/templates/shared/components/copyable.html.twig new file mode 100644 index 00000000..0eb4a860 --- /dev/null +++ b/src/BootstrapAdminUi/templates/shared/components/copyable.html.twig @@ -0,0 +1,22 @@ +{% props + value = null, + hookableMetadata = null, + disabled = false, + button_class = 'btn btn-icon btn-sm btn-ghost-secondary', + copy_label = 'sylius.ui.copy_to_clipboard', + copied_label = 'sylius.ui.copied_to_clipboard', + error_label = 'sylius.ui.unable_to_copy_to_clipboard' +%} + + + {% block content %}{{ value }}{% endblock %} + + {{ component('sylius_bootstrap_admin_ui:copy_to_clipboard', attributes.nested('button').all()|merge({ + value: value, + disabled: disabled, + button_class: button_class, + copy_label: copy_label, + copied_label: copied_label, + error_label: error_label, + })) }} + diff --git a/src/BootstrapAdminUi/tests/Functional/.application/config/packages/sylius_bootstrap_admin_ui.yaml b/src/BootstrapAdminUi/tests/Functional/.application/config/packages/sylius_bootstrap_admin_ui.yaml index 870b2b91..c8c780df 100644 --- a/src/BootstrapAdminUi/tests/Functional/.application/config/packages/sylius_bootstrap_admin_ui.yaml +++ b/src/BootstrapAdminUi/tests/Functional/.application/config/packages/sylius_bootstrap_admin_ui.yaml @@ -1,2 +1,15 @@ imports: - { resource: '../../../../../config/app.php' } + +sylius_twig_hooks: + hooks: + 'app.copy_to_clipboard': + copy: + component: 'sylius_bootstrap_admin_ui:copy_to_clipboard' + props: + value: '@=_context.value' + 'app.copyable': + copy: + component: 'sylius_bootstrap_admin_ui:copyable' + props: + value: '@=_context.value' diff --git a/src/BootstrapAdminUi/tests/Functional/CopyToClipboardComponentTest.php b/src/BootstrapAdminUi/tests/Functional/CopyToClipboardComponentTest.php new file mode 100644 index 00000000..de1a82df --- /dev/null +++ b/src/BootstrapAdminUi/tests/Functional/CopyToClipboardComponentTest.php @@ -0,0 +1,218 @@ +get(Environment::class); + self::assertInstanceOf(Environment::class, $twig); + + $this->twig = $twig; + } + + public function testItRendersWithTheDefaultConfiguration(): void + { + $crawler = $this->renderComponent(<<<'TWIG' + + TWIG); + + $controller = $crawler->filter('[data-controller~="copy-to-clipboard"]'); + self::assertCount(1, $controller); + self::assertSame('customer@example.com', $controller->attr('data-copy-to-clipboard-value-value')); + self::assertSame('sylius.ui.copied_to_clipboard', $controller->attr('data-copy-to-clipboard-copied-label-value')); + self::assertSame('sylius.ui.unable_to_copy_to_clipboard', $controller->attr('data-copy-to-clipboard-error-label-value')); + + $button = $controller->filter('button[data-test-copy-to-clipboard]'); + self::assertCount(1, $button); + self::assertSame('button', $button->attr('type')); + self::assertSame('copy-to-clipboard#copy', $button->attr('data-action')); + self::assertSame('sylius.ui.copy_to_clipboard', $button->attr('aria-label')); + self::assertSame('sylius.ui.copy_to_clipboard', $button->attr('title')); + self::assertSame('button', $button->attr('data-copy-to-clipboard-target')); + self::assertNull($button->attr('data-bs-toggle')); + } + + public function testItSafelyRendersAValueContainingSpecialCharacters(): void + { + $value = 'customer+"' %} + + TWIG); + + self::assertCount(0, $crawler->filter('script')); + self::assertSame('', $crawler->filter('.d-inline-flex')->text()); + } + + public function testItRendersThroughTwigHooks(): void + { + $crawler = $this->renderComponent("{% hook 'app.copyable' with { value: 'ABC-123' } %}"); + + self::assertSame('ABC-123', $crawler->filter('.d-inline-flex')->text()); + self::assertCount(1, $crawler->filter('button')); + self::assertCount(0, $crawler->filter('[hookableMetadata]')); + } + + public function testItDisablesCopyingAnAbsentValueButPreservesCustomContent(): void + { + $crawler = $this->renderComponent(<<<'TWIG' + + Not available + + TWIG); + + self::assertSame('Not available', $crawler->filter('.d-inline-flex')->text()); + self::assertCount(1, $crawler->filter('button[disabled]')); + } + + private function renderComponent(string $template): Crawler + { + self::bootKernel(); + $twig = self::getContainer()->get(Environment::class); + self::assertInstanceOf(Environment::class, $twig); + + return new Crawler($twig->createTemplate($template)->render()); + } +} diff --git a/src/BootstrapAdminUi/tests/JavaScript/copy-to-clipboard-controller.test.mjs b/src/BootstrapAdminUi/tests/JavaScript/copy-to-clipboard-controller.test.mjs new file mode 100644 index 00000000..191b30b0 --- /dev/null +++ b/src/BootstrapAdminUi/tests/JavaScript/copy-to-clipboard-controller.test.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import CopyToClipboardController from '../../assets/controllers/copy-to-clipboard-controller.js'; + +// Test the real controller with small DOM doubles; no browser or additional dependency is required. +function element(...classes) { + const tokens = new Set(classes); + const attributes = new Map(); + + return { + disabled: false, + textContent: '', + setAttribute: (name, value) => attributes.set(name, value), + removeAttribute: name => attributes.delete(name), + getAttribute: name => attributes.get(name), + classList: { + add: token => tokens.add(token), + remove: token => tokens.delete(token), + contains: token => tokens.has(token), + toggle: (token, force) => force ? tokens.add(token) : tokens.delete(token), + }, + }; +} + +function setup(t, writeText = t.mock.fn(async () => {})) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText } }, + }); + t.after(() => { + if (navigatorDescriptor) { + Object.defineProperty(globalThis, 'navigator', navigatorDescriptor); + } else { + delete globalThis.navigator; + } + }); + + const controller = Object.assign(new CopyToClipboardController({}), { + buttonTarget: element(), + hasButtonTarget: true, + copyIconTarget: element(), + successIconTarget: element('d-none'), + errorIconTarget: element('d-none'), + statusTarget: element('visually-hidden'), + hasValueValue: true, + valueValue: '000123', + copiedLabelValue: 'Copied', + errorLabelValue: 'Unable to copy', + }); + controller.connect(); + t.after(() => controller.disconnect()); + + return { controller, writeText }; +} + +test('copies the explicit value and resets successful feedback after two seconds', async t => { + const { controller, writeText } = setup(t); + controller.valueValue = 'Référence 日本語\n000123'; + await controller.copy(); + + assert.deepEqual(writeText.mock.calls[0].arguments, [controller.valueValue]); + assert.equal(controller.copyIconTarget.classList.contains('d-none'), true); + assert.equal(controller.successIconTarget.classList.contains('d-none'), false); + assert.equal(controller.statusTarget.textContent, 'Copied'); + assert.equal(controller.statusTarget.classList.contains('visually-hidden'), true); + assert.equal(controller.buttonTarget.getAttribute('aria-busy'), undefined); + t.mock.timers.tick(2000); + assert.equal(controller.copyIconTarget.classList.contains('d-none'), false); + assert.equal(controller.successIconTarget.classList.contains('d-none'), true); + assert.equal(controller.statusTarget.textContent, ''); +}); + +test('makes failures visible and allows retrying', async t => { + const writeText = t.mock.fn(async () => { throw new Error('Permission denied'); }); + const { controller } = setup(t, writeText); + await controller.copy(); + + assert.equal(controller.errorIconTarget.classList.contains('d-none'), false); + assert.equal(controller.statusTarget.classList.contains('visually-hidden'), false); + assert.equal(controller.statusTarget.textContent, 'Unable to copy'); + assert.equal(controller.pendingCopy, null); + + writeText.mock.mockImplementation(async () => {}); + await controller.copy(); + assert.equal(controller.errorIconTarget.classList.contains('d-none'), true); + assert.equal(controller.successIconTarget.classList.contains('d-none'), false); + assert.equal(controller.statusTarget.classList.contains('visually-hidden'), true); +}); + +test('handles an unavailable Clipboard API', async t => { + const { controller } = setup(t); + delete navigator.clipboard; + await controller.copy(); + + assert.equal(controller.statusTarget.textContent, 'Unable to copy'); + assert.equal(controller.statusTarget.classList.contains('visually-hidden'), false); +}); + +test('ignores repeated clicks while a copy is pending without removing keyboard focus', async t => { + const deferred = Promise.withResolvers(); + const writeText = t.mock.fn(() => deferred.promise); + const { controller } = setup(t, writeText); + const pending = controller.copy(); + await controller.copy(); + + assert.equal(writeText.mock.callCount(), 1); + assert.equal(controller.buttonTarget.getAttribute('aria-busy'), 'true'); + assert.equal(controller.buttonTarget.disabled, false); + deferred.resolve(); + await pending; + assert.equal(controller.statusTarget.textContent, 'Copied'); + assert.equal(controller.buttonTarget.getAttribute('aria-busy'), undefined); +}); + +for (const outcome of ['resolve', 'reject']) { + test(`ignores a late ${outcome} after disconnect and reconnect`, async t => { + const stale = Promise.withResolvers(); + const current = Promise.withResolvers(); + const writeText = t.mock.fn(() => stale.promise); + const { controller } = setup(t, writeText); + const staleCopy = controller.copy(); + controller.disconnect(); + controller.connect(); + writeText.mock.mockImplementation(() => current.promise); + const currentCopy = controller.copy(); + stale[outcome](); + await staleCopy; + + assert.equal(controller.statusTarget.textContent, ''); + assert.equal(controller.buttonTarget.getAttribute('aria-busy'), 'true'); + t.mock.timers.tick(2000); + assert.equal(controller.statusTarget.textContent, ''); + current.resolve(); + await currentCopy; + assert.equal(controller.statusTarget.textContent, 'Copied'); + }); +} + +test('does not access removed targets or schedule feedback after disconnect', async t => { + const deferred = Promise.withResolvers(); + const { controller } = setup(t, () => deferred.promise); + const pending = controller.copy(); + controller.hasButtonTarget = false; + controller.disconnect(); + delete controller.buttonTarget; + delete controller.successIconTarget; + delete controller.errorIconTarget; + delete controller.statusTarget; + deferred.resolve(); + await pending; + t.mock.timers.tick(2000); +}); + +test('clears the feedback timer on disconnect and resets on reconnect', async t => { + const { controller } = setup(t); + await controller.copy(); + const reset = t.mock.method(controller, 'reset'); + controller.disconnect(); + t.mock.timers.tick(2000); + assert.equal(reset.mock.callCount(), 0); + controller.connect(); + assert.equal(controller.statusTarget.textContent, ''); + assert.equal(controller.successIconTarget.classList.contains('d-none'), true); +}); + +test('does not let an older timer clear newer feedback', async t => { + const { controller } = setup(t); + await controller.copy(); + t.mock.timers.tick(1500); + await controller.copy(); + t.mock.timers.tick(500); + assert.equal(controller.statusTarget.textContent, 'Copied'); + t.mock.timers.tick(1500); + assert.equal(controller.statusTarget.textContent, ''); +}); + +for (const state of [{ valueValue: '' }, { hasValueValue: false }, { disabled: true }]) { + test(`does not copy an unavailable value or disabled button: ${JSON.stringify(state)}`, async t => { + const { controller, writeText } = setup(t); + if (state.disabled) { + controller.buttonTarget.disabled = true; + } else { + Object.assign(controller, state); + } + await controller.copy(); + assert.equal(writeText.mock.callCount(), 0); + assert.equal(controller.statusTarget.textContent, ''); + }); +} + +test('copies zero and keeps multiple instances independent', async t => { + const { controller: first, writeText } = setup(t); + const second = Object.assign(new CopyToClipboardController({}), { + ...first, + buttonTarget: element(), + copyIconTarget: element(), + successIconTarget: element('d-none'), + errorIconTarget: element('d-none'), + statusTarget: element('visually-hidden'), + valueValue: '0', + }); + second.connect(); + t.after(() => second.disconnect()); + await second.copy(); + + assert.deepEqual(writeText.mock.calls[0].arguments, ['0']); + assert.equal(second.statusTarget.textContent, 'Copied'); + assert.equal(first.statusTarget.textContent, ''); +}); diff --git a/src/UiTranslations/translations/messages.de.yaml b/src/UiTranslations/translations/messages.de.yaml index ba18bae6..99632a2e 100644 --- a/src/UiTranslations/translations/messages.de.yaml +++ b/src/UiTranslations/translations/messages.de.yaml @@ -13,6 +13,8 @@ sylius: are_your_sure_you_want_to_perform_this_action: 'Möchten Sie diese Aktion wirklich durchführen?' cancel: 'Abbrechen' contains: 'Enthält' + copied_to_clipboard: 'In die Zwischenablage kopiert' + copy_to_clipboard: 'In die Zwischenablage kopieren' create: 'Erstellen' dashboard: 'Dashboard' delete: 'Löschen' @@ -51,6 +53,7 @@ sylius: success: 'Erfolgreich' this_form_contains_errors: 'Dieses Formular enthält Fehler.' to: 'Bis' + unable_to_copy_to_clipboard: 'Kopieren in die Zwischenablage nicht möglich' update: 'Aktualisieren' value: 'Wert' warning: 'Warnung' diff --git a/src/UiTranslations/translations/messages.en.yaml b/src/UiTranslations/translations/messages.en.yaml index 6d0ab5b5..7cc06bc5 100644 --- a/src/UiTranslations/translations/messages.en.yaml +++ b/src/UiTranslations/translations/messages.en.yaml @@ -15,6 +15,8 @@ sylius: cancel: Cancel copyright: Copyright contains: Contains + copied_to_clipboard: Copied to clipboard + copy_to_clipboard: Copy to clipboard create: Create dashboard: Dashboard date_filter: '%label% | %altLabel%' @@ -58,6 +60,7 @@ sylius: success: Success this_form_contains_errors: 'This form contains errors.' to: To + unable_to_copy_to_clipboard: Unable to copy to clipboard update: Update value: Value warning: Warning diff --git a/src/UiTranslations/translations/messages.es.yaml b/src/UiTranslations/translations/messages.es.yaml index 6805d8d1..21c8d81f 100644 --- a/src/UiTranslations/translations/messages.es.yaml +++ b/src/UiTranslations/translations/messages.es.yaml @@ -12,6 +12,8 @@ sylius: are_your_sure_you_want_to_perform_this_action: '\¿Está seguro que quiere realizar esta acción?' cancel: 'Cancelar' contains: 'Contiene' + copied_to_clipboard: 'Copiado al portapapeles' + copy_to_clipboard: 'Copiar al portapapeles' create: 'Crear' dashboard: 'Panel general' delete: 'Eliminar' @@ -46,6 +48,7 @@ sylius: success: 'Operación realizada correctamente' this_form_contains_errors: 'Este formulario contiene errores.' to: 'Hasta' + unable_to_copy_to_clipboard: 'No se pudo copiar al portapapeles' update: 'Actualizar' value: 'Valor' warning: 'Advertencia' diff --git a/src/UiTranslations/translations/messages.fr.yaml b/src/UiTranslations/translations/messages.fr.yaml index bb696a69..06afe76d 100644 --- a/src/UiTranslations/translations/messages.fr.yaml +++ b/src/UiTranslations/translations/messages.fr.yaml @@ -15,6 +15,8 @@ sylius: cancel: 'Annuler' copyright: Copyright contains: 'Contient' + copied_to_clipboard: 'Copié dans le presse-papiers' + copy_to_clipboard: 'Copier dans le presse-papiers' create: 'Créer' dashboard: 'Tableau de bord' date_filter: '%label% | %altLabel%' @@ -58,6 +60,7 @@ sylius: success: 'Succès' this_form_contains_errors: 'Ce formulaire contient des erreurs.' to: 'À' + unable_to_copy_to_clipboard: 'Impossible de copier dans le presse-papiers' update: 'Mise à jour' value: 'Valeur' warning: 'Avertissement' diff --git a/src/UiTranslations/translations/messages.pl.yaml b/src/UiTranslations/translations/messages.pl.yaml index 4a13b8b9..6ad4df0a 100644 --- a/src/UiTranslations/translations/messages.pl.yaml +++ b/src/UiTranslations/translations/messages.pl.yaml @@ -14,6 +14,8 @@ sylius: cancel: 'Anuluj' copyright: 'Prawa autorskie' contains: 'Zawiera' + copied_to_clipboard: 'Skopiowano do schowka' + copy_to_clipboard: 'Kopiuj do schowka' create: 'Utwórz' dashboard: 'Panel' delete: 'Usuń' @@ -51,6 +53,7 @@ sylius: success: 'Sukces' this_form_contains_errors: 'Ten formularz zawiera błędy.' to: 'Do' + unable_to_copy_to_clipboard: 'Nie udało się skopiować do schowka' update: 'Zaktualizuj' update_cart: 'Aktualizuj koszyk' value: 'Wartość'