@myparcel-dev/pdk-admin 2.2.2 → 2.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/actions/print/waitForLabelPrompt.d.ts +8 -0
- package/dist/types/context.types.d.ts +3 -2
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/notificationBelongsToOrder.d.ts +6 -0
- package/package.json +1 -1
- package/src/actions/executors/executeHandler.spec.ts +99 -0
- package/src/actions/executors/executeHandler.ts +8 -1
- package/src/actions/print/waitForLabelPrompt.spec.ts +220 -0
- package/src/actions/print/waitForLabelPrompt.ts +56 -20
- package/src/services/actions/createActionContext.ts +3 -0
- package/src/services/createNotification.spec.ts +66 -0
- package/src/services/createNotification.ts +7 -3
- package/src/types/context.types.ts +5 -2
- package/src/utils/index.ts +1 -0
- package/src/utils/notificationBelongsToOrder.spec.ts +28 -0
- package/src/utils/notificationBelongsToOrder.ts +18 -0
- package/src/views/OrderListItem.vue +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- MONOWEAVE:BELOW -->
|
|
4
4
|
|
|
5
|
+
## [2.2.4](https://github.com/myparcelnl/js-pdk/compare/@myparcel-dev/pdk-admin@2.2.3...@myparcel-dev/pdk-admin@2.2.4) "@myparcel-dev/pdk-admin" (2026-09-22)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **print:** print on every press when the label prompt is enabled ([#446](https://github.com/myparcelnl/js-pdk/issues/446)) ([1d6e022](https://github.com/myparcelnl/js-pdk/commit/1d6e02225b61cd794396063dd1ea8d34870a1557))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## [2.2.3](https://github.com/myparcelnl/js-pdk/compare/@myparcel-dev/pdk-admin@2.2.2...@myparcel-dev/pdk-admin@2.2.3) "@myparcel-dev/pdk-admin" (2026-09-09)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **notifications:** show action errors and match bulk order tags ([#432](https://github.com/myparcelnl/js-pdk/issues/432)) ([81a476e](https://github.com/myparcelnl/js-pdk/commit/81a476ea092bc3b0bcdc7da03715b5b6a82362bc)), closes [myparcelnl/pdk#528](https://github.com/myparcelnl/pdk/issues/528)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
5
25
|
## [2.2.2](https://github.com/myparcelnl/js-pdk/compare/@myparcel-dev/pdk-admin@2.2.1...@myparcel-dev/pdk-admin@2.2.2) "@myparcel-dev/pdk-admin" (2026-08-19)
|
|
6
26
|
|
|
7
27
|
|
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
import { type ActionContext } from '../executors';
|
|
2
2
|
import { type ActionParameters, type PrintAction } from '../../types';
|
|
3
|
+
/**
|
|
4
|
+
* Ask the merchant for the label options before printing. Use this as the `beforeHandle` of a
|
|
5
|
+
* print action, which the print actions do whenever the `label.prompt` setting is on.
|
|
6
|
+
*
|
|
7
|
+
* @param context The action context. Its parameters are merged with the submitted values.
|
|
8
|
+
* @returns The print parameters, or a rejection with `StopActionHandler` when the merchant
|
|
9
|
+
* closes the modal instead of submitting it.
|
|
10
|
+
*/
|
|
3
11
|
export declare const waitForLabelPrompt: <A extends PrintAction>({ parameters, }: ActionContext<A>) => Promise<ActionParameters<A>>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Raw } from 'vue';
|
|
2
2
|
import { type FormInstance } from '@myparcel-dev/vue-form-builder';
|
|
3
3
|
import { type OneOrMore, type Replace } from '@myparcel-dev/ts-utils';
|
|
4
|
+
import { type AdminContextKey, type Plugin } from '@myparcel-dev/pdk-common';
|
|
4
5
|
import { type AdminInstanceContextKey, type AdminModalKey } from '../data';
|
|
5
6
|
import { type BackendPdkEndpointObject } from './endpoints.types';
|
|
6
7
|
export type AdminContextObject = Replace<Plugin.ModelContextContextBag, 'global', GlobalAdminContext> & Partial<AdminInstanceContext>;
|
|
@@ -12,7 +13,7 @@ export type AdminInstanceContext = {
|
|
|
12
13
|
[AdminInstanceContextKey.ProductIdentifier]: Plugin.ModelContextProductDataContext['externalIdentifier'];
|
|
13
14
|
};
|
|
14
15
|
type BaseModalContext = {
|
|
15
|
-
form?: FormInstance
|
|
16
|
+
form?: Raw<FormInstance>;
|
|
16
17
|
};
|
|
17
18
|
export type AdminModalContext<T extends AdminModalKey = AdminModalKey> = (BaseModalContext & (T extends AdminModalKey.ShipmentOptions ? {
|
|
18
19
|
orderIds?: OneOrMore<string>;
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './forms';
|
|
|
10
10
|
export * from './generateLabelFilename';
|
|
11
11
|
export * from './getOrderId';
|
|
12
12
|
export * from './getOrderShipmentIds';
|
|
13
|
+
export * from './notificationBelongsToOrder';
|
|
13
14
|
export * from './openUrlInNewTab';
|
|
14
15
|
export * from './prefixComponent';
|
|
15
16
|
export * from './query';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type PdkNotification } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* A backend notification is tagged with every order the action ran for, so an order list column
|
|
4
|
+
* matches on that list instead of on the whole tag.
|
|
5
|
+
*/
|
|
6
|
+
export declare const notificationBelongsToOrder: (notification: Pick<PdkNotification, "tags">, externalIdentifier?: string) => boolean;
|
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
4
|
+
import {ApiException} from '@myparcel-dev/sdk';
|
|
5
|
+
import {Variant} from '@myparcel-dev/pdk-common';
|
|
6
|
+
import {useNotificationStore} from '../../stores';
|
|
7
|
+
import {NotificationCategory} from '../../data';
|
|
8
|
+
import {doComponentTestSetup, doComponentTestTeardown} from '../../__tests__';
|
|
9
|
+
import {type ActionContext} from './types';
|
|
10
|
+
import {executeHandler} from './executeHandler';
|
|
11
|
+
|
|
12
|
+
const ERROR_NOTIFICATION = {
|
|
13
|
+
category: NotificationCategory.Action,
|
|
14
|
+
title: 'Something went wrong',
|
|
15
|
+
variant: Variant.Error,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const BACKEND_NOTIFICATION = {
|
|
19
|
+
category: NotificationCategory.Action,
|
|
20
|
+
content: ['Street is required.'],
|
|
21
|
+
title: 'The given data was invalid.',
|
|
22
|
+
variant: Variant.Error,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const createContext = (handler: () => Promise<unknown>): ActionContext =>
|
|
26
|
+
({
|
|
27
|
+
action: {name: 'ordersPrint', handler},
|
|
28
|
+
instance: {logger: {debug: vi.fn(), error: vi.fn()}},
|
|
29
|
+
notifications: {[Variant.Error]: ERROR_NOTIFICATION},
|
|
30
|
+
parameters: {},
|
|
31
|
+
} as unknown as ActionContext);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* PdkFetchClient adds the notifications of an error response to the store and then throws.
|
|
35
|
+
*/
|
|
36
|
+
const createApiException = (notifications?: unknown[]): ApiException => {
|
|
37
|
+
const store = useNotificationStore();
|
|
38
|
+
|
|
39
|
+
notifications?.forEach((notification) => store.add(notification as never));
|
|
40
|
+
|
|
41
|
+
return new ApiException({
|
|
42
|
+
errors: [{code: 400, message: 'Request failed.'}],
|
|
43
|
+
...(notifications ? {notifications} : {}),
|
|
44
|
+
} as never);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('executeHandler', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
doComponentTestSetup();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
doComponentTestTeardown();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reports a failing action to the user', async () => {
|
|
57
|
+
const context = createContext(() => Promise.reject(new Error('Printing failed')));
|
|
58
|
+
|
|
59
|
+
await executeHandler(context);
|
|
60
|
+
|
|
61
|
+
expect(useNotificationStore().notifications).toMatchObject([
|
|
62
|
+
{category: NotificationCategory.Action, content: 'Printing failed', variant: Variant.Error},
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not report a failure the backend already reported', async () => {
|
|
67
|
+
const store = useNotificationStore();
|
|
68
|
+
const context = createContext(() => Promise.reject(createApiException([BACKEND_NOTIFICATION])));
|
|
69
|
+
|
|
70
|
+
await executeHandler(context);
|
|
71
|
+
|
|
72
|
+
expect(store.notifications).toHaveLength(1);
|
|
73
|
+
expect(store.notifications[0].title).toBe('The given data was invalid.');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('reports a failure the backend did not report itself', async () => {
|
|
77
|
+
const store = useNotificationStore();
|
|
78
|
+
const context = createContext(() => Promise.reject(createApiException()));
|
|
79
|
+
|
|
80
|
+
await executeHandler(context);
|
|
81
|
+
|
|
82
|
+
expect(store.notifications).toMatchObject([{title: 'Something went wrong', variant: Variant.Error}]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('still reports the failure when an unrelated error notification is in the store', async () => {
|
|
86
|
+
const store = useNotificationStore();
|
|
87
|
+
|
|
88
|
+
const context = createContext(() => {
|
|
89
|
+
store.add({category: NotificationCategory.Api, content: 'Unrelated', variant: Variant.Error});
|
|
90
|
+
|
|
91
|
+
return Promise.reject(new Error('Printing failed'));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await executeHandler(context);
|
|
95
|
+
|
|
96
|
+
expect(store.notifications).toHaveLength(2);
|
|
97
|
+
expect(store.notifications[1]).toMatchObject({content: 'Printing failed', variant: Variant.Error});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {ApiException} from '@myparcel-dev/sdk';
|
|
1
2
|
import {type ActionResponse, type MaybeAdminAction} from '../../types';
|
|
2
3
|
import {useNotificationStore} from '../../stores';
|
|
3
4
|
import {NotificationCategory} from '../../data';
|
|
@@ -25,7 +26,13 @@ export async function executeHandler<A extends MaybeAdminAction>(
|
|
|
25
26
|
|
|
26
27
|
return response as ActionResponse<A>;
|
|
27
28
|
} catch (error) {
|
|
28
|
-
|
|
29
|
+
// The backend reports a failure through the notifications in its error response, which
|
|
30
|
+
// PdkFetchClient adds to the store. Only fall back to a generic message when the response
|
|
31
|
+
// carried none, for example because the request never reached the backend.
|
|
32
|
+
const reported =
|
|
33
|
+
error instanceof ApiException && Boolean((error.data as {notifications?: unknown[]}).notifications?.length);
|
|
34
|
+
|
|
35
|
+
if (!reported && notifications?.error && error instanceof Error) {
|
|
29
36
|
store.add({...notifications.error, timeout: false, content: error.message}, context.parameters);
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import {defineComponent, h, KeepAlive} from 'vue';
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
5
|
+
import {config, mount} from '@vue/test-utils';
|
|
6
|
+
import {useFormBuilder} from '@myparcel-dev/vue-form-builder';
|
|
7
|
+
import {useActionStore, useModalStore, useNotificationStore, useQueryStore} from '../../stores';
|
|
8
|
+
import {AdminAction, OrderMode} from '../../data';
|
|
9
|
+
import {useOrderMode} from '../../composables/context/useOrderMode';
|
|
10
|
+
import PrintOptionsModal from '../../components/modals/PrintOptionsModal.vue';
|
|
11
|
+
import {mockDefaultPrintOptionsView} from '../../__tests__/mocks/mockDefaultPrintOptionsView';
|
|
12
|
+
import {mockDefaultPluginSettings} from '../../__tests__/mocks/mockDefaultPluginSettings';
|
|
13
|
+
import {doComponentTestSetup, doComponentTestTeardown} from '../../__tests__';
|
|
14
|
+
|
|
15
|
+
const printOrders = vi.fn();
|
|
16
|
+
const updateShipments = vi.fn();
|
|
17
|
+
const fetchOrders = vi.fn();
|
|
18
|
+
const fetchContext = vi.fn();
|
|
19
|
+
|
|
20
|
+
vi.mock('../../sdk', async (importOriginal) => {
|
|
21
|
+
const actual = await importOriginal<typeof import('../../sdk')>();
|
|
22
|
+
|
|
23
|
+
return {...actual, usePdkAdminApi: () => ({printOrders, updateShipments, fetchOrders, fetchContext})};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
vi.mock('../../composables/context/useOrderMode', async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal<typeof import('../../composables/context/useOrderMode')>();
|
|
28
|
+
|
|
29
|
+
return {...actual, useOrderMode: vi.fn()};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Mirrors WcModal and Bootstrap4Modal: the modal builds a new form on every open. */
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
34
|
+
const RebuildingModalStub = defineComponent({
|
|
35
|
+
name: 'RebuildingModalStub',
|
|
36
|
+
props: {actions: {type: Array, default: () => []}, modalKey: {type: String, default: null}, title: String},
|
|
37
|
+
setup(props, {slots}) {
|
|
38
|
+
const modalStore = useModalStore();
|
|
39
|
+
|
|
40
|
+
return () => {
|
|
41
|
+
const isOpen = props.modalKey && modalStore.opened === props.modalKey;
|
|
42
|
+
|
|
43
|
+
return h('div', isOpen ? [h(KeepAlive, () => slots.default?.({context: modalStore.context}))] : []);
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** Mirrors DefaultModal, which hides its content instead of unmounting it, so one form is reused. */
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
50
|
+
const ReusingModalStub = defineComponent({
|
|
51
|
+
name: 'ReusingModalStub',
|
|
52
|
+
props: {actions: {type: Array, default: () => []}, modalKey: {type: String, default: null}, title: String},
|
|
53
|
+
setup(props, {slots}) {
|
|
54
|
+
const modalStore = useModalStore();
|
|
55
|
+
|
|
56
|
+
return () => h('div', [slots.default?.({context: modalStore.context})]);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const ORDER_A = 'ORDER-A';
|
|
61
|
+
const ORDER_B = 'ORDER-B';
|
|
62
|
+
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
64
|
+
const RowHost = defineComponent({
|
|
65
|
+
setup() {
|
|
66
|
+
const queryStore = useQueryStore();
|
|
67
|
+
const actionStore = useActionStore();
|
|
68
|
+
|
|
69
|
+
queryStore.registerContextQueries();
|
|
70
|
+
queryStore.registerOrderQueries(ORDER_A);
|
|
71
|
+
queryStore.registerOrderQueries(ORDER_B);
|
|
72
|
+
actionStore.registerOrderActions();
|
|
73
|
+
|
|
74
|
+
return () => h('div');
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
79
|
+
const ModalsHost = defineComponent({
|
|
80
|
+
setup() {
|
|
81
|
+
useQueryStore().registerContextQueries();
|
|
82
|
+
useActionStore().registerModalActions();
|
|
83
|
+
|
|
84
|
+
return () => h(PrintOptionsModal);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const mountApp = (): void => {
|
|
89
|
+
mount(ModalsHost);
|
|
90
|
+
mount(RowHost);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Press print on one order, fill in the modal and submit it. */
|
|
94
|
+
const printOne = async (orderId: string): Promise<void> => {
|
|
95
|
+
const actionStore = useActionStore();
|
|
96
|
+
const modalStore = useModalStore();
|
|
97
|
+
|
|
98
|
+
const action = actionStore.dispatch(AdminAction.OrdersPrint, {orderIds: orderId} as never);
|
|
99
|
+
|
|
100
|
+
await vi.waitFor(() => expect(modalStore.context?.form).toBeTruthy());
|
|
101
|
+
|
|
102
|
+
await actionStore.dispatch('modal_submit');
|
|
103
|
+
await vi.waitFor(() => expect(modalStore.opened).toBeNull());
|
|
104
|
+
await action;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
describe('waitForLabelPrompt', () => {
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
// The form registry is module-level, so a form built by the previous test would still be
|
|
110
|
+
// there when this one starts.
|
|
111
|
+
useFormBuilder().forms.value = {};
|
|
112
|
+
|
|
113
|
+
// One real field, so the form has values to submit and the merge into the print parameters
|
|
114
|
+
// is actually exercised.
|
|
115
|
+
vi.mocked(mockDefaultPrintOptionsView).mockReturnValue({
|
|
116
|
+
id: 'print-options',
|
|
117
|
+
children: [],
|
|
118
|
+
description: '',
|
|
119
|
+
subtext: '',
|
|
120
|
+
title: '',
|
|
121
|
+
elements: [{name: 'format', label: 'label_format', $component: 'TextInput'}],
|
|
122
|
+
} as never);
|
|
123
|
+
|
|
124
|
+
vi.mocked(mockDefaultPluginSettings).mockReturnValue({
|
|
125
|
+
label: {prompt: true, format: 'a4', output: 'download', position: [1, 2, 3, 4]},
|
|
126
|
+
order: {},
|
|
127
|
+
} as never);
|
|
128
|
+
|
|
129
|
+
doComponentTestSetup();
|
|
130
|
+
config.global.stubs.PdkModal = RebuildingModalStub as never;
|
|
131
|
+
|
|
132
|
+
vi.mocked(useOrderMode).mockReturnValue({value: OrderMode.Shipments} as never);
|
|
133
|
+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
|
|
134
|
+
|
|
135
|
+
printOrders.mockReset().mockResolvedValue({url: 'https://example.test/label.pdf'});
|
|
136
|
+
updateShipments.mockReset().mockResolvedValue([]);
|
|
137
|
+
fetchOrders.mockResolvedValue([{externalIdentifier: ORDER_A, shipments: [{id: 1}]}]);
|
|
138
|
+
fetchContext.mockResolvedValue([{}]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
afterEach(() => {
|
|
142
|
+
doComponentTestTeardown();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('prints on every press, not only the first of the page', async () => {
|
|
146
|
+
mountApp();
|
|
147
|
+
|
|
148
|
+
await printOne(ORDER_A);
|
|
149
|
+
await printOne(ORDER_B);
|
|
150
|
+
await printOne(ORDER_A);
|
|
151
|
+
|
|
152
|
+
expect(printOrders).toHaveBeenCalledTimes(3);
|
|
153
|
+
expect(useNotificationStore().notifications).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('prints on every press when the modal reuses one form across opens', async () => {
|
|
157
|
+
config.global.stubs.PdkModal = ReusingModalStub as never;
|
|
158
|
+
|
|
159
|
+
mountApp();
|
|
160
|
+
|
|
161
|
+
await printOne(ORDER_A);
|
|
162
|
+
await printOne(ORDER_B);
|
|
163
|
+
|
|
164
|
+
expect(printOrders).toHaveBeenCalledTimes(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('cancels silently when the modal is closed without submitting', async () => {
|
|
168
|
+
mountApp();
|
|
169
|
+
|
|
170
|
+
const actionStore = useActionStore();
|
|
171
|
+
const modalStore = useModalStore();
|
|
172
|
+
|
|
173
|
+
const action = actionStore.dispatch(AdminAction.OrdersPrint, {orderIds: ORDER_A} as never);
|
|
174
|
+
|
|
175
|
+
await vi.waitFor(() => expect(modalStore.context?.form).toBeTruthy());
|
|
176
|
+
modalStore.close();
|
|
177
|
+
await action;
|
|
178
|
+
|
|
179
|
+
expect(printOrders).not.toHaveBeenCalled();
|
|
180
|
+
expect(useNotificationStore().notifications).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('prints after a cancelled print', async () => {
|
|
184
|
+
mountApp();
|
|
185
|
+
|
|
186
|
+
const actionStore = useActionStore();
|
|
187
|
+
const modalStore = useModalStore();
|
|
188
|
+
|
|
189
|
+
const cancelled = actionStore.dispatch(AdminAction.OrdersPrint, {orderIds: ORDER_A} as never);
|
|
190
|
+
|
|
191
|
+
await vi.waitFor(() => expect(modalStore.context?.form).toBeTruthy());
|
|
192
|
+
modalStore.close();
|
|
193
|
+
await cancelled;
|
|
194
|
+
|
|
195
|
+
await printOne(ORDER_A);
|
|
196
|
+
|
|
197
|
+
expect(printOrders).toHaveBeenCalledTimes(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('prints with the options the merchant picked in the modal', async () => {
|
|
201
|
+
mountApp();
|
|
202
|
+
|
|
203
|
+
const actionStore = useActionStore();
|
|
204
|
+
const modalStore = useModalStore();
|
|
205
|
+
|
|
206
|
+
const action = actionStore.dispatch(AdminAction.OrdersPrint, {orderIds: ORDER_A} as never);
|
|
207
|
+
|
|
208
|
+
await vi.waitFor(() => expect(modalStore.context?.form).toBeTruthy());
|
|
209
|
+
|
|
210
|
+
modalStore.context?.form?.setValues({format: 'a6'});
|
|
211
|
+
|
|
212
|
+
await actionStore.dispatch('modal_submit');
|
|
213
|
+
await vi.waitFor(() => expect(modalStore.opened).toBeNull());
|
|
214
|
+
await action;
|
|
215
|
+
|
|
216
|
+
expect(printOrders).toHaveBeenCalledWith(
|
|
217
|
+
expect.objectContaining({parameters: expect.objectContaining({format: 'a6'})}),
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import {markRaw} from 'vue';
|
|
2
|
-
import {FormHook, useFormBuilder} from '@myparcel-dev/vue-form-builder';
|
|
1
|
+
import {markRaw, watch} from 'vue';
|
|
2
|
+
import {type FormInstance, FormHook, useFormBuilder} from '@myparcel-dev/vue-form-builder';
|
|
3
3
|
import {StopActionHandler} from '../stopActionHandler';
|
|
4
4
|
import {type ActionContext} from '../executors';
|
|
5
5
|
import {type ActionParameters, type PrintAction} from '../../types';
|
|
6
6
|
import {useModalStore} from '../../stores';
|
|
7
7
|
import {AdminModalKey} from '../../data';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Ask the merchant for the label options before printing. Use this as the `beforeHandle` of a
|
|
11
|
+
* print action, which the print actions do whenever the `label.prompt` setting is on.
|
|
12
|
+
*
|
|
13
|
+
* @param context The action context. Its parameters are merged with the submitted values.
|
|
14
|
+
* @returns The print parameters, or a rejection with `StopActionHandler` when the merchant
|
|
15
|
+
* closes the modal instead of submitting it.
|
|
16
|
+
*/
|
|
9
17
|
export const waitForLabelPrompt = <A extends PrintAction>({
|
|
10
18
|
parameters,
|
|
11
19
|
}: ActionContext<A>): Promise<ActionParameters<A>> => {
|
|
@@ -13,27 +21,55 @@ export const waitForLabelPrompt = <A extends PrintAction>({
|
|
|
13
21
|
const modalStore = useModalStore();
|
|
14
22
|
|
|
15
23
|
return new Promise((resolve, reject) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
form.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Runs when the merchant submits the modal. Stops watching and takes this listener back off
|
|
26
|
+
* the form, so the next print can put its own listener on. `vue-form-builder` matches
|
|
27
|
+
* listeners by their source text and refuses one it already has, so a modal that reuses a
|
|
28
|
+
* single form would otherwise drop the next print's listener as a duplicate and never
|
|
29
|
+
* answer it.
|
|
30
|
+
*/
|
|
31
|
+
const onAfterSubmit = (form: FormInstance): void => {
|
|
32
|
+
stopWatching();
|
|
33
|
+
form.off(FormHook.AfterSubmit, onAfterSubmit);
|
|
34
|
+
|
|
35
|
+
// Lets the print action carry on, printing with the options the merchant just picked.
|
|
36
|
+
// Those win over whatever the action was called with.
|
|
37
|
+
resolve({...parameters, ...form.getValues()} as ActionParameters<A>);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Open before watching. Opening resets the modal context, which would wipe the form the
|
|
41
|
+
// watcher publishes there when the modal reuses a form it built earlier.
|
|
42
|
+
modalStore.open(AdminModalKey.PrintOptions);
|
|
43
|
+
|
|
44
|
+
// Listens for the print options form appearing or being replaced, and hooks every form it
|
|
45
|
+
// sees. The modal builds its form after it opens, and builds a fresh one on a later open,
|
|
46
|
+
// so binding once would bind to a form the merchant never fills in. A listener left behind
|
|
47
|
+
// on a replaced form does no harm, because `getForm` never returns that instance again.
|
|
48
|
+
const stopWatching = watch(
|
|
49
|
+
() => formBuilder.getForm(AdminModalKey.PrintOptions),
|
|
50
|
+
(form?: FormInstance) => {
|
|
51
|
+
if (!form) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The modal's submit action reads the form back out of here.
|
|
56
|
+
modalStore.context ??= {};
|
|
57
|
+
modalStore.context.form = markRaw(form);
|
|
58
|
+
|
|
59
|
+
form.on(FormHook.AfterSubmit, onAfterSubmit);
|
|
60
|
+
},
|
|
61
|
+
// Immediate, because a modal that reuses its form already has one by the time we get
|
|
62
|
+
// here, and the watcher would never fire for it.
|
|
63
|
+
{immediate: true},
|
|
64
|
+
);
|
|
32
65
|
|
|
33
66
|
modalStore.onClose(() => {
|
|
67
|
+
stopWatching();
|
|
68
|
+
formBuilder.getForm(AdminModalKey.PrintOptions)?.off(FormHook.AfterSubmit, onAfterSubmit);
|
|
69
|
+
|
|
70
|
+
// Ends the print action without printing anything. The executor reads a StopActionHandler
|
|
71
|
+
// as "the merchant cancelled", so it stops quietly instead of showing an error.
|
|
34
72
|
reject(new StopActionHandler());
|
|
35
73
|
});
|
|
36
|
-
|
|
37
|
-
modalStore.open(AdminModalKey.PrintOptions);
|
|
38
74
|
});
|
|
39
75
|
};
|
|
@@ -38,6 +38,9 @@ export const createActionContext = <A extends MaybeAdminAction>(
|
|
|
38
38
|
(acc, variant) => ({
|
|
39
39
|
...acc,
|
|
40
40
|
[variant]: createNotification(variant, {
|
|
41
|
+
// Action notifications are the only ones rendered next to the order and in the order
|
|
42
|
+
// box, which is where a failing action has to report itself.
|
|
43
|
+
category: NotificationCategory.Action,
|
|
41
44
|
tags: {
|
|
42
45
|
action: identifier,
|
|
43
46
|
},
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
|
4
|
+
import {mount} from '@vue/test-utils';
|
|
5
|
+
import {Variant} from '@myparcel-dev/pdk-common';
|
|
6
|
+
import {type PdkNotification} from '../types';
|
|
7
|
+
import {useQueryStore} from '../stores';
|
|
8
|
+
import {NotificationCategory} from '../data';
|
|
9
|
+
import {doComponentTestSetup, doComponentTestTeardown, mockDefaultTranslations} from '../__tests__';
|
|
10
|
+
import {createNotification} from './createNotification';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* createNotification needs the translations, which are only available inside a setup context.
|
|
14
|
+
*/
|
|
15
|
+
const create = (options: Parameters<typeof createNotification>[1]): undefined | PdkNotification => {
|
|
16
|
+
let notification: undefined | PdkNotification;
|
|
17
|
+
|
|
18
|
+
mount({
|
|
19
|
+
setup() {
|
|
20
|
+
useQueryStore().registerContextQueries();
|
|
21
|
+
notification = createNotification(Variant.Error, options);
|
|
22
|
+
|
|
23
|
+
return () => null;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return notification;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('createNotification', () => {
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
mockDefaultTranslations.mockReturnValue({
|
|
33
|
+
notification_action_error: 'Something went wrong',
|
|
34
|
+
notification_action_error_body: 'Try again later.',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
doComponentTestSetup();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
doComponentTestTeardown();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
mockDefaultTranslations.mockReset();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('translates the title it derives from the category', () => {
|
|
51
|
+
expect(create({category: NotificationCategory.Action})).toMatchObject({
|
|
52
|
+
category: NotificationCategory.Action,
|
|
53
|
+
content: 'Try again later.',
|
|
54
|
+
title: 'Something went wrong',
|
|
55
|
+
variant: Variant.Error,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('leaves a title that was passed in untouched', () => {
|
|
60
|
+
expect(create({title: 'Street is required.'})?.title).toBe('Street is required.');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns undefined when there is nothing to show', () => {
|
|
64
|
+
expect(create({category: NotificationCategory.General})).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -22,14 +22,18 @@ export const createNotification = (
|
|
|
22
22
|
return notification;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
const contentKey = `${
|
|
25
|
+
const titleKey = `${PREFIX}${identifier ?? options?.category ?? NotificationCategory.General}_${variant}`;
|
|
26
|
+
const contentKey = `${titleKey}_body`;
|
|
27
27
|
|
|
28
28
|
const content = language.has(contentKey) ? language.translate(contentKey) : undefined;
|
|
29
29
|
|
|
30
|
-
if (!language.has(
|
|
30
|
+
if (!language.has(titleKey) && !content) {
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// No notification component translates the title, so resolve it here instead of handing them a
|
|
35
|
+
// translation key to render verbatim.
|
|
36
|
+
const title = language.has(titleKey) ? language.translate(titleKey) : undefined;
|
|
37
|
+
|
|
34
38
|
return {...notification, title, content};
|
|
35
39
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {type
|
|
1
|
+
import {type Raw} from 'vue';
|
|
2
2
|
import {type FormInstance} from '@myparcel-dev/vue-form-builder';
|
|
3
3
|
import {type OneOrMore, type Replace} from '@myparcel-dev/ts-utils';
|
|
4
|
+
import {type AdminContextKey, type Plugin} from '@myparcel-dev/pdk-common';
|
|
4
5
|
import {type AdminInstanceContextKey, type AdminModalKey} from '../data';
|
|
5
6
|
import {type BackendPdkEndpointObject} from './endpoints.types';
|
|
6
7
|
|
|
@@ -18,7 +19,9 @@ export type AdminInstanceContext = {
|
|
|
18
19
|
[AdminInstanceContextKey.ProductIdentifier]: Plugin.ModelContextProductDataContext['externalIdentifier'];
|
|
19
20
|
};
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
// The form is stored with markRaw, so Vue leaves its refs alone at runtime. Raw says that in
|
|
23
|
+
// the type too, which keeps the store's deep ref-unwrapping off it.
|
|
24
|
+
type BaseModalContext = {form?: Raw<FormInstance>};
|
|
22
25
|
|
|
23
26
|
export type AdminModalContext<T extends AdminModalKey = AdminModalKey> =
|
|
24
27
|
| (BaseModalContext & (T extends AdminModalKey.ShipmentOptions ? {orderIds?: OneOrMore<string>} : unknown))
|
package/src/utils/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './forms';
|
|
|
10
10
|
export * from './generateLabelFilename';
|
|
11
11
|
export * from './getOrderId';
|
|
12
12
|
export * from './getOrderShipmentIds';
|
|
13
|
+
export * from './notificationBelongsToOrder';
|
|
13
14
|
export * from './openUrlInNewTab';
|
|
14
15
|
export * from './prefixComponent';
|
|
15
16
|
export * from './query';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
import {notificationBelongsToOrder} from './notificationBelongsToOrder';
|
|
3
|
+
|
|
4
|
+
describe('notificationBelongsToOrder', () => {
|
|
5
|
+
it('matches the order the notification was tagged with', () => {
|
|
6
|
+
expect(notificationBelongsToOrder({tags: {orderIds: '123'}}, '123')).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('matches an order inside a bulk action tag', () => {
|
|
10
|
+
expect(notificationBelongsToOrder({tags: {orderIds: '123,456,789'}}, '456')).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('does not match another order', () => {
|
|
14
|
+
expect(notificationBelongsToOrder({tags: {orderIds: '123,456'}}, '999')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('does not match a partial order id', () => {
|
|
18
|
+
expect(notificationBelongsToOrder({tags: {orderIds: '1234'}}, '123')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not match an untagged notification', () => {
|
|
22
|
+
expect(notificationBelongsToOrder({tags: {}}, '123')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('does not match when there is no order', () => {
|
|
26
|
+
expect(notificationBelongsToOrder({tags: {orderIds: '123'}}, undefined)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {type PdkNotification} from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A backend notification is tagged with every order the action ran for, so an order list column
|
|
5
|
+
* matches on that list instead of on the whole tag.
|
|
6
|
+
*/
|
|
7
|
+
export const notificationBelongsToOrder = (
|
|
8
|
+
notification: Pick<PdkNotification, 'tags'>,
|
|
9
|
+
externalIdentifier?: string,
|
|
10
|
+
): boolean => {
|
|
11
|
+
const {orderIds} = notification.tags ?? {};
|
|
12
|
+
|
|
13
|
+
if (!orderIds || !externalIdentifier) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return orderIds.split(',').includes(externalIdentifier);
|
|
18
|
+
};
|
|
@@ -21,6 +21,7 @@ import {useActionStore, useQueryStore} from '../stores';
|
|
|
21
21
|
import {NotificationCategory, OrderMode} from '../data';
|
|
22
22
|
import {useOrderData, useOrderMode} from '../composables';
|
|
23
23
|
import {NotificationContainer} from '../components';
|
|
24
|
+
import {notificationBelongsToOrder} from '../utils';
|
|
24
25
|
|
|
25
26
|
/* eslint-disable @typescript-eslint/naming-convention */
|
|
26
27
|
const OrderModeOrderListItem = defineAsyncComponent(() => {
|
|
@@ -46,6 +47,6 @@ const orderMode = useOrderMode();
|
|
|
46
47
|
const {query} = useOrderData();
|
|
47
48
|
|
|
48
49
|
const notificationFilter: NotificationFilter = (notification) => {
|
|
49
|
-
return notification
|
|
50
|
+
return notificationBelongsToOrder(notification, toValue(query.data)?.externalIdentifier);
|
|
50
51
|
};
|
|
51
52
|
</script>
|