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
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
19 changes: 12 additions & 7 deletions vueManager/src/components/ExtraFieldsManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 },
])

/**
Expand All @@ -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' },
Expand Down Expand Up @@ -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()
}
Expand Down
35 changes: 34 additions & 1 deletion vueManager/src/utils/structuredExtraField.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
42 changes: 42 additions & 0 deletions vueManager/src/utils/structuredExtraField.test.js
Original file line number Diff line number Diff line change
@@ -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({})
})
})