Browse Source

Merge pull request #9831 from nocodb/nc-calendar-end-date

Nc calendar end date
renovate/major-major
Anbarasu 1 day ago committed by GitHub
parent
commit
9f99d0ba20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 248
      packages/nc-gui/components/dlg/ViewCreate.vue
  2. 3
      packages/nc-gui/components/smartsheet/PlainCell.vue
  3. 544
      packages/nc-gui/components/smartsheet/calendar/DateTimeSpanningContainer.vue
  4. 77
      packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue
  5. 462
      packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue
  6. 535
      packages/nc-gui/components/smartsheet/calendar/MonthView.vue
  7. 70
      packages/nc-gui/components/smartsheet/calendar/RecordCard.vue
  8. 4
      packages/nc-gui/components/smartsheet/calendar/SideMenu.vue
  9. 32
      packages/nc-gui/components/smartsheet/calendar/SideRecordCard.vue
  10. 121
      packages/nc-gui/components/smartsheet/calendar/VRecordCard.vue
  11. 273
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue
  12. 520
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue
  13. 92
      packages/nc-gui/components/smartsheet/toolbar/Calendar/Mode.vue
  14. 125
      packages/nc-gui/components/smartsheet/toolbar/Calendar/Range.vue
  15. 7
      packages/nc-gui/composables/useBetaFeatureToggle.ts
  16. 3
      packages/nc-gui/lang/en.json
  17. 1
      packages/nc-gui/lib/types.ts
  18. 2
      tests/playwright/pages/Dashboard/Calendar/CalendarMonth.ts
  19. 2
      tests/playwright/tests/db/views/viewCalendar.spec.ts

248
packages/nc-gui/components/dlg/ViewCreate.vue

