Browse Source

fix: spanning records feat: updated styles

pull/9831/head
DarkPhoenix2704 2 days ago
parent
commit
748e4d97f6
  1. 304
      packages/nc-gui/components/smartsheet/calendar/DateTimeSpanningContainer.vue
  2. 8
      packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue
  3. 339
      packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue
  4. 44
      packages/nc-gui/components/smartsheet/calendar/MonthView.vue
  5. 20
      packages/nc-gui/components/smartsheet/calendar/RecordCard.vue
  6. 12
      packages/nc-gui/components/smartsheet/calendar/VRecordCard.vue
  7. 3
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue
  8. 48
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue
  9. 71
      packages/nc-gui/components/smartsheet/toolbar/Calendar/Range.vue

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

@ -0,0 +1,304 @@
<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 { $e } = useNuxtApp()
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,
} = 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}px`,
left: `${startDayIndex * perDayWidth}px`,
width: `${spanDays * perDayWidth + 8}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 onResizeStart = (record: Row, direction: 'right' | 'left') => {
resizeDirection.value = direction
resizeRecord.value = record
}
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)
}
</script>
<template>
<div style="z-index: 100" class="sticky flex top-0 bg-white border-b-1 border-gray-100 prevent-select">
<div
style="width: 64px"
:style="{
width: `${activeCalendarView === 'week' ? '64px' : '66px'}`,
}"
class="text-xs top-0 text-right z-50 !sticky h-full left-0 text-[#6A7184] p-2"
>
All day
</div>
<div
ref="container"
:style="{
height: `min(64px)`,
width: 'calc(100% - 67px)',
}"
:class="{
'border-gray-100': activeCalendarView === 'day',
'border-gray-200': activeCalendarView === 'week',
}"
class="relative border-l-1 z-30 py-1"
>
<div class="pointer-events-none inset-y-0 relative">
<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"
: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>

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

@ -54,7 +54,7 @@ const recordsAcrossAllRange = computed<Row[]>(() => {
dayRecordCount++ dayRecordCount++
const style: Partial<CSSStyleDeclaration> = { const style: Partial<CSSStyleDeclaration> = {
top: `${(dayRecordCount - 1) * perRecordHeight}px`, top: `${(dayRecordCount - 1) * perRecordHeight + dayRecordCount * 8}px`,
width: '100%', width: '100%',
} }
@ -97,7 +97,7 @@ const recordsAcrossAllRange = computed<Row[]>(() => {
style: { style: {
width: '100%', width: '100%',
left: '0', left: '0',
top: `${(dayRecordCount - 1) * perRecordHeight}px`, top: `${(dayRecordCount - 1) * perRecordHeight + dayRecordCount * 8}px`,
}, },
position: 'rounded', position: 'rounded',
}, },
@ -212,17 +212,15 @@ const newRecord = () => {
v-for="(record, rowIndex) in recordsAcrossAllRange" v-for="(record, rowIndex) in recordsAcrossAllRange"
:key="rowIndex" :key="rowIndex"
:style="record.rowMeta.style" :style="record.rowMeta.style"
class="absolute mt-2" class="absolute"
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"> <LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard <LazySmartsheetCalendarRecordCard
:position="record.rowMeta.position"
:record="record" :record="record"
:resize="false" :resize="false"
color="blue"
size="small" size="small"
@click.prevent="emit('expandRecord', record)" @click.prevent="emit('expandRecord', record)"
> >

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

@ -73,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()
@ -213,6 +217,7 @@ const getMaxOverlaps = ({
const recordsAcrossAllRange = computed<{ const recordsAcrossAllRange = computed<{
record: Row[] record: Row[]
spanningRecords: Row[]
gridTimeMap: Map< gridTimeMap: Map<
number, number,
{ {
@ -254,6 +259,7 @@ const recordsAcrossAllRange = computed<{
if (fromDate?.isValid() && toDate?.isValid()) { if (fromDate?.isValid() && toDate?.isValid()) {
if (!fromDate.isSame(toDate, 'day')) { if (!fromDate.isSame(toDate, 'day')) {
// TODO: If multiple range is introduced, we have to make sure no duplicate records are inserted
recordSpanningDays.push(record) recordSpanningDays.push(record)
return false return false
} }
@ -288,30 +294,10 @@ const recordsAcrossAllRange = computed<{
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,
@ -329,7 +315,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
@ -347,7 +332,6 @@ const recordsAcrossAllRange = computed<{
range: range as any, range: range as any,
style, style,
id, id,
position: 'rounded',
}, },
}) })
} }
@ -460,14 +444,15 @@ const recordsAcrossAllRange = computed<{
record.rowMeta.style = { record.rowMeta.style = {
...record.rowMeta.style, ...record.rowMeta.style,
display, display,
width: `${width.toFixed(2)}%`, width: `calc(${width.toFixed(2)}% - 8px)`,
left: `${left.toFixed(2)}%`, left: `calc(${left.toFixed(2)}% + 4px)`,
} }
} }
return { return {
gridTimeMap, gridTimeMap,
record: recordsByRange, record: recordsByRange,
spanningRecords: recordSpanningDays,
} }
}) })
@ -902,82 +887,83 @@ 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 :records="recordsAcrossAllRange.spanningRecords" @expand-record="expandRecord" />
class="w-full flex relative no-selection h-[calc(100vh-5.3rem)] overflow-y-auto nc-scrollbar-md" <div ref="container" class="w-full flex relative no-selection" data-testid="nc-calendar-day-view" @drop="dropEvent">
data-testid="nc-calendar-day-view"
@drop="dropEvent"
>
<div
v-if="!isPublic && dayjs().isSame(selectedDate, 'day')"
class="absolute ml-2 pointer-events-none w-full z-4"
:style="{
top: `${overlayTop}px`,
}"
>
<div class="flex w-full items-center">
<span
class="text-brand-500 text-xs rounded-md border-1 pointer-events-auto px-0.5 border-brand-200 cursor-pointer bg-brand-50"
@click="newRecord(currTime)"
>
{{ currTime.format('hh:mm A') }}
</span>
<div class="flex-1 border-b-1 border-brand-500"></div>
</div>
</div>
<div>
<div <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 +981,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 +1019,69 @@ 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 in recordsAcrossAllRange.record" :key="record.rowMeta.id">
<div
v-if="record.rowMeta.style?.display !== 'none'"
:data-testid="`nc-calendar-day-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id"
:style="record.rowMeta.style"
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 class="relative !ml-[68px] !mr-1 nc-calendar-day-record-container" data-testid="nc-calendar-day-record-container">
<template #time> <template v-for="record in recordsAcrossAllRange.record" :key="record.rowMeta.id">
<div class="text-xs font-medium text-gray-400"> <div
{{ dayjs(record.row[record.rowMeta.range?.fk_from_col!.title!]).format('h:mm a') }} v-if="record.rowMeta.style?.display !== 'none'"
</div> :data-testid="`nc-calendar-day-record-${record.row[displayField!.title!]}`"
</template> :data-unique-id="record.rowMeta.id"
</LazySmartsheetCalendarVRecordCard> :style="record.rowMeta.style"
</LazySmartsheetRow> class="absolute draggable-record transition group cursor-pointer pointer-events-auto"
</div> @mousedown="dragStart($event, record)"
</template> @mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id as string"
@dragover.prevent
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarVRecordCard
:hover="hoverRecord === record.rowMeta.id || record.rowMeta.id === dragRecord?.rowMeta?.id"
:selected="record.rowMeta.id === dragRecord?.rowMeta?.id"
:record="record"
: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 +1095,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: 3;
box-shadow: 0 0 0 2px #3366ff !important;
}
}
</style> </style>

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

