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 enableDescription = ref(false)
const removeDescription = () => { const removeDescription = () => {
@ -977,111 +979,153 @@ const getPluralName = (name: string) => {
/> />
</a-form-item> </a-form-item>
<template v-if="form.type === ViewTypes.CALENDAR && !form.copy_from_id"> <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"> <div
<span class="text-gray-800"> v-for="(range, index) in form.calendar_range"
{{ $t('labels.organiseBy') }} :key="`range-${index}`"
</span> :class="{
<NcSelect '!gap-2': range.fk_to_column_id === null,
v-model:value="range.fk_from_column_id" }"
:disabled="isMetaLoading" class="flex flex-col w-full gap-6"
:loading="isMetaLoading" >
class="nc-select-shadow nc-from-select" <div class="w-full space-y-2">
> <div class="text-gray-800">
<a-select-option {{ $t('labels.organiseBy') }}
v-for="(option, id) in [...viewSelectFieldOptions!].filter((f) => { </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 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 the fk_from_column_id of first range is DateTime, then all the other ranges should be DateTime
if (index === 0) return true if (index === 0) return true
const firstRange = viewSelectFieldOptions!.find((f) => f.value === form.calendar_range[0].fk_from_column_id) const firstRange = viewSelectFieldOptions!.find((f) => f.value === form.calendar_range[0].fk_from_column_id)
return firstRange?.uidt === f.uidt 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" :key="id"
:value="option.value" :value="option.value"
> >
<div class="flex items-center"> <div class="w-full flex gap-2 items-center justify-between" :title="option.label">
<SmartsheetHeaderIcon :column="option" /> <div class="flex items-center gap-1 max-w-[calc(100%_-_20px)]">
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only> <SmartsheetHeaderIcon :column="option" />
<template #title>{{ option.label }}</template>
{{ option.label }} <NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only>
</NcTooltip> <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> </div>
</a-select-option> </a-select-option>
</NcSelect> </a-select>
<NcButton class="!rounded-l-none !border-l-0" size="small" type="secondary" @click="range.fk_to_column_id = null"> </div>
<component :is="iconMap.delete" class="h-4 w-4" /> <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> </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> </div>
<NcButton </div>
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 class="mt-2" size="small" type="secondary" @click="addCalendarRange"> <!-- <NcButton class="mt-2" size="small" type="secondary" @click="addCalendarRange">
<component :is="iconMap.plus" /> <component :is="iconMap.plus" />
Add another date field Add another date field
</NcButton> --> </NcButton> -->
</div>
<div <div
v-if="isCalendarReadonly(form.calendar_range)" v-if="isCalendarReadonly(form.calendar_range)"
@ -1429,7 +1473,6 @@ const getPluralName = (name: string) => {
.nc-input-text-area { .nc-input-text-area {
padding-block: 8px !important; padding-block: 8px !important;
} }
.ant-form-item-required { .ant-form-item-required {
@apply !text-gray-800 font-medium; @apply !text-gray-800 font-medium;
&:before { &: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 { .ant-form-item {
@apply !mb-0; @apply !mb-0;
} }
@ -1465,11 +1500,6 @@ const getPluralName = (name: string) => {
@apply content-[''] m-0; @apply content-[''] m-0;
} }
} }
:deep(.ant-select) {
.ant-select-selector {
@apply !rounded-lg;
}
}
.nc-nocoai-footer { .nc-nocoai-footer {
@apply px-6 py-1 flex items-center gap-2 text-nc-content-purple-dark border-t-1 border-purple-100; @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; @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> </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="plain-cell before:px-1"
:class="{ :class="{
'!font-bold': bold, '!font-bold': bold,
'italic': italic, '!italic': italic,
'underline': underline, 'underline': underline,
}" }"
data-testid="nc-plain-cell" data-testid="nc-plain-cell"
@ -372,6 +372,7 @@ const parseValue = (value: any, col: ColumnType): string => {
<style lang="scss" scoped> <style lang="scss" scoped>
.plain-cell { .plain-cell {
font-synthesis: initial !important;
&::before { &::before {
content: '•'; content: '•';
padding: 0 4px; 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> <script lang="ts" setup>
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { UseVirtualList } from '@vueuse/components'
const emit = defineEmits(['expandRecord', 'newRecord']) const emit = defineEmits(['expandRecord', 'newRecord'])
@ -53,11 +54,6 @@ const recordsAcrossAllRange = computed<Row[]>(() => {
dayRecordCount++ 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 // This property is used to determine which side the record should be rounded. It can be left, right, both or none
let position = 'none' let position = 'none'
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(selectedDate.value, 'day') const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(selectedDate.value, 'day')
@ -81,7 +77,6 @@ const recordsAcrossAllRange = computed<Row[]>(() => {
rowMeta: { rowMeta: {
...record.rowMeta, ...record.rowMeta,
position, position,
style,
range: range as any, range: range as any,
}, },
}) })
@ -94,11 +89,6 @@ const recordsAcrossAllRange = computed<Row[]>(() => {
rowMeta: { rowMeta: {
...record.rowMeta, ...record.rowMeta,
range: range as any, range: range as any,
style: {
width: '100%',
left: '0',
top: `${(dayRecordCount - 1) * perRecordHeight}px`,
},
position: 'rounded', position: 'rounded',
}, },
}) })
@ -203,43 +193,44 @@ const newRecord = () => {
<div <div
v-if="recordsAcrossAllRange.length" v-if="recordsAcrossAllRange.length"
ref="container" 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" data-testid="nc-calendar-day-view"
@dblclick="newRecord" @dblclick="newRecord"
@drop="dropEvent" @drop="dropEvent"
> >
<div <UseVirtualList height="calc(100vh - 5rem)" :list="recordsAcrossAllRange" :options="{ itemHeight: 36 }">
v-for="(record, rowIndex) in recordsAcrossAllRange" <template #default="{ data: record }">
:key="rowIndex" <div
:style="record.rowMeta.style" :key="record.rowMeta.id"
class="absolute mt-2" class="mt-2"
data-testid="nc-calendar-day-record-card" data-testid="nc-calendar-day-record-card"
@mouseleave="hoverRecord = null" @mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id as string" @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)"
> >
<template v-for="(field, id) in fields" :key="id"> <LazySmartsheetRow :row="record">
<LazySmartsheetPlainCell <LazySmartsheetCalendarRecordCard
v-if="!isRowEmpty(record, field!)" :record="record"
v-model="record.row[field!.title!]" :resize="false"
class="text-xs" :position="record.rowMeta.position"
:bold="getFieldStyle(field).bold" size="small"
:column="field" @click.prevent="emit('expandRecord', record)"
:italic="getFieldStyle(field).italic" >
:underline="getFieldStyle(field).underline" <template v-for="(field, id) in fields" :key="id">
/> <LazySmartsheetPlainCell
</template> v-if="!isRowEmpty(record, field!)"
</LazySmartsheetCalendarRecordCard> v-model="record.row[field!.title!]"
</LazySmartsheetRow> class="text-xs"
</div> :bold="getFieldStyle(field).bold"
:column="field"
:italic="getFieldStyle(field).italic"
:underline="getFieldStyle(field).underline"
/>
</template>
</LazySmartsheetCalendarRecordCard>
</LazySmartsheetRow>
</div>
</template>
</UseVirtualList>
</div> </div>
<div <div

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

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

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

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

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

@ -9,15 +9,16 @@ interface Props {
selected?: boolean selected?: boolean
size?: 'small' | 'medium' | 'large' | 'auto' size?: 'small' | 'medium' | 'large' | 'auto'
position?: 'leftRounded' | 'rightRounded' | 'rounded' | 'none' position?: 'leftRounded' | 'rightRounded' | 'rounded' | 'none'
dragging?: boolean
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
resize: true, resize: true,
selected: false,
hover: false, hover: false,
color: 'blue', color: 'gray',
size: 'small', size: 'small',
position: 'rounded', position: 'rounded',
dragging: false,
}) })
const emit = defineEmits(['resize-start']) const emit = defineEmits(['resize-start'])
@ -26,23 +27,27 @@ const emit = defineEmits(['resize-start'])
<template> <template>
<div <div
:class="{ :class="{
'h-6': size === 'small', 'h-7': size === 'small',
'h-full': size === 'auto', 'h-full': size === 'auto',
'rounded-l-md ml-1': position === 'leftRounded', 'rounded-l-[4px] !border-r-0 ml-1': position === 'leftRounded',
'rounded-r-md mr-1': position === 'rightRounded', 'rounded-r-[4px] !border-l-0 mr-1': position === 'rightRounded',
'rounded-md mx-1': position === 'rounded', 'rounded-[4px] mx-1': position === 'rounded',
'rounded-none': position === 'none', 'rounded-none !border-x-0': position === 'none',
'bg-maroon-50': color === 'maroon', 'bg-maroon-50': color === 'maroon',
'bg-blue-50': color === 'blue', 'bg-blue-50': color === 'blue',
'bg-green-50': color === 'green', 'bg-green-50': color === 'green',
'bg-yellow-50': color === 'yellow', 'bg-yellow-50': color === 'yellow',
'bg-pink-50': color === 'pink', 'bg-pink-50': color === 'pink',
'bg-purple-50': color === 'purple', 'bg-purple-50': color === 'purple',
'group-hover:(border-brand-500 border-1)': resize, 'bg-white border-gray-300': color === 'gray',
'!border-blue-200 border-1': selected,
'shadow-md': hover,
}" }"
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 <div
v-if="position === 'leftRounded' || position === 'rounded'" v-if="position === 'leftRounded' || position === 'rounded'"
@ -53,23 +58,16 @@ const emit = defineEmits(['resize-start'])
'bg-yellow-500': color === 'yellow', 'bg-yellow-500': color === 'yellow',
'bg-pink-500': color === 'pink', 'bg-pink-500': color === 'pink',
'bg-purple-500': color === 'purple', '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>
<div v-if="(position === 'leftRounded' || position === 'rounded') && resize" class="mt-0.7 h-7.1 absolute -left-4 resize"> <div
<NcButton v-if="(position === 'leftRounded' || position === 'rounded') && resize"
:class="{ class="mt-0.7 w-2 h-7.1 -left-1 absolute resize"
'!block z-2 !border-brand-500': selected || hover, @mousedown.stop="emit('resize-start', 'left', $event, record)"
'!hidden': !selected && !hover, ></div>
}"
size="xsmall"
type="secondary"
@mousedown.stop="emit('resize-start', 'left', $event, record)"
>
<component :is="iconMap.drag" class="text-gray-400"></component>
</NcButton>
</div>
<div class="overflow-hidden items-center justify-center gap-2 flex w-full"> <div class="overflow-hidden items-center justify-center gap-2 flex w-full">
<span v-if="position === 'rightRounded' || position === 'none'" class="ml-2"> .... </span> <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" 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" /> <slot class="text-sm text-nowrap text-gray-800 leading-7" />
<template #title> <template #title>
<slot /> <slot />
</template> </template>
</NcTooltip> </NcTooltip>
</div> </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>
<div v-if="(position === 'rightRounded' || position === 'rounded') && resize" class="absolute mt-0.3 -right-4 resize"> <div
<NcButton v-if="(position === 'rightRounded' || position === 'rounded') && resize"
:class="{ class="absolute mt-0.3 h-7.1 w-2 right-0 resize"
'!block !border-brand-500 z-2': selected || hover, @mousedown.stop="emit('resize-start', 'right', $event, record)"
'!hidden': !selected && !hover, ></div>
}"
size="xsmall"
type="secondary"
@mousedown.stop="emit('resize-start', 'right', $event, record)"
>
<component :is="iconMap.drag" class="text-gray-400"></component>
</NcButton>
</div>
</div> </div>
</template> </template>

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

@ -346,7 +346,8 @@ onClickOutside(searchRef, toggleSearch)
'right-2': !showSideMenu, 'right-2': !showSideMenu,
'right-74': 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 hide-on-click
> >
<template #title> {{ $t('activity.toggleSidebar') }}</template> <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') : dayjs(record.row[record.rowMeta.range!.fk_to_col.title!]).format('DD MMM • HH:mm A')
: null : null
" "
color="blue"
data-testid="nc-sidebar-record-card" data-testid="nc-sidebar-record-card"
@click="emit('expandRecord', record)" @click="emit('expandRecord', record)"
@dragstart="dragStart($event, 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>(), { const props = withDefaults(defineProps<Props>(), {
fromDate: '', fromDate: '',
color: 'blue', color: 'gray',
showDate: true, showDate: true,
invalid: false, invalid: false,
}) })
</script> </script>
<template> <template>
<div class="border-1 cursor-pointer h-14 border-gray-200 flex gap-2 items-center rounded-lg"> <div class="border-1 cursor-pointer h-12.5 border-gray-200 flex gap-2 flex-col rounded-lg">
<div class="flex items-center pl-2 gap-2"> <div class="flex relative items-center gap-2">
<span <span
:class="{ :class="{
'bg-maroon-500': props.color === 'maroon', 'bg-maroon-500': props.color === 'maroon',
@ -26,27 +26,33 @@ const props = withDefaults(defineProps<Props>(), {
'bg-yellow-500': props.color === 'yellow', 'bg-yellow-500': props.color === 'yellow',
'bg-pink-500': props.color === 'pink', 'bg-pink-500': props.color === 'pink',
'bg-purple-500': props.color === 'purple', '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> ></span>
<slot name="image" /> <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"> <span class="text-[13px] leading-4 max-w-56 font-medium truncate text-gray-800">
<slot /> <slot />
</span> </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" <span v-if="showDate" class="text-xs font-medium leading-4 text-gray-600"
>{{ fromDate }} {{ toDate ? ` - ${toDate}` : '' }}</span >{{ fromDate }} {{ toDate ? ` - ${toDate}` : '' }}</span
> >
</div> </div>
</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> </div>
</template> </template>

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

@ -5,15 +5,15 @@ interface Props {
resize?: boolean resize?: boolean
selected?: boolean selected?: boolean
hover?: boolean hover?: boolean
position?: 'topRounded' | 'bottomRounded' | 'rounded' | 'none' dragging?: boolean
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
resize: true, resize: true,
selected: false, selected: false,
hover: false, hover: false,
color: 'blue', color: 'gray',
position: 'rounded', dragging: false,
}) })
const emit = defineEmits(['resize-start']) const emit = defineEmits(['resize-start'])
@ -21,85 +21,70 @@ const emit = defineEmits(['resize-start'])
<template> <template>
<div <div
v-if="(position === 'topRounded' || position === 'rounded') && resize" :style="{
class="absolute flex items-center justify-center w-full -mt-4 h-7.1 hidden z-1 resize !group-hover:(flex rounded-lg)" boxShadow:
> hover || dragging
<NcButton ? '0px 12px 16px -4px rgba(0, 0, 0, 0.10), 0px 4px 6px -2px rgba(0, 0, 0, 0.06)'
:class="{ : '0px 2px 4px -2px rgba(0, 0, 0, 0.06), 0px 4px 4px -2px rgba(0, 0, 0, 0.02)',
'!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
:class="{ :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-maroon-50': color === 'maroon',
'bg-blue-50': color === 'blue', 'bg-blue-50': color === 'blue',
'bg-green-50': color === 'green', 'bg-green-50': color === 'green',
'bg-yellow-50': color === 'yellow', 'bg-yellow-50': color === 'yellow',
'bg-pink-50': color === 'pink', 'bg-pink-50': color === 'pink',
'bg-purple-50': color === 'purple', 'bg-purple-50': color === 'purple',
'group-hover:(border-brand-500)': resize, 'bg-white border-gray-300': color === 'gray',
'!border-blue-200 border-1': selected, 'z-90': hover,
'shadow-md': 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 <div
class="flex overflow-x-hidden break-word whitespace-nowrap overflow-hidden text-ellipsis w-full truncate text-ellipsis flex-col gap-1" v-if="resize"
> class="absolute w-full h-1 z-20 top-0 cursor-row-resize"
<NcTooltip :disabled="selected" class="inline-block" show-on-truncate-only wrap-child="span"> @mousedown.stop="emit('resize-start', 'left', $event, record)"
<slot class="pl-1 pr-2 text-sm text-nowrap text-gray-800 leading-7" /> ></div>
<template #title> <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 /> <slot />
</template> </NcTooltip>
</NcTooltip> </div>
<slot name="time" /> <slot name="time" />
</div> </div>
<div
<div v-if="position === 'topRounded' || position === 'none'" class="h-full pb-7 flex items-end ml-3">....</div> v-if="resize"
</div> class="absolute cursor-row-resize w-full bottom-0 w-full h-1"
<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"
@mousedown.stop="emit('resize-start', 'right', $event, record)" @mousedown.stop="emit('resize-start', 'right', $event, record)"
> ></div>
<component :is="iconMap.drag" class="mt-0.5" />
</NcButton>
</div> </div>
</template> </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 isInRange = (date: dayjs.Dayjs) => {
const rangeEndDate =
maxVisibleDays.value === 5 ? dayjs(selectedDateRange.value.end).subtract(2, 'day') : dayjs(selectedDateRange.value.end)
return ( return (
date && date && date.isBetween(dayjs(selectedDateRange.value.start).startOf('day'), dayjs(rangeEndDate).endOf('day'), 'day', '[]')
date.isBetween(
dayjs(selectedDateRange.value.start).startOf('day'),
dayjs(selectedDateRange.value.end).endOf('day'),
'day',
'[]',
)
) )
} }
const calendarData = computed(() => { const calendarData = computed(() => {
if (!formattedData.value || !calendarRange.value) return [] if (!formattedData.value || !calendarRange.value) return []
// We use the recordsInDay object to keep track of which columns are occupied for each day const recordsInDay = Array.from({ length: 7 }, () => ({})) as Record<number, Record<number, boolean>>
// This is used to calculate the position of the records in the calendar const recordsInRange = [] as Row[]
// 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 perDayWidth = containerWidth.value / maxVisibleDays.value const perDayWidth = containerWidth.value / maxVisibleDays.value
calendarRange.value.forEach((range) => { calendarRange.value.forEach(({ fk_from_col, fk_to_col }) => {
const fromCol = range.fk_from_col if (!fk_from_col) return
const toCol = range.fk_to_col
if (fromCol && toCol) { const processRecord = (record: Row) => {
// Filter out records that have an invalid date range const id = record.rowMeta.id ?? generateRandomNumber()
// i.e. the end date is before the start date let startDate = dayjs(record.row[fk_from_col.title!])
const ogStartDate = startDate.clone()
for (const record of [...formattedData.value].filter((r) => { const endDate = fk_to_col ? dayjs(record.row[fk_to_col.title!]) : startDate
const startDate = dayjs(r.row[fromCol.title!])
const endDate = dayjs(r.row[toCol.title!]) if (startDate.isBefore(selectedDateRange.value.start)) {
if (!startDate.isValid() || !endDate.isValid()) return false startDate = dayjs(selectedDateRange.value.start)
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`,
},
},
})
} }
} else if (fromCol) {
for (const record of formattedData.value) { const startDaysDiff = startDate.diff(selectedDateRange.value.start, 'day')
const id = record.rowMeta.id ?? generateRandomNumber() const spanDays = fk_to_col
? Math.min(Math.max(endDate.diff(startDate, 'day') + 1, 1), maxVisibleDays.value - startDaysDiff)
const startDate = dayjs(record.row[fromCol.title!]) : 1
const startDaysDiff = Math.max(startDate.diff(selectedDateRange.value.start, 'day'), 0)
const suitableRow = findFirstSuitableRow(recordsInDay, startDaysDiff, spanDays)
// 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) for (let i = 0; i < spanDays; i++) {
recordsInDay[startDaysDiff][suitableRow] = true const dayIndex = startDaysDiff + i
if (!recordsInDay[dayIndex]) recordsInDay[dayIndex] = {}
recordsInRange.push({ recordsInDay[dayIndex][suitableRow] = true
...record, }
rowMeta: {
...record.rowMeta, const isStartInRange = isInRange(ogStartDate)
range: range as any, const isEndInRange = isInRange(endDate)
id,
position: 'rounded', let position = 'none'
style: { if (isStartInRange && isEndInRange) position = 'rounded'
width: `calc(${perDayWidth}px)`, else if (isStartInRange) position = 'leftRounded'
left: `${startDaysDiff * perDayWidth}px`, else if (isEndInRange) position = 'rightRounded'
top: `${suitableRow * 28}px`,
}, 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) 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 // 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 calculateNewRow = (event: MouseEvent, updateSideBarData?: boolean) => {
const { width, left } = container.value.getBoundingClientRect() const { width, left } = container.value.getBoundingClientRect()
// Calculate the percentage of the width based on the mouse position // Calculate the percentage of the width based on the mouse position
// This is used to calculate the day index // 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 fromCol = dragRecord.value.rowMeta.range?.fk_from_col
const toCol = dragRecord.value.rowMeta.range?.fk_to_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 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') const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => { allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) { if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
@ -546,6 +474,12 @@ const dropEvent = (event: DragEvent) => {
dragElement.value = null dragElement.value = null
} }
updateRowProperty(newRow, updateProperty, false) updateRowProperty(newRow, updateProperty, false)
dragOffset.value = {
x: null,
y: null,
}
$e('c:calendar:day:drag-record') $e('c:calendar:day:drag-record')
} }
} }
@ -576,7 +510,7 @@ const addRecord = (date: dayjs.Dayjs) => {
v-for="(date, weekIndex) in weekDates" v-for="(date, weekIndex) in weekDates"
:key="weekIndex" :key="weekIndex"
:class="{ :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/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7, 'w-1/7': maxVisibleDays === 7,
}" }"
@ -587,12 +521,12 @@ const addRecord = (date: dayjs.Dayjs) => {
{{ dayjs(date).format('DD ddd') }} {{ dayjs(date).format('DD ddd') }}
</div> </div>
</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 <div
v-for="(date, dateIndex) in weekDates" v-for="(date, dateIndex) in weekDates"
:key="dateIndex" :key="dateIndex"
:class="{ :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, '!bg-gray-50': date.get('day') === 0 || date.get('day') === 6,
'w-1/5': maxVisibleDays === 5, 'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7, 'w-1/7': maxVisibleDays === 7,
@ -604,7 +538,7 @@ const addRecord = (date: dayjs.Dayjs) => {
></div> ></div>
</div> </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" data-testid="nc-calendar-week-record-container"
> >
<template v-for="(record, id) in calendarData" :key="id"> <template v-for="(record, id) in calendarData" :key="id">
@ -622,12 +556,11 @@ const addRecord = (date: dayjs.Dayjs) => {
> >
<LazySmartsheetRow :row="record"> <LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard <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" :position="record.rowMeta.position"
:record="record" :record="record"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')" :resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
:selected="dragRecord?.rowMeta?.id === record.rowMeta.id"
color="blue"
@dblclick.stop="emits('expandRecord', record)" @dblclick.stop="emits('expandRecord', record)"
@resize-start="onResizeStart" @resize-start="onResizeStart"
> >
@ -656,4 +589,24 @@ const addRecord = (date: dayjs.Dayjs) => {
-ms-user-select: none; -ms-user-select: none;
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> </style>

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

@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib/types' import type { Row } from '~/lib/types'
const emits = defineEmits(['expandRecord', 'newRecord']) const emits = defineEmits(['expandRecord', 'newRecord'])
@ -39,17 +38,14 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow() const { fields: _fields } = useViewColumnsOrThrow()
const fieldStyles = computed(() => { const fieldStyles = computed(() => {
if (!_fields.value) return new Map() return (_fields.value ?? []).reduce((acc, field) => {
return new Map( acc[field.fk_column_id!] = {
_fields.value.map((field) => [ bold: !!field.bold,
field.fk_column_id, italic: !!field.italic,
{ underline: !!field.underline,
underline: field.underline, }
bold: field.bold, return acc
italic: field.italic, }, {} as Record<string, { bold?: boolean; italic?: boolean; underline?: boolean }>)
},
]),
)
}) })
const getDayIndex = (date: dayjs.Dayjs) => { const getDayIndex = (date: dayjs.Dayjs) => {
@ -96,9 +92,36 @@ onMounted(() => {
}) })
}) })
const getFieldStyle = (field: ColumnType) => { const calculateHourIndices = (dayIndex: number, startDate: dayjs.Dayjs, endDate: dayjs.Dayjs) => {
return fieldStyles.value.get(field.id) // 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( const calculateNewDates = useMemoize(
({ ({
startDate, startDate,
@ -116,6 +139,10 @@ const calculateNewDates = useMemoize(
endDate = startDate.clone().add(15, 'minutes') 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 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 // 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 // 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 } 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 // 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 = computed(() => {
const datesHours: Array<Array<dayjs.Dayjs>> = [] const start = dayjs(selectedDateRange.value.start).startOf('week')
let startOfWeek = dayjs(selectedDateRange.value.start) ?? dayjs().startOf('week') return Array.from({ length: maxVisibleDays.value }, (_, i) =>
let endOfWeek = dayjs(selectedDateRange.value.end) ?? dayjs().endOf('week') Array.from({ length: 24 }, (_, h) => start.add(i, 'day').hour(h).minute(0).second(0)),
)
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 getGridTime = (date: dayjs.Dayjs, round = false) => { const getGridTime = (date: dayjs.Dayjs, round = false) => {
const gridCalc = date.hour() * 60 + date.minute() const minutes = date.hour() * 60 + date.minute()
if (round) { return round ? Math.ceil(minutes) : Math.floor(minutes)
return Math.ceil(gridCalc)
} else {
return Math.floor(gridCalc)
}
} }
const getGridTimeSlots = (from: dayjs.Dayjs, to: dayjs.Dayjs) => { const getGridTimeSlots = (from: dayjs.Dayjs, to: dayjs.Dayjs) => ({
return { from: getGridTime(from),
from: getGridTime(from, false), to: getGridTime(to, true) - 1,
to: getGridTime(to, true) - 1, dayIndex: getDayIndex(from),
dayIndex: getDayIndex(from), })
}
}
const hasSlotForRecord = ( const hasSlotForRecord = (
columnArray: Row[], columnArray: Row[],
@ -248,7 +246,6 @@ const getMaxOverlaps = ({
if (graph.has(id)) { if (graph.has(id)) {
dfs(id) dfs(id)
} }
const overlapIterations: Array<number> = [] const overlapIterations: Array<number> = []
columnArray[dayIndex] columnArray[dayIndex]
@ -258,11 +255,25 @@ const getMaxOverlaps = ({
overlapIterations.push(record.rowMeta.overLapIteration!) overlapIterations.push(record.rowMeta.overLapIteration!)
}) })
maxOverlaps = Math.max(...overlapIterations) maxOverlaps = overlapIterations?.length > 0 ? Math.max(...overlapIterations) : 1
return { maxOverlaps, dayIndex, overlapIndex } 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<{ const recordsAcrossAllRange = computed<{
records: Array<Row> records: Array<Row>
gridTimeMap: Map< gridTimeMap: Map<
@ -275,11 +286,13 @@ const recordsAcrossAllRange = computed<{
} }
> >
> >
spanningRecords: Row[]
}>(() => { }>(() => {
if (!formattedData.value || !calendarRange.value || !container.value || !scrollContainer.value) if (!formattedData.value || !calendarRange.value || !container.value || !scrollContainer.value)
return { return {
records: [], records: [],
gridTimeMap: new Map(), gridTimeMap: new Map(),
spanningRecords: [],
} }
const perWidth = containerWidth.value / maxVisibleDays.value const perWidth = containerWidth.value / maxVisibleDays.value
const perHeight = 52 const perHeight = 52
@ -303,6 +316,7 @@ const recordsAcrossAllRange = computed<{
> >
>() >()
const recordsToDisplay: Array<Row> = [] const recordsToDisplay: Array<Row> = []
const recordSpanningDays: Array<Row> = []
calendarRange.value.forEach((range) => { calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col 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 // But not all fetched records are valid for the certain range, so we filter them out & sort them
const sortedFormattedData = [...formattedData.value] const sortedFormattedData = [...formattedData.value]
.filter((record) => { .filter((record) => {
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
if (fromCol && toCol) { if (fromCol && toCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null const fromDate = dayjs(record.row[fromCol.title!])
const toDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null const toDate = dayjs(record.row[toCol.title!])
return fromDate && toDate && !toDate.isBefore(fromDate) if (fromDate.isValid() && toDate.isValid()) {
} else if (fromCol && !toCol) { const isMultiDay = !fromDate.isSame(toDate, 'day')
return !!fromDate if (isMultiDay) {
recordSpanningDays.push(record)
return false
}
return true
}
} }
return false return fromCol && !!record.row[fromCol.title!]
})
.sort((a, b) => {
const aDate = dayjs(a.row[fromCol!.title!])
const bDate = dayjs(b.row[fromCol!.title!])
return aDate.isBefore(bDate) ? 1 : -1
}) })
.sort((a, b) => (dayjs(a.row[fromCol!.title!]).isBefore(dayjs(b.row[fromCol!.title!])) ? 1 : -1))
for (const record of sortedFormattedData) { for (const record of sortedFormattedData) {
const id = record.rowMeta.id ?? generateRandomNumber() const id = record.rowMeta.id ?? generateRandomNumber()
@ -340,73 +353,34 @@ const recordsAcrossAllRange = computed<{
scheduleStart, scheduleStart,
scheduleEnd, scheduleEnd,
}) })
const dayIndex = getDayIndex(startDate)
// Setting the current start date to the start date of the record const { startHourIndex, startMinutes } = calculateHourIndices(dayIndex, startDate, endDate)
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 top = startHourIndex * perHeight let style: Partial<CSSStyleDeclaration> = {}
const height = (endHourIndex - startHourIndex + 1) * perHeight - spanHours - 5 const top = (startHourIndex + startMinutes / 60) * perHeight
style = { const totalHours = endDate.diff(startDate, 'minute') / 60
...style, const height = totalHours * perHeight
top: `${top}px`,
height: `${height}px`,
}
recordsToDisplay.push({ style = {
...record, ...style,
rowMeta: { top: `${top}px`,
...record.rowMeta, height: `${height}px`,
id,
style,
range,
position,
dayIndex,
},
})
// We set the current start date to the next day
currentStartDate = currentStartDate.add(1, 'day').hour(0).minute(0)
} }
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
id,
style,
range,
position: 'rounded',
dayIndex,
},
})
} else if (fromCol) { } else if (fromCol) {
// If there is no toColumn chosen in the range // If there is no toColumn chosen in the range
const { startDate } = calculateNewDates({ const { startDate } = calculateNewDates({
@ -563,18 +537,38 @@ const recordsAcrossAllRange = computed<{
let width = 0 let width = 0
let left = 100 let left = 100
const majorLeft = dayIndex * perWidth const majorLeft = dayIndex * perWidth
if (record.rowMeta.overLapIteration! - 1 > 2) { const isRecordDraggingOrResizeState =
display = 'none' 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 { } else {
width = 100 / Math.min(maxOverlaps, 3) / maxVisibleDays.value left = majorLeft + 4
left = width * (overlapIndex - 1) width = perWidth - 8
} }
record.rowMeta.style = { record.rowMeta.style = {
...record.rowMeta.style, ...record.rowMeta.style,
left: `calc(${majorLeft}px + ${left}%)`, left: `${left}px`,
width: `calc(${width}%)`, width: `${width}px`,
minWidth: '72px',
display, display,
} }
} }
@ -583,108 +577,88 @@ const recordsAcrossAllRange = computed<{
return { return {
records: recordsToDisplay, records: recordsToDisplay,
gridTimeMap, 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) => { const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => {
updateRowProperty(row, updateProperty, isDelete) updateRowProperty(row, updateProperty, isDelete)
}, 500) }, 500)
const onResize = (event: MouseEvent) => { const onResize = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !container.value || !resizeRecord.value || !scrollContainer.value) return if (!isUIAllowed('dataEdit') || !container.value || !resizeRecord.value || !scrollContainer.value) return
if (resizeRecord.value.rowMeta.range?.is_readonly) return if (resizeRecord.value.rowMeta.range?.is_readonly) return
const { width, left, top, bottom } = container.value.getBoundingClientRect() const { width, left, top, bottom } = container.value.getBoundingClientRect()
const { scrollHeight, scrollTop } = container.value
const { scrollHeight } = container.value
// If the mouse is near the bottom of the container, we scroll down // 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) { if (event.clientY > bottom - 20) {
container.value.scrollTop += 10 container.value.scrollTop += 10
} else if (event.clientY < top + 20) { } else if (event.clientY < top + 20) {
container.value.scrollTop -= 10 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 percentX = (event.clientX - left - window.scrollX) / width
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight const percentY = (event.clientY - top + scrollTop) / scrollHeight
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col
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 day = Math.floor(percentX * maxVisibleDays.value)
const hour = Math.floor(percentY * 23) const minutes = Math.round((percentY * 24 * 60) / 15) * 15 // Round to nearest 15 minutes
const minutes = Math.round((percentY * 24 * 60) % 60)
let updateProperty: string[] = [] const baseDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(minutes, 'minute')
let newRow: Row = resizeRecord.value
if (resizeDirection.value === 'right') { let newDate: dayjs.Dayjs
let newEndDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour').add(minutes, 'minute') let updateProperty: string
updateProperty = [toCol.title!] let isValid = true
// 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()
}
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 = { if (newDate.isBefore(minEndDate)) {
...resizeRecord.value, newDate = minEndDate
row: {
...resizeRecord.value.row,
[toCol.title!]: newEndDate.format(updateFormat.value),
},
} }
updateProperty = toCol.title
} else if (resizeDirection.value === 'left') { } else if (resizeDirection.value === 'left') {
let newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour').add(minutes, 'minute') const minStartDate = ogEndDate.subtract(1, 'hour')
updateProperty = [fromCol.title!] 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 (newDate.isAfter(minStartDate)) {
if (dayjs(newStartDate).isAfter(ogEndDate)) { newDate = minStartDate
newStartDate = dayjs(dayjs(ogEndDate)).clone()
}
if (!newStartDate) return
newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format(updateFormat.value),
},
} }
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!) const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
formattedData.value = formattedData.value.map((r) => { formattedData.value = formattedData.value.map((r) => (extractPkFromRow(r.row, meta.value!.columns!) === newPk ? newRow : r))
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r useDebouncedRowUpdate(newRow, [updateProperty], false)
})
useDebouncedRowUpdate(newRow, updateProperty, false)
} }
const onResizeEnd = () => { const onResizeEnd = () => {
@ -712,18 +686,18 @@ const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: R
const calculateNewRow = ( const calculateNewRow = (
event: MouseEvent, event: MouseEvent,
updateSideBar?: boolean, updateSideBar?: boolean,
skipChangeCheck?: boolean,
): { ): {
newRow: Row | null newRow: Row | null
updatedProperty: string[] updatedProperty: string[]
skipChangeCheck?: boolean skipChangeCheck?: boolean
} => { } => {
if (!container.value) return { newRow: null, updatedProperty: [] }
const { width, left, top } = container.value.getBoundingClientRect() 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 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 fromCol = dragRecord.value.rowMeta.range?.fk_from_col
const toCol = dragRecord.value.rowMeta.range?.fk_to_col const toCol = dragRecord.value.rowMeta.range?.fk_to_col
@ -751,10 +725,11 @@ const calculateNewRow = (
if (toCol) { if (toCol) {
const fromDate = dragRecord.value.row[fromCol.title!] ? dayjs(dragRecord.value.row[fromCol.title!]) : null 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) { 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) { } else if (fromDate && !toDate) {
endDate = dayjs(newStartDate).endOf('day') endDate = dayjs(newStartDate).endOf('day')
} else if (!fromDate && toDate) { } else if (!fromDate && toDate) {
@ -763,15 +738,14 @@ const calculateNewRow = (
endDate = newStartDate.clone() endDate = newStartDate.clone()
} }
if (endDate.isBefore(newStartDate)) {
endDate = newStartDate.clone().add(15, 'minutes')
}
newRow.row[toCol.title!] = dayjs(endDate).format(updateFormat.value) newRow.row[toCol.title!] = dayjs(endDate).format(updateFormat.value)
updatedProperty.push(toCol.title!) 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: [] } if (!newRow) return { newRow: null, updatedProperty: [] }
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
@ -819,16 +793,12 @@ const stopDrag = (event: MouseEvent) => {
const { newRow, updatedProperty } = calculateNewRow(event, false, true) 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) { if (newRow) {
updateRowProperty(newRow, updatedProperty, false) updateRowProperty(newRow, updatedProperty, false)
} }
dragRecord.value = null
$e('c:calendar:week:drag-record') $e('c:calendar:week:drag-record')
document.removeEventListener('mousemove', onDrag) document.removeEventListener('mousemove', onDrag)
@ -850,14 +820,6 @@ const dragStart = (event: MouseEvent, record: Row) => {
target = target.parentElement as HTMLElement 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 isDragging.value = true
dragRecord.value = record dragRecord.value = record
@ -946,18 +908,42 @@ watch(
}, },
{ immediate: true }, { 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> </script>
<template> <template>
<div <div
ref="scrollContainer" 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" data-testid="nc-calendar-week-view"
@drop="dropEvent" @drop="dropEvent"
> >
<div <div
v-if="!isPublic && dayjs().isBetween(selectedDateRange.start, selectedDateRange.end)" 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" :style="overlayStyle"
> >
<div class="flex w-full items-center"> <div class="flex w-full items-center">
@ -970,8 +956,7 @@ watch(
<div class="flex-1 border-b-1 border-brand-500"></div> <div class="flex-1 border-b-1 border-brand-500"></div>
</div> </div>
</div> </div>
<div class="flex sticky h-6 z-3 top-0 pl-16 bg-gray-50 w-full">
<div class="flex sticky h-6 z-1 top-0 pl-16 bg-gray-50 w-full">
<div <div
v-for="date in datesHours" v-for="date in datesHours"
:key="date[0].toISOString()" :key="date[0].toISOString()"
@ -985,16 +970,41 @@ watch(
{{ dayjs(date[0]).format('DD ddd') }} {{ dayjs(date[0]).format('DD ddd') }}
</div> </div>
</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 <div
v-for="(hour, index) in datesHours[0]" v-for="(hour, index) in datesHours[0]"
:key="index" :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') }} {{ hour.format('hh a') }}
</div> </div>
</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 <div
v-for="(date, index) in datesHours" v-for="(date, index) in datesHours"
:key="index" :key="index"
@ -1011,7 +1021,7 @@ watch(
:class="{ :class="{
'border-1 !border-brand-500 !bg-gray-100': 'border-1 !border-brand-500 !bg-gray-100':
hour.isSame(selectedTime, 'hour') && (hour.get('day') === 6 || hour.get('day') === 0), 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, '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, 'hover:bg-gray-50': hour.get('day') !== 0 && hour.get('day') !== 6,
}" }"
@ -1022,14 +1032,14 @@ watch(
() => { () => {
selectedDate = hour selectedDate = hour
selectedTime = hour selectedTime = hour
dragRecord = undefined dragRecord = null
} }
" "
> >
<NcButton <NcButton
v-if="isOverflowAcrossHourRange(hour).isOverflow" v-if="isOverflowAcrossHourRange(hour).isOverflow"
v-e="`['c:calendar:week-view-more']`" 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" size="xxsmall"
type="secondary" type="secondary"
@click="viewMore(hour)" @click="viewMore(hour)"
@ -1043,13 +1053,23 @@ watch(
</div> </div>
</div> </div>
<div class="absolute pointer-events-none inset-0 overflow-hidden !mt-5.95" data-testid="nc-calendar-week-record-container"> <div
<template v-for="(record, rowIndex) in recordsAcrossAllRange.records" :key="rowIndex"> 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 <div
v-if="record.rowMeta.style?.display !== 'none'" v-if="record.rowMeta.style?.display !== 'none'"
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`" :data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta!.id" :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="{ :class="{
'w-1/5': maxVisibleDays === 5, 'w-1/5': maxVisibleDays === 5,
'w-1/7': maxVisibleDays === 7, 'w-1/7': maxVisibleDays === 7,
@ -1062,11 +1082,11 @@ watch(
> >
<LazySmartsheetRow :row="record"> <LazySmartsheetRow :row="record">
<LazySmartsheetCalendarVRecordCard <LazySmartsheetCalendarVRecordCard
:hover="hoverRecord === record.rowMeta.id || record.rowMeta.id === dragRecord?.rowMeta?.id" :hover="hoverRecord === record.rowMeta.id"
:position="record.rowMeta!.position" :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')" :resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
:record="record" :record="record"
color="blue"
:selected="record.rowMeta!.id === dragRecord?.rowMeta?.id" :selected="record.rowMeta!.id === dragRecord?.rowMeta?.id"
@resize-start="onResizeStart" @resize-start="onResizeStart"
> >
@ -1075,10 +1095,10 @@ watch(
v-if="!isRowEmpty(record, field!)" v-if="!isRowEmpty(record, field!)"
v-model="record.row[field!.title!]" v-model="record.row[field!.title!]"
class="text-xs" class="text-xs"
:bold="getFieldStyle(field).bold"
:column="field" :column="field"
:italic="getFieldStyle(field).italic" :bold="!!fieldStyles[field.id]?.bold"
:underline="getFieldStyle(field).underline" :italic="!!fieldStyles[field.id]?.italic"
:underline="!!fieldStyles[field.id]?.underline"
/> />
</template> </template>
<template #time> <template #time>
@ -1101,4 +1121,14 @@ watch(
-ms-user-select: none; -ms-user-select: none;
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> </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) changeCalendarView(mode)
const tabElement = event.target as HTMLElement const tabElement = event.target as HTMLElement
highlightStyle.value.left = `${tabElement.offsetLeft}px` highlightStyle.value.left = `${tabElement.offsetLeft}px`
highlightStyle.value.width = `${tabElement.offsetWidth}px`
} }
const updateHighlightPosition = () => { const updateHighlightPosition = () => {
@ -20,6 +21,7 @@ const updateHighlightPosition = () => {
const activeTab = document.querySelector('.nc-calendar-mode-tab .tab.active') as HTMLElement const activeTab = document.querySelector('.nc-calendar-mode-tab .tab.active') as HTMLElement
if (activeTab) { if (activeTab) {
highlightStyle.value.left = `${activeTab.offsetLeft}px` highlightStyle.value.left = `${activeTab.offsetLeft}px`
highlightStyle.value.width = `${activeTab.offsetWidth}px`
} }
}) })
} }
@ -37,53 +39,57 @@ watch(activeCalendarView, () => {
<template> <template>
<div <div
v-if="isTab" 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" data-testid="nc-calendar-view-mode"
> >
<div :style="highlightStyle" class="highlight"></div> <div class="flex flex-row">
<div <div :style="highlightStyle" class="highlight h-0.5 rounded-t-md absolute transition-all -bottom-2.5 bg-brand-500"></div>
v-for="mode in ['day', 'week', 'month', 'year']" <div
:key="mode" v-for="mode in ['day', 'week', 'month', 'year']"
:class="{ active: activeCalendarView === mode }" :key="mode"
:data-testid="`nc-calendar-view-mode-${mode}`" :class="{ '!text-brand-500 !font-bold bg-transparent active': activeCalendarView === mode }"
class="tab" :data-testid="`nc-calendar-view-mode-${mode}`"
@click="setActiveCalendarMode(mode, $event)" 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="tab-title !text-xs nc-tab">{{ $t(`objects.${mode}`) }}</div> >
<div class="min-w-0 pointer-events-none !text-[13px]]">{{ $t(`objects.${mode}`) }}</div>
</div>
</div> </div>
</div> </div>
<NcSelect v-else v-model:value="activeCalendarView" class="!w-21" data-testid="nc-calendar-view-mode" size="small"> <a-select
<a-select-option v-for="option in ['day', 'week', 'month', 'year']" :key="option" :value="option" class="!h-7 !w-21"> v-else
<div class="flex gap-2 mt-0.5 items-center"> v-model:value="activeCalendarView"
<NcTooltip class="!capitalize flex-1 max-w-21" placement="top" show-on-truncate-only> class="nc-select-shadow !w-21 !rounded-lg"
<template #title> dropdown-class-name="!rounded-lg"
<span class="capitalize min-w-21"> size="small"
{{ option }} data-testid="nc-calendar-view-mode"
</span> @click.stop
</template> >
<span class="text-[13px]"> <template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
{{ option }}
</span>
</NcTooltip>
<component <a-select-option v-for="option in ['day', 'week', 'month', 'year']" :key="option" :value="option">
:is="iconMap.check" <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" v-if="option === activeCalendarView"
id="nc-selected-item-icon" id="nc-selected-item-icon"
class="text-primary w-4 h-4" icon="check"
class="flex-none text-primary w-4 h-4"
/> />
</div> </div>
</a-select-option> </a-select-option>
</NcSelect> </a-select>
</template> </template>
<style lang="scss" scoped> <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 { .nc-calendar-mode-menu {
:deep(.nc-menu-item-inner) { :deep(.nc-menu-item-inner) {
@apply !text-[13px]; @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) { :deep(.ant-select-selector) {
@apply !h-7; @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 } = const { loadCalendarMeta, loadCalendarData, loadSidebarData, fetchActiveDates, updateCalendarMeta, viewMetaProperties } =
useCalendarViewStoreOrThrow() useCalendarViewStoreOrThrow()
const { isFeatureEnabled } = useBetaFeatureToggle()
const isRangeEnabled = computed(() => isFeatureEnabled(FEATURE_FLAG.CALENDAR_VIEW_RANGE))
const calendarRangeDropdown = ref(false) const calendarRangeDropdown = ref(false)
const hideWeekends = computed({ const showWeekends = computed({
get: () => viewMetaProperties.value?.hide_weekend ?? false, get: () => !viewMetaProperties.value?.hide_weekend,
set: (newValue) => { set: (newValue) => {
updateCalendarMeta({ updateCalendarMeta({
meta: { meta: {
hide_weekend: newValue, hide_weekend: !newValue,
}, },
}) })
}, },
@ -134,7 +138,6 @@ const saveCalendarRanges = async () => {
} }
} }
/*
const removeRange = async (id: number) => { const removeRange = async (id: number) => {
_calendar_ranges.value = _calendar_ranges.value.filter((_, i) => i !== id) _calendar_ranges.value = _calendar_ranges.value.filter((_, i) => i !== id)
await saveCalendarRanges() await saveCalendarRanges()
@ -143,7 +146,7 @@ const removeRange = async (id: number) => {
const saveCalendarRange = async (range: CalendarRangeType, value?) => { const saveCalendarRange = async (range: CalendarRangeType, value?) => {
range.fk_to_column_id = value range.fk_to_column_id = value
await saveCalendarRanges() await saveCalendarRanges()
} */ }
</script> </script>
<template> <template>
@ -177,23 +180,28 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
</NcTooltip> </NcTooltip>
<template #overlay> <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 <div
v-for="(range, id) in _calendar_ranges" v-for="(range, id) in _calendar_ranges"
:key="id" :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" data-testid="nc-calendar-range-option"
> >
<span> <span class="text-gray-800">
{{ $t('labels.organiseBy') }} {{ $t('labels.organiseBy') }}
</span> </span>
<NcSelect
<a-select
v-model:value="range.fk_from_column_id" 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')" :placeholder="$t('placeholder.notSelected')"
data-testid="nc-calendar-range-from-field-select" data-testid="nc-calendar-range-from-field-select"
:disabled="isLocked" :disabled="isLocked"
@change="saveCalendarRanges" @change="saveCalendarRanges"
@click.stop
> >
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
<a-select-option <a-select-option
v-for="(option, opId) in [...(dateFieldOptions ?? [])].filter((r) => { v-for="(option, opId) in [...(dateFieldOptions ?? [])].filter((r) => {
if (id === 0) return true if (id === 0) return true
@ -201,68 +209,96 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
return firstRange?.uidt === r.uidt return firstRange?.uidt === r.uidt
})" })"
:key="opId" :key="opId"
class="w-40"
:value="option.value" :value="option.value"
> >
<div class="flex w-full gap-2 justify-between items-center"> <div class="w-full flex gap-2 items-center justify-between" :title="option.label">
<div class="flex items-center"> <div class="flex items-center gap-1 max-w-[calc(100%_-_20px)]">
<SmartsheetHeaderIcon :column="option" /> <SmartsheetHeaderIcon :column="option" />
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template> <NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only>
{{ option.label }} <template #title>
{{ option.label }}
</template>
<template #default>{{ option.label }}</template>
</NcTooltip> </NcTooltip>
</div> </div>
<GeneralIcon
<component
:is="iconMap.check"
v-if="option.value === range.fk_from_column_id" v-if="option.value === range.fk_from_column_id"
id="nc-selected-item-icon" 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> </div>
</a-select-option> </a-select-option>
</NcSelect> </a-select>
<!-- <div <NcButton
v-if="range.fk_to_column_id === null && isEeUI" v-if="range.fk_to_column_id === null && isRangeEnabled"
class="flex cursor-pointer flex text-gray-800 items-center gap-1" size="small"
data-testid="nc-calendar-range-add-end-date" 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" /> <div class="flex gap-2 items-center">
{{ $t('activity.addEndDate') }} <component :is="iconMap.plus" class="h-4 w-4" />
</div> {{ $t('activity.endDate') }}
<template v-else-if="isEeUI"> </div>
</NcButton>
<template v-else-if="isEeUI && isRangeEnabled">
<span> <span>
{{ $t('activity.withEndDate') }} {{ $t('activity.withEndDate') }}
</span> </span>
<div class="flex"> <div class="flex">
<NcSelect <a-select
v-model:value="range.fk_to_column_id" 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')" :placeholder="$t('placeholder.notSelected')"
class="!rounded-r-none nc-to-select"
data-testid="nc-calendar-range-to-field-select" data-testid="nc-calendar-range-to-field-select"
dropdown-class-name="!rounded-lg"
@change="saveCalendarRanges" @change="saveCalendarRanges"
@click.stop
> >
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
<a-select-option <a-select-option
v-for="(option, opId) in [...dateFieldOptions].filter((f) => { v-for="(option, opId) in [...dateFieldOptions].filter((f) => {
const firstRange = dateFieldOptions.find((f) => f.value === calendarRange[0].fk_from_column_id) 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" :key="opId"
:value="option.value" :value="option.value"
> >
<div class="flex items-center"> <div class="w-full flex gap-2 items-center justify-between" :title="option.label">
<SmartsheetHeaderIcon :column="option" /> <div class="flex items-center gap-1 max-w-[calc(100%_-_20px)]">
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only> <SmartsheetHeaderIcon :column="option" />
<template #title>{{ option.label }}</template>
{{ option.label }} <NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only>
</NcTooltip> <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> </div>
</a-select-option> </a-select-option>
</NcSelect> </a-select>
<NcButton class="!rounded-l-none !border-l-0" size="small" type="secondary" @click="saveCalendarRange(range, null)">
<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" /> <component :is="iconMap.delete" class="h-4 w-4" />
</NcButton> </NcButton>
</div> </div>
@ -271,7 +307,6 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
<NcButton v-if="id !== 0" size="small" type="secondary" @click="removeRange(id)"> <NcButton v-if="id !== 0" size="small" type="secondary" @click="removeRange(id)">
<component :is="iconMap.close" /> <component :is="iconMap.close" />
</NcButton> </NcButton>
-->
</div> </div>
<div v-if="!isSetup" class="flex items-center gap-2 !mt-2"> <div v-if="!isSetup" class="flex items-center gap-2 !mt-2">
@ -280,9 +315,9 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
</div> </div>
<div> <div>
<NcSwitch v-model:checked="hideWeekends" :disabled="isLocked"> <NcSwitch v-model:checked="showWeekends" :disabled="isLocked">
<span class="text-gray-800"> <span class="text-gray-800 font-semibold">
{{ $t('activity.hideWeekends') }} {{ $t('activity.showSaturdaysAndSundays') }}
</span> </span>
</NcSwitch> </NcSwitch>
</div> </div>
@ -296,7 +331,7 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
</NcDropdown> </NcDropdown>
</template> </template>
<style lang="scss" scoped> <style lang="scss">
.nc-to-select .ant-select-selector { .nc-to-select .ant-select-selector {
@apply !rounded-r-none; @apply !rounded-r-none;
} }

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

@ -43,6 +43,13 @@ const FEATURES = [
enabled: false, enabled: false,
isEngineering: true, 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< 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": { "activity": {
"assignView": "Assign view", "assignView": "Assign view",
"webhookDetails": "Webhook Details", "webhookDetails": "Webhook Details",
"hideWeekends": "Hide weekends", "showSaturdaysAndSundays": "Show Saturdays & Sundays",
"renameBase": "Rename Base", "renameBase": "Rename Base",
"renameWorkspace": "Rename workspace", "renameWorkspace": "Rename workspace",
"deactivate": "De-activate", "deactivate": "De-activate",
@ -1069,6 +1069,7 @@
"goToToday": "Go to Today", "goToToday": "Go to Today",
"toggleSidebar": "Toggle Sidebar", "toggleSidebar": "Toggle Sidebar",
"addEndDate": "Add end date", "addEndDate": "Add end date",
"endDate": "End Date",
"withEndDate": "with end date", "withEndDate": "with end date",
"calendar": "Calendar", "calendar": "Calendar",
"viewSettings": "View settings", "viewSettings": "View settings",

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

@ -102,6 +102,7 @@ interface Row {
overLapIteration?: number overLapIteration?: number
numberOfOverlaps?: number numberOfOverlaps?: number
minutes?: 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.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(); await this.rootPage.mouse.up();
} }

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

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

Loading…
Cancel
Save