Merge pull request #438 from directus/date

DateTime overhaul
This commit is contained in:
Rijk van Zanten
2020-09-28 18:25:41 -04:00
committed by GitHub
11 changed files with 33132 additions and 206 deletions

32829
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -79,6 +79,7 @@
"commander": "^5.1.0",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"date-fns": "^2.16.1",
"dotenv": "^8.2.0",
"eventemitter2": "^6.4.3",
"execa": "^4.0.3",

View File

@@ -29,7 +29,7 @@ export class FieldsService {
this.payloadService = new PayloadService('directus_fields');
}
async readAll(collection?: string) {
async readAll(collection?: string): Promise<Field[]> {
let fields: FieldMeta[];
const nonAuthorizedItemsService = new ItemsService('directus_fields', { knex: this.knex });
@@ -347,6 +347,8 @@ export class FieldsService {
column = table[type](field.field /* precision, scale */);
} else if (field.type === 'csv') {
column = table.string(field.field);
} else if (field.type === 'dateTime') {
column = table.dateTime(field.field, { useTz: false });
} else {
column = table[field.type](field.field);
}
@@ -355,7 +357,7 @@ export class FieldsService {
column.defaultTo(field.schema.default_value);
}
if (field.schema.is_nullable !== undefined && field.schema.is_nullable === false) {
if (field.schema?.is_nullable !== undefined && field.schema.is_nullable === false) {
column.notNullable();
} else {
column.nullable();

View File

@@ -12,6 +12,9 @@ import { ItemsService } from './items';
import { URL } from 'url';
import Knex from 'knex';
import env from '../env';
import SchemaInspector from 'knex-schema-inspector';
import getLocalType from '../utils/get-local-type';
import { format, formatISO } from 'date-fns';
type Action = 'create' | 'read' | 'update';
@@ -138,7 +141,7 @@ export class PayloadService {
action: Action,
payload: Partial<Item> | Partial<Item>[]
): Promise<Partial<Item> | Partial<Item>[]> {
const processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
let processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
if (processedPayload.length === 0) return [];
@@ -172,6 +175,10 @@ export class PayloadService {
})
);
if (action === 'read') {
await this.processDates(processedPayload);
}
if (['create', 'update'].includes(action)) {
processedPayload.forEach((record) => {
for (const [key, value] of Object.entries(record)) {
@@ -214,6 +221,51 @@ export class PayloadService {
return value;
}
/**
* Knex returns `datetime` and `date` columns as Date.. This is wrong for date / datetime, as those
* shouldn't return with time / timezone info respectively
*/
async processDates(payloads: Partial<Record<string, any>>[]) {
const schemaInspector = SchemaInspector(this.knex);
const columnsInCollection = await schemaInspector.columnInfo(this.collection);
const columnsWithType = columnsInCollection.map((column) => ({
name: column.name,
type: getLocalType(column.type),
}));
const dateColumns = columnsWithType.filter((column) => ['dateTime', 'date', 'timestamp'].includes(column.type));
if (dateColumns.length === 0) return payloads;
for (const dateColumn of dateColumns) {
for (const payload of payloads) {
const value: Date = payload[dateColumn.name];
if (value) {
if (dateColumn.type === 'timestamp') {
const newValue = formatISO(value);
payload[dateColumn.name] = newValue;
}
if (dateColumn.type === 'dateTime') {
// Strip off the Z at the end of a non-timezone datetime value
const newValue = format(value, "yyyy-MM-dd'T'HH:mm:ss");
payload[dateColumn.name] = newValue;
}
if (dateColumn.type === 'date') {
// Strip off the time / timezone information from a date-only value
const newValue = format(value, 'yyyy-MM-dd');
payload[dateColumn.name] = newValue;
}
}
}
}
return payloads;
}
/**
* Recursively save/update all nested related m2o items
*/

View File

@@ -41,6 +41,6 @@ export type Field = {
collection: string;
field: string;
type: typeof types[number];
schema: Column;
schema: Column | null;
meta: FieldMeta | null;
};

View File

@@ -28,6 +28,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
char: { type: 'string' },
date: { type: 'date' },
datetime: { type: 'dateTime' },
dateTime: { type: 'dateTime' },
timestamp: { type: 'timestamp' },
time: { type: 'time' },
float: { type: 'float' },
@@ -70,7 +71,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
bpchar: { type: 'string' },
timestamptz: { type: 'timestamp' },
'timestamp with time zone': { type: 'timestamp', useTimezone: true },
'timestamp without time zone': { type: 'timestamp' },
'timestamp without time zone': { type: 'dateTime' },
timetz: { type: 'time' },
'time with time zone': { type: 'time', useTimezone: true },
'time without time zone': { type: 'time' },

17
app/package-lock.json generated
View File

@@ -16288,17 +16288,6 @@
"esprima": "^4.0.0"
}
},
"js-yaml-loader": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/js-yaml-loader/-/js-yaml-loader-1.2.2.tgz",
"integrity": "sha512-H+NeuNrG6uOs/WMjna2SjkaCw13rMWiT/D7l9+9x5n8aq88BDsh2sRmdfxckWPIHtViYHWRG6XiCKYvS1dfyLg==",
"dev": true,
"requires": {
"js-yaml": "^3.13.1",
"loader-utils": "^1.2.3",
"un-eval": "^1.2.0"
}
},
"jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
@@ -25401,12 +25390,6 @@
}
}
},
"un-eval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/un-eval/-/un-eval-1.2.0.tgz",
"integrity": "sha512-Wlj/pum6dQtGTPD/lclDtoVPkSfpjPfy1dwnnKw/sZP5DpBH9fLhBgQfsqNhe5/gS1D+vkZUuB771NRMUPA5CA==",
"dev": true
},
"underscore": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz",

