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
42 changes: 41 additions & 1 deletion resources/js/components/fieldtypes/DateFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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')}`;
},
},
};
</script>
1 change: 1 addition & 0 deletions resources/js/components/ui/DatePicker/DatePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ const getInputLabel = (part) => {
</template>
</div>
<Text
v-if="timeZoneLabel"
class="text-gray-600 dark:text-gray-400 me-1"
size="xs"
v-tooltip="timeZoneName"
Expand Down
55 changes: 55 additions & 0 deletions resources/js/tests/components/fieldtypes/DateFieldtype.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,58 @@ test('datePickerValue returns null when value is "now"', () => {

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);
});
42 changes: 39 additions & 3 deletions src/Fieldtypes/Date.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand All @@ -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'),
Expand Down Expand Up @@ -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());
Expand All @@ -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,
];
}
Expand All @@ -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());
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 3 additions & 1 deletion src/Rules/DateFieldtype.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
104 changes: 102 additions & 2 deletions tests/Fieldtypes/DateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
];
}

Expand Down Expand Up @@ -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'],
Expand All @@ -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.
Expand All @@ -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',
Expand Down Expand Up @@ -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],
],
];
}

Expand Down Expand Up @@ -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',
Expand Down
Loading