Add date(time) interface (#499)

* Add localized-format util

* Add active prop to v-input

* Add strings for datetime interface

* Add overflow-scroll prop to v-menu

* Add close-on-content-click prop to v-select

* Add datetime interface

* Show display value synced with prop

* Sync value with prop

* Set lang after user hydration

* Add NL date-fns lang to test datetime

* Fix locale fetching in date-fns

* Dont stage value if year isnt fully filled out

* Localize date fns based on shared util

* Handle type, render type based display

* Don't use exact on v-list-item

* Pass type to interface on v-form
This commit is contained in:
Rijk van Zanten
2020-04-29 10:00:22 -04:00
committed by GitHub
parent fcbe0af502
commit b5d6fdefa2
21 changed files with 520 additions and 69 deletions

View File

@@ -84,6 +84,7 @@
: values[field.field]
"
:width="field.width"
:type="field.type"
@input="setValue(field, $event)"
/>
</div>

View File

@@ -23,6 +23,7 @@ You can add any custom (text) prefix/suffix to the value in the input using the
| `suffix` | Show a value at the end of the input | -- |
| `slug` | Force the value to be URL safe | `false` |
| `slug-separator` | What character to use as separator in slugs | `-` |
| `active` | Force the focus state | `false` |
Note: all other attached attributes are bound to the input HTMLELement in the component. This allows you to attach any of the standard HTML attributes like `min`, `length`, or `pattern`.

View File

@@ -7,7 +7,7 @@
<div v-if="$slots['prepend-outer']" class="prepend-outer">
<slot name="prepend-outer" :value="value" :disabled="disabled" />
</div>
<div class="input" :class="{ disabled }">
<div class="input" :class="{ disabled, active }">
<div v-if="$slots.prepend" class="prepend">
<slot name="prepend" :value="value" :disabled="disabled" />
</div>
@@ -105,6 +105,10 @@ export default defineComponent({
type: Number,
default: 1,
},
active: {
type: Boolean,
default: false,
},
},
setup(props, { emit, listeners }) {
const input = ref<HTMLInputElement>(null);
@@ -221,7 +225,8 @@ body {
border-color: var(--border-normal-alt);
}
&:focus-within {
&:focus-within,
&.active {
--arrow-color: var(--primary);
color: var(--foreground-normal);
@@ -249,7 +254,7 @@ body {
input {
flex-grow: 1;
width: 100px; // allows flex to shrink to allow for slots
width: 20px; // allows flex to grow/shrink to allow for slots
height: 100%;
font-family: var(--v-input-font-family);
background-color: transparent;
@@ -290,12 +295,6 @@ body {
}
}
&.active .input {
color: var(--foreground-normal);
background-color: var(--background-page);
border-color: var(--primary);
}
.append-outer {
margin-left: 8px;
}

View File

@@ -3,7 +3,6 @@
:is="component"
active-class="active"
class="v-list-item"
exact
:to="to"
:class="{
active,

View File

@@ -57,8 +57,6 @@ export default {
## Props
Strap in
### Positioning
| Prop | Description | Default |
@@ -70,22 +68,22 @@ Strap in
| `bottom` | Aligns the menu to the bottom of the activator, going down (if possible) | `false` |
| `left` | Aligns the menu to the left of the activator, expanding left (if possible) | `false` |
| `right` | Aligns the menu to the right of the activator, expanding right (if possible) | `false` |
| `offsetX` | Positions the menu along the X-Axis so as not to cover any of the activator | `false` |
| `offsetY` | Positions the menu along the Y-Axis so as not to cover any of the activator | `false` |
| `positionX` | "left" css value of menu. Only works with `absolute` or `fixed` | `undefined` |
| `positionY` | "top" css value of menu. Only works with `absolute` or `fixed` | `undefined` |
| `disabled` | Prevent the menu from being opened by clicking on the activator | `false` |
| `offset-x` | Positions the menu along the X-Axis so as not to cover any of the activator | `false` |
| `offset-y` | Positions the menu along the Y-Axis so as not to cover any of the activator | `false` |
| `position-x` | "left" css value of menu. Only works with `absolute` or `fixed` | `undefined` |
| `position-y` | "top" css value of menu. Only works with `absolute` or `fixed` | `undefined` |
### Behavior
| Prop | Description | Default |
| --------------------- | --------------------------------------------------------- | ------- |
| `closeOnClick` | Closes the menu when user clicks somewhere else | `true` |
| `closeOnContentClick` | Closes the menu when user clicks on a menu item | `false` |
| `openOnClick` | Open the menu when activator is clicked | `true` |
| `openOnHover` | Open the menu when activator is hovered over | `false` |
| `openDelay` | Delay in milliseconds after hover enter for menu to open | `0` |
| `closeDelay` | Delay in milliseconds after hover leave for menu to close | `0` |
| Prop | Description | Default |
|-----------------------|-------------------------------------------------------------|---------|
| `closeOnClick` | Closes the menu when user clicks somewhere else | `true` |
| `closeOnContentClick` | Closes the menu when user clicks on a menu item | `false` |
| `openOnClick` | Open the menu when activator is clicked | `true` |
| `openOnHover` | Open the menu when activator is hovered over | `false` |
| `openDelay` | Delay in milliseconds after hover enter for menu to open | `0` |
| `closeDelay` | Delay in milliseconds after hover leave for menu to close | `0` |
| `overflow-scroll` | Overflow the content in the menu when it reaches max height | `true` |
### Control

View File

@@ -23,7 +23,11 @@
:style="arrowStyles"
data-popper-arrow
/>
<div :class="{ active: isActive }" class="v-menu-content" @click="onContentClick">
<div
:class="{ active: isActive, 'overflow-scroll': overflowScroll }"
class="v-menu-content"
@click="onContentClick"
>
<slot />
</div>
</div>
@@ -65,6 +69,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
overflowScroll: {
type: Boolean,
default: true,
},
},
setup(props, { emit }) {
const activator = ref<HTMLElement>(null);
@@ -239,8 +247,6 @@ body {
.v-menu-content {
max-height: 50vh;
padding: 0 4px;
overflow-x: hidden;
overflow-y: auto;
background-color: var(--background-subdued);
border: 2px solid var(--border-normal);
border-radius: var(--border-radius);
@@ -249,7 +255,12 @@ body {
transition-timing-function: var(--transition-out);
transition-duration: var(--fast);
transition-property: opacity, transform;
contain: content;
&.overflow-scroll {
contain: content;
overflow-x: hidden;
overflow-y: auto;
}
.v-list {
--v-list-background-color: transparent;

View File

@@ -22,17 +22,18 @@ Renders a dropdown input.
## Props
| Prop | Description | Default |
|-----------------|-----------------------------------------------------|---------|
| `items`\* | Items to render in the select | |
| `itemText` | What item value to use for the display text | `text` |
| `itemValue` | What item value to use for the item value | `value` |
| `value` | Currently selected item(s) | |
| `multiple` | Allow multiple items to be selected | `false` |
| `placeholder` | What placeholder to show when no items are selected | |
| `full-width` | Render the select at full width | |
| `disabled` | Disable the select | |
| `show-deselect` | Show the deselect option when a value has been set | |
| Prop | Description | Default |
|--------------------------|-----------------------------------------------------|---------|
| `items`\* | Items to render in the select | |
| `itemText` | What item value to use for the display text | `text` |
| `itemValue` | What item value to use for the item value | `value` |
| `value` | Currently selected item(s) | |
| `multiple` | Allow multiple items to be selected | `false` |
| `placeholder` | What placeholder to show when no items are selected | |
| `full-width` | Render the select at full width | |
| `disabled` | Disable the select | |
| `show-deselect` | Show the deselect option when a value has been set | |
| `close-on-content-click` | Close the select when selecting a value | `true` |
## Events

View File

@@ -1,6 +1,11 @@
<template>
<v-menu :disabled="disabled" class="v-select" attached>
<template #activator="{ toggle }">
<v-menu
:disabled="disabled"
class="v-select"
attached
:close-on-content-click="closeOnContentClick"
>
<template #activator="{ toggle, active }">
<v-input
:full-width="fullWidth"
readonly
@@ -8,6 +13,7 @@
@click="toggle"
:placeholder="placeholder"
:disabled="disabled"
:active="active"
>
<template #prepend><slot name="prepend" /></template>
<template #append><v-icon name="expand_more" /></template>
@@ -48,7 +54,11 @@
</v-list-item-content>
</v-list-item>
<v-list-item v-if="allowOther && multiple === false" :active="usesOtherValue">
<v-list-item
v-if="allowOther && multiple === false"
:active="usesOtherValue"
@click.stop
>
<v-list-item-content>
<input
class="other-input"
@@ -64,6 +74,7 @@
v-for="otherValue in otherValues"
:key="otherValue.key"
:active="(value || []).includes(otherValue.value)"
@click.stop
>
<v-list-item-icon>
<v-checkbox
@@ -157,6 +168,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
closeOnContentClick: {
type: Boolean,
default: true,
},
},
setup(props, { emit }) {
const { _items } = useItems();

View File

@@ -9,6 +9,7 @@ import { useProjectsStore } from '@/stores/projects/';
import { useLatencyStore } from '@/stores/latency';
import { usePermissionsStore } from '@/stores/permissions';
import { useRelationsStore } from '@/stores/relations';
import { setLanguage, Language } from '@/lang';
type GenericStore = {
id: string;
@@ -38,6 +39,7 @@ export function useStores(
/* istanbul ignore next: useStores has a test already */
export async function hydrate(stores = useStores()) {
const appStore = useAppStore();
const userStore = useUserStore();
if (appStore.state.hydrated) return;
if (appStore.state.hydrating) return;
@@ -51,7 +53,9 @@ export async function hydrate(stores = useStores()) {
* following makes sure that the user store is always fetched first, before we hydrate anything
* else.
*/
await useUserStore().hydrate();
await userStore.hydrate();
setLanguage((userStore.state.currentUser?.locale as Language) || 'en-US');
await Promise.all(
stores.filter(({ id }) => id !== 'userStore').map((store) => store.hydrate?.())

View File

@@ -0,0 +1,53 @@
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"
/>
<raw-value>{{ value }}</raw-value>
</div>
`,
});

View File

@@ -0,0 +1,308 @@
<template>
<v-menu attached :disabled="disabled" :overflow-scroll="false">
<template #activator="{ toggle, active }">
<v-input
:active="active"
@click="toggle"
readonly
:value="displayValue"
:disabled="disabled"
:placeholder="$t('enter_a_value')"
>
<template #append>
<v-icon name="today" :class="{ active }" />
</template>
</v-input>
</template>
<div class="date" v-if="type === 'datetime' || type === 'date'">
<div class="month">
<v-select :placeholder="$t('month')" :items="months" v-model="localValue.month" />
</div>
<div class="day">
<v-select :placeholder="$t('date')" :items="days" v-model="localValue.day" />
</div>
<div class="year">
<v-select
:placeholder="$t('year')"
:items="years"
v-model="localValue.year"
allow-other
/>
</div>
</div>
<v-divider v-if="type === 'datetime'" />
<div class="time" v-if="type === 'datetime' || type === 'time'">
<div class="hour">
<v-select :items="hours" v-model="localValue.hours" />
</div>
<div class="minutes">
<v-select :items="minutesSeconds" v-model="localValue.minutes" />
</div>
<div class="seconds">
<v-select :items="minutesSeconds" v-model="localValue.seconds" />
</div>
</div>
<v-divider />
<button class="to-now" @click="setToNow">{{ $t('set_to_now') }}</button>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, ref, watch, computed, reactive, PropType } from '@vue/composition-api';
import formatLocalized from '@/utils/localized-format';
import { i18n } from '@/lang';
import parse from 'date-fns/parse';
import format from 'date-fns/format';
type LocalValue = {
month: null | number;
day: null | number;
year: null | number;
hours: null | number;
minutes: null | number;
seconds: null | number;
};
export default defineComponent({
props: {
disabled: {
type: Boolean,
default: false,
},
value: {
type: String,
default: null,
},
type: {
type: String as PropType<'datetime' | 'time' | 'date'>,
required: true,
validator: (val: string) => ['datetime', 'date', 'time'].includes(val),
},
},
setup(props, { emit }) {
const formatString = computed(() => {
const date = 'yyyy-MM-dd';
const time = 'HH:mm:ss';
if (props.type === 'datetime') {
return date + ' ' + time;
}
if (props.type === 'date') {
return date;
}
return time;
});
const valueAsDate = computed(() =>
props.value ? parse(props.value, formatString.value, new Date()) : null
);
const displayValue = ref<string>(null);
syncDisplayValue();
const localValue = reactive({
month: null,
day: null,
year: null,
hours: 9,
minutes: 0,
seconds: 0,
} 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.day !== null &&
newValue.hours !== null &&
newValue.minutes !== null &&
newValue.seconds !== null
) {
const { year, month, day, hours, minutes, seconds } = newValue;
const date = new Date(year, month, day, hours, minutes, seconds);
emit('input', format(date, formatString.value));
}
},
{
deep: true,
}
);
const { months, days, years, hours, minutesSeconds } = useOptions();
return {
displayValue,
months,
days,
years,
hours,
minutesSeconds,
setToNow,
localValue,
};
function setToNow() {
const date = new Date();
localValue.month = date.getMonth();
localValue.day = date.getDate();
localValue.year = date.getFullYear();
localValue.hours = date.getHours();
localValue.minutes = date.getMinutes();
localValue.seconds = date.getSeconds();
}
function syncLocalValue() {
if (!valueAsDate.value) return;
localValue.month = valueAsDate.value.getMonth();
localValue.day = valueAsDate.value.getDate();
localValue.year = valueAsDate.value?.getFullYear();
localValue.hours = valueAsDate.value?.getHours();
localValue.minutes = valueAsDate.value?.getMinutes();
localValue.seconds = valueAsDate.value?.getSeconds();
}
async function syncDisplayValue() {
if (valueAsDate.value === null) return null;
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
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(valueAsDate.value as Date, format);
}
function useOptions() {
const months = computed(() =>
[
i18n.t('months.january'),
i18n.t('months.february'),
i18n.t('months.march'),
i18n.t('months.april'),
i18n.t('months.may'),
i18n.t('months.june'),
i18n.t('months.july'),
i18n.t('months.august'),
i18n.t('months.september'),
i18n.t('months.october'),
i18n.t('months.november'),
i18n.t('months.december'),
].map((text, index) => ({
text: text,
value: index,
}))
);
const days = computed(() => {
const days = [];
for (let i = 1; i <= 31; i++) {
days.push(`${i}`);
}
return days;
});
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 hours = [];
for (let i = 0; i <= 24; i++) {
let hour = String(i);
if (hour.length === 1) hour = '0' + hour;
hours.push({
text: hour,
value: i,
});
}
return hours;
});
const minutesSeconds = computed(() => {
const values = [];
for (let i = 0; i <= 60; i++) {
let val = String(i);
if (val.length === 1) val = '0' + val;
values.push({
text: val,
value: i,
});
}
return values;
});
return { days, years, months, hours, minutesSeconds };
}
},
});
</script>
<style lang="scss" scoped>
.date,
.time {
display: grid;
grid-gap: 8px;
width: 100%;
padding: 16px 8px;
}
.date {
grid-template-columns: repeat(2, 1fr);
}
.time {
grid-template-columns: repeat(3, 1fr);
}
.month {
grid-column: 1 / span 2;
}
.to-now {
width: 100%;
margin: 8px 0;
color: var(--primary);
text-align: center;
}
.v-icon.active {
--v-icon-color: var(--primary);
}
</style>

View File

@@ -0,0 +1,10 @@
import { defineInterface } from '@/interfaces/define';
import InterfaceDateTime from './datetime.vue';
export default defineInterface(({ i18n }) => ({
id: 'datetime',
name: i18n.t('datetime'),
icon: 'today',
component: InterfaceDateTime,
options: [],
}));

View File

@@ -0,0 +1,2 @@
# DateTime interface

View File

@@ -10,6 +10,7 @@ import InterfaceDropdownMultiselect from './dropdown-multiselect/';
import InterfaceRadioButtons from './radio-buttons';
import InterfaceCheckboxes from './checkboxes';
import InterfaceStatus from './status';
import InterfaceDateTime from './datetime';
export const interfaces = [
InterfaceTextInput,
@@ -24,6 +25,7 @@ export const interfaces = [
InterfaceRadioButtons,
InterfaceCheckboxes,
InterfaceStatus,
InterfaceDateTime,
];
export default interfaces;

View File

@@ -92,6 +92,30 @@
"submit": "Submit",
"datetime": "DateTime",
"date-fns_date": "PPP",
"date-fns_time": "K:mm a",
"month": "Month",
"date": "Date",
"year": "Year",
"months": {
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December"
},
"set_to_now": "Set to Now",
"name": "Name",
"primary_key_field": "Primary Key Field",
"type": "Type",
@@ -670,20 +694,6 @@
"max_size": "Max Size: {size}",
"mixed": "Mixed",
"more_options": "More options",
"months": {
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December"
},
"my_activity": "My Activity",
"my_profile": "My Profile: {name}",
"name_bookmark": "What would you like to name this bookmark?",

View File

@@ -48,6 +48,8 @@
"collection_names_cannot_be_changed": "Collectie namen kunnen niet gewijzigd worden.",
"collection_removed": "Collectie verwijderd",
"collection_updated": "Collectie Geüpdatet",
"date-fns_date": "PPP",
"date-fns_time": "HH:mm",
"collections_and_fields": "Collecties & Velden",
"collections": {
"directus_activity": "Activiteit",

View File

@@ -0,0 +1,25 @@
import { i18n } from '@/lang';
export async function getDateFNSLocale() {
const lang = i18n.locale;
const localesToTry = [lang, lang.split('-')[0], 'en-US'];
let locale;
for (const l of localesToTry) {
try {
const mod = await import(
/* webpackMode: 'lazy', webpackChunkName: 'df-[index]' */
`date-fns/locale/${l}/index.js`
);
locale = mod.default;
break;
} catch {
continue;
}
}
return locale;
}

View File

@@ -0,0 +1,4 @@
import { getDateFNSLocale } from './get-date-fns-locale';
export { getDateFNSLocale };
export default getDateFNSLocale;

View File

@@ -1,5 +1,5 @@
import formatDistanceOriginal from 'date-fns/formatDistance';
import { i18n } from '@/lang';
import getDateFNSLocale from '@/utils/get-date-fns-locale';
type LocalizedFormatDistance = (...a: Parameters<typeof formatDistanceOriginal>) => Promise<string>;
@@ -8,17 +8,8 @@ export const localizedFormatDistance: LocalizedFormatDistance = async (
baseDate,
options
): Promise<string> => {
const lang = i18n.locale;
const locale = (
await import(
/* webpackMode: 'lazy', webpackChunkName: 'df-[index]' */
`date-fns/locale/${lang}/index.js`
)
).default;
return formatDistanceOriginal(date, baseDate, {
...options,
locale,
locale: await getDateFNSLocale(),
});
};

View File

@@ -0,0 +1,4 @@
import { localizedFormat } from './localized-format';
export { localizedFormat };
export default localizedFormat;

View File

@@ -0,0 +1,11 @@
import formatOriginal from 'date-fns/format';
import getDateFNSLocale from '@/utils/get-date-fns-locale';
type localizedFormat = (...a: Parameters<typeof formatOriginal>) => Promise<string>;
export const localizedFormat: localizedFormat = async (date, format, options): Promise<string> => {
return formatOriginal(date, format, {
...options,
locale: await getDateFNSLocale(),
});
};