@myparcel-dev/pdk-admin 1.14.2 → 2.0.1
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 +19 -0
- package/package.json +6 -6
- package/src/__tests__/__snapshots__/exports.spec.ts.snap +8 -1
- package/src/__tests__/doComponentTestTeardown.ts +2 -0
- package/src/__tests__/mocks/index.ts +1 -1
- package/src/__tests__/mocks/mockDefaultAccount.ts +1 -0
- package/src/__tests__/mocks/mockDefaultGlobalContext.ts +2 -2
- package/src/__tests__/mocks/{mockDefaultPlatform.ts → mockDefaultProposition.ts} +1 -3
- package/src/__tests__/mocks/mockDefaultShop.ts +1 -0
- package/src/__tests__/utils/getActionIds.ts +12 -0
- package/src/__tests__/utils/mockOrderData.ts +20 -0
- package/src/actions/composables/mutations/orders/useExportOrdersMutation.ts +1 -1
- package/src/actions/composables/queries/account/useOrderCapabilitiesQuery.ts +67 -0
- package/src/actions/composables/queries/account/useShipmentCapabilitiesQuery.ts +96 -0
- package/src/actions/definitions/orders.ts +6 -8
- package/src/actions/index.ts +1 -0
- package/src/actions/orderModeActions.spec.ts +150 -0
- package/src/actions/orderModeActions.ts +90 -0
- package/src/components/OrderBox/ShipmentOptionsBox.spec.ts +109 -0
- package/src/components/OrderBox/ShipmentOptionsBox.vue +9 -25
- package/src/components/OrderListItem/OrderModeOrderListItem.spec.ts +125 -0
- package/src/components/OrderListItem/OrderModeOrderListItem.vue +10 -6
- package/src/components/PluginSettings/PluginSettingsForms.vue +7 -4
- package/src/components/common/DeliveryOptionsExcerptCarrierName.vue +3 -3
- package/src/components/modals/ShipmentOptionsModal.spec.ts +79 -0
- package/src/components/modals/ShipmentOptionsModal.vue +4 -21
- package/src/composables/components/useToggleInputContext.ts +2 -2
- package/src/composables/components/useTriStateInputContext.ts +2 -2
- package/src/composables/context/index.ts +1 -0
- package/src/composables/context/useOrderMode.spec.ts +75 -0
- package/src/composables/context/useOrderMode.ts +26 -0
- package/src/composables/language/useLanguage.spec.ts +2 -2
- package/src/composables/shipments/useShipmentData.ts +2 -2
- package/src/data/index.ts +1 -0
- package/src/data/orderMode.spec.ts +22 -0
- package/src/data/orderMode.ts +50 -0
- package/src/forms/form-builder/builders/buildAfterUpdate.spec.ts +5 -8
- package/src/forms/form-builder/operations/executeOperations.spec.ts +4 -7
- package/src/forms/form-builder/operations/executeSetPropOperation.spec.ts +4 -7
- package/src/forms/form-builder/operations/executeSetValueOperation.spec.ts +3 -2
- package/src/forms/form-builder/utils/createValueSetter.ts +21 -8
- package/src/forms/helpers/addCapabilitiesClearNotification.ts +39 -0
- package/src/forms/helpers/getDeliveryTypes.ts +5 -10
- package/src/forms/helpers/getFieldLabel.ts +7 -2
- package/src/forms/helpers/getPackageTypes.ts +7 -12
- package/src/forms/helpers/index.ts +3 -10
- package/src/forms/helpers/useFormCapabilities.spec.ts +227 -0
- package/src/forms/helpers/useFormCapabilities.ts +176 -0
- package/src/forms/pluginSettings/createPluginSettingsForm.ts +2 -2
- package/src/forms/pluginSettings/filterPluginSettingsView.spec.ts +158 -0
- package/src/forms/pluginSettings/filterPluginSettingsView.ts +96 -0
- package/src/forms/pluginSettings/index.ts +1 -0
- package/src/forms/pluginSettings/resolveSettingsValues.spec.ts +62 -0
- package/src/forms/pluginSettings/resolveSettingsValues.ts +16 -0
- package/src/forms/shipmentOptions/createShipmentOptionsForm.spec.ts +108 -0
- package/src/forms/shipmentOptions/createShipmentOptionsForm.ts +114 -52
- package/src/forms/shipmentOptions/field.ts +6 -66
- package/src/forms/shipmentOptions/fieldFactoryRegistry.ts +26 -0
- package/src/forms/shipmentOptions/fields/createCarrierField.ts +119 -30
- package/src/forms/shipmentOptions/fields/createDigitalStampRangeField.ts +3 -3
- package/src/forms/shipmentOptions/fields/createInsuranceField.ts +43 -22
- package/src/forms/shipmentOptions/fields/createPackageTypeField.ts +1 -2
- package/src/forms/shipmentOptions/fields/createShipmentOptionField.spec.ts +177 -0
- package/src/forms/shipmentOptions/fields/createShipmentOptionField.ts +90 -8
- package/src/forms/shipmentOptions/fields/index.ts +0 -9
- package/src/forms/shipmentOptions/useCapabilitiesAutoClear.spec.ts +347 -0
- package/src/forms/shipmentOptions/useCapabilitiesAutoClear.ts +452 -0
- package/src/forms/shipmentOptions/useCapabilitiesWatcher.spec.ts +14 -0
- package/src/forms/shipmentOptions/useCapabilitiesWatcher.ts +29 -0
- package/src/forms/shipmentOptions/wireProxyCapabilities.spec.ts +160 -0
- package/src/forms/shipmentOptions/wireProxyCapabilities.ts +102 -0
- package/src/index.ts +2 -0
- package/src/pdk/instance/plugins/createRegisterComponentsPlugin.ts +2 -0
- package/src/sdk/composables/api/useFetchCarrier.ts +39 -4
- package/src/sdk/composables/usePdkAdminApi.ts +8 -3
- package/src/stores/useActionStore.spec.ts +101 -0
- package/src/stores/useActionStore.ts +4 -28
- package/src/stores/useQueryStore.ts +15 -0
- package/src/types/actions/endpoints.types.ts +2 -1
- package/src/types/sdk.types.ts +54 -1
- package/src/utils/forms/formToBody.ts +2 -1
- package/src/utils/forms/getEnabledValues.spec.ts +66 -0
- package/src/utils/forms/getEnabledValues.ts +25 -0
- package/src/utils/forms/index.ts +1 -0
- package/src/utils/translations/getDynamicTranslation.ts +1 -1
- package/src/views/OrderBox.vue +11 -5
- package/src/views/OrderListItem.spec.ts +71 -0
- package/src/views/OrderListItem.vue +6 -7
- package/src/forms/helpers/createHasShipmentOptionWatcher.ts +0 -12
- package/src/forms/helpers/getCarrier.ts +0 -11
- package/src/forms/helpers/getInsuranceOptions.ts +0 -23
- package/src/forms/helpers/hasPostNlAgeCheck.ts +0 -11
- package/src/forms/helpers/hasShipmentOption.ts +0 -8
- package/src/forms/helpers/isPackageTypeMailbox.ts +0 -7
- package/src/forms/helpers/isPackageTypePackage.ts +0 -7
- package/src/forms/helpers/isPackageTypePackageOrSmall.ts +0 -7
- package/src/forms/helpers/isPackageTypeSmallPackage.ts +0 -7
- package/src/forms/helpers/setPostNlAgeCheckSubtext.ts +0 -17
- package/src/forms/shipmentOptions/fields/createAgeCheckField.ts +0 -33
- package/src/forms/shipmentOptions/fields/createDirectReturnField.ts +0 -8
- package/src/forms/shipmentOptions/fields/createHideSenderField.ts +0 -8
- package/src/forms/shipmentOptions/fields/createLargeFormatField.ts +0 -8
- package/src/forms/shipmentOptions/fields/createOnlyRecipientField.ts +0 -21
- package/src/forms/shipmentOptions/fields/createPriorityDeliveryField.ts +0 -12
- package/src/forms/shipmentOptions/fields/createReceiptCodeField.ts +0 -21
- package/src/forms/shipmentOptions/fields/createSameDayDeliveryField.ts +0 -8
- package/src/forms/shipmentOptions/fields/createSignatureField.ts +0 -11
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import {toValue} from 'vue';
|
|
2
|
+
import {type FormInstance} from '@myparcel-dev/vue-form-builder';
|
|
3
|
+
import {AdminContextKey, BackendEndpoint, type CarrierModel, TriState} from '@myparcel-dev/pdk-common';
|
|
4
|
+
import {FIELD_CARRIER} from '../shipmentOptions/field';
|
|
5
|
+
import {getOrderId} from '../../utils';
|
|
6
|
+
import {type AdminContext, type SelectOption} from '../../types';
|
|
7
|
+
import {useQueryStore, type ResolvedQuery} from '../../stores';
|
|
8
|
+
import {Format, type Formatter} from '../../composables';
|
|
9
|
+
|
|
10
|
+
interface InsuranceAmountData {
|
|
11
|
+
insuredAmount?: {
|
|
12
|
+
min: {amount: number; currency: string};
|
|
13
|
+
max: {amount: number; currency: string};
|
|
14
|
+
default: {amount: number; currency: string};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bag of capability resolvers, each pre-bound to a Pinia store + orderId snapshot captured at
|
|
20
|
+
* the composable's invocation time. Returned by {@link useFormCapabilities}.
|
|
21
|
+
*
|
|
22
|
+
* The functions are plain — they don't call `useQueryStore()` / `getOrderId()` on each
|
|
23
|
+
* invocation, so they're safe to call from outside Vue setup context (e.g. inside
|
|
24
|
+
* vue-form-builder's `visibleWhen` / `disabledWhen` / `readOnlyWhen` / `afterUpdate`
|
|
25
|
+
* watchEffect-driven re-evaluators, where `inject()` is not available).
|
|
26
|
+
*/
|
|
27
|
+
export type FormCapabilities = {
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the chosen carrier's order-level capability data — destination + weight scope only.
|
|
30
|
+
* Used to populate carrier / packageType / deliveryType dropdowns. `requires` / `excludes`
|
|
31
|
+
* are NEVER populated through this path; they only appear on `getCarrierCapabilitiesForShipment`.
|
|
32
|
+
*/
|
|
33
|
+
getCarrierCapabilitiesForOrder: (form: FormInstance) => CarrierModel | undefined;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the chosen carrier's shipment-level capability data — narrowed to the full
|
|
37
|
+
* selection (carrier + packageType + deliveryType + cc + weight). Three return states:
|
|
38
|
+
*
|
|
39
|
+
* 1. **Shipment-narrow carrier** — query is `success` AND its results contain the chosen
|
|
40
|
+
* carrier. Returns that carrier with the narrow option set.
|
|
41
|
+
* 2. **Order-level fallback** — query isn't registered, loading, or errored. Falls back to
|
|
42
|
+
* `getCarrierCapabilitiesForOrder` so transient loading windows don't empty the form.
|
|
43
|
+
* 3. **`undefined`** — query is `success` but its results don't contain the chosen carrier.
|
|
44
|
+
* Server has confirmed the combo is invalid; consumers must handle gracefully.
|
|
45
|
+
*/
|
|
46
|
+
getCarrierCapabilitiesForShipment: (form: FormInstance) => CarrierModel | undefined;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether the chosen shipment configuration supports a given option. Reads from the
|
|
50
|
+
* shipment-scoped carrier; falls through to order-level data when shipment data isn't
|
|
51
|
+
* available yet.
|
|
52
|
+
*/
|
|
53
|
+
hasShipmentOption: (form: FormInstance, option: string) => boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Insurance amount bracket options derived from the currently selected carrier's
|
|
57
|
+
* `insuredAmount` data. Amounts in the context are in cents; emitted values are strings
|
|
58
|
+
* representing cent amounts so they round-trip through form select inputs unchanged.
|
|
59
|
+
*/
|
|
60
|
+
getInsuranceOptions: (form: FormInstance, formatter: Formatter) => SelectOption[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Closure-factory composable that captures Pinia + orderId references ONCE at its
|
|
65
|
+
* setup-context invocation, and returns plain resolver functions pre-bound to those captures.
|
|
66
|
+
*
|
|
67
|
+
* Why this exists: form-builder's `visibleWhen` / `disabledWhen` / `readOnlyWhen` / `afterUpdate`
|
|
68
|
+
* re-evaluators run inside an internal `watchEffect`, NOT inside Vue setup. Calling
|
|
69
|
+
* `useQueryStore()` / `useContext()` / `getOrderId()` from those callbacks fires Vue's
|
|
70
|
+
* `inject() can only be used inside setup()` warning AND can return a disconnected store
|
|
71
|
+
* instance, breaking reactive dep tracking on `queries.value[modifier]`.
|
|
72
|
+
*
|
|
73
|
+
* Capturing once here, then closing over those references in the returned resolvers, gives
|
|
74
|
+
* factories a single seam to call from setup — `const capabilities = useFormCapabilities()` — without
|
|
75
|
+
* threading a context object through every helper signature.
|
|
76
|
+
*
|
|
77
|
+
* Pinia is a singleton, so all `useFormCapabilities()` callers within the same Pinia instance
|
|
78
|
+
* reference the same underlying store. The dynamic-context entry in the store is itself a
|
|
79
|
+
* TanStack query whose `data` is reactive — `useFetchContextQuery` refetches `Dynamic` on
|
|
80
|
+
* window focus, so we read `query.data` live on every resolver call rather than snapshotting
|
|
81
|
+
* the carriers array at setup time.
|
|
82
|
+
*/
|
|
83
|
+
export const useFormCapabilities = (): FormCapabilities => {
|
|
84
|
+
const queryStore = useQueryStore();
|
|
85
|
+
const dynamicContextQuery = queryStore.get(BackendEndpoint.FetchContext, AdminContextKey.Dynamic) as ResolvedQuery<
|
|
86
|
+
BackendEndpoint.FetchContext
|
|
87
|
+
>;
|
|
88
|
+
const orderId = getOrderId();
|
|
89
|
+
|
|
90
|
+
const orderModifier = typeof orderId === 'string' ? `${orderId}.order` : undefined;
|
|
91
|
+
const shipmentModifier = typeof orderId === 'string' ? `${orderId}.shipment` : undefined;
|
|
92
|
+
|
|
93
|
+
const carriersFromQuery = (modifier: string): CarrierModel[] | undefined => {
|
|
94
|
+
if (!queryStore.has(BackendEndpoint.ProxyCapabilities, modifier)) return undefined;
|
|
95
|
+
|
|
96
|
+
const query = queryStore.get(BackendEndpoint.ProxyCapabilities, modifier);
|
|
97
|
+
|
|
98
|
+
if (toValue(query.status) !== 'success') return undefined;
|
|
99
|
+
|
|
100
|
+
return (toValue(query.data) ?? []) as CarrierModel[];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const liveDynamicCarriers = (): CarrierModel[] => {
|
|
104
|
+
const data = toValue(dynamicContextQuery.data) as AdminContext<AdminContextKey.Dynamic> | undefined;
|
|
105
|
+
|
|
106
|
+
return data?.carriers ?? [];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const getCarrierCapabilitiesForOrder = (form: FormInstance): CarrierModel | undefined => {
|
|
110
|
+
const chosenCarrier = form.getValue(FIELD_CARRIER);
|
|
111
|
+
|
|
112
|
+
if (orderModifier) {
|
|
113
|
+
const carriers = carriersFromQuery(orderModifier);
|
|
114
|
+
const fromQuery = carriers?.find((carrier) => carrier.carrier === chosenCarrier);
|
|
115
|
+
|
|
116
|
+
if (fromQuery) return fromQuery;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return liveDynamicCarriers().find((carrier) => carrier.carrier === chosenCarrier);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const getCarrierCapabilitiesForShipment = (form: FormInstance): CarrierModel | undefined => {
|
|
123
|
+
if (!shipmentModifier) return getCarrierCapabilitiesForOrder(form);
|
|
124
|
+
|
|
125
|
+
const carriers = carriersFromQuery(shipmentModifier);
|
|
126
|
+
|
|
127
|
+
if (!carriers) return getCarrierCapabilitiesForOrder(form);
|
|
128
|
+
|
|
129
|
+
const chosenCarrier = form.getValue(FIELD_CARRIER);
|
|
130
|
+
|
|
131
|
+
return carriers.find((carrier) => carrier.carrier === chosenCarrier);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const hasShipmentOption = (form: FormInstance, option: string): boolean => {
|
|
135
|
+
const carrier = getCarrierCapabilitiesForShipment(form);
|
|
136
|
+
|
|
137
|
+
return Object.hasOwn(carrier?.options ?? {}, option);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const getInsuranceOptions = (form: FormInstance, formatter: Formatter): SelectOption[] => {
|
|
141
|
+
// Insurance bracket steps: €250 increments below €500, €500 increments above. Switching
|
|
142
|
+
// step size at the threshold keeps the dropdown short for high-value shipments.
|
|
143
|
+
const STEP_BELOW_THRESHOLD = 250;
|
|
144
|
+
const STEP_ABOVE_THRESHOLD = 500;
|
|
145
|
+
const STEP_THRESHOLD = 500;
|
|
146
|
+
|
|
147
|
+
const carrier = getCarrierCapabilitiesForShipment(form);
|
|
148
|
+
const insuranceData = (carrier?.options?.insurance ?? {}) as InsuranceAmountData;
|
|
149
|
+
|
|
150
|
+
const min = insuranceData.insuredAmount?.min.amount ? insuranceData.insuredAmount.min.amount / 100 : 0;
|
|
151
|
+
const max = insuranceData.insuredAmount?.max.amount ? insuranceData.insuredAmount.max.amount / 100 : 0;
|
|
152
|
+
|
|
153
|
+
if (max === 0) return [];
|
|
154
|
+
|
|
155
|
+
const insurancePossibilities: number[] = [];
|
|
156
|
+
|
|
157
|
+
for (let i = min; i <= max; i += i < STEP_THRESHOLD ? STEP_BELOW_THRESHOLD : STEP_ABOVE_THRESHOLD) {
|
|
158
|
+
insurancePossibilities.push(i * 100);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return [
|
|
162
|
+
{label: 'option_default', value: TriState.Inherit},
|
|
163
|
+
...insurancePossibilities.map((amount) => ({
|
|
164
|
+
plainLabel: formatter.format(Format.Currency, amount / 100),
|
|
165
|
+
value: amount.toString(),
|
|
166
|
+
})),
|
|
167
|
+
];
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
getCarrierCapabilitiesForOrder,
|
|
172
|
+
getCarrierCapabilitiesForShipment,
|
|
173
|
+
hasShipmentOption,
|
|
174
|
+
getInsuranceOptions,
|
|
175
|
+
};
|
|
176
|
+
};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import {get as lodashGet} from 'lodash-unified';
|
|
2
1
|
import {BackendEndpoint, type Plugin} from '@myparcel-dev/pdk-common';
|
|
3
2
|
import {defineForm, type FormInstance} from '@myparcel-dev/vue-form-builder';
|
|
4
3
|
import {useStoreQuery} from '../../composables';
|
|
@@ -6,6 +5,7 @@ import {SubmitButton} from '../../components';
|
|
|
6
5
|
import {executeAction} from '../../actions';
|
|
7
6
|
import {type PluginSettingsTabsContext} from './types';
|
|
8
7
|
import {generateFormFields} from './generateFormFields';
|
|
8
|
+
import {resolveSettingsValues} from './resolveSettingsValues';
|
|
9
9
|
|
|
10
10
|
export const createPluginSettingsForm = (
|
|
11
11
|
id: string,
|
|
@@ -16,7 +16,7 @@ export const createPluginSettingsForm = (
|
|
|
16
16
|
|
|
17
17
|
const mutation = useStoreQuery(BackendEndpoint.UpdatePluginSettings);
|
|
18
18
|
|
|
19
|
-
const values =
|
|
19
|
+
const values = resolveSettingsValues(pluginSettings, id);
|
|
20
20
|
|
|
21
21
|
const generatedFields = generateFormFields({fields: view.elements, values}, `${id}.`);
|
|
22
22
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
import {type Plugin} from '@myparcel-dev/pdk-common';
|
|
3
|
+
import {OrderMode} from '../../data';
|
|
4
|
+
import {filterPluginSettingsView} from './filterPluginSettingsView';
|
|
5
|
+
|
|
6
|
+
const createField = (name: string, component = 'ToggleInput'): Plugin.Field => ({
|
|
7
|
+
name,
|
|
8
|
+
$component: component,
|
|
9
|
+
label: `label_${name}`,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const createDivider = (heading: string): Plugin.Field =>
|
|
13
|
+
({
|
|
14
|
+
$component: 'SettingsDivider',
|
|
15
|
+
$wrapper: false,
|
|
16
|
+
level: 2,
|
|
17
|
+
heading,
|
|
18
|
+
content: `${heading}_description`,
|
|
19
|
+
} as Plugin.Field);
|
|
20
|
+
|
|
21
|
+
const createSettingsView = (overrides: Partial<Plugin.SettingsView> = {}): Plugin.SettingsView => ({
|
|
22
|
+
id: 'test',
|
|
23
|
+
title: 'test',
|
|
24
|
+
elements: null,
|
|
25
|
+
children: null,
|
|
26
|
+
...overrides,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const createOrderView = (): Plugin.SettingsView =>
|
|
30
|
+
createSettingsView({
|
|
31
|
+
id: 'order',
|
|
32
|
+
title: 'settings_order_title',
|
|
33
|
+
elements: [
|
|
34
|
+
createDivider('settings_order_general_title'),
|
|
35
|
+
createField('conceptShipments'),
|
|
36
|
+
createField('processDirectly', 'SelectInput'),
|
|
37
|
+
createField('sendReturnEmail'),
|
|
38
|
+
createField('saveCustomerAddress'),
|
|
39
|
+
createField('shareCustomerInformation'),
|
|
40
|
+
createDivider('settings_order_status_title'),
|
|
41
|
+
createField('statusOnLabelCreate', 'SelectInput'),
|
|
42
|
+
createField('statusWhenLabelScanned', 'SelectInput'),
|
|
43
|
+
createDivider('settings_order_track_trace_title'),
|
|
44
|
+
createField('trackTraceInEmail'),
|
|
45
|
+
createField('trackTraceInAccount'),
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const createFullView = (): Plugin.ModelContextPluginSettingsViewContext =>
|
|
50
|
+
({
|
|
51
|
+
order: createOrderView(),
|
|
52
|
+
label: createSettingsView({id: 'label', title: 'Labels'}),
|
|
53
|
+
customs: createSettingsView({id: 'customs', title: 'Customs'}),
|
|
54
|
+
checkout: createSettingsView({id: 'checkout', title: 'Checkout'}),
|
|
55
|
+
carrier: createSettingsView({id: 'carrier', title: 'Carriers'}),
|
|
56
|
+
} as Plugin.ModelContextPluginSettingsViewContext);
|
|
57
|
+
|
|
58
|
+
const getFieldNames = (elements: Plugin.Field[] | null): string[] =>
|
|
59
|
+
(elements ?? []).filter((el) => el.name).map((el) => el.name!);
|
|
60
|
+
|
|
61
|
+
const getTabIds = (view: Plugin.ModelContextPluginSettingsViewContext): string[] => Object.keys(view);
|
|
62
|
+
|
|
63
|
+
describe('filterPluginSettingsView', () => {
|
|
64
|
+
describe('Shipments mode', () => {
|
|
65
|
+
it('keeps all General section fields visible', () => {
|
|
66
|
+
const result = filterPluginSettingsView(createFullView(), OrderMode.Shipments);
|
|
67
|
+
const fieldNames = getFieldNames(result.order.elements);
|
|
68
|
+
|
|
69
|
+
expect(fieldNames).toContain('conceptShipments');
|
|
70
|
+
expect(fieldNames).toContain('saveCustomerAddress');
|
|
71
|
+
expect(fieldNames).toContain('processDirectly');
|
|
72
|
+
expect(fieldNames).toContain('sendReturnEmail');
|
|
73
|
+
expect(fieldNames).toContain('shareCustomerInformation');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('keeps all tabs visible', () => {
|
|
77
|
+
const result = filterPluginSettingsView(createFullView(), OrderMode.Shipments);
|
|
78
|
+
|
|
79
|
+
expect(getTabIds(result)).toEqual(['order', 'label', 'customs', 'checkout', 'carrier']);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('OrderV1 mode', () => {
|
|
84
|
+
it('hides conceptShipments and saveCustomerAddress', () => {
|
|
85
|
+
const result = filterPluginSettingsView(createFullView(), OrderMode.OrderV1);
|
|
86
|
+
const fieldNames = getFieldNames(result.order.elements);
|
|
87
|
+
|
|
88
|
+
expect(fieldNames).not.toContain('conceptShipments');
|
|
89
|
+
expect(fieldNames).not.toContain('saveCustomerAddress');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('keeps other General section fields visible', () => {
|
|
93
|
+
const result = filterPluginSettingsView(createFullView(), OrderMode.OrderV1);
|
|
94
|
+
const fieldNames = getFieldNames(result.order.elements);
|
|
95
|
+
|
|
96
|
+
expect(fieldNames).toContain('processDirectly');
|
|
97
|
+
expect(fieldNames).toContain('sendReturnEmail');
|
|
98
|
+
expect(fieldNames).toContain('shareCustomerInformation');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('keeps all tabs visible', () => {
|
|
102
|
+
const result = filterPluginSettingsView(createFullView(), OrderMode.OrderV1);
|
|
103
|
+
|
|
104
|
+
expect(getTabIds(result)).toEqual(['order', 'label', 'customs', 'checkout', 'carrier']);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('OrderV2 mode (hybrid: surfaces the same fields and tabs as Shipments)', () => {
|
|
109
|
+
it('keeps all General section fields visible, including the shipment-export-only ones', () => {
|
|
110
|
+
const result = filterPluginSettingsView(createFullView(), OrderMode.OrderV2);
|
|
111
|
+
const fieldNames = getFieldNames(result.order.elements);
|
|
112
|
+
|
|
113
|
+
expect(fieldNames).toContain('conceptShipments');
|
|
114
|
+
expect(fieldNames).toContain('saveCustomerAddress');
|
|
115
|
+
expect(fieldNames).toContain('processDirectly');
|
|
116
|
+
expect(fieldNames).toContain('sendReturnEmail');
|
|
117
|
+
expect(fieldNames).toContain('shareCustomerInformation');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('keeps the General section divider in place', () => {
|
|
121
|
+
const result = filterPluginSettingsView(createFullView(), OrderMode.OrderV2);
|
|
122
|
+
const elements = result.order.elements ?? [];
|
|
123
|
+
|
|
124
|
+
const hasGeneralDivider = elements.some(
|
|
125
|
+
(el) => (el as Record<string, unknown>).heading === 'settings_order_general_title',
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(hasGeneralDivider).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('keeps all tabs visible (label and customs return alongside Shipments-mode behaviour)', () => {
|
|
132
|
+
const result = filterPluginSettingsView(createFullView(), OrderMode.OrderV2);
|
|
133
|
+
|
|
134
|
+
expect(getTabIds(result)).toEqual(['order', 'label', 'customs', 'checkout', 'carrier']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('keeps non-General sections in the order tab', () => {
|
|
138
|
+
const result = filterPluginSettingsView(createFullView(), OrderMode.OrderV2);
|
|
139
|
+
const fieldNames = getFieldNames(result.order.elements);
|
|
140
|
+
|
|
141
|
+
expect(fieldNames).toContain('statusOnLabelCreate');
|
|
142
|
+
expect(fieldNames).toContain('statusWhenLabelScanned');
|
|
143
|
+
expect(fieldNames).toContain('trackTraceInEmail');
|
|
144
|
+
expect(fieldNames).toContain('trackTraceInAccount');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not mutate the input view', () => {
|
|
149
|
+
const view = createFullView();
|
|
150
|
+
const originalOrderElements = [...(view.order.elements ?? [])];
|
|
151
|
+
const originalTabIds = Object.keys(view);
|
|
152
|
+
|
|
153
|
+
filterPluginSettingsView(view, OrderMode.OrderV2);
|
|
154
|
+
|
|
155
|
+
expect(Object.keys(view)).toEqual(originalTabIds);
|
|
156
|
+
expect(view.order.elements).toEqual(originalOrderElements);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {type Plugin} from '@myparcel-dev/pdk-common';
|
|
2
|
+
import {OrderMode} from '../../data';
|
|
3
|
+
|
|
4
|
+
const GENERAL_SECTION_HEADING = 'settings_order_general_title';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Settings only relevant when the plugin creates shipments via the shipments API —
|
|
8
|
+
* that covers Shipments mode and the V2 hybrid (manual export). Order v1 uses the
|
|
9
|
+
* fulfilment-orders path instead, so these settings are hidden there.
|
|
10
|
+
*
|
|
11
|
+
* @TODO INT-1590: revisit when sales-channel detection lands — under V2 with an
|
|
12
|
+
* active sales channel these settings should be hidden again.
|
|
13
|
+
*/
|
|
14
|
+
const SHIPMENT_EXPORT_FIELDS: ReadonlySet<string> = new Set(['conceptShipments', 'saveCustomerAddress']);
|
|
15
|
+
|
|
16
|
+
// @TODO INT-1590: restore tab hiding once sales-channel detection lands; under V2
|
|
17
|
+
// with an active sales channel the label and customs tabs become irrelevant
|
|
18
|
+
// again (the sales channel handles fulfilment end-to-end).
|
|
19
|
+
// const ORDER_V2_HIDDEN_TABS: ReadonlySet<string> = new Set(['label', 'customs']);
|
|
20
|
+
|
|
21
|
+
const isSettingsDivider = (field: Plugin.Field): boolean => {
|
|
22
|
+
return field.$component === 'SettingsDivider';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isGeneralDivider = (field: Plugin.Field): boolean => {
|
|
26
|
+
return isSettingsDivider(field) && (field as Record<string, unknown>).heading === GENERAL_SECTION_HEADING;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const filterOrderElements = (elements: Plugin.Field[], orderMode: OrderMode): Plugin.Field[] => {
|
|
30
|
+
let inGeneralSection = false;
|
|
31
|
+
|
|
32
|
+
return elements.filter((field) => {
|
|
33
|
+
if (isGeneralDivider(field)) {
|
|
34
|
+
inGeneralSection = true;
|
|
35
|
+
} else if (isSettingsDivider(field)) {
|
|
36
|
+
inGeneralSection = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!inGeneralSection) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// @TODO INT-1590: restore the V2-divider hiding for active-sales-channel state.
|
|
44
|
+
// if (isSettingsDivider(field)) {
|
|
45
|
+
// return orderMode !== OrderMode.OrderV2;
|
|
46
|
+
// }
|
|
47
|
+
|
|
48
|
+
if (SHIPMENT_EXPORT_FIELDS.has(field.name ?? '')) {
|
|
49
|
+
return orderMode !== OrderMode.OrderV1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// @TODO INT-1590: restore the V2 general-section hiding for active-sales-channel state.
|
|
53
|
+
// return orderMode !== OrderMode.OrderV2;
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Filter the plugin-settings view for the active order mode.
|
|
60
|
+
*
|
|
61
|
+
* Today the V2 hybrid surfaces all the same tabs and fields as Shipments mode so
|
|
62
|
+
* manual export keeps working. Only Order v1 hides the shipment-export-specific
|
|
63
|
+
* fields, because v1 uses fulfilment orders instead.
|
|
64
|
+
*
|
|
65
|
+
* @TODO INT-1590: once sales-channel detection lands, re-introduce tab hiding for
|
|
66
|
+
* V2 with an active sales channel — the label/customs tabs and shipment-export
|
|
67
|
+
* fields become irrelevant when the sales channel handles fulfilment
|
|
68
|
+
* end-to-end.
|
|
69
|
+
*/
|
|
70
|
+
export const filterPluginSettingsView = (
|
|
71
|
+
view: Plugin.ModelContextPluginSettingsViewContext,
|
|
72
|
+
orderMode: OrderMode,
|
|
73
|
+
): Plugin.ModelContextPluginSettingsViewContext => {
|
|
74
|
+
// @TODO INT-1590: restore the V2 tab-hiding step using ORDER_V2_HIDDEN_TABS
|
|
75
|
+
// (declared as commented constant above) once sales-channel detection lands.
|
|
76
|
+
// const entries = Object.entries(view).filter(([tabId]) => {
|
|
77
|
+
// return !(orderMode === OrderMode.OrderV2 && ORDER_V2_HIDDEN_TABS.has(tabId));
|
|
78
|
+
// });
|
|
79
|
+
const entries = Object.entries(view);
|
|
80
|
+
|
|
81
|
+
return Object.fromEntries(
|
|
82
|
+
entries.map(([tabId, tabView]) => {
|
|
83
|
+
if (tabId !== 'order' || !tabView.elements) {
|
|
84
|
+
return [tabId, tabView];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return [
|
|
88
|
+
tabId,
|
|
89
|
+
{
|
|
90
|
+
...tabView,
|
|
91
|
+
elements: filterOrderElements(tabView.elements, orderMode),
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
}),
|
|
95
|
+
) as Plugin.ModelContextPluginSettingsViewContext;
|
|
96
|
+
};
|
|
@@ -2,5 +2,6 @@ export * from './createFormTab';
|
|
|
2
2
|
export * from './createPluginSettingsForm';
|
|
3
3
|
export * from './createPluginSettingsTabs';
|
|
4
4
|
export * from './createPluginSettingsTabsComponent';
|
|
5
|
+
export * from './filterPluginSettingsView';
|
|
5
6
|
export * from './generateFormFields';
|
|
6
7
|
export * from './types';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
import {resolveSettingsValues} from './resolveSettingsValues';
|
|
3
|
+
|
|
4
|
+
describe('resolveSettingsValues', () => {
|
|
5
|
+
const pluginSettings = {
|
|
6
|
+
order: {
|
|
7
|
+
conceptShipments: true,
|
|
8
|
+
processDirectly: '-1',
|
|
9
|
+
},
|
|
10
|
+
carrier: {
|
|
11
|
+
'*': {
|
|
12
|
+
exportSignature: 0,
|
|
13
|
+
exportAgeCheck: 0,
|
|
14
|
+
allowSignature: true,
|
|
15
|
+
allowOnlyRecipient: true,
|
|
16
|
+
},
|
|
17
|
+
POSTNL: {
|
|
18
|
+
exportSignature: 1,
|
|
19
|
+
exportAgeCheck: 0,
|
|
20
|
+
allowSignature: true,
|
|
21
|
+
allowOnlyRecipient: false,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Direct match — carrier-specific settings exist.
|
|
27
|
+
it('returns carrier-specific values when they exist', () => {
|
|
28
|
+
const values = resolveSettingsValues(pluginSettings, 'carrier.POSTNL');
|
|
29
|
+
|
|
30
|
+
expect(values).toEqual(pluginSettings.carrier.POSTNL);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Wildcard fallback — no carrier-specific settings saved yet.
|
|
34
|
+
it('falls back to wildcard values when carrier key does not exist', () => {
|
|
35
|
+
const values = resolveSettingsValues(pluginSettings, 'carrier.DHL_FOR_YOU');
|
|
36
|
+
|
|
37
|
+
expect(values).toEqual(pluginSettings.carrier['*']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Top-level key without sub-keys — no wildcard needed.
|
|
41
|
+
it('resolves top-level settings directly', () => {
|
|
42
|
+
const values = resolveSettingsValues(pluginSettings, 'order');
|
|
43
|
+
|
|
44
|
+
expect(values).toEqual(pluginSettings.order);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Neither specific key nor wildcard exists — returns empty object.
|
|
48
|
+
it('returns empty object when neither key nor wildcard exists', () => {
|
|
49
|
+
const values = resolveSettingsValues(pluginSettings, 'nonexistent.section');
|
|
50
|
+
|
|
51
|
+
expect(values).toEqual({});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Carrier-specific values take precedence over wildcard.
|
|
55
|
+
it('does not merge wildcard — carrier-specific values fully override', () => {
|
|
56
|
+
const values = resolveSettingsValues(pluginSettings, 'carrier.POSTNL');
|
|
57
|
+
|
|
58
|
+
// POSTNL has allowOnlyRecipient: false, wildcard has true.
|
|
59
|
+
// Should use the carrier-specific value, not the wildcard.
|
|
60
|
+
expect(values).toHaveProperty('allowOnlyRecipient', false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {get as lodashGet} from 'lodash-unified';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve settings values for a given form ID, falling back to the wildcard
|
|
5
|
+
* key when no carrier-specific settings have been saved yet.
|
|
6
|
+
*
|
|
7
|
+
* Example: "carrier.POSTNL" → try pluginSettings.carrier.POSTNL first,
|
|
8
|
+
* fall back to pluginSettings.carrier.* if it doesn't exist.
|
|
9
|
+
*/
|
|
10
|
+
export const resolveSettingsValues = (
|
|
11
|
+
pluginSettings: Record<string, unknown>,
|
|
12
|
+
id: string,
|
|
13
|
+
): Record<string, unknown> => {
|
|
14
|
+
return (lodashGet(pluginSettings, id)
|
|
15
|
+
?? lodashGet(pluginSettings, id.replace(/\.[^.]+$/, '.*'), {})) as Record<string, unknown>;
|
|
16
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
|
2
|
+
import {useContext} from '../../composables';
|
|
3
|
+
import {FIELD_SHIPMENT_OPTIONS_PREFIX} from './field';
|
|
4
|
+
import {createShipmentOptionsForm} from './createShipmentOptionsForm';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../composables', () => ({
|
|
7
|
+
useAdminConfig: vi.fn(() => ({formConfigOverrides: {}})),
|
|
8
|
+
useContext: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('../../stores', () => ({
|
|
12
|
+
useModalStore: vi.fn(() => ({opened: undefined})),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../helpers', () => ({
|
|
16
|
+
addBulkEditNotification: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('../../utils', () => ({
|
|
20
|
+
createShipmentFormName: vi.fn(() => 'shipment-form'),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('@myparcel-dev/vue-form-builder', () => ({
|
|
24
|
+
defineForm: vi.fn((_name: string, config: {fields: unknown[]}) => config),
|
|
25
|
+
MagicForm: {name: 'MagicForm', render: () => null},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('./fields', () => ({
|
|
29
|
+
createDigitalStampRangeField: () => ({name: '__static_digitalStampRange'}),
|
|
30
|
+
createLabelAmountField: () => ({name: '__static_labelAmount'}),
|
|
31
|
+
createPackageTypeField: () => ({name: '__static_packageType'}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('./fields/createCarrierField', () => ({
|
|
35
|
+
createCarrierField: () => ({name: '__static_carrier'}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock('./fields/createDeliveryTypeField', () => ({
|
|
39
|
+
createDeliveryTypeField: () => ({name: '__static_deliveryType'}),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('./fields/createShipmentOptionField', () => ({
|
|
43
|
+
createShipmentOptionField: (_refs: unknown, name: string) => ({name}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('./fieldFactoryRegistry', () => ({
|
|
47
|
+
fieldFactoryRegistry: {},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const mockedUseContext = vi.mocked(useContext);
|
|
51
|
+
|
|
52
|
+
const dynamicOptionKeys = (form: unknown): string[] => {
|
|
53
|
+
const fields = (form as {fields: {name: string}[]}).fields;
|
|
54
|
+
const prefix = `${FIELD_SHIPMENT_OPTIONS_PREFIX}.`;
|
|
55
|
+
|
|
56
|
+
return fields.filter((f) => f.name.startsWith(prefix)).map((f) => f.name.slice(prefix.length));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe('createShipmentOptionsForm — dynamic option fields from carrier context', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
mockedUseContext.mockReset();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('creates a field for the union of option keys across carriers', () => {
|
|
65
|
+
mockedUseContext.mockReturnValue({
|
|
66
|
+
carriers: [
|
|
67
|
+
{options: {signature: {}, insurance: {}}},
|
|
68
|
+
{options: {insurance: {}, ageCheck: {}}},
|
|
69
|
+
],
|
|
70
|
+
} as never);
|
|
71
|
+
|
|
72
|
+
const form = createShipmentOptionsForm();
|
|
73
|
+
|
|
74
|
+
expect(dynamicOptionKeys(form).sort()).toEqual(['ageCheck', 'insurance', 'signature']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('does not throw when a carrier has no options field', () => {
|
|
78
|
+
mockedUseContext.mockReturnValue({carriers: [{}]} as never);
|
|
79
|
+
|
|
80
|
+
expect(() => createShipmentOptionsForm()).not.toThrow();
|
|
81
|
+
expect(dynamicOptionKeys(createShipmentOptionsForm())).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('does not throw when options is explicitly null', () => {
|
|
85
|
+
mockedUseContext.mockReturnValue({carriers: [{options: null}]} as never);
|
|
86
|
+
|
|
87
|
+
expect(() => createShipmentOptionsForm()).not.toThrow();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('skips carriers without options but still collects keys from siblings', () => {
|
|
91
|
+
mockedUseContext.mockReturnValue({
|
|
92
|
+
carriers: [
|
|
93
|
+
{options: {signature: {}}},
|
|
94
|
+
{},
|
|
95
|
+
{options: null},
|
|
96
|
+
{options: {ageCheck: {}}},
|
|
97
|
+
],
|
|
98
|
+
} as never);
|
|
99
|
+
|
|
100
|
+
expect(dynamicOptionKeys(createShipmentOptionsForm()).sort()).toEqual(['ageCheck', 'signature']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('produces no dynamic option fields for an empty carrier list', () => {
|
|
104
|
+
mockedUseContext.mockReturnValue({carriers: []} as never);
|
|
105
|
+
|
|
106
|
+
expect(dynamicOptionKeys(createShipmentOptionsForm())).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
});
|