$payload
+ */
+ protected function normalizeRepeaterFieldsInPayload(array &$payload): ?string
+ {
+ $repeaterFields = $this->getProductRepeaterFields();
+ if ($repeaterFields === []) {
+ return null;
+ }
+
+ $repeaterService = $this->getRepeaterFieldService();
+ $this->modx->lexicon->load('minishop3:default');
+
+ foreach ($repeaterFields as $fieldKey => $config) {
+ if (!array_key_exists($fieldKey, $payload)) {
+ continue;
+ }
+
+ try {
+ $payload[$fieldKey] = $repeaterService->processValue($payload[$fieldKey], $config);
+ } catch (\InvalidArgumentException $e) {
+ return $this->modx->lexicon('ms3_repeater_validation_error', [
+ 'field' => $fieldKey,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ return null;
+ }
}
diff --git a/vueManager/src/components/DynamicField.vue b/vueManager/src/components/DynamicField.vue
index 88f74e73..a9ef8bf3 100644
--- a/vueManager/src/components/DynamicField.vue
+++ b/vueManager/src/components/DynamicField.vue
@@ -182,6 +182,14 @@
+
+
+
@@ -222,9 +230,11 @@ import Textarea from 'primevue/textarea'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref, watch } from 'vue'
+import { getRepeaterConfigFromField, parseRepeaterModelValue } from '../utils/repeaterField.js'
import AutocompleteCombo from './AutocompleteCombo.vue'
import FileBrowser from './FileBrowser.vue'
import OptionsChips from './OptionsChips.vue'
+import RepeaterField from './RepeaterField.vue'
import VendorCombo from './VendorCombo.vue'
const props = defineProps({
@@ -310,12 +320,6 @@ const isExtJSComboField = computed(() => {
return props.fieldConfig.xtype.startsWith('ms3-combo-')
})
-/**
- * Parse select_options string into array for ms3-combo-select
- * Format: "value1==label1\nvalue2==label2" or just "value1\nvalue2"
- * Note: select_options may be in fieldConfig.config.select_options or fieldConfig.select_options
- * depending on how the config was merged in PHP
- */
const selectOptions = computed(() => {
const optionsString =
props.fieldConfig.config?.select_options || props.fieldConfig.select_options || ''
@@ -333,6 +337,15 @@ const selectOptions = computed(() => {
})
})
+const repeaterConfig = computed(() => getRepeaterConfigFromField(props.fieldConfig))
+
+function normalizeIncomingValue(value) {
+ if (props.fieldConfig.xtype === 'ms3-repeater') {
+ return parseRepeaterModelValue(value)
+ }
+ return value
+}
+
/**
* Get ExtJS combo field description
*/
@@ -371,13 +384,13 @@ const serializedValue = computed(() => {
const emit = defineEmits(['update:modelValue', 'blur'])
// Local value for v-model
-const localValue = ref(props.modelValue)
+const localValue = ref(normalizeIncomingValue(props.modelValue))
// Watch for external changes
watch(
() => props.modelValue,
newValue => {
- localValue.value = newValue
+ localValue.value = normalizeIncomingValue(newValue)
}
)
diff --git a/vueManager/src/components/ExtraFieldsManager.vue b/vueManager/src/components/ExtraFieldsManager.vue
index 57e53a57..48cf3df3 100644
--- a/vueManager/src/components/ExtraFieldsManager.vue
+++ b/vueManager/src/components/ExtraFieldsManager.vue
@@ -15,9 +15,15 @@ import Textarea from 'primevue/textarea'
import Toast from 'primevue/toast'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
-import { computed, onMounted, ref } from 'vue'
+import { computed, onMounted, ref, watch } from 'vue'
import request from '../request.js'
+import {
+ defaultRepeaterConfig,
+ parseRepeaterConfig,
+ REPEATER_XTYPE,
+} from '../utils/repeaterField.js'
+import RepeaterSchemaEditor from './RepeaterSchemaEditor.vue'
const toast = useToast()
const confirm = useConfirm()
@@ -51,6 +57,7 @@ const fieldForm = ref({
index_type: 'NONE',
active: true,
select_options: '',
+ repeater_config: defaultRepeaterConfig(),
})
/**
@@ -86,6 +93,7 @@ const xtypeOptions = computed(() => [
{ label: _('ms3_vue_xtype_textarea'), value: 'textarea' },
{ 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_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' },
@@ -138,6 +146,29 @@ const indexTypeOptions = computed(() => [
{ label: _('ms3_vue_index_fulltext'), value: 'FULLTEXT' },
])
+const isRepeaterField = computed(() => fieldForm.value.xtype === REPEATER_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()
+ }
+ }
+)
+
+function buildRepeaterConfigPayload() {
+ return JSON.stringify(parseRepeaterConfig(fieldForm.value.repeater_config))
+}
+
/**
* Load fields list
*/
@@ -191,6 +222,7 @@ function openCreateDialog() {
index_type: 'NONE',
active: true,
select_options: '',
+ repeater_config: defaultRepeaterConfig(),
}
dialogVisible.value = true
@@ -221,6 +253,7 @@ function openEditDialog(field) {
index_type: field.index_type || 'NONE',
active: field.active,
select_options: field.select_options || '',
+ repeater_config: parseRepeaterConfig(field.repeater_config),
}
dialogVisible.value = true
@@ -282,8 +315,11 @@ async function createField() {
fieldForm.value.active === true ||
fieldForm.value.active === 'true' ||
fieldForm.value.active === 1,
+ repeater_config: isRepeaterField.value ? buildRepeaterConfigPayload() : '',
}
+ delete payload.id
+
const response = await request.post('/api/mgr/extra-fields', payload)
if (response && response.field) {
@@ -330,6 +366,7 @@ async function updateField() {
fieldForm.value.active === 1,
select_options:
fieldForm.value.xtype === 'ms3-combo-select' ? fieldForm.value.select_options : '',
+ repeater_config: isRepeaterField.value ? buildRepeaterConfigPayload() : '',
}
const response = await request.put(`/api/mgr/extra-fields/${fieldForm.value.id}`, payload)
@@ -676,6 +713,13 @@ onMounted(() => {
/>
{{ _('ms3_vue_select_options_help') }}
+
+
+
+
+
+ {{ _('ms3_vue_repeater_schema_help') }}
+
@@ -693,7 +737,7 @@ onMounted(() => {
option-value="value"
:placeholder="_('ms3_vue_dialog_xtype_select')"
class="w-full"
- :disabled="isEditMode"
+ :disabled="isEditMode || isRepeaterField"
/>
@@ -705,7 +749,7 @@ onMounted(() => {
v-model="fieldForm.precision"
:placeholder="_('ms3_vue_dialog_precision_placeholder')"
class="w-full"
- :disabled="isEditMode"
+ :disabled="isEditMode || isRepeaterField"
/>
@@ -720,7 +764,7 @@ onMounted(() => {
option-value="value"
:placeholder="_('ms3_vue_dialog_xtype_select')"
class="w-full"
- :disabled="isEditMode"
+ :disabled="isEditMode || isRepeaterField"
/>
diff --git a/vueManager/src/components/OrderView.vue b/vueManager/src/components/OrderView.vue
index 22fc1153..589f7286 100644
--- a/vueManager/src/components/OrderView.vue
+++ b/vueManager/src/components/OrderView.vue
@@ -23,6 +23,7 @@ import { useOrderFieldHelpers } from '../composables/useOrderFieldHelpers.js'
import { useOrderFormatters } from '../composables/useOrderFormatters.js'
import request from '../request.js'
import { normalizeOrderPluginTab } from '../utils/orderPluginTab.js'
+import { parseRepeaterModelValue, REPEATER_XTYPE } from '../utils/repeaterField.js'
import OrderAddressTab from './order/OrderAddressTab.vue'
import OrderHistoryTab from './order/OrderHistoryTab.vue'
import OrderInfoTab from './order/OrderInfoTab.vue'
@@ -246,6 +247,21 @@ function groupFieldsBySection(fields, sections) {
/**
* Load order data
*/
+/**
+ * Parse repeater extra field values from API (string JSON → array).
+ */
+function hydrateRepeaterExtraFields(fields) {
+ if (!order.value || !Array.isArray(fields)) {
+ return
+ }
+
+ for (const field of fields) {
+ if (field.xtype === REPEATER_XTYPE && field.key) {
+ order.value[field.key] = parseRepeaterModelValue(order.value[field.key])
+ }
+ }
+}
+
async function loadOrder() {
if (!orderId.value) {
toast.add({
@@ -275,6 +291,9 @@ async function loadOrder() {
loadAddressExtraFields(),
loadOrderCustomer(),
])
+
+ hydrateRepeaterExtraFields(orderExtraFields.value)
+ hydrateRepeaterExtraFields(addressExtraFields.value)
} catch (error) {
console.error('[OrderView] Error loading order:', error)
toast.add({
@@ -2137,6 +2156,7 @@ onMounted(async () => {
{
v-model:selected-customer="selectedCustomer"
v-model:create-customer-from-data="createCustomerFromData"
:address-fields-by-section="addressFieldsBySection"
+ :address-extra-fields="addressExtraFields"
/>
diff --git a/vueManager/src/components/ProductDataFields.vue b/vueManager/src/components/ProductDataFields.vue
index 49313577..848d2af6 100644
--- a/vueManager/src/components/ProductDataFields.vue
+++ b/vueManager/src/components/ProductDataFields.vue
@@ -7,6 +7,7 @@ import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref } from 'vue'
import request from '../request.js'
+import { parseRepeaterModelValue, REPEATER_XTYPE } from '../utils/repeaterField.js'
import DynamicField from './DynamicField.vue'
const props = defineProps({
@@ -106,6 +107,8 @@ async function loadConfig() {
} else {
value = parseInt(value) || 0
}
+ } else if (field.xtype === REPEATER_XTYPE) {
+ value = parseRepeaterModelValue(value)
}
fieldValues.value[fieldName] = value
@@ -260,7 +263,7 @@ onMounted(() => {
:key="field.name"
:class="[
'field-item',
- `col-${field.width || 4}`,
+ field.xtype === REPEATER_XTYPE ? 'col-12' : `col-${field.width || 4}`,
{ 'field-checkbox': field.xtype === 'xcheckbox' || field.xtype === 'checkbox' },
]"
>
diff --git a/vueManager/src/components/RepeaterField.vue b/vueManager/src/components/RepeaterField.vue
new file mode 100644
index 00000000..23c5354f
--- /dev/null
+++ b/vueManager/src/components/RepeaterField.vue
@@ -0,0 +1,245 @@
+
+
+
+
+
+ {{ _('ms3_vue_repeater_no_columns') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vueManager/src/components/RepeaterSchemaEditor.vue b/vueManager/src/components/RepeaterSchemaEditor.vue
new file mode 100644
index 00000000..5d1a9a7c
--- /dev/null
+++ b/vueManager/src/components/RepeaterSchemaEditor.vue
@@ -0,0 +1,209 @@
+
+
+
+
+
+
+
+ {{ _('ms3_vue_repeater_columns') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vueManager/src/components/order/OrderAddressTab.vue b/vueManager/src/components/order/OrderAddressTab.vue
index 073998cf..a7d638c5 100644
--- a/vueManager/src/components/order/OrderAddressTab.vue
+++ b/vueManager/src/components/order/OrderAddressTab.vue
@@ -11,6 +11,7 @@ import Textarea from 'primevue/textarea'
import { computed, inject } from 'vue'
import { ORDER_CONTEXT_KEY } from '../../composables/orderContext.js'
+import OrderExtraFieldsSection from './OrderExtraFieldsSection.vue'
import OrderFormActionsBar from './OrderFormActionsBar.vue'
const selectedCustomer = defineModel('selectedCustomer', {
@@ -24,6 +25,7 @@ const createCustomerFromData = defineModel('createCustomerFromData', {
const props = defineProps({
addressFieldsBySection: { type: Array, required: true },
+ addressExtraFields: { type: Array, default: () => [] },
})
const orderCtx = inject(ORDER_CONTEXT_KEY, null)
@@ -59,12 +61,18 @@ const hasAddressFieldSections = computed(
() => (props.addressFieldsBySection?.length ?? 0) > 0
)
+const hasAddressExtraFields = computed(() => (props.addressExtraFields?.length ?? 0) > 0)
+
/**
* Скрыть «Сохранить»/«Отмена», если нет полей адреса и нет сценария с клиентом (#182):
* create / draft (блок выбора клиента) / есть секции полей.
*/
const showAddressTabActions = computed(
- () => isCreateMode.value || isDraft.value || hasAddressFieldSections.value
+ () =>
+ isCreateMode.value ||
+ isDraft.value ||
+ hasAddressFieldSections.value ||
+ hasAddressExtraFields.value
)
@@ -218,7 +226,15 @@ const showAddressTabActions = computed(
-
+
+
+
{{ _('ms3_order_tab_address_model_fields_empty_hint') }}
diff --git a/vueManager/src/components/order/OrderExtraFieldsSection.vue b/vueManager/src/components/order/OrderExtraFieldsSection.vue
new file mode 100644
index 00000000..0c75e98e
--- /dev/null
+++ b/vueManager/src/components/order/OrderExtraFieldsSection.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
diff --git a/vueManager/src/components/order/OrderInfoTab.vue b/vueManager/src/components/order/OrderInfoTab.vue
index 7dd7998d..1daa6ec4 100644
--- a/vueManager/src/components/order/OrderInfoTab.vue
+++ b/vueManager/src/components/order/OrderInfoTab.vue
@@ -12,10 +12,12 @@ import Textarea from 'primevue/textarea'
import { computed, inject, watch } from 'vue'
import { ORDER_CONTEXT_KEY } from '../../composables/orderContext.js'
+import OrderExtraFieldsSection from './OrderExtraFieldsSection.vue'
import OrderFormActionsBar from './OrderFormActionsBar.vue'
const props = defineProps({
orderFieldsBySection: { type: Array, required: true },
+ orderExtraFields: { type: Array, default: () => [] },
})
const orderCtx = inject(ORDER_CONTEXT_KEY, null)
@@ -90,9 +92,11 @@ const hasOrderFieldSections = computed(
() => (props.orderFieldsBySection?.length ?? 0) > 0
)
+const hasOrderExtraFields = computed(() => (props.orderExtraFields?.length ?? 0) > 0)
+
/** Скрыть «Сохранить»/«Отмена», если нет полей заказа (#182); в режиме создания кнопки нужны. */
const showOrderInfoActions = computed(
- () => isCreateMode.value || hasOrderFieldSections.value
+ () => isCreateMode.value || hasOrderFieldSections.value || hasOrderExtraFields.value
)
@@ -270,7 +274,9 @@ const showOrderInfoActions = computed(
-
+
+
+
{{ _('ms3_order_tab_info_model_fields_empty_hint') }}
diff --git a/vueManager/src/utils/repeaterField.js b/vueManager/src/utils/repeaterField.js
new file mode 100644
index 00000000..15ac49b0
--- /dev/null
+++ b/vueManager/src/utils/repeaterField.js
@@ -0,0 +1,105 @@
+export const REPEATER_XTYPE = 'ms3-repeater'
+
+export function defaultRepeaterConfig() {
+ return {
+ columns: [
+ { key: 'name', label: 'Name', xtype: 'textfield', required: true },
+ { key: 'value', label: 'Value', xtype: 'textfield', required: false },
+ ],
+ minRows: 0,
+ maxRows: null,
+ sortable: true,
+ rankField: 'rank',
+ }
+}
+
+export function parseRepeaterConfig(raw) {
+ if (!raw) {
+ return defaultRepeaterConfig()
+ }
+
+ let parsed = raw
+ if (typeof raw === 'string') {
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ return defaultRepeaterConfig()
+ }
+ }
+
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return defaultRepeaterConfig()
+ }
+
+ const defaults = defaultRepeaterConfig()
+
+ return {
+ ...defaults,
+ ...parsed,
+ columns: Array.isArray(parsed.columns) && parsed.columns.length
+ ? parsed.columns
+ : defaults.columns,
+ }
+}
+
+export function normalizeRepeaterRows(rows, config) {
+ const schema = parseRepeaterConfig(config)
+ const columns = schema.columns || []
+
+ return (Array.isArray(rows) ? rows : []).map((row, index) => {
+ const normalized = {}
+ for (const column of columns) {
+ if (!column.key) {
+ continue
+ }
+ const value = row?.[column.key]
+ if (column.xtype === 'numberfield') {
+ normalized[column.key] = value === '' || value === null || value === undefined
+ ? null
+ : Number(value)
+ } else {
+ normalized[column.key] = value ?? ''
+ }
+ }
+ normalized._ms3RowId = row?._ms3RowId ?? `row-${index}-${Date.now()}`
+ return normalized
+ })
+}
+
+export function stripRepeaterRowMeta(rows, config) {
+ const schema = parseRepeaterConfig(config)
+ const rankField = schema.rankField || 'rank'
+
+ return (Array.isArray(rows) ? rows : []).map(row => {
+ const clean = { ...row }
+ delete clean._ms3RowId
+ delete clean[rankField]
+ return clean
+ })
+}
+
+export function parseRepeaterModelValue(value) {
+ if (Array.isArray(value)) {
+ return value
+ }
+ if (typeof value === 'string' && value.trim() !== '') {
+ try {
+ const parsed = JSON.parse(value)
+ return Array.isArray(parsed) ? parsed : []
+ } catch {
+ return []
+ }
+ }
+ return []
+}
+
+export function getRepeaterConfigFromField(fieldConfig) {
+ if (!fieldConfig) {
+ return defaultRepeaterConfig()
+ }
+ return parseRepeaterConfig(
+ fieldConfig.config?.repeater_config
+ ?? fieldConfig.repeater_config
+ ?? fieldConfig.config
+ )
+}