@ -146,10 +146,10 @@ const recordsToDisplay = computed<{
const perWidth = gridContainerWidth.value / maxVisibleDays.value const perWidth = gridContainerWidth.value / maxVisibleDays.value
const perHeight = gridContainerHeight.value / calendarData.value.weeks.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 + 4)) const maxLanes = Math.floor((perHeight - spaceBetweenRecords) / (perRecordHeight + 8))
// Track records and lanes for each day // Track records and lanes for each day
const recordsInDay: { const recordsInDay: {
@ -202,17 +202,26 @@ const recordsToDisplay = computed<{
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) {
@ -922,7 +931,12 @@ const addRecord = (date: dayjs.Dayjs) => {
} }
.selected-date { .selected-date {
z-index: 2; @apply relative;
box-shadow: 0 0 0 2px #3366ff !important; &:after {
@apply rounded-sm pointer-events-none absolute inset-0 w-full h-full;
content: '';
z-index: 3;
box-shadow: 0 0 0 2px #3366ff !important;
}
} }
</style> </style>

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

@ -14,7 +14,7 @@ interface Props {
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
resize: true, resize: true,
hover: false, hover: false,
color: 'blue', color: 'gray',
size: 'small', size: 'small',
position: 'rounded', position: 'rounded',
}) })
@ -25,21 +25,22 @@ 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',
'bg-white border-gray-200': color === 'gray',
'shadow-md': hover, 'shadow-md': hover,
}" }"
class="relative transition-all flex items-center gap-2 group" 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'"
@ -50,8 +51,9 @@ 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-6 bg-blue-500 rounded-x rounded-l-md" class="w-1 min-h-6.5 rounded-l-[4px] bg-blue-500"
></div> ></div>
<div <div
@ -76,7 +78,7 @@ const emit = defineEmits(['resize-start'])
</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 <div

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

