Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/components/minishop3/lexicon/en/vue.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)';
Expand Down
2 changes: 2 additions & 0 deletions core/components/minishop3/lexicon/ru/vue.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] = 'Варианты списка';
Expand Down Expand Up @@ -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)';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Tests\Unit\Services\Product;

use MiniShop3\Model\msProductData;
use MiniShop3\Services\ExtraFields\KeyValueFieldService;
use MiniShop3\Services\ExtraFields\RepeaterFieldService;
use MiniShop3\Services\Product\ProductDataService;
use MODX\Revolution\modX;
use PHPUnit\Framework\TestCase;
use xPDO\xPDO;

/**
* Empty optional date extras must become NULL before save (#623 / biz87 review).
*/
final class ProductDataPrepareObjectDateNullTest extends TestCase
{
protected function setUp(): void
{
if (!class_exists(modX::class, false)) {
require_once dirname(__DIR__, 3) . '/stubs/ModxStub.php';
}
}

public function testEmptyDateScalarsBecomeNull(): void
{
$modx = new modX();
$service = new DateNullTestableProductDataService($modx);
$xpdo = new xPDO();
$xpdo->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<string, mixed> */
public array $fields;

/** @var array<string, array<string, mixed>> */
public $_fieldMeta = [];

/**
* @param array<string, mixed> $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;
}
}
97 changes: 77 additions & 20 deletions vueManager/src/components/DynamicField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,25 @@
/>

<!-- Date picker -->
<DatePicker
v-else-if="fieldConfig.xtype === 'datefield'"
v-model="localValue"
class="w-full"
:input-id="fieldHtmlId"
:placeholder="fieldConfig.placeholder"
:disabled="disabled"
show-icon
fluid
icon-display="input"
:date-format="fieldConfig.props?.dateFormat ?? 'dd.mm.yy'"
@blur="handleBlur"
/>
<template v-else-if="fieldConfig.xtype === DATEFIELD_XTYPE">
<DatePicker
v-model="datePickerValue"
class="w-full"
:input-id="fieldHtmlId"
:placeholder="fieldConfig.placeholder"
:disabled="disabled"
show-icon
fluid
icon-display="input"
:date-format="fieldConfig.props?.dateFormat ?? 'dd.mm.yy'"
@blur="handleBlur"
/>
<input
type="hidden"
:name="fieldConfig.name"
:value="formatLocalDateYmd(datePickerValue) ?? ''"
/>
</template>

<!-- Color picker -->
<ColorPicker
Expand Down Expand Up @@ -238,8 +244,7 @@
<Message severity="warn"> Unknown field type: {{ fieldConfig.xtype }} </Message>
</div>

<!-- Hidden field for complex types (combobox, datefield, colorpicker, chips, multiselect) -->
<!-- These fields require JSON serialization to pass to ExtJS form -->
<!-- Hidden field for complex types (combobox, colorpicker, chips, multiselect) -->
<input v-if="isComplexField" type="hidden" :name="fieldConfig.name" :value="serializedValue" />
</div>
</template>
Expand All @@ -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'
Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
})
}
</script>
Expand Down
Loading