diff --git a/.github/workflows/admin-jest.yml b/.github/workflows/admin-jest.yml
new file mode 100644
index 00000000..182ee81c
--- /dev/null
+++ b/.github/workflows/admin-jest.yml
@@ -0,0 +1,30 @@
+name: Administration Jest
+
+on:
+ pull_request:
+ paths:
+ - 'src/Resources/app/administration/**'
+ - .github/workflows/admin-jest.yml
+ push:
+ branches:
+ - main
+ paths:
+ - 'src/Resources/app/administration/**'
+ - .github/workflows/admin-jest.yml
+ workflow_dispatch:
+
+jobs:
+ jest:
+ strategy:
+ fail-fast: false
+ matrix:
+ shopware-version:
+ - '6.6.x'
+ - 'trunk'
+ uses: shopware/github-actions/.github/workflows/admin-jest.yml@main
+ with:
+ extensionName: ${{ github.event.repository.name }}
+ shopwareVersion: ${{ matrix.shopware-version }}
+ uploadCoverage: true
+ secrets:
+ codecovToken: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.gitignore b/.gitignore
index f65cb550..3c4f5704 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@
/src/Resources/public/
/src/Resources/app/administration/node_modules/
/src/Resources/app/administration/.tmp/
+/src/Resources/app/administration/build/
diff --git a/README.md b/README.md
index c9fd3904..58cd4104 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,9 @@ The current feature set consists of:
- basic view of order, transaction and delivery states
- **Override system config by config files**
- Overwrite any system config value with static or environment values
+- **Command Palette**
+ - Open with `Ctrl/⌘ + K` (or the sidebar search control)
+ - Jump to any Tools tab and run common actions (clear caches, compile themes, reset queue, register tasks)
## Installation
diff --git a/src/Resources/app/administration/jest.config.js b/src/Resources/app/administration/jest.config.js
new file mode 100644
index 00000000..4c7337d6
--- /dev/null
+++ b/src/Resources/app/administration/jest.config.js
@@ -0,0 +1,32 @@
+/**
+ * Plugin test config: reuses the Shopware administration's jest config and
+ * only points it at this plugin's spec files. This keeps the plugin on the
+ * exact same harness (transformers, Shopware globals, jsdom setup) that the
+ * Shopware installation provides, instead of duplicating it.
+ */
+const { join } = require('path');
+const resolveAdminPath = require('./test/resolve-admin-path');
+
+const adminPath = resolveAdminPath();
+
+process.env.ADMIN_PATH = process.env.ADMIN_PATH || adminPath;
+process.env.PROJECT_ROOT = process.env.PROJECT_ROOT || adminPath;
+
+// eslint-disable-next-line import/no-dynamic-require
+const coreConfig = require(join(adminPath, 'jest.config.js'));
+
+module.exports = {
+ ...coreConfig,
+ rootDir: adminPath,
+ roots: [join(__dirname, 'src')],
+ testMatch: [join(__dirname, 'src/**/*.spec.js')],
+ moduleNameMapper: {
+ ...coreConfig.moduleNameMapper,
+ '^frosh-test/(.*)$': join(__dirname, 'test/$1'),
+ },
+ coverageDirectory: join(__dirname, 'build', 'artifacts', 'jest'),
+ collectCoverageFrom: [
+ join(__dirname, 'src/**/*.js'),
+ `!${join(__dirname, 'src/**/*.spec.js')}`,
+ ],
+};
diff --git a/src/Resources/app/administration/package.json b/src/Resources/app/administration/package.json
index 8b98a08b..2df03ca9 100644
--- a/src/Resources/app/administration/package.json
+++ b/src/Resources/app/administration/package.json
@@ -2,6 +2,10 @@
"name": "Resources",
"version": "1.0.0",
"main": "index.js",
+ "scripts": {
+ "unit": "node test/run.js",
+ "unit:watch": "node test/run.js --watch"
+ },
"dependencies": {
"diff-match-patch": "^1.0.5",
"lucide-static": "^1.14.0",
diff --git a/src/Resources/app/administration/src/api/frosh-tools.spec.js b/src/Resources/app/administration/src/api/frosh-tools.spec.js
index 0a964d8d..f7f37cc6 100644
--- a/src/Resources/app/administration/src/api/frosh-tools.spec.js
+++ b/src/Resources/app/administration/src/api/frosh-tools.spec.js
@@ -1,17 +1,25 @@
-import FroshTools from './frosh-tools';
+import FroshToolsService from './frosh-tools';
-describe('FroshTools queue API', () => {
+/**
+ * The service is constructed the same way the DI container does it, with the
+ * HTTP client as the only mock — that is the system boundary. Base path and
+ * auth headers are the real implementation.
+ */
+describe('FroshTools API service', () => {
+ let httpClient;
let service;
beforeEach(() => {
- service = Object.create(FroshTools.prototype);
- service.httpClient = {
+ httpClient = {
get: jest.fn().mockResolvedValue({ data: {} }),
post: jest.fn().mockResolvedValue({ data: {} }),
delete: jest.fn().mockResolvedValue({ data: {} }),
};
- service.getApiBasePath = () => '/_action/frosh-tools';
- service.getBasicHeaders = () => ({});
+
+ service = new FroshToolsService(httpClient, {
+ getToken: () => 'test-token',
+ isLoggedIn: () => true,
+ });
});
it('encodes transport names in every queue route', async () => {
@@ -23,24 +31,40 @@ describe('FroshTools queue API', () => {
await service.deleteQueueMessage(name, 'message/id');
await service.purgeQueueTransport(name);
- expect(service.httpClient.get).toHaveBeenCalledWith(
- `/_action/frosh-tools/queue/transport/${encodedName}/messages`,
- expect.any(Object)
+ expect(httpClient.get).toHaveBeenCalledWith(
+ `_action/frosh-tools/queue/transport/${encodedName}/messages`,
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer test-token',
+ }),
+ })
);
- expect(service.httpClient.post).toHaveBeenCalledWith(
- `/_action/frosh-tools/queue/transport/${encodedName}/messages/message%2Fid/retry`,
+ expect(httpClient.post).toHaveBeenCalledWith(
+ `_action/frosh-tools/queue/transport/${encodedName}/messages/message%2Fid/retry`,
{},
- expect.any(Object)
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer test-token',
+ }),
+ })
);
- expect(service.httpClient.delete).toHaveBeenNthCalledWith(
+ expect(httpClient.delete).toHaveBeenNthCalledWith(
1,
- `/_action/frosh-tools/queue/transport/${encodedName}/messages/message%2Fid`,
- expect.any(Object)
+ `_action/frosh-tools/queue/transport/${encodedName}/messages/message%2Fid`,
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer test-token',
+ }),
+ })
);
- expect(service.httpClient.delete).toHaveBeenNthCalledWith(
+ expect(httpClient.delete).toHaveBeenNthCalledWith(
2,
- `/_action/frosh-tools/queue/transport/${encodedName}`,
- expect.any(Object)
+ `_action/frosh-tools/queue/transport/${encodedName}`,
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer test-token',
+ }),
+ })
);
});
@@ -48,23 +72,21 @@ describe('FroshTools queue API', () => {
await service.getSecuritySbom();
await service.getSecuritySbom(true);
- expect(service.httpClient.get).toHaveBeenNthCalledWith(
+ expect(httpClient.get).toHaveBeenNthCalledWith(
1,
- '/_action/frosh-tools/security/sbom',
- {
- headers: {},
+ '_action/frosh-tools/security/sbom',
+ expect.objectContaining({
params: {},
responseType: 'blob',
- }
+ })
);
- expect(service.httpClient.get).toHaveBeenNthCalledWith(
+ expect(httpClient.get).toHaveBeenNthCalledWith(
2,
- '/_action/frosh-tools/security/sbom',
- {
- headers: {},
+ '_action/frosh-tools/security/sbom',
+ expect.objectContaining({
params: { includeDev: 1 },
responseType: 'blob',
- }
+ })
);
});
});
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-elasticsearch/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-elasticsearch/index.spec.js
index f80653e8..cdffc450 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-elasticsearch/index.spec.js
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-elasticsearch/index.spec.js
@@ -1,6 +1,4 @@
-import { mount } from '@vue/test-utils';
-import '../../../../mixin/sortable-table';
-import '../ft-modal';
+import { mountFrosh } from 'frosh-test/mount';
import './index';
function createService() {
@@ -18,41 +16,40 @@ function createService() {
};
}
+/**
+ * Mounts the tab with the real ft-* component tree (including the real
+ * ft-modal for the confirmation dialog) and real translations. Only the
+ * elasticsearch API service (the HTTP boundary) is faked; the Shopware
+ * core code editor stays stubbed.
+ */
async function createWrapper(service) {
- const [component, ftModal] = await Promise.all([
- Shopware.Component.build('frosh-tools-tab-elasticsearch'),
- Shopware.Component.build('ft-modal'),
- ]);
-
- return mount(component, {
- global: {
- provide: { froshElasticSearch: service },
- components: { 'ft-modal': ftModal },
- stubs: [
- 'ft-page-head',
- 'ft-panel',
- 'ft-empty',
- 'ft-hero-state',
- 'ft-pill',
- 'ft-icon',
- 'ft-th-sort',
- 'ft-refresh-button',
- 'sw-code-editor',
- 'teleport',
- ],
- mocks: {
- $t: (key) => key,
- $tc: (key) => key,
- },
- directives: {
- tooltip: {},
- },
- },
+ return mountFrosh('frosh-tools-tab-elasticsearch', {
+ provide: { froshElasticSearch: service },
+ stubs: ['sw-code-editor'],
attachTo: document.body,
});
}
+/**
+ * Success notifications are growl-only and never persisted; errors are
+ * persisted. Read both store slices to see what the user was shown.
+ */
+function notificationsOfVariant(variant) {
+ const store = Shopware.Store.get('notification');
+
+ return [
+ ...Object.values(store.growlNotifications),
+ ...Object.values(store.notifications),
+ ].filter((notification) => notification.variant === variant);
+}
+
describe('frosh-tools-tab-elasticsearch destructive actions', () => {
+ beforeEach(() => {
+ const store = Shopware.Store.get('notification');
+ store.growlNotifications = {};
+ store.notifications = {};
+ });
+
afterEach(() => {
document.body.innerHTML = '';
});
@@ -71,14 +68,10 @@ describe('frosh-tools-tab-elasticsearch destructive actions', () => {
});
expect(service.deleteIndex).not.toHaveBeenCalled();
- // The confirm modal renders the matching snippet for the action.
+ // The confirm modal renders the real snippets for the action.
const modalText = wrapper.find('[role="dialog"]').text();
- expect(modalText).toContain(
- 'frosh-tools.tabs.elasticsearch.confirm.deleteIndex.title'
- );
- expect(modalText).toContain(
- 'frosh-tools.tabs.elasticsearch.confirm.deleteIndex.confirm'
- );
+ expect(modalText).toContain('Delete index "shopware-product"?');
+ expect(modalText).toContain('Delete index');
});
it('does not run the action when the confirmation is cancelled', async () => {
@@ -98,17 +91,12 @@ describe('frosh-tools-tab-elasticsearch destructive actions', () => {
const wrapper = await createWrapper(service);
await flushPromises();
- const notifySuccess = jest.spyOn(
- wrapper.vm,
- 'createNotificationSuccess'
- );
-
wrapper.vm.askFlushAll();
await wrapper.vm.runConfirmedAction();
await flushPromises();
expect(service.flushAll).toHaveBeenCalledTimes(1);
- expect(notifySuccess).toHaveBeenCalled();
+ expect(notificationsOfVariant('success')).toHaveLength(1);
expect(wrapper.vm.confirmAction).toBeNull();
// Refreshed status + indices after the action.
expect(service.status).toHaveBeenCalledTimes(2);
@@ -121,13 +109,13 @@ describe('frosh-tools-tab-elasticsearch destructive actions', () => {
const wrapper = await createWrapper(service);
await flushPromises();
- const notifyError = jest.spyOn(wrapper.vm, 'createNotificationError');
-
wrapper.vm.askReset();
await wrapper.vm.runConfirmedAction();
expect(service.reset).toHaveBeenCalledTimes(1);
- expect(notifyError).toHaveBeenCalledWith({ message: 'boom' });
+ expect(
+ notificationsOfVariant('error').map((n) => n.message)
+ ).toContain('boom');
expect(wrapper.vm.confirmAction).not.toBeNull();
expect(wrapper.vm.isConfirmingAction).toBe(false);
});
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-index/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-index/index.spec.js
index 7f921c29..0ced6587 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-index/index.spec.js
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-index/index.spec.js
@@ -1,40 +1,39 @@
-import { mount } from '@vue/test-utils';
-import '../../../../mixin/sortable-table';
+import { mountFrosh } from 'frosh-test/mount';
import './index';
-const STUBS = [
- 'ft-page-head',
- 'ft-panel',
- 'ft-empty',
- 'ft-hero-state',
- 'ft-th-sort',
- 'ft-pill',
- 'ft-modal',
- 'ft-button',
- 'ft-refresh-button',
-];
+const PHP_HEALTH = {
+ id: 'php',
+ snippet: 'PHP version',
+ state: 'STATE_OK',
+ current: '8.3.0',
+ recommended: '8.2.0',
+};
+/**
+ * Mounts the tab with the real ft-* component tree and real translations.
+ * Only the API service (the HTTP boundary) is faked.
+ */
async function createWrapper(service) {
- const component = await Shopware.Component.build('frosh-tools-tab-index');
-
- return mount(component, {
- global: {
- provide: {
- froshToolsService: service,
- },
- stubs: STUBS,
- mocks: {
- $t: (key) => key,
- $tc: (key) => key,
- },
+ return mountFrosh('frosh-tools-tab-index', {
+ provide: {
+ froshToolsService: service,
},
});
}
+function errorNotifications() {
+ return Object.values(Shopware.Store.get('notification').notifications)
+ .filter((notification) => notification.variant === 'error');
+}
+
describe('frosh-tools-tab-index', () => {
+ beforeEach(() => {
+ Shopware.Store.get('notification').notifications = {};
+ });
+
it('loads health and performance status on creation', async () => {
const service = {
- healthStatus: jest.fn().mockResolvedValue([{ id: 'php' }]),
+ healthStatus: jest.fn().mockResolvedValue([PHP_HEALTH]),
performanceStatus: jest.fn().mockResolvedValue([]),
};
@@ -43,8 +42,14 @@ describe('frosh-tools-tab-index', () => {
expect(wrapper.vm.isLoading).toBe(false);
expect(wrapper.vm.loadError).toBeNull();
- expect(wrapper.vm.health).toEqual([{ id: 'php' }]);
+ expect(wrapper.vm.health).toEqual([PHP_HEALTH]);
expect(wrapper.vm.performanceStatus).toEqual([]);
+
+ // The real table renders the loaded health row.
+ expect(wrapper.find('tbody tr').exists()).toBe(true);
+ expect(wrapper.find('.ft-table__name').text()).toContain(
+ 'PHP version'
+ );
});
it('shows an error state instead of loading forever when loading fails', async () => {
@@ -56,18 +61,21 @@ describe('frosh-tools-tab-index', () => {
};
const wrapper = await createWrapper(service);
- const notifyError = jest.spyOn(wrapper.vm, 'createNotificationError');
await flushPromises();
// No infinite spinner: loading finished and an error is surfaced.
expect(wrapper.vm.isLoading).toBe(false);
expect(wrapper.vm.loadError).toBe('Request failed');
- expect(notifyError).toHaveBeenCalledWith({
- message: 'Request failed',
- });
+
+ // The failure created a real error notification.
+ expect(errorNotifications().map((n) => n.message)).toContain(
+ 'Request failed'
+ );
await wrapper.vm.$nextTick();
- expect(wrapper.find('ft-hero-state-stub').exists()).toBe(true);
+ const heroState = wrapper.find('.ft-hero-state--danger');
+ expect(heroState.exists()).toBe(true);
+ expect(heroState.text()).toContain('Request failed');
});
it('recovers when retrying after a failure', async () => {
@@ -75,7 +83,7 @@ describe('frosh-tools-tab-index', () => {
healthStatus: jest
.fn()
.mockRejectedValueOnce(new Error('Request failed'))
- .mockResolvedValue([{ id: 'php' }]),
+ .mockResolvedValue([PHP_HEALTH]),
performanceStatus: jest.fn().mockResolvedValue([]),
};
@@ -87,7 +95,8 @@ describe('frosh-tools-tab-index', () => {
await flushPromises();
expect(wrapper.vm.loadError).toBeNull();
- expect(wrapper.vm.health).toEqual([{ id: 'php' }]);
+ expect(wrapper.vm.health).toEqual([PHP_HEALTH]);
expect(wrapper.vm.isLoading).toBe(false);
+ expect(wrapper.find('.ft-hero-state--danger').exists()).toBe(false);
});
});
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-scheduled/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-scheduled/index.spec.js
index fe95184a..ff626585 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-scheduled/index.spec.js
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-scheduled/index.spec.js
@@ -1,5 +1,4 @@
-import { mount } from '@vue/test-utils';
-import '../../../../mixin/sortable-table';
+import { mountFrosh } from 'frosh-test/mount';
import './index';
const TASKS = [
@@ -21,44 +20,27 @@ const TASKS = [
},
];
-const STUBS = {
- // The panel must render its default slot — the table lives inside it.
- 'ft-panel': { template: '' },
- 'ft-page-head': true,
- 'ft-empty': true,
- 'ft-hero-state': true,
- 'ft-th-sort': { template: '
| ' },
- 'ft-pill': true,
- 'ft-icon': true,
- 'ft-modal': true,
- 'ft-button': true,
- 'ft-refresh-button': true,
- 'sw-number-field': true,
- 'sw-datepicker': true,
-};
-
+/**
+ * Mounts the tab with the real ft-* component tree (panel, table headers,
+ * empty states, modals) and real translations. Only the system boundaries
+ * are faked: the repository (HTTP) and the shared admin search. Shopware
+ * core form fields inside the edit modal stay stubbed.
+ */
async function createWrapper({ searchTerm = '' } = {}) {
const scheduledRepository = {
search: jest.fn().mockResolvedValue(TASKS),
save: jest.fn().mockResolvedValue({}),
};
- const component = await Shopware.Component.build(
- 'frosh-tools-tab-scheduled'
- );
-
- return mount(component, {
- global: {
- provide: {
- repositoryFactory: { create: () => scheduledRepository },
- froshToolsService: {},
- froshToolsSearch: { searchTerm },
- },
- stubs: STUBS,
- mocks: {
- $t: (key) => key,
- $tc: (key) => key,
- },
+ return mountFrosh('frosh-tools-tab-scheduled', {
+ provide: {
+ repositoryFactory: { create: () => scheduledRepository },
+ froshToolsService: {},
+ froshToolsSearch: { searchTerm },
+ },
+ stubs: {
+ 'sw-number-field': true,
+ 'sw-datepicker': true,
},
});
}
@@ -90,6 +72,8 @@ describe('frosh-tools-tab-scheduled search', () => {
expect(wrapper.vm.visibleItems).toHaveLength(0);
expect(wrapper.findAll('tbody tr')).toHaveLength(0);
- expect(wrapper.find('ft-empty-stub').exists()).toBe(true);
+
+ const emptyState = wrapper.find('.ft-empty');
+ expect(emptyState.exists()).toBe(true);
});
});
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.js
new file mode 100644
index 00000000..07721cba
--- /dev/null
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.js
@@ -0,0 +1,448 @@
+/**
+ * Static command definitions for the FroshTools command palette.
+ *
+ * Each command is either navigation (route) or an executable action.
+ * Labels/descriptions are snippet keys resolved by the palette component.
+ */
+
+/**
+ * @typedef {Object} CommandContext
+ * @property {(location: { name: string }) => Promise|unknown} routerPush
+ * @property {Record} froshToolsService
+ * @property {{ assignTheme: Function }} [themeService]
+ * @property {{ create: Function }} [repositoryFactory]
+ * @property {(payload: { message: string }) => void} notifySuccess
+ * @property {(payload: { message: string }) => void} notifyError
+ * @property {(key: string, params?: Record) => string} t
+ * @property {boolean} elasticsearchAvailable
+ * @property {boolean} logsAvailable
+ * @property {boolean} fastlyAvailable
+ */
+
+/**
+ * @typedef {Object} CommandDefinition
+ * @property {string} id
+ * @property {'navigate'|'action'} type
+ * @property {string} group // snippet key suffix under frosh-tools.commandPalette.groups
+ * @property {string} icon
+ * @property {string} labelKey
+ * @property {string} [descriptionKey]
+ * @property {string[]} [keywords]
+ * @property {boolean} [confirm]
+ * @property {string} [confirmLabelKey]
+ * @property {(ctx: CommandContext) => boolean} [available]
+ * @property {string} [route]
+ * @property {(ctx: CommandContext) => Promise|void} [run]
+ */
+
+/**
+ * @returns {CommandDefinition[]}
+ */
+export function getCommandDefinitions() {
+ return [
+ // ── Navigation ──────────────────────────────────────────────
+ {
+ id: 'nav.index',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'bolt',
+ labelKey: 'frosh-tools.tabs.index.title',
+ descriptionKey: 'frosh-tools.tabs.index.subtitle',
+ keywords: ['status', 'health', 'system', 'overview', 'home'],
+ route: 'frosh.tools.index.index',
+ },
+ {
+ id: 'nav.security',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'alert',
+ labelKey: 'frosh-tools.tabs.security.title',
+ descriptionKey: 'frosh-tools.tabs.security.subtitle',
+ keywords: ['security', 'sbom', 'audit', 'eol', 'vulnerabilities'],
+ route: 'frosh.tools.index.security',
+ },
+ {
+ id: 'nav.shopmon',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'send',
+ labelKey: 'frosh-tools.tabs.shopmon.title',
+ descriptionKey:
+ 'frosh-tools.commandPalette.descriptions.shopmon',
+ keywords: ['shopmon', 'monitoring', 'integration'],
+ route: 'frosh.tools.index.shopmon',
+ },
+ {
+ id: 'nav.cache',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'refresh',
+ labelKey: 'frosh-tools.tabs.cache.title',
+ descriptionKey: 'frosh-tools.tabs.cache.subtitle',
+ keywords: ['cache', 'opcache', 'theme'],
+ route: 'frosh.tools.index.cache',
+ },
+ {
+ id: 'nav.statistics',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'chart',
+ labelKey: 'frosh-tools.tabs.statistics.title',
+ descriptionKey: 'frosh-tools.tabs.statistics.subtitle',
+ keywords: ['statistics', 'redis', 'fpm', 'database', 'opcache'],
+ route: 'frosh.tools.index.statistics',
+ },
+ {
+ id: 'nav.elasticsearch',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'search',
+ labelKey: 'frosh-tools.tabs.elasticsearch.title',
+ descriptionKey: 'frosh-tools.tabs.elasticsearch.subtitle',
+ keywords: ['elasticsearch', 'opensearch', 'index', 'search'],
+ available: (ctx) => ctx.elasticsearchAvailable,
+ route: 'frosh.tools.index.elasticsearch',
+ },
+ {
+ id: 'nav.queue',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'flow',
+ labelKey: 'frosh-tools.tabs.queue.title',
+ descriptionKey: 'frosh-tools.tabs.queue.subtitle',
+ keywords: ['queue', 'messenger', 'messages', 'worker'],
+ route: 'frosh.tools.index.queue',
+ },
+ {
+ id: 'nav.scheduled',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'play',
+ labelKey: 'frosh-tools.tabs.scheduledTaskOverview.title',
+ descriptionKey: 'frosh-tools.tabs.scheduledTaskOverview.subtitle',
+ keywords: ['scheduled', 'tasks', 'cron', 'schedule'],
+ route: 'frosh.tools.index.scheduled',
+ },
+ {
+ id: 'nav.statemachines',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'flow',
+ labelKey: 'frosh-tools.tabs.state-machines.title',
+ descriptionKey: 'frosh-tools.tabs.state-machines.subtitle',
+ keywords: ['state', 'machine', 'order', 'transaction', 'delivery'],
+ route: 'frosh.tools.index.statemachines',
+ },
+ {
+ id: 'nav.logs',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'file',
+ labelKey: 'frosh-tools.tabs.logs.title',
+ descriptionKey: 'frosh-tools.tabs.logs.subtitle',
+ keywords: ['logs', 'log viewer', 'error', 'var/log'],
+ available: (ctx) => ctx.logsAvailable,
+ route: 'frosh.tools.index.logs',
+ },
+ {
+ id: 'nav.featureflags',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'cog',
+ labelKey: 'frosh-tools.tabs.feature-flags.title',
+ descriptionKey: 'frosh-tools.tabs.feature-flags.subtitle',
+ keywords: ['feature', 'flags', 'toggle'],
+ route: 'frosh.tools.index.featureflags',
+ },
+ {
+ id: 'nav.fastly',
+ type: 'navigate',
+ group: 'navigate',
+ icon: 'bolt',
+ labelKey: 'frosh-tools.tabs.fastly.title',
+ descriptionKey: 'frosh-tools.commandPalette.descriptions.fastly',
+ keywords: ['fastly', 'cdn', 'purge'],
+ available: (ctx) => ctx.fastlyAvailable,
+ route: 'frosh.tools.index.fastly',
+ },
+
+ // ── Cache actions ───────────────────────────────────────────
+ {
+ id: 'action.cache.clear-all',
+ type: 'action',
+ group: 'cache',
+ icon: 'trash',
+ labelKey: 'frosh-tools.commandPalette.actions.clearAllCaches',
+ descriptionKey:
+ 'frosh-tools.commandPalette.actions.clearAllCachesDescription',
+ keywords: ['clear', 'cache', 'all', 'flush'],
+ confirm: true,
+ confirmLabelKey:
+ 'frosh-tools.commandPalette.actions.clearAllCachesConfirm',
+ async run(ctx) {
+ const pools = await ctx.froshToolsService.getCacheInfo();
+ const list = Array.isArray(pools) ? pools : [];
+
+ for (const pool of list) {
+ if (pool?.name) {
+ await ctx.froshToolsService.clearCache(pool.name);
+ }
+ }
+
+ ctx.notifySuccess({
+ message: ctx.t(
+ 'frosh-tools.commandPalette.actions.clearAllCachesSuccess',
+ { count: list.length }
+ ),
+ });
+ },
+ },
+ {
+ id: 'action.cache.clear-http',
+ type: 'action',
+ group: 'cache',
+ icon: 'trash',
+ labelKey: 'frosh-tools.commandPalette.actions.clearHttpCache',
+ descriptionKey:
+ 'frosh-tools.commandPalette.actions.clearHttpCacheDescription',
+ keywords: ['clear', 'http', 'cache', 'http_cache'],
+ async run(ctx) {
+ await clearCacheIfPresent(ctx, [
+ 'http',
+ 'http_cache',
+ 'shopware.http_cache',
+ ]);
+ },
+ },
+ {
+ id: 'action.cache.clear-object',
+ type: 'action',
+ group: 'cache',
+ icon: 'trash',
+ labelKey: 'frosh-tools.commandPalette.actions.clearObjectCache',
+ descriptionKey:
+ 'frosh-tools.commandPalette.actions.clearObjectCacheDescription',
+ keywords: ['clear', 'object', 'cache', 'app'],
+ async run(ctx) {
+ await clearCacheIfPresent(ctx, [
+ 'object',
+ 'app',
+ 'cache.object',
+ 'shopware.cache',
+ ]);
+ },
+ },
+ {
+ id: 'action.cache.clear-opcache',
+ type: 'action',
+ group: 'cache',
+ icon: 'refresh',
+ labelKey: 'frosh-tools.clearOpCache',
+ descriptionKey:
+ 'frosh-tools.commandPalette.actions.clearOpcacheDescription',
+ keywords: ['opcache', 'php', 'clear'],
+ async run(ctx) {
+ await ctx.froshToolsService.clearOPcache();
+ ctx.notifySuccess({
+ message: ctx.t('frosh-tools.clearedOpcache'),
+ });
+ },
+ },
+ {
+ id: 'action.cache.compile-theme',
+ type: 'action',
+ group: 'cache',
+ icon: 'paint',
+ labelKey: 'frosh-tools.compileTheme',
+ descriptionKey:
+ 'frosh-tools.commandPalette.actions.compileThemeDescription',
+ keywords: ['theme', 'compile', 'storefront', 'scss'],
+ available: (ctx) =>
+ Boolean(ctx.themeService && ctx.repositoryFactory),
+ async run(ctx) {
+ const Criteria = Shopware.Data.Criteria;
+ const criteria = new Criteria();
+ criteria.addAssociation('themes');
+
+ const salesChannelRepository =
+ ctx.repositoryFactory.create('sales_channel');
+ const salesChannels = await salesChannelRepository.search(
+ criteria,
+ Shopware.Context.api
+ );
+
+ let compiled = 0;
+
+ for (const salesChannel of salesChannels) {
+ const theme = salesChannel.extensions?.themes?.first?.();
+ if (!theme) {
+ continue;
+ }
+
+ await ctx.themeService.assignTheme(
+ theme.id,
+ salesChannel.id
+ );
+ compiled += 1;
+ ctx.notifySuccess({
+ message: `${salesChannel.translated?.name ?? salesChannel.name}: ${ctx.t('frosh-tools.themeCompiled')}`,
+ });
+ }
+
+ if (compiled === 0) {
+ ctx.notifyError({
+ message: ctx.t(
+ 'frosh-tools.commandPalette.actions.compileThemeNone'
+ ),
+ });
+ }
+ },
+ },
+
+ // ── Queue actions ───────────────────────────────────────────
+ {
+ id: 'action.queue.reset',
+ type: 'action',
+ group: 'queue',
+ icon: 'trash',
+ labelKey: 'frosh-tools.resetQueue',
+ descriptionKey:
+ 'frosh-tools.commandPalette.actions.resetQueueDescription',
+ keywords: ['queue', 'reset', 'clear', 'purge', 'messenger'],
+ confirm: true,
+ confirmLabelKey:
+ 'frosh-tools.commandPalette.actions.resetQueueConfirm',
+ async run(ctx) {
+ await ctx.froshToolsService.resetQueue();
+ ctx.notifySuccess({
+ message: ctx.t('frosh-tools.tabs.queue.reset.success'),
+ });
+ },
+ },
+
+ // ── Task actions ────────────────────────────────────────────
+ {
+ id: 'action.tasks.register',
+ type: 'action',
+ group: 'tasks',
+ icon: 'refresh',
+ labelKey: 'frosh-tools.commandPalette.actions.registerTasks',
+ descriptionKey:
+ 'frosh-tools.commandPalette.actions.registerTasksDescription',
+ keywords: ['scheduled', 'tasks', 'register', 'cron'],
+ async run(ctx) {
+ await ctx.froshToolsService.scheduledTasksRegister();
+ ctx.notifySuccess({
+ message: ctx.t('frosh-tools.scheduledTasksRegisterSucceed'),
+ });
+ },
+ },
+ ];
+}
+
+/**
+ * Clear the first cache pool whose name matches any candidate (case-insensitive).
+ *
+ * @param {CommandContext} ctx
+ * @param {string[]} candidates
+ */
+async function clearCacheIfPresent(ctx, candidates) {
+ const pools = await ctx.froshToolsService.getCacheInfo();
+ const list = Array.isArray(pools) ? pools : [];
+ const lower = candidates.map((name) => name.toLowerCase());
+
+ const match = list.find((pool) =>
+ lower.some(
+ (candidate) =>
+ String(pool?.name ?? '')
+ .toLowerCase()
+ .includes(candidate) ||
+ String(pool?.type ?? '')
+ .toLowerCase()
+ .includes(candidate)
+ )
+ );
+
+ if (!match?.name) {
+ ctx.notifyError({
+ message: ctx.t(
+ 'frosh-tools.commandPalette.actions.cachePoolNotFound',
+ { names: candidates.join(', ') }
+ ),
+ });
+ return;
+ }
+
+ await ctx.froshToolsService.clearCache(match.name);
+ ctx.notifySuccess({
+ message: ctx.t('frosh-tools.cacheCleared', { name: match.name }),
+ });
+}
+
+/**
+ * Rank + filter commands by a free-text query.
+ *
+ * @param {Array} commands
+ * @param {string} query
+ * @returns {typeof commands}
+ */
+export function filterCommands(commands, query) {
+ const term = String(query ?? '')
+ .trim()
+ .toLowerCase();
+
+ if (!term) {
+ return commands;
+ }
+
+ const tokens = term.split(/\s+/).filter(Boolean);
+
+ return commands
+ .map((command) => {
+ const haystack = [
+ command.label,
+ command.description,
+ command.groupLabel,
+ command.id,
+ ...(command.keywords ?? []),
+ ]
+ .join(' ')
+ .toLowerCase();
+
+ let score = 0;
+
+ for (const token of tokens) {
+ if (!haystack.includes(token)) {
+ return null;
+ }
+
+ if (command.label.toLowerCase().startsWith(token)) {
+ score += 8;
+ } else if (command.label.toLowerCase().includes(token)) {
+ score += 5;
+ } else if (
+ (command.keywords ?? []).some((keyword) =>
+ keyword.toLowerCase().includes(token)
+ )
+ ) {
+ score += 3;
+ } else {
+ score += 1;
+ }
+ }
+
+ // Prefer exact label hits
+ if (command.label.toLowerCase() === term) {
+ score += 20;
+ }
+
+ return { command, score };
+ })
+ .filter(Boolean)
+ .sort(
+ (a, b) =>
+ b.score - a.score ||
+ a.command.label.localeCompare(b.command.label)
+ )
+ .map(({ command }) => command);
+}
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.spec.js
new file mode 100644
index 00000000..5fd4370b
--- /dev/null
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.spec.js
@@ -0,0 +1,147 @@
+import { filterCommands, getCommandDefinitions } from './commands';
+
+describe('ft-command-palette/commands', () => {
+ it('exposes navigation and action commands', () => {
+ const commands = getCommandDefinitions();
+
+ expect(commands.some((command) => command.id === 'nav.index')).toBe(
+ true
+ );
+ expect(
+ commands.some((command) => command.id === 'action.cache.clear-all')
+ ).toBe(true);
+ expect(
+ commands.some((command) => command.id === 'action.queue.reset')
+ ).toBe(true);
+ });
+
+ it('hides conditional navigation when features are unavailable', () => {
+ const elasticsearch = getCommandDefinitions().find(
+ (command) => command.id === 'nav.elasticsearch'
+ );
+
+ expect(elasticsearch.available({ elasticsearchAvailable: false })).toBe(
+ false
+ );
+ expect(elasticsearch.available({ elasticsearchAvailable: true })).toBe(
+ true
+ );
+ });
+
+ it('filters commands by label and keywords', () => {
+ const commands = [
+ {
+ id: 'nav.cache',
+ label: 'Cache',
+ description: 'Manage cache pools',
+ groupLabel: 'Go to',
+ keywords: ['opcache', 'theme'],
+ },
+ {
+ id: 'nav.queue',
+ label: 'Queue',
+ description: 'Pending messages',
+ groupLabel: 'Go to',
+ keywords: ['messenger'],
+ },
+ {
+ id: 'action.cache.clear-all',
+ label: 'Clear all cache pools',
+ description: 'Flush every pool',
+ groupLabel: 'Cache',
+ keywords: ['clear', 'flush'],
+ },
+ ];
+
+ const cacheHits = filterCommands(commands, 'cache');
+ expect(cacheHits.map((command) => command.id)).toEqual([
+ 'nav.cache',
+ 'action.cache.clear-all',
+ ]);
+
+ const messengerHits = filterCommands(commands, 'messenger');
+ expect(messengerHits.map((command) => command.id)).toEqual([
+ 'nav.queue',
+ ]);
+
+ expect(filterCommands(commands, 'does-not-exist')).toEqual([]);
+ });
+
+ it('ranks exact and prefix label matches higher', () => {
+ const commands = [
+ {
+ id: 'a',
+ label: 'Cache manager tools',
+ description: '',
+ groupLabel: '',
+ keywords: [],
+ },
+ {
+ id: 'b',
+ label: 'Cache',
+ description: '',
+ groupLabel: '',
+ keywords: [],
+ },
+ {
+ id: 'c',
+ label: 'Clear all cache pools',
+ description: '',
+ groupLabel: '',
+ keywords: ['cache'],
+ },
+ ];
+
+ const ranked = filterCommands(commands, 'cache');
+ expect(ranked[0].id).toBe('b');
+ });
+
+ it('clears matching cache pools and reports missing ones', async () => {
+ const clearHttp = getCommandDefinitions().find(
+ (command) => command.id === 'action.cache.clear-http'
+ );
+
+ const froshToolsService = {
+ getCacheInfo: jest
+ .fn()
+ .mockResolvedValue([{ name: 'http_cache' }, { name: 'object' }]),
+ clearCache: jest.fn().mockResolvedValue({}),
+ };
+ const notifySuccess = jest.fn();
+ const notifyError = jest.fn();
+
+ await clearHttp.run({
+ froshToolsService,
+ notifySuccess,
+ notifyError,
+ t: (key, params) => `${key}:${JSON.stringify(params || {})}`,
+ });
+
+ expect(froshToolsService.clearCache).toHaveBeenCalledWith('http_cache');
+ expect(notifySuccess).toHaveBeenCalled();
+ expect(notifyError).not.toHaveBeenCalled();
+ });
+
+ it('resets the queue via the service', async () => {
+ const reset = getCommandDefinitions().find(
+ (command) => command.id === 'action.queue.reset'
+ );
+
+ const froshToolsService = {
+ resetQueue: jest.fn().mockResolvedValue({}),
+ };
+ const notifySuccess = jest.fn();
+
+ await reset.run({
+ froshToolsService,
+ notifySuccess,
+ notifyError: jest.fn(),
+ t: (key) => key,
+ });
+
+ expect(froshToolsService.resetQueue).toHaveBeenCalled();
+ expect(notifySuccess).toHaveBeenCalledWith({
+ message: 'frosh-tools.tabs.queue.reset.success',
+ });
+ });
+});
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.js
new file mode 100644
index 00000000..68794ede
--- /dev/null
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.js
@@ -0,0 +1,409 @@
+import './style.scss';
+import template from './template.html.twig';
+import { filterCommands, getCommandDefinitions } from './commands';
+
+const { Component, Mixin } = Shopware;
+
+const RECENT_STORAGE_KEY = 'frosh-tools.command-palette.recent';
+const RECENT_LIMIT = 5;
+
+Component.register('ft-command-palette', {
+ template,
+
+ inject: {
+ froshToolsService: { from: 'froshToolsService', default: null },
+ themeService: { from: 'themeService', default: null },
+ repositoryFactory: { from: 'repositoryFactory', default: null },
+ },
+
+ mixins: [Mixin.getByName('notification')],
+
+ props: {
+ elasticsearchAvailable: { type: Boolean, default: false },
+ logsAvailable: { type: Boolean, default: false },
+ fastlyAvailable: { type: Boolean, default: false },
+ },
+
+ emits: ['close'],
+
+ data() {
+ return {
+ query: '',
+ activeId: null,
+ isRunning: false,
+ pendingConfirm: null,
+ recentIds: this.readRecentIds(),
+ };
+ },
+
+ computed: {
+ titleId() {
+ return `ft-command-palette-title-${this.$.uid}`;
+ },
+
+ listboxId() {
+ return `ft-command-palette-list-${this.$.uid}`;
+ },
+
+ commandContext() {
+ return {
+ routerPush: (location) => this.$router.push(location),
+ froshToolsService: this.froshToolsService,
+ themeService: this.themeService,
+ repositoryFactory: this.repositoryFactory,
+ notifySuccess: (payload) => this.createNotificationSuccess(payload),
+ notifyError: (payload) => this.createNotificationError(payload),
+ t: (key, params) => this.$t(key, params),
+ elasticsearchAvailable: this.elasticsearchAvailable,
+ logsAvailable: this.logsAvailable,
+ fastlyAvailable: this.fastlyAvailable,
+ };
+ },
+
+ resolvedCommands() {
+ const ctx = this.commandContext;
+
+ return getCommandDefinitions()
+ .filter((command) =>
+ typeof command.available === 'function'
+ ? command.available(ctx)
+ : true
+ )
+ .map((command) => ({
+ ...command,
+ label: this.$t(command.labelKey),
+ description: command.descriptionKey
+ ? this.$t(command.descriptionKey)
+ : '',
+ groupLabel: this.$t(
+ `frosh-tools.commandPalette.groups.${command.group}`
+ ),
+ }));
+ },
+
+ visibleCommands() {
+ const filtered = filterCommands(this.resolvedCommands, this.query);
+
+ if (this.query.trim()) {
+ return filtered;
+ }
+
+ // Promote recent commands to the top when the query is empty.
+ const recent = [];
+ const rest = [];
+
+ for (const id of this.recentIds) {
+ const match = filtered.find((command) => command.id === id);
+ if (match) {
+ recent.push(match);
+ }
+ }
+
+ for (const command of filtered) {
+ if (!this.recentIds.includes(command.id)) {
+ rest.push(command);
+ }
+ }
+
+ return [...recent, ...rest];
+ },
+
+ groupedCommands() {
+ const groups = new Map();
+
+ for (const command of this.visibleCommands) {
+ const key =
+ !this.query.trim() && this.recentIds.includes(command.id)
+ ? 'recent'
+ : command.group;
+
+ if (!groups.has(key)) {
+ groups.set(key, {
+ group: key,
+ groupLabel:
+ key === 'recent'
+ ? this.$t(
+ 'frosh-tools.commandPalette.groups.recent'
+ )
+ : command.groupLabel,
+ items: [],
+ });
+ }
+
+ groups.get(key).items.push(command);
+ }
+
+ return Array.from(groups.values());
+ },
+
+ activeOptionId() {
+ return this.activeId ? this.optionId(this.activeId) : null;
+ },
+
+ pendingConfirmLabel() {
+ if (!this.pendingConfirm) {
+ return '';
+ }
+
+ if (this.pendingConfirm.confirmLabelKey) {
+ return this.$t(this.pendingConfirm.confirmLabelKey);
+ }
+
+ return this.pendingConfirm.label;
+ },
+ },
+
+ watch: {
+ visibleCommands: {
+ immediate: true,
+ handler(commands) {
+ if (commands.length === 0) {
+ this.activeId = null;
+ return;
+ }
+
+ if (!commands.some((command) => command.id === this.activeId)) {
+ this.activeId = commands[0].id;
+ }
+ },
+ },
+ },
+
+ mounted() {
+ document.addEventListener('keydown', this.onDocumentKeydown, true);
+ this.previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ this.previousActiveElement = document.activeElement;
+
+ this.$nextTick(() => {
+ this.focusInput();
+ });
+ },
+
+ unmounted() {
+ document.removeEventListener('keydown', this.onDocumentKeydown, true);
+ document.body.style.overflow = this.previousOverflow || '';
+ this.restorePreviousFocus();
+ },
+
+ methods: {
+ optionId(id) {
+ return `${this.listboxId}-option-${id}`;
+ },
+
+ focusInput() {
+ const input = this.$refs.input;
+ if (input && typeof input.focus === 'function') {
+ input.focus();
+ if (typeof input.select === 'function') {
+ input.select();
+ }
+ }
+ },
+
+ restorePreviousFocus() {
+ const element = this.previousActiveElement;
+
+ if (
+ element &&
+ document.contains(element) &&
+ typeof element.focus === 'function'
+ ) {
+ element.focus();
+ }
+
+ this.previousActiveElement = null;
+ },
+
+ onInput(event) {
+ this.query = event.target.value;
+ this.pendingConfirm = null;
+ },
+
+ onInputKeydown(event) {
+ if (this.pendingConfirm) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ this.confirmAndRun();
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ this.cancelConfirm();
+ }
+ return;
+ }
+
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ this.moveActive(1);
+ return;
+ }
+
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ this.moveActive(-1);
+ return;
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ const active = this.visibleCommands.find(
+ (command) => command.id === this.activeId
+ );
+ if (active) {
+ this.runCommand(active);
+ }
+ }
+ },
+
+ onDocumentKeydown(event) {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (this.pendingConfirm) {
+ this.cancelConfirm();
+ return;
+ }
+
+ this.close();
+ }
+ },
+
+ setActive(id) {
+ this.activeId = id;
+ },
+
+ moveActive(delta) {
+ const commands = this.visibleCommands;
+ if (commands.length === 0) {
+ return;
+ }
+
+ const currentIndex = commands.findIndex(
+ (command) => command.id === this.activeId
+ );
+ const nextIndex =
+ currentIndex < 0
+ ? 0
+ : (currentIndex + delta + commands.length) %
+ commands.length;
+
+ this.activeId = commands[nextIndex].id;
+ this.scrollActiveIntoView();
+ },
+
+ scrollActiveIntoView() {
+ this.$nextTick(() => {
+ const option = document.getElementById(this.activeOptionId);
+ if (option && typeof option.scrollIntoView === 'function') {
+ option.scrollIntoView({ block: 'nearest' });
+ }
+ });
+ },
+
+ async runCommand(command) {
+ if (this.isRunning || !command) {
+ return;
+ }
+
+ if (command.confirm) {
+ this.pendingConfirm = command;
+ return;
+ }
+
+ await this.executeCommand(command);
+ },
+
+ cancelConfirm() {
+ this.pendingConfirm = null;
+ this.$nextTick(() => this.focusInput());
+ },
+
+ async confirmAndRun() {
+ if (!this.pendingConfirm) {
+ return;
+ }
+
+ const command = this.pendingConfirm;
+ this.pendingConfirm = null;
+ await this.executeCommand(command);
+ },
+
+ async executeCommand(command) {
+ this.isRunning = true;
+
+ try {
+ if (command.type === 'navigate' && command.route) {
+ try {
+ await this.$router.push({ name: command.route });
+ } catch {
+ // Ignore duplicate-navigation rejections from vue-router.
+ }
+ this.rememberRecent(command.id);
+ // Drop the busy flag before close() — close refuses to emit
+ // while a command is still marked as running.
+ this.isRunning = false;
+ this.close();
+ return;
+ }
+
+ if (typeof command.run === 'function') {
+ await command.run(this.commandContext);
+ this.rememberRecent(command.id);
+ this.isRunning = false;
+ this.close();
+ return;
+ }
+ } catch (error) {
+ this.createNotificationError({
+ message: error?.response?.data?.error ?? error.message,
+ });
+ } finally {
+ this.isRunning = false;
+ }
+ },
+
+ rememberRecent(id) {
+ const next = [
+ id,
+ ...this.recentIds.filter((entry) => entry !== id),
+ ].slice(0, RECENT_LIMIT);
+
+ this.recentIds = next;
+
+ try {
+ window.localStorage.setItem(
+ RECENT_STORAGE_KEY,
+ JSON.stringify(next)
+ );
+ } catch {
+ /* private mode / blocked storage — ignore */
+ }
+ },
+
+ readRecentIds() {
+ try {
+ const raw = window.localStorage.getItem(RECENT_STORAGE_KEY);
+ if (!raw) {
+ return [];
+ }
+
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed)
+ ? parsed.filter((entry) => typeof entry === 'string')
+ : [];
+ } catch {
+ return [];
+ }
+ },
+
+ close() {
+ if (this.isRunning) {
+ return;
+ }
+
+ this.$emit('close');
+ },
+ },
+});
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.spec.js
new file mode 100644
index 00000000..dc944cad
--- /dev/null
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.spec.js
@@ -0,0 +1,145 @@
+import { mountFrosh } from 'frosh-test/mount';
+import './index';
+
+/**
+ * Mounts the palette with the real ft-* components and real translations.
+ * Only the system boundaries are faked: the API services (HTTP) and the
+ * router (navigation).
+ */
+async function createWrapper({
+ props = {},
+ service = {},
+ routerPush = jest.fn().mockResolvedValue(),
+} = {}) {
+ return mountFrosh('ft-command-palette', {
+ props: {
+ elasticsearchAvailable: true,
+ logsAvailable: true,
+ fastlyAvailable: true,
+ ...props,
+ },
+ provide: {
+ froshToolsService: {
+ getCacheInfo: jest.fn().mockResolvedValue([]),
+ clearCache: jest.fn().mockResolvedValue({}),
+ clearOPcache: jest.fn().mockResolvedValue({}),
+ resetQueue: jest.fn().mockResolvedValue({}),
+ scheduledTasksRegister: jest.fn().mockResolvedValue({}),
+ ...service,
+ },
+ themeService: {
+ assignTheme: jest.fn().mockResolvedValue({}),
+ },
+ repositoryFactory: {
+ create: jest.fn(),
+ },
+ },
+ mocks: {
+ $router: {
+ push: routerPush,
+ },
+ },
+ attachTo: document.body,
+ });
+}
+
+describe('ft-command-palette', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ window.localStorage.clear();
+ });
+
+ it('renders the search field and navigation commands', async () => {
+ const wrapper = await createWrapper();
+ await flushPromises();
+
+ expect(wrapper.find('.ft-command-palette__input').exists()).toBe(true);
+ expect(wrapper.text()).toContain('System-Status');
+ expect(wrapper.text()).toContain('Cache');
+ });
+
+ it('filters the list when the query changes', async () => {
+ const wrapper = await createWrapper();
+ await flushPromises();
+
+ const input = wrapper.find('.ft-command-palette__input');
+ await input.setValue('queue');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Queue');
+ expect(wrapper.text()).not.toContain('Feature Flags');
+ });
+
+ it('navigates when a navigation command is activated', async () => {
+ const routerPush = jest.fn().mockResolvedValue();
+ const wrapper = await createWrapper({ routerPush });
+ await flushPromises();
+
+ const indexCommand = wrapper.vm.visibleCommands.find(
+ (command) => command.id === 'nav.index'
+ );
+ expect(indexCommand).toBeTruthy();
+
+ await wrapper.vm.runCommand(indexCommand);
+ await flushPromises();
+
+ expect(routerPush).toHaveBeenCalledWith({
+ name: 'frosh.tools.index.index',
+ });
+ expect(wrapper.emitted('close')).toBeTruthy();
+ });
+
+ it('asks for confirmation before destructive actions', async () => {
+ const resetQueue = jest.fn().mockResolvedValue({});
+ const wrapper = await createWrapper({
+ service: { resetQueue },
+ });
+ await flushPromises();
+
+ const resetCommand = wrapper.vm.visibleCommands.find(
+ (command) => command.id === 'action.queue.reset'
+ );
+ expect(resetCommand).toBeTruthy();
+
+ await wrapper.vm.runCommand(resetCommand);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.pendingConfirm).toBeTruthy();
+ expect(resetQueue).not.toHaveBeenCalled();
+ expect(wrapper.text()).toContain('Confirm action');
+
+ await wrapper.vm.confirmAndRun();
+ await flushPromises();
+
+ expect(resetQueue).toHaveBeenCalled();
+ expect(wrapper.emitted('close')).toBeTruthy();
+ });
+
+ it('closes on Escape', async () => {
+ const wrapper = await createWrapper();
+ await flushPromises();
+
+ document.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
+ );
+ await flushPromises();
+
+ expect(wrapper.emitted('close')).toHaveLength(1);
+ });
+
+ it('hides unavailable feature navigation', async () => {
+ const wrapper = await createWrapper({
+ props: {
+ elasticsearchAvailable: false,
+ logsAvailable: false,
+ fastlyAvailable: false,
+ },
+ });
+ await flushPromises();
+
+ const ids = wrapper.vm.visibleCommands.map((command) => command.id);
+ expect(ids).not.toContain('nav.elasticsearch');
+ expect(ids).not.toContain('nav.logs');
+ expect(ids).not.toContain('nav.fastly');
+ });
+});
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/style.scss b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/style.scss
new file mode 100644
index 00000000..b82872d7
--- /dev/null
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/style.scss
@@ -0,0 +1,252 @@
+.ft-command-palette {
+ position: fixed;
+ inset: 0;
+ z-index: 1100;
+ background: rgba(15, 23, 42, 0.55);
+ backdrop-filter: blur(2px);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 12vh 16px 24px;
+ overflow-y: auto;
+ animation: ft-command-palette-fade 140ms ease-out;
+
+ &__dialog {
+ background: var(--ft-surface);
+ color: var(--ft-text);
+ border: 1px solid var(--ft-border);
+ border-radius: var(--ft-radius-lg);
+ box-shadow: var(--ft-shadow-lg);
+ width: min(640px, 100%);
+ display: flex;
+ flex-direction: column;
+ max-height: min(520px, calc(100vh - 18vh));
+ overflow: hidden;
+ animation: ft-command-palette-pop 160ms cubic-bezier(0.16, 1, 0.3, 1);
+
+ &:focus {
+ outline: none;
+ }
+ }
+
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--ft-divider);
+ flex-shrink: 0;
+ color: var(--ft-text-muted);
+ }
+
+ &__input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ color: var(--ft-text);
+ font: inherit;
+ font-size: 15px;
+ line-height: 1.4;
+ outline: none;
+
+ &::placeholder {
+ color: var(--ft-text-muted);
+ }
+
+ // Hide native search clear in WebKit so layout stays stable.
+ &::-webkit-search-cancel-button {
+ display: none;
+ }
+ }
+
+ &__body {
+ overflow-y: auto;
+ flex: 1;
+ min-height: 0;
+ padding: 8px 0 10px;
+ }
+
+ &__empty {
+ padding: 28px 18px;
+ text-align: center;
+ color: var(--ft-text-muted);
+ font-size: 13px;
+ }
+
+ &__group {
+ & + & {
+ margin-top: 4px;
+ }
+ }
+
+ &__group-label {
+ padding: 8px 16px 4px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ft-text-muted);
+ }
+
+ &__results {
+ padding: 0 8px;
+ }
+
+ &__item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 9px 10px;
+ border-radius: var(--ft-radius-md);
+ cursor: pointer;
+ color: var(--ft-text);
+ transition: background var(--ft-transition);
+
+ &.is-active {
+ background: var(--ft-accent-soft);
+ color: var(--ft-text);
+
+ .ft-command-palette__item-icon {
+ color: var(--ft-accent);
+ background: var(--ft-surface);
+ }
+ }
+ }
+
+ &__item-icon {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--ft-radius-sm);
+ background: var(--ft-surface-2);
+ color: var(--ft-text-soft);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+
+ &__item-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ }
+
+ &__item-label {
+ font-size: 13.5px;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__item-description {
+ font-size: 11.5px;
+ color: var(--ft-text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__item-badge {
+ flex-shrink: 0;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--ft-accent);
+ background: var(--ft-surface);
+ border: 1px solid var(--ft-border);
+ border-radius: 999px;
+ padding: 2px 7px;
+ }
+
+ &__confirm {
+ padding: 20px 18px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ &__confirm-title {
+ font-size: 14px;
+ font-weight: 600;
+ }
+
+ &__confirm-text {
+ margin: 0;
+ font-size: 13px;
+ color: var(--ft-text-soft);
+ line-height: 1.45;
+ }
+
+ &__confirm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 6px;
+ }
+
+ &__foot {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 18px;
+ padding: 8px 14px;
+ border-top: 1px solid var(--ft-divider);
+ color: var(--ft-text-muted);
+ font-size: 11.5px;
+ flex-shrink: 0;
+ }
+
+ &__kbd {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 20px;
+ height: 20px;
+ padding: 0 5px;
+ border-radius: 4px;
+ border: 1px solid var(--ft-border);
+ background: var(--ft-surface-2);
+ color: var(--ft-text-soft);
+ font-family: var(--ft-mono);
+ font-size: 10.5px;
+ line-height: 1;
+ box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
+
+ &--muted {
+ opacity: 0.7;
+ }
+ }
+}
+
+@keyframes ft-command-palette-fade {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes ft-command-palette-pop {
+ from {
+ opacity: 0;
+ transform: translateY(-6px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+// Dark theme inherits .ft variables from the design system.
+[data-theme='dark'] .ft-command-palette,
+.dark .ft-command-palette,
+html.dark .ft-command-palette,
+body.dark .ft-command-palette {
+ background: rgba(2, 6, 23, 0.72);
+}
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/template.html.twig b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/template.html.twig
new file mode 100644
index 00000000..07eb2386
--- /dev/null
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/template.html.twig
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+ {{ $t('frosh-tools.commandPalette.confirmTitle') }}
+
+
+ {{ pendingConfirmLabel }}
+
+
+
+
+
+
+
+
+
+ {{ $t('frosh-tools.commandPalette.noResults', { term: query }) }}
+
+
+
+
+
+ {{ section.groupLabel }}
+
+
+
+
+
+
+
+ {{ command.label }}
+
+
+ {{ command.description }}
+
+
+
+ {{ $t('frosh-tools.commandPalette.actionBadge') }}
+
+
+ ↵
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-modal/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-modal/index.spec.js
index aa2f6eeb..d46bf444 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/component/ft-modal/index.spec.js
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-modal/index.spec.js
@@ -1,24 +1,12 @@
-import { mount } from '@vue/test-utils';
-import './index';
+import { mountFrosh } from 'frosh-test/mount';
async function createWrapper({ props = {}, slots = {} } = {}) {
- const component = await Shopware.Component.build('ft-modal');
-
- return mount(component, {
+ return mountFrosh('ft-modal', {
props,
slots: {
default: 'Body content
',
...slots,
},
- global: {
- stubs: {
- 'ft-icon': true,
- teleport: true,
- },
- mocks: {
- $t: (key) => key,
- },
- },
attachTo: document.body,
});
}
@@ -40,6 +28,11 @@ describe('ft-modal', () => {
const titleId = dialog.attributes('aria-labelledby');
expect(titleId).toBeTruthy();
expect(wrapper.find(`#${titleId}`).text()).toBe('Confirm action');
+
+ // The close button is labelled with the real core snippet.
+ expect(
+ wrapper.find('.ft-modal__close').attributes('aria-label')
+ ).toBe('Close');
});
it('emits close when Escape is pressed', async () => {
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-th-sort/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-th-sort/index.spec.js
index 28caf7a5..d612fee2 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/component/ft-th-sort/index.spec.js
+++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-th-sort/index.spec.js
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
+import { froshComponents, froshI18nMocks } from 'frosh-test/mount';
import '../../../../mixin/sortable-table';
import './index';
@@ -8,7 +9,10 @@ import './index';
* real combination instead of mocking the host.
*/
async function createWrapper({ sortKey = 'name', table = 'default' } = {}) {
- const thSort = await Shopware.Component.build('ft-th-sort');
+ const [thSort, realComponents] = await Promise.all([
+ Shopware.Component.build('ft-th-sort'),
+ froshComponents('ft-icon'),
+ ]);
const host = {
template: `
@@ -39,7 +43,8 @@ async function createWrapper({ sortKey = 'name', table = 'default' } = {}) {
return mount(host, {
global: {
- stubs: { 'ft-icon': true },
+ components: realComponents,
+ mocks: froshI18nMocks(),
},
});
}
@@ -87,12 +92,16 @@ describe('ft-th-sort', () => {
});
it('works without a sort host', async () => {
- const thSort = await Shopware.Component.build('ft-th-sort');
+ const [thSort, realComponents] = await Promise.all([
+ Shopware.Component.build('ft-th-sort'),
+ froshComponents('ft-icon'),
+ ]);
const wrapper = mount(thSort, {
props: { sortKey: 'name' },
slots: { default: 'Name' },
global: {
- stubs: { 'ft-icon': true },
+ components: realComponents,
+ mocks: froshI18nMocks(),
},
});
diff --git a/src/Resources/app/administration/src/module/frosh-tools/index.js b/src/Resources/app/administration/src/module/frosh-tools/index.js
index 5d99653d..6d9a0ece 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/index.js
+++ b/src/Resources/app/administration/src/module/frosh-tools/index.js
@@ -1,6 +1,7 @@
import './component/ft-icon';
import './component/ft-button';
import './component/ft-modal';
+import './component/ft-command-palette';
import './component/ft-page-head';
import './component/ft-panel';
import './component/ft-pill';
diff --git a/src/Resources/app/administration/src/module/frosh-tools/page/index/index.js b/src/Resources/app/administration/src/module/frosh-tools/page/index/index.js
index 731b7dec..806aa088 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/page/index/index.js
+++ b/src/Resources/app/administration/src/module/frosh-tools/page/index/index.js
@@ -60,6 +60,7 @@ Component.register('frosh-tools-index', {
data() {
return {
searchTerm: '',
+ commandPaletteOpen: false,
};
},
@@ -79,9 +80,11 @@ Component.register('frosh-tools-index', {
created() {
this.adminMenuStore('collapseSidebar');
+ document.addEventListener('keydown', this.onGlobalKeydown, true);
},
unmounted() {
+ document.removeEventListener('keydown', this.onGlobalKeydown, true);
this.adminMenuStore('expandSidebar');
},
@@ -176,6 +179,19 @@ Component.register('frosh-tools-index', {
{ labelKey: 'frosh-tools.nav.cdn', items: cdn },
];
},
+
+ commandPaletteShortcut() {
+ const platform =
+ typeof navigator !== 'undefined'
+ ? navigator.platform || navigator.userAgent || ''
+ : '';
+
+ if (/Mac|iPhone|iPad|iPod/i.test(platform)) {
+ return '⌘K';
+ }
+
+ return 'Ctrl K';
+ },
},
methods: {
@@ -183,6 +199,29 @@ Component.register('frosh-tools-index', {
this.searchTerm = term;
},
+ openCommandPalette() {
+ this.commandPaletteOpen = true;
+ },
+
+ closeCommandPalette() {
+ this.commandPaletteOpen = false;
+ },
+
+ onGlobalKeydown(event) {
+ // Ignore plain key presses inside editable fields except when the
+ // palette itself is open (it handles Escape/arrows on its own).
+ const isModifier = event.metaKey || event.ctrlKey;
+ const key = String(event.key || '').toLowerCase();
+
+ if (isModifier && key === 'k') {
+ // Only capture while FroshTools is the active page so we do not
+ // fight the rest of the admin when the user navigates away.
+ event.preventDefault();
+ event.stopPropagation();
+ this.commandPaletteOpen = !this.commandPaletteOpen;
+ }
+ },
+
adminMenuStore(action) {
// Pinia (Shopware 6.7+)
try {
diff --git a/src/Resources/app/administration/src/module/frosh-tools/page/index/template.twig b/src/Resources/app/administration/src/module/frosh-tools/page/index/template.twig
index f99424af..2f0d1b7d 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/page/index/template.twig
+++ b/src/Resources/app/administration/src/module/frosh-tools/page/index/template.twig
@@ -24,6 +24,22 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json b/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json
index edaa981c..b3429bc5 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json
+++ b/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json
@@ -478,6 +478,47 @@
"sidebar": {
"developerConsole": "Entwicklerkonsole"
},
+ "commandPalette": {
+ "trigger": "Befehle suchen\u2026",
+ "placeholder": "Befehl oder Seite eingeben\u2026",
+ "noResults": "Keine Befehle f\u00fcr \"{term}\"",
+ "actionBadge": "Ausf\u00fchren",
+ "confirmTitle": "Aktion best\u00e4tigen",
+ "confirm": "Best\u00e4tigen",
+ "cancel": "Abbrechen",
+ "hintNavigate": "navigieren",
+ "hintRun": "ausf\u00fchren",
+ "hintClose": "schlie\u00dfen",
+ "groups": {
+ "recent": "Zuletzt",
+ "navigate": "Gehe zu",
+ "cache": "Cache",
+ "queue": "Warteschlange",
+ "tasks": "Geplante Aufgaben"
+ },
+ "descriptions": {
+ "shopmon": "Diesen Shop mit dem Shopmon-Monitoring verbinden.",
+ "fastly": "Fastly-CDN-Cache f\u00fcr diesen Shop leeren."
+ },
+ "actions": {
+ "clearAllCaches": "Alle Cache-Pools leeren",
+ "clearAllCachesDescription": "Jeden gemeldeten Cache-Pool der Reihe nach leeren.",
+ "clearAllCachesConfirm": "Dadurch werden alle vom Cache-Manager gemeldeten Pools geleert. Fortfahren?",
+ "clearAllCachesSuccess": "{count} Cache-Pool(s) geleert",
+ "clearHttpCache": "HTTP-Cache leeren",
+ "clearHttpCacheDescription": "HTTP-/Reverse-Proxy-Cache leeren, falls vorhanden.",
+ "clearObjectCache": "Objekt-/App-Cache leeren",
+ "clearObjectCacheDescription": "Anwendungs-Objektcache leeren, falls vorhanden.",
+ "clearOpcacheDescription": "PHP-OPcache f\u00fcr diesen Runtime zur\u00fccksetzen.",
+ "compileThemeDescription": "Storefront-Themes f\u00fcr alle Verkaufskan\u00e4le neu kompilieren.",
+ "compileThemeNone": "Kein Verkaufskanal mit zugewiesenem Theme gefunden.",
+ "resetQueueDescription": "Alle ausstehenden Messenger-Nachrichten aus allen Transporten entfernen.",
+ "resetQueueConfirm": "Dadurch werden alle ausstehenden Warteschlangennachrichten endg\u00fcltig gel\u00f6scht. Fortfahren?",
+ "registerTasks": "Geplante Aufgaben registrieren",
+ "registerTasksDescription": "Geplante Aufgaben aus Core und aktiven Plugins neu registrieren.",
+ "cachePoolNotFound": "Kein Cache-Pool gefunden f\u00fcr: {names}"
+ }
+ },
"nav": {
"overview": "Übersicht",
"performance": "Performance",
diff --git a/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json b/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json
index bc2ca433..ae5ab014 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json
+++ b/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json
@@ -478,6 +478,47 @@
"sidebar": {
"developerConsole": "Developer Console"
},
+ "commandPalette": {
+ "trigger": "Search commands\u2026",
+ "placeholder": "Type a command or page\u2026",
+ "noResults": "No commands match \"{term}\"",
+ "actionBadge": "Run",
+ "confirmTitle": "Confirm action",
+ "confirm": "Confirm",
+ "cancel": "Cancel",
+ "hintNavigate": "to navigate",
+ "hintRun": "to run",
+ "hintClose": "to close",
+ "groups": {
+ "recent": "Recent",
+ "navigate": "Go to",
+ "cache": "Cache",
+ "queue": "Queue",
+ "tasks": "Scheduled tasks"
+ },
+ "descriptions": {
+ "shopmon": "Connect this shop to the Shopmon monitoring dashboard.",
+ "fastly": "Purge Fastly CDN cache for this shop."
+ },
+ "actions": {
+ "clearAllCaches": "Clear all cache pools",
+ "clearAllCachesDescription": "Iterate every reported cache pool and clear it.",
+ "clearAllCachesConfirm": "This clears every cache pool reported by the cache manager. Continue?",
+ "clearAllCachesSuccess": "Cleared {count} cache pool(s)",
+ "clearHttpCache": "Clear HTTP cache",
+ "clearHttpCacheDescription": "Clear the HTTP / reverse-proxy cache pool if present.",
+ "clearObjectCache": "Clear object / app cache",
+ "clearObjectCacheDescription": "Clear the application object cache pool if present.",
+ "clearOpcacheDescription": "Reset PHP OPcache for this runtime.",
+ "compileThemeDescription": "Recompile storefront themes for all sales channels.",
+ "compileThemeNone": "No sales channel with an assigned theme was found.",
+ "resetQueueDescription": "Remove all pending messenger messages from every transport.",
+ "resetQueueConfirm": "This permanently deletes every pending queue message. Continue?",
+ "registerTasks": "Register scheduled tasks",
+ "registerTasksDescription": "Re-register scheduled tasks from all active plugins and core.",
+ "cachePoolNotFound": "No cache pool matching: {names}"
+ }
+ },
"nav": {
"overview": "Overview",
"performance": "Performance",
diff --git a/src/Resources/app/administration/src/module/frosh-tools/styles/design-system.scss b/src/Resources/app/administration/src/module/frosh-tools/styles/design-system.scss
index 345906d1..25cfbdcc 100644
--- a/src/Resources/app/administration/src/module/frosh-tools/styles/design-system.scss
+++ b/src/Resources/app/administration/src/module/frosh-tools/styles/design-system.scss
@@ -179,6 +179,67 @@ body.dark .ft {
}
}
+ &__command {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ margin: 0 0 14px;
+ padding: 8px 10px;
+ border: 1px solid var(--ft-border);
+ border-radius: var(--ft-radius-sm);
+ background: var(--ft-surface-2);
+ color: var(--ft-text-muted);
+ font: inherit;
+ font-size: 12.5px;
+ cursor: pointer;
+ transition:
+ background var(--ft-transition),
+ border-color var(--ft-transition),
+ color var(--ft-transition);
+
+ &:hover {
+ background: var(--ft-surface-hover);
+ border-color: var(--ft-border-strong);
+ color: var(--ft-text-soft);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--ft-accent);
+ outline-offset: 1px;
+ }
+
+ @media (max-width: 540px) {
+ display: none;
+ }
+ }
+
+ &__command-label {
+ flex: 1;
+ min-width: 0;
+ text-align: left;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__command-kbd {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 28px;
+ height: 20px;
+ padding: 0 5px;
+ border-radius: 4px;
+ border: 1px solid var(--ft-border);
+ background: var(--ft-surface);
+ color: var(--ft-text-soft);
+ font-family: var(--ft-mono);
+ font-size: 10.5px;
+ line-height: 1;
+ }
+
&__group {
margin-bottom: 14px;
diff --git a/src/Resources/app/administration/test/mount.js b/src/Resources/app/administration/test/mount.js
new file mode 100644
index 00000000..3b99785e
--- /dev/null
+++ b/src/Resources/app/administration/test/mount.js
@@ -0,0 +1,182 @@
+/**
+ * Integration mount helper for the plugin's administration components.
+ *
+ * Mounts components with the real plugin component tree (all ft-*
+ * design-system components) and real translations resolved from the
+ * plugin's own snippet files through vue-i18n. Only system boundaries
+ * (HTTP services, repositories, router) are left to the specs to fake.
+ */
+import { mount } from '@vue/test-utils';
+
+import '../src/mixin/sortable-table';
+import '../src/module/frosh-tools/component/ft-icon';
+import '../src/module/frosh-tools/component/ft-button';
+import '../src/module/frosh-tools/component/ft-modal';
+import '../src/module/frosh-tools/component/ft-page-head';
+import '../src/module/frosh-tools/component/ft-panel';
+import '../src/module/frosh-tools/component/ft-pill';
+import '../src/module/frosh-tools/component/ft-empty';
+import '../src/module/frosh-tools/component/ft-hero-state';
+import '../src/module/frosh-tools/component/ft-refresh-button';
+import '../src/module/frosh-tools/component/ft-th-sort';
+
+const { join } = require('path');
+const { existsSync } = require('fs');
+
+const { createI18n } = require(join(
+ process.env.ADMIN_PATH,
+ 'node_modules',
+ 'vue-i18n'
+));
+
+const pluginSnippetsEnGB = require('../src/module/frosh-tools/snippet/en-GB.json');
+const pluginSnippetsDeDE = require('../src/module/frosh-tools/snippet/de-DE.json');
+
+/**
+ * Core app snippets (global.default.* etc.). The file was renamed in
+ * Shopware 6.7, so try both names and simply skip when unavailable —
+ * missing keys then render as the key itself, like in the real app.
+ */
+function loadCoreSnippets() {
+ const snippetDir = join(process.env.ADMIN_PATH, 'src', 'app', 'snippet');
+
+ for (const fileName of ['en.json', 'en-GB.json']) {
+ const filePath = join(snippetDir, fileName);
+ if (existsSync(filePath)) {
+ // eslint-disable-next-line import/no-dynamic-require
+ return require(filePath);
+ }
+ }
+
+ return {};
+}
+
+/**
+ * Deep merge: the plugin also overrides single core keys (e.g.
+ * global.entities), a shallow spread would drop the whole core subtree.
+ */
+function deepMerge(target, source) {
+ const result = { ...target };
+
+ Object.entries(source).forEach(([key, value]) => {
+ if (
+ value !== null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ typeof result[key] === 'object' &&
+ result[key] !== null
+ ) {
+ result[key] = deepMerge(result[key], value);
+ } else {
+ result[key] = value;
+ }
+ });
+
+ return result;
+}
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en-GB',
+ fallbackLocale: 'en-GB',
+ missingWarn: false,
+ fallbackWarn: false,
+ messages: {
+ 'en-GB': deepMerge(loadCoreSnippets(), pluginSnippetsEnGB),
+ 'de-DE': pluginSnippetsDeDE,
+ },
+});
+
+/**
+ * $t/$tc backed by the real snippet catalog and the real vue-i18n
+ * interpolation/pluralization engine. Wired as mocks because Shopware
+ * itself also bridges $t onto the component instance instead of using
+ * vue-i18n's global injection.
+ */
+export function froshI18nMocks() {
+ return {
+ $t: (key, ...args) => i18n.global.t(key, ...args),
+ $tc: (key, ...args) => i18n.global.t(key, ...args),
+ };
+}
+
+const FROSH_COMPONENTS = [
+ 'ft-icon',
+ 'ft-button',
+ 'ft-modal',
+ 'ft-page-head',
+ 'ft-panel',
+ 'ft-pill',
+ 'ft-empty',
+ 'ft-hero-state',
+ 'ft-refresh-button',
+ 'ft-th-sort',
+];
+
+/** Builds real component definitions keyed by their tag name. */
+export async function froshComponents(...names) {
+ const components = {};
+ const built = await Promise.all(
+ names.map((name) => Shopware.Component.build(name))
+ );
+
+ names.forEach((name, index) => {
+ components[name] = built[index];
+ });
+
+ return components;
+}
+
+/**
+ * Mounts a registered plugin component together with the real ft-*
+ * component tree and real translations. Pass `provide`/`mocks` only for
+ * system boundaries (services, repositories, router) and `stubs` for
+ * Shopware core components that are not under test.
+ */
+export async function mountFrosh(
+ name,
+ {
+ props,
+ slots,
+ provide = {},
+ mocks = {},
+ stubs = {},
+ components = {},
+ directives = {},
+ attachTo,
+ data,
+ } = {}
+) {
+ const [component, realChildren] = await Promise.all([
+ Shopware.Component.build(name),
+ froshComponents(
+ ...FROSH_COMPONENTS.filter((childName) => childName !== name)
+ ),
+ ]);
+
+ return mount(component, {
+ props,
+ slots,
+ attachTo,
+ data,
+ global: {
+ provide,
+ mocks: {
+ ...froshI18nMocks(),
+ ...mocks,
+ },
+ directives: {
+ tooltip: {},
+ ...directives,
+ },
+ stubs: {
+ teleport: true,
+ ...stubs,
+ },
+ components: {
+ ...realChildren,
+ ...components,
+ },
+ },
+ });
+}
diff --git a/src/Resources/app/administration/test/resolve-admin-path.js b/src/Resources/app/administration/test/resolve-admin-path.js
new file mode 100644
index 00000000..d573b804
--- /dev/null
+++ b/src/Resources/app/administration/test/resolve-admin-path.js
@@ -0,0 +1,49 @@
+/**
+ * Locates the Shopware administration app of the surrounding installation.
+ *
+ * The plugin has no test tooling of its own — the runner, Vue and the test
+ * harness all come from the Shopware installation the plugin is installed in.
+ * Works with the development template (src/Administration/…) and the
+ * production template (vendor/shopware/administration/…).
+ */
+const { existsSync } = require('fs');
+const { dirname, join } = require('path');
+
+const RELATIVE_ADMIN_PATHS = [
+ 'src/Administration/Resources/app/administration',
+ 'vendor/shopware/administration/Resources/app/administration',
+];
+
+function isAdminPath(candidate) {
+ return existsSync(join(candidate, 'jest.config.js'));
+}
+
+function resolveAdminPath() {
+ if (process.env.ADMIN_PATH && isAdminPath(process.env.ADMIN_PATH)) {
+ return process.env.ADMIN_PATH;
+ }
+
+ // Walk up from this file until a Shopware installation root is found.
+ let dir = __dirname;
+ for (;;) {
+ const match = RELATIVE_ADMIN_PATHS.map((relative) =>
+ join(dir, relative)
+ ).find(isAdminPath);
+
+ if (match) {
+ return match;
+ }
+
+ const parent = dirname(dir);
+ if (parent === dir) {
+ throw new Error(
+ 'Could not locate a Shopware administration. Install the plugin into a Shopware ' +
+ 'installation (custom/plugins) or set the ADMIN_PATH environment variable to the ' +
+ 'administration app (the directory containing its jest.config.js).'
+ );
+ }
+ dir = parent;
+ }
+}
+
+module.exports = resolveAdminPath;
diff --git a/src/Resources/app/administration/test/run.js b/src/Resources/app/administration/test/run.js
new file mode 100644
index 00000000..42a5b6b8
--- /dev/null
+++ b/src/Resources/app/administration/test/run.js
@@ -0,0 +1,57 @@
+/**
+ * Runs the plugin's administration unit tests with the jest installation of
+ * the surrounding Shopware installation. All arguments are passed through to
+ * jest, e.g. `npm run unit -- --watch` or `npm run unit -- --coverage`.
+ */
+const { existsSync } = require('fs');
+const { join } = require('path');
+const { spawnSync } = require('child_process');
+const resolveAdminPath = require('./resolve-admin-path');
+
+const adminPath = resolveAdminPath();
+
+// Shopware's jest config refuses to run without the generated component
+// import map, so make sure it exists before handing over to jest. Older
+// Shopware versions neither generate nor require it.
+const componentImports = join(
+ adminPath,
+ 'test',
+ '_helper_',
+ 'componentWrapper',
+ 'component-imports.js'
+);
+const adminPackage = require(join(adminPath, 'package.json'));
+const hasGeneratorScript = Boolean(
+ adminPackage.scripts &&
+ adminPackage.scripts['generate-component-import-resolver-map']
+);
+
+if (!existsSync(componentImports) && hasGeneratorScript) {
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
+ const setup = spawnSync(
+ npm,
+ ['run', 'generate-component-import-resolver-map'],
+ { cwd: adminPath, stdio: 'inherit' }
+ );
+
+ if (setup.status !== 0) {
+ process.exit(setup.status ?? 1);
+ }
+}
+
+const result = spawnSync(
+ process.execPath,
+ [
+ join(adminPath, 'node_modules', 'jest', 'bin', 'jest.js'),
+ '--config',
+ join(__dirname, '..', 'jest.config.js'),
+ ...process.argv.slice(2),
+ ],
+ {
+ cwd: adminPath,
+ stdio: 'inherit',
+ env: { ...process.env, ADMIN_PATH: adminPath },
+ }
+);
+
+process.exit(result.status ?? 1);