Browse Source

fix: week datetime overlap fix (#8256)

* fix: week datetime overlap fix

* fix: perf improvements & overlap fix

* fix: remove additional data structure

* fix: make fields style computed

* fix: refactor

* fix: improved error handling

* fix: clone date

* fix: reuse generated graph

* fix: some fixes

* fix: some fixes

* test: fix flaky in add collaborator

---------

Co-authored-by: Raju Udava <86527202+dstala@users.noreply.github.com>
pull/8375/head
Anbarasu 6 months ago committed by GitHub
parent
commit
aa2ba2fc17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 23
      packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue
  2. 215
      packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue
  3. 36
      packages/nc-gui/components/smartsheet/calendar/MonthView.vue
  4. 41
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue
  5. 601
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue
  6. 3
      packages/nc-gui/composables/useCalendarViewStore.ts
  7. 8
      tests/playwright/pages/WorkspacePage/CollaborationPage.ts

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

@ -18,16 +18,23 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType) => {
const fi = _fields.value?.find((f) => f.title === field.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
}
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
// We loop through all the records and calculate the position of each record based on the range
// We only need to calculate the top, of the record since there is no overlap in the day view of date Field
const recordsAcrossAllRange = computed<Row[]>(() => {

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

@ -28,15 +28,22 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType) => {
if (!_fields.value) return { underline: false, bold: false, italic: false }
const fi = _fields.value.find((f) => f.title === field.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
}
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
const hours = computed(() => {
@ -49,36 +56,38 @@ const hours = computed(() => {
return hours
})
const calculateNewDates = ({
endDate,
startDate,
scheduleStart,
scheduleEnd,
}: {
endDate: dayjs.Dayjs
startDate: dayjs.Dayjs
scheduleStart: dayjs.Dayjs
scheduleEnd: dayjs.Dayjs
}) => {
// If there is no end date, we add 15 minutes to the start date and use that as the end date
if (!endDate.isValid()) {
endDate = startDate.clone().add(15, 'minutes')
}
const calculateNewDates = useMemoize(
({
endDate,
startDate,
scheduleStart,
scheduleEnd,
}: {
endDate: dayjs.Dayjs
startDate: dayjs.Dayjs
scheduleStart: dayjs.Dayjs
scheduleEnd: dayjs.Dayjs
}) => {
// If there is no end date, we add 15 minutes to the start date and use that as the end date
if (!endDate.isValid()) {
endDate = startDate.clone().add(15, 'minutes')
}
// If the start date is before the opened date, we use the schedule start as the start date
// This is to ensure the generated style of the record is not outside the bounds of the calendar
if (startDate.isSameOrBefore(scheduleStart)) {
startDate = scheduleStart
}
// If the start date is before the opened date, we use the schedule start as the start date
// This is to ensure the generated style of the record is not outside the bounds of the calendar
if (startDate.isSameOrBefore(scheduleStart)) {
startDate = scheduleStart
}
// If the end date is after the schedule end, we use the schedule end as the end date
// This is to ensure the generated style of the record is not outside the bounds of the calendar
if (endDate.isAfter(scheduleEnd)) {
endDate = scheduleEnd
}
// If the end date is after the schedule end, we use the schedule end as the end date
// This is to ensure the generated style of the record is not outside the bounds of the calendar
if (endDate.isAfter(scheduleEnd)) {
endDate = scheduleEnd
}
return { endDate, startDate }
}
return { endDate, startDate }
},
)
const getGridTime = (date: dayjs.Dayjs, round = false) => {
const gridCalc = date.hour() * 60 + date.minute()
@ -133,35 +142,14 @@ const hasSlotForRecord = (
}
const getMaxOverlaps = ({
row,
gridTimeMap,
columnArray,
graph,
}: {
row: Row
gridTimeMap: Map<
number,
{
count: number
id: string[]
}
>
columnArray: Array<Array<Row>>
graph: Map<string, Set<string>>
}) => {
const visited: Set<string> = new Set()
const graph: Map<string, Set<string>> = new Map()
// Build the graph
for (const [_gridTime, { id: ids }] of gridTimeMap) {
for (const id1 of ids) {
if (!graph.has(id1)) {
graph.set(id1, new Set())
}
for (const id2 of ids) {
if (id1 !== id2) {
graph.get(id1)!.add(id2)
}
}
}
}
const dfs = (id: string): number => {
visited.add(id)
@ -169,6 +157,7 @@ const getMaxOverlaps = ({
const neighbors = graph.get(id)
if (neighbors) {
for (const neighbor of neighbors) {
if (maxOverlaps >= columnArray.length) return maxOverlaps
if (!visited.has(neighbor)) {
maxOverlaps = Math.min(Math.max(maxOverlaps, dfs(neighbor) + 1), columnArray.length)
}
@ -187,32 +176,19 @@ const getMaxOverlaps = ({
const recordsAcrossAllRange = computed<{
record: Row[]
count: {
[key: string]: {
gridTimeMap: Map<
number,
{
count: number
id: string[]
overflow: boolean
overflowCount: number
}
}
>
}>(() => {
if (!calendarRange.value || !formattedData.value) return { record: [], count: {} }
const scheduleStart = dayjs(selectedDate.value).startOf('day')
const scheduleEnd = dayjs(selectedDate.value).endOf('day')
// We use this object to keep track of the number of records that overlap at a given time, and if the number of records exceeds 4, we hide the record
// and show a button to view more records
// The key is the time in HH:mm format
// id is the id of the record generated below
const overlaps: {
[key: string]: {
id: string[]
overflow: boolean
overflowCount: number
}
} = {}
const perRecordHeight = 52
const columnArray: Array<Array<Row>> = [[]]
@ -400,11 +376,28 @@ const recordsAcrossAllRange = computed<{
record.rowMeta.overLapIteration = parseInt(columnIndex) + 1
}
}
const graph = new Map<string, Set<string>>()
// Build the graph
for (const [_gridTime, { id: ids }] of gridTimeMap) {
for (const id1 of ids) {
if (!graph.has(id1)) {
graph.set(id1, new Set())
}
for (const id2 of ids) {
if (id1 !== id2) {
graph.get(id1)!.add(id2)
}
}
}
}
for (const record of recordsByRange) {
const numberOfOverlaps = getMaxOverlaps({
row: record,
gridTimeMap,
columnArray,
graph,
})
record.rowMeta.numberOfOverlaps = numberOfOverlaps
@ -418,24 +411,6 @@ const recordsAcrossAllRange = computed<{
if (record.rowMeta.overLapIteration! - 1 > 7) {
display = 'none'
gridTimeMap.forEach((value, key) => {
if (value.id.includes(record.rowMeta.id!)) {
if (!overlaps[key]) {
overlaps[key] = {
id: value.id,
overflow: true,
overflowCount: value.id.length,
}
} else {
overlaps[key].overflow = true
value.id.forEach((id) => {
if (!overlaps[key].id.includes(id)) {
overlaps[key].id.push(id)
}
})
}
}
})
} else {
left = width * (record.rowMeta.overLapIteration! - 1)
}
@ -453,7 +428,7 @@ const recordsAcrossAllRange = computed<{
}
return {
count: overlaps,
gridTimeMap,
record: recordsByRange,
}
})
@ -477,7 +452,7 @@ const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[],
}, 500)
// When the user is dragging a record, we calculate the new start and end date based on the mouse position
const calculateNewRow = (event: MouseEvent) => {
const calculateNewRow = (event: MouseEvent, skipChangeCheck?: boolean) => {
if (!container.value || !dragRecord.value) return { newRow: null, updateProperty: [] }
const { top } = container.value.getBoundingClientRect()
@ -505,7 +480,7 @@ const calculateNewRow = (event: MouseEvent) => {
...dragRecord.value,
row: {
...dragRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
[fromCol.title!]: dayjs(newStartDate).utc().format('YYYY-MM-DD HH:mm:ssZ'),
},
}
@ -528,11 +503,16 @@ const calculateNewRow = (event: MouseEvent) => {
endDate = newStartDate.clone()
}
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
newRow.row[toCol.title!] = dayjs(endDate).utc().format('YYYY-MM-DD HH:mm:ssZ')
updateProperty.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, updateProperty: [] }
}
if (!newRow) {
return { newRow: null, updateProperty: [] }
}
@ -552,6 +532,11 @@ const calculateNewRow = (event: MouseEvent) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk !== newPk
})
dragRecord.value = {
...dragRecord.value,
row: newRow.row,
}
}
return { newRow, updateProperty }
}
@ -668,7 +653,7 @@ const stopDrag = (event: MouseEvent) => {
clearTimeout(dragTimeout.value!)
if (!isUIAllowed('dataEdit') || !isDragging.value || !container.value || !dragRecord.value) return
const { newRow, updateProperty } = calculateNewRow(event)
const { newRow, updateProperty } = calculateNewRow(event, true)
if (!newRow && !updateProperty) return
const allRecords = document.querySelectorAll('.draggable-record')
@ -823,32 +808,18 @@ const dropEvent = (event: DragEvent) => {
}
const isOverflowAcrossHourRange = (hour: dayjs.Dayjs) => {
let startOfHour = hour.startOf('hour')
const endOfHour = hour.endOf('hour')
const ids: Array<string> = []
let isOverflow = false
if (!recordsAcrossAllRange.value || !recordsAcrossAllRange.value.gridTimeMap) return { isOverflow: false, overflowCount: 0 }
const { gridTimeMap } = recordsAcrossAllRange.value
const startMinute = hour.hour() * 60 + hour.minute()
const endMinute = hour.hour() * 60 + hour.minute() + 59
let overflowCount = 0
while (startOfHour.isBefore(endOfHour, 'minute')) {
const hourKey = startOfHour.hour() * 60 + startOfHour.minute()
if (recordsAcrossAllRange.value?.count?.[hourKey]?.overflow) {
isOverflow = true
recordsAcrossAllRange.value?.count?.[hourKey]?.id.forEach((id) => {
if (!ids.includes(id)) {
ids.push(id)
overflowCount += 1
}
})
}
startOfHour = startOfHour.add(1, 'minute')
for (let minute = startMinute; minute <= endMinute; minute++) {
const recordCount = gridTimeMap.get(minute)?.count ?? 0
overflowCount = Math.max(overflowCount, recordCount)
}
overflowCount = overflowCount > 8 ? overflowCount - 8 : 0
return { isOverflow, overflowCount }
return { isOverflow: overflowCount - 8 > 0, overflowCount: overflowCount - 8 }
}
const viewMore = (hour: dayjs.Dayjs) => {

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

@ -64,15 +64,22 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType | undefined) => {
if (!field) return { underline: false, bold: false, italic: false }
const fi = _fields.value?.find((f) => f.title === field.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
}
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
const dates = computed(() => {
@ -343,7 +350,7 @@ const recordsToDisplay = computed<{
}
})
const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean) => {
const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean, skipChangeCheck?: boolean) => {
const { top, height, width, left } = calendarGridContainer.value.getBoundingClientRect()
const percentY = (event.clientY - top - window.scrollY) / height
@ -364,7 +371,7 @@ const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean) => {
...dragRecord.value,
row: {
...dragRecord.value?.row,
[fromCol!.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
[fromCol!.title!]: dayjs(newStartDate).utc().format('YYYY-MM-DD HH:mm:ssZ'),
},
}
@ -384,10 +391,15 @@ const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean) => {
endDate = newStartDate.clone()
}
newRow.row[toCol!.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
newRow.row[toCol!.title!] = dayjs(endDate).utc().format('YYYY-MM-DD HH:mm:ssZ')
updateProperty.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, updateProperty: [] }
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
@ -515,7 +527,7 @@ const stopDrag = (event: MouseEvent) => {
event.preventDefault()
dragElement.value!.style.boxShadow = 'none'
const { newRow, updateProperty } = calculateNewRow(event, false)
const { newRow, updateProperty } = calculateNewRow(event, false, true)
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {

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

@ -1,6 +1,6 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { type ColumnType } from 'nocodb-sdk'
import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib'
import { computed, ref, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils'
@ -22,14 +22,22 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType | undefined) => {
const fi = _fields.value?.find((f) => f.title === field?.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
}
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
// Calculate the dates of the week
@ -71,6 +79,18 @@ const findFirstSuitableRow = (recordsInDay: any, startDayIndex: number, spanDays
}
}
const isInRange = (date: dayjs.Dayjs) => {
return (
date &&
date.isBetween(
dayjs(selectedDateRange.value.start).startOf('day'),
dayjs(selectedDateRange.value.end).endOf('day'),
'day',
'[]',
)
)
}
const calendarData = computed(() => {
if (!formattedData.value || !calendarRange.value) return []
@ -156,9 +176,8 @@ const calendarData = computed(() => {
let position = 'none'
const isStartInRange =
ogStartDate && ogStartDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]')
const isEndInRange = endDate && endDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]')
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'

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

@ -1,8 +1,8 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { type ColumnType } from 'nocodb-sdk'
import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib'
import { computed, ref, useViewColumnsOrThrow } from '#imports'
import { computed, ref, useMemoize, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils'
const emits = defineEmits(['expandRecord', 'newRecord'])
@ -14,7 +14,6 @@ const {
calendarRange,
displayField,
selectedTime,
selectedDate,
updateRowProperty,
sideBarFilterOption,
showSideMenu,
@ -34,16 +33,53 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType | undefined) => {
if (!field) return { underline: false, bold: false, italic: false }
const fi = _fields.value?.find((f) => f.title === field.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
}
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
const calculateNewDates = useMemoize(
({
startDate,
endDate,
scheduleStart,
scheduleEnd,
}: {
startDate: dayjs.Dayjs
endDate: dayjs.Dayjs
scheduleStart: dayjs.Dayjs
scheduleEnd: dayjs.Dayjs
}) => {
// If the end date is not valid, we set it to 15 minutes after the start date
if (!endDate?.isValid()) {
endDate = startDate.clone().add(15, 'minutes')
}
// If the start date is before the start of the schedule, we set it to the start of the schedule
// If the end date is after the end of the schedule, we set it to the end of the schedule
// This is to ensure that the records are within the bounds of the schedule and do not overflow
if (startDate.isBefore(scheduleStart, 'minutes')) {
startDate = scheduleStart.clone()
}
if (endDate.isAfter(scheduleEnd, 'minutes')) {
endDate = scheduleEnd.clone()
}
return { startDate, endDate }
},
)
// Since it is a datetime Week view, we need to create a 2D array of dayjs objects to represent the hours in a day for each day in the week
const datesHours = computed(() => {
@ -71,22 +107,122 @@ const datesHours = computed(() => {
return datesHours
})
const recordsAcrossAllRange = computed<{
records: Array<Row>
count: {
[key: string]: {
[key: string]: {
id: Array<string>
overflow: boolean
overflowCount: number
const getDayIndex = (date: dayjs.Dayjs) => {
let dayIndex = date.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
return dayIndex
}
const getGridTime = (date: dayjs.Dayjs, round = false) => {
const gridCalc = date.hour() * 60 + date.minute()
if (round) {
return Math.ceil(gridCalc)
} else {
return Math.floor(gridCalc)
}
}
const getGridTimeSlots = (from: dayjs.Dayjs, to: dayjs.Dayjs) => {
return {
from: getGridTime(from, false),
to: getGridTime(to, true) - 1,
dayIndex: getDayIndex(from),
}
}
const hasSlotForRecord = (
columnArray: Row[],
dates: {
fromDate: dayjs.Dayjs
toDate: dayjs.Dayjs
},
) => {
const { fromDate, toDate } = dates
if (!fromDate || !toDate) return false
for (const column of columnArray) {
const columnFromCol = column.rowMeta.range?.fk_from_col
const columnToCol = column.rowMeta.range?.fk_to_col
if (!columnFromCol) return false
const { startDate: columnFromDate, endDate: columnToDate } = calculateNewDates({
startDate: dayjs(column.row[columnFromCol.title!]),
endDate: columnToCol
? dayjs(column.row[columnToCol.title!])
: dayjs(column.row[columnFromCol.title!]).add(1, 'hour').subtract(1, 'minute'),
scheduleStart: dayjs(selectedDateRange.value.start).startOf('day'),
scheduleEnd: dayjs(selectedDateRange.value.end).endOf('day'),
})
if (
fromDate.isBetween(columnFromDate, columnToDate, null, '[]') ||
toDate.isBetween(columnFromDate, columnToDate, null, '[]')
) {
return false
}
}
return true
}
const getMaxOverlaps = ({
row,
columnArray,
graph,
}: {
row: Row
columnArray: Array<Array<Array<Row>>>
graph: Map<string, Set<string>>
}) => {
const id = row.rowMeta.id as string
const visited: Set<string> = new Set()
const dayIndex = row.rowMeta.dayIndex
const overlapIndex = columnArray[dayIndex].findIndex((column) => column.findIndex((r) => r.rowMeta.id === id) !== -1) + 1
const dfs = (id: string): number => {
visited.add(id)
let maxOverlaps = 1
const neighbors = graph.get(id)
if (neighbors) {
for (const neighbor of neighbors) {
if (maxOverlaps >= columnArray[dayIndex].length) return maxOverlaps
if (!visited.has(neighbor)) {
maxOverlaps = Math.min(Math.max(maxOverlaps, dfs(neighbor) + 1), columnArray[dayIndex].length)
}
}
}
return maxOverlaps
}
let maxOverlaps = 1
if (graph.has(id)) {
maxOverlaps = dfs(id)
}
return { maxOverlaps, dayIndex, overlapIndex }
}
const recordsAcrossAllRange = computed<{
records: Array<Row>
gridTimeMap: Map<
number,
Map<
number,
{
count: number
id: string[]
}
>
>
}>(() => {
if (!formattedData.value || !calendarRange.value || !container.value || !scrollContainer.value)
return {
records: [],
count: {},
gridTimeMap: new Map(),
}
const perWidth = containerWidth.value / 7
const perHeight = 52
@ -94,20 +230,18 @@ const recordsAcrossAllRange = computed<{
const scheduleStart = dayjs(selectedDateRange.value.start).startOf('day')
const scheduleEnd = dayjs(selectedDateRange.value.end).endOf('day')
// We need to keep track of the overlaps for each day and hour, minute in the week to calculate the width and left position of each record
// The first key is the date, the second key is the hour, and the value is an object containing the ids of the records that overlap
// The key is in the format YYYY-MM-DD and the hour is in the format HH:mm
const overlaps: {
[key: string]: {
[key: string]: {
id: Array<string>
overflow: boolean
overflowCount: number
const columnArray: Array<Array<Array<Row>>> = [[[]]]
const gridTimeMap = new Map<
number,
Map<
number,
{
count: number
id: string[]
}
}
} = {}
let recordsToDisplay: Array<Row> = []
>
>()
const recordsToDisplay: Array<Row> = []
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
@ -115,123 +249,36 @@ const recordsAcrossAllRange = computed<{
// We fetch all the records that match the calendar ranges in a single time.
// But not all fetched records are valid for the certain range, so we filter them out & sort them
const sortedFormattedData = [...formattedData.value].filter((record) => {
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
const sortedFormattedData = [...formattedData.value]
.filter((record) => {
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
if (fromCol && toCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
return fromDate && toDate && !toDate.isBefore(fromDate)
} else if (fromCol && !toCol) {
return !!fromDate
}
return false
})
sortedFormattedData.forEach((record: Row) => {
if (!toCol && fromCol) {
// If there is no toColumn chosen in the range
const ogStartDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
if (!ogStartDate) return
let endDate = ogStartDate.clone().add(1, 'hour')
if (endDate.isAfter(scheduleEnd, 'minutes')) {
endDate = scheduleEnd
}
const id = record.rowMeta.id ?? generateRandomNumber()
let startDate = ogStartDate.clone()
let style: Partial<CSSStyleDeclaration> = {}
while (startDate.isBefore(endDate, 'minutes')) {
const dateKey = startDate?.format('YYYY-MM-DD')
const hourKey = startDate?.format('HH:mm')
// If the dateKey and hourKey are valid, we add the id to the overlaps object
if (dateKey && hourKey) {
if (!overlaps[dateKey]) {
overlaps[dateKey] = {}
}
if (!overlaps[dateKey][hourKey]) {
overlaps[dateKey][hourKey] = {
id: [],
overflow: false,
overflowCount: 0,
}
}
overlaps[dateKey][hourKey].id.push(id)
}
// If the number of records that overlap in a single hour is more than 4, we hide the record and set the overflow flag to true
// We also keep track of the number of records that overflow
if (overlaps[dateKey][hourKey].id.length > 4) {
overlaps[dateKey][hourKey].overflow = true
style.display = 'none'
overlaps[dateKey][hourKey].overflowCount += 1
}
// TODO: dayIndex is not calculated perfectly
// Should revisit this part in next iteration
let dayIndex = dayjs(dateKey).day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
if (fromCol && toCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
startDate = startDate.add(1, 'minute')
return fromDate && toDate && !toDate.isBefore(fromDate)
} else if (fromCol && !toCol) {
return !!fromDate
}
return false
})
.sort((a, b) => {
const aDate = dayjs(a.row[fromCol!.title!])
const bDate = dayjs(b.row[fromCol!.title!])
return aDate.isBefore(bDate) ? 1 : -1
})
let dayIndex = ogStartDate.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
const minutes = (ogStartDate.minute() / 60 + ogStartDate.hour()) * 52
style = {
...style,
top: `${minutes + 1}px`,
height: `${perHeight - 2}px`,
}
for (const record of sortedFormattedData) {
const id = record.rowMeta.id ?? generateRandomNumber()
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
id,
position: 'rounded',
style,
range,
dayIndex,
},
if (fromCol && toCol) {
const { startDate, endDate } = calculateNewDates({
startDate: dayjs(record.row[fromCol.title!]),
endDate: dayjs(record.row[toCol.title!]),
scheduleStart,
scheduleEnd,
})
} else if (fromCol && toCol) {
const id = record.rowMeta.id ?? generateRandomNumber()
let startDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
let endDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
// If the start date is not valid, we skip the record
if (!startDate?.isValid()) return
// If the end date is not valid, we set it to 30 minutes after the start date
if (!endDate?.isValid()) {
endDate = startDate.clone().add(30, 'minutes')
}
// If the start date is before the start of the schedule, we set it to the start of the schedule
// If the end date is after the end of the schedule, we set it to the end of the schedule
// This is to ensure that the records are within the bounds of the schedule and do not overflow
if (startDate.isBefore(scheduleStart, 'minutes')) {
startDate = scheduleStart
}
if (endDate.isAfter(scheduleEnd, 'minutes')) {
endDate = scheduleEnd
}
// Setting the current start date to the start date of the record
let currentStartDate: dayjs.Dayjs = startDate.clone()
@ -242,14 +289,7 @@ const recordsAcrossAllRange = computed<{
const recordStart: dayjs.Dayjs = currentEndDate.isSame(startDate, 'day') ? startDate : currentStartDate
const recordEnd = currentEndDate.isSame(endDate, 'day') ? endDate : currentEndDate
const dateKey = recordStart.format('YYYY-MM-DD')
// TODO: dayIndex is not calculated perfectly
// Should revisit this part in next iteration
let dayIndex = recordStart.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
const dayIndex = getDayIndex(recordStart)
// We calculate the index of the start and end hour in the day
const startHourIndex = Math.max(
@ -278,36 +318,8 @@ const recordsAcrossAllRange = computed<{
position = 'none'
}
let _startHourIndex = startHourIndex
let style: Partial<CSSStyleDeclaration> = {}
// We loop through the start hour index to the end hour index and add the id to the overlaps object
while (_startHourIndex <= endHourIndex) {
const hourKey = datesHours.value[dayIndex][_startHourIndex].format('HH:mm')
if (!overlaps[dateKey]) {
overlaps[dateKey] = {}
}
if (!overlaps[dateKey][hourKey]) {
overlaps[dateKey][hourKey] = {
id: [],
overflow: false,
overflowCount: 0,
}
}
overlaps[dateKey][hourKey].id.push(id)
// If the number of records that overlap in a single hour is more than 4, we hide the record and set the overflow flag to true
// We also keep track of the number of records that overflow
if (overlaps[dateKey][hourKey].id.length > 4) {
overlaps[dateKey][hourKey].overflow = true
style.display = 'none'
overlaps[dateKey][hourKey].overflowCount += 1
}
_startHourIndex++
}
const spanHours = endHourIndex - startHourIndex + 1
const top = startHourIndex * perHeight
@ -334,45 +346,170 @@ const recordsAcrossAllRange = computed<{
// We set the current start date to the next day
currentStartDate = currentStartDate.add(1, 'day').hour(0).minute(0)
}
} else if (fromCol) {
// If there is no toColumn chosen in the range
const { startDate } = calculateNewDates({
startDate: dayjs(record.row[fromCol.title!]),
endDate: dayjs(record.row[fromCol.title!]).add(1, 'hour').subtract(1, 'minute'),
scheduleStart,
scheduleEnd,
})
let style: Partial<CSSStyleDeclaration> = {}
const dayIndex = getDayIndex(startDate)
const minutes = (startDate.minute() / 60 + startDate.hour()) * perHeight
style = {
...style,
top: `${minutes + 1}px`,
height: `${perHeight - 2}px`,
}
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
id,
position: 'rounded',
style,
range,
dayIndex,
},
})
}
}
recordsToDisplay.sort((a, b) => {
const fromColA = a.rowMeta.range?.fk_from_col
const fromColB = b.rowMeta.range?.fk_from_col
if (!fromColA || !fromColB) return 0
return dayjs(a.row[fromColA.title!]).isBefore(dayjs(b.row[fromColB.title!])) ? -1 : 1
})
// With can't find the left and width of the record without knowing the overlaps
// Hence the first iteration is to find the overlaps, top, height and then the second iteration is to find the left and width
// This is because the left and width of the record depends on the overlaps
recordsToDisplay = recordsToDisplay.map((record) => {
// maxOverlaps is the maximum number of records that overlap in a single hour
// overlapIndex is the index of the record in the overlaps object
let maxOverlaps = 1
let overlapIndex = 0
const dayIndex = record.rowMeta.dayIndex as number
const dateKey = dayjs(selectedDateRange.value.start).add(dayIndex, 'day').format('YYYY-MM-DD')
for (const hours in overlaps[dateKey]) {
// We are checking if the overlaps object contains the id of the record
// If it does, we set the maxOverlaps and overlapIndex
if (overlaps[dateKey][hours].id.includes(record.rowMeta.id!)) {
maxOverlaps = Math.max(maxOverlaps, overlaps[dateKey][hours].id.length - overlaps[dateKey][hours].overflowCount)
overlapIndex = Math.max(overlapIndex, overlaps[dateKey][hours].id.indexOf(record.rowMeta.id!))
for (const record of recordsToDisplay) {
const fromCol = record.rowMeta.range?.fk_from_col
const toCol = record.rowMeta.range?.fk_to_col
if (!fromCol) continue
const { startDate, endDate } = calculateNewDates({
startDate: dayjs(record.row[fromCol.title!]),
endDate: toCol ? dayjs(record.row[toCol.title!]) : dayjs(record.row[fromCol.title!]).add(1, 'hour').subtract(1, 'minute'),
scheduleStart,
scheduleEnd,
})
const gridTimes = getGridTimeSlots(startDate, endDate)
const dayIndex = record.rowMeta.dayIndex ?? gridTimes.dayIndex
for (let gridCounter = gridTimes.from; gridCounter <= gridTimes.to; gridCounter++) {
if (!gridTimeMap.has(dayIndex)) {
gridTimeMap.set(
dayIndex,
new Map<
number,
{
count: number
id: string[]
}
>(),
)
}
if (!gridTimeMap.get(dayIndex)?.has(gridCounter)) {
gridTimeMap.set(dayIndex, (gridTimeMap.get(dayIndex) ?? new Map()).set(gridCounter, { count: 0, id: [] }))
}
const idArray = gridTimeMap.get(dayIndex)!.get(gridCounter)!.id
idArray.push(record.rowMeta.id!)
const count = gridTimeMap.get(dayIndex)!.get(gridCounter)!.count + 1
gridTimeMap.set(dayIndex, (gridTimeMap.get(dayIndex) ?? new Map()).set(gridCounter, { count, id: idArray }))
}
let foundAColumn = false
if (!columnArray[dayIndex]) {
columnArray[dayIndex] = []
}
for (const column in columnArray[dayIndex]) {
if (hasSlotForRecord(columnArray[dayIndex][column], { fromDate: startDate, toDate: endDate })) {
columnArray[dayIndex][column].push(record)
foundAColumn = true
break
}
}
if (!foundAColumn) {
columnArray[dayIndex].push([record])
}
}
const graph: Map<number, Map<string, Set<string>>> = new Map()
for (const dayIndex of gridTimeMap.keys()) {
if (!graph.has(dayIndex)) {
graph.set(dayIndex, new Map())
}
for (const [_gridTime, { id: ids }] of gridTimeMap.get(dayIndex)) {
for (const id1 of ids) {
if (!graph.get(dayIndex).has(id1)) {
graph.get(dayIndex).set(id1, new Set())
}
for (const id2 of ids) {
if (id1 !== id2) {
if (!graph.get(dayIndex).get(id1).has(id2)) {
graph.get(dayIndex).get(id1).add(id2)
}
}
}
}
}
}
for (const dayIndex in columnArray) {
for (const columnIndex in columnArray[dayIndex]) {
for (const record of columnArray[dayIndex][columnIndex]) {
record.rowMeta.overLapIteration = parseInt(columnIndex) + 1
}
}
const spacing = 0.1
const widthPerRecord = (100 - spacing * (maxOverlaps - 1)) / maxOverlaps / 7
const leftPerRecord = widthPerRecord * overlapIndex
}
for (const record of recordsToDisplay) {
const { maxOverlaps, overlapIndex } = getMaxOverlaps({
row: record,
columnArray,
graph: graph.get(record.rowMeta.dayIndex!) ?? new Map(),
})
const dayIndex = record.rowMeta.dayIndex ?? tDayIndex
record.rowMeta.numberOfOverlaps = maxOverlaps
let width = 0
let left = 100
const majorLeft = dayIndex * perWidth
let display = 'block'
if (record.rowMeta.overLapIteration! - 1 > 2) {
display = 'none'
} else {
width = 100 / Math.min(maxOverlaps, 3) / 7
left = width * (overlapIndex - 1)
}
record.rowMeta.style = {
...record.rowMeta.style,
left: `calc(${dayIndex * perWidth}px + ${leftPerRecord}% )`,
width: `calc(${widthPerRecord - 0.1}%)`,
left: `calc(${majorLeft}px + ${left}%)`,
width: `calc(${width}%)`,
display,
}
return record
})
}
})
return {
records: recordsToDisplay,
count: overlaps,
gridTimeMap,
}
})
@ -497,9 +634,11 @@ const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: R
const calculateNewRow = (
event: MouseEvent,
updateSideBar?: boolean,
skipChangeCheck?: boolean,
): {
newRow: Row | null
updatedProperty: string[]
skipChangeCheck?: boolean
} => {
const { width, left, top } = container.value.getBoundingClientRect()
@ -528,7 +667,7 @@ const calculateNewRow = (
...dragRecord.value,
row: {
...dragRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
[fromCol.title!]: dayjs(newStartDate).utc().format('YYYY-MM-DD HH:mm:ssZ'),
},
}
@ -546,11 +685,16 @@ const calculateNewRow = (
endDate = newStartDate.clone()
}
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
newRow.row[toCol.title!] = dayjs(endDate).utc().format('YYYY-MM-DD HH:mm:ssZ')
updatedProperty.push(toCol.title!)
}
if (!newRow) return { newRow: null, updatedProperty }
// If from and to columns of the dragRecord and the newRow are the same, we don't manipulate the formattedRecords and formattedSideBarData. This removes unwanted computation
if (dragRecord.value.row[fromCol.title!] === newRow.row[fromCol.title!] && !skipChangeCheck) {
return { newRow: null, updatedProperty: [] }
}
if (!newRow) return { newRow: null, updatedProperty: [] }
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
@ -565,6 +709,10 @@ const calculateNewRow = (
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
dragRecord.value = {
...dragRecord.value,
row: newRow.row,
}
}
return { newRow, updatedProperty }
@ -591,7 +739,7 @@ const stopDrag = (event: MouseEvent) => {
event.preventDefault()
clearTimeout(dragTimeout.value!)
const { newRow, updatedProperty } = calculateNewRow(event, false)
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')
@ -676,33 +824,19 @@ const viewMore = (hour: dayjs.Dayjs) => {
}
const isOverflowAcrossHourRange = (hour: dayjs.Dayjs) => {
let startOfHour = hour.startOf('hour')
const endOfHour = hour.endOf('hour')
const ids: Array<string> = []
let isOverflow = false
if (!recordsAcrossAllRange.value || !recordsAcrossAllRange.value.gridTimeMap) return { isOverflow: false, overflowCount: 0 }
const { gridTimeMap } = recordsAcrossAllRange.value
const dayIndex = getDayIndex(hour)
const startMinute = hour.hour() * 60 + hour.minute()
const endMinute = hour.hour() * 60 + hour.minute() + 59
let overflowCount = 0
while (startOfHour.isBefore(endOfHour, 'minute')) {
const dateKey = startOfHour.format('YYYY-MM-DD')
const hourKey = startOfHour.format('HH:mm')
if (recordsAcrossAllRange.value?.count?.[dateKey]?.[hourKey]?.overflow) {
isOverflow = true
recordsAcrossAllRange.value?.count?.[dateKey]?.[hourKey]?.id.forEach((id) => {
if (!ids.includes(id)) {
ids.push(id)
overflowCount += 1
}
})
}
startOfHour = startOfHour.add(1, 'minute')
for (let minute = startMinute; minute <= endMinute; minute++) {
const recordCount = gridTimeMap.get(dayIndex)?.get(minute)?.count ?? 0
overflowCount = Math.max(overflowCount, recordCount)
}
overflowCount = overflowCount > 4 ? overflowCount - 4 : 0
return { isOverflow, overflowCount }
return { isOverflow: overflowCount - 3 > 0, overflowCount: overflowCount - 3 }
}
// TODO: Add Support for multiple ranges when multiple ranges are supported
@ -773,7 +907,6 @@ watch(
@click="
() => {
selectedTime = hour
selectedDate = hour
dragRecord = undefined
}
"

3
packages/nc-gui/composables/useCalendarViewStore.ts

@ -738,7 +738,8 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
watch(activeCalendarView, async (value, oldValue) => {
if (oldValue === 'week') {
pageDate.value = selectedDate.value
selectedMonth.value = selectedDate.value ?? selectedDateRange.value.start
selectedMonth.value = selectedTime.value ?? selectedDate.value ?? selectedDateRange.value.start
selectedDate.value = selectedTime.value ?? selectedDateRange.value.start
selectedTime.value = selectedDate.value ?? selectedDateRange.value.start
} else if (oldValue === 'month') {
selectedDate.value = selectedMonth.value

8
tests/playwright/pages/WorkspacePage/CollaborationPage.ts

@ -39,9 +39,11 @@ export class CollaborationPage extends BasePage {
const selector_role = inviteModal.locator('.ant-select-selector');
const button_addUser = inviteModal.locator('.nc-invite-btn');
// flaky test: wait for the input to be ready
await this.rootPage.waitForTimeout(500);
// email
await input_email.fill(email);
await this.rootPage.keyboard.press('Enter');
await input_email.fill(email + ' ');
// role
await selector_role.first().click();
@ -52,7 +54,7 @@ export class CollaborationPage extends BasePage {
// allow button to be enabled
await this.rootPage.waitForTimeout(500);
await this.rootPage.keyboard.press('Enter');
await button_addUser.click();
await this.verifyToast({ message: 'Invitation sent successfully' });
await this.rootPage.waitForTimeout(500);

Loading…
Cancel
Save