diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index d93550177..a67a06f61 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 01da74b1b..ef986bc94 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/core/components/minishop3/src/Services/Product/ProductDataService.php b/core/components/minishop3/src/Services/Product/ProductDataService.php index 901e9cca9..0fa15b970 100644 --- a/core/components/minishop3/src/Services/Product/ProductDataService.php +++ b/core/components/minishop3/src/Services/Product/ProductDataService.php @@ -125,7 +125,7 @@ public function prepareObject(msProductData $productData): void $productData->set('source_id', $this->modx->getOption('ms3_product_source_default', null, 1)); } - // Cast numeric/boolean fields (incl. extra fields) so '' does not break MySQL decimals/ints + // Cast numeric/boolean/date fields (incl. extra fields) so '' does not break MySQL foreach ($productData->_fieldMeta as $key => $meta) { if ($key === 'id') { continue; @@ -139,11 +139,30 @@ public function prepareObject(msProductData $productData): void 'float' => $productData->set($key, $isEmpty ? 0.0 : (float)$value), 'integer' => $productData->set($key, $isEmpty ? 0 : (int)$value), 'boolean' => $productData->set($key, $isEmpty ? false : (bool)$value), + 'date', 'datetime', 'timestamp' => $productData->set( + $key, + $this->isEmptyDateScalar($value) ? null : $value + ), default => null, }; } } + /** + * Empty form posts and MySQL zero-dates must become NULL for DATE/DATETIME columns. + */ + private function isEmptyDateScalar(mixed $value): bool + { + if ($value === '' || $value === null) { + return true; + } + if (!is_string($value)) { + return false; + } + + return str_starts_with($value, '0000-00-00'); + } + public function saveCategories(msProductData $productData): void { $this->categoryWriter->saveCategories($productData); diff --git a/core/components/minishop3/tests/Unit/Services/Product/ProductDataPrepareObjectDateNullTest.php b/core/components/minishop3/tests/Unit/Services/Product/ProductDataPrepareObjectDateNullTest.php new file mode 100644 index 000000000..c5cb363b4 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Product/ProductDataPrepareObjectDateNullTest.php @@ -0,0 +1,131 @@ +services = new class { + public function has(string $key): bool + { + return false; + } + }; + $productData = new DateNullProductData($xpdo, [ + 'optional_date' => '', + 'zero_date' => '0000-00-00', + 'zero_datetime' => '0000-00-00 00:00:00', + 'kept_date' => '2026-04-20', + 'price' => '', + ]); + $productData->_fieldMeta = [ + 'id' => ['phptype' => 'integer'], + 'optional_date' => ['phptype' => 'datetime'], + 'zero_date' => ['phptype' => 'date'], + 'zero_datetime' => ['phptype' => 'timestamp'], + 'kept_date' => ['phptype' => 'date'], + 'price' => ['phptype' => 'float'], + ]; + + $service->prepareObject($productData); + + self::assertNull($productData->fields['optional_date']); + self::assertNull($productData->fields['zero_date']); + self::assertNull($productData->fields['zero_datetime']); + self::assertSame('2026-04-20', $productData->fields['kept_date']); + self::assertSame(0.0, $productData->fields['price']); + } +} + +/** + * @internal + */ +final class DateNullTestableProductDataService extends ProductDataService +{ + protected function getProductRepeaterFields(): array + { + return []; + } + + protected function getProductKeyValueFields(): array + { + return []; + } + + protected function getRepeaterFieldService(): RepeaterFieldService + { + return new RepeaterFieldService($this->modx); + } + + protected function getKeyValueFieldService(): KeyValueFieldService + { + return new KeyValueFieldService($this->modx); + } +} + +/** + * @internal + */ +final class DateNullProductData extends msProductData +{ + /** @var array */ + public array $fields; + + /** @var array> */ + public $_fieldMeta = []; + + /** + * @param array $fields + */ + public function __construct(xPDO $xpdo, array $fields = []) + { + parent::__construct($xpdo); + $this->fields = $fields; + } + + public function get($k, $format = null, $formatTemplate = null) + { + return $this->fields[$k] ?? null; + } + + public function set($k, $v = null, $vType = '') + { + $this->fields[$k] = $v; + + return true; + } + + public function getArraysValues() + { + return []; + } + + public function isNew($checkDefaults = false) + { + return false; + } +} 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 05d3d92ec..51dc0a5ce 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' @@ -103,6 +104,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 }, ]) /** @@ -113,6 +115,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' }, @@ -162,18 +165,20 @@ function isValidFieldKey(key) { return typeof key === 'string' && key !== '' && SQL_IDENTIFIER_PATTERN.test(key) } +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..c6ecaaeca 100644 --- a/vueManager/src/utils/structuredExtraField.js +++ b/vueManager/src/utils/structuredExtraField.js @@ -1,13 +1,51 @@ 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 + } + + // MySQL zero-date / zero-datetime — treat as empty, not a real calendar day. + if (value.startsWith('0000-00-00')) { + 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..09dc4a857 --- /dev/null +++ b/vueManager/src/utils/structuredExtraField.test.js @@ -0,0 +1,49 @@ +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('treats null, empty string, and MySQL zero-date as empty', () => { + expect(parseDateFieldValue(null)).toBeNull() + expect(parseDateFieldValue('')).toBeNull() + expect(parseDateFieldValue('0000-00-00')).toBeNull() + expect(parseDateFieldValue('0000-00-00 00:00:00')).toBeNull() + }) + + 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({}) + }) +})