View File

@@ -3,11 +3,11 @@
</template>
<script lang="ts">
import { defineComponent, ref, watch, PropType } from '@vue/composition-api';
import { defineComponent, ref, watch, PropType, computed } from '@vue/composition-api';
import localizedFormat from '@/utils/localized-format';
import localizedFormatDistance from '@/utils/localized-format-distance';
import i18n from '@/lang';
import parseISO from 'date-fns/parseISO';
import { parseISO, parse } from 'date-fns';
export default defineComponent({
props: {
@@ -26,20 +26,34 @@ export default defineComponent({
},
},
setup(props) {
const localValue = computed(() => {
if (!props.value) return null;
if (props.type === 'timestamp') {
return parseISO(props.value);
} else if (props.type === 'dateTime') {
return parse(props.value, "yyyy-MM-dd'T'HH:mm:ss", new Date());
} else if (props.type === 'date') {
return parse(props.value, 'yyyy-MM-dd', new Date());
} else if (props.type === 'time') {
return parse(props.value, 'HH:mm:ss', new Date());
}
return null;
});
const displayValue = ref<string | null>(null);
watch(
() => props.value,
localValue,
async (newValue) => {
if (newValue === null) {
displayValue.value = null;
return;
}
const date = parseISO(props.value);
if (props.relative) {
displayValue.value = await localizedFormatDistance(date, new Date(), {
displayValue.value = await localizedFormatDistance(newValue, new Date(), {
addSuffix: true,
});
} else {
@@ -47,7 +61,7 @@ export default defineComponent({
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
displayValue.value = await localizedFormat(date, format);
displayValue.value = await localizedFormat(newValue, format);
}
},
{ immediate: true }

View File

@@ -1,54 +0,0 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent, ref, watch } from '@vue/composition-api';
import { withKnobs, select } from '@storybook/addon-knobs';
import readme from './readme.md';
import i18n from '@/lang';
import RawValue from '../../../.storybook/raw-value.vue';
export default {
title: 'Interfaces / DateTime',
decorators: [withPadding, withKnobs],
parameters: {
notes: readme,
},
};
export const basic = () =>
defineComponent({
i18n,
props: {
type: {
default: select('Type', ['datetime', 'date', 'time'], 'datetime'),
},
},
components: { RawValue },
setup(props) {
const value = ref('2020-04-28 14:40:00');
watch(
() => props.type,
(newType: string) => {
if (newType === 'datetime') {
value.value = '2020-04-28 14:40:00';
} else if (newType === 'time') {
value.value = '14:40:00';
} else {
// date
value.value = '2020-04-28';
}
}
);
return { value };
},
template: `
<div style="max-width: 300px;">
<interface-datetime
v-model="value"
:type="type"
/>
<portal-target multiple name="outlet" />
<raw-value>{{ value }}</raw-value>
</div>
`,
});

View File

@@ -17,13 +17,13 @@
<div class="date-selects" v-if="type === 'timestamp' || type === 'dateTime' || type === 'date'">
<div class="month">
<v-select :placeholder="$t('month')" :items="months" v-model="localValue.month" />
<v-select :placeholder="$t('month')" :items="monthItems" v-model="month" />
</div>
<div class="date">
<v-select :placeholder="$t('date')" :items="dates" v-model="localValue.date" />
<v-select :placeholder="$t('date')" :items="dateItems" v-model="date" />
</div>
<div class="year">
<v-select :placeholder="$t('year')" :items="years" v-model="localValue.year" allow-other />
<v-select :placeholder="$t('year')" :items="yearItems" v-model="year" allow-other />
</div>
</div>
@@ -32,19 +32,19 @@
<div
class="time-selects"
v-if="type === 'timestamp' || type === 'dateTime' || type === 'time'"
:class="{ seconds: includeSeconds }"
:class="{ seconds: includeSeconds, 'use-24': use24 }"
>
<div class="hour">
<v-select :items="hours" v-model="localValue.hours" />
<v-select :items="hourItems" v-model="hours" />
</div>
<div class="minutes">
<v-select :items="minutesSeconds" v-model="localValue.minutes" />
<v-select :items="minutesSecondItems" v-model="minutes" />
</div>
<div v-if="includeSeconds" class="seconds">
<v-select :items="minutesSeconds" v-model="localValue.seconds" />
<v-select :items="minutesSecondItems" v-model="seconds" />
</div>
<div class="period">
<v-select :items="['am', 'pm']" v-model="localValue.period" />
<div class="period" v-if="use24 === false">
<v-select :items="['am', 'pm']" v-model="period" />
</div>
</div>
@@ -58,7 +58,7 @@
import { defineComponent, ref, watch, computed, reactive, PropType } from '@vue/composition-api';
import formatLocalized from '@/utils/localized-format';
import { i18n } from '@/lang';
import { formatISO, parseISO } from 'date-fns';
import { formatISO, parseISO, format, parse } from 'date-fns';
type LocalValue = {
month: null | number;
@@ -67,7 +67,6 @@ type LocalValue = {
hours: null | number;
minutes: null | number;
seconds: null | number;
period: 'am' | 'pm';
};
export default defineComponent({
@@ -89,121 +88,209 @@ export default defineComponent({
type: Boolean,
default: false,
},
use24: {
type: Boolean,
default: true,
},
},
setup(props, { emit }) {
const valueAsDate = computed(() => {
if (props.value === null) return null;
return parseISO(props.value);
});
const displayValue = ref<string | null>(null);
syncDisplayValue();
const localValue = reactive({
month: null,
date: null,
year: null,
hours: 9,
minutes: 0,
seconds: 0,
period: 'am',
} as LocalValue);
syncLocalValue();
watch(
() => props.value,
(newValue, oldValue) => {
if (newValue !== oldValue && newValue !== null && newValue.length !== 0) {
syncLocalValue();
syncDisplayValue();
}
}
);
watch(
() => localValue,
(newValue) => {
if (
newValue.year !== null &&
String(newValue.year).length === 4 &&
newValue.month !== null &&
newValue.date !== null &&
newValue.hours !== null &&
newValue.minutes !== null &&
newValue.seconds !== null
) {
const { year, month, date, hours, minutes, seconds, period } = newValue;
const asDate = new Date(year, month, date, period === 'am' ? hours : hours + 12, minutes, seconds);
if(valueAsDate.value?.getTime() != asDate.getTime())
emit('input', formatISO(asDate));
}
},
{
deep: true,
}
);
const { months, dates, years, hours, minutesSeconds } = useOptions();
const { _value, year, month, date, hours, minutes, seconds, period } = useLocalValue();
const { yearItems, monthItems, dateItems, hourItems, minutesSecondItems } = useOptions();
const { displayValue } = useDisplayValue();
return {
displayValue,
months,
dates,
years,
year,
month,
date,
hours,
minutesSeconds,
minutes,
seconds,
period,
setToNow,
localValue,
onAMPMInput,
yearItems,
monthItems,
dateItems,
hourItems,
minutesSecondItems,
displayValue,
};
function useLocalValue() {
const _value = computed({
get() {
if (!props.value) return null;
if (props.type === 'timestamp') {
return parseISO(props.value);
} else if (props.type === 'dateTime') {
return parse(props.value, "yyyy-MM-dd'T'HH:mm:ss", new Date());
} else if (props.type === 'date') {
return parse(props.value, 'yyyy-MM-dd', new Date());
} else if (props.type === 'time') {
return parse(props.value, 'HH:mm:ss', new Date());
}
return null;
},
set(newValue: Date | null) {
if (newValue === null) return emit('input', null);
if (props.type === 'timestamp') {
emit('input', formatISO(newValue));
} else if (props.type === 'dateTime') {
emit('input', format(newValue, "yyyy-MM-dd'T'HH:mm:ss"));
} else if (props.type === 'date') {
emit('input', format(newValue, 'yyyy-MM-dd'));
} else if (props.type === 'time') {
emit('input', format(newValue, 'HH:mm:ss'));
}
},
});
const year = computed({
get() {
if (!_value.value) return null;
return _value.value.getFullYear();
},
set(newYear: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setFullYear(newYear || 0);
_value.value = newValue;
},
});
const month = computed({
get() {
if (!_value.value) return null;
return _value.value.getMonth();
},
set(newMonth: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setMonth(newMonth || 0);
_value.value = newValue;
},
});
const date = computed({
get() {
if (!_value.value) return null;
return _value.value.getDate();
},
set(newDate: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setDate(newDate || 1);
_value.value = newValue;
},
});
const hours = computed({
get() {
if (!_value.value) return null;
const hours = _value.value.getHours();
if (props.use24 === false) {
return hours % 12;
}
return hours;
},
set(newHours: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setHours(newHours || 0);
_value.value = newValue;
},
});
const minutes = computed({
get() {
if (!_value.value) return null;
return _value.value.getMinutes();
},
set(newMinutes: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setMinutes(newMinutes || 0);
_value.value = newValue;
},
});
const seconds = computed({
get() {
if (!_value.value) return null;
return _value.value.getSeconds();
},
set(newSeconds: number | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
newValue.setSeconds(newSeconds || 0);
_value.value = newValue;
},
});
const period = computed({
get() {
if (!_value.value) return null;
return _value.value.getHours() >= 12 ? 'pm' : 'am';
},
set(newAMPM: 'am' | 'pm' | null) {
const newValue = _value.value ? new Date(_value.value) : new Date();
const current = newValue.getHours() >= 12 ? 'pm' : 'am';
if (current !== newAMPM) {
if (newAMPM === 'am') {
newValue.setHours(newValue.getHours() - 12);
} else {
newValue.setHours(newValue.getHours() + 12);
}
}
_value.value = newValue;
},
});
return { _value, year, month, date, hours, minutes, seconds, period };
}
function setToNow() {
const date = new Date();
localValue.month = date.getMonth();
localValue.date = date.getDate();
localValue.year = date.getFullYear();
localValue.hours = date.getHours();
localValue.minutes = date.getMinutes();
localValue.seconds = date.getSeconds();
_value.value = new Date();
}
function syncLocalValue() {
if (!valueAsDate.value) return;
localValue.month = valueAsDate.value.getMonth();
localValue.date = valueAsDate.value.getDate();
localValue.year = valueAsDate.value?.getFullYear();
localValue.hours = valueAsDate.value?.getHours() % 12;
localValue.minutes = valueAsDate.value?.getMinutes();
localValue.seconds = valueAsDate.value?.getSeconds();
}
function useDisplayValue() {
const displayValue = ref<string | null>(null);
async function syncDisplayValue() {
if (valueAsDate.value === null) return null;
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
watch(_value, setDisplayValue);
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
return { displayValue };
displayValue.value = await formatLocalized(valueAsDate.value as Date, format);
}
async function setDisplayValue() {
if (!props.value || !_value.value) {
displayValue.value = null;
return;
}
function onAMPMInput(newValue: 'PM' | 'AM') {
if (!localValue.hours) return;
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
if (newValue === 'AM') {
localValue.hours = localValue.hours - 12;
} else {
localValue.hours = localValue.hours + 12;
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
displayValue.value = await formatLocalized(_value.value, format);
}
}
function useOptions() {
const months = computed(() =>
const yearItems = computed(() => {
const current = _value.value?.getFullYear() || new Date().getFullYear();
const years = [];
for (let i = current - 5; i <= current + 5; i++) {
years.push({
text: String(i),
value: i,
});
}
return years;
});
const monthItems = computed(() =>
[
i18n.t('months.january'),
i18n.t('months.february'),
@@ -223,7 +310,7 @@ export default defineComponent({
}))
);
const dates = computed(() => {
const dateItems = computed(() => {
const dates = [];
for (let i = 1; i <= 31; i++) {
@@ -233,24 +320,15 @@ export default defineComponent({
return dates;
});
const years = computed(() => {
const current = valueAsDate.value?.getFullYear() || new Date().getFullYear();
const years = [];
for (let i = current - 5; i <= current + 5; i++) {
years.push({
text: String(i),
value: i,
});
}
return years;
});
const hours = computed(() => {
const hourItems = computed(() => {
const hours = [];
for (let i = 1; i <= 12; i++) {
const hoursInADay = props.use24 ? 24 : 12;
for (let i = 1; i <= hoursInADay; i++) {
let hour = String(i);
if (hour.length === 1) hour = '0' + hour;
hours.push({
text: hour,
value: i,
@@ -260,7 +338,7 @@ export default defineComponent({
return hours;
});
const minutesSeconds = computed(() => {
const minutesSecondItems = computed(() => {
const values = [];
for (let i = 0; i < 60; i++) {
@@ -275,7 +353,7 @@ export default defineComponent({
return values;
});
return { dates, years, months, hours, minutesSeconds };
return { yearItems, monthItems, dateItems, hourItems, minutesSecondItems };
}
},
});
@@ -300,6 +378,14 @@ export default defineComponent({
&.seconds {
grid-template-columns: repeat(4, 1fr);
}
&.use-24 {
grid-template-columns: repeat(2, 1fr);
&.seconds {
grid-template-columns: repeat(3, 1fr);
}
}
}
.month {

View File

@@ -21,6 +21,18 @@ export default defineInterface(({ i18n }) => ({
default_value: false,
},
},
{
field: 'use24',
name: i18n.t('interfaces.datetime.use_24'),
type: 'boolean',
meta: {
width: 'half',
interface: 'toggle',
},
schema: {
default_value: true,
},
},
],
recommendedDisplays: ['datetime'],
}));