@ -11,7 +11,7 @@ withDefaults(defineProps<Props>(), {
resize: true, resize: true,
selected: false, selected: false,
hover: false, hover: false,
color: 'blue', color: 'gray',
}) })
const emit = defineEmits(['resize-start']) const emit = defineEmits(['resize-start'])
@ -26,9 +26,10 @@ const emit = defineEmits(['resize-start'])
'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',
'bg-white border-gray-200': color === 'gray',
'shadow-md': hover, 'shadow-md': hover,
}" }"
class="relative flex gap-2 relative rounded-md h-full" class="relative flex gap-2 border-1 relative rounded-md h-full"
> >
<div <div
v-if="resize" v-if="resize"
@ -43,6 +44,7 @@ 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="h-full min-h-3 w-1.25 -ml-0.25 rounded-l-md" class="h-full min-h-3 w-1.25 -ml-0.25 rounded-l-md"
></div> ></div>
@ -71,4 +73,10 @@ const emit = defineEmits(['resize-start'])
.cursor-row-resize { .cursor-row-resize {
cursor: ns-resize; cursor: ns-resize;
} }
.plain-cell {
.bold {
@apply !text-gray-800 font-semibold;
}
}
</style> </style>

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

@ -157,7 +157,7 @@ const calendarData = computed(() => {
style: { style: {
width: `calc(max(${spanDays} * ${perDayWidth}px, ${perDayWidth}px))`, width: `calc(max(${spanDays} * ${perDayWidth}px, ${perDayWidth}px))`,
left: `${startDaysDiff * perDayWidth}px`, left: `${startDaysDiff * perDayWidth}px`,
top: `${suitableRow * 28}px`, top: `${suitableRow * 28 + suitableRow * 8}px`,
}, },
}, },
}) })
@ -560,7 +560,6 @@ const addRecord = (date: dayjs.Dayjs) => {
: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')"
color="blue"
@dblclick.stop="emits('expandRecord', record)" @dblclick.stop="emits('expandRecord', record)"
@resize-start="onResizeStart" @resize-start="onResizeStart"
> >

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

@ -268,11 +268,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
@ -529,8 +531,8 @@ const recordsAcrossAllRange = computed<{
} }
record.rowMeta.style = { record.rowMeta.style = {
...record.rowMeta.style, ...record.rowMeta.style,
left: `calc(${majorLeft}px + ${left}%)`, left: `calc(${majorLeft}px + ${left}% + 4px)`,
width: `calc(${width}%)`, width: `calc(${width}% - 8px)`,
display, display,
} }
} }
@ -539,6 +541,7 @@ const recordsAcrossAllRange = computed<{
return { return {
records: recordsToDisplay, records: recordsToDisplay,
gridTimeMap, gridTimeMap,
spanningRecords: recordSpanningDays,
} }
}) })
@ -895,18 +898,24 @@ watch(
}, },
{ immediate: true }, { immediate: true },
) )
const expandRecord = (record: Row) => {
emits('expandRecord', record)
}
</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"
> >
<!-- mt-23 should be dynamic TODO: @DarkPhoenix2704 -->
<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 mt-23 pointer-events-none z-2"
:style="overlayStyle" :style="overlayStyle"
> >
<div class="flex w-full items-center"> <div class="flex w-full items-center">
@ -919,8 +928,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-2 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()"
@ -934,16 +942,25 @@ 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"> <!-- top-16 should be dynamic TODO: @DarkPhoenix2704 -->
<div class="absolute top-16 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 class="sticky top-6 bg-white z-3 inset-x-0 w-full">
<SmartsheetCalendarDateTimeSpanningContainer
:records="recordsAcrossAllRange.spanningRecords"
@expand-record="expandRecord"
/>
</div>
<!-- mt-16 should be dynamic TODO: @DarkPhoenix2704 -->
<div ref="container" class="absolute !mt-16 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"
@ -960,7 +977,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,
}" }"
@ -1015,7 +1032,6 @@ watch(
:position="record.rowMeta!.position" :position="record.rowMeta!.position"
: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"
> >
@ -1050,4 +1066,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: 3;
box-shadow: 0 0 0 2px #3366ff !important;
}
}
</style> </style>

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

@ -190,13 +190,18 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
<span class="text-gray-800"> <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
@ -204,27 +209,28 @@ 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>
<NcButton <NcButton
v-if="range.fk_to_column_id === null && isRangeEnabled" v-if="range.fk_to_column_id === null && isRangeEnabled"
@ -245,14 +251,18 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
{{ $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"
class="!rounded-r-none nc-select-shadow w-full flex-1 nc-to-select"
:disabled="!range.fk_from_column_id" :disabled="!range.fk_from_column_id"
:placeholder="$t('placeholder.notSelected')" :placeholder="$t('placeholder.notSelected')"
class="!rounded-r-none flex-1 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)
@ -261,16 +271,33 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
: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>

Loading…
Cancel
Save