@ -323,6 +323,8 @@ const addCalendarRange = async () => {
}
*/
const isRangeEnabled = computed(() => isFeatureEnabled(FEATURE_FLAG.CALENDAR_VIEW_RANGE))
const enableDescription = ref(false)
const removeDescription = () => {
@ -977,111 +979,153 @@ const getPluralName = (name: string) => {
/>
</a-form-item>
<template v-if="form.type === ViewTypes.CALENDAR && !form.copy_from_id">
<div v-for="(range, index) in form.calendar_range" :key="`range-${index}`" class="flex w-full items-center gap-2">
<span class="text-gray-800">
{{ $t('labels.organiseBy') }}
</span>
<NcSelect
v-model:value="range.fk_from_column_id"
:disabled="isMetaLoading"
:loading="isMetaLoading"
class="nc-select-shadow nc-from-select"
>
<a-select-option
v-for="(option, id) in [...viewSelectFieldOptions!].filter((f) => {
<div
v-for="(range, index) in form.calendar_range"
:key="`range-${index}`"
:class="{
'!gap-2': range.fk_to_column_id === null,
}"
class="flex flex-col w-full gap-6"
>
<div class="w-full space-y-2">
<div class="text-gray-800">
{{ $t('labels.organiseBy') }}
</div>
<a-select
v-model:value="range.fk_from_column_id"
class="nc-select-shadow w-full nc-from-select !rounded-lg"
dropdown-class-name="!rounded-lg"
:placeholder="$t('placeholder.notSelected')"
data-testid="nc-calendar-range-from-field-select"
@click.stop
>
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
<a-select-option
v-for="(option, id) in [...viewSelectFieldOptions!].filter((f) => {
// If the fk_from_column_id of first range is Date, then all the other ranges should be Date
// If the fk_from_column_id of first range is DateTime, then all the other ranges should be DateTime
if (index === 0) return true
const firstRange = viewSelectFieldOptions!.find((f) => f.value === form.calendar_range[0].fk_from_column_id)
return firstRange?.uidt === f.uidt
})"
:key="id"
class="w-40"
:value="option.value"
>
<div class="flex w-full gap-2 justify-between items-center">
<div class="flex gap-2 items-center">
<SmartsheetHeaderIcon :column="option" class="!ml-0" />
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template>
{{ option.label }}
</NcTooltip>
</div>
<div class="flex-1" />
<component
:is="iconMap.check"
v-if="option.value === range.fk_from_column_id"
id="nc-selected-item-icon"
class="text-primary min-w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
<!-- <div
v-if="range.fk_to_column_id === null && isEeUI"
class="cursor-pointer flex items-center text-gray-800 gap-1"
@click="range.fk_to_column_id = undefined"
>
<component :is="iconMap.plus" class="h-4 w-4" />
{{ $t('activity.addEndDate') }}
</div>
<template v-else-if="isEeUI">
<span>
{{ $t('activity.withEndDate') }}
</span>
<div class="flex">
<NcSelect
v-model:value="range.fk_to_column_id"
:disabled="isMetaLoading"
:loading="isMetaLoading"
:placeholder="$t('placenc-to-seleholder.notSelected')"
class="!rounded-r-none ct"
>
<a-select-option
v-for="(option, id) in [...viewSelectFieldOptions].filter((f) => {
// If the fk_from_column_id of first range is Date, then all the other ranges should be Date
// If the fk_from_column_id of first range is DateTime, then all the other ranges should be DateTime
const firstRange = viewSelectFieldOptions.find((f) => f.value === form.calendar_range[0].fk_from_column_id)
return firstRange?.uidt === f.uidt
})"
:key="id"
:value="option.value"
>
<div class="flex items-center">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template>
{{ option.label }}
</NcTooltip>
<div class="w-full flex gap-2 items-center justify-between" :title="option.label">
<div class="flex items-center gap-1 max-w-[calc(100%_-_20px)]">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only>
<template #title>
{{ option.label }}
</template>
<template #default>{{ option.label }}</template>
</NcTooltip>
</div>
<GeneralIcon
v-if="option.value === range.fk_from_column_id"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
<NcButton class="!rounded-l-none !border-l-0" size="small" type="secondary" @click="range.fk_to_column_id = null">
<component :is="iconMap.delete" class="h-4 w-4" />
</a-select>
</div>
<div class="w-full space-y-2">
<NcButton
v-if="range.fk_to_column_id === null && isRangeEnabled"
size="small"
class="w-28"
type="text"
:disabled="!isEeUI"
@click="range.fk_to_column_id = undefined"
>
<component :is="iconMap.plus" class="h-4 w-4" />
{{ $t('activity.endDate') }}
</NcButton>
<template v-else-if="isEeUI && isRangeEnabled">
<span class="text-gray-700">
{{ $t('activity.withEndDate') }}
</span>
<div class="flex">
<a-select
v-model:value="range.fk_to_column_id"
class="!rounded-r-none nc-select-shadow w-full flex-1 nc-to-select"
:disabled="isMetaLoading"
:loading="isMetaLoading"
:placeholder="$t('placeholder.notSelected')"
data-testid="nc-calendar-range-to-field-select"
dropdown-class-name="!rounded-lg"
@click.stop
>
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
<a-select-option
v-for="(option, id) in [...viewSelectFieldOptions].filter((f) => {
// If the fk_from_column_id of first range is Date, then all the other ranges should be Date
// If the fk_from_column_id of first range is DateTime, then all the other ranges should be DateTime
const firstRange = viewSelectFieldOptions.find(
(f) => f.value === form.calendar_range[0].fk_from_column_id,
)
return firstRange?.uidt === f.uidt && f.value !== range.fk_from_column_id
})"
:key="id"
:value="option.value"
>
<div class="w-full flex gap-2 items-center justify-between" :title="option.label">
<div class="flex items-center gap-1 max-w-[calc(100%_-_20px)]">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only>
<template #title>
{{ option.label }}
</template>
<template #default>{{ option.label }}</template>
</NcTooltip>
</div>
<GeneralIcon
v-if="option.value === range.fk_from_column_id"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</div>
</a-select-option>
</a-select>
<NcButton
class="!rounded-l-none !border-l-0"
size="small"
type="secondary"
@click="range.fk_to_column_id = null"
>
<component :is="iconMap.delete" class="h-4 w-4" />
</NcButton>
</div>
<NcButton
v-if="index !== 0"
size="small"
type="secondary"
@click="
() => {
form.calendar_range = form.calendar_range.filter((_, i) => i !== index)
}
"
>
<component :is="iconMap.close" />
</NcButton>
</template>
</div>
<NcButton
v-if="index !== 0"
size="small"
type="secondary"
@click="
() => {
form.calendar_range = form.calendar_range.filter((_, i) => i !== index)
}
"
>
<component :is="iconMap.close" />
</NcButton>
</template>
</div> -->
</div>
<!-- <NcButton class="mt-2" size="small" type="secondary" @click="addCalendarRange">
<!-- <NcButton class="mt-2" size="small" type="secondary" @click="addCalendarRange">
<component :is="iconMap.plus" />
Add another date field
</NcButton> -->
</div>
<div
v-if="isCalendarReadonly(form.calendar_range)"
@ -1429,7 +1473,6 @@ const getPluralName = (name: string) => {
.nc-input-text-area {
padding-block: 8px !important;
}
.ant-form-item-required {
@apply !text-gray-800 font-medium;
&:before {
@ -1437,14 +1480,6 @@ const getPluralName = (name: string) => {
}
}
.nc-from-select .ant-select-selector {
@apply !mr-2;
}
.nc-to-select .ant-select-selector {
@apply !rounded-r-none;
}
.ant-form-item {
@apply !mb-0;
}
@ -1465,11 +1500,6 @@ const getPluralName = (name: string) => {
@apply content-[''] m-0;
}
}
:deep(.ant-select) {
.ant-select-selector {
@apply !rounded-lg;
}
}
.nc-nocoai-footer {
@apply px-6 py-1 flex items-center gap-2 text-nc-content-purple-dark border-t-1 border-purple-100;
@ -1504,4 +1534,18 @@ const getPluralName = (name: string) => {
@apply !rounded-5;
}
}
:deep(.ant-select) {
.ant-select-selector {
@apply !rounded-lg;
}
}
.nc-to-select {
:deep(.ant-select) {
.ant-select-selector {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
}
}
</style>

3
packages/nc-gui/components/smartsheet/PlainCell.vue

@ -361,7 +361,7 @@ const parseValue = (value: any, col: ColumnType): string => {
class="plain-cell before:px-1"
:class="{
'!font-bold': bold,
'italic': italic,
'!italic': italic,
'underline': underline,
}"
data-testid="nc-plain-cell"
@ -372,6 +372,7 @@ const parseValue = (value: any, col: ColumnType): string => {
<style lang="scss" scoped>
.plain-cell {
font-synthesis: initial !important;
&::before {
content: '•';
padding: 0 4px;

544
packages/nc-gui/components/smartsheet/calendar/DateTimeSpanningContainer.vue

@ -0,0 +1,544 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type { Row } from '#imports'
const props = defineProps<{
records: Row[]
}>()
const emit = defineEmits(['expandRecord', 'newRecord'])
const container = ref<null | HTMLElement>(null)
const { width: containerWidth } = useElementSize(container)
const meta = inject(MetaInj, ref())
const { isUIAllowed } = useRoles()
const records = toRef(props, 'records')
const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const fieldStyles = computed(() => {
return (_fields.value ?? []).reduce((acc, field) => {
acc[field.fk_column_id!] = {
bold: !!field.bold,
italic: !!field.italic,
underline: !!field.underline,
}
return acc
}, {} as Record<string, { bold?: boolean; italic?: boolean; underline?: boolean }>)
})
const {
selectedDate,
formattedData,
formattedSideBarData,
calendarRange,
updateRowProperty,
selectedDateRange,
viewMetaProperties,
displayField,
activeCalendarView,
updateFormat,
} = useCalendarViewStoreOrThrow()
const maxVisibleDays = computed(() => {
return activeCalendarView.value === 'week' ? (viewMetaProperties.value?.hide_weekend ? 5 : 7) : 1
})
// This function is used to find the first suitable row for a record
// It takes the recordsInDay object, the start day index and the span of the record in days
// It returns the first suitable row for the entire span of the record
const findFirstSuitableRow = (recordsInDay: any, startDayIndex: number, spanDays: number) => {
let row = 0
while (true) {
let isRowSuitable = true
// Check if the row is suitable for the entire span
for (let i = 0; i < spanDays; i++) {
const dayIndex = startDayIndex + i
if (!recordsInDay[dayIndex]) {
recordsInDay[dayIndex] = {}
}
// If the row is occupied, the entire span is not suitable
if (recordsInDay[dayIndex][row]) {
isRowSuitable = false
break
}
}
// If the row is suitable, return it
if (isRowSuitable) {
return row
}
row++
}
}
const viewStartDate = computed(() => {
if (activeCalendarView.value === 'week') {
return selectedDateRange.value.start
} else {
return selectedDate.value
}
})
const isInRange = (date: dayjs.Dayjs) => {
if (activeCalendarView.value === 'day') {
return date.isSame(selectedDate.value, 'day')
} else {
const rangeEndDate =
maxVisibleDays.value === 5 ? dayjs(selectedDateRange.value.end).subtract(2, 'day') : dayjs(selectedDateRange.value.end)
return (
date && date.isBetween(dayjs(selectedDateRange.value.start).startOf('day'), dayjs(rangeEndDate).endOf('day'), 'day', '[]')
)
}
}
const calendarData = computed(() => {
if (!records.value?.length || !calendarRange.value) return []
const recordsInDay = Array.from({ length: 7 }, () => ({})) as Record<number, Record<number, boolean>>
const perDayWidth = containerWidth.value / maxVisibleDays.value
const recordsInRange = [] as Row[]
calendarRange.value.forEach(({ fk_from_col, fk_to_col }) => {
if (!fk_from_col || !fk_to_col) return
for (const record of records.value) {
const id = record.rowMeta.id ?? generateRandomNumber()
const startDate = dayjs(record.row[fk_from_col.title!])
const endDate = dayjs(record.row[fk_to_col.title!])
const startDayIndex = Math.max(startDate.diff(viewStartDate.value, 'day'), 0)
const endDayIndex = Math.min(endDate.diff(viewStartDate.value, 'day'), maxVisibleDays.value - 1)
const spanDays = endDayIndex - startDayIndex + 1
const row = findFirstSuitableRow(recordsInDay, startDayIndex, spanDays)
for (let i = 0; i < spanDays; i++) {
const dayIndex = startDayIndex + i
recordsInDay[dayIndex][row] = true
}
const isStartInRange = isInRange(startDate)
const isEndInRange = isInRange(endDate)
let position = 'none'
if (isStartInRange && isEndInRange) position = 'rounded'
else if (isStartInRange) position = 'leftRounded'
else if (isEndInRange) position = 'rightRounded'
const style: Partial<CSSStyleDeclaration> = {
top: `${row * 28 + row * 8 + 8}px`,
left: `${startDayIndex * perDayWidth}px`,
width: `${spanDays * perDayWidth}px`,
}
recordsInRange.push({
...record,
rowMeta: {
...record.rowMeta,
style,
position,
range: { fk_from_col, fk_to_col },
id,
},
})
}
})
return recordsInRange
})
const dragElement = ref<HTMLElement | null>(null)
const resizeInProgress = ref(false)
const dragTimeout = ref<ReturnType<typeof setTimeout>>()
const isDragging = ref(false)
const dragRecord = ref<Row>()
const resizeDirection = ref<'right' | 'left' | null>()
const resizeRecord = ref<Row | null>(null)
const hoverRecord = ref<string | null>()
const isExpanded = ref(false)
// This method is used to calculate the new start and end date of a record when dragging and dropping
const calculateNewRow = (event: MouseEvent, updateSideBarData?: boolean) => {
if (!container.value || !dragRecord.value) return { updatedProperty: [], newRow: null }
const { width, left } = container.value.getBoundingClientRect()
// Calculate the percentage of the width based on the mouse position
// This is used to calculate the day index
const relativeX = event.clientX - left
// TODO: @DarkPhoenix2704 handle offset
// if (dragOffset.value.x) {
// relativeX -= dragOffset.value.x
// }
const percentX = Math.max(0, Math.min(1, relativeX / width))
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col
const toCol = dragRecord.value.rowMeta.range?.fk_to_col
if (!fromCol) return { updatedProperty: [], newRow: null }
// Calculate the day index based on the percentage of the width
// The day index is a number between 0 and 6
const day = Math.floor(percentX * maxVisibleDays.value)
// Calculate the new start date based on the day index by adding the day index to the start date of the selected date range
const newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day')
if (!newStartDate) return { updatedProperty: [], newRow: null }
let endDate
const newRow = {
...dragRecord.value,
row: {
...dragRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format(updateFormat.value),
},
}
const updateProperty = [fromCol.title!]
if (toCol) {
const fromDate = dragRecord.value.row[fromCol.title!] ? dayjs(dragRecord.value.row[fromCol.title!]) : null
const toDate = dragRecord.value.row[toCol.title!] ? dayjs(dragRecord.value.row[toCol.title!]) : null
// Calculate the new end date based on the day index by adding the day index to the start date of the selected date range
// If the record has an end date, we need to calculate the new end date based on the difference between the start and end date
// If the record doesn't have an end date, we need to calculate the new end date based on the start date
// If the record has an end date and no start Date, we set the end date to the start date
if (fromDate && toDate) {
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day')
} else if (fromDate && !toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else if (!fromDate && toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else {
endDate = newStartDate.clone()
}
newRow.row[toCol.title!] = dayjs(endDate).format(updateFormat.value)
updateProperty.push(toCol.title!)
}
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
if (updateSideBarData) {
// If the record is being dragged from the sidebar, we need to remove the record from the sidebar data
// and add the new record to the calendar data
formattedData.value = [...formattedData.value, newRow]
formattedSideBarData.value = formattedSideBarData.value.filter((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk !== newPk
})
} else {
// If the record is being dragged within the calendar, we need to update the record in the calendar data
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
}
return { updateProperty, newRow }
}
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => {
updateRowProperty(row, updateProperty, isDelete)
}, 500)
// This function is used to calculate the new start and end date of a record when resizing
const onResize = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !container.value || !resizeRecord.value) return
const { width, left } = container.value.getBoundingClientRect()
// Calculate the percentage of the width based on the mouse position
const percentX = (event.clientX - left - window.scrollX) / width
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col
if (!fromCol || !toCol) return
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!])
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!])
const day = Math.floor(percentX * maxVisibleDays.value)
let updateProperty: string[] = []
let updateRecord: Row
if (resizeDirection.value === 'right') {
// Calculate the new end date based on the day index by adding the day index to the start date of the selected date range
let newEndDate = dayjs(selectedDateRange.value.start).add(day, 'day')
let newStartDate = ogStartDate.clone()
updateProperty = [toCol.title!]
// If the new end date is before the start date, we need to adjust the end date to the start date
if (dayjs(newEndDate).isSameOrBefore(ogStartDate, 'day')) {
newEndDate = ogStartDate.clone().add(1, 'hour')
newStartDate = ogStartDate.clone().subtract(1, 'hour')
updateProperty.push(fromCol.title!)
}
if (!newEndDate.isValid()) return
updateRecord = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[toCol.title!]: newEndDate.format(updateFormat.value),
[fromCol.title!]: newStartDate.format(updateFormat.value),
},
}
} else if (resizeDirection.value === 'left') {
// Calculate the new start date based on the day index by adding the day index to the start date of the selected date range
let newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day')
let newEndDate = ogEndDate.clone()
updateProperty = [fromCol.title!]
// If the new start date is after the end date, we need to adjust the start date to the end date
if (dayjs(newStartDate).isSameOrAfter(ogEndDate)) {
newStartDate = dayjs(dayjs(ogEndDate)).clone()
newEndDate = dayjs(newStartDate).clone().add(1, 'hour')
updateProperty.push(toCol.title!)
}
if (!newStartDate) return
updateRecord = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format(updateFormat.value),
[toCol.title!]: dayjs(newEndDate).format(updateFormat.value),
},
}
}
// Update the record in the store
const newPk = extractPkFromRow(updateRecord.row, meta.value!.columns!)
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? updateRecord : r
})
useDebouncedRowUpdate(updateRecord, updateProperty, false)
}
const onResizeEnd = () => {
resizeInProgress.value = false
resizeDirection.value = null
resizeRecord.value = null
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', onResizeEnd)
}
const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
resizeInProgress.value = true
resizeDirection.value = direction
resizeRecord.value = record
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', onResizeEnd)
}
const onDrag = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit')) return
if (!container.value || !dragRecord.value) return
calculateNewRow(event, false)
}
const stopDrag = (event: MouseEvent) => {
event.preventDefault()
clearTimeout(dragTimeout.value!)
if (!isUIAllowed('dataEdit')) return
if (!isDragging.value || !container.value || !dragRecord.value) return
const { updateProperty, newRow } = calculateNewRow(event)
if (!newRow) return
// Open drop the record, we reset the opacity of the other records
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
el.style.visibility = ''
el.style.opacity = '100%'
})
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
dragElement.value = null
}
dragRecord.value = undefined
updateRowProperty(newRow, updateProperty, false)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
const dragStart = (event: MouseEvent, record: Row) => {
if (resizeInProgress.value) return
let target = event.target as HTMLElement
if (activeCalendarView.value === 'day') {
emit('expandRecord', record)
return
}
isDragging.value = false
dragTimeout.value = setTimeout(() => {
if (!isUIAllowed('dataEdit')) return
isDragging.value = true
while (!target.classList.contains('draggable-record')) {
target = target.parentElement as HTMLElement
}
// TODO: Implement dragOffset @DarkPhoenix2704
/* dragOffset.value = {
x: event.clientX - target.getBoundingClientRect().left,
y: event.clientY - target.getBoundingClientRect().top,
} */
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
el.style.opacity = '30%'
}
})
isDragging.value = true
dragElement.value = target
dragRecord.value = record
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}, 200)
const onMouseUp = () => {
clearTimeout(dragTimeout.value!)
document.removeEventListener('mouseup', onMouseUp)
if (!isDragging.value) {
emit('expandRecord', record)
}
}
document.addEventListener('mouseup', onMouseUp)
}
const isSpanningRecordExpanded = () => isExpanded.value
defineExpose({
isSpanningRecordExpanded,
})
</script>
<template>
<div style="z-index: 100" class="sticky flex top-0 bg-white border-b-1 border-gray-200 shadow-sm prevent-select">
<div
:style="{
maxWidth: `${activeCalendarView === 'week' ? '64px' : '66px'}`,
minWidth: `${activeCalendarView === 'week' ? '64px' : '66px'}`,
}"
:class="{
'p-2': activeCalendarView === 'day',
'py-2 pr-1': activeCalendarView === 'week',
}"
class="text-xs top-0 text-right z-50 !sticky h-full left-0 text-[#6A7184]"
>
All day
<NcButton size="xsmall" class="mt-2" type="text" @click="isExpanded = !isExpanded">
<GeneralIcon v-if="!isExpanded" class="w-4 h-4 text-gray-800" icon="maximize" />
<GeneralIcon v-else-if="isExpanded" class="w-4 h-4 text-gray-800" icon="minimize" />
</NcButton>
</div>
<div
ref="container"
:style="{
width: `calc(100% - ${activeCalendarView === 'week' ? '64' : '66'}px)`,
}"
:class="{
'border-gray-100': activeCalendarView === 'day',
'border-gray-200': activeCalendarView === 'week',
'min-h-32 max-h-32 ': isExpanded,
'h-20': !isExpanded,
}"
class="relative border-l-1 transition-all overflow-y-scroll z-30"
>
<div class="pointer-events-none h-full inset-y-0 relative">
<div
v-if="maxVisibleDays === 7"
class="absolute !right-0 h-full bg-gray-100 inset-y-0"
:style="{
width: `${(containerWidth / 7) * 2}px`,
}"
></div>
<template v-for="(record, id) in calendarData" :key="id">
<div
v-if="record.rowMeta.style?.display !== 'none'"
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id"
:style="{
...record.rowMeta.style,
}"
class="absolute group draggable-record pointer-events-auto nc-calendar-week-record-card"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id"
@mousedown.stop="dragStart($event, record)"
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard
:hover="hoverRecord === record.rowMeta.id"
:dragging="resizeRecord?.rowMeta.id === record.rowMeta.id || dragRecord?.rowMeta.id === record.rowMeta.id"
:position="record.rowMeta.position"
:record="record"
:resize="activeCalendarView === 'week'"
@dblclick.stop="emit('expandRecord', record)"
@resize-start="onResizeStart"
>
<template v-for="(field, index) in fields" :key="index">
<LazySmartsheetPlainCell
v-if="!isRowEmpty(record, field!)"
v-model="record.row[field!.title!]"
class="text-xs"
:column="field"
:bold="!!fieldStyles[field.id]?.bold"
:italic="!!fieldStyles[field.id]?.italic"
:underline="!!fieldStyles[field.id]?.underline"
/>
</template>
</LazySmartsheetCalendarRecordCard>
</LazySmartsheetRow>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.prevent-select {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>

77
packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue

@ -1,6 +1,7 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import type { ColumnType } from 'nocodb-sdk'
import { UseVirtualList } from '@vueuse/components'
const emit = defineEmits(['expandRecord', 'newRecord'])
@ -53,11 +54,6 @@ const recordsAcrossAllRange = computed<Row[]>(() => {
dayRecordCount++
const style: Partial<CSSStyleDeclaration> = {
top: `${(dayRecordCount - 1) * perRecordHeight}px`,
width: '100%',
}
// This property is used to determine which side the record should be rounded. It can be left, right, both or none
let position = 'none'
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(selectedDate.value, 'day')
@ -81,7 +77,6 @@ const recordsAcrossAllRange = computed<Row[]>(() => {
rowMeta: {
...record.rowMeta,
position,
style,
range: range as any,
},
})
@ -94,11 +89,6 @@ const recordsAcrossAllRange = computed<Row[]>(() => {
rowMeta: {
...record.rowMeta,
range: range as any,
style: {
width: '100%',
left: '0',
top: `${(dayRecordCount - 1) * perRecordHeight}px`,
},
position: 'rounded',
},
})
@ -203,43 +193,44 @@ const newRecord = () => {
<div
v-if="recordsAcrossAllRange.length"
ref="container"
class="w-full cursor-pointer relative h-[calc(100vh-10.8rem)] overflow-y-auto nc-scrollbar-md"
class="w-full cursor-pointer relative overflow-y-auto nc-scrollbar-md"
data-testid="nc-calendar-day-view"
@dblclick="newRecord"
@drop="dropEvent"
>
<div
v-for="(record, rowIndex) in recordsAcrossAllRange"
:key="rowIndex"
:style="record.rowMeta.style"
class="absolute mt-2"
data-testid="nc-calendar-day-record-card"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id as string"
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard
:position="record.rowMeta.position"
:record="record"
:resize="false"
color="blue"
size="small"
@click.prevent="emit('expandRecord', record)"
<UseVirtualList height="calc(100vh - 5rem)" :list="recordsAcrossAllRange" :options="{ itemHeight: 36 }">
<template #default="{ data: record }">
<div
:key="record.rowMeta.id"
class="mt-2"
data-testid="nc-calendar-day-record-card"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id as string"
>
<template v-for="(field, id) in fields" :key="id">
<LazySmartsheetPlainCell
v-if="!isRowEmpty(record, field!)"
v-model="record.row[field!.title!]"
class="text-xs"
:bold="getFieldStyle(field).bold"
:column="field"
:italic="getFieldStyle(field).italic"
:underline="getFieldStyle(field).underline"
/>
</template>
</LazySmartsheetCalendarRecordCard>
</LazySmartsheetRow>
</div>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard
:record="record"
:resize="false"
:position="record.rowMeta.position"
size="small"
@click.prevent="emit('expandRecord', record)"
>
<template v-for="(field, id) in fields" :key="id">
<LazySmartsheetPlainCell
v-if="!isRowEmpty(record, field!)"
v-model="record.row[field!.title!]"
class="text-xs"
:bold="getFieldStyle(field).bold"
:column="field"
:italic="getFieldStyle(field).italic"
:underline="getFieldStyle(field).underline"
/>
</template>
</LazySmartsheetCalendarRecordCard>
</LazySmartsheetRow>
</div>
</template>
</UseVirtualList>
</div>
<div

462
packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue

@ -1,6 +1,7 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { type ColumnType, UITypes } from 'nocodb-sdk'
import type { Row } from '~/lib/types'
const emit = defineEmits(['expandRecord', 'newRecord'])
@ -72,6 +73,10 @@ const overlayTop = computed(() => {
return top
})
const shouldEnableOverlay = computed(() => {
return !isPublic.value && dayjs().isSame(selectedDate.value, 'day')
})
onMounted(() => {
const intervalId = setInterval(() => {
currTime.value = dayjs()
@ -100,6 +105,14 @@ const calculateNewDates = useMemoize(
endDate = startDate.clone().add(15, 'minutes')
}
if (endDate.diff(startDate, 'minute') < 15) {
endDate = startDate.clone().add(15, 'minutes')
}
if (endDate.diff(startDate, 'minute') === 60) {
endDate = startDate.clone().add(59, 'minutes')
}
// If the start date is before the opened date, we use the schedule start as the start date
// This is to ensure the generated style of the record is not outside the bounds of the calendar
if (startDate.isSameOrBefore(scheduleStart)) {
@ -210,8 +223,23 @@ const getMaxOverlaps = ({
return Math.max(...overlapIterations)
}
const dragRecord = ref<Row | null>(null)
const isDragging = ref(false)
const dragElement = ref<HTMLElement | null>(null)
const resizeDirection = ref<'right' | 'left' | null>()
const resizeRecord = ref<Row | null>(null)
const dragTimeout = ref<ReturnType<typeof setTimeout>>()
const hoverRecord = ref<string | null>(null)
const recordsAcrossAllRange = computed<{
record: Row[]
spanningRecords: Row[]
gridTimeMap: Map<
number,
{
@ -237,32 +265,34 @@ const recordsAcrossAllRange = computed<{
>()
const recordsByRange: Array<Row> = []
const recordSpanningDays: Array<Row> = []
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const endCol = range.fk_to_col
const { fk_from_col: fromCol, fk_to_col: endCol } = range
// We fetch all the records that match the calendar ranges in a single time.
// But not all fetched records are valid for the certain range, so we filter them out & sort them
const sortedFormattedData = [...formattedData.value]
.filter((record) => {
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
const fromDate = record.row[fromCol?.title] ? dayjs(record.row[fromCol?.title!]) : null
if (fromCol && endCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[endCol.title!] ? dayjs(record.row[endCol.title!]) : null
return fromDate && toDate?.isValid() ? fromDate.isSameOrBefore(toDate) : true
} else if (fromCol && !endCol) {
return !!fromDate
if (fromDate?.isValid() && toDate?.isValid()) {
if (!fromDate.isSame(toDate, 'day')) {
// TODO: If multiple range is introduced, we have to make sure no duplicate records are inserted
recordSpanningDays.push(record)
return false
}
return fromDate.isSameOrBefore(toDate)
}
return true
}
return false
})
.sort((a, b) => {
const aDate = dayjs(a.row[fromCol!.title!])
const bDate = dayjs(b.row[fromCol!.title!])
return aDate.isBefore(bDate) ? 1 : -1
return fromCol ? !!fromDate : false
})
.sort((a, b) => (dayjs(a.row[fromCol!.title!]).isBefore(dayjs(b.row[fromCol!.title!])) ? 1 : -1))
for (const record of sortedFormattedData) {
const id = record.rowMeta.id ?? generateRandomNumber()
@ -279,37 +309,17 @@ const recordsAcrossAllRange = computed<{
// A minimum height of 52px is set for each record
// The height of the record is calculated based on the difference between the start and end date
const heightInPixels = Math.max(endDate.diff(startDate, 'minute'), perRecordHeight)
const heightInPixels = Math.max((endDate.diff(startDate, 'minute') / 60) * 52, perRecordHeight)
const style: Partial<CSSStyleDeclaration> = {
height: `${heightInPixels - 2}px`,
top: `${topInPixels + 1}px`,
}
// This property is used to determine which side the record should be rounded. It can be top, bottom, both or none
// We use the start and end date to determine the position of the record
let position = 'none'
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(selectedDate.value, 'day')
const isBeforeSelectedDay = (date: dayjs.Dayjs) => date.isBefore(selectedDate.value, 'day')
const isAfterSelectedDay = (date: dayjs.Dayjs) => date.isAfter(selectedDate.value, 'day')
if (isSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'rounded'
} else if (isBeforeSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'none'
} else if (isSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'topRounded'
} else if (isBeforeSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'bottomRounded'
} else {
position = 'none'
}
recordsByRange.push({
...record,
rowMeta: {
...record.rowMeta,
position,
style,
id,
range: range as any,
@ -327,7 +337,6 @@ const recordsAcrossAllRange = computed<{
// The top of the record is calculated based on the start hour
// Update such that it is also based on Minutes
const topInPixels = (startDate.minute() / 60 + startDate.hour()) * perRecordHeight
// A minimum height of 80px is set for each record
@ -345,7 +354,6 @@ const recordsAcrossAllRange = computed<{
range: range as any,
style,
id,
position: 'rounded',
},
})
}
@ -442,7 +450,17 @@ const recordsAcrossAllRange = computed<{
let left = 100
let display = 'block'
if (numberOfOverlaps && numberOfOverlaps > 0) {
const isRecordDraggingOrResizeState =
record.rowMeta.id === dragRecord.value?.rowMeta.id || record.rowMeta.id === resizeRecord.value?.rowMeta.id
if (isRecordDraggingOrResizeState) {
record.rowMeta.style = {
...record.rowMeta.style,
zIndex: 10,
}
}
if (numberOfOverlaps && numberOfOverlaps > 0 && !isRecordDraggingOrResizeState) {
width = 100 / Math.min(numberOfOverlaps, 8)
if (record.rowMeta.overLapIteration! - 1 > 7) {
@ -458,31 +476,19 @@ const recordsAcrossAllRange = computed<{
record.rowMeta.style = {
...record.rowMeta.style,
display,
width: `${width.toFixed(2)}%`,
left: `${left.toFixed(2)}%`,
width: `calc(max(calc(${width.toFixed(2)}% - 4px), 180px))`,
left: `min(calc(${left.toFixed(2)}% + 4px), calc(100% - max(${width.toFixed(2)}%, 180px) + 4px))`,
minWidth: '180px',
}
}
return {
gridTimeMap,
record: recordsByRange,
spanningRecords: recordSpanningDays,
}
})
const dragRecord = ref<Row | null>(null)
const isDragging = ref(false)
const dragElement = ref<HTMLElement | null>(null)
const resizeDirection = ref<'right' | 'left' | null>()
const resizeRecord = ref<Row | null>()
const dragTimeout = ref<ReturnType<typeof setTimeout>>()
const hoverRecord = ref<string | null>(null)
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => {
updateRowProperty(row, updateProperty, isDelete)
}, 500)
@ -504,7 +510,6 @@ const calculateNewRow = (event: MouseEvent, skipChangeCheck?: boolean) => {
// We calculate the hour based on the percentage of the mouse position in the scroll container
// It can be between 0 and 23 (inclusive)
const hour = Math.max(Math.floor(percentY * 23), 0)
const minutes = Math.min(Math.max(Math.round(Math.floor((percentY * 23 - hour) * 60) / 15) * 15, 0), 60)
// We calculate the new startDate by adding the hour to the start of the selected date
const newStartDate = dayjs(selectedDate.value).startOf('day').add(hour, 'hour').add(minutes, 'minute')
@ -601,15 +606,14 @@ const onResize = (event: MouseEvent) => {
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!])
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!])
const hour = Math.floor(percentY * 24) // Round down to the nearest hour
const minutes = Math.round((percentY * 24 * 60) % 60)
const minutes = Math.round((percentY * 24 * 60) / 15) * 15 // Round to nearest 15 minutes
let newRow: Row | null = null
let updateProperty: string[] = []
if (resizeDirection.value === 'right') {
// If the user is resizing the record to the right, we calculate the new end date based on the mouse position
let newEndDate = dayjs(selectedDate.value).add(hour, 'hour').add(minutes, 'minute')
let newEndDate = dayjs(selectedDate.value).startOf('day').add(minutes, 'minute')
updateProperty = [toCol.title!]
@ -629,7 +633,7 @@ const onResize = (event: MouseEvent) => {
},
}
} else if (resizeDirection.value === 'left') {
let newStartDate = dayjs(selectedDate.value).add(hour, 'hour').add(minutes, 'minute')
let newStartDate = dayjs(selectedDate.value).startOf('day').add(minutes, 'minute')
updateProperty = [fromCol.title!]
@ -695,19 +699,13 @@ const stopDrag = (event: MouseEvent) => {
const { newRow, updateProperty } = calculateNewRow(event, true)
if (!newRow && !updateProperty) return
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
el.style.visibility = ''
el.style.opacity = '100%'
})
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
dragElement.value = null
}
if (dragRecord.value) {
dragRecord.value = undefined
dragRecord.value = null
}
if (!newRow) return
@ -734,17 +732,7 @@ const dragStart = (event: MouseEvent, record: Row) => {
target = target.parentElement as HTMLElement
}
// When the user starts dragging a record, we reduce opacity of all other records
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
// el.style.visibility = 'hidden'
el.style.opacity = '30%'
}
})
dragRecord.value = record
dragElement.value = target
document.addEventListener('mousemove', onDrag)
@ -902,82 +890,90 @@ watch(
},
{ immediate: true },
)
const expandRecord = (record: Row) => {
emit('expandRecord', record)
}
</script>
<template>
<div
ref="container"
class="w-full flex relative no-selection h-[calc(100vh-5.3rem)] overflow-y-auto nc-scrollbar-md"
data-testid="nc-calendar-day-view"
@drop="dropEvent"
>
<div
v-if="!isPublic && dayjs().isSame(selectedDate, 'day')"
class="absolute ml-2 pointer-events-none w-full z-4"
:style="{
top: `${overlayTop}px`,
}"
>
<div class="flex w-full items-center">
<span
class="text-brand-500 text-xs rounded-md border-1 pointer-events-auto px-0.5 border-brand-200 cursor-pointer bg-brand-50"
@click="newRecord(currTime)"
>
{{ currTime.format('hh:mm A') }}
</span>
<div class="flex-1 border-b-1 border-brand-500"></div>
</div>
</div>
<div>
<div class="h-[calc(100vh-5.3rem)] overflow-y-auto nc-scrollbar-md">
<SmartsheetCalendarDateTimeSpanningContainer
v-if="
calendarRange.some((range) => range.fk_to_col !== null && range.fk_to_col !== undefined) &&
recordsAcrossAllRange.spanningRecords?.length
"
:records="recordsAcrossAllRange.spanningRecords"
@expand-record="expandRecord"
/>
<div ref="container" class="w-full flex relative no-selection" data-testid="nc-calendar-day-view" @drop="dropEvent">
<div
v-for="(hour, index) in hours"
:key="index"
class="flex h-13 relative border-1 group hover:bg-gray-50 border-white"
data-testid="nc-calendar-day-hour"
@click="selectHour(hour)"
@dblclick="newRecord(hour)"
v-if="shouldEnableOverlay"
class="absolute ml-2 pointer-events-none w-full z-4"
:style="{
top: `${overlayTop}px`,
}"
>
<div class="w-16 border-b-0 pr-2 pl-2 text-right text-xs text-gray-400 font-semibold h-13">
{{ dayjs(hour).format('hh a') }}
<div class="flex w-full items-center">
<span
class="text-brand-500 text-xs rounded-md border-1 pointer-events-auto px-0.5 border-brand-200 cursor-pointer bg-brand-50"
@click="newRecord(currTime)"
>
{{ currTime.format('hh:mm A') }}
</span>
<div class="flex-1 border-b-1 border-brand-500"></div>
</div>
</div>
</div>
<div class="w-full">
<div
v-for="(hour, index) in hours"
:key="index"
:class="{
'!border-brand-500': hour.isSame(selectedTime),
}"
class="flex w-full border-l-gray-100 h-13 transition nc-calendar-day-hour relative border-1 group hover:bg-gray-50 border-white border-b-gray-100"
data-testid="nc-calendar-day-hour"
@click="selectHour(hour)"
@dblclick="newRecord(hour)"
>
<NcDropdown
v-if="calendarRange.length > 1 && !isPublic"
<div>
<div
v-for="(hour, index) in hours"
:key="index"
class="flex h-13 relative border-1 group hover:bg-gray-50 border-white"
data-testid="nc-calendar-day-hour"
@click="selectHour(hour)"
@dblclick="newRecord(hour)"
>
<div class="w-16 border-b-0 pr-2 pl-2 text-right text-xs text-gray-400 font-semibold h-13">
{{ dayjs(hour).format('hh a') }}
</div>
</div>
</div>
<div class="w-full">
<div
v-for="(hour, index) in hours"
:key="index"
:class="{
'!block': hour.isSame(selectedTime),
'!hidden': !hour.isSame(selectedTime),
'selected-hour': hour.isSame(selectedTime),
}"
auto-close
class="flex w-full border-l-gray-100 h-13 transition nc-calendar-day-hour relative border-1 group hover:bg-gray-50 border-white border-b-gray-100"
data-testid="nc-calendar-day-hour"
@click="selectHour(hour)"
@dblclick="newRecord(hour)"
>
<NcButton
class="!group-hover:block mr-12 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute"
size="xsmall"
type="secondary"
<NcDropdown
v-if="calendarRange.length > 1 && !isPublic"
:class="{
'!block': hour.isSame(selectedTime),
'!hidden': !hour.isSame(selectedTime),
}"
auto-close
>
<component :is="iconMap.plus" class="h-4 w-4" />
</NcButton>
<template #overlay>
<NcMenu class="w-64">
<NcMenuItem> Select date field to add </NcMenuItem>
<template v-for="(range, calIndex) in calendarRange" :key="calIndex">
<NcMenuItem
v-if="!range.is_readonly"
class="text-gray-800 font-semibold text-sm"
@click="
<NcButton
class="!group-hover:block mr-12 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute"
size="xsmall"
type="secondary"
>
<component :is="iconMap.plus" class="h-4 w-4" />
</NcButton>
<template #overlay>
<NcMenu class="w-64">
<NcMenuItem> Select date field to add </NcMenuItem>
<template v-for="(range, calIndex) in calendarRange" :key="calIndex">
<NcMenuItem
v-if="!range.is_readonly"
class="text-gray-800 font-semibold text-sm"
@click="
() => {
let record = {
row: {
@ -995,26 +991,26 @@ watch(
emit('newRecord', record)
}
"
>
<div class="flex items-center gap-1">
<LazySmartsheetHeaderCellIcon :column-meta="range.fk_from_col" />
<span class="ml-1">{{ range.fk_from_col!.title! }}</span>
</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-else-if="!isPublic && isUIAllowed('dataEdit') && [UITypes.DateTime, UITypes.Date].includes(calDataType)"
:class="{
'!block': hour.isSame(selectedTime),
'!hidden': !hour.isSame(selectedTime),
}"
class="!group-hover:block mr-12 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute"
size="xsmall"
type="secondary"
@click="
>
<div class="flex items-center gap-1">
<LazySmartsheetHeaderCellIcon :column-meta="range.fk_from_col" />
<span class="ml-1">{{ range.fk_from_col!.title! }}</span>
</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-else-if="!isPublic && isUIAllowed('dataEdit') && [UITypes.DateTime, UITypes.Date].includes(calDataType)"
:class="{
'!block': hour.isSame(selectedTime),
'!hidden': !hour.isSame(selectedTime),
}"
class="!group-hover:block mr-12 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute"
size="xsmall"
type="secondary"
@click="
() => {
let record = {
row: {
@ -1033,70 +1029,80 @@ watch(
emit('newRecord', record)
}
"
>
<component :is="iconMap.plus" class="h-4 w-4" />
</NcButton>
<NcButton
v-if="isOverflowAcrossHourRange(hour).isOverflow"
v-e="`['c:calendar:week-view-more']`"
class="!absolute bottom-2 text-center w-15 mx-auto inset-x-0 z-3 text-gray-500"
size="xxsmall"
type="secondary"
@click="viewMore(hour)"
>
<span class="text-xs">
+
{{ isOverflowAcrossHourRange(hour).overflowCount }}
more
</span>
</NcButton>
</div>
</div>
<div class="absolute inset-0 pointer-events-none">
<div class="relative !ml-[68px] !mr-1 nc-calendar-day-record-container" data-testid="nc-calendar-day-record-container">
<template v-for="(record, rowIndex) in recordsAcrossAllRange.record" :key="rowIndex">
<div
v-if="record.rowMeta.style?.display !== 'none'"
:data-testid="`nc-calendar-day-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id"
:style="record.rowMeta.style"
class="absolute draggable-record transition group cursor-pointer pointer-events-auto"
@mousedown="dragStart($event, record)"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id as string"
@dragover.prevent
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarVRecordCard
:hover="hoverRecord === record.rowMeta.id || record.rowMeta.id === dragRecord?.rowMeta?.id"
:selected="record.rowMeta.id === dragRecord?.rowMeta?.id"
:position="record.rowMeta!.position"
:record="record"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
color="blue"
@resize-start="onResizeStart"
>
<template v-for="(field, id) in fields" :key="id">
<LazySmartsheetPlainCell
v-if="!isRowEmpty(record, field!)"
v-model="record.row[field!.title!]"
class="text-xs font-medium"
:bold="getFieldStyle(field).bold"
:column="field"
:italic="getFieldStyle(field).italic"
:underline="getFieldStyle(field).underline"
/>
</template>
<template #time>
<div class="text-xs font-medium text-gray-400">
{{ dayjs(record.row[record.rowMeta.range?.fk_from_col!.title!]).format('h:mm a') }}
</div>
</template>
</LazySmartsheetCalendarVRecordCard>
</LazySmartsheetRow>
</div>
</template>
<component :is="iconMap.plus" class="h-4 w-4" />
</NcButton>
<NcButton
v-if="isOverflowAcrossHourRange(hour).isOverflow"
v-e="`['c:calendar:week-view-more']`"
class="!absolute bottom-2 text-center w-15 mx-auto inset-x-0 z-3 text-gray-500"
size="xxsmall"
type="secondary"
@click="viewMore(hour)"
>
<span class="text-xs">
+
{{ isOverflowAcrossHourRange(hour).overflowCount }}
more
</span>
</NcButton>
</div>
</div>
<div class="absolute inset-0 pointer-events-none">
<div
class="relative !ml-[68px] !mr-1 z-2 nc-calendar-day-record-container"
data-testid="nc-calendar-day-record-container"
>
<template v-for="record in recordsAcrossAllRange.record" :key="record.rowMeta.id">
<div
v-if="record.rowMeta.style?.display !== 'none'"
:data-testid="`nc-calendar-day-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id"
:style="{
...record.rowMeta.style,
opacity:
(dragRecord === null || record.rowMeta.id === dragRecord?.rowMeta.id) &&
(resizeRecord === null || record.rowMeta.id === resizeRecord?.rowMeta.id)
? 1
: 0.3,
}"
class="absolute draggable-record transition group cursor-pointer pointer-events-auto"
@mousedown="dragStart($event, record)"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id as string"
@dragover.prevent
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarVRecordCard
:hover="hoverRecord === record.rowMeta.id"
:selected="record.rowMeta.id === dragRecord?.rowMeta?.id"
:record="record"
:dragging="record.rowMeta.id === dragRecord?.rowMeta?.id || record.rowMeta.id === resizeRecord?.rowMeta?.id"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
@resize-start="onResizeStart"
>
<template v-for="(field, id) in fields" :key="id">
<LazySmartsheetPlainCell
v-if="!isRowEmpty(record, field!)"
v-model="record.row[field!.title!]"
class="text-xs font-medium"
:bold="getFieldStyle(field).bold"
:column="field"
:italic="getFieldStyle(field).italic"
:underline="getFieldStyle(field).underline"
/>
</template>
<template #time>
<div class="text-xs font-medium text-gray-400">
{{ dayjs(record.row[record.rowMeta.range?.fk_from_col!.title!]).format('h:mm a') }}
</div>
</template>
</LazySmartsheetCalendarVRecordCard>
</LazySmartsheetRow>
</div>
</template>
</div>
</div>
</div>
</div>
@ -1110,4 +1116,14 @@ watch(
-ms-user-select: none;
user-select: none;
}
.selected-hour {
@apply relative;
&:after {
@apply rounded-sm pointer-events-none absolute inset-0 w-full h-full;
content: '';
z-index: 1;
box-shadow: 0 0 0 2px #3366ff !important;
}
}
</style>

535
packages/nc-gui/components/smartsheet/calendar/MonthView.vue

@ -1,6 +1,5 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import type { ColumnType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
const emit = defineEmits(['newRecord', 'expandRecord'])
@ -52,10 +51,6 @@ const calendarGridContainer = ref()
const { width: gridContainerWidth, height: gridContainerHeight } = useElementSize(calendarGridContainer)
const isDayInPagedMonth = (date: dayjs.Dayjs) => {
return date.month() === selectedMonth.value.month()
}
const dragElement = ref<HTMLElement | null>(null)
const draggingId = ref<string | null>(null)
@ -64,7 +59,7 @@ const resizeInProgress = ref(false)
const isDragging = ref(false)
const dragRecord = ref<Row>()
const dragRecord = ref<Row | null>(null)
const hoverRecord = ref<string | null>()
@ -74,95 +69,144 @@ const focusedDate = ref<dayjs.Dayjs | null>(null)
const resizeDirection = ref<'right' | 'left'>()
const resizeRecord = ref<Row>()
const resizeRecord = ref<Row | null>(null)
const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
return (_fields.value ?? []).reduce((acc, field) => {
acc[field.fk_column_id!] = {
bold: !!field.bold,
italic: !!field.italic,
underline: !!field.underline,
}
return acc
}, {} as Record<string, { bold?: boolean; italic?: boolean; underline?: boolean }>)
})
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
const dates = computed(() => {
const calendarData = computed(() => {
const startOfMonth = selectedMonth.value.startOf('month')
const endOfMonth = selectedMonth.value.endOf('month')
const firstDayToDisplay = startOfMonth.startOf('week').add(isMondayFirst.value ? 0 : -1, 'day')
const lastDayToDisplay = endOfMonth.endOf('week').add(isMondayFirst.value ? 0 : -1, 'day')
const daysToDisplay = lastDayToDisplay.diff(firstDayToDisplay, 'day') + 1
let numberOfRows = Math.ceil(daysToDisplay / 7)
numberOfRows = Math.max(numberOfRows, 5)
const weeksArray: Array<Array<dayjs.Dayjs>> = []
let currentDay = firstDayToDisplay
for (let week = 0; week < numberOfRows; week++) {
const weekArray = []
for (let day = 0; day < 7; day++) {
weekArray.push(currentDay)
currentDay = currentDay.add(1, 'day')
}
weeksArray.push(weekArray)
}
const firstDayOffset = isMondayFirst.value ? 0 : -1
const firstDayToDisplay = startOfMonth.startOf('week').add(firstDayOffset, 'day')
const today = dayjs()
return weeksArray
const daysInView = Math.min(
35,
Math.ceil((startOfMonth.daysInMonth() + startOfMonth.day() + (isMondayFirst.value ? 0 : 1)) / 7) * 7,
)
return {
weeks: Array.from({ length: daysInView / 7 }, (_, weekIndex) => ({
weekIndex,
days: Array.from({ length: 7 }, (_, dayIndex) => {
const day = firstDayToDisplay.add(weekIndex * 7 + dayIndex, 'day')
return {
date: day,
key: `${weekIndex}-${dayIndex}`,
isWeekend: day.get('day') === 0 || day.get('day') === 6,
isToday: day.isSame(today, 'date'),
isInPagedMonth: day.isSame(startOfMonth, 'month'),
isVisible: maxVisibleDays.value === 5 ? day.get('day') !== 0 && day.get('day') !== 6 : true,
dayNumber: day.format('DD'),
}
}),
})),
gridClass: {
'grid-cols-7': maxVisibleDays.value === 7,
'grid-cols-5': maxVisibleDays.value === 5,
'grid': true,
'grow': true,
},
}
})
const recordsToDisplay = computed<{
records: Row[]
count: { [p: string]: { overflow: boolean; count: number; overflowCount: number } }
}>(() => {
if (!dates.value || !calendarRange.value) return []
if (!calendarData.value || !calendarRange.value) return { records: [], count: {} }
const perWidth = gridContainerWidth.value / maxVisibleDays.value
const perHeight = gridContainerHeight.value / dates.value.length
const perRecordHeight = 24
const perHeight = gridContainerHeight.value / calendarData.value.weeks.length
const perRecordHeight = 28
const spaceBetweenRecords = 27
const maxLanes = Math.floor((perHeight - spaceBetweenRecords) / (perRecordHeight + 8))
// This object is used to keep track of the number of records in a day
// The key is the date in the format YYYY-MM-DD
// Track records and lanes for each day
const recordsInDay: {
[key: string]: {
overflow: boolean
count: number
overflowCount: number
lanes: boolean[]
}
} = {}
if (!calendarRange.value) return []
const findAvailableLane = (dateKey: string, duration: number = 1): number => {
if (!recordsInDay[dateKey]) {
recordsInDay[dateKey] = { overflow: false, count: 0, overflowCount: 0, lanes: [] }
}
const { lanes } = recordsInDay[dateKey]
for (let i = 0; i < maxLanes; i++) {
if (!lanes[i]) {
// Check if the lane is available for the entire duration
let isAvailable = true
for (let j = 0; j < duration; j++) {
const checkDate = dayjs(dateKey).add(j, 'day').format('YYYY-MM-DD')
if (recordsInDay[checkDate]?.lanes[i]) {
isAvailable = false
break
}
}
if (isAvailable) return i
}
}
return -1 // No available lane
}
const occupyLane = (dateKey: string, lane: number, duration: number = 1) => {
for (let i = 0; i < duration; i++) {
const occupyDate = dayjs(dateKey).add(i, 'day').format('YYYY-MM-DD')
if (!recordsInDay[occupyDate]) {
recordsInDay[occupyDate] = { overflow: false, count: 0, overflowCount: 0, lanes: [] }
}
recordsInDay[occupyDate].lanes[lane] = true
recordsInDay[occupyDate].count++
}
}
const recordsToDisplay: Array<Row> = []
calendarRange.value.forEach((range) => {
const startCol = range.fk_from_col
const endCol = range.fk_to_col
// Filter out records that don't satisfy the range and sort them by start date
const sortedFormattedData = [...formattedData.value].filter((record) => {
if (startCol && endCol) {
const fromDate = record.row[startCol.title!] ? dayjs(record.row[startCol.title!]) : null
const toDate = record.row[endCol.title!] ? dayjs(record.row[endCol.title!]) : null
return fromDate && toDate && !toDate.isBefore(fromDate)
} else if (startCol && !endCol) {
const fromDate = record.row[startCol!.title!] ? dayjs(record.row[startCol!.title!]) : null
return !!fromDate
}
return false
})
const sortedFormattedData = [...formattedData.value]
.filter((record) => {
if (startCol && endCol) {
const fromDate = record.row[startCol.title!] ? dayjs(record.row[startCol.title!]) : null
const toDate = record.row[endCol.title!] ? dayjs(record.row[endCol.title!]) : null
return fromDate && toDate && !toDate.isBefore(fromDate)
} else if (startCol && !endCol) {
const fromDate = record.row[startCol!.title!] ? dayjs(record.row[startCol!.title!]) : null
return !!fromDate
}
return false
})
.sort((a, b) => {
const aStart = dayjs(a.row[startCol.title!])
const aEnd = endCol ? dayjs(a.row[endCol.title!]) : aStart
const bStart = dayjs(b.row[startCol.title!])
const bEnd = endCol ? dayjs(b.row[endCol.title!]) : bStart
return bEnd.diff(bStart) - aEnd.diff(aStart)
})
sortedFormattedData.forEach((record: Row) => {
if (!endCol && startCol) {
@ -170,47 +214,39 @@ const recordsToDisplay = computed<{
const startDate = dayjs(record.row[startCol.title!])
const dateKey = startDate.format('YYYY-MM-DD')
if (!recordsInDay[dateKey]) {
recordsInDay[dateKey] = { overflow: false, count: 0, overflowCount: 0 }
const lane = findAvailableLane(dateKey)
if (lane === -1) {
recordsInDay[dateKey].overflow = true
recordsInDay[dateKey].overflowCount++
return // Skip this record as there's no available lane
}
recordsInDay[dateKey].count++
const id = record.rowMeta.id ?? generateRandomNumber()
// Find the index of the week from the dates array
const weekIndex = dates.value.findIndex((week) => week.some((day) => dayjs(day).isSame(startDate, 'day')))
occupyLane(dateKey, lane)
// Find the index of the day from the dates array
const dayIndex = (dates.value[weekIndex] ?? []).findIndex((day) => {
return dayjs(day).isSame(startDate, 'day')
})
const weekIndex = calendarData.value.weeks.findIndex((week) =>
week.days.some((day) => dayjs(day.date).isSame(startDate, 'day')),
)
const dayIndex = calendarData.value.weeks[weekIndex]?.days.findIndex((day) => dayjs(day.date).isSame(startDate, 'day'))
const id = record.rowMeta.id ?? generateRandomNumber()
const isRecordDraggingOrResizeState = id === draggingId.value || id === resizeRecord.value?.rowMeta.id
const style: Partial<CSSStyleDeclaration> = {
left: `${dayIndex * perWidth}px`,
width: `${perWidth}px`,
top: isRecordDraggingOrResizeState
? `${weekIndex * perHeight}px`
: `${weekIndex * perHeight + (spaceBetweenRecords + lane * (perRecordHeight + 4))}px`,
}
if (maxVisibleDays.value === 5) {
if (dayIndex === 5 || dayIndex === 6) {
style.display = 'none'
}
if (isRecordDraggingOrResizeState) {
style.zIndex = '100'
style.display = 'block'
}
// Number of records in that day
const recordIndex = recordsInDay[dateKey].count
// The top is calculated from the week index and the record index
// If the record in 1st week and no record in that date them the top will be 0
const top = weekIndex * perHeight + spaceBetweenRecords + (recordIndex - 1) * (perRecordHeight + 4)
// The 25 is obtained from the trial and error
const heightRequired = perRecordHeight * recordIndex + spaceBetweenRecords + 12
if (heightRequired > perHeight) {
if (maxVisibleDays.value === 5 && (dayIndex === 5 || dayIndex === 6) && !isRecordDraggingOrResizeState) {
style.display = 'none'
recordsInDay[dateKey].overflow = true
recordsInDay[dateKey].overflowCount++
} else {
style.top = `${top}px`
}
recordsToDisplay.push({
@ -224,101 +260,99 @@ const recordsToDisplay = computed<{
},
})
} else if (startCol && endCol) {
// If the range specifies fromCol and endCol
const startDate = dayjs(record.row[startCol.title!])
// Multi-day event logic
let startDate = dayjs(record.row[startCol.title!])
const endDate = dayjs(record.row[endCol.title!])
let currentWeekStart = startDate.startOf('week')
if (startDate.isBefore(currentWeekStart)) {
startDate = calendarData.value.weeks[0].days[0].date
}
const id = record.rowMeta.id ?? generateRandomNumber()
// Since the records can span multiple weeks, to display, we render multiple elements
// for each week the record spans. The id is used to identify the elements that belong to the same record
let recordIndex = 0
while (
currentWeekStart.isSameOrBefore(endDate, 'day') &&
// If the current week start is before the last day of the last week
currentWeekStart.isBefore(dates.value[dates.value.length - 1][6])
currentWeekStart.isBefore(calendarData.value.weeks[calendarData.value.weeks.length - 1].days[6].date)
) {
// We update the record start to currentWeekStart if it is before the start date
// and record end to currentWeekEnd if it is after the end date
const currentWeekEnd = currentWeekStart.endOf('week')
let currentWeekEnd = currentWeekStart.endOf('week')
// If the maxVisibleDays is 5, we skip the weekends
if (maxVisibleDays.value === 5) {
currentWeekEnd = currentWeekEnd.subtract(2, 'day')
}
const recordStart = currentWeekStart.isBefore(startDate) ? startDate : currentWeekStart
const recordEnd = currentWeekEnd.isAfter(endDate) ? endDate : currentWeekEnd
if (recordEnd.isBefore(dates.value[0][0])) {
if (recordEnd.isBefore(calendarData.value.weeks[0].days[0].date)) {
currentWeekStart = currentWeekStart.add(1, 'week')
continue
}
// Update the recordsInDay object to keep track of the number of records in a day
let day = recordStart.clone()
while (day.isSameOrBefore(recordEnd)) {
const dateKey = day.format('YYYY-MM-DD')
if (!recordsInDay[dateKey]) {
recordsInDay[dateKey] = { overflow: false, count: 0, overflowCount: 0 }
}
recordsInDay[dateKey].count++
day = day.add(1, 'day')
}
// Find the index of the week from the dates array
const weekIndex = Math.max(
dates.value.findIndex((week) => {
return (
week.findIndex((day) => {
return dayjs(day).isSame(recordStart, 'day')
}) !== -1
)
}),
0,
)
let maxRecordCount = 0
const duration = recordEnd.diff(recordStart, 'day') + 1
// Find the maximum number of records in a day in that week
for (let i = 0; i < (dates.value[weekIndex] ?? []).length; i++) {
const day = dates.value[weekIndex][i]
const dateKey = recordStart.format('YYYY-MM-DD')
const lane = findAvailableLane(dateKey, duration)
const dateKey = dayjs(day).format('YYYY-MM-DD')
if (!recordsInDay[dateKey]) {
recordsInDay[dateKey] = {
count: 0,
overflow: false,
overflowCount: 0,
if (lane === -1) {
for (let i = 0; i < duration; i++) {
const overflowDate = recordStart.add(i, 'day').format('YYYY-MM-DD')
if (recordsInDay[overflowDate]) {
recordsInDay[overflowDate].overflow = true
recordsInDay[overflowDate].overflowCount++
}
}
maxRecordCount = Math.max(maxRecordCount, recordsInDay[dateKey].count)
currentWeekStart = currentWeekStart.add(1, 'week')
continue
}
const startDayIndex = Math.max(
(dates.value[weekIndex] ?? []).findIndex((day) => dayjs(day).isSame(recordStart, 'day')),
0,
occupyLane(dateKey, lane, duration)
const weekIndex = calendarData.value.weeks.findIndex((week) =>
week.days.some((day) => dayjs(day.date).isSame(recordStart, 'day')),
)
const startDayIndex = calendarData.value.weeks[weekIndex]?.days.findIndex((day) =>
dayjs(day.date).isSame(recordStart, 'day'),
)
const endDayIndex = Math.max(
(dates.value[weekIndex] ?? []).findIndex((day) => dayjs(day).isSame(recordEnd, 'day')),
0,
const endDayIndex = calendarData.value.weeks[weekIndex]?.days.findIndex((day) =>
dayjs(day.date).isSame(recordEnd, 'day'),
)
// The left and width of the record is calculated based on the start and end day index
const isRecordDraggingOrResizeState = id === draggingId.value || id === resizeRecord.value?.rowMeta.id
const style: Partial<CSSStyleDeclaration> = {
left: `${startDayIndex * perWidth}px`,
width: `${(endDayIndex - startDayIndex + 1) * perWidth}px`,
top: isRecordDraggingOrResizeState
? `${weekIndex * perHeight + perRecordHeight}px`
: `${weekIndex * perHeight + (spaceBetweenRecords + lane * (perRecordHeight + 4))}px`,
}
// The top is calculated from the week index and the record index
// If the record in 1st week and no record in that date them the top will be 0
// If the record in 1st week and 1 record in that date then the top will be perRecordHeight + spaceBetweenRecords
const top = weekIndex * perHeight + spaceBetweenRecords + Math.max(maxRecordCount - 1, 0) * (perRecordHeight + 4)
const heightRequired = perRecordHeight * Math.max(maxRecordCount, 0) + spaceBetweenRecords + 12
if (isRecordDraggingOrResizeState) {
style.zIndex = '100'
}
let position = 'rounded'
// Here we are checking if the startDay is before all the dates shown in UI rather that the current month
const isStartMonthBeforeCurrentWeek = dates.value[weekIndex - 1]
? dayjs(dates.value[weekIndex - 1][0]).isBefore(startDate, 'month')
const isStartMonthBeforeCurrentWeek = calendarData.value.weeks[weekIndex - 1]
? dayjs(calendarData.value.weeks[weekIndex - 1].days[0].date).isBefore(recordStart, 'month')
: false
if (startDate.isSame(currentWeekStart, 'week') && endDate.isSame(currentWeekEnd, 'week')) {
if (
startDate.isSame(currentWeekStart, 'week') &&
endDate.isSame(currentWeekEnd, 'week') &&
endDate.isSameOrBefore(currentWeekEnd) // Weekend check
) {
position = 'rounded'
} else if (startDate.isSame(recordStart, 'week')) {
if (isStartMonthBeforeCurrentWeek) {
@ -332,23 +366,6 @@ const recordsToDisplay = computed<{
position = 'none'
}
// If the height required is more than the height of the week, we hide the record
// and update the recordsInDay object for all the spanned days
if (heightRequired > perHeight) {
style.display = 'none'
for (let i = startDayIndex; i <= endDayIndex; i++) {
const week = dates.value[weekIndex]
if (!week) continue
const day = week[i]
const dateKey = dayjs(day).format('YYYY-MM-DD')
if (!recordsInDay[dateKey]) continue
recordsInDay[dateKey].overflow = true
recordsInDay[dateKey].overflowCount++
}
} else {
style.top = `${top}px`
}
recordsToDisplay.push({
...record,
rowMeta: {
@ -357,8 +374,10 @@ const recordsToDisplay = computed<{
style,
range,
id,
recordIndex,
},
})
recordIndex++
currentWeekStart = currentWeekStart.add(1, 'week')
}
}
@ -371,21 +390,35 @@ const recordsToDisplay = computed<{
}
})
const dragOffset = ref<{
x: number | null
y: number | null
}>({ x: null, y: null })
const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean, skipChangeCheck?: boolean) => {
const { top, height, width, left } = calendarGridContainer.value.getBoundingClientRect()
const percentY = (event.clientY - top - window.scrollY) / height
const percentX = (event.clientX - left - window.scrollX) / width
let relativeX = event.clientX - left
if (dragOffset.value.x) {
relativeX -= dragOffset.value.x
}
const relativeY = event.clientY - top
const percentX = Math.max(0, Math.min(1, relativeX / width))
const percentY = Math.max(0, Math.min(1, relativeY / height))
const fromCol = dragRecord.value?.rowMeta.range?.fk_from_col
const toCol = dragRecord.value?.rowMeta.range?.fk_to_col
if (!fromCol) return { newRow: null, updateProperty: [] }
const week = Math.floor(percentY * dates.value.length)
const week = Math.floor(percentY * calendarData.value.weeks.length)
const day = Math.floor(percentX * maxVisibleDays.value)
let newStartDate = dates.value[week] ? dayjs(dates.value[week][day]) : null
let newStartDate = calendarData.value.weeks[week] ? dayjs(calendarData.value.weeks[week].days[day].date) : null
if (!newStartDate) return { newRow: null, updateProperty: [] }
let fromDate = dayjs(dragRecord.value.row[fromCol.title!])
@ -434,8 +467,10 @@ const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean, skipChangeC
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
newRow.rowMeta.id = draggingId?.value
if (updateSideBar) {
formattedData.value = [...formattedData.value, newRow]
formattedData.value = [...(formattedData.value as Row[]), newRow as Row]
formattedSideBarData.value = formattedSideBarData.value.filter((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk !== newPk
@ -444,7 +479,7 @@ const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean, skipChangeC
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
}) as Row[]
}
return {
@ -455,6 +490,7 @@ const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean, skipChangeC
const onDrag = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !dragRecord.value) return
calculateNewRow(event, false)
}
@ -476,14 +512,14 @@ const onResize = (event: MouseEvent) => {
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col
const week = Math.floor(percentY * dates.value.length)
const week = Math.floor(percentY * calendarData.value.weeks.length)
const day = Math.floor(percentX * maxVisibleDays.value)
let updateProperty: string[] = []
let newRow: Row
if (resizeDirection.value === 'right') {
let newEndDate = dates.value[week] ? dayjs(dates.value[week][day]).endOf('day') : null
let newEndDate = calendarData.value.weeks[week] ? dayjs(calendarData.value.weeks[week].days[day].date).endOf('day') : null
updateProperty = [toCol!.title!]
if (dayjs(newEndDate).isBefore(ogStartDate)) {
@ -500,7 +536,7 @@ const onResize = (event: MouseEvent) => {
},
}
} else {
let newStartDate = dates.value[week] ? dayjs(dates.value[week][day]) : null
let newStartDate = calendarData.value.weeks[week] ? dayjs(calendarData.value.weeks[week].days[day].date) : null
updateProperty = [fromCol!.title!]
if (dayjs(newStartDate).isAfter(ogEndDate)) {
@ -517,6 +553,8 @@ const onResize = (event: MouseEvent) => {
}
}
newRow.rowMeta.id = resizeRecord.value.rowMeta.id
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
@ -532,7 +570,7 @@ const onResize = (event: MouseEvent) => {
const onResizeEnd = () => {
resizeInProgress.value = false
resizeDirection.value = undefined
resizeRecord.value = undefined
resizeRecord.value = null
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', onResizeEnd)
@ -562,12 +600,6 @@ const stopDrag = (event: MouseEvent) => {
const { newRow, updateProperty } = calculateNewRow(event, false, true)
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
el.style.visibility = ''
el.style.opacity = '100%'
})
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
isDragging.value = false
@ -575,10 +607,15 @@ const stopDrag = (event: MouseEvent) => {
dragElement.value = null
}
dragRecord.value = undefined
dragRecord.value = null
updateRowProperty(newRow, updateProperty, false)
focusedDate.value = null
dragOffset.value = {
x: null,
y: null,
}
$e('c:calendar:month:drag-record')
document.removeEventListener('mousemove', onDrag)
@ -599,12 +636,13 @@ const dragStart = (event: MouseEvent, record: Row) => {
target = target.parentElement as HTMLElement
}
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
el.style.opacity = '30%'
}
})
// TODO: @DarkPhoenix2704
// const initialDragElement = document.querySelector(`[data-unique-id="${record.rowMeta.id}-0"]`)
dragOffset.value = {
x: event.clientX - target.getBoundingClientRect().left,
y: event.clientY - target.getBoundingClientRect().top,
}
// selectedDate.value = null
@ -657,9 +695,9 @@ const dropEvent = (event: DragEvent) => {
}
const selectDate = (date: dayjs.Dayjs) => {
dragRecord.value = undefined
dragRecord.value = null
draggingId.value = null
resizeRecord.value = undefined
resizeRecord.value = null
resizeInProgress.value = false
resizeDirection.value = undefined
focusedDate.value = null
@ -703,7 +741,7 @@ const addRecord = (date: dayjs.Dayjs) => {
<div
v-for="(day, index) in days"
:key="index"
class="text-center bg-gray-50 py-1 border-r-1 last:border-r-0 border-gray-200 font-semibold leading-4 uppercase text-[10px] text-gray-500"
class="text-center bg-gray-50 py-1 border-r-1 last:border-r-0 border-gray-100 font-semibold leading-4 uppercase text-[10px] text-gray-500"
>
{{ day }}
</div>
@ -711,53 +749,49 @@ const addRecord = (date: dayjs.Dayjs) => {
<div
ref="calendarGridContainer"
:class="{
'grid-rows-5': dates.length === 5,
'grid-rows-6': dates.length === 6,
'grid-rows-7': dates.length === 7,
'grid-rows-5': calendarData.weeks.length === 5,
'grid-rows-6': calendarData.weeks.length === 6,
'grid-rows-7': calendarData.weeks.length === 7,
}"
class="grid"
style="height: calc(100% - 1.59rem)"
@drop="dropEvent"
>
<div
v-for="(week, weekIndex) in dates"
:key="weekIndex"
:class="{
'grid-cols-7': maxVisibleDays === 7,
'grid-cols-5': maxVisibleDays === 5,
}"
class="grid grow"
v-for="week in calendarData.weeks"
:key="week.weekIndex"
:class="calendarData.gridClass"
data-testid="nc-calendar-month-week"
>
<template v-for="(day, dateIndex) in week">
<template v-for="(day, i) in week.days">
<div
v-if="maxVisibleDays === 5 ? day.get('day') !== 0 && day.get('day') !== 6 : true"
:key="`${weekIndex}-${dateIndex}`"
v-if="day.isVisible"
:key="day.key"
:class="{
'border-brand-500 border-1 !border-r-1 border-b-1':
isDateSelected(day) || (focusedDate && dayjs(day).isSame(focusedDate, 'day')),
'!text-gray-400': !isDayInPagedMonth(day),
'!bg-gray-50 !hover:bg-gray-100': day.get('day') === 0 || day.get('day') === 6,
'border-t-1': weekIndex === 0,
'selected-date': isDateSelected(day.date) || (focusedDate && dayjs(day.date).isSame(focusedDate, 'day')),
'!text-gray-400': !day.isInPagedMonth,
'!bg-gray-50 !hover:bg-gray-100 !border-gray-200': day.isWeekend,
'!border-r-gray-200': week.days[i + 1]?.isWeekend,
'border-t-1': week.weekIndex === 0,
}"
class="text-right relative group last:border-r-0 transition text-sm h-full border-r-1 border-b-1 border-gray-200 font-medium hover:bg-gray-50 text-gray-800 bg-white"
class="text-right relative group last:border-r-0 transition text-sm h-full border-r-1 border-b-1 border-gray-100 font-medium hover:bg-gray-50 text-gray-800 bg-white"
data-testid="nc-calendar-month-day"
@click="selectDate(day)"
@dblclick="addRecord(day)"
@click="selectDate(day.date)"
@dblclick="addRecord(day.date)"
>
<div v-if="isUIAllowed('dataEdit')" class="flex justify-between p-1">
<span
:class="{
'block group-hover:hidden': !isDateSelected(day) && [UITypes.DateTime, UITypes.Date].includes(calDataType),
'hidden': isDateSelected(day) && [UITypes.DateTime, UITypes.Date].includes(calDataType),
'block group-hover:hidden': !isDateSelected(day.date) && [UITypes.DateTime, UITypes.Date].includes(calDataType),
'hidden': isDateSelected(day.date) && [UITypes.DateTime, UITypes.Date].includes(calDataType),
}"
></span>
<NcDropdown v-if="calendarRange.length > 1" auto-close>
<NcButton
:class="{
'!block': isDateSelected(day),
'!hidden': !isDateSelected(day),
'!block': isDateSelected(day.date),
'!hidden': !isDateSelected(day.date),
}"
class="!group-hover:block rounded"
size="small"
@ -776,7 +810,12 @@ const addRecord = (date: dayjs.Dayjs) => {
() => {
const record = {
row: {
[range.fk_from_col!.title!]: dayjs(day).format('YYYY-MM-DD HH:mm:ssZ'),
[range.fk_from_col!.title!]: (day.date).format('YYYY-MM-DD HH:mm:ssZ'),
...(range.fk_to_col
? {
[range.fk_to_col!.title!]: (day.date).endOf('day').format('YYYY-MM-DD HH:mm:ssZ'),
}
: {}),
},
}
emit('newRecord', record)
@ -794,8 +833,8 @@ const addRecord = (date: dayjs.Dayjs) => {
<NcButton
v-else-if="[UITypes.DateTime, UITypes.Date].includes(calDataType)"
:class="{
'!block': isDateSelected(day),
'!hidden': !isDateSelected(day),
'!block': isDateSelected(day.date),
'!hidden': !isDateSelected(day.date),
}"
class="!group-hover:block !w-6 !h-6 !rounded"
size="xsmall"
@ -804,7 +843,12 @@ const addRecord = (date: dayjs.Dayjs) => {
() => {
const record = {
row: {
[calendarRange[0].fk_from_col!.title!]: (day).format('YYYY-MM-DD HH:mm:ssZ'),
[calendarRange[0].fk_from_col!.title!]: (day.date).format('YYYY-MM-DD HH:mm:ssZ'),
...(calendarRange[0].fk_to_col
? {
[calendarRange[0].fk_to_col!.title!]: (day.date).endOf('day').format('YYYY-MM-DD HH:mm:ssZ'),
}
: {}),
},
}
emit('newRecord', record)
@ -815,55 +859,66 @@ const addRecord = (date: dayjs.Dayjs) => {
</NcButton>
<span
:class="{
'bg-brand-50 text-brand-500 !font-bold': day.isSame(dayjs(), 'date'),
'bg-brand-50 text-brand-500 !font-bold': day.isToday,
}"
class="px-1.3 py-1 text-sm leading-3 font-medium rounded-lg"
class="px-1.3 py-1 text-[13px] text-sm leading-3 font-medium rounded-lg"
>
{{ day.format('DD') }}
{{ day.dayNumber }}
</span>
</div>
<div v-if="!isUIAllowed('dataEdit')" class="leading-3 p-3">{{ dayjs(day).format('DD') }}</div>
<div v-if="!isUIAllowed('dataEdit')" class="leading-3 text-[13px] p-3">{{ day.dayNumber }}</div>
<NcButton
v-if="
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')] &&
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflow &&
recordsToDisplay.count[dayjs(day.date).format('YYYY-MM-DD')] &&
recordsToDisplay.count[dayjs(day.date).format('YYYY-MM-DD')]?.overflow &&
!draggingId
"
v-e="`['c:calendar:month-view-more']`"
class="!absolute bottom-1 right-1 text-center min-w-4.5 mx-auto z-3 text-gray-500"
size="xxsmall"
type="secondary"
@click="viewMore(day)"
@click="viewMore(day.date)"
>
<span class="text-xs px-1"> + {{ recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflowCount }} </span>
<span class="text-xs px-1">
+ {{ recordsToDisplay.count[dayjs(day.date).format('YYYY-MM-DD')]?.overflowCount }}
</span>
</NcButton>
</div>
</template>
</div>
</div>
<div class="absolute inset-0 pointer-events-none mt-8 pb-7.5" data-testid="nc-calendar-month-record-container">
<template v-for="(record, recordIndex) in recordsToDisplay.records" :key="recordIndex">
<div class="absolute inset-0 z-2 pointer-events-none mt-8 pb-7.5" data-testid="nc-calendar-month-record-container">
<template v-for="record in recordsToDisplay.records">
<div
v-if="record.rowMeta.style?.display !== 'none'"
:key="record.rowMeta.id"
:data-testid="`nc-calendar-month-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id"
:data-unique-id="`${record.rowMeta.id}`"
:style="{
...record.rowMeta.style,
zIndex: record.rowMeta.id === draggingId ? 100 : 0,
opacity:
(draggingId === null || record.rowMeta.id === draggingId) &&
(resizeRecord === null || record.rowMeta.id === resizeRecord?.rowMeta.id)
? 1
: 0.3,
}"
:class="{
'cursor-pointer': !resizeInProgress,
}"
class="absolute group draggable-record transition cursor-pointer pointer-events-auto"
class="absolute group draggable-record transition pointer-events-auto"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id"
@mousedown.stop="dragStart($event, record)"
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard
:hover="hoverRecord === record.rowMeta.id || record.rowMeta.id === draggingId"
:hover="hoverRecord === record.rowMeta.id"
:position="record.rowMeta.position"
:record="record"
:dragging="draggingId === record.rowMeta.id || resizeRecord?.rowMeta?.id === record.rowMeta.id"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
:selected="dragRecord?.rowMeta?.id === record.rowMeta.id || resizeRecord?.rowMeta?.id === record.rowMeta.id"
@resize-start="onResizeStart"
>
<template v-if="[UITypes.DateTime, UITypes.LastModifiedTime, UITypes.CreatedTime].includes(calDataType)" #time>
@ -871,15 +926,15 @@ const addRecord = (date: dayjs.Dayjs) => {
{{ dayjs(record.row[record.rowMeta.range?.fk_from_col!.title!]).format('h:mma').slice(0, -1) }}
</span>
</template>
<template v-for="(field, id) in fields" :key="id">
<template v-for="(field, id) in fields" :key="field.id">
<LazySmartsheetPlainCell
v-if="!isRowEmpty(record, field!)"
v-model="record.row[field!.title!]"
class="text-xs"
:bold="getFieldStyle(field).bold"
:bold="fieldStyles[field.id].bold"
:column="field"
:italic="getFieldStyle(field).italic"
:underline="getFieldStyle(field).underline"
:italic="fieldStyles[field.id].italic"
:underline="fieldStyles[field.id].underline"
/>
</template>
</LazySmartsheetCalendarRecordCard>
@ -900,4 +955,14 @@ const addRecord = (date: dayjs.Dayjs) => {
.grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.selected-date {
@apply relative;
&:after {
@apply rounded-sm pointer-events-none absolute inset-0 w-full h-full;
content: '';
z-index: 2;
box-shadow: 0 0 0 2px #3366ff !important;
}
}
</style>

70
packages/nc-gui/components/smartsheet/calendar/RecordCard.vue

@ -9,15 +9,16 @@ interface Props {
selected?: boolean
size?: 'small' | 'medium' | 'large' | 'auto'
position?: 'leftRounded' | 'rightRounded' | 'rounded' | 'none'
dragging?: boolean
}
withDefaults(defineProps<Props>(), {
resize: true,
selected: false,
hover: false,
color: 'blue',
color: 'gray',
size: 'small',
position: 'rounded',
dragging: false,
})
const emit = defineEmits(['resize-start'])
@ -26,23 +27,27 @@ const emit = defineEmits(['resize-start'])
<template>
<div
:class="{
'h-6': size === 'small',
'h-7': size === 'small',
'h-full': size === 'auto',
'rounded-l-md ml-1': position === 'leftRounded',
'rounded-r-md mr-1': position === 'rightRounded',
'rounded-md mx-1': position === 'rounded',
'rounded-none': position === 'none',
'rounded-l-[4px] !border-r-0 ml-1': position === 'leftRounded',
'rounded-r-[4px] !border-l-0 mr-1': position === 'rightRounded',
'rounded-[4px] mx-1': position === 'rounded',
'rounded-none !border-x-0': position === 'none',
'bg-maroon-50': color === 'maroon',
'bg-blue-50': color === 'blue',
'bg-green-50': color === 'green',
'bg-yellow-50': color === 'yellow',
'bg-pink-50': color === 'pink',
'bg-purple-50': color === 'purple',
'group-hover:(border-brand-500 border-1)': resize,
'!border-blue-200 border-1': selected,
'shadow-md': hover,
'bg-white border-gray-300': color === 'gray',
}"
class="relative transition-all flex items-center px-1 gap-2 group border-1 border-transparent"
:style="{
boxShadow:
hover || dragging
? '0px 12px 16px -4px rgba(0, 0, 0, 0.10), 0px 4px 6px -2px rgba(0, 0, 0, 0.06)'
: '0px 2px 4px -2px rgba(0, 0, 0, 0.06), 0px 4px 4px -2px rgba(0, 0, 0, 0.02)',
}"
class="relative transition-all border-1 flex items-center gap-2 group"
>
<div
v-if="position === 'leftRounded' || position === 'rounded'"
@ -53,23 +58,16 @@ const emit = defineEmits(['resize-start'])
'bg-yellow-500': color === 'yellow',
'bg-pink-500': color === 'pink',
'bg-purple-500': color === 'purple',
'bg-gray-900': color === 'gray',
}"
class="w-1 min-h-4 bg-blue-500 rounded-x rounded-y-sm"
class="w-1 min-h-6.5 rounded-l-[4px] bg-blue-500"
></div>
<div v-if="(position === 'leftRounded' || position === 'rounded') && resize" class="mt-0.7 h-7.1 absolute -left-4 resize">
<NcButton
:class="{
'!block z-2 !border-brand-500': selected || hover,
'!hidden': !selected && !hover,
}"
size="xsmall"
type="secondary"
@mousedown.stop="emit('resize-start', 'left', $event, record)"
>
<component :is="iconMap.drag" class="text-gray-400"></component>
</NcButton>
</div>
<div
v-if="(position === 'leftRounded' || position === 'rounded') && resize"
class="mt-0.7 w-2 h-7.1 -left-1 absolute resize"
@mousedown.stop="emit('resize-start', 'left', $event, record)"
></div>
<div class="overflow-hidden items-center justify-center gap-2 flex w-full">
<span v-if="position === 'rightRounded' || position === 'none'" class="ml-2"> .... </span>
@ -80,29 +78,21 @@ const emit = defineEmits(['resize-start'])
}"
class="flex mb-0.5 overflow-x-hidden break-word whitespace-nowrap overflow-hidden text-ellipsis w-full truncate text-ellipsis flex-col gap-1"
>
<NcTooltip :disabled="selected" class="inline-block" show-on-truncate-only wrap-child="span">
<NcTooltip :disabled="selected || dragging" class="inline-block" show-on-truncate-only wrap-child="span">
<slot class="text-sm text-nowrap text-gray-800 leading-7" />
<template #title>
<slot />
</template>
</NcTooltip>
</div>
<span v-if="position === 'leftRounded' || position === 'none'" class="absolute my-0 right-5"> .... </span>
<span v-if="position === 'leftRounded' || position === 'none'" class="absolute my-0 z-10 right-5"> .... </span>
</div>
<div v-if="(position === 'rightRounded' || position === 'rounded') && resize" class="absolute mt-0.3 -right-4 resize">
<NcButton
:class="{
'!block !border-brand-500 z-2': selected || hover,
'!hidden': !selected && !hover,
}"
size="xsmall"
type="secondary"
@mousedown.stop="emit('resize-start', 'right', $event, record)"
>
<component :is="iconMap.drag" class="text-gray-400"></component>
</NcButton>
</div>
<div
v-if="(position === 'rightRounded' || position === 'rounded') && resize"
class="absolute mt-0.3 h-7.1 w-2 right-0 resize"
@mousedown.stop="emit('resize-start', 'right', $event, record)"
></div>
</div>
</template>

4
packages/nc-gui/components/smartsheet/calendar/SideMenu.vue

@ -346,7 +346,8 @@ onClickOutside(searchRef, toggleSearch)
'right-2': !showSideMenu,
'right-74': showSideMenu,
}"
class="absolute transition-all ease-in-out z-9 top-2"
style="z-index: 100"
class="absolute transition-all ease-in-out top-2"
hide-on-click
>
<template #title> {{ $t('activity.toggleSidebar') }}</template>
@ -546,7 +547,6 @@ onClickOutside(searchRef, toggleSearch)
: dayjs(record.row[record.rowMeta.range!.fk_to_col.title!]).format('DD MMM • HH:mm A')
: null
"
color="blue"
data-testid="nc-sidebar-record-card"
@click="emit('expandRecord', record)"
@dragstart="dragStart($event, record)"

32
packages/nc-gui/components/smartsheet/calendar/SideRecordCard.vue

@ -9,15 +9,15 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
fromDate: '',
color: 'blue',
color: 'gray',
showDate: true,
invalid: false,
})
</script>
<template>
<div class="border-1 cursor-pointer h-14 border-gray-200 flex gap-2 items-center rounded-lg">
<div class="flex items-center pl-2 gap-2">
<div class="border-1 cursor-pointer h-12.5 border-gray-200 flex gap-2 flex-col rounded-lg">
<div class="flex relative items-center gap-2">
<span
:class="{
'bg-maroon-500': props.color === 'maroon',
@ -26,27 +26,33 @@ const props = withDefaults(defineProps<Props>(), {
'bg-yellow-500': props.color === 'yellow',
'bg-pink-500': props.color === 'pink',
'bg-purple-500': props.color === 'purple',
'bg-gray-900': color === 'gray',
}"
class="block h-10 w-1 rounded"
class="block h-12 w-1 rounded-l-lg"
></span>
<slot name="image" />
<div class="flex gap-1 flex-col">
<div class="flex gap-1 py-1 flex-col">
<span class="text-[13px] leading-4 max-w-56 font-medium truncate text-gray-800">
<slot />
</span>
<NcTooltip v-if="invalid" placement="left" class="top-1 absolute right-2">
<NcBadge color="red" :border="false" class="!h-5">
<div class="flex items-center gap-1">
<GeneralIcon icon="warning" class="text-red-500 !h-4 !w-4" />
<span class="font-normal text-xs"> Date Error </span>
</div>
</NcBadge>
<template #title>
Record with end date before the start date won't be displayed in the calendar. Update the end date to display the
record.
</template>
</NcTooltip>
<span v-if="showDate" class="text-xs font-medium leading-4 text-gray-600"
>{{ fromDate }} {{ toDate ? ` - ${toDate}` : '' }}</span
>
</div>
</div>
<div v-if="invalid" class="gap-3 bg-yellow-50 mt-2 border-gray-200 border-1 rounded-lg p-2 flex">
<component :is="iconMap.warning" class="text-yellow-500 h-4 w-4" />
<div class="flex flex-col gap-1">
<h1 class="text-gray-800 text-bold">Date mismatch</h1>
<p class="text-gray-500">Selected End date is before Start date.</p>
</div>
</div>
</div>
</template>

121
packages/nc-gui/components/smartsheet/calendar/VRecordCard.vue

@ -5,15 +5,15 @@ interface Props {
resize?: boolean
selected?: boolean
hover?: boolean
position?: 'topRounded' | 'bottomRounded' | 'rounded' | 'none'
dragging?: boolean
}
withDefaults(defineProps<Props>(), {
resize: true,
selected: false,
hover: false,
color: 'blue',
position: 'rounded',
color: 'gray',
dragging: false,
})
const emit = defineEmits(['resize-start'])
@ -21,85 +21,70 @@ const emit = defineEmits(['resize-start'])
<template>
<div
v-if="(position === 'topRounded' || position === 'rounded') && resize"
class="absolute flex items-center justify-center w-full -mt-4 h-7.1 hidden z-1 resize !group-hover:(flex rounded-lg)"
>
<NcButton
:class="{
'!flex rounded-md border-brand-500': selected || hover,
}"
class="!group-hover:(border-brand-500) !border-1 text-gray-400 cursor-ns-resize"
size="xsmall"
type="secondary"
@mousedown.stop="emit('resize-start', 'left', $event, record)"
>
<component :is="iconMap.drag" class="mt-0.5" />
</NcButton>
</div>
<div
:style="{
boxShadow:
hover || dragging
? '0px 12px 16px -4px rgba(0, 0, 0, 0.10), 0px 4px 6px -2px rgba(0, 0, 0, 0.06)'
: '0px 2px 4px -2px rgba(0, 0, 0, 0.06), 0px 4px 4px -2px rgba(0, 0, 0, 0.02)',
}"
:class="{
'rounded-t-md': position === 'topRounded',
'rounded-b-md': position === 'bottomRounded',
'rounded-md': position === 'rounded',
'rounded-none': position === 'none',
'bg-maroon-50': color === 'maroon',
'bg-blue-50': color === 'blue',
'bg-green-50': color === 'green',
'bg-yellow-50': color === 'yellow',
'bg-pink-50': color === 'pink',
'bg-purple-50': color === 'purple',
'group-hover:(border-brand-500)': resize,
'!border-blue-200 border-1': selected,
'shadow-md': hover,
'bg-white border-gray-300': color === 'gray',
'z-90': hover,
}"
class="relative flex gap-2 items-center !pr-1 h-full ml-0.25 border-1 border-transparent"
class="relative flex gap-2 border-1 relative rounded-md h-full"
>
<div class="h-full py-1">
<div
:class="{
'bg-maroon-500': color === 'maroon',
'bg-blue-500': color === 'blue',
'bg-green-500': color === 'green',
'bg-yellow-500': color === 'yellow',
'bg-pink-500': color === 'pink',
'bg-purple-500': color === 'purple',
}"
class="block h-full min-h-9 ml-1 w-1 rounded"
></div>
</div>
<div v-if="position === 'bottomRounded' || position === 'none'" class="ml-3">....</div>
<div
class="flex overflow-x-hidden break-word whitespace-nowrap overflow-hidden text-ellipsis w-full truncate text-ellipsis flex-col gap-1"
>
<NcTooltip :disabled="selected" class="inline-block" show-on-truncate-only wrap-child="span">
<slot class="pl-1 pr-2 text-sm text-nowrap text-gray-800 leading-7" />
<template #title>
v-if="resize"
class="absolute w-full h-1 z-20 top-0 cursor-row-resize"
@mousedown.stop="emit('resize-start', 'left', $event, record)"
></div>
<div
:class="{
'bg-maroon-500': color === 'maroon',
'bg-blue-500': color === 'blue',
'bg-green-500': color === 'green',
'bg-yellow-500': color === 'yellow',
'bg-pink-500': color === 'pink',
'bg-purple-500': color === 'purple',
'bg-gray-900': color === 'gray',
}"
class="h-full min-h-3 w-1.25 -ml-0.25 rounded-l-md"
></div>
<div class="flex overflow-x-hidden whitespace-nowrap text-ellipsis pt-1 w-full truncate text-ellipsis flex-col gap-1">
<div class="truncate">
<NcTooltip show-on-truncate-only :disabled="selected">
<template #title>
<slot />
</template>
<slot />
</template>
</NcTooltip>
</NcTooltip>
</div>
<slot name="time" />
</div>
<div v-if="position === 'topRounded' || position === 'none'" class="h-full pb-7 flex items-end ml-3">....</div>
</div>
<div
v-if="(position === 'bottomRounded' || position === 'rounded') && resize"
class="absolute items-center justify-center w-full hidden h-7.1 -mt-4 !group-hover:(flex rounded-lg)"
>
<NcButton
:class="{
'!flex border-1 rounded-lg z-1 cursor-ns-resize border-brand-500': selected || hover,
}"
class="!group-hover:(border-brand-500) text-gray-400 !border-1"
size="xsmall"
type="secondary"
<div
v-if="resize"
class="absolute cursor-row-resize w-full bottom-0 w-full h-1"
@mousedown.stop="emit('resize-start', 'right', $event, record)"
>
<component :is="iconMap.drag" class="mt-0.5" />
</NcButton>
></div>
</div>
</template>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.cursor-row-resize {
cursor: ns-resize;
}
.plain-cell {
.bold {
@apply !text-gray-800 font-semibold;
}
}
</style>

273
packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue

@ -98,170 +98,81 @@ const findFirstSuitableRow = (recordsInDay: any, startDayIndex: number, spanDays
}
const isInRange = (date: dayjs.Dayjs) => {
const rangeEndDate =
maxVisibleDays.value === 5 ? dayjs(selectedDateRange.value.end).subtract(2, 'day') : dayjs(selectedDateRange.value.end)
return (
date &&
date.isBetween(
dayjs(selectedDateRange.value.start).startOf('day'),
dayjs(selectedDateRange.value.end).endOf('day'),
'day',
'[]',
)
date && date.isBetween(dayjs(selectedDateRange.value.start).startOf('day'), dayjs(rangeEndDate).endOf('day'), 'day', '[]')
)
}
const calendarData = computed(() => {
if (!formattedData.value || !calendarRange.value) return []
// We use the recordsInDay object to keep track of which columns are occupied for each day
// This is used to calculate the position of the records in the calendar
// The key is the day index (0-6) and the value is an object with the row index as the key and a boolean as the value
// Since no hours are considered, the rowIndex will be sufficient to calculate the position
const recordsInDay: {
[key: number]: {
[key: number]: boolean
}
} = {
0: {},
1: {},
2: {},
3: {},
4: {},
5: {},
6: {},
}
const recordsInRange: Array<Row> = []
const recordsInDay = Array.from({ length: 7 }, () => ({})) as Record<number, Record<number, boolean>>
const recordsInRange = [] as Row[]
const perDayWidth = containerWidth.value / maxVisibleDays.value
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
if (fromCol && toCol) {
// Filter out records that have an invalid date range
// i.e. the end date is before the start date
for (const record of [...formattedData.value].filter((r) => {
const startDate = dayjs(r.row[fromCol.title!])
const endDate = dayjs(r.row[toCol.title!])
if (!startDate.isValid() || !endDate.isValid()) return false
return !endDate.isBefore(startDate)
})) {
// Generate a unique id for the record if it doesn't have one
const id = record.rowMeta.id ?? generateRandomNumber()
let startDate = dayjs(record.row[fromCol.title!])
const ogStartDate = startDate.clone()
const endDate = dayjs(record.row[toCol.title!])
// If the start date is before the selected date range, we need to adjust the start date
if (startDate.isBefore(selectedDateRange.value.start)) {
startDate = dayjs(selectedDateRange.value.start)
}
const startDaysDiff = startDate.diff(selectedDateRange.value.start, 'day')
// Calculate the span of the record in days
let spanDays = Math.max(Math.min(endDate.diff(startDate, 'day'), 6) + 1, 1)
// If the end date is after the month of the selected date range, we need to adjust the span
if (endDate.isAfter(startDate, 'month')) {
spanDays = 7 - startDaysDiff
}
if (startDaysDiff > 0) {
spanDays = Math.min(spanDays, 7 - startDaysDiff)
}
const widthStyle = `calc(max(${spanDays} * ${perDayWidth}px, ${perDayWidth}px))`
let suitableRow = -1
// Find the first suitable row for the entire span
for (let i = 0; i < spanDays; i++) {
const dayIndex = startDaysDiff + i
if (!recordsInDay[dayIndex]) {
recordsInDay[dayIndex] = {}
}
if (suitableRow === -1) {
suitableRow = findFirstSuitableRow(recordsInDay, dayIndex, spanDays)
}
}
// Mark the suitable column as occupied for the entire span
for (let i = 0; i < spanDays; i++) {
const dayIndex = startDaysDiff + i
recordsInDay[dayIndex][suitableRow] = true
}
let position = 'none'
const isStartInRange = isInRange(ogStartDate)
const isEndInRange = isInRange(endDate)
// Calculate the position of the record in the calendar based on the start and end date
// The position can be 'none', 'leftRounded', 'rightRounded', 'rounded'
// This is used to assign the rounded corners to the records
if (isStartInRange && isEndInRange) {
position = 'rounded'
} else if (
startDate &&
endDate &&
startDate.isBefore(selectedDateRange.value.start) &&
endDate.isAfter(selectedDateRange.value.end)
) {
position = 'none'
} else if (
startDate &&
endDate &&
(startDate.isAfter(selectedDateRange.value.end) || endDate.isBefore(selectedDateRange.value.start))
) {
position = 'none'
} else if (isStartInRange) {
position = 'leftRounded'
} else if (isEndInRange) {
position = 'rightRounded'
}
recordsInRange.push({
...record,
rowMeta: {
...record.rowMeta,
range: range as any,
position,
id,
style: {
width: widthStyle,
left: `${startDaysDiff * perDayWidth}px`,
top: `${suitableRow * 28}px`,
},
},
})
calendarRange.value.forEach(({ fk_from_col, fk_to_col }) => {
if (!fk_from_col) return
const processRecord = (record: Row) => {
const id = record.rowMeta.id ?? generateRandomNumber()
let startDate = dayjs(record.row[fk_from_col.title!])
const ogStartDate = startDate.clone()
const endDate = fk_to_col ? dayjs(record.row[fk_to_col.title!]) : startDate
if (startDate.isBefore(selectedDateRange.value.start)) {
startDate = dayjs(selectedDateRange.value.start)
}
} else if (fromCol) {
for (const record of formattedData.value) {
const id = record.rowMeta.id ?? generateRandomNumber()
const startDate = dayjs(record.row[fromCol.title!])
const startDaysDiff = Math.max(startDate.diff(selectedDateRange.value.start, 'day'), 0)
// Find the first suitable row for record. Here since the span is 1, we can use the findFirstSuitableRow function
const suitableRow = findFirstSuitableRow(recordsInDay, startDaysDiff, 1)
recordsInDay[startDaysDiff][suitableRow] = true
recordsInRange.push({
...record,
rowMeta: {
...record.rowMeta,
range: range as any,
id,
position: 'rounded',
style: {
width: `calc(${perDayWidth}px)`,
left: `${startDaysDiff * perDayWidth}px`,
top: `${suitableRow * 28}px`,
},
const startDaysDiff = startDate.diff(selectedDateRange.value.start, 'day')
const spanDays = fk_to_col
? Math.min(Math.max(endDate.diff(startDate, 'day') + 1, 1), maxVisibleDays.value - startDaysDiff)
: 1
const suitableRow = findFirstSuitableRow(recordsInDay, startDaysDiff, spanDays)
for (let i = 0; i < spanDays; i++) {
const dayIndex = startDaysDiff + i
if (!recordsInDay[dayIndex]) recordsInDay[dayIndex] = {}
recordsInDay[dayIndex][suitableRow] = true
}
const isStartInRange = isInRange(ogStartDate)
const isEndInRange = isInRange(endDate)
let position = 'none'
if (isStartInRange && isEndInRange) position = 'rounded'
else if (isStartInRange) position = 'leftRounded'
else if (isEndInRange) position = 'rightRounded'
recordsInRange.push({
...record,
rowMeta: {
...record.rowMeta,
range: { fk_from_col, fk_to_col },
position,
id,
style: {
width: `calc(max(${spanDays * perDayWidth - 10}px, ${perDayWidth - 10}px))`,
left: `${startDaysDiff * perDayWidth + 4}px`,
top: `${suitableRow * 28 + Math.max(suitableRow + 1, 1) * 8}px`,
},
},
})
}
if (fk_to_col) {
formattedData.value
.filter((r) => {
const startDate = dayjs(r.row[fk_from_col.title!])
const endDate = dayjs(r.row[fk_to_col.title!])
return startDate.isValid() && endDate.isValid() && !endDate.isBefore(startDate)
})
}
.forEach(processRecord)
} else {
formattedData.value.forEach(processRecord)
}
})
@ -375,13 +286,25 @@ const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: R
document.addEventListener('mouseup', onResizeEnd)
}
const dragOffset = ref<{
x: number | null
y: number | null
}>({ x: null, y: null })
// This method is used to calculate the new start and end date of a record when dragging and dropping
const calculateNewRow = (event: MouseEvent, updateSideBarData?: boolean) => {
const { width, left } = container.value.getBoundingClientRect()
// Calculate the percentage of the width based on the mouse position
// This is used to calculate the day index
const percentX = (event.clientX - left - window.scrollX) / width
let relativeX = event.clientX - left
if (dragOffset.value.x) {
relativeX -= dragOffset.value.x
}
const percentX = Math.max(0, Math.min(1, relativeX / width))
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col
const toCol = dragRecord.value.rowMeta.range?.fk_to_col
@ -499,6 +422,11 @@ const dragStart = (event: MouseEvent, record: Row) => {
target = target.parentElement as HTMLElement
}
dragOffset.value = {
x: event.clientX - target.getBoundingClientRect().left,
y: event.clientY - target.getBoundingClientRect().top,
}
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
@ -546,6 +474,12 @@ const dropEvent = (event: DragEvent) => {
dragElement.value = null
}
updateRowProperty(newRow, updateProperty, false)
dragOffset.value = {
x: null,
y: null,
}
$e('c:calendar:day:drag-record')
}
}
@ -576,7 +510,7 @@ const addRecord = (date: dayjs.Dayjs) => {
v-for="(date, weekIndex) in weekDates"
:key="weekIndex"
:class="{
'!border-brand-500 !border-b-gray-200': dayjs(date).isSame(selectedDate, 'day'),
'selected-date-header': dayjs(date).isSame(selectedDate, 'day'),
'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7,
}"
@ -587,12 +521,12 @@ const addRecord = (date: dayjs.Dayjs) => {
{{ dayjs(date).format('DD ddd') }}
</div>
</div>
<div ref="container" class="flex h-[calc(100vh-11.6rem)]">
<div ref="container" class="flex h-[calc(100vh-7.3rem)] w-full">
<div
v-for="(date, dateIndex) in weekDates"
:key="dateIndex"
:class="{
'!border-1 !border-t-0 border-brand-500': dayjs(date).isSame(selectedDate, 'day'),
'selected-date': dayjs(date).isSame(selectedDate, 'day'),
'!bg-gray-50': date.get('day') === 0 || date.get('day') === 6,
'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7,
@ -604,7 +538,7 @@ const addRecord = (date: dayjs.Dayjs) => {
></div>
</div>
<div
class="absolute nc-scrollbar-md overflow-y-auto mt-9 pointer-events-none inset-0"
class="absolute nc-scrollbar-md overflow-y-auto z-2 mt-6 pointer-events-none inset-0"
data-testid="nc-calendar-week-record-container"
>
<template v-for="(record, id) in calendarData" :key="id">
@ -622,12 +556,11 @@ const addRecord = (date: dayjs.Dayjs) => {
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard
:hover="hoverRecord === record.rowMeta.id || record.rowMeta.id === dragRecord?.rowMeta?.id"
:hover="hoverRecord === record.rowMeta.id"
:dragging="record.rowMeta.id === dragRecord?.rowMeta?.id || record.rowMeta.id === resizeRecord?.rowMeta?.id"
:position="record.rowMeta.position"
:record="record"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
:selected="dragRecord?.rowMeta?.id === record.rowMeta.id"
color="blue"
@dblclick.stop="emits('expandRecord', record)"
@resize-start="onResizeStart"
>
@ -656,4 +589,24 @@ const addRecord = (date: dayjs.Dayjs) => {
-ms-user-select: none;
user-select: none;
}
.selected-date {
@apply relative;
&:after {
@apply rounded-b-sm pointer-events-none absolute inset-0 w-full h-full;
content: '';
z-index: 1;
box-shadow: 2px 0 0 #3366ff, -2px 0 0 #3366ff, 0 2px 0 #3366ff !important;
}
}
.selected-date-header {
@apply relative;
&:after {
@apply rounded-t-sm pointer-events-none absolute inset-0 -left-0.25 w-[calc(100% + 2px)] h-full;
content: '';
z-index: 10;
box-shadow: 2px 0 0 #3366ff, -2px 0 0 #3366ff, 0 -2px 0 #3366ff !important;
}
}
</style>

520
packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue

@ -1,6 +1,5 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib/types'
const emits = defineEmits(['expandRecord', 'newRecord'])
@ -39,17 +38,14 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
return (_fields.value ?? []).reduce((acc, field) => {
acc[field.fk_column_id!] = {
bold: !!field.bold,
italic: !!field.italic,
underline: !!field.underline,
}
return acc
}, {} as Record<string, { bold?: boolean; italic?: boolean; underline?: boolean }>)
})
const getDayIndex = (date: dayjs.Dayjs) => {
@ -96,9 +92,36 @@ onMounted(() => {
})
})
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
const calculateHourIndices = (dayIndex: number, startDate: dayjs.Dayjs, endDate: dayjs.Dayjs) => {
// Get the hour component for start and end times
const startHour = startDate.hour()
const endHour = endDate.hour()
// Find the indices directly based on hours since datesHours uses integer hours
const startHourIndex = Math.max(
(datesHours.value[dayIndex] ?? []).findIndex((h) => h.hour() === startHour),
0,
)
// For end hour, we need to handle cases where the end time has minutes
let endHourIndex = (datesHours.value[dayIndex] ?? []).findIndex((h) => h.hour() === endHour)
// If we have minutes in the end time, we should include the next hour
if (endDate.minute() > 0 && endHour < 23) {
endHourIndex++
}
endHourIndex = Math.max(endHourIndex, 0)
return {
startHourIndex,
endHourIndex,
// You might also want these for more precise calculations
startMinutes: startDate.minute(),
endMinutes: endDate.minute(),
}
}
const calculateNewDates = useMemoize(
({
startDate,
@ -116,6 +139,10 @@ const calculateNewDates = useMemoize(
endDate = startDate.clone().add(15, 'minutes')
}
if (endDate.diff(startDate, 'minute') <= 60) {
endDate = startDate.clone().add(59, 'minutes')
}
// If the start date is before the start of the schedule, we set it to the start of the schedule
// If the end date is after the end of the schedule, we set it to the end of the schedule
// This is to ensure that the records are within the bounds of the schedule and do not overflow
@ -129,53 +156,24 @@ const calculateNewDates = useMemoize(
return { startDate, endDate }
},
)
// Since it is a datetime Week view, we need to create a 2D array of dayjs objects to represent the hours in a day for each day in the week
const datesHours = computed(() => {
const datesHours: Array<Array<dayjs.Dayjs>> = []
let startOfWeek = dayjs(selectedDateRange.value.start) ?? dayjs().startOf('week')
let endOfWeek = dayjs(selectedDateRange.value.end) ?? dayjs().endOf('week')
if (maxVisibleDays.value === 5) {
endOfWeek = endOfWeek.subtract(2, 'day')
}
while (startOfWeek.isSameOrBefore(endOfWeek)) {
const hours: Array<dayjs.Dayjs> = []
for (let i = 0; i < 24; i++) {
hours.push(
dayjs()
.hour(i)
.minute(0)
.second(0)
.millisecond(0)
.year(startOfWeek.year())
.month(startOfWeek.month())
.date(startOfWeek.date()),
)
}
datesHours.push(hours)
startOfWeek = startOfWeek.add(1, 'day')
}
return datesHours
const start = dayjs(selectedDateRange.value.start).startOf('week')
return Array.from({ length: maxVisibleDays.value }, (_, i) =>
Array.from({ length: 24 }, (_, h) => start.add(i, 'day').hour(h).minute(0).second(0)),
)
})
const getGridTime = (date: dayjs.Dayjs, round = false) => {
const gridCalc = date.hour() * 60 + date.minute()
if (round) {
return Math.ceil(gridCalc)
} else {
return Math.floor(gridCalc)
}
const minutes = date.hour() * 60 + date.minute()
return round ? Math.ceil(minutes) : Math.floor(minutes)
}
const getGridTimeSlots = (from: dayjs.Dayjs, to: dayjs.Dayjs) => {
return {
from: getGridTime(from, false),
to: getGridTime(to, true) - 1,
dayIndex: getDayIndex(from),
}
}
const getGridTimeSlots = (from: dayjs.Dayjs, to: dayjs.Dayjs) => ({
from: getGridTime(from),
to: getGridTime(to, true) - 1,
dayIndex: getDayIndex(from),
})
const hasSlotForRecord = (
columnArray: Row[],
@ -248,7 +246,6 @@ const getMaxOverlaps = ({
if (graph.has(id)) {
dfs(id)
}
const overlapIterations: Array<number> = []
columnArray[dayIndex]
@ -258,11 +255,25 @@ const getMaxOverlaps = ({
overlapIterations.push(record.rowMeta.overLapIteration!)
})
maxOverlaps = Math.max(...overlapIterations)
maxOverlaps = overlapIterations?.length > 0 ? Math.max(...overlapIterations) : 1
return { maxOverlaps, dayIndex, overlapIndex }
}
const resizeInProgress = ref(false)
const dragTimeout = ref<ReturnType<typeof setTimeout>>()
const hoverRecord = ref<string | null>()
const resizeDirection = ref<'right' | 'left' | null>()
const resizeRecord = ref<Row | null>(null)
const isDragging = ref(false)
const dragRecord = ref<Row | null>(null)
const recordsAcrossAllRange = computed<{
records: Array<Row>
gridTimeMap: Map<
@ -275,11 +286,13 @@ const recordsAcrossAllRange = computed<{
}
>
>
spanningRecords: Row[]
}>(() => {
if (!formattedData.value || !calendarRange.value || !container.value || !scrollContainer.value)
return {
records: [],
gridTimeMap: new Map(),
spanningRecords: [],
}
const perWidth = containerWidth.value / maxVisibleDays.value
const perHeight = 52
@ -303,6 +316,7 @@ const recordsAcrossAllRange = computed<{
>
>()
const recordsToDisplay: Array<Row> = []
const recordSpanningDays: Array<Row> = []
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
@ -312,23 +326,22 @@ const recordsAcrossAllRange = computed<{
// But not all fetched records are valid for the certain range, so we filter them out & sort them
const sortedFormattedData = [...formattedData.value]
.filter((record) => {
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
if (fromCol && toCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
return fromDate && toDate && !toDate.isBefore(fromDate)
} else if (fromCol && !toCol) {
return !!fromDate
const fromDate = dayjs(record.row[fromCol.title!])
const toDate = dayjs(record.row[toCol.title!])
if (fromDate.isValid() && toDate.isValid()) {
const isMultiDay = !fromDate.isSame(toDate, 'day')
if (isMultiDay) {
recordSpanningDays.push(record)
return false
}
return true
}
}
return false
})
.sort((a, b) => {
const aDate = dayjs(a.row[fromCol!.title!])
const bDate = dayjs(b.row[fromCol!.title!])
return aDate.isBefore(bDate) ? 1 : -1
return fromCol && !!record.row[fromCol.title!]
})
.sort((a, b) => (dayjs(a.row[fromCol!.title!]).isBefore(dayjs(b.row[fromCol!.title!])) ? 1 : -1))
for (const record of sortedFormattedData) {
const id = record.rowMeta.id ?? generateRandomNumber()
@ -340,73 +353,34 @@ const recordsAcrossAllRange = computed<{
scheduleStart,
scheduleEnd,
})
const dayIndex = getDayIndex(startDate)
// Setting the current start date to the start date of the record
let currentStartDate: dayjs.Dayjs = startDate.clone()
// We loop through the start date to the end date and create a record for each day as it spans bottom to top
while (currentStartDate.isSameOrBefore(endDate!, 'day')) {
const currentEndDate = currentStartDate.clone().endOf('day')
const recordStart: dayjs.Dayjs = currentEndDate.isSame(startDate, 'day') ? startDate : currentStartDate
const recordEnd = currentEndDate.isSame(endDate, 'day') ? endDate : currentEndDate
const dayIndex = getDayIndex(recordStart)
// We calculate the index of the start and end hour in the day
const startHourIndex = Math.max(
(datesHours.value[dayIndex] ?? []).findIndex((h) => h.format('HH:mm') === recordStart.format('HH:mm')),
0,
)
const endHourIndex = Math.max(
(datesHours.value[dayIndex] ?? []).findIndex((h) => h.format('HH:mm') === recordEnd?.startOf('hour').format('HH:mm')),
0,
)
let position: 'topRounded' | 'bottomRounded' | 'rounded' | 'none' = 'rounded'
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(currentStartDate, 'day')
const isBeforeSelectedDay = (date: dayjs.Dayjs) => date.isBefore(currentStartDate, 'day')
const isAfterSelectedDay = (date: dayjs.Dayjs) => date.isAfter(currentStartDate, 'day')
if (isSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'rounded'
} else if (isBeforeSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'none'
} else if (isSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'topRounded'
} else if (isBeforeSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'bottomRounded'
} else {
position = 'none'
}
let style: Partial<CSSStyleDeclaration> = {}
const spanHours = endHourIndex - startHourIndex + 1
const { startHourIndex, startMinutes } = calculateHourIndices(dayIndex, startDate, endDate)
const top = startHourIndex * perHeight
let style: Partial<CSSStyleDeclaration> = {}
const height = (endHourIndex - startHourIndex + 1) * perHeight - spanHours - 5
const top = (startHourIndex + startMinutes / 60) * perHeight
style = {
...style,
top: `${top}px`,
height: `${height}px`,
}
const totalHours = endDate.diff(startDate, 'minute') / 60
const height = totalHours * perHeight
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
id,
style,
range,
position,
dayIndex,
},
})
// We set the current start date to the next day
currentStartDate = currentStartDate.add(1, 'day').hour(0).minute(0)
style = {
...style,
top: `${top}px`,
height: `${height}px`,
}
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
id,
style,
range,
position: 'rounded',
dayIndex,
},
})
} else if (fromCol) {
// If there is no toColumn chosen in the range
const { startDate } = calculateNewDates({
@ -563,18 +537,38 @@ const recordsAcrossAllRange = computed<{
let width = 0
let left = 100
const majorLeft = dayIndex * perWidth
if (record.rowMeta.overLapIteration! - 1 > 2) {
display = 'none'
const isRecordDraggingOrResizeState =
record.rowMeta.id === dragRecord.value?.rowMeta.id || record.rowMeta.id === resizeRecord.value?.rowMeta.id
if (!isRecordDraggingOrResizeState) {
if (record.rowMeta.overLapIteration! - 1 > 2) {
display = 'none'
} else {
width = 100 / Math.min(maxOverlaps, 3) / maxVisibleDays.value
left = width * (overlapIndex - 1)
width = Math.max((width / 100) * containerWidth.value - 8, 72)
left = majorLeft + (left / 100) * containerWidth.value + 4
if (majorLeft + perWidth < left + width) {
left = majorLeft + (perWidth - width - 4)
}
}
} else {
width = 100 / Math.min(maxOverlaps, 3) / maxVisibleDays.value
left = width * (overlapIndex - 1)
left = majorLeft + 4
width = perWidth - 8
}
record.rowMeta.style = {
...record.rowMeta.style,
left: `calc(${majorLeft}px + ${left}%)`,
width: `calc(${width}%)`,
left: `${left}px`,
width: `${width}px`,
minWidth: '72px',
display,
}
}
@ -583,108 +577,88 @@ const recordsAcrossAllRange = computed<{
return {
records: recordsToDisplay,
gridTimeMap,
spanningRecords: recordSpanningDays,
}
})
const resizeInProgress = ref(false)
const dragTimeout = ref<ReturnType<typeof setTimeout>>()
const hoverRecord = ref<string | null>()
const resizeDirection = ref<'right' | 'left' | null>()
const resizeRecord = ref<Row | null>()
const isDragging = ref(false)
const dragRecord = ref<Row>()
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => {
updateRowProperty(row, updateProperty, isDelete)
}, 500)
const onResize = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !container.value || !resizeRecord.value || !scrollContainer.value) return
if (resizeRecord.value.rowMeta.range?.is_readonly) return
const { width, left, top, bottom } = container.value.getBoundingClientRect()
const { scrollHeight } = container.value
const { scrollHeight, scrollTop } = container.value
// If the mouse is near the bottom of the container, we scroll down
// If the mouse is near the top of the container, we scroll up
// If the mouse is near the top of the container, we scroll up if (event.clientY > bottom - 20) {
if (event.clientY > bottom - 20) {
container.value.scrollTop += 10
} else if (event.clientY < top + 20) {
container.value.scrollTop -= 10
}
// We calculate the percentage of the mouse position in the container
// percentX is used for the day and percentY is used for the hour
const percentX = (event.clientX - left - window.scrollX) / width
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col
const percentY = (event.clientY - top + scrollTop) / scrollHeight
if (!fromCol || !toCol) return
const { range } = resizeRecord.value.rowMeta
const fromCol = range?.fk_from_col
const toCol = range?.fk_to_col
if (!fromCol?.title || !toCol?.title) return
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!])
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!])
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title])
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title])
const day = Math.floor(percentX * maxVisibleDays.value)
const hour = Math.floor(percentY * 23)
const minutes = Math.round((percentY * 24 * 60) % 60)
const minutes = Math.round((percentY * 24 * 60) / 15) * 15 // Round to nearest 15 minutes
let updateProperty: string[] = []
let newRow: Row = resizeRecord.value
const baseDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(minutes, 'minute')
if (resizeDirection.value === 'right') {
let newEndDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour').add(minutes, 'minute')
updateProperty = [toCol.title!]
// If the new end date is before the start date, we set the new end date to the start date
if (dayjs(newEndDate).isBefore(ogStartDate, 'day')) {
newEndDate = ogStartDate.clone()
}
let newDate: dayjs.Dayjs
let updateProperty: string
let isValid = true
if (!newEndDate.isValid()) return
if (resizeDirection.value === 'right') {
const minEndDate = ogStartDate.add(1, 'hour')
newDate = baseDate.isBefore(ogStartDate)
? ogStartDate.add(Math.ceil(ogStartDate.diff(baseDate, 'minute') / 15) * 15, 'minute')
: baseDate
newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[toCol.title!]: newEndDate.format(updateFormat.value),
},
if (newDate.isBefore(minEndDate)) {
newDate = minEndDate
}
updateProperty = toCol.title
} else if (resizeDirection.value === 'left') {
let newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour').add(minutes, 'minute')
updateProperty = [fromCol.title!]
const minStartDate = ogEndDate.subtract(1, 'hour')
newDate = baseDate.isAfter(ogEndDate)
? ogEndDate.subtract(Math.ceil(baseDate.diff(ogEndDate, 'minute') / 15) * 15, 'minute')
: baseDate
// If the new start date is after the end date, we set the new start date to the end date
if (dayjs(newStartDate).isAfter(ogEndDate)) {
newStartDate = dayjs(dayjs(ogEndDate)).clone()
}
if (!newStartDate) return
newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format(updateFormat.value),
},
if (newDate.isAfter(minStartDate)) {
newDate = minStartDate
}
updateProperty = fromCol.title
} else {
isValid = false
}
if (!isValid || !newDate.isValid()) return
const newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[updateProperty]: newDate.format(updateFormat.value),
},
}
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
useDebouncedRowUpdate(newRow, updateProperty, false)
formattedData.value = formattedData.value.map((r) => (extractPkFromRow(r.row, meta.value!.columns!) === newPk ? newRow : r))
useDebouncedRowUpdate(newRow, [updateProperty], false)
}
const onResizeEnd = () => {
@ -712,18 +686,18 @@ const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: R
const calculateNewRow = (
event: MouseEvent,
updateSideBar?: boolean,
skipChangeCheck?: boolean,
): {
newRow: Row | null
updatedProperty: string[]
skipChangeCheck?: boolean
} => {
if (!container.value) return { newRow: null, updatedProperty: [] }
const { width, left, top } = container.value.getBoundingClientRect()
const { scrollHeight } = container.value
const { scrollHeight, scrollTop } = container.value
const percentX = (event.clientX - left - window.scrollX) / width
const percentY = (event.clientY - top + container.value.scrollTop - 36.8) / scrollHeight
const percentY = (event.clientY - top + scrollTop - 36.8) / scrollHeight
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col
const toCol = dragRecord.value.rowMeta.range?.fk_to_col
@ -751,10 +725,11 @@ const calculateNewRow = (
if (toCol) {
const fromDate = dragRecord.value.row[fromCol.title!] ? dayjs(dragRecord.value.row[fromCol.title!]) : null
const toDate = dragRecord.value.row[toCol.title!] ? dayjs(dragRecord.value.row[toCol.title!]) : null
const toDate = dragRecord.value.row[toCol.title!] ? dayjs(dragRecord.value.row[toCol.title!]) : fromDate?.clone()
if (fromDate && toDate) {
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day')
const newMinutes = Math.round(toDate.diff(fromDate, 'minute') / 15) * 15
endDate = newStartDate.add(newMinutes, 'minute')
} else if (fromDate && !toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else if (!fromDate && toDate) {
@ -763,15 +738,14 @@ const calculateNewRow = (
endDate = newStartDate.clone()
}
if (endDate.isBefore(newStartDate)) {
endDate = newStartDate.clone().add(15, 'minutes')
}
newRow.row[toCol.title!] = dayjs(endDate).format(updateFormat.value)
updatedProperty.push(toCol.title!)
}
// If from and to columns of the dragRecord and the newRow are the same, we don't manipulate the formattedRecords and formattedSideBarData. This removes unwanted computation
if (dragRecord.value.row[fromCol.title!] === newRow.row[fromCol.title!] && !skipChangeCheck) {
return { newRow: null, updatedProperty: [] }
}
if (!newRow) return { newRow: null, updatedProperty: [] }
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
@ -819,16 +793,12 @@ const stopDrag = (event: MouseEvent) => {
const { newRow, updatedProperty } = calculateNewRow(event, false, true)
// We set the visibility and opacity of the records back to normal
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
el.style.visibility = ''
el.style.opacity = '100%'
})
if (newRow) {
updateRowProperty(newRow, updatedProperty, false)
}
dragRecord.value = null
$e('c:calendar:week:drag-record')
document.removeEventListener('mousemove', onDrag)
@ -850,14 +820,6 @@ const dragStart = (event: MouseEvent, record: Row) => {
target = target.parentElement as HTMLElement
}
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
// el.style.visibility = 'hidden'
el.style.opacity = '30%'
}
})
isDragging.value = true
dragRecord.value = record
@ -946,18 +908,42 @@ watch(
},
{ immediate: true },
)
const expandRecord = (record: Row) => {
emits('expandRecord', record)
}
const spanningRecordsContainer = ref<HTMLElement | null>(null)
const isExpanded = ref(false)
const isRangeEnabled = computed(() =>
calendarRange.value.some((range) => range.fk_to_col !== null && range.fk_to_col !== undefined),
)
watch(
() => spanningRecordsContainer.value?.isSpanningRecordExpanded(),
() => {
isExpanded.value = spanningRecordsContainer.value?.isSpanningRecordExpanded()
},
)
</script>
<template>
<div
ref="scrollContainer"
class="h-[calc(100vh-5.4rem)] prevent-select relative flex w-full overflow-y-auto nc-scrollbar-md"
class="prevent-select h-[calc(100vh-5.4rem)] overflow-y-auto nc-scrollbar-md relative flex flex-col w-full"
data-testid="nc-calendar-week-view"
@drop="dropEvent"
>
<div
v-if="!isPublic && dayjs().isBetween(selectedDateRange.start, selectedDateRange.end)"
class="absolute ml-16 mt-7 pointer-events-none z-4"
class="absolute top-16 ml-16 pointer-events-none z-2"
:class="{
'!mt-38.5': isExpanded && isRangeEnabled,
'mt-27': !isExpanded && isRangeEnabled,
'!mt-6': !recordsAcrossAllRange.spanningRecords?.length,
}"
:style="overlayStyle"
>
<div class="flex w-full items-center">
@ -970,8 +956,7 @@ watch(
<div class="flex-1 border-b-1 border-brand-500"></div>
</div>
</div>
<div class="flex sticky h-6 z-1 top-0 pl-16 bg-gray-50 w-full">
<div class="flex sticky h-6 z-3 top-0 pl-16 bg-gray-50 w-full">
<div
v-for="date in datesHours"
:key="date[0].toISOString()"
@ -985,16 +970,41 @@ watch(
{{ dayjs(date[0]).format('DD ddd') }}
</div>
</div>
<div class="absolute bg-white w-16 z-1">
<div
:class="{
'top-20.5': !isExpanded && isRangeEnabled,
'top-32': isExpanded && isRangeEnabled,
'!top-0': !recordsAcrossAllRange.spanningRecords?.length,
}"
class="absolute bg-white w-16 z-1"
>
<div
v-for="(hour, index) in datesHours[0]"
:key="index"
class="h-13 first:mt-0 pt-7.1 nc-calendar-day-hour text-right pr-2 font-semibold text-xs text-gray-400 py-1"
class="h-13 first:mt-0 pt-7.1 nc-calendar-day-hour text-right pr-2 font-semibold text-xs text-gray-500 py-1"
>
{{ hour.format('hh a') }}
</div>
</div>
<div ref="container" class="absolute ml-16 flex w-[calc(100%-64px)]">
<div
v-if="isRangeEnabled && recordsAcrossAllRange.spanningRecords?.length"
class="sticky top-6 bg-white z-3 inset-x-0 w-full"
>
<SmartsheetCalendarDateTimeSpanningContainer
ref="spanningRecordsContainer"
:records="recordsAcrossAllRange.spanningRecords"
@expand-record="expandRecord"
/>
</div>
<div
ref="container"
:class="{
'mt-20.5 ': !isExpanded && isRangeEnabled,
'mt-32': isExpanded && isRangeEnabled,
'!mt-0': !recordsAcrossAllRange.spanningRecords?.length,
}"
class="absolute ml-16 flex w-[calc(100%-64px)]"
>
<div
v-for="(date, index) in datesHours"
:key="index"
@ -1011,7 +1021,7 @@ watch(
:class="{
'border-1 !border-brand-500 !bg-gray-100':
hour.isSame(selectedTime, 'hour') && (hour.get('day') === 6 || hour.get('day') === 0),
'border-1 !border-brand-500 bg-gray-50': hour.isSame(selectedTime, 'hour'),
'selected-hour': hour.isSame(selectedTime, 'hour'),
'bg-gray-50 hover:bg-gray-100': hour.get('day') === 0 || hour.get('day') === 6,
'hover:bg-gray-50': hour.get('day') !== 0 && hour.get('day') !== 6,
}"
@ -1022,14 +1032,14 @@ watch(
() => {
selectedDate = hour
selectedTime = hour
dragRecord = undefined
dragRecord = null
}
"
>
<NcButton
v-if="isOverflowAcrossHourRange(hour).isOverflow"
v-e="`['c:calendar:week-view-more']`"
class="!absolute bottom-1 text-center w-15 ml-auto inset-x-0 z-3 text-gray-500"
class="!absolute bottom-1 text-center w-15 ml-auto inset-x-0 z-2 text-gray-500"
size="xxsmall"
type="secondary"
@click="viewMore(hour)"
@ -1043,13 +1053,23 @@ watch(
</div>
</div>
<div class="absolute pointer-events-none inset-0 overflow-hidden !mt-5.95" data-testid="nc-calendar-week-record-container">
<template v-for="(record, rowIndex) in recordsAcrossAllRange.records" :key="rowIndex">
<div
class="absolute pointer-events-none z-2 inset-0 overflow-hidden !mt-5.95"
data-testid="nc-calendar-week-record-container"
>
<template v-for="record in recordsAcrossAllRange.records" :key="record.rowMeta.id">
<div
v-if="record.rowMeta.style?.display !== 'none'"
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta!.id"
:style="record.rowMeta!.style "
:style="{
...record.rowMeta.style,
opacity:
(dragRecord === null || record.rowMeta.id === dragRecord?.rowMeta.id) &&
(resizeRecord === null || record.rowMeta.id === resizeRecord?.rowMeta.id)
? 1
: 0.3,
}"
:class="{
'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7,
@ -1062,11 +1082,11 @@ watch(
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarVRecordCard
:hover="hoverRecord === record.rowMeta.id || record.rowMeta.id === dragRecord?.rowMeta?.id"
:hover="hoverRecord === record.rowMeta.id"
:position="record.rowMeta!.position"
:dragging="record.rowMeta.id === dragRecord?.rowMeta?.id || resizeRecord?.rowMeta.id === record.rowMeta.id"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
:record="record"
color="blue"
:selected="record.rowMeta!.id === dragRecord?.rowMeta?.id"
@resize-start="onResizeStart"
>
@ -1075,10 +1095,10 @@ watch(
v-if="!isRowEmpty(record, field!)"
v-model="record.row[field!.title!]"
class="text-xs"
:bold="getFieldStyle(field).bold"
:column="field"
:italic="getFieldStyle(field).italic"
:underline="getFieldStyle(field).underline"
:bold="!!fieldStyles[field.id]?.bold"
:italic="!!fieldStyles[field.id]?.italic"
:underline="!!fieldStyles[field.id]?.underline"
/>
</template>
<template #time>
@ -1101,4 +1121,14 @@ watch(
-ms-user-select: none;
user-select: none;
}
.selected-hour {
@apply relative;
&:after {
@apply rounded-sm pointer-events-none absolute inset-0 w-full h-full;
content: '';
z-index: 1;
box-shadow: 0 0 0 2px #3366ff !important;
}
}
</style>

92
packages/nc-gui/components/smartsheet/toolbar/Calendar/Mode.vue

@ -13,6 +13,7 @@ const setActiveCalendarMode = (mode: 'day' | 'week' | 'month' | 'year', event: M
changeCalendarView(mode)
const tabElement = event.target as HTMLElement
highlightStyle.value.left = `${tabElement.offsetLeft}px`
highlightStyle.value.width = `${tabElement.offsetWidth}px`
}
const updateHighlightPosition = () => {
@ -20,6 +21,7 @@ const updateHighlightPosition = () => {
const activeTab = document.querySelector('.nc-calendar-mode-tab .tab.active') as HTMLElement
if (activeTab) {
highlightStyle.value.left = `${activeTab.offsetLeft}px`
highlightStyle.value.width = `${activeTab.offsetWidth}px`
}
})
}
@ -37,53 +39,57 @@ watch(activeCalendarView, () => {
<template>
<div
v-if="isTab"
class="flex flex-row px-1 pointer-events-auto mx-3 mt-3 rounded-lg gap-x-0.5 nc-calendar-mode-tab"
class="px-1 pointer-events-auto relative mx-3 mt-1 mb-2.5 rounded-lg gap-x-0.5 relative nc-calendar-mode-tab"
data-testid="nc-calendar-view-mode"
>
<div :style="highlightStyle" class="highlight"></div>
<div
v-for="mode in ['day', 'week', 'month', 'year']"
:key="mode"
:class="{ active: activeCalendarView === mode }"
:data-testid="`nc-calendar-view-mode-${mode}`"
class="tab"
@click="setActiveCalendarMode(mode, $event)"
>
<div class="tab-title !text-xs nc-tab">{{ $t(`objects.${mode}`) }}</div>
<div class="flex flex-row">
<div :style="highlightStyle" class="highlight h-0.5 rounded-t-md absolute transition-all -bottom-2.5 bg-brand-500"></div>
<div
v-for="mode in ['day', 'week', 'month', 'year']"
:key="mode"
:class="{ '!text-brand-500 !font-bold bg-transparent active': activeCalendarView === mode }"
:data-testid="`nc-calendar-view-mode-${mode}`"
class="tab flex items-center h-7 w-14 z-10 justify-center px-2 py-1 rounded-lg gap-x-1.5 text-gray-600 hover:text-black cursor-pointer transition-all duration-300 select-none"
@click="setActiveCalendarMode(mode, $event)"
>
<div class="min-w-0 pointer-events-none !text-[13px]]">{{ $t(`objects.${mode}`) }}</div>
</div>
</div>
</div>
<NcSelect v-else v-model:value="activeCalendarView" class="!w-21" data-testid="nc-calendar-view-mode" size="small">
<a-select-option v-for="option in ['day', 'week', 'month', 'year']" :key="option" :value="option" class="!h-7 !w-21">
<div class="flex gap-2 mt-0.5 items-center">
<NcTooltip class="!capitalize flex-1 max-w-21" placement="top" show-on-truncate-only>
<template #title>
<span class="capitalize min-w-21">
{{ option }}
</span>
</template>
<span class="text-[13px]">
{{ option }}
</span>
</NcTooltip>
<a-select
v-else
v-model:value="activeCalendarView"
class="nc-select-shadow !w-21 !rounded-lg"
dropdown-class-name="!rounded-lg"
size="small"
data-testid="nc-calendar-view-mode"
@click.stop
>
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
<component
:is="iconMap.check"
<a-select-option v-for="option in ['day', 'week', 'month', 'year']" :key="option" :value="option">
<div class="w-full flex gap-2 items-center justify-between" :title="option">
<div class="flex items-center gap-1">
<NcTooltip class="flex-1 capitalize mt-0.5 truncate" show-on-truncate-only>
<template #title>
{{ option }}
</template>
<template #default>{{ option }}</template>
</NcTooltip>
</div>
<GeneralIcon
v-if="option === activeCalendarView"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
</a-select>
</template>
<style lang="scss" scoped>
.highlight {
@apply absolute h-6.5 w-14 transition-all border-b-2 border-brand-500 duration-200;
z-index: 0;
}
.nc-calendar-mode-menu {
:deep(.nc-menu-item-inner) {
@apply !text-[13px];
@ -95,26 +101,6 @@ watch(activeCalendarView, () => {
}
}
.tab {
@apply flex items-center h-7 w-14 z-10 justify-center px-2 py-1 rounded-lg gap-x-1.5 text-gray-500 hover:text-black cursor-pointer transition-all duration-300 select-none;
}
.tab .tab-title {
@apply min-w-0 mb-3 pointer-events-none;
word-break: keep-all;
white-space: 'nowrap';
display: 'inline';
line-height: 0.95;
}
.active {
@apply !text-brand-500 !font-bold bg-transparent;
}
.nc-calendar-mode-tab {
@apply relative;
}
:deep(.ant-select-selector) {
@apply !h-7;
}

125
packages/nc-gui/components/smartsheet/toolbar/Calendar/Range.vue

@ -22,14 +22,18 @@ const { loadViewColumns } = useViewColumnsOrThrow()
const { loadCalendarMeta, loadCalendarData, loadSidebarData, fetchActiveDates, updateCalendarMeta, viewMetaProperties } =
useCalendarViewStoreOrThrow()
const { isFeatureEnabled } = useBetaFeatureToggle()
const isRangeEnabled = computed(() => isFeatureEnabled(FEATURE_FLAG.CALENDAR_VIEW_RANGE))
const calendarRangeDropdown = ref(false)
const hideWeekends = computed({
get: () => viewMetaProperties.value?.hide_weekend ?? false,
const showWeekends = computed({
get: () => !viewMetaProperties.value?.hide_weekend,
set: (newValue) => {
updateCalendarMeta({
meta: {
hide_weekend: newValue,
hide_weekend: !newValue,
},
})
},
@ -134,7 +138,6 @@ const saveCalendarRanges = async () => {
}
}
/*
const removeRange = async (id: number) => {
_calendar_ranges.value = _calendar_ranges.value.filter((_, i) => i !== id)
await saveCalendarRanges()
@ -143,7 +146,7 @@ const removeRange = async (id: number) => {
const saveCalendarRange = async (range: CalendarRangeType, value?) => {
range.fk_to_column_id = value
await saveCalendarRanges()
} */
}
</script>
<template>
@ -177,23 +180,28 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
</NcTooltip>
<template #overlay>
<div v-if="calendarRangeDropdown" class="w-98 space-y-6 rounded-2xl p-4" data-testid="nc-calendar-range-menu" @click.stop>
<div v-if="calendarRangeDropdown" class="w-108 space-y-6 rounded-2xl p-6" data-testid="nc-calendar-range-menu" @click.stop>
<div
v-for="(range, id) in _calendar_ranges"
:key="id"
class="flex w-full gap-2 mb-2 items-center"
class="flex flex-col w-full gap-2 mb-2"
data-testid="nc-calendar-range-option"
>
<span>
<span class="text-gray-800">
{{ $t('labels.organiseBy') }}
</span>
<NcSelect
<a-select
v-model:value="range.fk_from_column_id"
class="nc-select-shadow w-full !rounded-lg"
dropdown-class-name="!rounded-lg"
:placeholder="$t('placeholder.notSelected')"
data-testid="nc-calendar-range-from-field-select"
:disabled="isLocked"
@change="saveCalendarRanges"
@click.stop
>
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
<a-select-option
v-for="(option, opId) in [...(dateFieldOptions ?? [])].filter((r) => {
if (id === 0) return true
@ -201,68 +209,96 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
return firstRange?.uidt === r.uidt
})"
:key="opId"
class="w-40"
:value="option.value"
>
<div class="flex w-full gap-2 justify-between items-center">
<div class="flex items-center">
<div class="w-full flex gap-2 items-center justify-between" :title="option.label">
<div class="flex items-center gap-1 max-w-[calc(100%_-_20px)]">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template>
{{ option.label }}
<NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only>
<template #title>
{{ option.label }}
</template>
<template #default>{{ option.label }}</template>
</NcTooltip>
</div>
<component
:is="iconMap.check"
<GeneralIcon
v-if="option.value === range.fk_from_column_id"
id="nc-selected-item-icon"
class="text-primary min-w-4 h-4"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
</a-select>
<!-- <div
v-if="range.fk_to_column_id === null && isEeUI"
class="flex cursor-pointer flex text-gray-800 items-center gap-1"
<NcButton
v-if="range.fk_to_column_id === null && isRangeEnabled"
size="small"
data-testid="nc-calendar-range-add-end-date"
@click="saveCalendarRange(range, undefined)"
class="!border-none w-28"
type="secondary"
:shadow="false"
:disabled="!isEeUI || isLocked"
@click="range.fk_to_column_id = undefined"
>
<component :is="iconMap.plus" class="h-4 w-4" />
{{ $t('activity.addEndDate') }}
</div>
<template v-else-if="isEeUI">
<div class="flex gap-2 items-center">
<component :is="iconMap.plus" class="h-4 w-4" />
{{ $t('activity.endDate') }}
</div>
</NcButton>
<template v-else-if="isEeUI && isRangeEnabled">
<span>
{{ $t('activity.withEndDate') }}
</span>
<div class="flex">
<NcSelect
<a-select
v-model:value="range.fk_to_column_id"
:disabled="!range.fk_from_column_id"
class="!rounded-r-none nc-select-shadow w-full flex-1 nc-to-select"
:disabled="!range.fk_from_column_id || isLocked"
:placeholder="$t('placeholder.notSelected')"
class="!rounded-r-none nc-to-select"
data-testid="nc-calendar-range-to-field-select"
dropdown-class-name="!rounded-lg"
@change="saveCalendarRanges"
@click.stop
>
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
<a-select-option
v-for="(option, opId) in [...dateFieldOptions].filter((f) => {
const firstRange = dateFieldOptions.find((f) => f.value === calendarRange[0].fk_from_column_id)
return firstRange?.uidt === f.uidt
return firstRange?.uidt === f.uidt && f.value !== range.fk_from_column_id
})"
:key="opId"
:value="option.value"
>
<div class="flex items-center">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template>
{{ option.label }}
</NcTooltip>
<div class="w-full flex gap-2 items-center justify-between" :title="option.label">
<div class="flex items-center gap-1 max-w-[calc(100%_-_20px)]">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only>
<template #title>
{{ option.label }}
</template>
<template #default>{{ option.label }}</template>
</NcTooltip>
</div>
<GeneralIcon
v-if="option.value === range.fk_from_column_id"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
<NcButton class="!rounded-l-none !border-l-0" size="small" type="secondary" @click="saveCalendarRange(range, null)">
</a-select>
<NcButton
class="!rounded-l-none nc-select-shadow !border-l-0"
size="small"
type="secondary"
@click="saveCalendarRange(range, null)"
>
<component :is="iconMap.delete" class="h-4 w-4" />
</NcButton>
</div>
@ -271,7 +307,6 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
<NcButton v-if="id !== 0" size="small" type="secondary" @click="removeRange(id)">
<component :is="iconMap.close" />
</NcButton>
-->
</div>
<div v-if="!isSetup" class="flex items-center gap-2 !mt-2">
@ -280,9 +315,9 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
</div>
<div>
<NcSwitch v-model:checked="hideWeekends" :disabled="isLocked">
<span class="text-gray-800">
{{ $t('activity.hideWeekends') }}
<NcSwitch v-model:checked="showWeekends" :disabled="isLocked">
<span class="text-gray-800 font-semibold">
{{ $t('activity.showSaturdaysAndSundays') }}
</span>
</NcSwitch>
</div>
@ -296,7 +331,7 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
</NcDropdown>
</template>
<style lang="scss" scoped>
<style lang="scss">
.nc-to-select .ant-select-selector {
@apply !rounded-r-none;
}

7
packages/nc-gui/composables/useBetaFeatureToggle.ts

@ -43,6 +43,13 @@ const FEATURES = [
enabled: false,
isEngineering: true,
},
{
id: 'calendar_view_range',
title: 'Allow configuring End Date for Calendar View',
description: 'Enables the calendar to display items as date ranges by allowing configuration of both start and end dates. ',
enabled: false,
isEngineering: true,
},
]
export const FEATURE_FLAG = Object.fromEntries(FEATURES.map((feature) => [feature.id.toUpperCase(), feature.id])) as Record<

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

@ -1053,7 +1053,7 @@
"activity": {
"assignView": "Assign view",
"webhookDetails": "Webhook Details",
"hideWeekends": "Hide weekends",
"showSaturdaysAndSundays": "Show Saturdays & Sundays",
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
@ -1069,6 +1069,7 @@
"goToToday": "Go to Today",
"toggleSidebar": "Toggle Sidebar",
"addEndDate": "Add end date",
"endDate": "End Date",
"withEndDate": "with end date",
"calendar": "Calendar",
"viewSettings": "View settings",

1
packages/nc-gui/lib/types.ts

@ -102,6 +102,7 @@ interface Row {
overLapIteration?: number
numberOfOverlaps?: number
minutes?: number
recordIndex?: number // For week spanning records in month view
}
}

2
tests/playwright/pages/Dashboard/Calendar/CalendarMonth.ts

@ -32,7 +32,7 @@ export class CalendarMonthPage extends BasePage {
await this.rootPage.waitForTimeout(500);
await this.rootPage.mouse.move(cord.x + cord.width / 2, cord.y + cord.height / 2, { steps: 10 });
await this.rootPage.mouse.move(cord.x + cord.width, cord.y + cord.height, { steps: 10 });
await this.rootPage.mouse.up();
}

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

@ -335,6 +335,8 @@ test.describe('Calendar View', () => {
action: 'prev',
});
await calendar.dashboard.rootPage.waitForTimeout(1000);
await calendar.calendarWeekDateTime.dragAndDrop({
record: 'Team Catchup',
to: {

Loading…
Cancel
Save