diff --git a/resources/js/components/fieldtypes/DateFieldtype.vue b/resources/js/components/fieldtypes/DateFieldtype.vue index 575e7542921..4bb8b416601 100644 --- a/resources/js/components/fieldtypes/DateFieldtype.vue +++ b/resources/js/components/fieldtypes/DateFieldtype.vue @@ -23,7 +23,7 @@ import Fieldtype from './Fieldtype.vue'; import DateFormatter from '@/components/DateFormatter.js'; import { DatePicker, DateRangePicker, Button } from '@/components/ui'; -import { getLocalTimeZone, parseAbsoluteToLocal, toTimeZone, toZoned } from '@internationalized/date'; +import { CalendarDate, getLocalTimeZone, parseAbsoluteToLocal, toTimeZone, toZoned } from '@internationalized/date'; export default { components: { @@ -67,11 +67,26 @@ export default { return this.config.inline; }, + formatHasTime() { + return this.meta?.formatHasTime ?? true; + }, + datePickerValue() { if (!this.value || this.value === 'now') { return null; } + if (!this.formatHasTime) { + if (this.isRange) { + return { + start: this.parseDateOnly(this.value.start), + end: this.parseDateOnly(this.value.end), + }; + } + + return this.parseDateOnly(this.value); + } + if (this.isRange) { return { start: parseAbsoluteToLocal(this.value.start), @@ -128,6 +143,17 @@ export default { return this.update(null); } + if (!this.formatHasTime) { + if (this.isRange) { + return this.update({ + start: this.formatDateOnly(value.start), + end: this.formatDateOnly(value.end), + }); + } + + return this.update(this.formatDateOnly(value)); + } + // Sometimes, we'll get a CalendarDateTime object, which doesn't include timezone // information. In that case, we need to convert it to a ZonedDateTime object. if (!this.isRange && !value.offset && !value.timeZone) { @@ -157,6 +183,11 @@ export default { addDate() { let now = new Date(); + if (!this.formatHasTime) { + const str = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + return this.update(this.isRange ? { start: str, end: str } : str); + } + now.setMilliseconds(0); if (!this.config.time_enabled) { @@ -167,6 +198,15 @@ export default { this.update(this.isRange ? { start: str, end: str } : str); }, + + parseDateOnly(value) { + const [year, month, day] = value.split('-').map(Number); + return new CalendarDate(year, month, day); + }, + + formatDateOnly(value) { + return `${value.year}-${String(value.month).padStart(2, '0')}-${String(value.day).padStart(2, '0')}`; + }, }, }; diff --git a/resources/js/components/ui/DatePicker/DatePicker.vue b/resources/js/components/ui/DatePicker/DatePicker.vue index 6c7bb8e8053..f60f1aa14a4 100644 --- a/resources/js/components/ui/DatePicker/DatePicker.vue +++ b/resources/js/components/ui/DatePicker/DatePicker.vue @@ -199,6 +199,7 @@ const getInputLabel = (part) => { { expect(dateField.vm.datePickerValue).toBe(null); }); + +test.each([ + ['UTC'], + ['America/New_York'], + ['Australia/Sydney'], +])('date-only format is not affected by timezone (%s)', async (tz) => { + process.env.TZ = tz; + + const dateField = makeDateField({ + value: '2025-12-25', + meta: { formatHasTime: false }, + }); + + const value = dateField.vm.datePickerValue; + expect(value.year).toBe(2025); + expect(value.month).toBe(12); + expect(value.day).toBe(25); + expect(value.timeZone).toBeUndefined(); +}); + +test('date-only format formats date correctly', async () => { + process.env.TZ = 'America/New_York'; + + const dateField = makeDateField({ + value: '2025-12-25', + meta: { formatHasTime: false }, + }); + + const { CalendarDate } = await import('@internationalized/date'); + const formatted = dateField.vm.formatDateOnly(new CalendarDate(2025, 6, 5)); + + expect(formatted).toBe('2025-06-05'); +}); + +test('date-only range format is not affected by timezone', async () => { + process.env.TZ = 'America/New_York'; + + const dateField = makeDateField({ + value: { start: '2025-12-25', end: '2025-12-31' }, + meta: { formatHasTime: false }, + config: { + mode: 'range', + earliest_date: { date: null, time: null }, + latest_date: { date: null, time: null }, + }, + }); + + const value = dateField.vm.datePickerValue; + expect(value.start.year).toBe(2025); + expect(value.start.month).toBe(12); + expect(value.start.day).toBe(25); + expect(value.end.year).toBe(2025); + expect(value.end.month).toBe(12); + expect(value.end.day).toBe(31); +}); diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index c4dc409b1e2..3868b7c3e91 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -150,6 +150,10 @@ private function preProcessSingle($value) $value = $value['start']; } + if (! $this->formatHasTime()) { + return $this->parseSavedToCarbon($value)->format('Y-m-d'); + } + return $this->parseSaved($value)->toIso8601ZuluString('millisecond'); } @@ -165,6 +169,13 @@ private function preProcessRange($value) if (! is_array($value)) { $carbon = $this->parseSavedToCarbon($value); + if (! $this->formatHasTime()) { + return [ + 'start' => $carbon->copy()->format('Y-m-d'), + 'end' => $carbon->copy()->format('Y-m-d'), + ]; + } + return [ 'start' => $carbon->copy()->startOfDay()->utc()->toIso8601ZuluString('millisecond'), 'end' => $carbon->copy()->endOfDay()->utc()->toIso8601ZuluString('millisecond'), @@ -222,6 +233,12 @@ private function processRange($data) private function processDateTime($value) { + if (! $this->formatHasTime()) { + $date = Carbon::parse($value, config('app.timezone')); + + return $this->formatAndCast($date, $this->saveFormat()); + } + $date = Carbon::parse($value, 'UTC'); return $this->formatAndCast($date, $this->saveFormat()); @@ -246,8 +263,8 @@ public function preProcessIndex($value) } return [ - 'start' => $this->parseSaved($value['start'])->toIso8601ZuluString('millisecond'), - 'end' => $this->parseSaved($value['end'])->toIso8601ZuluString('millisecond'), + 'start' => $this->preProcessIndexDate($value['start']), + 'end' => $this->preProcessIndexDate($value['end']), ...$common, ]; } @@ -258,11 +275,20 @@ public function preProcessIndex($value) } return [ - 'date' => $this->parseSaved($value)->toIso8601ZuluString('millisecond'), + 'date' => $this->preProcessIndexDate($value), ...$common, ]; } + private function preProcessIndexDate($value) + { + if (! $this->formatHasTime()) { + return $this->parseSavedToCarbon($value)->format('Y-m-d'); + } + + return $this->parseSaved($value)->toIso8601ZuluString('millisecond'); + } + private function saveFormat() { return $this->config('format', $this->defaultFormat()); @@ -362,6 +388,16 @@ private function parseSavedToCarbon($value): Carbon } } + public function formatHasTime(): bool + { + return DateFormat::containsTime($this->saveFormat()); + } + + public function preload() + { + return ['formatHasTime' => $this->formatHasTime()]; + } + public function timeEnabled() { return $this->config('time_enabled'); diff --git a/src/Rules/DateFieldtype.php b/src/Rules/DateFieldtype.php index 16a5af4545f..436df34a22a 100644 --- a/src/Rules/DateFieldtype.php +++ b/src/Rules/DateFieldtype.php @@ -88,7 +88,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void private function validDateFormat($value) { - $format = 'Y-m-d\TH:i:s.v\Z'; + $format = $this->fieldtype->formatHasTime() + ? 'Y-m-d\TH:i:s.v\Z' + : 'Y-m-d'; $date = DateTime::createFromFormat('!'.$format, $value); diff --git a/tests/Fieldtypes/DateTest.php b/tests/Fieldtypes/DateTest.php index bbb49a4e2e0..e98f110fe1c 100644 --- a/tests/Fieldtypes/DateTest.php +++ b/tests/Fieldtypes/DateTest.php @@ -214,6 +214,36 @@ public static function processProvider() ['start' => '2012-08-29T00:00:00Z', 'end' => '2013-09-27T23:59:00Z'], ['start' => '2012-08-28 20:00', 'end' => '2013-09-27 19:59'], ], + 'date-only format' => [ + 'UTC', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only format in a different timezone' => [ + 'America/New_York', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only custom format' => [ + 'UTC', + ['format' => 'Y--m--d'], + '2012-08-29', + '2012--08--29', + ], + 'date-only format range' => [ + 'UTC', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ], + 'date-only format range in a different timezone' => [ + 'America/New_York', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ], ]; } @@ -294,6 +324,30 @@ public static function preProcessProvider() '2012-08-29 13:43', '2012-08-29T17:43:00.000Z', ], + 'date-only format' => [ + 'UTC', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only format in a different timezone' => [ + 'America/New_York', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only format in a positive offset timezone' => [ + 'Australia/Sydney', + ['format' => 'Y-m-d'], + '2012-08-29', + '2012-08-29', + ], + 'date-only custom format' => [ + 'America/New_York', + ['format' => 'Y--m--d'], + '2012--08--29', + '2012-08-29', + ], 'null range' => [ 'UTC', ['mode' => 'range'], @@ -318,6 +372,18 @@ public static function preProcessProvider() ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 23:59'], ['start' => '2012-08-29T04:00:00.000Z', 'end' => '2013-09-28T03:59:00.000Z'], ], + 'date-only format range' => [ + 'UTC', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ], + 'date-only format range in a different timezone' => [ + 'America/New_York', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ], 'range where single date has been provided' => [ 'UTC', // e.g. If it was once a non-range field. @@ -326,11 +392,11 @@ public static function preProcessProvider() '2012-08-29', ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T23:59:59.999Z'], ], - 'range where single date has been provided with custom format' => [ + 'range where single date has been provided with date-only custom format' => [ 'UTC', ['mode' => 'range', 'format' => 'Y--m--d'], '2012--08--29', - ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T23:59:59.999Z'], + ['start' => '2012-08-29', 'end' => '2012-08-29'], ], 'date where range has been provided' => [ 'UTC', @@ -459,6 +525,30 @@ public static function preProcessIndexProvider() ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], ['start' => '2012-08-29T00:00:00.000Z', 'end' => '2013-09-27T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => true], ], + 'date-only format' => [ + 'UTC', + ['format' => 'Y-m-d'], + '2012-08-29', + ['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false], + ], + 'date-only format in a different timezone' => [ + 'America/New_York', + ['format' => 'Y-m-d'], + '2012-08-29', + ['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false], + ], + 'date-only format range' => [ + 'UTC', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false], + ], + 'date-only format range in a different timezone' => [ + 'America/New_York', + ['mode' => 'range', 'format' => 'Y-m-d'], + ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false], + ], ]; } @@ -603,6 +693,16 @@ public static function validationProvider() '2024-01-29', ['Not a valid date.'], ], + 'valid date-only format' => [ + ['format' => 'Y-m-d'], + '2024-01-29', + [], + ], + 'invalid date-only format' => [ + ['format' => 'Y-m-d'], + 'marchtember oneteenth', + ['Not a valid date.'], + ], 'ridiculous invalid date format' => [ [], 'marchtember oneteenth',