diff --git a/apps/admin-component-tests/src/__tests__/__snapshots__/exports.spec.ts.snap b/apps/admin-component-tests/src/__tests__/__snapshots__/exports.spec.ts.snap index 34e4a8227..2b6407e73 100644 --- a/apps/admin-component-tests/src/__tests__/__snapshots__/exports.spec.ts.snap +++ b/apps/admin-component-tests/src/__tests__/__snapshots__/exports.spec.ts.snap @@ -69,6 +69,7 @@ exports[`exports > exports from index.ts 1`] = ` "defineAction", "deleteAccountAction", "doMutate", + "downloadLogsAction", "executeAction", "executeAdminComponentTest", "executeAfterHandle", @@ -116,6 +117,7 @@ exports[`exports > exports from index.ts 1`] = ` "useDeleteAccountMutation", "useDeleteShipmentsMutation", "useDeleteWebhooksMutation", + "useDownloadLogsMutation", "useDropOffInputContext", "useDropdownData", "useElement", diff --git a/apps/admin-js/src/__tests__/__snapshots__/exports.spec.ts.snap b/apps/admin-js/src/__tests__/__snapshots__/exports.spec.ts.snap index e05e06ed1..383e0f68a 100644 --- a/apps/admin-js/src/__tests__/__snapshots__/exports.spec.ts.snap +++ b/apps/admin-js/src/__tests__/__snapshots__/exports.spec.ts.snap @@ -68,6 +68,7 @@ exports[`exports > exports from index.ts 1`] = ` "defineAction", "deleteAccountAction", "doMutate", + "downloadLogsAction", "executeAction", "executeAfterHandle", "executeBeforeHandle", @@ -113,6 +114,7 @@ exports[`exports > exports from index.ts 1`] = ` "useDeleteAccountMutation", "useDeleteShipmentsMutation", "useDeleteWebhooksMutation", + "useDownloadLogsMutation", "useDropOffInputContext", "useDropdownData", "useElement", diff --git a/apps/admin/src/__tests__/__snapshots__/exports.spec.ts.snap b/apps/admin/src/__tests__/__snapshots__/exports.spec.ts.snap index e05e06ed1..383e0f68a 100644 --- a/apps/admin/src/__tests__/__snapshots__/exports.spec.ts.snap +++ b/apps/admin/src/__tests__/__snapshots__/exports.spec.ts.snap @@ -68,6 +68,7 @@ exports[`exports > exports from index.ts 1`] = ` "defineAction", "deleteAccountAction", "doMutate", + "downloadLogsAction", "executeAction", "executeAfterHandle", "executeBeforeHandle", @@ -113,6 +114,7 @@ exports[`exports > exports from index.ts 1`] = ` "useDeleteAccountMutation", "useDeleteShipmentsMutation", "useDeleteWebhooksMutation", + "useDownloadLogsMutation", "useDropOffInputContext", "useDropdownData", "useElement", diff --git a/apps/admin/src/__tests__/mocks/mockDefaultPlatform.ts b/apps/admin/src/__tests__/mocks/mockDefaultPlatform.ts index 807bbb888..25cc71fd7 100644 --- a/apps/admin/src/__tests__/mocks/mockDefaultPlatform.ts +++ b/apps/admin/src/__tests__/mocks/mockDefaultPlatform.ts @@ -1,10 +1,14 @@ import {vi} from 'vitest'; +import {type GlobalAdminContext} from '../../types'; -export const mockDefaultPlatform = vi.fn(() => ({ - backofficeUrl: 'https://backoffice.test.myparcel.nl', - defaultCarrier: 'postnl', - defaultCarrierId: 1, - human: 'Test', - localCountry: 'NL', - name: 'test', -})); +export const mockDefaultPlatform = vi.fn((): GlobalAdminContext['platform'] => { + return { + backofficeUrl: 'https://backoffice.test.myparcel.nl', + defaultCarrier: 'postnl', + defaultCarrierId: 1, + human: 'Test', + localCountry: 'NL', + name: 'test', + supportUrl: 'https://developer.myparcel.nl/contact', + }; +}); diff --git a/apps/admin/src/__tests__/utils/mockLinkElement.ts b/apps/admin/src/__tests__/utils/mockLinkElement.ts new file mode 100644 index 000000000..3e879f09c --- /dev/null +++ b/apps/admin/src/__tests__/utils/mockLinkElement.ts @@ -0,0 +1,20 @@ +import {vi} from 'vitest'; + +export const mockLinkElement = () => { + const createElementSpy = vi.spyOn(document, 'createElement'); + const appendChildSpy = vi.spyOn(document.body, 'appendChild'); + + const mockElement = document.createElement('a'); + + mockElement.setAttribute = vi.fn(); + mockElement.click = vi.fn(); + mockElement.remove = vi.fn(); + + createElementSpy.mockReturnValue(mockElement); + + return { + createElementSpy, + appendChildSpy, + mockElement, + }; +}; diff --git a/apps/admin/src/actions/composables/mutations/debug/index.ts b/apps/admin/src/actions/composables/mutations/debug/index.ts new file mode 100644 index 000000000..af2c76b29 --- /dev/null +++ b/apps/admin/src/actions/composables/mutations/debug/index.ts @@ -0,0 +1 @@ +export * from './useDownloadLogsMutation'; diff --git a/apps/admin/src/actions/composables/mutations/debug/useDownloadLogsMutation.ts b/apps/admin/src/actions/composables/mutations/debug/useDownloadLogsMutation.ts new file mode 100644 index 000000000..8becee121 --- /dev/null +++ b/apps/admin/src/actions/composables/mutations/debug/useDownloadLogsMutation.ts @@ -0,0 +1,8 @@ +import {BackendEndpoint} from '@myparcel-pdk/common'; +import {usePdkMutation} from '../usePdkMutation'; +import {type ResolvedQuery} from '../../../../stores'; +import {usePdkAdminApi} from '../../../../sdk'; + +export const useDownloadLogsMutation = (): ResolvedQuery => { + return usePdkMutation(BackendEndpoint.DownloadLogs, () => usePdkAdminApi().downloadLogs()); +}; diff --git a/apps/admin/src/actions/composables/mutations/index.ts b/apps/admin/src/actions/composables/mutations/index.ts index 91e299af6..1a5fc0f09 100644 --- a/apps/admin/src/actions/composables/mutations/index.ts +++ b/apps/admin/src/actions/composables/mutations/index.ts @@ -1,4 +1,5 @@ export * from './account'; +export * from './debug'; export * from './orders'; export * from './settings'; export * from './shipments'; diff --git a/apps/admin/src/actions/definitions/debug.ts b/apps/admin/src/actions/definitions/debug.ts new file mode 100644 index 000000000..6296286a1 --- /dev/null +++ b/apps/admin/src/actions/definitions/debug.ts @@ -0,0 +1,19 @@ +import {BackendEndpoint} from '@myparcel-pdk/common'; +import {createMutationHandler} from '../executors'; +import {defineAction} from '../defineAction'; +import {downloadBlob} from '../../utils/downloadBlob'; +import {AdminAction, AdminIcon} from '../../data'; + +/** + * Download zip with logs. + */ +export const downloadLogsAction = defineAction({ + name: AdminAction.DownloadLogs, + icon: AdminIcon.Download, + label: 'action_download_logs', + handler: createMutationHandler(BackendEndpoint.DownloadLogs), + // @ts-expect-error todo + afterHandle(response) { + downloadBlob(response.response, 'logs.zip'); + }, +}); diff --git a/apps/admin/src/actions/definitions/index.ts b/apps/admin/src/actions/definitions/index.ts index a446125bc..1cc7642b1 100644 --- a/apps/admin/src/actions/definitions/index.ts +++ b/apps/admin/src/actions/definitions/index.ts @@ -1,5 +1,6 @@ export * from './account'; export * from './context'; +export * from './debug'; export * from './modal'; export * from './orders'; export * from './settings'; diff --git a/apps/admin/src/actions/print/openOrPrintPdf.ts b/apps/admin/src/actions/print/openOrPrintPdf.ts index e41001e2d..6458200be 100644 --- a/apps/admin/src/actions/print/openOrPrintPdf.ts +++ b/apps/admin/src/actions/print/openOrPrintPdf.ts @@ -2,7 +2,7 @@ import {isOfType} from '@myparcel/ts-utils'; import {type ActionContextWithResponse} from '../executors'; import {generateLabelFilename} from '../../utils'; import {type PdfDataResponse, type PrintAction} from '../../types'; -import {downloadPdf, openPdfInNewWindow} from '../../services'; +import {downloadFile, openPdfInNewWindow} from '../../services'; export const openOrPrintPdf = async ({ response, @@ -12,5 +12,5 @@ export const openOrPrintPdf = async ({ return openPdfInNewWindow(response.data); } - await downloadPdf(response.url, generateLabelFilename(parameters)); + await downloadFile(response.url, generateLabelFilename(parameters)); }; diff --git a/apps/admin/src/components/PluginSettings/AccountSettings.vue b/apps/admin/src/components/PluginSettings/AccountSettings.vue index 6067ec58d..21026370b 100644 --- a/apps/admin/src/components/PluginSettings/AccountSettings.vue +++ b/apps/admin/src/components/PluginSettings/AccountSettings.vue @@ -11,39 +11,29 @@ :button-wrapper="prefixComponent(AdminComponent.ButtonGroup)" :closeable="hasAccount" :initial-tab="!hasAccount" - :tabs="tabs"> - - + :tabs="tabs" /> diff --git a/apps/admin/src/components/PluginSettings/DebugOptions.vue b/apps/admin/src/components/PluginSettings/DebugOptions.vue new file mode 100644 index 000000000..8d85beb37 --- /dev/null +++ b/apps/admin/src/components/PluginSettings/DebugOptions.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/admin/src/data/constants.ts b/apps/admin/src/data/constants.ts index d619a99ab..7e7a249b1 100644 --- a/apps/admin/src/data/constants.ts +++ b/apps/admin/src/data/constants.ts @@ -69,4 +69,6 @@ export enum AdminAction { WebhooksCreate = 'webhooksCreate', WebhooksDelete = 'webhooksDelete', WebhooksFetch = 'webhooksFetch', + + DownloadLogs = 'downloadLogs', } diff --git a/apps/admin/src/data/endpoints.ts b/apps/admin/src/data/endpoints.ts index 2db33ca85..0ff99f924 100644 --- a/apps/admin/src/data/endpoints.ts +++ b/apps/admin/src/data/endpoints.ts @@ -20,3 +20,5 @@ export const BACKEND_ENDPOINTS_WEBHOOKS = [ BackendEndpoint.DeleteWebhooks, BackendEndpoint.FetchWebhooks, ] as const; + +export const BACKEND_ENDPOINTS_DEBUG = [BackendEndpoint.DownloadLogs] as const; diff --git a/apps/admin/src/services/actions/downloadFile.ts b/apps/admin/src/services/actions/downloadFile.ts new file mode 100644 index 000000000..c9bdfc799 --- /dev/null +++ b/apps/admin/src/services/actions/downloadFile.ts @@ -0,0 +1,16 @@ +import {downloadFileFromUrl} from '../../utils'; + +/** + * Try to get a file from an url. An error means the file is not ready yet. Retry until it is. + */ +export async function downloadFile(url: string, filename: string): Promise { + try { + downloadFileFromUrl(url, filename); + } catch (e) { + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + return downloadFile(url, filename); + } +} diff --git a/apps/admin/src/services/actions/downloadPdf.ts b/apps/admin/src/services/actions/downloadPdf.ts deleted file mode 100644 index 6c115ab1e..000000000 --- a/apps/admin/src/services/actions/downloadPdf.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {openUrl} from '../../utils'; - -/** - * Try to get the pdf url. An error means the pdf file is not ready yet. Retry until it is, then download the label. - */ -export async function downloadPdf(url: string, filename: string): Promise { - try { - openUrl(url, {download: filename}); - } catch (e) { - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - - return downloadPdf(url, filename); - } -} diff --git a/apps/admin/src/services/actions/index.ts b/apps/admin/src/services/actions/index.ts index 22dae0d82..2a7530e9b 100644 --- a/apps/admin/src/services/actions/index.ts +++ b/apps/admin/src/services/actions/index.ts @@ -1,4 +1,4 @@ export * from './createAction'; export * from './createActionContext'; -export * from './downloadPdf'; +export * from './downloadFile'; export * from './getActionIdentifier'; diff --git a/apps/admin/src/services/print/openPdfInNewWindow.ts b/apps/admin/src/services/print/openPdfInNewWindow.ts index 699d6102c..4cca25b50 100644 --- a/apps/admin/src/services/print/openPdfInNewWindow.ts +++ b/apps/admin/src/services/print/openPdfInNewWindow.ts @@ -1,10 +1,11 @@ -import {openUrl} from '../../utils'; +import {openUrlInNewTab} from '../../utils'; /** * Opens a new window with the given base64 encoded pdf. */ export const openPdfInNewWindow = async (pdf: string): Promise => { const blob = await (await fetch(`data:application/pdf;base64,${pdf}`)).blob(); + const url = URL.createObjectURL(blob); - openUrl(URL.createObjectURL(blob)); + openUrlInNewTab(url); }; diff --git a/apps/admin/src/types/actions/actions.types.ts b/apps/admin/src/types/actions/actions.types.ts index 0a387ea12..958c6162f 100644 --- a/apps/admin/src/types/actions/actions.types.ts +++ b/apps/admin/src/types/actions/actions.types.ts @@ -18,6 +18,7 @@ export interface AdminActionEndpointMap extends Record { + const url = URL.createObjectURL(blob); + + downloadFileFromUrl(url, filename); + + URL.revokeObjectURL(url); +}; diff --git a/apps/admin/src/utils/downloadFileFromUrl.spec.ts b/apps/admin/src/utils/downloadFileFromUrl.spec.ts new file mode 100644 index 000000000..a261064d6 --- /dev/null +++ b/apps/admin/src/utils/downloadFileFromUrl.spec.ts @@ -0,0 +1,29 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {mockLinkElement} from '../__tests__/utils/mockLinkElement'; +import {downloadFileFromUrl} from './downloadFileFromUrl'; + +/** + * @vitest-environment happy-dom + */ + +describe('downloadFileFromUrl', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a link with correct href and download attributes, clicks it, and removes it', () => { + const url = 'https://example.com/file.zip'; + const filename = 'file.zip'; + + const {createElementSpy, appendChildSpy, mockElement} = mockLinkElement(); + + downloadFileFromUrl(url, filename); + + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(mockElement.setAttribute).toHaveBeenCalledWith('href', url); + expect(mockElement.setAttribute).toHaveBeenCalledWith('download', filename); + expect(appendChildSpy).toHaveBeenCalledWith(mockElement); + expect(mockElement.click).toHaveBeenCalled(); + expect(mockElement.remove).toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/utils/downloadFileFromUrl.ts b/apps/admin/src/utils/downloadFileFromUrl.ts new file mode 100644 index 000000000..132888da1 --- /dev/null +++ b/apps/admin/src/utils/downloadFileFromUrl.ts @@ -0,0 +1,5 @@ +import {fakeLinkClick} from './fakeLinkClick'; + +export const downloadFileFromUrl = (url: string, filename: string): void => { + fakeLinkClick(url, {download: filename}); +}; diff --git a/apps/admin/src/utils/openUrl.ts b/apps/admin/src/utils/fakeLinkClick.ts similarity index 54% rename from apps/admin/src/utils/openUrl.ts rename to apps/admin/src/utils/fakeLinkClick.ts index eb3e68486..c78d88dfb 100644 --- a/apps/admin/src/utils/openUrl.ts +++ b/apps/admin/src/utils/fakeLinkClick.ts @@ -1,17 +1,17 @@ /** - * Opens a new tab with the given URL. Avoids popup blockers by creating a hidden link. + * Creates and clicks a link with the given URL. Avoids popup blockers by creating a hidden link element. */ -export const openUrl = (url: string, attributes: Record = {}): void => { +export const fakeLinkClick = (url: string, attributes: Record): void => { const link = document.createElement('a'); link.href = url; - link.target = '_blank'; Object.entries(attributes).forEach(([key, value]) => { link.setAttribute(key, value); }); document.body.appendChild(link); + link.click(); link.remove(); }; diff --git a/apps/admin/src/utils/index.ts b/apps/admin/src/utils/index.ts index 94f0dce23..18a3a8ac4 100644 --- a/apps/admin/src/utils/index.ts +++ b/apps/admin/src/utils/index.ts @@ -10,7 +10,7 @@ export * from './forms'; export * from './generateLabelFilename'; export * from './getOrderId'; export * from './getOrderShipmentIds'; -export * from './openUrl'; +export * from './openUrlInNewTab'; export * from './prefixComponent'; export * from './query'; export * from './resolveCarrier'; @@ -18,3 +18,6 @@ export * from './translations'; export * from './triStateToBoolean'; export * from './unprefixComponent'; export * from './validateId'; +export {downloadFileFromUrl} from './downloadFileFromUrl'; + +export {fakeLinkClick} from './fakeLinkClick'; diff --git a/apps/admin/src/utils/openUrlInNewTab.spec.ts b/apps/admin/src/utils/openUrlInNewTab.spec.ts new file mode 100644 index 000000000..e188227e9 --- /dev/null +++ b/apps/admin/src/utils/openUrlInNewTab.spec.ts @@ -0,0 +1,29 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {mockLinkElement} from '../__tests__/utils/mockLinkElement'; +import {openUrlInNewTab} from './openUrlInNewTab'; + +/** + * @vitest-environment happy-dom + */ + +describe('openUrlInNewTab', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a fake link, sets attributes, clicks it, and removes it', () => { + const {createElementSpy, appendChildSpy, mockElement} = mockLinkElement(); + + const url = 'https://example.com'; + + openUrlInNewTab(url); + + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(appendChildSpy).toHaveBeenCalledWith(mockElement); + expect(mockElement.setAttribute).toHaveBeenCalledWith('href', url); + expect(mockElement.setAttribute).toHaveBeenCalledWith('target', '_blank'); + expect(mockElement.setAttribute).toHaveBeenCalledWith('rel', 'noopener noreferrer'); + expect(mockElement.click).toHaveBeenCalledOnce(); + expect(mockElement.remove).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/admin/src/utils/openUrlInNewTab.ts b/apps/admin/src/utils/openUrlInNewTab.ts new file mode 100644 index 000000000..5af2bb6e6 --- /dev/null +++ b/apps/admin/src/utils/openUrlInNewTab.ts @@ -0,0 +1,5 @@ +import {fakeLinkClick} from './fakeLinkClick'; + +export const openUrlInNewTab = (url: string, attributes: Record = {}): void => { + fakeLinkClick(url, {target: '_blank', rel: 'noopener noreferrer', ...attributes}); +}; diff --git a/apps/backend-demo/data/db/context/dynamic.json b/apps/backend-demo/data/db/context/dynamic.json index a4df0d69e..561c96a75 100644 --- a/apps/backend-demo/data/db/context/dynamic.json +++ b/apps/backend-demo/data/db/context/dynamic.json @@ -121,6 +121,7 @@ "pluginSettings": { "account": { "id": "account", + "apiKey": "test_1234567890", "apiKeyValid": true }, "order": { diff --git a/apps/backend-demo/data/db/context/global.json b/apps/backend-demo/data/db/context/global.json index 0f109bb1a..fa8ddf5a9 100644 --- a/apps/backend-demo/data/db/context/global.json +++ b/apps/backend-demo/data/db/context/global.json @@ -190,6 +190,15 @@ }, "path": "", "property": "context" + }, + "downloadLogs": { + "headers": [], + "method": "GET", + "parameters": { + "action": "downloadLogs" + }, + "path": "", + "property": "logs" } }, "eventPing": "myparcel_pdk_ping", @@ -199,6 +208,7 @@ "platform": { "name": "myparcel", "human": "MyParcel", + "supportUrl": "https://developer.myparcel.nl/contact", "backofficeUrl": "https://backoffice.myparcel.nl", "localCountry": "NL", "defaultCarrier": "postnl", diff --git a/libs/common/src/data/endpoints.ts b/libs/common/src/data/endpoints.ts index a75cb8739..fae0e1d05 100644 --- a/libs/common/src/data/endpoints.ts +++ b/libs/common/src/data/endpoints.ts @@ -83,6 +83,11 @@ export enum BackendEndpoint { CreateWebhooks = 'createWebhooks', DeleteWebhooks = 'deleteWebhooks', FetchWebhooks = 'fetchWebhooks', + + /** + * Debug actions. + */ + DownloadLogs = 'downloadLogs', } /** diff --git a/libs/common/src/types/php-pdk.types.ts b/libs/common/src/types/php-pdk.types.ts index b9d0699cf..52c52be0c 100644 --- a/libs/common/src/types/php-pdk.types.ts +++ b/libs/common/src/types/php-pdk.types.ts @@ -417,6 +417,7 @@ export namespace Plugin { human: string; localCountry: string; name: string; + supportUrl: string; }; translations: Record; };