diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index 3e59cca7d..14e504192 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -165,6 +165,7 @@ $_lang['ms3_vue_xtype_combo_vendor'] = 'Vendor (combo)'; $_lang['ms3_vue_xtype_combo_autocomplete'] = 'Autocomplete (combo)'; $_lang['ms3_vue_xtype_combo_options'] = 'Product Options (chips)'; +$_lang['ms3_vue_xtype_datefield'] = 'Date'; // Dropdown list settings $_lang['ms3_vue_select_options_label'] = 'List Options'; @@ -215,6 +216,7 @@ $_lang['ms3_vue_dbtype_text'] = 'TEXT (text)'; $_lang['ms3_vue_dbtype_int'] = 'INT (integer)'; $_lang['ms3_vue_dbtype_decimal'] = 'DECIMAL (decimal)'; +$_lang['ms3_vue_dbtype_date'] = 'DATE (date only)'; $_lang['ms3_vue_dbtype_datetime'] = 'DATETIME (date and time)'; $_lang['ms3_vue_dbtype_timestamp'] = 'TIMESTAMP'; $_lang['ms3_vue_dbtype_tinyint'] = 'TINYINT (0/1)'; diff --git a/core/components/minishop3/lexicon/ru/vue.inc.php b/core/components/minishop3/lexicon/ru/vue.inc.php index 64d600371..6c8705e36 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -165,6 +165,7 @@ $_lang['ms3_vue_xtype_combo_vendor'] = 'Производитель (combo)'; $_lang['ms3_vue_xtype_combo_autocomplete'] = 'Автодополнение (combo)'; $_lang['ms3_vue_xtype_combo_options'] = 'Опции товара (chips)'; +$_lang['ms3_vue_xtype_datefield'] = 'Дата'; // Настройки выпадающего списка $_lang['ms3_vue_select_options_label'] = 'Варианты списка'; @@ -215,6 +216,7 @@ $_lang['ms3_vue_dbtype_text'] = 'TEXT (текст)'; $_lang['ms3_vue_dbtype_int'] = 'INT (целое число)'; $_lang['ms3_vue_dbtype_decimal'] = 'DECIMAL (число с точностью)'; +$_lang['ms3_vue_dbtype_date'] = 'DATE (только дата)'; $_lang['ms3_vue_dbtype_datetime'] = 'DATETIME (дата и время)'; $_lang['ms3_vue_dbtype_timestamp'] = 'TIMESTAMP'; $_lang['ms3_vue_dbtype_tinyint'] = 'TINYINT (0/1)'; diff --git a/vueManager/src/components/DynamicField.vue b/vueManager/src/components/DynamicField.vue index 8cb5e2883..2d063586a 100644 --- a/vueManager/src/components/DynamicField.vue +++ b/vueManager/src/components/DynamicField.vue @@ -92,19 +92,25 @@ /> - + Unknown field type: {{ fieldConfig.xtype }} - - + @@ -256,9 +261,10 @@ import Textarea from 'primevue/textarea' import ToggleSwitch from 'primevue/toggleswitch' import { computed, ref, watch } from 'vue' +import { formatLocalDateYmd } from '../utils/formatLocalDateYmd.js' import { getKeyValueConfigFromField, serializeKeyValueForPost } from '../utils/keyValueField.js' import { getRepeaterConfigFromField } from '../utils/repeaterField.js' -import { parseStructuredExtraFieldValue } from '../utils/structuredExtraField.js' +import { DATEFIELD_XTYPE, parseDateFieldValue, parseStructuredExtraFieldValue } from '../utils/structuredExtraField.js' import AutocompleteCombo from './AutocompleteCombo.vue' import FileBrowser from './FileBrowser.vue' import KeyValueField from './KeyValueField.vue' @@ -332,7 +338,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'] + const complexTypes = ['combobox', 'colorpicker', 'chips', 'multiselect'] return complexTypes.includes(props.fieldConfig.xtype) }) @@ -368,11 +374,28 @@ const selectOptions = computed(() => { const repeaterConfig = computed(() => getRepeaterConfigFromField(props.fieldConfig)) const keyValueConfig = computed(() => getKeyValueConfigFromField(props.fieldConfig)) +const isDateField = computed(() => props.fieldConfig.xtype === DATEFIELD_XTYPE) function normalizeIncomingValue(value) { return parseStructuredExtraFieldValue(props.fieldConfig.xtype, value) } +function normalizedDateString(value) { + if (value == null || value === '') { + return null + } + + if (typeof value === 'string') { + return value.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? value + } + + return formatLocalDateYmd(value) +} + +function sameCalendarDay(left, right) { + return normalizedDateString(left) === normalizedDateString(right) +} + /** * Serialise the repeater value for the hidden legacy-form input. * RepeaterField emits an array; the processor expects JSON string or array. @@ -426,27 +449,61 @@ const serializedValue = computed(() => { const emit = defineEmits(['update:modelValue', 'blur']) -// Local value for v-model -const localValue = ref(normalizeIncomingValue(props.modelValue)) +// Local value for v-model (non-date fields) +const localValue = ref( + isDateField.value ? null : normalizeIncomingValue(props.modelValue) +) + +// DatePicker uses Date internally; parent state stays YYYY-MM-DD string +const datePickerValue = ref( + isDateField.value ? parseDateFieldValue(props.modelValue) : null +) // Watch for external changes watch( () => props.modelValue, newValue => { + if (isDateField.value) { + const parsed = parseDateFieldValue(newValue) + if (!sameCalendarDay(datePickerValue.value, parsed)) { + datePickerValue.value = parsed + } + return + } + localValue.value = normalizeIncomingValue(newValue) } ) // Watch for local changes and emit to parent watch(localValue, newValue => { + if (isDateField.value) { + return + } + emit('update:modelValue', newValue) }) +watch(datePickerValue, newDate => { + if (!isDateField.value) { + return + } + + const serialized = formatLocalDateYmd(newDate) ?? null + if (sameCalendarDay(serialized, props.modelValue)) { + return + } + + emit('update:modelValue', serialized) +}) + // Handle blur event const handleBlur = () => { emit('blur', { fieldId: props.fieldConfig.id, - value: localValue.value, + value: isDateField.value + ? (formatLocalDateYmd(datePickerValue.value) ?? null) + : localValue.value, }) } diff --git a/vueManager/src/components/ExtraFieldsManager.vue b/vueManager/src/components/ExtraFieldsManager.vue index 545ba44f5..e6e3f300d 100644 --- a/vueManager/src/components/ExtraFieldsManager.vue +++ b/vueManager/src/components/ExtraFieldsManager.vue @@ -29,6 +29,7 @@ import { parseRepeaterConfig, REPEATER_XTYPE, } from '../utils/repeaterField.js' +import { DATEFIELD_XTYPE } from '../utils/structuredExtraField.js' import KeyValueSchemaEditor from './KeyValueSchemaEditor.vue' import RepeaterSchemaEditor from './RepeaterSchemaEditor.vue' @@ -106,6 +107,7 @@ const xtypeOptions = computed(() => [ { 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' }, + { label: _('ms3_vue_xtype_datefield'), value: DATEFIELD_XTYPE }, ]) /** @@ -116,6 +118,7 @@ const dbtypeOptions = computed(() => [ { label: _('ms3_vue_dbtype_text'), value: 'text' }, { label: _('ms3_vue_dbtype_int'), value: 'int' }, { label: _('ms3_vue_dbtype_decimal'), value: 'decimal' }, + { label: _('ms3_vue_dbtype_date'), value: 'date' }, { label: _('ms3_vue_dbtype_datetime'), value: 'datetime' }, { label: _('ms3_vue_dbtype_timestamp'), value: 'timestamp' }, { label: _('ms3_vue_dbtype_tinyint'), value: 'tinyint' }, @@ -158,18 +161,20 @@ const indexTypeOptions = computed(() => [ const isRepeaterField = computed(() => fieldForm.value.xtype === REPEATER_XTYPE) const isKeyValueField = computed(() => fieldForm.value.xtype === KEY_VALUE_XTYPE) +const XTYPE_DB_DEFAULTS = { + [REPEATER_XTYPE]: { dbtype: 'json', phptype: 'json', precision: '', null: true }, + [KEY_VALUE_XTYPE]: { dbtype: 'json', phptype: 'json', precision: '', null: true }, + [DATEFIELD_XTYPE]: { dbtype: 'date', phptype: 'datetime', precision: '', null: true }, +} + watch( () => fieldForm.value.xtype, xtype => { - if (xtype !== REPEATER_XTYPE && xtype !== KEY_VALUE_XTYPE) { - return + const defaults = XTYPE_DB_DEFAULTS[xtype] + if (defaults) { + Object.assign(fieldForm.value, defaults) } - fieldForm.value.dbtype = 'json' - fieldForm.value.phptype = 'json' - fieldForm.value.precision = '' - fieldForm.value.null = true - if (xtype === REPEATER_XTYPE && !fieldForm.value.repeater_config?.columns?.length) { fieldForm.value.repeater_config = defaultRepeaterConfig() } diff --git a/vueManager/src/utils/structuredExtraField.js b/vueManager/src/utils/structuredExtraField.js index f83c14506..301232fec 100644 --- a/vueManager/src/utils/structuredExtraField.js +++ b/vueManager/src/utils/structuredExtraField.js @@ -1,13 +1,46 @@ import { KEY_VALUE_XTYPE, parseKeyValueModelValue } from './keyValueField.js' import { parseRepeaterModelValue, REPEATER_XTYPE } from './repeaterField.js' +export const DATEFIELD_XTYPE = 'datefield' + const STRUCTURED_EXTRA_FIELD_PARSERS = { [REPEATER_XTYPE]: parseRepeaterModelValue, [KEY_VALUE_XTYPE]: parseKeyValueModelValue, } +const FULL_WIDTH_EXTRA_FIELD_XTYPES = new Set([REPEATER_XTYPE, KEY_VALUE_XTYPE]) + +/** + * Parse stored date (YYYY-MM-DD or ISO) into a local Date for DatePicker. + * + * @param {string|number|Date|null|undefined} value + * @returns {Date|null} + */ +export function parseDateFieldValue(value) { + if (value == null || value === '') { + return null + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value + } + + if (typeof value !== 'string') { + return null + } + + const ymd = value.match(/^(\d{4})-(\d{2})-(\d{2})/) + if (ymd) { + const local = new Date(Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3])) + return Number.isNaN(local.getTime()) ? null : local + } + + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + export function isFullWidthExtraFieldXtype(xtype) { - return Object.prototype.hasOwnProperty.call(STRUCTURED_EXTRA_FIELD_PARSERS, xtype) + return FULL_WIDTH_EXTRA_FIELD_XTYPES.has(xtype) } export function parseStructuredExtraFieldValue(xtype, value) { diff --git a/vueManager/src/utils/structuredExtraField.test.js b/vueManager/src/utils/structuredExtraField.test.js new file mode 100644 index 000000000..0835d2fba --- /dev/null +++ b/vueManager/src/utils/structuredExtraField.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { formatLocalDateYmd } from './formatLocalDateYmd.js' +import { KEY_VALUE_XTYPE } from './keyValueField.js' +import { REPEATER_XTYPE } from './repeaterField.js' +import { + DATEFIELD_XTYPE, + isFullWidthExtraFieldXtype, + parseDateFieldValue, + parseStructuredExtraFieldValue, +} from './structuredExtraField.js' + +describe('structuredExtraField datefield', () => { + it('does not treat datefield as full-width layout', () => { + expect(isFullWidthExtraFieldXtype(DATEFIELD_XTYPE)).toBe(false) + }) + + it('parses YYYY-MM-DD into local calendar Date', () => { + const parsed = parseDateFieldValue('2026-04-20') + expect(parsed).toBeInstanceOf(Date) + expect(parsed.getFullYear()).toBe(2026) + expect(parsed.getMonth()).toBe(3) + expect(parsed.getDate()).toBe(20) + }) + + it('serializes Date without UTC ISO shift', () => { + const localMidnight = new Date(2026, 3, 20, 0, 0, 0) + expect(formatLocalDateYmd(localMidnight)).toBe('2026-04-20') + expect(formatLocalDateYmd(localMidnight)).not.toBe(localMidnight.toISOString()) + }) + + it('leaves datefield scalar in parseStructuredExtraFieldValue', () => { + const stored = '2026-04-21' + expect(parseStructuredExtraFieldValue(DATEFIELD_XTYPE, stored)).toBe(stored) + expect(parseStructuredExtraFieldValue(DATEFIELD_XTYPE, null)).toBeNull() + }) + + it('still parses repeater and key-value structured values', () => { + expect(parseStructuredExtraFieldValue(REPEATER_XTYPE, '[]')).toEqual([]) + expect(parseStructuredExtraFieldValue(KEY_VALUE_XTYPE, '{}')).toEqual({}) + }) +})