diff --git a/ui/src/__tests__/system-hook-view.test.js b/ui/src/__tests__/system-hook-view.test.js new file mode 100644 index 0000000..59946db --- /dev/null +++ b/ui/src/__tests__/system-hook-view.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { mount, flushPromises } from '@vue/test-utils' +import { VibrantDataTable, VibrantConfirmDialog, useI18nStore } from '@ligoj/host' +import SystemHookView from '../views/SystemHookView.vue' +import enMessages from '../i18n/en.js' + +// One server-paginated hook row (DataTables shape). `match` is the JSON +// string the view splits into the path / method columns. +const HOOK = { + id: 1, name: 'deploy', command: 'echo hi', workingDirectory: '/opt/app', + match: '{"path":"system/.*","method":"POST"}', inject: [], timeout: 10, delay: 0, +} + +function jsonResponse(body) { + return { + ok: true, status: 200, + headers: { get: () => 'application/json' }, + clone() { return jsonResponse(body) }, + json: async () => body, + text: async () => JSON.stringify(body), + } +} + +// Render-the-slot stubs so the header actions, table rows, dialogs and row +// menu are reachable from the test (Vuetify is externalized in the plugin +// build, hence the v-* stubs too). +const stubs = { + LjPageHeader: { template: '
' }, + LjSearch: { template: '' }, + LjButton: { emits: ['click'], template: '' }, + LjDialog: { props: ['modelValue'], template: '
' }, + LjAvailabilityField: { template: '
' }, + VibrantConfirmDialog: { props: ['modelValue'], emits: ['confirm'], template: '
' }, + RowActionsCog: { template: '
' }, + VibrantDataTable: { + props: ['headers', 'items', 'itemsLength', 'loading', 'defaultSort'], + template: '
', + }, + 'v-form': { template: '
' }, + 'v-select': true, 'v-text-field': true, 'v-combobox': true, 'v-icon': true, +} + +function mountView() { + return mount(SystemHookView, { global: { stubs } }) +} + +describe('SystemHookView', () => { + beforeEach(() => { + setActivePinia(createPinia()) + useI18nStore().merge(enMessages, 'en') + globalThis.fetch = vi.fn().mockResolvedValue( + jsonResponse({ recordsTotal: 1, recordsFiltered: 1, data: [HOOK] }), + ) + }) + + it('renders the data table with the expected columns', () => { + const w = mountView() + const table = w.findComponent(VibrantDataTable) + expect(table.exists()).toBe(true) + expect(table.props('headers').map((h) => h.key)).toEqual(['name', 'method', 'path', 'command', 'delay']) + expect(table.props('defaultSort')).toBe('name') + }) + + it('queries rest/system/hook (name asc) when the table requests options', async () => { + const w = mountView() + await w.findComponent(VibrantDataTable).vm.$emit('update:options', { page: 1, itemsPerPage: 25, sortBy: [] }) + await flushPromises() + + const url = globalThis.fetch.mock.calls[0][0] + expect(url).toContain('rest/system/hook') + expect(url).toContain('sidx=name') + }) + + it('opens the create dialog when clicking New', async () => { + const w = mountView() + expect(w.find('.ljdialog').exists()).toBe(false) + await w.find('.ljbtn').trigger('click') + expect(w.find('.ljdialog').exists()).toBe(true) + }) + + it('deletes a hook through the confirm dialog (DELETE on its id)', async () => { + const w = mountView() + // Populate the table, then open the row's delete confirm and confirm it. + await w.findComponent(VibrantDataTable).vm.$emit('update:options', { page: 1, itemsPerPage: 25, sortBy: [] }) + await flushPromises() + + await w.find('.cog button.danger').trigger('click') + await w.findComponent(VibrantConfirmDialog).vm.$emit('confirm') + await flushPromises() + + const delCall = globalThis.fetch.mock.calls.find((c) => c[1]?.method === 'DELETE') + expect(delCall).toBeTruthy() + expect(delCall[0]).toBe('rest/system/hook/1') + }) +}) diff --git a/ui/src/i18n/en.js b/ui/src/i18n/en.js index 98cb783..0b853a8 100644 --- a/ui/src/i18n/en.js +++ b/ui/src/i18n/en.js @@ -153,6 +153,39 @@ export default { 'system.config.sourcePrefix': 'Source: {source}', 'system.config.sourceOverridden': '{base} — overridden', + // System → Hooks + 'system.hook.title': 'Hooks', + 'system.hook.countLabel': 'hooks', + 'system.hook.searchPlaceholder': 'Search hooks…', + 'system.hook.new': 'New hook', + 'system.hook.headerName': 'Name', + 'system.hook.headerMethod': 'Method', + 'system.hook.headerPath': 'Path', + 'system.hook.headerCommand': 'Command', + 'system.hook.headerDelay': 'Execution', + 'system.hook.methodAll': 'ALL', + 'system.hook.sync': 'Sync', + 'system.hook.async': 'Async', + 'system.hook.editTitle': 'Edit hook', + 'system.hook.newTitle': 'New hook', + 'system.hook.deleteTitle': 'Delete hook', + 'system.hook.deleteConfirmBefore': 'Delete the hook ', + 'system.hook.deleteConfirmAfter': '?', + 'system.hook.fieldName': 'Name', + 'system.hook.fieldMethod': 'Method', + 'system.hook.fieldPath': 'Path (regular expression)', + 'system.hook.fieldCommand': 'Command', + 'system.hook.fieldWorkingDirectory': 'Working directory', + 'system.hook.fieldInject': 'Injected values', + 'system.hook.fieldTimeout': 'Timeout (s)', + 'system.hook.fieldDelay': 'Delay (s)', + 'system.hook.pathHint': 'Matched against the REST path, e.g. system/.*', + 'system.hook.workingDirectoryHint': 'No whitespace allowed', + 'system.hook.injectHint': 'Configuration / secret names to inject (press Enter to add)', + 'system.hook.timeoutHint': 'Empty = default (ligoj.hook.timeout)', + 'system.hook.delayHint': '0 = synchronous execution', + 'system.hook.noSpace': 'Whitespace is not allowed', + // System → Cache 'system.cache.title': 'Caches', 'system.cache.headerName': 'Cache', diff --git a/ui/src/i18n/fr.js b/ui/src/i18n/fr.js index d21f0b2..28d8493 100644 --- a/ui/src/i18n/fr.js +++ b/ui/src/i18n/fr.js @@ -152,6 +152,39 @@ export default { 'system.config.sourcePrefix': 'Source : {source}', 'system.config.sourceOverridden': '{base} — surchargée', + // Système → Hooks + 'system.hook.title': 'Hooks', + 'system.hook.countLabel': 'hooks', + 'system.hook.searchPlaceholder': 'Rechercher des hooks…', + 'system.hook.new': 'Nouveau hook', + 'system.hook.headerName': 'Nom', + 'system.hook.headerMethod': 'Méthode', + 'system.hook.headerPath': 'Chemin', + 'system.hook.headerCommand': 'Commande', + 'system.hook.headerDelay': 'Exécution', + 'system.hook.methodAll': 'TOUTES', + 'system.hook.sync': 'Synchrone', + 'system.hook.async': 'Asynchrone', + 'system.hook.editTitle': 'Modifier le hook', + 'system.hook.newTitle': 'Nouveau hook', + 'system.hook.deleteTitle': 'Supprimer le hook', + 'system.hook.deleteConfirmBefore': 'Supprimer le hook ', + 'system.hook.deleteConfirmAfter': ' ?', + 'system.hook.fieldName': 'Nom', + 'system.hook.fieldMethod': 'Méthode', + 'system.hook.fieldPath': 'Chemin (expression régulière)', + 'system.hook.fieldCommand': 'Commande', + 'system.hook.fieldWorkingDirectory': 'Répertoire de travail', + 'system.hook.fieldInject': 'Valeurs injectées', + 'system.hook.fieldTimeout': 'Délai d\'expiration (s)', + 'system.hook.fieldDelay': 'Délai (s)', + 'system.hook.pathHint': 'Comparé au chemin REST, ex. system/.*', + 'system.hook.workingDirectoryHint': 'Aucun espace autorisé', + 'system.hook.injectHint': 'Noms de configuration / secrets à injecter (Entrée pour ajouter)', + 'system.hook.timeoutHint': 'Vide = valeur par défaut (ligoj.hook.timeout)', + 'system.hook.delayHint': '0 = exécution synchrone', + 'system.hook.noSpace': 'Les espaces ne sont pas autorisés', + // Système → Cache 'system.cache.title': 'Caches', 'system.cache.headerName': 'Cache', diff --git a/ui/src/index.js b/ui/src/index.js index 2b3f00a..656c7e1 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -67,6 +67,7 @@ import SystemInfoView from './views/SystemInfoView.vue' import SystemUserLogView from './views/SystemUserLogView.vue' import ActuatorView from './views/ActuatorView.vue' import SystemConfigurationView from './views/SystemConfigurationView.vue' +import SystemHookView from './views/SystemHookView.vue' import SystemUserView from './views/SystemUserView.vue' import SystemRoleView from './views/SystemRoleView.vue' import SystemPluginView from './views/SystemPluginView.vue' @@ -108,6 +109,7 @@ const routes = [ { path: '/system/actuator', redirect: '/system/information/actuator/info' }, { path: '/system/logs', redirect: '/system/information/actuator/logfile' }, { path: '/system/configuration', name: 'ui-system-configuration', component: SystemConfigurationView }, + { path: '/system/hook', name: 'ui-system-hook', component: SystemHookView }, { path: '/system/user', name: 'ui-system-user', component: SystemUserView }, { path: '/system/role', name: 'ui-system-role', component: SystemRoleView }, { path: '/system/plugin', name: 'ui-system-plugin', component: SystemPluginView }, diff --git a/ui/src/views/SystemHookView.vue b/ui/src/views/SystemHookView.vue new file mode 100644 index 0000000..4ad4f8b --- /dev/null +++ b/ui/src/views/SystemHookView.vue @@ -0,0 +1,255 @@ + + + + + +