feat(frontend): add support for datetime and time (#3256)

* feat(frontend): add support for datetime and time

* feat(frontend): Time component

* feat(frontend): add datetime

* feat(frontend): fix default dimensions

* feat(frontend): fix default dimensions

* feat(frontend): add missing case

* feat(frontend): fix datetime picker + improve time picker

* feat(frontend): compute date validity

* feat(frontend): fix placeholder

* feat(frontend): adapt tooltips + add missing min/max

* feat(frontend): remove console.log

* Update components.ts

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Faton Ramadani
2024-02-21 12:44:39 +01:00
committed by GitHub
parent d143a5a569
commit d302e8ff46
14 changed files with 462 additions and 13 deletions
@@ -6,6 +6,8 @@
export let autofocus: boolean | null = false
export let useDropdown: boolean = false
export let minDate: string | undefined = undefined
export let maxDate: string | undefined = undefined
let date: string | undefined = undefined
let time: string | undefined = undefined
@@ -53,9 +55,9 @@
let randomId = 'datetarget-' + Math.random().toString(36).substring(7)
</script>
<div class="flex flex-row gap-1 items-center w-full" id={randomId}>
<div class="flex flex-row gap-1 items-center w-full" id={randomId} on:pointerdown on:focus>
<!-- svelte-ignore a11y-autofocus -->
<input type="date" bind:value={date} {autofocus} class="!w-3/4" />
<input type="date" bind:value={date} {autofocus} class="!w-3/4" min={minDate} max={maxDate} />
<input type="time" bind:value={time} class="!w-1/4 min-w-[100px]" />
<Button
variant="border"
+12 -2
View File
@@ -1,13 +1,16 @@
<script lang="ts">
import Markdown from 'svelte-exmarkdown'
import type { PopoverPlacement } from './Popover.model'
import Popover from './Popover.svelte'
import { ExternalLink, InfoIcon } from 'lucide-svelte'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
export let light = false
export let wrapperClass = ''
export let placement: PopoverPlacement | undefined = undefined
export let documentationLink: string | undefined = undefined
export let small = false
export let markdownTooltip: string | undefined = undefined
const plugins = [gfmPlugin()]
</script>
<Popover notClickable {placement} class={wrapperClass}>
@@ -19,7 +22,14 @@
<InfoIcon class="{small ? 'bottom-0' : '-bottom-0.5'} absolute" size={small ? 12 : 14} />
</div>
<svelte:fragment slot="text">
<slot />
{#if markdownTooltip}
<div class="prose-sm">
<Markdown md={markdownTooltip} {plugins} />
</div>
{:else}
<slot />
{/if}
{#if documentationLink}
<a href={documentationLink} target="_blank" class="text-blue-300 text-xs">
<div class="flex flex-row gap-2 mt-4">
@@ -38,7 +38,7 @@
result: undefined as string | undefined
})
$: handleDefault(resolvedConfig.defaultValue)
$: !value && handleDefault(resolvedConfig.defaultValue)
function formatDate(dateString: string, formatString: string = 'dd.MM.yyyy') {
if (formatString === '') {
@@ -98,7 +98,7 @@
min={resolvedConfig.minDate}
max={resolvedConfig.maxDate}
placeholder="Type..."
class={twMerge(css?.input?.class, 'wm-date-input')}
class={twMerge('windmillapp w-full py-1.5 text-sm px-2', css?.input?.class, 'wm-date-input')}
style={css?.input?.style ?? ''}
/>
{/if}
@@ -0,0 +1,151 @@
<script lang="ts">
import { getContext } from 'svelte'
import { initConfig, initOutput } from '../../editor/appUtils'
import type {
AppViewerContext,
ComponentCustomCSS,
VerticalAlignment,
RichConfigurations
} from '../../types'
import { initCss } from '../../utils'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import { twMerge } from 'tailwind-merge'
import { parseISO, format as formatDateFns } from 'date-fns'
export let id: string
export let configuration: RichConfigurations
export let inputType: 'date'
export let verticalAlignment: VerticalAlignment | undefined = undefined
export let customCss: ComponentCustomCSS<'datetimeinputcomponent'> | undefined = undefined
export let render: boolean
const { app, worldStore, componentControl, selectedComponent } =
getContext<AppViewerContext>('AppViewerContext')
let resolvedConfig = initConfig(
components['datetimeinputcomponent'].initialData.configuration,
configuration
)
let value: string | undefined = undefined
$componentControl[id] = {
setValue(nvalue: string) {
value = nvalue
}
}
let outputs = initOutput($worldStore, id, {
result: undefined as string | undefined,
validity: true as boolean | undefined
})
$: handleDefault(resolvedConfig.defaultValue)
function formatDate(dateString: string, formatString: string = 'dd.MM.yyyy HH:mm') {
if (formatString === '') {
formatString = 'dd.MM.yyyy HH:mm'
}
try {
const isoDate = parseISO(dateString)
return formatDateFns(isoDate, formatString)
} catch (error) {
return 'Error formatting date:' + error.message
}
}
$: {
if (value) {
outputs?.result.set(formatDate(value, resolvedConfig.outputFormat))
const valueDate = new Date(value)
if (resolvedConfig.minDateTime) {
const minDate = new Date(resolvedConfig.minDateTime)
if (
minDate.getDay() === valueDate.getDay() &&
minDate.getMonth() === valueDate.getMonth() &&
minDate.getFullYear() === valueDate.getFullYear()
) {
outputs?.validity.set(minDate.getTime() < valueDate.getTime())
}
if (minDate.getTime() > valueDate.getTime()) {
outputs?.validity.set(false)
}
}
if (resolvedConfig.maxDateTime) {
const maxDate = new Date(resolvedConfig.maxDateTime)
if (
maxDate.getDay() === valueDate.getDay() &&
maxDate.getMonth() === valueDate.getMonth() &&
maxDate.getFullYear() === valueDate.getFullYear()
) {
outputs?.validity.set(maxDate.getTime() > valueDate.getTime())
}
if (maxDate.getTime() < valueDate.getTime()) {
outputs?.validity.set(false)
}
}
} else {
outputs?.result.set(undefined)
}
}
function handleDefault(defaultValue: string | undefined) {
value = defaultValue
}
let css = initCss($app.css?.datetimeinputcomponent, customCss)
</script>
{#each Object.keys(components['datetimeinputcomponent'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
{key}
bind:resolvedConfig={resolvedConfig[key]}
configuration={configuration[key]}
/>
{/each}
{#each Object.keys(css ?? {}) as key (key)}
<ResolveStyle
{id}
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.datetimeinputcomponent}
/>
{/each}
<InitializeComponent {id} />
<AlignWrapper {render} {verticalAlignment}>
<div class={twMerge(css?.container?.class, 'w-full')} style={css?.container?.style}>
{#if inputType === 'date'}
<DateTimeInput
bind:value
useDropdown={resolvedConfig?.displayPresets}
on:pointerdown={(e) => {
e.stopPropagation()
$selectedComponent = [id]
}}
minDate={resolvedConfig.minDateTime
? formatDate(resolvedConfig.minDateTime, 'yyyy-MM-dd')
: undefined}
maxDate={resolvedConfig.maxDateTime
? formatDate(resolvedConfig.maxDateTime, 'yyyy-MM-dd')
: undefined}
on:focus={() => ($selectedComponent = [id])}
/>
{/if}
</div>
</AlignWrapper>
@@ -0,0 +1,122 @@
<script lang="ts">
import { getContext } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { initConfig, initOutput } from '../../editor/appUtils'
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
import { initCss } from '../../utils'
import AlignWrapper from '../helpers/AlignWrapper.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
export let id: string
export let configuration: RichConfigurations
export let verticalAlignment: 'top' | 'center' | 'bottom' | undefined = undefined
export let customCss: ComponentCustomCSS<'timeinputcomponent'> | undefined = undefined
export let render: boolean
const { app, worldStore, selectedComponent, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
let resolvedConfig = initConfig(
components['timeinputcomponent'].initialData.configuration,
configuration
)
let value: string | undefined = undefined
$componentControl[id] = {
setValue(nvalue: string) {
value = nvalue
}
}
let outputs = initOutput($worldStore, id, {
result: undefined as string | undefined,
validity: true as boolean
})
$: !value && handleDefault(resolvedConfig.defaultValue)
function convertToMinutes(time: string) {
const [hours, minutes] = time.split(':').map(Number)
return hours * 60 + minutes
}
$: {
if (value) {
if (!resolvedConfig['24hFormat']) {
let time = value.split(':')
let hours = parseInt(time[0])
let minutes = time[1]
let ampm = hours >= 12 ? 'pm' : 'am'
hours = hours % 12
hours = hours ? hours : 12
outputs?.result.set(hours + ':' + minutes + ' ' + ampm)
} else {
outputs?.result.set(value)
}
let currentValueInMinutes = convertToMinutes(value)
let isValid = true
if (resolvedConfig.minTime) {
const minTimeInMinutes = convertToMinutes(resolvedConfig.minTime)
if (currentValueInMinutes < minTimeInMinutes) {
isValid = false
}
}
if (resolvedConfig.maxTime) {
const maxTimeInMinutes = convertToMinutes(resolvedConfig.maxTime)
if (currentValueInMinutes > maxTimeInMinutes) {
isValid = false
}
}
// At the end, set the validity
outputs?.validity.set(isValid)
}
}
function handleDefault(defaultValue: string | undefined) {
value = defaultValue
}
let css = initCss($app.css?.timeinputcomponent, customCss)
</script>
{#each Object.keys(components['timeinputcomponent'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
{key}
bind:resolvedConfig={resolvedConfig[key]}
configuration={configuration[key]}
/>
{/each}
{#each Object.keys(css ?? {}) as key (key)}
<ResolveStyle
{id}
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.timeinputcomponent}
/>
{/each}
<InitializeComponent {id} />
<AlignWrapper {render} {verticalAlignment}>
<input
on:focus={() => ($selectedComponent = [id])}
on:pointerdown|stopPropagation={() => ($selectedComponent = [id])}
type="time"
bind:value
min={resolvedConfig.minTime}
max={resolvedConfig.maxTime}
placeholder="Type..."
class={twMerge('windmillapp w-full py-1.5 text-sm px-2', css?.input?.class)}
style={css?.input?.style ?? ''}
/>
</AlignWrapper>
@@ -71,6 +71,8 @@
import AppS3FileInput from '../../components/inputs/AppS3FileInput.svelte'
import AppAlert from '../../components/display/AppAlert.svelte'
import AppDateSliderInput from '../../components/inputs/AppDateSliderInput.svelte'
import AppTimeInput from '../../components/inputs/AppTimeInput.svelte'
import AppDateTimeInput from '../../components/inputs/AppDateTimeInput.svelte'
export let component: AppComponent
export let selected: boolean
@@ -494,6 +496,23 @@
customCss={component.customCss}
{render}
/>
{:else if component.type === 'timeinputcomponent'}
<AppTimeInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'datetimeinputcomponent'}
<AppDateTimeInput
verticalAlignment={component.verticalAlignment}
configuration={component.configuration}
inputType="date"
id={component.id}
customCss={component.customCss}
{render}
/>
{:else if component.type === 'numberinputcomponent'}
<AppNumberInput
verticalAlignment={component.verticalAlignment}
@@ -46,7 +46,9 @@ import {
Network,
Database,
UploadCloud,
AlertTriangle
AlertTriangle,
Clock,
CalendarClock
} from 'lucide-svelte'
import type {
Aligned,
@@ -89,6 +91,8 @@ export type TextareaInputComponent = BaseComponent<'textareainputcomponent'>
export type PasswordInputComponent = BaseComponent<'passwordinputcomponent'>
export type EmailInputComponent = BaseComponent<'emailinputcomponent'>
export type DateInputComponent = BaseComponent<'dateinputcomponent'>
export type TimeInputComponent = BaseComponent<'timeinputcomponent'>
export type DateTimeInputComponent = BaseComponent<'datetimeinputcomponent'>
export type NumberInputComponent = BaseComponent<'numberinputcomponent'>
export type CurrencyComponent = BaseComponent<'currencycomponent'>
export type SliderComponent = BaseComponent<'slidercomponent'>
@@ -291,6 +295,8 @@ export type TypedComponent =
| AgChartsComponentEe
| AlertComponent
| DateSliderComponent
| TimeInputComponent
| DateTimeInputComponent
export type AppComponent = BaseAppComponent & TypedComponent
@@ -2314,12 +2320,14 @@ This is a paragraph.
minDate: {
type: 'static',
value: '',
fieldType: 'date'
fieldType: 'date',
tooltip: 'The minimum date that can be selected. The format is: "yyyy-MM-dd"'
},
maxDate: {
type: 'static',
value: '',
fieldType: 'date'
fieldType: 'date',
tooltip: 'The maximum date that can be selected. The format is: "yyyy-MM-dd"'
},
defaultValue: {
type: 'static',
@@ -2330,8 +2338,125 @@ This is a paragraph.
type: 'static',
value: undefined,
fieldType: 'text',
tooltip: 'See date-fns format for more information',
documentationLink: 'https://date-fns.org/v1.29.0/docs/format'
markdownTooltip: `### Output format
See date-fns format for more information. By default, it is 'dd.MM.yyyy'
| Format | Result | Description |
| ----------- | ----------- | ----------- |
| DD | 01, 02, ..., 31 | Day of the month |
| D | 1, 2, ..., 31 | Day of the month |
| MM | 01, 02, ..., 12 | Month |
| MMM | Jan, Feb, ..., Dec | Month |
| MMMM | January, February, ..., December | Month |
| YYYY | 2021, 2022, ... | Year |
`,
documentationLink: 'https://date-fns.org/v1.29.0/docs/format',
placeholder: 'dd.MM.yyyy'
}
}
}
},
datetimeinputcomponent: {
name: 'Date & Time',
icon: CalendarClock,
documentationLink: `${documentationBaseUrl}/datetime_input`,
dims: '2:1-6:2' as AppComponentDimensions,
customCss: {
container: { class: '', style: '' }
},
initialData: {
verticalAlignment: 'center',
componentInput: undefined,
configuration: {
displayPresets: {
type: 'static',
value: false,
fieldType: 'boolean',
tooltip: 'Display presets to select the date for example, in 1 week, in 1 month, etc.'
},
minDateTime: {
type: 'static',
value: '',
fieldType: 'datetime',
tooltip:
'The minimum date that can be selected. The format is the ISO 8601 format: "yyyy-MM-ddTHH:mm:ss:SSSZ", for example "2021-11-06T23:39:30.000Z", or toISOString() from a Date'
},
maxDateTime: {
type: 'static',
value: '',
fieldType: 'datetime',
tooltip:
'The maximum date that can be selected. The format is the ISO 8601 format: "yyyy-MM-ddTHH:mm:ss:SSSZ", for example "2021-11-06T23:39:30.000Z", or toISOString() from a Date'
},
outputFormat: {
type: 'static',
value: undefined,
fieldType: 'text',
documentationLink: 'https://date-fns.org/v1.29.0/docs/format',
placeholder: 'dd.MM.yyyy HH:mm',
markdownTooltip: `### Output format
See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
| Format | Result | Description |
| ----------- | ----------- | ----------- |
| DD | 01, 02, ..., 31 | Day of the month |
| D | 1, 2, ..., 31 | Day of the month |
| MM | 01, 02, ..., 12 | Month |
| MMM | Jan, Feb, ..., Dec | Month |
| MMMM | January, February, ..., December | Month |
| YYYY | 2021, 2022, ... | Year |
| HH | 00, 01, ..., 23 | Hours |
| mm | 00, 01, ..., 59 | Minutes |
| ss | 00, 01, ..., 59 | Seconds |
`
},
defaultValue: {
type: 'static',
value: undefined,
fieldType: 'datetime'
}
}
}
},
timeinputcomponent: {
name: 'Time',
icon: Clock,
documentationLink: `${documentationBaseUrl}/time_input`,
dims: '2:1-3:1' as AppComponentDimensions,
customCss: {
input: { class: '', style: '' }
},
initialData: {
verticalAlignment: 'center',
componentInput: undefined,
configuration: {
minTime: {
type: 'static',
value: '',
fieldType: 'time',
tooltip:
'The minimum date that can be selected. If the time provided is not valid, it will set the output "validity" to false. The format is: "HH:mm"'
},
maxTime: {
type: 'static',
value: '',
fieldType: 'time',
tooltip:
'The maximum date that can be selected. If the time provided is not valid, it will set the output "validity" to false. The format is: "HH:mm"'
},
defaultValue: {
type: 'static',
value: undefined,
fieldType: 'time'
},
['24hFormat']: {
type: 'static',
value: true,
fieldType: 'boolean',
tooltip:
'Use 24h format. Will change the format of the output of the component: HH:mm to hh:mm am/pm'
}
}
}
@@ -43,6 +43,8 @@ const inputs: ComponentSet = {
'dateslidercomponent',
'rangecomponent',
'dateinputcomponent',
'timeinputcomponent',
'datetimeinputcomponent',
'fileinputcomponent',
's3fileinputcomponent',
'checkboxcomponent',
@@ -105,6 +105,8 @@ export function getComponentControl(type: keyof typeof components): Array<Compon
return [clearFiles]
case 'displaycomponent':
case 'dateinputcomponent':
case 'timeinputcomponent':
case 'datetimeinputcomponent':
case 'textinputcomponent':
case 'numberinputcomponent':
case 'currencycomponent':
@@ -696,6 +696,12 @@ export const quickStyleProperties: Record<
dateinputcomponent: {
input: inputDefaultProps
},
timeinputcomponent: {
input: inputDefaultProps
},
datetimeinputcomponent: {
container: inputDefaultProps
},
fileinputcomponent: {
container: containerDefaultProps
},
@@ -39,6 +39,8 @@
export let acceptSelf: boolean = false
export let recomputeOnInputChanged = true
export let showOnDemandOnlyToggle = true
export let documentationLink: string | undefined = undefined
export let markdownTooltip: string | undefined = undefined
const { connectingInput, app } = getContext<AppViewerContext>('AppViewerContext')
@@ -80,8 +82,8 @@
{#if loading}
<Loader2 size={14} class="animate-spin ml-2" />
{/if}
{#if tooltip}
<Tooltip small>
{#if tooltip || markdownTooltip}
<Tooltip small {documentationLink} {markdownTooltip}>
{tooltip}
</Tooltip>
{/if}
@@ -59,6 +59,8 @@
placeholder={meta?.['placeholder']}
customTitle={meta?.['customTitle']}
loading={meta?.['loading']}
documentationLink={meta?.['documentationLink']}
markdownTooltip={meta?.['markdownTooltip']}
{displayType}
{recomputeOnInputChanged}
{showOnDemandOnlyToggle}
@@ -101,6 +101,7 @@
tooltip={config?.['tooltip']}
fileUpload={config?.['fileUpload']}
loading={config?.['loading']}
documentationLink={config?.['documentationLink']}
{showOnDemandOnlyToggle}
/>
{/if}
@@ -20,6 +20,7 @@
import AgChartWizard from '$lib/components/wizards/AgChartWizard.svelte'
import DBExplorerWizard from '$lib/components/wizards/DBExplorerWizard.svelte'
import Label from '$lib/components/Label.svelte'
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
export let componentInput: StaticInput<any> | undefined
export let fieldType: InputType | undefined = undefined
@@ -42,6 +43,10 @@
<textarea use:autosize on:keydown|stopPropagation bind:value={componentInput.value} />
{:else if fieldType === 'date'}
<input on:keydown|stopPropagation type="date" bind:value={componentInput.value} />
{:else if fieldType === 'time'}
<input on:keydown|stopPropagation type="time" bind:value={componentInput.value} />
{:else if fieldType === 'datetime'}
<DateTimeInput bind:value={componentInput.value} />
{:else if fieldType === 'boolean'}
<Toggle bind:checked={componentInput.value} size="xs" class="mt-2" />
{:else if fieldType === 'select' && selectOptions}