Browse Source

Nc feat/new date time cell UI (#8546)

* feat(nc-gui): new date picker setup

* feat(nc-gui): new date picker

* fix(nc-gui): date cell form view validation issue

* fix(nc-gui): disable date cell type support in mobile view

* fix(nc-gui): small changes

* feat(nc-gui): new cell year and month picker

* fix(nc-gui): add updated date time picker setup

* feat: update date time cell picker

* fix(nc-gui): add now option in time picker

* fix(nc-gui): small changes

* fix(nc-gui): add support to update month, year from date picker

* fix(nc-gui): update date picker select mont/year flow

* fix(test): date selector test case

* fix(nc-gui): update dateTime cell time picker

* fix(test): update time picker test case

* chore(nc-gui): lint

* fix(nc-gui): invalid date issue

* fix(nc-gui): date time picker tab issue

* fix(nc-gui): year cell test fail issue

* fix(nc-gui): date picker filter test fail issue

* fix(test): survey form test fail issue

* fix(test): update year field fill handler test case

* fix(test): update bulk update test

* fix(nc-gui): datetime multiple api call issue

* fix(test): update timezone related test

* fix(test): timezone related test update

* fix(nc-gui): tab focus issue

* fix(test): filter datetime test udpate

* fix(test): ai review changes

* fix(nc-gui): date picker font weight issue

* fix(nc-gui): update year picker font weight

* fix(nc-gui): show full date from date time cell instead of truncate

* fix(nc-gui): date time picker ui changes

* fix(nc-gui): date time cell width issue

* fix(nc-gui): update datetime time option width according to time format

* fix(nc-gui): disable datetime input if cell is readonly

* fic(nc-gui): add new time picker

* feat(nc-gui): update time picker

* chore(nc-gui): cleanup unwanted code

* fix(test): update time cell test cases

* fix(nc-gui): multiple api calls

* fix(test): update time cell filter & bulk update test cases

* fix(test): revert unrelated changes

* fix(nc-gui): pr review changes

* fix(nc-gui): add clear datetime cell icon in non grid view
pull/8559/head
Ramesh Mane 6 months ago committed by GitHub
parent
commit
56b5264177
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 145
      packages/nc-gui/components/cell/DatePicker.vue
  2. 328
      packages/nc-gui/components/cell/DateTimePicker.vue
  3. 159
      packages/nc-gui/components/cell/TimePicker.vue
  4. 123
      packages/nc-gui/components/cell/YearPicker.vue
  5. 150
      packages/nc-gui/components/nc/DatePicker.vue
  6. 88
      packages/nc-gui/components/nc/DateWeekSelector.vue
  7. 68
      packages/nc-gui/components/nc/MonthYearSelector.vue
  8. 104
      packages/nc-gui/components/nc/TimeSelector.vue
  9. 3
      packages/nc-gui/lang/en.json
  10. 4
      packages/nc-gui/utils/formValidations.ts
  11. 13
      packages/nocodb-sdk/src/lib/dateTimeHelper.ts
  12. 62
      tests/playwright/pages/Dashboard/BulkUpdate/index.ts
  13. 14
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  14. 6
      tests/playwright/pages/Dashboard/SurveyForm/index.ts
  15. 101
      tests/playwright/pages/Dashboard/common/Cell/DateTimeCell.ts
  16. 43
      tests/playwright/pages/Dashboard/common/Cell/TimeCell.ts
  17. 2
      tests/playwright/pages/Dashboard/common/Cell/YearCell.ts
  18. 2
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  19. 48
      tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts
  20. 11
      tests/playwright/tests/db/columns/columnDateTime.spec.ts
  21. 2
      tests/playwright/tests/db/views/viewFormShareSurvey.spec.ts

145
packages/nc-gui/components/cell/DatePicker.vue

@ -43,6 +43,8 @@ const isClearedInputMode = ref<boolean>(false)
const open = ref<boolean>(false) const open = ref<boolean>(false)
const tempDate = ref<dayjs.Dayjs | undefined>()
const localState = computed({ const localState = computed({
get() { get() {
if (!modelValue || isClearedInputMode.value) { if (!modelValue || isClearedInputMode.value) {
@ -56,7 +58,9 @@ const localState = computed({
const format = picker.value === 'month' ? dateFormat : 'YYYY-MM-DD' const format = picker.value === 'month' ? dateFormat : 'YYYY-MM-DD'
return dayjs(/^\d+$/.test(modelValue) ? +modelValue : modelValue, format) const value = dayjs(/^\d+$/.test(modelValue) ? +modelValue : modelValue, format)
return value
}, },
set(val?: dayjs.Dayjs) { set(val?: dayjs.Dayjs) {
isClearedInputMode.value = false isClearedInputMode.value = false
@ -79,20 +83,36 @@ const localState = computed({
}, },
}) })
watchEffect(() => {
if (localState.value) {
tempDate.value = localState.value
}
})
const randomClass = `picker_${Math.floor(Math.random() * 99999)}` const randomClass = `picker_${Math.floor(Math.random() * 99999)}`
onClickOutside(datePickerRef, (e) => { onClickOutside(datePickerRef, (e) => {
if ((e.target as HTMLElement)?.closest(`.${randomClass}`)) return if ((e.target as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`)) return
datePickerRef.value?.blur?.() datePickerRef.value?.blur?.()
open.value = false open.value = false
}) })
const onBlur = (e) => { const onBlur = (e) => {
if ((e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}`)) return if (
(e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`) ||
(e?.target as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`)
) {
return
}
open.value = false open.value = false
} }
const onFocus = () => {
open.value = true
}
watch( watch(
open, open,
(next) => { (next) => {
@ -165,14 +185,17 @@ const clickHandler = () => {
cellClickHandler() cellClickHandler()
} }
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent, _open?: boolean) => {
if (e.key !== 'Enter') { if (e.key !== 'Enter' && e.key !== 'Tab') {
e.stopPropagation() e.stopPropagation()
} }
switch (e.key) { switch (e.key) {
case 'Enter': case 'Enter':
open.value = !open.value e.preventDefault()
localState.value = tempDate.value
open.value = !_open
if (!open.value) { if (!open.value) {
editable.value = false editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) { if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
@ -181,7 +204,7 @@ const handleKeydown = (e: KeyboardEvent) => {
} }
return return
case 'Escape': case 'Escape':
if (open.value) { if (_open) {
open.value = false open.value = false
editable.value = false editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) { if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
@ -193,9 +216,18 @@ const handleKeydown = (e: KeyboardEvent) => {
datePickerRef.value?.blur?.() datePickerRef.value?.blur?.()
} }
return
case 'Tab':
open.value = false
if (isGrid.value) {
editable.value = false
datePickerRef.value?.blur?.()
}
return return
default: default:
if (!open.value && /^[0-9a-z]$/i.test(e.key)) { if (!_open && /^[0-9a-z]$/i.test(e.key)) {
open.value = true open.value = true
} }
} }
@ -232,32 +264,87 @@ useEventListener(document, 'keydown', (e: KeyboardEvent) => {
} }
} }
}) })
const handleUpdateValue = (e: Event) => {
const targetValue = (e.target as HTMLInputElement).value
if (!targetValue) {
tempDate.value = undefined
return
}
const value = dayjs(targetValue, dateFormat.value)
if (value.isValid()) {
tempDate.value = value
}
}
function handleSelectDate(value?: dayjs.Dayjs) {
tempDate.value = value
localState.value = value
open.value = false
}
</script> </script>
<template> <template>
<a-date-picker <NcDropdown
ref="datePickerRef" :visible="isOpen"
v-model:value="localState" :auto-close="false"
:disabled="readOnly" :trigger="['click']"
:picker="picker" class="nc-cell-field"
:tabindex="0"
:bordered="false"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]" :class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]"
:format="dateFormat" :overlay-class-name="`${randomClass} nc-picker-date ${open ? 'active' : ''} !min-w-[260px]`"
:placeholder="placeholder"
:allow-clear="!readOnly && !isEditColumn"
:input-read-only="!!isMobileMode"
:dropdown-class-name="`${randomClass} nc-picker-date children:border-1 children:border-gray-200 ${open ? 'active' : ''} `"
:open="isOpen"
@blur="onBlur"
@click="clickHandler"
@keydown="handleKeydown"
@mouseup.stop
@mousedown.stop
> >
<template #suffixIcon></template> <div
</a-date-picker> :title="localState?.format(dateFormat)"
class="nc-date-picker h-full flex items-center justify-between ant-picker-input relative group"
>
<input
ref="datePickerRef"
type="text"
:value="localState?.format(dateFormat) ?? ''"
:placeholder="placeholder"
class="nc-date-input border-none outline-none !text-current bg-transparent !focus:(border-none outline-none ring-transparent)"
:readonly="readOnly || !!isMobileMode"
@blur="onBlur"
@focus="onFocus"
@keydown="handleKeydown($event, open)"
@mouseup.stop
@mousedown.stop
@click="clickHandler"
@input="handleUpdateValue"
/>
<GeneralIcon
v-if="localState"
icon="closeCircle"
class="absolute right-0 top-[50%] transform -translate-y-1/2 invisible group-hover:visible cursor-pointer"
@click.stop="handleSelectDate()"
/>
</div>
<template #overlay>
<div class="w-[256px]">
<NcDatePicker
v-if="picker === 'month'"
v-model:page-date="tempDate"
v-model:selected-date="localState"
:is-open="isOpen"
type="month"
size="medium"
/>
<NcDatePicker
v-else
v-model:page-date="tempDate"
:is-open="isOpen"
:selected-date="localState"
:is-monday-first="false"
type="date"
size="medium"
@update:selected-date="handleSelectDate"
/>
</div>
</template>
</NcDropdown>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div> <div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template> </template>

328
packages/nc-gui/components/cell/DateTimePicker.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { dateFormats, isSystemColumn, timeFormats } from 'nocodb-sdk' import { dateFormats, isSystemColumn, isValidTimeFormat, timeFormats } from 'nocodb-sdk'
interface Props { interface Props {
modelValue?: string | null modelValue?: string | null
@ -37,18 +37,27 @@ const isDateInvalid = ref(false)
const datePickerRef = ref<HTMLInputElement>() const datePickerRef = ref<HTMLInputElement>()
const timePickerRef = ref<HTMLInputElement>()
const dateTimeFormat = computed(() => { const dateTimeFormat = computed(() => {
const dateFormat = parseProp(column?.value?.meta)?.date_format ?? dateFormats[0] const dateFormat = parseProp(column?.value?.meta)?.date_format ?? dateFormats[0]
const timeFormat = parseProp(column?.value?.meta)?.time_format ?? timeFormats[0] const timeFormat = parseProp(column?.value?.meta)?.time_format ?? timeFormats[0]
return `${dateFormat} ${timeFormat}` return `${dateFormat} ${timeFormat}`
}) })
const dateFormat = computed(() => parseProp(column?.value?.meta)?.date_format ?? dateFormats[0])
const timeFormat = computed(() => parseProp(column?.value?.meta)?.time_format ?? timeFormats[0])
let localModelValue = modelValue ? dayjs(modelValue).utc().local() : undefined let localModelValue = modelValue ? dayjs(modelValue).utc().local() : undefined
const isClearedInputMode = ref<boolean>(false) const isClearedInputMode = ref<boolean>(false)
const open = ref(false) const open = ref(false)
const tempDate = ref<dayjs.Dayjs | undefined>()
const isDatePicker = ref<boolean>(true)
const localState = computed({ const localState = computed({
get() { get() {
if (!modelValue || isClearedInputMode.value) { if (!modelValue || isClearedInputMode.value) {
@ -117,8 +126,18 @@ const localState = computed({
}, },
}) })
watchEffect(() => {
if (localState.value) {
tempDate.value = localState.value
}
})
const isColDisabled = computed(() => {
return isSystemColumn(column.value) || readOnly.value || (localState.value && isPk)
})
const isOpen = computed(() => { const isOpen = computed(() => {
if (readOnly.value) return false if (readOnly.value || isColDisabled.value) return false
return readOnly.value || (localState.value && isPk) ? false : open.value && (active.value || editable.value) return readOnly.value || (localState.value && isPk) ? false : open.value && (active.value || editable.value)
}) })
@ -126,15 +145,16 @@ const isOpen = computed(() => {
const randomClass = `picker_${Math.floor(Math.random() * 99999)}` const randomClass = `picker_${Math.floor(Math.random() * 99999)}`
onClickOutside(datePickerRef, (e) => { onClickOutside(datePickerRef, (e) => {
if ((e.target as HTMLElement)?.closest(`.${randomClass}`)) return if ((e.target as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`)) return
datePickerRef.value?.blur?.() datePickerRef.value?.blur?.()
timePickerRef.value?.blur?.()
open.value = false open.value = false
}) })
const onBlur = (e) => { const onFocus = (_isDatePicker: boolean) => {
if ((e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}`)) return isDatePicker.value = _isDatePicker
open.value = true
open.value = false
} }
watch( watch(
@ -142,7 +162,8 @@ watch(
(next) => { (next) => {
if (next) { if (next) {
editable.value = true editable.value = true
datePickerRef.value?.focus?.()
isDatePicker.value ? datePickerRef.value?.focus?.() : timePickerRef.value?.focus?.()
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, (e) => { onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, (e) => {
if ((e?.target as HTMLElement)?.closest(`.nc-${randomClass}`)) { if ((e?.target as HTMLElement)?.closest(`.nc-${randomClass}`)) {
@ -162,7 +183,7 @@ const placeholder = computed(() => {
((isForm.value || isExpandedForm.value) && !isDateInvalid.value) || ((isForm.value || isExpandedForm.value) && !isDateInvalid.value) ||
(isGrid.value && !showNull.value && !isDateInvalid.value && !isSystemColumn(column.value) && active.value) (isGrid.value && !showNull.value && !isDateInvalid.value && !isSystemColumn(column.value) && active.value)
) { ) {
return dateTimeFormat.value return { dateTime: dateTimeFormat.value, date: dateFormat.value, time: timeFormat.value }
} else if (isEditColumn.value && (modelValue === '' || modelValue === null)) { } else if (isEditColumn.value && (modelValue === '' || modelValue === null)) {
return t('labels.optional') return t('labels.optional')
} else if (modelValue === null && showNull.value) { } else if (modelValue === null && showNull.value) {
@ -180,25 +201,6 @@ const cellClickHandler = () => {
open.value = active.value || editable.value open.value = active.value || editable.value
} }
function okHandler(val: dayjs.Dayjs | string) {
isClearedInputMode.value = false
if (!val) {
emit('update:modelValue', null)
} else if (dayjs(val).isValid()) {
// setting localModelValue to cater NOW function in date picker
localModelValue = dayjs(val)
// send the payload in UTC format
emit('update:modelValue', dayjs(val).utc().format('YYYY-MM-DD HH:mm:ssZ'))
}
open.value = !open.value
if (!open.value && isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.()
editable.value = false
}
}
onMounted(() => { onMounted(() => {
cellClickHook?.on(cellClickHandler) cellClickHook?.on(cellClickHandler)
}) })
@ -206,13 +208,8 @@ onUnmounted(() => {
cellClickHook?.on(cellClickHandler) cellClickHook?.on(cellClickHandler)
}) })
const clickHandler = (e) => { const clickHandler = (e: MouseEvent, _isDatePicker: boolean = false) => {
if ((e.target as HTMLElement).closest(`.nc-${randomClass} .ant-picker-clear`)) { isDatePicker.value = _isDatePicker
e.stopPropagation()
emit('update:modelValue', null)
open.value = false
return
}
if (cellClickHook) { if (cellClickHook) {
return return
@ -220,46 +217,59 @@ const clickHandler = (e) => {
cellClickHandler() cellClickHandler()
} }
const isColDisabled = computed(() => { const handleKeydown = (e: KeyboardEvent, _open?: boolean, _isDatePicker: boolean = false) => {
return isSystemColumn(column.value) || readOnly.value || (localState.value && isPk)
})
const handleKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') { if (e.key !== 'Enter') {
e.stopPropagation() e.stopPropagation()
} }
switch (e.key) { switch (e.key) {
case 'Enter': case 'Enter':
if (isOpen.value) { e.preventDefault()
return okHandler((e.target as HTMLInputElement).value) localState.value = tempDate.value
if (!_isDatePicker) {
e.stopPropagation()
timePickerRef.value?.blur?.()
isDatePicker.value = false
datePickerRef.value?.focus?.()
cellClickHandler()
} else { } else {
open.value = true datePickerRef.value?.blur?.()
open.value = false
editable.value = false
} }
return return
case 'Escape': case 'Escape':
if (open.value) { if (_open) {
open.value = false open.value = false
editable.value = false editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) { if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.() _isDatePicker ? datePickerRef.value?.blur?.() : timePickerRef.value?.blur?.()
} }
} else { } else {
editable.value = false editable.value = false
datePickerRef.value?.blur?.() _isDatePicker ? datePickerRef.value?.blur?.() : timePickerRef.value?.blur?.()
} }
return return
case 'Tab': case 'Tab':
open.value = false open.value = false
if (isGrid.value) { if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
editable.value = false _isDatePicker ? datePickerRef.value?.blur?.() : timePickerRef.value?.blur?.()
datePickerRef.value?.blur()
if (e.shiftKey && _isDatePicker) {
editable.value = false
} else if (!e.shiftKey && !_isDatePicker) {
editable.value = false
} else {
e.stopPropagation()
}
} }
return return
default: default:
if (!open.value && /^[0-9a-z]$/i.test(e.key)) { if (!_open && /^[0-9a-z]$/i.test(e.key)) {
open.value = true open.value = true
} }
} }
@ -288,9 +298,9 @@ useEventListener(document, 'keydown', (e: KeyboardEvent) => {
e.preventDefault() e.preventDefault()
break break
default: default:
if (!isOpen.value && datePickerRef.value && /^[0-9a-z]$/i.test(e.key)) { if (!isOpen.value && (datePickerRef.value || timePickerRef.value) && /^[0-9a-z]$/i.test(e.key)) {
isClearedInputMode.value = true isClearedInputMode.value = true
datePickerRef.value.focus() isDatePicker.value ? datePickerRef.value?.focus() : timePickerRef.value?.focus()
editable.value = true editable.value = true
open.value = true open.value = true
} }
@ -299,35 +309,203 @@ useEventListener(document, 'keydown', (e: KeyboardEvent) => {
watch(editable, (nextValue) => { watch(editable, (nextValue) => {
if (isGrid.value && nextValue && !open.value) { if (isGrid.value && nextValue && !open.value) {
isDatePicker.value = true
open.value = true open.value = true
} }
}) })
const handleUpdateValue = (e: Event, _isDatePicker: boolean) => {
let targetValue = (e.target as HTMLInputElement).value
if (_isDatePicker) {
if (!targetValue) {
tempDate.value = undefined
return
}
const date = dayjs(targetValue, dateFormat.value)
if (date.isValid()) {
if (localState.value) {
tempDate.value = dayjs(`${date.format('YYYY-MM-DD')} ${localState.value.format(timeFormat.value)}`)
} else {
tempDate.value = date
}
}
}
if (!_isDatePicker) {
if (!targetValue) {
tempDate.value = dayjs(dayjs().format('YYYY-MM-DD'))
return
}
if (timeFormat.value === 'HH:mm' && targetValue.length > 5) {
targetValue = targetValue.slice(0, 5)
}
if (isValidTimeFormat(targetValue, timeFormat.value)) {
tempDate.value = dayjs(`${(tempDate.value ?? dayjs()).format('YYYY-MM-DD')} ${targetValue}`)
}
}
}
function handleSelectDate(value?: dayjs.Dayjs) {
if (value && localState.value) {
const dateTime = dayjs(`${value.format('YYYY-MM-DD')} ${localState.value.format(timeFormat.value)}`)
tempDate.value = dateTime
localState.value = dateTime
} else {
tempDate.value = value
localState.value = value
}
open.value = false
}
function handleSelectTime(value: dayjs.Dayjs) {
if (!value.isValid()) return
if (localState.value) {
const dateTime = dayjs(`${localState.value.format('YYYY-MM-DD')} ${value.format('HH:mm')}:00`)
tempDate.value = dateTime
localState.value = dateTime
} else {
const dateTime = dayjs(`${dayjs().format('YYYY-MM-DD')} ${value.format('HH:mm')}:00`)
tempDate.value = dateTime
localState.value = dateTime
}
open.value = false
}
const selectedTime = computed(() => {
const result = {
value: '',
label: '',
}
if (localState.value) {
const time = localState.value.format(timeFormat.value)
const [hours, minutes] = time.split(':')
result.value = `${hours}:${minutes}`
result.label = time
}
return result
})
const timeCellMaxWidth = computed(() => {
return {
[timeFormats[0]]: 'max-w-[65px]',
[timeFormats[1]]: 'max-w-[80px]',
[timeFormats[2]]: 'max-w-[110px]',
}[timeFormat.value]
})
</script> </script>
<template> <template>
<a-date-picker <div class="nc-cell-field group relative">
ref="datePickerRef" <NcDropdown
:value="localState" :visible="isOpen"
:disabled="isColDisabled" :placement="isDatePicker ? 'bottomLeft' : 'bottomRight'"
:show-time="true" :auto-close="false"
:bordered="false" :trigger="['click']"
class="nc-cell-field nc-cell-picker-datetime !w-full !py-1 !border-none !text-current" class="nc-cell-picker-datetime"
:class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]" :class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]"
:format="dateTimeFormat" :overlay-class-name="`${randomClass} nc-picker-datetime ${open ? 'active' : ''} !min-w-[0] overflow-hidden`"
:placeholder="placeholder" >
:allow-clear="!isColDisabled && !isEditColumn" <div
:input-read-only="!!isMobileMode" :title="localState?.format(dateTimeFormat)"
:dropdown-class-name="`${randomClass} nc-picker-datetime children:border-1 children:border-gray-200 ${open ? 'active' : ''}`" class="nc-date-picker ant-picker-input flex justify-between gap-2 relative group !w-auto"
:open="isOpen" >
@blur="onBlur" <div
@click="clickHandler" class="flex-none hover:bg-gray-100 px-1 rounded-md box-border w-[60%] max-w-[110px]"
@ok="okHandler" :class="{
@keydown="handleKeydown" 'py-0': isForm,
@mouseup.stop 'py-0.5': !isForm,
@mousedown.stop 'bg-gray-100': isDatePicker && isOpen,
> }"
<template #suffixIcon></template> >
</a-date-picker> <input
ref="datePickerRef"
:value="localState?.format(dateFormat) ?? ''"
:placeholder="typeof placeholder === 'string' ? placeholder : placeholder?.date"
class="nc-date-input w-full !truncate border-transparent outline-none !text-current !bg-transparent !focus:(border-none outline-none ring-transparent)"
:readonly="!!isMobileMode || isColDisabled"
@focus="onFocus(true)"
@keydown="handleKeydown($event, isOpen, true)"
@mouseup.stop
@mousedown.stop
@click.stop="clickHandler($event, true)"
@input="handleUpdateValue($event, true)"
/>
</div>
<div
class="flex-none hover:bg-gray-100 px-1 rounded-md box-border flex-1"
:class="[
`${timeCellMaxWidth}`,
{
'py-0': isForm,
'py-0.5': !isForm,
'bg-gray-100': !isDatePicker && isOpen,
},
]"
>
<input
ref="timePickerRef"
:value="selectedTime.value ? `${selectedTime.label}` : ''"
:placeholder="typeof placeholder === 'string' ? placeholder : placeholder?.time"
class="nc-time-input w-full !truncate border-transparent outline-none !text-current !bg-transparent !focus:(border-none outline-none ring-transparent)"
:readonly="!!isMobileMode || isColDisabled"
@focus="onFocus(false)"
@keydown="handleKeydown($event, open)"
@mouseup.stop
@mousedown.stop
@click.stop="clickHandler($event, false)"
@input="handleUpdateValue($event, false)"
/>
</div>
</div>
<template #overlay>
<div
class="min-w-[72px]"
:class="{
'w-[256px]': isDatePicker,
}"
>
<NcDatePicker
v-if="isDatePicker"
v-model:page-date="tempDate"
:selected-date="localState"
:is-open="isOpen"
type="date"
size="medium"
@update:selected-date="handleSelectDate"
/>
<template v-else>
<NcTimeSelector
:selected-date="localState"
:min-granularity="30"
is-min-granularity-picker
:is-open="isOpen"
@update:selected-date="handleSelectTime"
/>
</template>
</div>
</template>
</NcDropdown>
<GeneralIcon
v-if="localState && (isExpandedForm || isForm || !isGrid)"
icon="closeCircle"
class="h-4 w-4 absolute right-0 top-[50%] transform -translate-y-1/2 invisible group-hover:visible cursor-pointer"
@click.stop="handleSelectDate()"
/>
</div>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div> <div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template> </template>

159
packages/nc-gui/components/cell/TimePicker.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { isSystemColumn } from 'nocodb-sdk' import { isSystemColumn, isValidTimeFormat } from 'nocodb-sdk'
interface Props { interface Props {
modelValue?: string | null | undefined modelValue?: string | null | undefined
@ -43,6 +43,8 @@ const { t } = useI18n()
const open = ref(false) const open = ref(false)
const tempDate = ref<dayjs.Dayjs | undefined>()
const localState = computed({ const localState = computed({
get() { get() {
if (!modelValue || isClearedInputMode.value) { if (!modelValue || isClearedInputMode.value) {
@ -78,20 +80,35 @@ const localState = computed({
}, },
}) })
watchEffect(() => {
if (localState.value) {
tempDate.value = localState.value
}
})
const randomClass = `picker_${Math.floor(Math.random() * 99999)}` const randomClass = `picker_${Math.floor(Math.random() * 99999)}`
onClickOutside(datePickerRef, (e) => { onClickOutside(datePickerRef, (e) => {
if ((e.target as HTMLElement)?.closest(`.${randomClass}`)) return if ((e.target as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`)) return
datePickerRef.value?.blur?.() datePickerRef.value?.blur?.()
open.value = false open.value = false
}) })
const onBlur = (e) => { const onBlur = (e) => {
if ((e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}`)) return if (
(e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`) ||
(e?.target as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`)
) {
return
}
open.value = false open.value = false
} }
const onFocus = () => {
open.value = true
}
watch( watch(
open, open,
(next) => { (next) => {
@ -146,26 +163,38 @@ const clickHandler = () => {
open.value = active.value || editable.value open.value = active.value || editable.value
} }
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent, _open?: boolean) => {
if (e.key !== 'Enter') { if (e.key !== 'Enter') {
e.stopPropagation() e.stopPropagation()
} }
switch (e.key) { switch (e.key) {
case 'Enter': case 'Enter':
open.value = !open.value e.preventDefault()
localState.value = tempDate.value
open.value = !_open
if (!open.value) { if (!open.value) {
editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) { if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
editable.value = false
datePickerRef.value?.blur?.() datePickerRef.value?.blur?.()
} }
} }
return return
case 'Tab':
open.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
editable.value = false
datePickerRef.value?.blur?.()
}
return
case 'Escape': case 'Escape':
if (open.value) { if (_open) {
open.value = false open.value = false
editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) { if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
editable.value = false
datePickerRef.value?.blur?.() datePickerRef.value?.blur?.()
} }
} else { } else {
@ -175,7 +204,7 @@ const handleKeydown = (e: KeyboardEvent) => {
} }
return return
default: default:
if (!open.value && /^[0-9a-z]$/i.test(e.key)) { if (!_open && /^[0-9a-z]$/i.test(e.key)) {
open.value = true open.value = true
} }
} }
@ -212,39 +241,93 @@ useEventListener(document, 'keydown', (e: KeyboardEvent) => {
} }
} }
}) })
const handleUpdateValue = (e: Event) => {
let targetValue = (e.target as HTMLInputElement).value
if (!targetValue) {
tempDate.value = undefined
return
}
if (targetValue.length > 5) {
targetValue = targetValue.slice(0, 5)
}
if (isValidTimeFormat(targetValue, 'HH:mm')) {
tempDate.value = dayjs(`${dayjs().format('YYYY-MM-DD')} ${targetValue}`)
}
}
function handleSelectTime(value?: dayjs.Dayjs) {
if (!value) {
tempDate.value = undefined
localState.value = undefined
}
if (!value?.isValid()) return
if (localState.value) {
const dateTime = dayjs(`${localState.value.format('YYYY-MM-DD')} ${value.format('HH:mm')}:00`)
tempDate.value = dateTime
localState.value = dateTime
} else {
const dateTime = dayjs(`${dayjs().format('YYYY-MM-DD')} ${value.format('HH:mm')}:00`)
tempDate.value = dateTime
localState.value = dateTime
}
open.value = false
}
</script> </script>
<template> <template>
<a-time-picker <NcDropdown
ref="datePickerRef" :visible="isOpen"
v-model:value="localState" :auto-close="false"
:tabindex="0" :trigger="['click']"
:disabled="readOnly" class="nc-cell-field"
:show-time="true"
:bordered="false"
use12-hours
format="HH:mm"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]" :class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]"
:placeholder="placeholder" :overlay-class-name="`${randomClass} nc-picker-time ${isOpen ? 'active' : ''} !min-w-[0]`"
:allow-clear="!readOnly && !isPk && !isEditColumn"
:input-read-only="!!isMobileMode"
:open="isOpen"
:popup-class-name="`${randomClass} nc-picker-time children:border-1 children:border-gray-200 ${open ? 'active' : ''}`"
@blur="onBlur"
@keydown="handleKeydown"
@click="clickHandler"
@ok="open = !open"
@mouseup.stop
@mousedown.stop
> >
<template #suffixIcon></template> <div
</a-time-picker> :title="localState?.format('HH:mm')"
class="nc-time-picker h-full flex items-center justify-between ant-picker-input relative group"
>
<input
ref="datePickerRef"
type="text"
:value="localState?.format('HH:mm') ?? ''"
:placeholder="placeholder"
class="nc-time-input border-none outline-none !text-current bg-transparent !focus:(border-none outline-none ring-transparent)"
:readonly="readOnly || !!isMobileMode"
@blur="onBlur"
@focus="onFocus"
@keydown="handleKeydown($event, isOpen)"
@mouseup.stop
@mousedown.stop
@click="clickHandler"
@input="handleUpdateValue"
/>
<GeneralIcon
v-if="localState"
icon="closeCircle"
class="absolute right-0 top-[50%] transform -translate-y-1/2 invisible group-hover:visible cursor-pointer"
@click.stop="handleSelectTime()"
/>
</div>
<template #overlay>
<div class="w-[72px]">
<NcTimeSelector
:selected-date="localState"
:min-granularity="30"
is-min-granularity-picker
:is-open="isOpen"
@update:selected-date="handleSelectTime"
/>
</div>
</template>
</NcDropdown>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div> <div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template> </template>
<style scoped>
:deep(.ant-picker-input > input) {
@apply !text-current;
}
</style>

123
packages/nc-gui/components/cell/YearPicker.vue

@ -39,6 +39,8 @@ const { t } = useI18n()
const open = ref<boolean>(false) const open = ref<boolean>(false)
const tempDate = ref<dayjs.Dayjs | undefined>()
const localState = computed({ const localState = computed({
get() { get() {
if (!modelValue || isClearedInputMode.value) { if (!modelValue || isClearedInputMode.value) {
@ -69,16 +71,27 @@ const localState = computed({
}, },
}) })
watchEffect(() => {
if (localState.value) {
tempDate.value = localState.value
}
})
const randomClass = `picker_${Math.floor(Math.random() * 99999)}` const randomClass = `picker_${Math.floor(Math.random() * 99999)}`
onClickOutside(datePickerRef, (e) => { onClickOutside(datePickerRef, (e) => {
if ((e.target as HTMLElement)?.closest(`.${randomClass}`)) return if ((e.target as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`)) return
datePickerRef.value?.blur?.() datePickerRef.value?.blur?.()
open.value = false open.value = false
}) })
const onBlur = (e) => { const onBlur = (e) => {
if ((e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}`)) return if (
(e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`) ||
(e?.target as HTMLElement)?.closest(`.${randomClass}, .nc-${randomClass}`)
) {
return
}
open.value = false open.value = false
} }
@ -136,14 +149,16 @@ const clickHandler = () => {
open.value = active.value || editable.value open.value = active.value || editable.value
} }
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent, _open?: boolean) => {
if (e.key !== 'Enter') { if (e.key !== 'Enter' && e.key !== 'Tab') {
e.stopPropagation() e.stopPropagation()
} }
switch (e.key) { switch (e.key) {
case 'Enter': case 'Enter':
open.value = !open.value e.preventDefault()
localState.value = tempDate.value
open.value = !_open
if (!open.value) { if (!open.value) {
editable.value = false editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) { if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
@ -151,9 +166,19 @@ const handleKeydown = (e: KeyboardEvent) => {
} }
} }
return
case 'Tab':
open.value = false
if (isGrid.value) {
editable.value = false
datePickerRef.value?.blur?.()
}
return return
case 'Escape': case 'Escape':
if (open.value) { if (_open) {
open.value = false open.value = false
editable.value = false editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) { if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
@ -166,7 +191,7 @@ const handleKeydown = (e: KeyboardEvent) => {
} }
return return
default: default:
if (!open.value && /^[0-9a-z]$/i.test(e.key)) { if (!_open && /^[0-9a-z]$/i.test(e.key)) {
open.value = true open.value = true
} }
} }
@ -203,31 +228,77 @@ useEventListener(document, 'keydown', (e: KeyboardEvent) => {
} }
} }
}) })
const handleUpdateValue = (e: Event) => {
const targetValue = (e.target as HTMLInputElement).value
if (!targetValue) {
tempDate.value = undefined
return
}
const value = dayjs(targetValue, 'YYYY')
if (value.isValid()) {
tempDate.value = value
}
}
function handleSelectDate(value?: dayjs.Dayjs) {
tempDate.value = value
localState.value = value
open.value = false
}
</script> </script>
<template> <template>
<a-date-picker <NcDropdown
ref="datePickerRef" :visible="isOpen"
v-model:value="localState" :auto-close="false"
:trigger="['click']"
:disabled="readOnly" :disabled="readOnly"
:tabindex="0" class="nc-cell-field"
picker="year"
:bordered="false"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]" :class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]"
:placeholder="placeholder" :overlay-class-name="`${randomClass} nc-picker-year ${open ? 'active' : ''} !min-w-[260px]`"
:allow-clear="!readOnly && !isPk"
:input-read-only="!!isMobileMode"
:open="isOpen"
:dropdown-class-name="`${randomClass} nc-picker-year children:border-1 children:border-gray-200 ${open ? 'active' : ''}`"
@blur="onBlur"
@keydown="handleKeydown"
@click="clickHandler"
@mouseup.stop
@mousedown.stop
> >
<template #suffixIcon></template> <div
</a-date-picker> :title="localState?.format('YYYY')"
class="nc-year-picker flex items-center justify-between ant-picker-input relative group"
>
<input
ref="datePickerRef"
type="text"
:value="localState?.format('YYYY') ?? ''"
:placeholder="placeholder"
class="nc-year-input border-none outline-none !text-current bg-transparent !focus:(border-none outline-none ring-transparent)"
:readonly="readOnly || !!isMobileMode"
@blur="onBlur"
@keydown="handleKeydown($event, open)"
@mouseup.stop
@mousedown.stop
@click="clickHandler"
@input="handleUpdateValue"
/>
<GeneralIcon
v-if="localState"
icon="closeCircle"
class="absolute right-0 top-[50%] transform -translate-y-1/2 invisible group-hover:visible cursor-pointer"
@click.stop="handleSelectDate()"
/>
</div>
<template #overlay>
<div class="w-[256px]">
<NcMonthYearSelector
v-model:page-date="tempDate"
v-model:selected-date="localState"
:is-open="isOpen"
is-year-picker
is-cell-input-field
size="medium"
/>
</div>
</template>
</NcDropdown>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div> <div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template> </template>

150
packages/nc-gui/components/nc/DatePicker.vue

@ -0,0 +1,150 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
interface Props {
size?: 'medium'
selectedDate?: dayjs.Dayjs | null
pageDate?: dayjs.Dayjs
isCellInputField?: boolean
type: 'date' | 'time' | 'year' | 'month'
isOpen: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
selectedDate: null,
pageDate: () => dayjs(),
isCellInputField: false,
type: 'date',
isOpen: false,
})
const emit = defineEmits(['update:selectedDate', 'update:pageDate', 'update:selectedWeek'])
// Page date is the date we use to manage which month/date that is currently being displayed
const pageDate = useVModel(props, 'pageDate', emit)
const selectedDate = useVModel(props, 'selectedDate', emit)
const { type, isOpen } = toRefs(props)
const localPageDate = ref()
const localSelectedDate = ref()
const pickerType = ref<Props['type'] | undefined>()
const pickerStack = ref<Props['type'][]>([])
const tempPickerType = computed(() => pickerType.value || type.value)
const handleUpdatePickerType = (value?: Props['type']) => {
if (value) {
pickerType.value = value
pickerStack.value.push(value)
} else {
if (pickerStack.value.length > 1) {
pickerStack.value.pop()
const lastPicker = pickerStack.value.pop()
pickerType.value = lastPicker
} else {
pickerStack.value = []
pickerType.value = type.value
}
}
}
const localStatePageDate = computed({
get: () => {
if (localPageDate.value) {
return localPageDate.value
}
return pageDate.value
},
set: (value) => {
pageDate.value = value
localPageDate.value = value
emit('update:pageDate', value)
},
})
const localStateSelectedDate = computed({
get: () => {
if (localSelectedDate.value) {
return localSelectedDate.value
}
return pageDate.value
},
set: (value: dayjs.Dayjs) => {
if (!value.isValid()) return
if (pickerType.value === type.value) {
localPageDate.value = value
emit('update:selectedDate', value)
localSelectedDate.value = undefined
return
}
if (['date', 'month'].includes(type.value)) {
if (pickerType.value === 'year') {
localSelectedDate.value = dayjs(localPageDate.value ?? localSelectedDate.value ?? selectedDate.value ?? dayjs()).year(
+value.format('YYYY'),
)
}
if (type.value !== 'month' && pickerType.value === 'month') {
localSelectedDate.value = dayjs(localPageDate.value ?? localSelectedDate.value ?? selectedDate.value ?? dayjs()).month(
+value.format('MM') - 1,
)
}
localPageDate.value = localSelectedDate.value
handleUpdatePickerType()
}
},
})
watch(isOpen, (next) => {
if (!next) {
pickerType.value = type.value
localPageDate.value = undefined
localSelectedDate.value = undefined
pickerStack.value = []
}
})
onUnmounted(() => {
pickerType.value = type.value
localPageDate.value = undefined
localSelectedDate.value = undefined
pickerStack.value = []
})
onMounted(() => {
localPageDate.value = undefined
localSelectedDate.value = undefined
pickerStack.value = []
})
</script>
<template>
<NcDateWeekSelector
v-if="tempPickerType === 'date'"
v-model:page-date="localStatePageDate"
v-model:selected-date="localStateSelectedDate"
:picker-type="pickerType"
:is-monday-first="false"
is-cell-input-field
size="medium"
@update:picker-type="handleUpdatePickerType"
/>
<NcMonthYearSelector
v-if="['month', 'year'].includes(tempPickerType)"
v-model:page-date="localStatePageDate"
v-model:selected-date="localStateSelectedDate"
:picker-type="pickerType"
:is-year-picker="tempPickerType === 'year'"
is-cell-input-field
size="medium"
@update:picker-type="handleUpdatePickerType"
/>
</template>
<style lang="scss" scoped></style>

88
packages/nc-gui/components/nc/DateWeekSelector.vue

@ -13,19 +13,23 @@ interface Props {
start: dayjs.Dayjs start: dayjs.Dayjs
end: dayjs.Dayjs end: dayjs.Dayjs
} | null } | null
isCellInputField?: boolean
pickerType?: 'date' | 'time' | 'year' | 'month'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'medium', size: 'medium',
selectedDate: null, selectedDate: null,
isMondayFirst: true, isMondayFirst: true,
pageDate: dayjs(), pageDate: () => dayjs(),
isWeekPicker: false, isWeekPicker: false,
activeDates: [] as Array<dayjs.Dayjs>, activeDates: () => [] as Array<dayjs.Dayjs>,
selectedWeek: null, selectedWeek: null,
hideCalendar: false, hideCalendar: false,
isCellInputField: false,
pickerType: 'date',
}) })
const emit = defineEmits(['update:selectedDate', 'update:pageDate', 'update:selectedWeek']) const emit = defineEmits(['update:selectedDate', 'update:pageDate', 'update:selectedWeek', 'update:pickerType'])
// Page date is the date we use to manage which month/date that is currently being displayed // Page date is the date we use to manage which month/date that is currently being displayed
const pageDate = useVModel(props, 'pageDate', emit) const pageDate = useVModel(props, 'pageDate', emit)
@ -35,6 +39,8 @@ const activeDates = useVModel(props, 'activeDates', emit)
const selectedWeek = useVModel(props, 'selectedWeek', emit) const selectedWeek = useVModel(props, 'selectedWeek', emit)
const pickerType = useVModel(props, 'pickerType', emit)
const days = computed(() => { const days = computed(() => {
if (props.isMondayFirst) { if (props.isMondayFirst) {
return ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] return ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
@ -47,6 +53,14 @@ const currentMonthYear = computed(() => {
return dayjs(pageDate.value).format('MMMM YYYY') return dayjs(pageDate.value).format('MMMM YYYY')
}) })
const currentMonth = computed(() => {
return dayjs(pageDate.value).format('MMMM')
})
const currentYear = computed(() => {
return dayjs(pageDate.value).format('YYYY')
})
const selectWeek = (date: dayjs.Dayjs) => { const selectWeek = (date: dayjs.Dayjs) => {
const dayOffset = +props.isMondayFirst const dayOffset = +props.isMondayFirst
const dayOfWeek = (date.day() - dayOffset + 7) % 7 const dayOfWeek = (date.day() - dayOffset + 7) % 7
@ -102,6 +116,9 @@ const isDayInPagedMonth = (date: dayjs.Dayjs) => {
const handleSelectDate = (date: dayjs.Dayjs) => { const handleSelectDate = (date: dayjs.Dayjs) => {
if (props.isWeekPicker) { if (props.isWeekPicker) {
selectWeek(date) selectWeek(date)
} else if (props.isCellInputField) {
selectedDate.value = date
emit('update:selectedDate', date)
} else { } else {
if (!isDayInPagedMonth(date)) { if (!isDayInPagedMonth(date)) {
pageDate.value = date pageDate.value = date
@ -137,17 +154,30 @@ const paginate = (action: 'next' | 'prev') => {
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex justify-between border-b-1 px-3 py-0.5 nc-date-week-header items-center"> <div
class="flex justify-between border-b-1 nc-date-week-header items-center box-border"
:class="{
'px-2 py-1 h-10': isCellInputField,
'px-3 py-0.5': !isCellInputField,
}"
>
<NcTooltip hide-on-click> <NcTooltip hide-on-click>
<NcButton class="!border-0" size="small" type="secondary" @click="paginate('prev')"> <NcButton class="!border-0" size="small" type="secondary" @click="paginate('prev')">
<component :is="iconMap.arrowLeft" class="h-4 w-4" /> <component :is="iconMap.arrowLeft" class="h-4 w-4" />
</NcButton> </NcButton>
<template #title> <template #title>
<span>{{ $t('labels.next') }}</span> <span>{{ $t('labels.previous') }}</span>
</template> </template>
</NcTooltip> </NcTooltip>
<span class="text-gray-700 text-sm font-semibold">{{ currentMonthYear }}</span> <div v-if="isCellInputField" class="text-gray-700 text-sm font-semibold">
<span class="nc-month-picker-btn cursor-pointer hover:text-brand-500" @click="pickerType = 'month'">{{
currentMonth
}}</span>
{{ ' ' }}
<span class="nc-year-picker-btn cursor-pointer hover:text-brand-500" @click="pickerType = 'year'">{{ currentYear }}</span>
</div>
<span v-else class="text-gray-700 text-sm font-semibold">{{ currentMonthYear }}</span>
<NcTooltip hide-on-click> <NcTooltip hide-on-click>
<NcButton class="!border-0" data-testid="nc-calendar-next-btn" size="small" type="secondary" @click="paginate('next')"> <NcButton class="!border-0" data-testid="nc-calendar-next-btn" size="small" type="secondary" @click="paginate('next')">
@ -159,21 +189,36 @@ const paginate = (action: 'next' | 'prev') => {
</NcTooltip> </NcTooltip>
</div> </div>
<div v-if="!hideCalendar" class="max-w-[320px] rounded-y-xl"> <div v-if="!hideCalendar" class="max-w-[320px] rounded-y-xl">
<div class="flex py-1 gap-1 px-2.5 rounded-t-xl flex-row border-gray-200 justify-between"> <div class="py-1 px-2.5 h-10">
<span <div
v-for="(day, index) in days" class="flex gap-1"
:key="index" :class="{
class="flex w-8 h-8 items-center uppercase font-medium justify-center text-gray-500" 'border-b-1 border-gray-200 ': isCellInputField,
>{{ day[0] }}</span }"
> >
<span
v-for="(day, index) in days"
:key="index"
class="flex w-8 h-8 items-center uppercase font-medium justify-center text-gray-500"
>{{ day[0] }}</span
>
</div>
</div> </div>
<div class="grid gap-1 py-1 px-2.5 nc-date-week-grid-wrapper grid-cols-7"> <div
class="grid gap-1 py-1 nc-date-week-grid-wrapper grid-cols-7"
:class="{
'px-2': isCellInputField,
'px-2.5': !isCellInputField,
}"
>
<span <span
v-for="(date, index) in dates" v-for="(date, index) in dates"
:key="index" :key="index"
:class="{ :class="{
'rounded-lg': !isWeekPicker, 'rounded-lg': !isWeekPicker && !isCellInputField,
'bg-gray-200 border-1 font-bold ': isSelectedDate(date) && !isWeekPicker && isDayInPagedMonth(date), 'border-1 ': isSelectedDate(date) && !isWeekPicker && isDayInPagedMonth(date),
'bg-gray-200 !font-bold': isSelectedDate(date) && !isWeekPicker && isDayInPagedMonth(date) && !isCellInputField,
'bg-gray-300 !font-weight-600': isSelectedDate(date) && !isWeekPicker && isDayInPagedMonth(date) && isCellInputField,
'hover:(border-1 border-gray-200 bg-gray-100)': !isSelectedDate(date) && !isWeekPicker, 'hover:(border-1 border-gray-200 bg-gray-100)': !isSelectedDate(date) && !isWeekPicker,
'nc-selected-week !font-semibold z-1': isDateInSelectedWeek(date) && isWeekPicker, 'nc-selected-week !font-semibold z-1': isDateInSelectedWeek(date) && isWeekPicker,
'border-none': isWeekPicker, 'border-none': isWeekPicker,
@ -183,9 +228,13 @@ const paginate = (action: 'next' | 'prev') => {
'nc-selected-week-end': isSameDate(date, selectedWeek?.end), 'nc-selected-week-end': isSameDate(date, selectedWeek?.end),
'rounded-md text-brand-500 !font-semibold nc-calendar-today': isSameDate(date, dayjs()) && isDateInCurrentMonth(date), 'rounded-md text-brand-500 !font-semibold nc-calendar-today': isSameDate(date, dayjs()) && isDateInCurrentMonth(date),
'text-gray-500': date.get('day') === 0 || date.get('day') === 6, 'text-gray-500': date.get('day') === 0 || date.get('day') === 6,
'nc-date-item font-weight-400': isCellInputField,
'font-medium': !isCellInputField,
'rounded': !isWeekPicker && isCellInputField,
}" }"
class="px-1 h-8 w-8 py-1 relative transition border-1 font-medium flex text-gray-700 items-center cursor-pointer justify-center" class="px-1 h-8 w-8 py-1 relative transition border-1 flex text-gray-700 items-center cursor-pointer justify-center"
data-testid="nc-calendar-date" data-testid="nc-calendar-date"
:title="isCellInputField ? date.format('YYYY-MM-DD') : undefined"
@click="handleSelectDate(date)" @click="handleSelectDate(date)"
> >
<span <span
@ -196,11 +245,16 @@ const paginate = (action: 'next' | 'prev') => {
}" }"
class="absolute top-1 transition right-1 h-1.5 w-1.5 z-2 border-1 rounded-full border-white bg-brand-500" class="absolute top-1 transition right-1 h-1.5 w-1.5 z-2 border-1 rounded-full border-white bg-brand-500"
></span> ></span>
<span class="z-2"> <span class="nc-date-item-inner z-2">
{{ date.get('date') }} {{ date.get('date') }}
</span> </span>
</span> </span>
</div> </div>
<div v-if="isCellInputField" class="flex items-center justify-center px-2 pb-2 pt-1">
<NcButton class="nc-date-picker-now-btn !h-7" size="small" type="secondary" @click="handleSelectDate(dayjs())">
<span class="text-small"> {{ $t('labels.today') }} </span>
</NcButton>
</div>
</div> </div>
</div> </div>
</template> </template>

68
packages/nc-gui/components/nc/MonthYearSelector.vue

@ -6,20 +6,26 @@ interface Props {
pageDate?: dayjs.Dayjs pageDate?: dayjs.Dayjs
isYearPicker?: boolean isYearPicker?: boolean
hideCalendar?: boolean hideCalendar?: boolean
isCellInputField?: boolean
pickerType?: 'date' | 'time' | 'year' | 'month'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
selectedDate: null, selectedDate: null,
pageDate: dayjs(), pageDate: () => dayjs(),
isYearPicker: false, isYearPicker: false,
hideCalendar: false, hideCalendar: false,
isCellInputField: false,
pickerType: 'date',
}) })
const emit = defineEmits(['update:selectedDate', 'update:pageDate']) const emit = defineEmits(['update:selectedDate', 'update:pageDate', 'update:pickerType'])
const pageDate = useVModel(props, 'pageDate', emit) const pageDate = useVModel(props, 'pageDate', emit)
const selectedDate = useVModel(props, 'selectedDate', emit) const selectedDate = useVModel(props, 'selectedDate', emit)
const pickerType = useVModel(props, 'pickerType', emit)
const years = computed(() => { const years = computed(() => {
const date = pageDate.value const date = pageDate.value
const startOfYear = date.startOf('year') const startOfYear = date.startOf('year')
@ -86,24 +92,41 @@ const compareYear = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex px-2 border-b-1 py-0.5 justify-between items-center"> <div
class="flex border-b-1 justify-between items-center"
:class="{
'px-2 py-1 h-10': isCellInputField,
'px-3 py-0.5': !isCellInputField,
}"
>
<div class="flex"> <div class="flex">
<NcTooltip hide-on-click> <NcTooltip hide-on-click>
<NcButton class="!border-0" size="small" type="secondary" @click="paginate('prev')"> <NcButton class="nc-prev-page-btn !border-0" size="small" type="secondary" @click="paginate('prev')">
<component :is="iconMap.arrowLeft" class="h-4 w-4" /> <component :is="iconMap.arrowLeft" class="h-4 w-4" />
</NcButton> </NcButton>
<template #title> <template #title>
<span>{{ $t('labels.next') }}</span> <span>{{ $t('labels.previous') }}</span>
</template> </template>
</NcTooltip> </NcTooltip>
</div> </div>
<span class="text-gray-700 font-semibold">{{ <span
isYearPicker ? dayjs(selectedDate).year() : dayjs(pageDate).format('YYYY') class="nc-year-picker-btn text-gray-700 font-semibold"
}}</span> :class="{
'cursor-pointer hover:text-brand-500': isCellInputField && !isYearPicker,
}"
@click="!isYearPicker ? (pickerType = 'year') : () => undefined"
>{{
isYearPicker
? isCellInputField
? dayjs(selectedDate).year() || dayjs().year()
: dayjs(selectedDate).year()
: dayjs(pageDate).format('YYYY')
}}</span
>
<div class="flex"> <div class="flex">
<NcTooltip hide-on-click> <NcTooltip hide-on-click>
<NcButton class="!border-0" size="small" type="secondary" @click="paginate('next')"> <NcButton class="nc-next-page-btn !border-0" size="small" type="secondary" @click="paginate('next')">
<component :is="iconMap.arrowRight" class="h-4 w-4" /> <component :is="iconMap.arrowRight" class="h-4 w-4" />
</NcButton> </NcButton>
<template #title> <template #title>
@ -112,17 +135,29 @@ const compareYear = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
</NcTooltip> </NcTooltip>
</div> </div>
</div> </div>
<div v-if="!hideCalendar" class="rounded-y-xl px-2.5 py-1 max-w-[350px]"> <div
v-if="!hideCalendar"
class="rounded-y-xl py-1 max-w-[350px]"
:class="{
'px-2': isCellInputField,
'px-2.5': !isCellInputField,
}"
>
<div class="grid grid-cols-4 gap-2"> <div class="grid grid-cols-4 gap-2">
<template v-if="!isYearPicker"> <template v-if="!isYearPicker">
<span <span
v-for="(month, id) in months" v-for="(month, id) in months"
:key="id" :key="id"
:class="{ :class="{
'!bg-gray-200 !text-brand-900 !font-bold ': isMonthSelected(month), 'bg-gray-200 !text-brand-900 !font-bold': isMonthSelected(month) && !isCellInputField,
'bg-gray-300 !font-weight-600 ': isMonthSelected(month) && isCellInputField,
'hover:(border-1 border-gray-200 bg-gray-100)': !isMonthSelected(month),
'!text-brand-500': dayjs().isSame(month, 'month'), '!text-brand-500': dayjs().isSame(month, 'month'),
'font-weight-400 rounded': isCellInputField,
'font-medium rounded-lg': !isCellInputField,
}" }"
class="h-8 rounded-lg flex items-center transition-all font-medium justify-center hover:(border-1 border-gray-200 bg-gray-100) text-gray-700 cursor-pointer" class="nc-month-item h-8 flex items-center transition-all justify-center text-gray-700 cursor-pointer"
:title="isCellInputField ? month.format('YYYY-MM') : undefined"
@click="selectedDate = month" @click="selectedDate = month"
> >
{{ month.format('MMM') }} {{ month.format('MMM') }}
@ -133,10 +168,15 @@ const compareYear = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
v-for="(year, id) in years" v-for="(year, id) in years"
:key="id" :key="id"
:class="{ :class="{
'!bg-gray-200 !text-brand-500 !font-bold ': compareYear(year, selectedDate), 'bg-gray-200 !text-brand-500 !font-bold ': compareYear(year, selectedDate) && !isCellInputField,
'bg-gray-300 !font-weight-600 ': compareYear(year, selectedDate) && isCellInputField,
'hover:(border-1 border-gray-200 bg-gray-100)': !compareYear(year, selectedDate),
'!text-brand-500': dayjs().isSame(year, 'year'), '!text-brand-500': dayjs().isSame(year, 'year'),
'font-weight-400 text-gray-700 rounded': isCellInputField,
'font-medium text-gray-900 rounded-lg': !isCellInputField,
}" }"
class="h-8 rounded-lg flex items-center transition-all font-medium justify-center hover:(border-1 border-gray-200 bg-gray-100) text-gray-900 cursor-pointer" class="nc-year-item h-8 flex items-center transition-all justify-center cursor-pointer"
:title="isCellInputField ? year.format('YYYY') : undefined"
@click="selectedDate = year" @click="selectedDate = year"
> >
{{ year.format('YYYY') }} {{ year.format('YYYY') }}

104
packages/nc-gui/components/nc/TimeSelector.vue

@ -0,0 +1,104 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
interface Props {
selectedDate: dayjs.Dayjs | null
is12hrFormat?: boolean
isMinGranularityPicker?: boolean
minGranularity?: number
isOpen?: boolean
}
const props = withDefaults(defineProps<Props>(), {
selectedDate: null,
is12hrFormat: false,
isMinGranularityPicker: false,
minGranularity: 30,
isOpen: false,
})
const emit = defineEmits(['update:selectedDate'])
const pageDate = ref<dayjs.Dayjs>(dayjs())
const selectedDate = useVModel(props, 'selectedDate', emit)
const { is12hrFormat, isMinGranularityPicker, minGranularity, isOpen } = toRefs(props)
const timeOptionsWrapperRef = ref<HTMLDivElement>()
const compareTime = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
if (!date1 || !date2) return false
return date1.format('HH:mm') === date2.format('HH:mm')
}
const handleSelectTime = (time: dayjs.Dayjs) => {
pageDate.value = dayjs().set('hour', time.get('hour')).set('minute', time.get('minute'))
selectedDate.value = pageDate.value
// emit('update:selectedDate', pageDate.value)
}
// TODO: 12hr time format & regular time picker
const timeOptions = computed(() => {
return Array.from({ length: is12hrFormat.value ? 12 : 24 }).flatMap((_, h) => {
return (isMinGranularityPicker.value ? [0, minGranularity.value] : Array.from({ length: 60 })).map((_m, m) => {
const time = dayjs()
.set('hour', h)
.set('minute', isMinGranularityPicker.value ? (_m as number) : m)
return time
})
})
})
const handleAutoScroll = (behavior: ScrollBehavior = 'instant') => {
if (!timeOptionsWrapperRef.value || !selectedDate.value) return
setTimeout(() => {
const timeEl = timeOptionsWrapperRef.value?.querySelector(
`[data-testid="time-option-${selectedDate.value?.format('HH:mm')}"]`,
)
timeEl?.scrollIntoView({ behavior, block: 'center' })
}, 50)
}
watch([selectedDate, isOpen], () => {
if (timeOptionsWrapperRef.value && isOpen.value && selectedDate.value) {
handleAutoScroll()
}
})
onMounted(() => {
handleAutoScroll()
})
</script>
<template>
<div class="flex flex-col max-w-[350px]">
<div v-if="isMinGranularityPicker" ref="timeOptionsWrapperRef" class="h-[180px] overflow-y-auto nc-scrollbar-thin">
<div
v-for="time of timeOptions"
:key="time.format('HH:mm')"
class="hover:bg-gray-100 py-1 px-3 text-sm text-gray-600 font-weight-500 text-center cursor-pointer"
:class="{
'nc-selected bg-gray-100': selectedDate && compareTime(time, selectedDate),
}"
:data-testid="`time-option-${time.format('HH:mm')}`"
@click="handleSelectTime(time)"
>
{{ time.format('HH:mm') }}
</div>
</div>
<div v-else></div>
<div class="px-2 py-1 box-border flex items-center justify-center">
<NcButton :tabindex="-1" class="!h-7" size="small" type="secondary" @click="handleSelectTime(dayjs())">
<span class="text-small"> {{ $t('general.now') }} </span>
</NcButton>
</div>
</div>
</template>
<style lang="scss" scoped></style>

3
packages/nc-gui/lang/en.json

@ -204,7 +204,8 @@
"verify": "Verify", "verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance",
"now": "Now"
}, },
"objects": { "objects": {
"owner": "Owner", "owner": "Owner",

4
packages/nc-gui/utils/formValidations.ts

@ -77,7 +77,9 @@ export const requiredFieldValidatorFn = (value: unknown) => {
} }
if (typeof value === 'object') { if (typeof value === 'object') {
for (let _ in value) return true if (Object.keys(value).length > 0) {
return true
}
return false return false
} }

13
packages/nocodb-sdk/src/lib/dateTimeHelper.ts

@ -137,3 +137,16 @@ export const timeAgo = (date: any) => {
// show in local time // show in local time
return dayjs(date).fromNow(); return dayjs(date).fromNow();
}; };
export const isValidTimeFormat = (value: string, format: string) => {
const regexValidator = {
[timeFormats[0]]: /^([01]\d|2[0-3]):[0-5]\d$/,
[timeFormats[1]]: /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/,
[timeFormats[2]]: /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}$/,
};
if (regexValidator[format]) {
return regexValidator[format].test(value);
}
return false;
};

62
tests/playwright/pages/Dashboard/BulkUpdate/index.ts

@ -107,20 +107,24 @@ export class BulkUpdatePage extends BasePage {
.click(); .click();
break; break;
case 'year': case 'year':
picker = this.rootPage.locator('.ant-picker-dropdown.active'); await field.locator('input').click();
picker = this.rootPage.locator('.nc-picker-year.active');
await picker.waitFor(); await picker.waitFor();
await picker.locator(`td[title="${value}"]`).click(); await this.configureYear(value);
break; break;
case 'time': case 'time':
picker = this.rootPage.locator('.ant-picker-dropdown.active');
await picker.waitFor();
// eslint-disable-next-line no-case-declarations // eslint-disable-next-line no-case-declarations
const time = value.split(':'); const timeInput = field.locator('.nc-time-input');
await timeInput.click();
// eslint-disable-next-line no-case-declarations // eslint-disable-next-line no-case-declarations
const timePanel = picker.locator('.ant-picker-time-panel-column'); const dropdown = this.rootPage.locator('.nc-picker-time.active');
await timePanel.nth(0).locator('li').nth(+time[0]).click(); await dropdown.waitFor({ state: 'visible' });
await timePanel.nth(1).locator('li').nth(+time[1]).click();
await picker.locator('.ant-picker-ok').click(); await timeInput.fill(value);
await this.rootPage.keyboard.press('Enter');
await dropdown.waitFor({ state: 'hidden' });
break; break;
case 'singleSelect': case 'singleSelect':
picker = this.rootPage.locator('.ant-select-dropdown.active'); picker = this.rootPage.locator('.ant-select-dropdown.active');
@ -146,27 +150,53 @@ export class BulkUpdatePage extends BasePage {
break; break;
case 'date': case 'date':
{ {
await field.locator('input').click();
const values = value.split('-'); const values = value.split('-');
const { year, month, day } = { year: values[0], month: values[1], day: values[2] }; const { year, month, day } = { year: values[0], month: values[1], day: values[2] };
picker = this.rootPage.locator('.ant-picker-dropdown.active'); picker = this.rootPage.locator('.nc-picker-date.active');
const monthBtn = picker.locator('.ant-picker-month-btn');
const yearBtn = picker.locator('.ant-picker-year-btn');
await yearBtn.click();
await picker.waitFor(); await picker.waitFor();
await picker.locator(`td[title="${year}"]`).click(); const yearBtn = picker.locator('.nc-year-picker-btn');
await yearBtn.waitFor();
await yearBtn.click();
await this.configureYear(year);
const monthBtn = picker.locator('.nc-month-picker-btn');
await monthBtn.click(); await monthBtn.click();
await picker.waitFor(); await picker.waitFor();
await picker.locator(`td[title="${year}-${month}"]`).click(); await picker.locator(`span[title="${year}-${month}"]`).click();
await picker.waitFor(); await picker.waitFor();
await picker.locator(`td[title="${year}-${month}-${day}"]`).click(); await picker.locator(`span[title="${year}-${month}-${day}"]:visible`).click();
} }
break; break;
} }
} }
async configureYear(year: string) {
// configure year
await this.rootPage.locator('.nc-year-picker-btn:visible').waitFor();
let flag = true;
while (flag) {
const firstVisibleYear = await this.rootPage.locator('.nc-year-item').first().textContent();
const lastVisibleYear = await this.rootPage.locator('.nc-year-item').last().textContent();
if (+year >= +firstVisibleYear && +year <= +lastVisibleYear) {
flag = false;
} else if (+year < +firstVisibleYear) {
await this.rootPage.locator('.nc-prev-page-btn').click();
} else if (+year > +lastVisibleYear) {
await this.rootPage.locator('.nc-next-page-btn').click();
}
}
await this.rootPage.locator(`span[title="${year}"]`).waitFor();
await this.rootPage.locator(`span[title="${year}"]`).click({ force: true });
}
async save({ async save({
awaitResponse = true, awaitResponse = true,
}: { }: {

14
tests/playwright/pages/Dashboard/ExpandedForm/index.ts

@ -93,12 +93,18 @@ export class ExpandedFormPage extends BasePage {
await this.dashboard.linkRecord.select(value); await this.dashboard.linkRecord.select(value);
break; break;
case 'dateTime': case 'dateTime':
await field.locator('.nc-cell').click(); await field.locator('.nc-cell .nc-date-input').click();
// eslint-disable-next-line no-case-declarations // eslint-disable-next-line no-case-declarations
const dateTimeObj = new DateTimeCellPageObject(this.dashboard.grid.cell); const dateTimeObj = new DateTimeCellPageObject(this.dashboard.grid.cell);
await dateTimeObj.selectDate({ date: value.slice(0, 10) });
await dateTimeObj.selectTime({ hour: +value.slice(11, 13), minute: +value.slice(14, 16) }); await dateTimeObj.selectDate({ date: value.slice(0, 10), locator: field.locator('.nc-cell') });
await dateTimeObj.save();
await dateTimeObj.selectTime({
hour: +value.slice(11, 13),
minute: +value.slice(14, 16),
locator: field.locator('.nc-cell'),
fillValue: `${value.slice(11, 13).padStart(2, '0')}:${value.slice(14, 16).padStart(2, '0')}`,
});
break; break;
} }
} }

6
tests/playwright/pages/Dashboard/SurveyForm/index.ts

@ -76,11 +76,11 @@ export class SurveyFormPage extends BasePage {
// press enter key // press enter key
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).press('Enter'); await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).press('Enter');
} else if (param.type === 'DateTime') { } else if (param.type === 'DateTime') {
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).click(); await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).first().click();
const modal = this.rootPage.locator('.nc-picker-datetime'); const modal = this.rootPage.locator('.nc-picker-datetime');
await expect(modal).toBeVisible(); await expect(modal).toBeVisible();
await modal.locator('.ant-picker-now-btn').click(); await modal.locator('.nc-date-picker-now-btn').click();
await modal.locator('.ant-picker-ok').click(); await modal.waitFor({ state: 'hidden' });
await this.nextButton.click(); await this.nextButton.click();
} }

101
tests/playwright/pages/Dashboard/common/Cell/DateTimeCell.ts

@ -1,3 +1,4 @@
import { Locator } from '@playwright/test';
import { CellPageObject } from '.'; import { CellPageObject } from '.';
import BasePage from '../../../Base'; import BasePage from '../../../Base';
@ -30,60 +31,103 @@ export class DateTimeCellPageObject extends BasePage {
// date formats in `YYYY-MM-DD` // date formats in `YYYY-MM-DD`
date, date,
skipDate = false, skipDate = false,
index,
columnHeader,
locator,
}: { }: {
date: string; date: string;
skipDate?: boolean; skipDate?: boolean;
index?: number;
columnHeader?: string;
locator?: Locator;
}) { }) {
// title date format needs to be YYYY-MM-DD // title date format needs to be YYYY-MM-DD
const [year, month, day] = date.split('-'); const [year, month, day] = date.split('-');
const dateLocator = locator ? locator : this.get({ index, columnHeader });
await dateLocator.click();
await dateLocator.locator('.nc-date-input').click();
// configure year // configure year
await this.rootPage.locator('.ant-picker-year-btn:visible').waitFor(); await this.rootPage.locator('.nc-year-picker-btn:visible').waitFor();
await this.rootPage.locator('.ant-picker-year-btn:visible').click(); await this.rootPage.locator('.nc-year-picker-btn:visible').click();
await this.rootPage.locator(`td[title="${year}"]`).click();
await this.rootPage.locator('.nc-year-picker-btn:visible').waitFor();
let flag = true;
while (flag) {
const firstVisibleYear = await this.rootPage.locator('.nc-year-item').first().textContent();
const lastVisibleYear = await this.rootPage.locator('.nc-year-item').last().textContent();
if (+year >= +firstVisibleYear && +year <= +lastVisibleYear) {
flag = false;
} else if (+year < +firstVisibleYear) {
await this.rootPage.locator('.nc-prev-page-btn').click();
} else if (+year > +lastVisibleYear) {
await this.rootPage.locator('.nc-next-page-btn').click();
}
}
await this.rootPage.locator(`span[title="${year}"]`).waitFor();
await this.rootPage.locator(`span[title="${year}"]`).click({ force: true });
if (skipDate) { if (skipDate) {
await this.rootPage.locator(`td[title="${year}-${month}"]`).click(); await this.rootPage.locator(`span[title="${year}-${month}"]`).click();
return; return;
} }
// configure month // configure month
await this.rootPage.locator('.ant-picker-month-btn:visible').click(); await this.rootPage.locator('.nc-month-picker-btn:visible').click();
await this.rootPage.locator(`td[title="${year}-${month}"]`).click(); await this.rootPage.locator(`span[title="${year}-${month}"]`).click();
// configure day // configure day
await this.rootPage.locator(`td[title="${year}-${month}-${day}"]:visible`).click(); await this.rootPage.locator(`span[title="${year}-${month}-${day}"]:visible`).click();
} }
async selectTime({ async selectTime({
// hour: 0 - 23 // hour: 0 - 23
// minute: 0 - 59 // minute: 0 - 59
// second: 0 - 59 // second: 0 - 59
hour, hour,
minute, minute,
second, second,
index,
columnHeader,
fillValue,
selectFromPicker = false,
locator,
}: { }: {
hour: number; hour: number;
minute: number; minute: number;
second?: number | null; second?: number | null;
index?: number;
columnHeader?: string;
fillValue: string;
selectFromPicker?: boolean;
locator?: Locator;
}) { }) {
await this.rootPage const timeLocator = locator ? locator : this.get({ index, columnHeader });
.locator( await timeLocator.click();
`.ant-picker-time-panel-column:nth-child(1) > .ant-picker-time-panel-cell:nth-child(${hour + 1}):visible` const timeInput = timeLocator.locator('.nc-time-input');
) await timeInput.click();
.click();
await this.rootPage const dropdown = this.rootPage.locator('.nc-picker-datetime.active');
.locator( await dropdown.waitFor({ state: 'visible' });
`.ant-picker-time-panel-column:nth-child(2) > .ant-picker-time-panel-cell:nth-child(${minute + 1}):visible`
) if (!selectFromPicker) {
.click(); await timeInput.fill(fillValue);
if (second != null) { await this.rootPage.keyboard.press('Enter');
await this.rootPage await this.rootPage.keyboard.press('Escape');
.locator( } else {
`.ant-picker-time-panel-column:nth-child(3) > .ant-picker-time-panel-cell:nth-child(${second + 1}):visible` await dropdown
) .locator(`[data-testid="time-option-${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}"]`)
.scrollIntoViewIfNeeded();
await dropdown
.locator(`[data-testid="time-option-${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}"]`)
.click(); .click();
} }
await dropdown.waitFor({ state: 'hidden' });
} }
async close() { async close() {
@ -94,8 +138,15 @@ export class DateTimeCellPageObject extends BasePage {
const [date, time] = dateTime.split(' '); const [date, time] = dateTime.split(' ');
const [hour, minute, _second] = time.split(':'); const [hour, minute, _second] = time.split(':');
await this.open({ index, columnHeader }); await this.open({ index, columnHeader });
await this.selectDate({ date }); await this.selectDate({ date, index, columnHeader });
await this.selectTime({ hour: +hour, minute: +minute }); await this.selectTime({ hour: +hour, minute: +minute, index, columnHeader, fillValue: time });
await this.save();
} }
clickDateInput = async (locator: Locator) => {
await locator.locator('.nc-date-input').click();
};
clickTimeInput = async (locator: Locator) => {
await locator.locator('.nc-time-input').click();
};
} }

43
tests/playwright/pages/Dashboard/common/Cell/TimeCell.ts

@ -1,6 +1,6 @@
import { CellPageObject } from '.'; import { CellPageObject } from '.';
import BasePage from '../../../Base'; import BasePage from '../../../Base';
import { expect } from '@playwright/test'; import { expect, Locator } from '@playwright/test';
export class TimeCellPageObject extends BasePage { export class TimeCellPageObject extends BasePage {
readonly cell: CellPageObject; readonly cell: CellPageObject;
@ -17,7 +17,7 @@ export class TimeCellPageObject extends BasePage {
async verify({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) { async verify({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const cell = this.get({ index, columnHeader }); const cell = this.get({ index, columnHeader });
await cell.scrollIntoViewIfNeeded(); await cell.scrollIntoViewIfNeeded();
await cell.locator(`input[title="${value}"]`).waitFor({ state: 'visible' }); await cell.locator(`.nc-time-picker[title="${value}"]`).waitFor({ state: 'visible' });
await expect(cell.locator(`[title="${value}"]`)).toBeVisible(); await expect(cell.locator(`[title="${value}"]`)).toBeVisible();
} }
@ -27,18 +27,36 @@ export class TimeCellPageObject extends BasePage {
// second: 0 - 59 // second: 0 - 59
hour, hour,
minute, minute,
fillValue,
locator,
selectFromPicker = false,
}: { }: {
hour: number; hour: number;
minute: number; minute: number;
fillValue: string;
locator: Locator;
selectFromPicker?: boolean;
}) { }) {
const timePanel = this.rootPage.locator('.ant-picker-time-panel-column'); const timeInput = locator.locator('.nc-time-input');
await timePanel.nth(0).locator('.ant-picker-time-panel-cell').nth(hour).click(); await timeInput.click();
await timePanel.nth(1).locator('.ant-picker-time-panel-cell').nth(minute).click();
if (hour < 12) { const dropdown = this.rootPage.locator('.nc-picker-time.active');
await timePanel.nth(2).locator('.ant-picker-time-panel-cell').nth(0).click(); await dropdown.waitFor({ state: 'visible' });
if (!selectFromPicker) {
await timeInput.fill(fillValue);
await this.rootPage.keyboard.press('Shift+Enter');
await this.rootPage.keyboard.press('Escape');
} else { } else {
await timePanel.nth(2).locator('.ant-picker-time-panel-cell').nth(1).click(); await dropdown
.locator(`[data-testid="time-option-${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}"]`)
.scrollIntoViewIfNeeded();
await dropdown
.locator(`[data-testid="time-option-${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}"]`)
.click();
} }
await dropdown.waitFor({ state: 'hidden' });
} }
async save() { async save() {
@ -49,7 +67,12 @@ export class TimeCellPageObject extends BasePage {
const [hour, minute, _second] = value.split(':'); const [hour, minute, _second] = value.split(':');
await this.get({ index, columnHeader }).click(); await this.get({ index, columnHeader }).click();
await this.get({ index, columnHeader }).dblclick(); await this.get({ index, columnHeader }).dblclick();
await this.selectTime({ hour: +hour, minute: +minute }); await this.selectTime({
await this.save(); hour: +hour,
minute: +minute,
fillValue: value,
locator: this.get({ index, columnHeader }),
selectFromPicker: +minute === 0 || +minute === 30,
});
} }
} }

2
tests/playwright/pages/Dashboard/common/Cell/YearCell.ts

@ -17,7 +17,7 @@ export class YearCellPageObject extends BasePage {
async verify({ index, columnHeader, value }: { index: number; columnHeader: string; value: number }) { async verify({ index, columnHeader, value }: { index: number; columnHeader: string; value: number }) {
const cell = this.get({ index, columnHeader }); const cell = this.get({ index, columnHeader });
await cell.scrollIntoViewIfNeeded(); await cell.scrollIntoViewIfNeeded();
await cell.locator(`input[title="${value}"]`).waitFor({ state: 'visible' }); await cell.locator(`.nc-year-picker[title="${value}"]`).waitFor({ state: 'visible' });
await expect(cell.locator(`[title="${value}"]`)).toBeVisible(); await expect(cell.locator(`[title="${value}"]`)).toBeVisible();
} }
} }

2
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -200,7 +200,7 @@ export class CellPageObject extends BasePage {
const cell = await this.get({ const cell = await this.get({
index, index,
columnHeader, columnHeader,
}).locator('input'); }).locator('.nc-date-picker');
return await cell.getAttribute('title'); return await cell.getAttribute('title');
}) })
.toEqual(expectedValue); .toEqual(expectedValue);

48
tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts

@ -225,36 +225,46 @@ export class ToolbarFilterPage extends BasePage {
switch (dataType) { switch (dataType) {
case UITypes.Year: case UITypes.Year:
await this.get().locator('.nc-filter-value-select').click(); await this.get().locator('.nc-filter-value-select').click();
await this.rootPage.locator(`.ant-picker-dropdown:visible`).waitFor(); await this.rootPage.locator(`.nc-picker-year:visible`).waitFor();
await this.rootPage.locator(`.ant-picker-cell-inner:has-text("${value}")`).click(); await this.rootPage.locator('.nc-year-picker-btn:visible').waitFor();
await this.get().locator('.nc-filter-value-select .nc-year-input').fill(value);
await this.rootPage.keyboard.press('Enter');
break; break;
case UITypes.Time: case UITypes.Time:
// eslint-disable-next-line no-case-declarations // eslint-disable-next-line no-case-declarations
const time = value.split(':'); const timeInput = this.get().locator('.nc-filter-value-select').locator('.nc-time-input');
await this.get().locator('.nc-filter-value-select').click(); await timeInput.click();
await this.rootPage.locator(`.ant-picker-dropdown:visible`).waitFor(); // eslint-disable-next-line no-case-declarations
await this.rootPage const dropdown = this.rootPage.locator('.nc-picker-time.active');
.locator(`.ant-picker-time-panel-column:nth-child(1)`) await dropdown.waitFor({ state: 'visible' });
.locator(`.ant-picker-time-panel-cell:has-text("${time[0]}")`)
.click(); await timeInput.fill(value);
await this.rootPage await this.rootPage.keyboard.press('Enter');
.locator(`.ant-picker-time-panel-column:nth-child(2)`)
.locator(`.ant-picker-time-panel-cell:has-text("${time[1]}")`) await dropdown.waitFor({ state: 'hidden' });
.click();
await this.rootPage.locator(`.ant-btn-primary:has-text("Ok")`).click();
break; break;
case UITypes.Date: case UITypes.Date:
if (subOperation === 'exact date') { if (subOperation === 'exact date') {
await this.get().locator('.nc-filter-value-select').click(); await this.get().locator('.nc-filter-value-select .nc-date-input').click();
await this.rootPage.locator(`.ant-picker-dropdown:visible`).waitFor(); const dropdown = this.rootPage.locator(`.nc-picker-date.active`);
await dropdown.waitFor({ state: 'visible' });
const dateItem = dropdown.locator('.nc-date-item').getByText(value);
await dateItem.waitFor();
await dateItem.scrollIntoViewIfNeeded();
await dateItem.hover();
if (skipWaitingResponse) { if (skipWaitingResponse) {
await this.rootPage.locator(`.ant-picker-cell-inner:has-text("${value}")`).click(); await dateItem.click();
await dropdown.waitFor({ state: 'hidden' });
await this.rootPage.waitForTimeout(350); await this.rootPage.waitForTimeout(350);
} else { } else {
await this.waitForResponse({ await this.waitForResponse({
uiAction: async () => uiAction: async () => {
await this.rootPage.locator(`.ant-picker-cell-inner:has-text("${value}")`).click(), await dateItem.click();
await dropdown.waitFor({ state: 'hidden' });
},
httpMethodsToMatch: ['GET'], httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`, requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
}); });

11
tests/playwright/tests/db/columns/columnDateTime.spec.ts

@ -45,6 +45,7 @@ const dateTimeData = [
hour: 4, hour: 4,
minute: 30, minute: 30,
output: '10-12-2022 04:30', output: '10-12-2022 04:30',
selectFromPicker: true,
}, },
{ {
dateFormat: 'DD-MM-YYYY', dateFormat: 'DD-MM-YYYY',
@ -122,16 +123,20 @@ test.describe('DateTime Column', () => {
await dashboard.grid.cell.dateTime.selectDate({ await dashboard.grid.cell.dateTime.selectDate({
date: dateTimeData[i].date, date: dateTimeData[i].date,
index: 0,
columnHeader: 'NC_DATETIME_0',
}); });
await dashboard.grid.cell.dateTime.selectTime({ await dashboard.grid.cell.dateTime.selectTime({
index: 0,
columnHeader: 'NC_DATETIME_0',
hour: dateTimeData[i].hour, hour: dateTimeData[i].hour,
minute: dateTimeData[i].minute, minute: dateTimeData[i].minute,
second: dateTimeData[i].second, second: dateTimeData[i].second,
fillValue: dateTimeData[i].output.split(' ')[1].trim(),
selectFromPicker: !!dateTimeData[i].selectFromPicker,
}); });
await dashboard.grid.cell.dateTime.save();
await dashboard.grid.cell.verifyDateCell({ await dashboard.grid.cell.verifyDateCell({
index: 0, index: 0,
columnHeader: 'NC_DATETIME_0', columnHeader: 'NC_DATETIME_0',
@ -182,6 +187,8 @@ test.describe('Date Column', () => {
await dashboard.grid.cell.dateTime.selectDate({ await dashboard.grid.cell.dateTime.selectDate({
date: dateData[i].date, date: dateData[i].date,
skipDate: dateData[i].dateFormat === 'YYYY-MM', skipDate: dateData[i].dateFormat === 'YYYY-MM',
index: 0,
columnHeader: 'NC_DATE_0',
}); });
await dashboard.grid.cell.verifyDateCell({ await dashboard.grid.cell.verifyDateCell({

2
tests/playwright/tests/db/views/viewFormShareSurvey.spec.ts

@ -38,6 +38,8 @@ test.describe('Share form', () => {
await dashboard.form.showAnotherFormRadioButton.click(); await dashboard.form.showAnotherFormRadioButton.click();
await dashboard.form.showAnotherFormAfter5SecRadioButton.click(); await dashboard.form.showAnotherFormAfter5SecRadioButton.click();
await dashboard.rootPage.waitForTimeout(200);
const surveyLink = await dashboard.form.topbar.getSharedViewUrl(true); const surveyLink = await dashboard.form.topbar.getSharedViewUrl(true);
await dashboard.rootPage.waitForTimeout(2000); await dashboard.rootPage.waitForTimeout(2000);
await dashboard.rootPage.goto(surveyLink); await dashboard.rootPage.goto(surveyLink);

Loading…
Cancel
Save