diff --git a/src/Resources/app/administration/src/global.d.ts b/src/Resources/app/administration/src/global.d.ts new file mode 100644 index 0000000..a3e60a4 --- /dev/null +++ b/src/Resources/app/administration/src/global.d.ts @@ -0,0 +1,20 @@ +// Shopware injects its Administration API as untyped globals at runtime; there's +// no official @shopware-ag type package for plugin authors to install against +// (per docs/research/admin-dump-manager-ui.md §4), so these are declared loosely +// as `any` — good enough for editor support, and the build (swc, type-stripping +// only, no tsc typecheck) doesn't need them at all. +declare const Shopware: any; + +interface Window { + Shopware: typeof Shopware; +} + +declare module '*.html.twig' { + const template: string; + export default template; +} + +declare module '*.json' { + const value: Record; + export default value; +} diff --git a/src/Resources/app/administration/src/main.js b/src/Resources/app/administration/src/main.ts similarity index 86% rename from src/Resources/app/administration/src/main.js rename to src/Resources/app/administration/src/main.ts index 682cf0e..b7cb9c0 100755 --- a/src/Resources/app/administration/src/main.js +++ b/src/Resources/app/administration/src/main.ts @@ -1,7 +1,7 @@ import DumpApiService from './service/dump-api.service'; import './module/aeon-dump-manager'; -Shopware.Application.addServiceProvider('aeonDumpManagerApiService', (container) => { +Shopware.Application.addServiceProvider('aeonDumpManagerApiService', (container: { loginService: unknown }) => { const initContainer = Shopware.Application.getContainer('init'); return new DumpApiService(initContainer.httpClient, container.loginService); diff --git a/src/Resources/app/administration/src/module/aeon-dump-manager/acl/index.js b/src/Resources/app/administration/src/module/aeon-dump-manager/acl/index.ts similarity index 100% rename from src/Resources/app/administration/src/module/aeon-dump-manager/acl/index.js rename to src/Resources/app/administration/src/module/aeon-dump-manager/acl/index.ts diff --git a/src/Resources/app/administration/src/module/aeon-dump-manager/index.js b/src/Resources/app/administration/src/module/aeon-dump-manager/index.js deleted file mode 100644 index 8e7ae07..0000000 --- a/src/Resources/app/administration/src/module/aeon-dump-manager/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import './acl'; -import './page/aeon-dump-manager-list'; -import deDE from './snippet/de-DE.json'; -import enGB from './snippet/en-GB.json'; - -Shopware.Module.register('aeon-dump-manager', { - type: 'plugin', - name: 'DumpManager', - title: 'aeon-dump-manager.general.mainMenuItemGeneral', - description: 'aeon-dump-manager.general.descriptionTextModule', - color: '#ff3d58', - icon: 'default-object-storage', - - snippets: { - 'de-DE': deDE, - 'en-GB': enGB, - }, - - routes: { - list: { - component: 'aeon-dump-manager-list', - path: 'list', - meta: { - parentPath: 'sw.settings.index.system', - privilege: 'aeon_dump_manager.viewer', - }, - }, - }, - - // Shown as a card on the Settings page's "System" group, next to - // Plugins/Cache — matches the locked ACL/menu-naming decision. Plugins - // cannot register a first-level main-menu entry. - settingsItem: { - group: 'system', - to: 'aeon.dump.manager.list', - icon: 'default-object-storage', - privilege: 'aeon_dump_manager.viewer', - }, -}); diff --git a/src/Resources/app/administration/src/module/aeon-dump-manager/index.ts b/src/Resources/app/administration/src/module/aeon-dump-manager/index.ts new file mode 100644 index 0000000..7a14eca --- /dev/null +++ b/src/Resources/app/administration/src/module/aeon-dump-manager/index.ts @@ -0,0 +1,53 @@ +import './acl'; +import './page/aeon-dump-manager-list'; +import deDE from './snippet/de-DE.json'; +import enGB from './snippet/en-GB.json'; + +Shopware.Module.register('aeon-dump-manager', { + type: 'plugin', + name: 'DumpManager', + title: 'aeon-dump-manager.general.mainMenuItemGeneral', + description: 'aeon-dump-manager.general.descriptionTextModule', + color: '#ff3d58', + icon: 'regular-database', + + snippets: { + 'de-DE': deDE, + 'en-GB': enGB, + }, + + routes: { + list: { + component: 'aeon-dump-manager-list', + path: 'list', + meta: { + parentPath: 'sw.settings.index.system', + privilege: 'aeon_dump_manager.viewer', + }, + }, + }, + + // Per https://developer.shopware.com/docs/v6.6/guides/plugins/plugins/administration/routing-navigation/add-menu-entry.html — + // plugins can't register a first-level main-menu entry, so this nests + // under the core 'sw-settings' navigation node. Clicking Settings in the + // main sidebar shows this as a sub-item, landing at #/sw/settings/. + navigation: [{ + id: 'aeon-dump-manager-list', + label: 'aeon-dump-manager.general.mainMenuItemGeneral', + color: '#ff3d58', + path: 'aeon.dump.manager.list', + icon: 'regular-database', + parent: 'sw-settings', + position: 100, + privilege: 'aeon_dump_manager.viewer', + }], + + // Also shown as a card on the Settings overview page's "System" group, + // next to Plugins/Cache — complements the navigation entry above. + settingsItem: { + group: 'system', + to: 'aeon.dump.manager.list', + icon: 'regular-database', + privilege: 'aeon_dump_manager.viewer', + }, +}); diff --git a/src/Resources/app/administration/src/module/aeon-dump-manager/page/aeon-dump-manager-list/index.js b/src/Resources/app/administration/src/module/aeon-dump-manager/page/aeon-dump-manager-list/index.ts similarity index 68% rename from src/Resources/app/administration/src/module/aeon-dump-manager/page/aeon-dump-manager-list/index.js rename to src/Resources/app/administration/src/module/aeon-dump-manager/page/aeon-dump-manager-list/index.ts index c51f8be..b2110e9 100644 --- a/src/Resources/app/administration/src/module/aeon-dump-manager/page/aeon-dump-manager-list/index.js +++ b/src/Resources/app/administration/src/module/aeon-dump-manager/page/aeon-dump-manager-list/index.ts @@ -1,13 +1,38 @@ import template from './aeon-dump-manager-list.html.twig'; +import type DumpApiService, { DumpFile, DumpJob } from '../../../../service/dump-api.service'; const { Component } = Shopware; +interface PendingJob { + status: string; + percent: number | null; + errorMessage?: string | null; +} + +interface ComponentData { + dumps: DumpFile[]; + isLoading: boolean; + pendingJob: PendingJob | null; + deleteFilename: string | null; + pollTimer: number | null; +} + +interface ComponentInstance extends ComponentData { + aeonDumpManagerApiService: DumpApiService; + $tc: (key: string, count?: number, values?: Record) => string; + createNotificationError: (options: { message: string }) => void; + createNotificationSuccess: (options: { message: string }) => void; + loadList: () => Promise; + stopPolling: () => void; + pollJobStatus: (jobId: string) => void; +} + Component.register('aeon-dump-manager-list', { template, inject: ['aeonDumpManagerApiService', 'acl'], - data() { + data(): ComponentData { return { dumps: [], isLoading: false, @@ -40,16 +65,16 @@ Component.register('aeon-dump-manager-list', { }, }, - created() { + created(this: ComponentInstance) { this.loadList(); }, - beforeDestroy() { + beforeDestroy(this: ComponentInstance) { this.stopPolling(); }, methods: { - loadList() { + loadList(this: ComponentInstance): Promise { this.isLoading = true; return this.aeonDumpManagerApiService.getList() @@ -61,22 +86,22 @@ Component.register('aeon-dump-manager-list', { }); }, - formatDatetime(value) { + formatDatetime(value: string): string { return new Date(value).toLocaleString(); }, - formatSize(bytes) { + formatSize(bytes: number): string { return Shopware.Utils.format.fileSize(bytes); }, - onCreate() { + onCreate(this: ComponentInstance) { this.pendingJob = { percent: 0, status: 'pending' }; this.aeonDumpManagerApiService.create() .then((response) => { this.pollJobStatus(response.jobId); }) - .catch((error) => { + .catch((error: Error) => { this.pendingJob = null; this.createNotificationError({ message: this.$tc('aeon-dump-manager.list.createFailed', 0, { error: error.message }), @@ -84,11 +109,11 @@ Component.register('aeon-dump-manager-list', { }); }, - pollJobStatus(jobId) { + pollJobStatus(this: ComponentInstance, jobId: string) { this.stopPolling(); this.pollTimer = window.setInterval(() => { - this.aeonDumpManagerApiService.getJobStatus(jobId).then((job) => { + this.aeonDumpManagerApiService.getJobStatus(jobId).then((job: DumpJob) => { if (job.status === 'done') { this.stopPolling(); this.pendingJob = null; @@ -101,7 +126,7 @@ Component.register('aeon-dump-manager-list', { this.stopPolling(); this.pendingJob = null; this.createNotificationError({ - message: this.$tc('aeon-dump-manager.list.createFailed', 0, { error: job.errorMessage }), + message: this.$tc('aeon-dump-manager.list.createFailed', 0, { error: job.errorMessage ?? '' }), }); return; } @@ -111,23 +136,23 @@ Component.register('aeon-dump-manager-list', { }, 2000); }, - stopPolling() { + stopPolling(this: ComponentInstance) { if (this.pollTimer !== null) { window.clearInterval(this.pollTimer); this.pollTimer = null; } }, - onRequestDelete(filename) { + onRequestDelete(this: ComponentInstance, filename: string) { this.deleteFilename = filename; }, - onCancelDelete() { + onCancelDelete(this: ComponentInstance) { this.deleteFilename = null; }, - onConfirmDelete() { - const filename = this.deleteFilename; + onConfirmDelete(this: ComponentInstance): Promise { + const filename = this.deleteFilename as string; this.deleteFilename = null; return this.aeonDumpManagerApiService.remove(filename) diff --git a/src/Resources/app/administration/src/service/dump-api.service.js b/src/Resources/app/administration/src/service/dump-api.service.js deleted file mode 100644 index 58f5cdf..0000000 --- a/src/Resources/app/administration/src/service/dump-api.service.js +++ /dev/null @@ -1,31 +0,0 @@ -const { ApiService } = Shopware.Classes; - -export default class DumpApiService extends ApiService { - constructor(httpClient, loginService, apiEndpoint = 'aeon-dump-manager') { - super(httpClient, loginService, apiEndpoint); - } - - getList() { - return this.httpClient - .get('/_action/aeon-dump-manager/dumps', { headers: this.getBasicHeaders() }) - .then((response) => ApiService.handleResponse(response)); - } - - create() { - return this.httpClient - .post('/_action/aeon-dump-manager/dumps', {}, { headers: this.getBasicHeaders() }) - .then((response) => ApiService.handleResponse(response)); - } - - remove(filename) { - return this.httpClient - .delete(`/_action/aeon-dump-manager/dumps/${encodeURIComponent(filename)}`, { headers: this.getBasicHeaders() }) - .then((response) => ApiService.handleResponse(response)); - } - - getJobStatus(jobId) { - return this.httpClient - .get(`/_action/aeon-dump-manager/dumps/jobs/${encodeURIComponent(jobId)}`, { headers: this.getBasicHeaders() }) - .then((response) => ApiService.handleResponse(response)); - } -} diff --git a/src/Resources/app/administration/src/service/dump-api.service.ts b/src/Resources/app/administration/src/service/dump-api.service.ts new file mode 100644 index 0000000..1f0ef0e --- /dev/null +++ b/src/Resources/app/administration/src/service/dump-api.service.ts @@ -0,0 +1,69 @@ +export interface DumpFile { + filename: string; + datetime: string; + sizeBytes: number; +} + +export interface DumpList { + dumps: DumpFile[]; +} + +export interface CreateDumpResponse { + jobId: string; +} + +export type DumpJobStatus = 'pending' | 'running' | 'done' | 'failed'; + +export interface DumpJob { + id: string; + status: DumpJobStatus; + percent: number | null; + filename: string | null; + errorMessage: string | null; +} + +// Shopware.Classes.ApiService has no official type package for plugin authors +// (per docs/research/admin-dump-manager-ui.md §4) — redeclaring the inherited +// member this class actually uses keeps everything below typed without one. +// `declare` is required here: a normal field declaration compiles to a real +// `this.httpClient = void 0` in the constructor, which runs *after* `super()` +// and silently clobbers the value the base class just set. +const { ApiService } = Shopware.Classes; + +export default class DumpApiService extends ApiService { + protected declare httpClient: { + get: (url: string, config?: unknown) => Promise; + post: (url: string, data: unknown, config?: unknown) => Promise; + delete: (url: string, config?: unknown) => Promise; + }; + + protected declare getBasicHeaders: (additionalHeaders?: Record) => Record; + + constructor(httpClient: unknown, loginService: unknown, apiEndpoint = 'aeon-dump-manager') { + super(httpClient, loginService, apiEndpoint); + } + + getList(): Promise { + return this.httpClient + .get('/_action/aeon-dump-manager/dumps', { headers: this.getBasicHeaders() }) + .then((response: unknown) => ApiService.handleResponse(response)); + } + + create(): Promise { + return this.httpClient + .post('/_action/aeon-dump-manager/dumps', {}, { headers: this.getBasicHeaders() }) + .then((response: unknown) => ApiService.handleResponse(response)); + } + + remove(filename: string): Promise { + return this.httpClient + .delete(`/_action/aeon-dump-manager/dumps/${encodeURIComponent(filename)}`, { headers: this.getBasicHeaders() }) + .then((response: unknown) => ApiService.handleResponse(response)); + } + + getJobStatus(jobId: string): Promise { + return this.httpClient + .get(`/_action/aeon-dump-manager/dumps/jobs/${encodeURIComponent(jobId)}`, { headers: this.getBasicHeaders() }) + .then((response: unknown) => ApiService.handleResponse(response)); + } +} diff --git a/src/Resources/app/administration/src/tsconfig.json b/src/Resources/app/administration/src/tsconfig.json new file mode 100644 index 0000000..a8b7109 --- /dev/null +++ b/src/Resources/app/administration/src/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "strict": false, + "noEmit": true, + "skipLibCheck": true, + "allowJs": true, + "esModuleInterop": true + }, + "include": ["**/*.ts", "global.d.ts"] +} diff --git a/src/Resources/public/administration/js/aeon-dump-manager.js b/src/Resources/public/administration/js/aeon-dump-manager.js new file mode 100644 index 0000000..303c280 --- /dev/null +++ b/src/Resources/public/administration/js/aeon-dump-manager.js @@ -0,0 +1 @@ +!function(){var e={74:function(){Shopware.Service("privileges").addPrivilegeMappingEntry({category:"permissions",parent:null,key:"aeon_dump_manager",roles:{viewer:{privileges:["aeon_dump_manager.viewer"],dependencies:[]},creator:{privileges:["aeon_dump_manager.creator"],dependencies:["aeon_dump_manager.viewer"]},deleter:{privileges:["aeon_dump_manager.deleter"],dependencies:["aeon_dump_manager.viewer"]}}})}},n={};function t(a){var i=n[a];if(void 0!==i)return i.exports;var r=n[a]={exports:{}};return e[a](r,r.exports,t),r.exports}t.p="bundles/aeondumpmanager/",window?.__sw__?.assetPath&&(t.p=window.__sw__.assetPath+"/bundles/aeondumpmanager/"),function(){"use strict";let{ApiService:e}=Shopware.Classes;class n extends e{constructor(e,n,t="aeon-dump-manager"){super(e,n,t)}getList(){return this.httpClient.get("/_action/aeon-dump-manager/dumps",{headers:this.getBasicHeaders()}).then(n=>e.handleResponse(n))}create(){return this.httpClient.post("/_action/aeon-dump-manager/dumps",{},{headers:this.getBasicHeaders()}).then(n=>e.handleResponse(n))}remove(n){return this.httpClient.delete(`/_action/aeon-dump-manager/dumps/${encodeURIComponent(n)}`,{headers:this.getBasicHeaders()}).then(n=>e.handleResponse(n))}getJobStatus(n){return this.httpClient.get(`/_action/aeon-dump-manager/dumps/jobs/${encodeURIComponent(n)}`,{headers:this.getBasicHeaders()}).then(n=>e.handleResponse(n))}}t(74);let{Component:a}=Shopware;a.register("aeon-dump-manager-list",{template:'{% block aeon_dump_manager_list %}\n\n \n\n \n\n \n\n{% endblock %}\n',inject:["aeonDumpManagerApiService","acl"],data(){return{dumps:[],isLoading:!1,pendingJob:null,deleteFilename:null,pollTimer:null}},computed:{columns(){return[{property:"datetime",label:this.$tc("aeon-dump-manager.list.columnDatetime"),allowResize:!0},{property:"filename",label:this.$tc("aeon-dump-manager.list.columnFilename"),allowResize:!0},{property:"sizeBytes",label:this.$tc("aeon-dump-manager.list.columnSize"),align:"right",allowResize:!0}]}},created(){this.loadList()},beforeDestroy(){this.stopPolling()},methods:{loadList(){return this.isLoading=!0,this.aeonDumpManagerApiService.getList().then(e=>{this.dumps=e.dumps}).finally(()=>{this.isLoading=!1})},formatDatetime(e){return new Date(e).toLocaleString()},formatSize(e){return Shopware.Utils.format.fileSize(e)},onCreate(){this.pendingJob={percent:0,status:"pending"},this.aeonDumpManagerApiService.create().then(e=>{this.pollJobStatus(e.jobId)}).catch(e=>{this.pendingJob=null,this.createNotificationError({message:this.$tc("aeon-dump-manager.list.createFailed",0,{error:e.message})})})},pollJobStatus(e){this.stopPolling(),this.pollTimer=window.setInterval(()=>{this.aeonDumpManagerApiService.getJobStatus(e).then(e=>{if("done"===e.status){this.stopPolling(),this.pendingJob=null,this.createNotificationSuccess({message:this.$tc("aeon-dump-manager.list.createSuccess")}),this.loadList();return}if("failed"===e.status){this.stopPolling(),this.pendingJob=null,this.createNotificationError({message:this.$tc("aeon-dump-manager.list.createFailed",0,{error:e.errorMessage??""})});return}this.pendingJob=e})},2e3)},stopPolling(){null!==this.pollTimer&&(window.clearInterval(this.pollTimer),this.pollTimer=null)},onRequestDelete(e){this.deleteFilename=e},onCancelDelete(){this.deleteFilename=null},onConfirmDelete(){let e=this.deleteFilename;return this.deleteFilename=null,this.aeonDumpManagerApiService.remove(e).then(()=>this.loadList())}}});var i=JSON.parse('{"aeon-dump-manager":{"general":{"mainMenuItemGeneral":"Dump-Verwaltung","descriptionTextModule":"SQL-Datenbank-Dumps erstellen, auflisten und l\xf6schen"},"list":{"columnFilename":"Name","columnDatetime":"Erstellt am","columnSize":"Gr\xf6\xdfe","buttonCreate":"Dump erstellen","buttonDelete":"L\xf6schen","confirmDeleteTitle":"Dump l\xf6schen?","confirmDeleteText":"M\xf6chten Sie \\"{filename}\\" wirklich l\xf6schen? Dies kann nicht r\xfcckg\xe4ngig gemacht werden.","empty":"Keine Dumps gefunden.","creating":"Dump wird erstellt…","createFailed":"Dump konnte nicht erstellt werden: {error}","createSuccess":"Dump erstellt."}}}'),r=JSON.parse('{"aeon-dump-manager":{"general":{"mainMenuItemGeneral":"Dump manager","descriptionTextModule":"Create, list and delete SQL database dumps"},"list":{"columnFilename":"Name","columnDatetime":"Created at","columnSize":"Size","buttonCreate":"Create dump","buttonDelete":"Delete","confirmDeleteTitle":"Delete dump?","confirmDeleteText":"Are you sure you want to delete \\"{filename}\\"? This cannot be undone.","empty":"No dumps found.","creating":"Creating dump…","createFailed":"Could not create dump: {error}","createSuccess":"Dump created."}}}');Shopware.Module.register("aeon-dump-manager",{type:"plugin",name:"DumpManager",title:"aeon-dump-manager.general.mainMenuItemGeneral",description:"aeon-dump-manager.general.descriptionTextModule",color:"#ff3d58",icon:"regular-database",snippets:{"de-DE":i,"en-GB":r},routes:{list:{component:"aeon-dump-manager-list",path:"list",meta:{parentPath:"sw.settings.index.system",privilege:"aeon_dump_manager.viewer"}}},navigation:[{id:"aeon-dump-manager-list",label:"aeon-dump-manager.general.mainMenuItemGeneral",color:"#ff3d58",path:"aeon.dump.manager.list",icon:"regular-database",parent:"sw-settings",position:100,privilege:"aeon_dump_manager.viewer"}],settingsItem:{group:"system",to:"aeon.dump.manager.list",icon:"regular-database",privilege:"aeon_dump_manager.viewer"}}),Shopware.Application.addServiceProvider("aeonDumpManagerApiService",e=>new n(Shopware.Application.getContainer("init").httpClient,e.loginService))}()}(); \ No newline at end of file