$payload
+ */
+ protected function normalizeKeyValueFieldsInPayload(array &$payload): ?string
+ {
+ $keyValueFields = $this->getProductKeyValueFields();
+ if ($keyValueFields === []) {
+ return null;
+ }
+
+ $keyValueService = $this->getKeyValueFieldService();
+ $this->modx->lexicon->load('minishop3:default');
+
+ foreach ($keyValueFields as $fieldKey => $config) {
+ if (!array_key_exists($fieldKey, $payload)) {
+ continue;
+ }
+
+ try {
+ $payload[$fieldKey] = $keyValueService->processValue($payload[$fieldKey], $config);
+ } catch (\InvalidArgumentException $e) {
+ return $this->modx->lexicon('ms3_key_value_validation_error', [
+ 'field' => $fieldKey,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ return null;
+ }
}
diff --git a/vueManager/src/components/DynamicField.vue b/vueManager/src/components/DynamicField.vue
index 41533117..43941e40 100644
--- a/vueManager/src/components/DynamicField.vue
+++ b/vueManager/src/components/DynamicField.vue
@@ -198,6 +198,17 @@
/>
+
+
+
+
+
+
+
@@ -238,9 +249,11 @@ import Textarea from 'primevue/textarea'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref, watch } from 'vue'
+import { getKeyValueConfigFromField, parseKeyValueModelValue, serializeKeyValueForPost } from '../utils/keyValueField.js'
import { getRepeaterConfigFromField, parseRepeaterModelValue } from '../utils/repeaterField.js'
import AutocompleteCombo from './AutocompleteCombo.vue'
import FileBrowser from './FileBrowser.vue'
+import KeyValueField from './KeyValueField.vue'
import OptionsChips from './OptionsChips.vue'
import RepeaterField from './RepeaterField.vue'
import VendorCombo from './VendorCombo.vue'
@@ -311,7 +324,7 @@ const isFileBrowserXtype = computed(() => {
* Determine if field is complex type (requires hidden field with JSON)
*/
const isComplexField = computed(() => {
- const complexTypes = ['combobox', 'datefield', 'colorpicker', 'chips', 'multiselect', 'ms3-repeater']
+ const complexTypes = ['combobox', 'datefield', 'colorpicker', 'chips', 'multiselect']
return complexTypes.includes(props.fieldConfig.xtype)
})
@@ -346,11 +359,15 @@ const selectOptions = computed(() => {
})
const repeaterConfig = computed(() => getRepeaterConfigFromField(props.fieldConfig))
+const keyValueConfig = computed(() => getKeyValueConfigFromField(props.fieldConfig))
function normalizeIncomingValue(value) {
if (props.fieldConfig.xtype === 'ms3-repeater') {
return parseRepeaterModelValue(value)
}
+ if (props.fieldConfig.xtype === 'ms3-key-value') {
+ return parseKeyValueModelValue(value)
+ }
return value
}
diff --git a/vueManager/src/components/ExtraFieldsManager.vue b/vueManager/src/components/ExtraFieldsManager.vue
index 48cf3df3..d457bc7d 100644
--- a/vueManager/src/components/ExtraFieldsManager.vue
+++ b/vueManager/src/components/ExtraFieldsManager.vue
@@ -18,11 +18,17 @@ import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref, watch } from 'vue'
import request from '../request.js'
+import {
+ defaultKeyValueConfig,
+ KEY_VALUE_XTYPE,
+ parseKeyValueConfig,
+} from '../utils/keyValueField.js'
import {
defaultRepeaterConfig,
parseRepeaterConfig,
REPEATER_XTYPE,
} from '../utils/repeaterField.js'
+import KeyValueSchemaEditor from './KeyValueSchemaEditor.vue'
import RepeaterSchemaEditor from './RepeaterSchemaEditor.vue'
const toast = useToast()
@@ -58,6 +64,7 @@ const fieldForm = ref({
active: true,
select_options: '',
repeater_config: defaultRepeaterConfig(),
+ key_value_config: defaultKeyValueConfig(),
})
/**
@@ -94,6 +101,7 @@ const xtypeOptions = computed(() => [
{ label: _('ms3_vue_xtype_xcheckbox'), value: 'xcheckbox' },
{ label: _('ms3_vue_xtype_combo_select'), value: 'ms3-combo-select' },
{ label: _('ms3_vue_xtype_repeater'), value: REPEATER_XTYPE },
+ { label: _('ms3_vue_xtype_key_value'), value: KEY_VALUE_XTYPE },
{ label: _('ms3_vue_xtype_combo_vendor'), value: 'ms3-combo-vendor' },
{ label: _('ms3_vue_xtype_combo_autocomplete'), value: 'ms3-combo-autocomplete' },
{ label: _('ms3_vue_xtype_combo_options'), value: 'ms3-combo-options' },
@@ -147,20 +155,27 @@ const indexTypeOptions = computed(() => [
])
const isRepeaterField = computed(() => fieldForm.value.xtype === REPEATER_XTYPE)
+const isKeyValueField = computed(() => fieldForm.value.xtype === KEY_VALUE_XTYPE)
watch(
() => fieldForm.value.xtype,
xtype => {
- if (xtype !== REPEATER_XTYPE) {
- return
- }
-
- fieldForm.value.dbtype = 'json'
- fieldForm.value.phptype = 'json'
- fieldForm.value.precision = ''
- fieldForm.value.null = true
- if (!fieldForm.value.repeater_config?.columns?.length) {
- fieldForm.value.repeater_config = defaultRepeaterConfig()
+ if (xtype === REPEATER_XTYPE) {
+ fieldForm.value.dbtype = 'json'
+ fieldForm.value.phptype = 'json'
+ fieldForm.value.precision = ''
+ fieldForm.value.null = true
+ if (!fieldForm.value.repeater_config?.columns?.length) {
+ fieldForm.value.repeater_config = defaultRepeaterConfig()
+ }
+ } else if (xtype === KEY_VALUE_XTYPE) {
+ fieldForm.value.dbtype = 'json'
+ fieldForm.value.phptype = 'json'
+ fieldForm.value.precision = ''
+ fieldForm.value.null = true
+ if (!fieldForm.value.key_value_config?.mode) {
+ fieldForm.value.key_value_config = defaultKeyValueConfig()
+ }
}
}
)
@@ -169,6 +184,10 @@ function buildRepeaterConfigPayload() {
return JSON.stringify(parseRepeaterConfig(fieldForm.value.repeater_config))
}
+function buildKeyValueConfigPayload() {
+ return JSON.stringify(parseKeyValueConfig(fieldForm.value.key_value_config))
+}
+
/**
* Load fields list
*/
@@ -223,6 +242,7 @@ function openCreateDialog() {
active: true,
select_options: '',
repeater_config: defaultRepeaterConfig(),
+ key_value_config: defaultKeyValueConfig(),
}
dialogVisible.value = true
@@ -254,6 +274,7 @@ function openEditDialog(field) {
active: field.active,
select_options: field.select_options || '',
repeater_config: parseRepeaterConfig(field.repeater_config),
+ key_value_config: parseKeyValueConfig(field.key_value_config),
}
dialogVisible.value = true
@@ -316,6 +337,7 @@ async function createField() {
fieldForm.value.active === 'true' ||
fieldForm.value.active === 1,
repeater_config: isRepeaterField.value ? buildRepeaterConfigPayload() : '',
+ key_value_config: isKeyValueField.value ? buildKeyValueConfigPayload() : '',
}
delete payload.id
@@ -367,6 +389,7 @@ async function updateField() {
select_options:
fieldForm.value.xtype === 'ms3-combo-select' ? fieldForm.value.select_options : '',
repeater_config: isRepeaterField.value ? buildRepeaterConfigPayload() : '',
+ key_value_config: isKeyValueField.value ? buildKeyValueConfigPayload() : '',
}
const response = await request.put(`/api/mgr/extra-fields/${fieldForm.value.id}`, payload)
@@ -720,6 +743,13 @@ onMounted(() => {
{{ _('ms3_vue_repeater_schema_help') }}
+
+
+
+
+
+ {{ _('ms3_vue_key_value_schema_help') }}
+
@@ -737,7 +767,7 @@ onMounted(() => {
option-value="value"
:placeholder="_('ms3_vue_dialog_xtype_select')"
class="w-full"
- :disabled="isEditMode || isRepeaterField"
+ :disabled="isEditMode || isRepeaterField || isKeyValueField"
/>
@@ -764,7 +794,7 @@ onMounted(() => {
option-value="value"
:placeholder="_('ms3_vue_dialog_xtype_select')"
class="w-full"
- :disabled="isEditMode || isRepeaterField"
+ :disabled="isEditMode || isRepeaterField || isKeyValueField"
/>
diff --git a/vueManager/src/components/KeyValueField.vue b/vueManager/src/components/KeyValueField.vue
new file mode 100644
index 00000000..b483ff93
--- /dev/null
+++ b/vueManager/src/components/KeyValueField.vue
@@ -0,0 +1,263 @@
+
+
+
+
+
+
+
+ {{ _('ms3_vue_key_value_no_keys') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vueManager/src/components/KeyValueSchemaEditor.vue b/vueManager/src/components/KeyValueSchemaEditor.vue
new file mode 100644
index 00000000..b3e763e0
--- /dev/null
+++ b/vueManager/src/components/KeyValueSchemaEditor.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ _('ms3_vue_key_value_no_keys') }}
+
+
+
+
+
+
diff --git a/vueManager/src/components/order/OrderExtraFieldsSection.vue b/vueManager/src/components/order/OrderExtraFieldsSection.vue
index 0c75e98e..3d04a7a5 100644
--- a/vueManager/src/components/order/OrderExtraFieldsSection.vue
+++ b/vueManager/src/components/order/OrderExtraFieldsSection.vue
@@ -4,6 +4,7 @@ import Fieldset from 'primevue/fieldset'
import { computed, inject } from 'vue'
import { ORDER_CONTEXT_KEY } from '../../composables/orderContext.js'
+import { KEY_VALUE_XTYPE } from '../../utils/keyValueField.js'
import { REPEATER_XTYPE } from '../../utils/repeaterField.js'
import DynamicField from '../DynamicField.vue'
@@ -31,13 +32,15 @@ function getFieldConfig(field) {
description: field.description || '',
config: {
repeater_config: field.repeater_config,
+ key_value_config: field.key_value_config,
},
repeater_config: field.repeater_config,
+ key_value_config: field.key_value_config,
}
}
function getWidthClass(field) {
- return field.xtype === REPEATER_XTYPE ? 'col-12' : 'col-6'
+ return [REPEATER_XTYPE, KEY_VALUE_XTYPE].includes(field.xtype) ? 'col-12' : 'col-6'
}
diff --git a/vueManager/src/utils/keyValueField.js b/vueManager/src/utils/keyValueField.js
new file mode 100644
index 00000000..86c9484f
--- /dev/null
+++ b/vueManager/src/utils/keyValueField.js
@@ -0,0 +1,111 @@
+export const KEY_VALUE_XTYPE = 'ms3-key-value'
+
+export function defaultKeyValueConfig() {
+ return {
+ mode: 'free', // 'free' or 'fixed'
+ keys: [], // Array of { key: string, label: string, valueType: 'string'|'number', required: boolean }
+ }
+}
+
+/**
+ * Example fixed keys config (optional):
+ * {
+ * mode: 'fixed',
+ * keys: [
+ * { key: 'width', label: 'Width', valueType: 'number', required: true },
+ * { key: 'color', label: 'Color', valueType: 'string', required: false }
+ * ]
+ * }
+ */
+
+export function parseKeyValueConfig(raw) {
+ if (!raw) {
+ return defaultKeyValueConfig()
+ }
+
+ let parsed = raw
+ if (typeof raw === 'string') {
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ return defaultKeyValueConfig()
+ }
+ }
+
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return defaultKeyValueConfig()
+ }
+
+ const defaults = defaultKeyValueConfig()
+
+ return {
+ ...defaults,
+ ...parsed,
+ keys: Array.isArray(parsed.keys) ? parsed.keys : defaults.keys,
+ }
+}
+
+export function parseKeyValueModelValue(value) {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ return value
+ }
+ if (typeof value === 'string' && value.trim() !== '') {
+ try {
+ const parsed = JSON.parse(value)
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
+ } catch {
+ return {}
+ }
+ }
+ return {}
+}
+
+export function normalizeKeyValueMap(map, config) {
+ const schema = parseKeyValueConfig(config)
+ const normalized = {}
+
+ if (schema.mode === 'fixed') {
+ for (const keyDef of schema.keys) {
+ if (!keyDef.key) continue
+ const value = map?.[keyDef.key]
+ if (keyDef.valueType === 'number') {
+ normalized[keyDef.key] = (value === '' || value === null || value === undefined)
+ ? null
+ : Number(value)
+ } else {
+ normalized[keyDef.key] = value ?? ''
+ }
+ }
+ } else {
+ // Free mode: just ensure it's a flat object of scalars
+ if (map && typeof map === 'object' && !Array.isArray(map)) {
+ for (const [k, v] of Object.entries(map)) {
+ normalized[k] = v
+ }
+ }
+ }
+
+ return normalized
+}
+
+export function serializeKeyValueForPost(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return '{}'
+ }
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return '{}'
+ }
+}
+
+export function getKeyValueConfigFromField(fieldConfig) {
+ if (!fieldConfig) {
+ return defaultKeyValueConfig()
+ }
+ return parseKeyValueConfig(
+ fieldConfig.config?.key_value_config
+ ?? fieldConfig.key_value_config
+ ?? fieldConfig.config
+ )
+}