@myparcel-dev/pdk-admin 2.2.3 → 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 +10 -0
- package/dist/actions/print/waitForLabelPrompt.d.ts +8 -0
- package/dist/types/context.types.d.ts +3 -2
- package/package.json +1 -1
- package/src/actions/print/waitForLabelPrompt.spec.ts +220 -0
- package/src/actions/print/waitForLabelPrompt.ts +56 -20
- package/src/types/context.types.ts +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
5
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)
|
|
6
16
|
|
|
7
17
|
|
|
@@ -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/package.json
CHANGED
|
@@ -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
|
};
|
|
@@ -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))
|