mirror of
https://github.com/nocodb/nocodb.git
synced 2026-06-02 00:22:02 +00:00
Merge pull request #13935 from nocodb/nc-fix/cal-support-issues
Nc fix/cal support issues
This commit is contained in:
@@ -44,11 +44,6 @@ const disableToolbar = computed(
|
||||
isForm.value,
|
||||
)
|
||||
|
||||
const isTab = computed(() => {
|
||||
if (!isCalendar.value) return false
|
||||
return width.value > 1200
|
||||
})
|
||||
|
||||
/** EE only: Check if any filters are pinned to the toolbar.
|
||||
* Hidden for restricted editors in collaborative/locked views — they cannot modify filters.
|
||||
* Visible for personal view owners — they have full control over view config. */
|
||||
@@ -144,7 +139,6 @@ const isMobileSearchActive = computed(() => isMobileMode.value && isSearchExpand
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<SmartsheetToolbarCalendarMode v-if="isCalendar && isTab" :tab="isTab" />
|
||||
|
||||
<SmartsheetToolbarRowHeight v-if="(isGrid || isList) && isViewOperationsAllowed && !isMobileMode" />
|
||||
|
||||
@@ -176,7 +170,7 @@ const isMobileSearchActive = computed(() => isMobileMode.value && isSearchExpand
|
||||
|
||||
<div v-if="isCalendar && isMobileMode" class="flex-1 pointer-events-none" />
|
||||
|
||||
<SmartsheetToolbarCalendarMode v-if="isCalendar && !isTab" :tab="isTab" />
|
||||
<SmartsheetToolbarCalendarMode v-if="isCalendar" :tab="false" />
|
||||
|
||||
<SmartsheetToolbarCalendarRange v-if="isCalendar && isViewOperationsAllowed" />
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const emit = defineEmits(['newRecord', 'expandRecord'])
|
||||
const {
|
||||
selectedDate,
|
||||
selectedMonth,
|
||||
selectedDateRange,
|
||||
formattedData,
|
||||
formattedSideBarData,
|
||||
calDataType,
|
||||
@@ -19,6 +20,8 @@ const {
|
||||
updateFormat,
|
||||
timezoneDayjs,
|
||||
isSyncedFromColumn,
|
||||
weeksInRange,
|
||||
isMultiWeekRange,
|
||||
} = useCalendarViewStoreOrThrow()
|
||||
|
||||
const { isSyncedTable } = useSmartsheetStoreOrThrow()
|
||||
@@ -32,7 +35,9 @@ const { isUIAllowed } = useRoles()
|
||||
const meta = inject(MetaInj, ref())
|
||||
|
||||
const maxVisibleDays = computed(() => {
|
||||
return viewMetaProperties.value?.hide_weekend ? 5 : 7
|
||||
// Weekend-hiding is only honoured for the calendar month layout — multi-week
|
||||
// ranges always render the full 7-day grid so weeks line up with the anchor.
|
||||
return viewMetaProperties.value?.hide_weekend && !isMultiWeekRange.value ? 5 : 7
|
||||
})
|
||||
|
||||
const days = computed(() => {
|
||||
@@ -92,17 +97,27 @@ const fieldStyles = computed(() => {
|
||||
|
||||
const calendarData = computed(() => {
|
||||
// startOf and endOf dayjs is bugged with timezone
|
||||
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.value.startOf('month'))
|
||||
const firstDayOffset = isMondayFirst.value ? 0 : -1
|
||||
const firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week')).add(firstDayOffset, 'day')
|
||||
const today = timezoneDayjs.timezonize()
|
||||
|
||||
const daysInMonth = startOfMonth.daysInMonth()
|
||||
const firstDayOfMonth = startOfMonth.day()
|
||||
let firstDayToDisplay: dayjs.Dayjs
|
||||
let weeksNeeded: number
|
||||
|
||||
const adjustedFirstDay = isMondayFirst.value ? (firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1) : firstDayOfMonth
|
||||
|
||||
const weeksNeeded = Math.ceil((daysInMonth + adjustedFirstDay) / 7)
|
||||
if (isMultiWeekRange.value) {
|
||||
// 2-week / 6-week grids anchor to the Monday of the selected week and
|
||||
// always render a fixed number of full weeks. The 6-week range maxes the
|
||||
// backend's 42-day calendar fetch window exactly — don't extend further
|
||||
// without also raising the limit in calendar-datas.service.ts.
|
||||
firstDayToDisplay = timezoneDayjs.timezonize(selectedDateRange.value.start.startOf('week')).add(firstDayOffset, 'day')
|
||||
weeksNeeded = weeksInRange.value
|
||||
} else {
|
||||
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.value.startOf('month'))
|
||||
firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week')).add(firstDayOffset, 'day')
|
||||
const daysInMonth = startOfMonth.daysInMonth()
|
||||
const firstDayOfMonth = startOfMonth.day()
|
||||
const adjustedFirstDay = isMondayFirst.value ? (firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1) : firstDayOfMonth
|
||||
weeksNeeded = Math.ceil((daysInMonth + adjustedFirstDay) / 7)
|
||||
}
|
||||
|
||||
return {
|
||||
weeks: Array.from({ length: weeksNeeded }, (_, weekIndex) => ({
|
||||
@@ -115,7 +130,9 @@ const calendarData = computed(() => {
|
||||
key: `${weekIndex}-${dayIndex}`,
|
||||
isWeekend: day.get('day') === 0 || day.get('day') === 6,
|
||||
isToday: day.isSame(today, 'date'),
|
||||
isInPagedMonth: day.isSame(selectedMonth.value, 'month'),
|
||||
// For multi-week grids every cell is "in range" — the layout itself
|
||||
// is bounded to the visible window, so we don't fade non-month days.
|
||||
isInPagedMonth: isMultiWeekRange.value ? true : day.isSame(selectedMonth.value, 'month'),
|
||||
isVisible: maxVisibleDays.value === 5 ? day.get('day') !== 0 && day.get('day') !== 6 : true,
|
||||
dayNumber: day.format('DD'),
|
||||
}
|
||||
@@ -799,6 +816,7 @@ const addRecordWithRange = (range: any, date: dayjs.Dayjs) => {
|
||||
<div
|
||||
ref="calendarGridContainer"
|
||||
:class="{
|
||||
'grid-rows-2': calendarData.weeks.length === 2,
|
||||
'grid-rows-5': calendarData.weeks.length === 5,
|
||||
'grid-rows-6': calendarData.weeks.length === 6,
|
||||
'grid-rows-7': calendarData.weeks.length === 7,
|
||||
|
||||
@@ -109,6 +109,8 @@ const renderData = computed<Array<Row>>(() => {
|
||||
sideBarFilterOption.value === 'selectedDate' ||
|
||||
sideBarFilterOption.value === 'selectedHours' ||
|
||||
sideBarFilterOption.value === 'week' ||
|
||||
sideBarFilterOption.value === '2week' ||
|
||||
sideBarFilterOption.value === '6week' ||
|
||||
sideBarFilterOption.value === 'day'
|
||||
) {
|
||||
let fromDate: dayjs.Dayjs | null = null
|
||||
@@ -128,9 +130,16 @@ const renderData = computed<Array<Row>>(() => {
|
||||
toDate = timezoneDayjs.dayjsTz(selectedDate.value).endOf('day')
|
||||
break
|
||||
case 'week':
|
||||
case '2week':
|
||||
case '6week': {
|
||||
const weeks = sideBarFilterOption.value === '2week' ? 2 : sideBarFilterOption.value === '6week' ? 6 : 1
|
||||
fromDate = timezoneDayjs.dayjsTz(selectedDateRange.value.start).startOf('week')
|
||||
toDate = timezoneDayjs.dayjsTz(selectedDateRange.value.end).endOf('week')
|
||||
toDate = fromDate
|
||||
.clone()
|
||||
.add(weeks * 7 - 1, 'day')
|
||||
.endOf('day')
|
||||
break
|
||||
}
|
||||
case 'day':
|
||||
fromDate = timezoneDayjs.dayjsTz(selectedDate.value).startOf('day')
|
||||
toDate = timezoneDayjs.dayjsTz(selectedDate.value).endOf('day')
|
||||
@@ -170,6 +179,8 @@ const renderData = computed<Array<Row>>(() => {
|
||||
}
|
||||
} else if (
|
||||
sideBarFilterOption.value === 'week' ||
|
||||
sideBarFilterOption.value === '2week' ||
|
||||
sideBarFilterOption.value === '6week' ||
|
||||
sideBarFilterOption.value === 'month' ||
|
||||
sideBarFilterOption.value === 'year'
|
||||
) {
|
||||
@@ -178,9 +189,16 @@ const renderData = computed<Array<Row>>(() => {
|
||||
|
||||
switch (sideBarFilterOption.value) {
|
||||
case 'week':
|
||||
case '2week':
|
||||
case '6week': {
|
||||
const weeks = sideBarFilterOption.value === '2week' ? 2 : sideBarFilterOption.value === '6week' ? 6 : 1
|
||||
fromDate = timezoneDayjs.dayjsTz(selectedDateRange.value.start).startOf('week')
|
||||
toDate = timezoneDayjs.dayjsTz(selectedDateRange.value.end).endOf('week')
|
||||
toDate = fromDate
|
||||
.clone()
|
||||
.add(weeks * 7 - 1, 'day')
|
||||
.endOf('day')
|
||||
break
|
||||
}
|
||||
case 'month':
|
||||
fromDate = timezoneDayjs.dayjsTz(selectedMonth.value).startOf('month')
|
||||
toDate = timezoneDayjs.dayjsTz(selectedMonth.value).endOf('month')
|
||||
@@ -236,6 +254,20 @@ const options = computed(() => {
|
||||
{ label: 'Without dates', value: 'withoutDates' },
|
||||
]
|
||||
}
|
||||
case '2week' as const:
|
||||
return [
|
||||
{ label: 'All records', value: 'allRecords' },
|
||||
{ label: 'In selected range', value: '2week' },
|
||||
{ label: 'In selected date', value: 'selectedDate' },
|
||||
{ label: 'Without dates', value: 'withoutDates' },
|
||||
]
|
||||
case '6week' as const:
|
||||
return [
|
||||
{ label: 'All records', value: 'allRecords' },
|
||||
{ label: 'In selected range', value: '6week' },
|
||||
{ label: 'In selected date', value: 'selectedDate' },
|
||||
{ label: 'Without dates', value: 'withoutDates' },
|
||||
]
|
||||
case 'month' as const:
|
||||
return [
|
||||
{ label: 'All records', value: 'allRecords' },
|
||||
@@ -273,7 +305,11 @@ const newRecord = () => {
|
||||
let fromDate
|
||||
if (activeCalendarView.value === 'day') {
|
||||
fromDate = selectedDate.value
|
||||
} else if (activeCalendarView.value === 'week') {
|
||||
} else if (
|
||||
activeCalendarView.value === 'week' ||
|
||||
activeCalendarView.value === '2week' ||
|
||||
activeCalendarView.value === '6week'
|
||||
) {
|
||||
fromDate = selectedDateRange.value.start
|
||||
} else if (activeCalendarView.value === 'month') {
|
||||
fromDate = selectedDate.value ?? selectedMonth.value
|
||||
|
||||
@@ -130,6 +130,13 @@ const calendarData = computed(() => {
|
||||
const ogStartDate = startDate.clone()
|
||||
const endDate = fk_to_col && record.row[fk_to_col.title!] ? dayjs(record.row[fk_to_col.title!]) : startDate
|
||||
|
||||
// Single-date records with no end column cannot span; if the date sits outside
|
||||
// the visible week (e.g. backend over-fetched a boundary day), skip rather
|
||||
// than clamping it onto Monday with a cutoff indicator.
|
||||
if (!fk_to_col && !isInRange(ogStartDate)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (startDate.isBefore(selectedDateRange.value.start)) {
|
||||
startDate = dayjs(selectedDateRange.value.start)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ watch(
|
||||
<LazySmartsheetCalendarYearView v-if="activeCalendarView === 'year'" />
|
||||
<template v-if="!isCalendarDataLoading">
|
||||
<LazySmartsheetCalendarMonthView
|
||||
v-if="activeCalendarView === 'month'"
|
||||
v-if="activeCalendarView === 'month' || activeCalendarView === '2week' || activeCalendarView === '6week'"
|
||||
@expand-record="expandRecord"
|
||||
@new-record="newRecord"
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import type dayjs from 'dayjs'
|
||||
import { computed } from '#imports'
|
||||
|
||||
const { selectedDate, selectedMonth, selectedDateRange, activeCalendarView, activeDates, timezone, pageDate, timezoneDayjs } =
|
||||
useCalendarViewStoreOrThrow()
|
||||
const {
|
||||
selectedDate,
|
||||
selectedMonth,
|
||||
selectedDateRange,
|
||||
activeCalendarView,
|
||||
activeDates,
|
||||
timezone,
|
||||
pageDate,
|
||||
timezoneDayjs,
|
||||
weeksInRange,
|
||||
} = useCalendarViewStoreOrThrow()
|
||||
|
||||
const calendarRangeDropdown = ref(false)
|
||||
|
||||
const formatWeekRange = (startDate: dayjs.Dayjs, endDate: dayjs.Dayjs) => {
|
||||
if (startDate.isSame(endDate, 'month')) {
|
||||
return `${startDate.format('D')} - ${endDate.format('D MMM YY')}`
|
||||
} else if (startDate.isSame(endDate, 'year')) {
|
||||
return `${startDate.format('D MMM')} - ${endDate.format('D MMM YY')}`
|
||||
} else {
|
||||
return `${startDate.format('D MMM YY')} - ${endDate.format('D MMM YY')}`
|
||||
}
|
||||
}
|
||||
|
||||
const headerText = computed(() => {
|
||||
switch (activeCalendarView.value) {
|
||||
case 'day':
|
||||
@@ -13,13 +33,13 @@ const headerText = computed(() => {
|
||||
case 'week': {
|
||||
const startDate = timezoneDayjs.timezonize(selectedDateRange.value.start)
|
||||
const endDate = timezoneDayjs.timezonize(selectedDateRange.value.end)
|
||||
if (startDate.isSame(endDate, 'month')) {
|
||||
return `${startDate.format('D')} - ${endDate.format('D MMM YY')}`
|
||||
} else if (startDate.isSame(endDate, 'year')) {
|
||||
return `${startDate.format('D MMM')} - ${endDate.format('D MMM YY')}`
|
||||
} else {
|
||||
return `${startDate.format('D MMM YY')} - ${endDate.format('D MMM YY')}`
|
||||
}
|
||||
return formatWeekRange(startDate, endDate)
|
||||
}
|
||||
case '2week':
|
||||
case '6week': {
|
||||
const startDate = timezoneDayjs.timezonize(selectedDateRange.value.start)
|
||||
const endDate = startDate.add(weeksInRange.value * 7 - 1, 'day')
|
||||
return formatWeekRange(startDate, endDate)
|
||||
}
|
||||
case 'month':
|
||||
return timezoneDayjs.timezonize(selectedMonth.value).format('MMM YYYY')
|
||||
@@ -39,7 +59,7 @@ const headerText = computed(() => {
|
||||
'w-20': activeCalendarView === 'year',
|
||||
'w-26.5': activeCalendarView === 'month',
|
||||
'w-29': activeCalendarView === 'day',
|
||||
'w-38': activeCalendarView === 'week',
|
||||
'w-38': activeCalendarView === 'week' || activeCalendarView === '2week' || activeCalendarView === '6week',
|
||||
}"
|
||||
class="prev-next-btn !h-7"
|
||||
full-width
|
||||
@@ -49,7 +69,8 @@ const headerText = computed(() => {
|
||||
<div class="flex w-full px-1 items-center justify-between">
|
||||
<span
|
||||
:class="{
|
||||
'max-w-38 truncate': activeCalendarView === 'week',
|
||||
'max-w-38 truncate':
|
||||
activeCalendarView === 'week' || activeCalendarView === '2week' || activeCalendarView === '6week',
|
||||
}"
|
||||
class="font-bold text-[13px] text-center text-nc-content-gray"
|
||||
data-testid="nc-calendar-active-date"
|
||||
@@ -72,7 +93,11 @@ const headerText = computed(() => {
|
||||
size="medium"
|
||||
/>
|
||||
<NcDateWeekSelector
|
||||
v-else-if="activeCalendarView === ('week' as const)"
|
||||
v-else-if="
|
||||
activeCalendarView === ('week' as const) ||
|
||||
activeCalendarView === ('2week' as const) ||
|
||||
activeCalendarView === ('6week' as const)
|
||||
"
|
||||
v-model:active-dates="activeDates"
|
||||
v-model:page-date="pageDate"
|
||||
v-model:selected-week="selectedDateRange"
|
||||
|
||||
@@ -9,13 +9,21 @@ const isTab = computed(() => props.tab)
|
||||
|
||||
const highlightStyle = ref({ left: '0px' })
|
||||
|
||||
const setActiveCalendarMode = (mode: 'day' | 'week' | 'month' | 'year', event: MouseEvent) => {
|
||||
const setActiveCalendarMode = (mode: 'day' | 'week' | '2week' | 'month' | '6week' | 'year', event: MouseEvent) => {
|
||||
changeCalendarView(mode)
|
||||
const tabElement = event.target as HTMLElement
|
||||
highlightStyle.value.left = `${tabElement.offsetLeft}px`
|
||||
highlightStyle.value.width = `${tabElement.offsetWidth}px`
|
||||
}
|
||||
|
||||
const modeI18nKey = (mode: string) => {
|
||||
if (mode === '2week') return 'objects.twoWeek'
|
||||
if (mode === '6week') return 'objects.sixWeek'
|
||||
return `objects.${mode}`
|
||||
}
|
||||
|
||||
const modes: Array<'day' | 'week' | '2week' | 'month' | '6week' | 'year'> = ['day', 'week', '2week', 'month', '6week', 'year']
|
||||
|
||||
const updateHighlightPosition = () => {
|
||||
nextTick(() => {
|
||||
const activeTab = document.querySelector('.nc-calendar-mode-tab .tab.active') as HTMLElement
|
||||
@@ -49,7 +57,7 @@ watch(activeCalendarView, () => {
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-for="mode in ['day', 'week', 'month', 'year']"
|
||||
v-for="mode in modes"
|
||||
:key="mode"
|
||||
:data-testid="`nc-calendar-view-mode-${mode}`"
|
||||
class="cursor-pointer tab transition-all px-1 duration-300 flex items-center h-10 z-10 justify-center"
|
||||
@@ -59,33 +67,47 @@ watch(activeCalendarView, () => {
|
||||
}"
|
||||
@click="setActiveCalendarMode(mode, $event)"
|
||||
>
|
||||
<div class="min-w-0 pointer-events-none px-2 leading-[18px] text-[13px] transition-all duration-300">
|
||||
{{ $t(`objects.${mode}`) }}
|
||||
<div class="min-w-0 pointer-events-none px-2 leading-[18px] text-[13px] transition-all duration-300 whitespace-nowrap">
|
||||
{{ $t(modeI18nKey(mode)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
`option-label-prop="label"` makes the SELECTED chip render just the plain
|
||||
`label` string (not a clone of the full option template). Without it antd
|
||||
duplicates the `justify-between` + check-icon row into the chip, leaving
|
||||
the label visually off-centre and the wrong size.
|
||||
`:value` + `@change` (not `v-model`) routes selection through
|
||||
`changeCalendarView` so the view-meta default also gets persisted.
|
||||
-->
|
||||
<a-select
|
||||
v-else
|
||||
v-model:value="activeCalendarView"
|
||||
class="nc-select-shadow !w-21 !rounded-lg"
|
||||
dropdown-class-name="!rounded-lg !min-w-25"
|
||||
:value="activeCalendarView"
|
||||
class="nc-select-shadow !w-24 !rounded-lg"
|
||||
dropdown-class-name="!rounded-lg !min-w-28"
|
||||
size="small"
|
||||
option-label-prop="label"
|
||||
data-testid="nc-calendar-view-mode"
|
||||
@change="(value) => changeCalendarView(value as typeof modes[number])"
|
||||
@click.stop
|
||||
>
|
||||
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-nc-content-gray-subtle" /></template>
|
||||
|
||||
<a-select-option v-for="option in ['day', 'week', 'month', 'year']" :key="option" :value="option">
|
||||
<div class="w-full flex gap-2 items-center justify-between" :title="option">
|
||||
<a-select-option v-for="option in modes" :key="option" :value="option" :label="$t(modeI18nKey(option))">
|
||||
<div
|
||||
class="w-full flex gap-2 items-center justify-between text-[13px]"
|
||||
:data-testid="`nc-calendar-view-mode-option-${option}`"
|
||||
:title="$t(modeI18nKey(option))"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<NcTooltip class="flex-1 capitalize mt-0.5 truncate" show-on-truncate-only>
|
||||
<NcTooltip class="flex-1 mt-0.5 truncate text-[13px]" show-on-truncate-only>
|
||||
<template #title>
|
||||
{{ option }}
|
||||
{{ $t(modeI18nKey(option)) }}
|
||||
</template>
|
||||
<template #default>{{ option }}</template>
|
||||
<template #default>{{ $t(modeI18nKey(option)) }}</template>
|
||||
</NcTooltip>
|
||||
</div>
|
||||
<GeneralIcon
|
||||
@@ -113,5 +135,13 @@ watch(activeCalendarView, () => {
|
||||
|
||||
:deep(.ant-select-selector) {
|
||||
@apply !h-7;
|
||||
|
||||
// With option-label-prop="label" the chip renders just the plain label.
|
||||
// antd's default line-height is shorter than our 28px chip, so the text
|
||||
// hugs the top — match the line-height to the chip height to centre it.
|
||||
.ant-select-selection-item {
|
||||
@apply !text-[13px] !text-center !font-medium;
|
||||
line-height: 28px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
const { activeCalendarView, paginateCalendarView } = useCalendarViewStoreOrThrow()
|
||||
|
||||
// Holding Shift while clicking prev/next nudges by exactly 1 week instead of
|
||||
// the natural per-mode step (1 month for Month, 1 day for Day, 2/6 weeks for
|
||||
// the multi-week modes). Hidden in Week/Year — the natural step already is
|
||||
// "one week" / "one year" so a Shift override would have no distinct meaning.
|
||||
const supportsWeekStep = computed(
|
||||
() =>
|
||||
activeCalendarView.value === 'month' ||
|
||||
activeCalendarView.value === 'day' ||
|
||||
activeCalendarView.value === '2week' ||
|
||||
activeCalendarView.value === '6week',
|
||||
)
|
||||
|
||||
const onNavigate = (action: 'next' | 'prev', event: MouseEvent) => {
|
||||
paginateCalendarView(action, event.shiftKey && supportsWeekStep.value ? 'week' : undefined)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<NcTooltip hide-on-click>
|
||||
<template #title> {{ $t('labels.previous') }}</template>
|
||||
<template #title>
|
||||
{{ $t('labels.previous') }}
|
||||
<div v-if="supportsWeekStep" class="text-xs text-nc-content-gray-muted">{{ $t('tooltip.shiftClickWeekStep') }}</div>
|
||||
</template>
|
||||
|
||||
<NcButton
|
||||
v-e="`['c:calendar:calendar-${activeCalendarView}-prev-btn']`"
|
||||
@@ -14,13 +33,16 @@ const { activeCalendarView, paginateCalendarView } = useCalendarViewStoreOrThrow
|
||||
data-testid="nc-calendar-prev-btn"
|
||||
size="xs"
|
||||
type="text"
|
||||
@click="paginateCalendarView('prev')"
|
||||
@click="onNavigate('prev', $event)"
|
||||
>
|
||||
<GeneralIcon icon="ncChevronLeft" class="h-4 !-ml-0.5 w-4" />
|
||||
</NcButton>
|
||||
</NcTooltip>
|
||||
<NcTooltip hide-on-click>
|
||||
<template #title> {{ $t('labels.next') }}</template>
|
||||
<template #title>
|
||||
{{ $t('labels.next') }}
|
||||
<div v-if="supportsWeekStep" class="text-xs text-nc-content-gray-muted">{{ $t('tooltip.shiftClickWeekStep') }}</div>
|
||||
</template>
|
||||
<NcButton
|
||||
v-e="`['c:calendar:calendar-${activeCalendarView}-next-btn']`"
|
||||
class="!w-7 !h-7 !rounded-lg !hover:(text-nc-content-gray-subtle) prev-next-btn"
|
||||
@@ -28,7 +50,7 @@ const { activeCalendarView, paginateCalendarView } = useCalendarViewStoreOrThrow
|
||||
data-testid="nc-calendar-next-btn"
|
||||
size="xs"
|
||||
type="text"
|
||||
@click="paginateCalendarView('next')"
|
||||
@click="onNavigate('next', $event)"
|
||||
>
|
||||
<GeneralIcon icon="ncChevronRight" class="h-4 !-ml-0.2 w-4" />
|
||||
</NcButton>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { isClient } from '@vueuse/core'
|
||||
import { EventType, FormulaDataTypes, UITypes, ViewTypes, isSystemColumn, isVirtualCol, workerWithTimezone } from 'nocodb-sdk'
|
||||
import type {
|
||||
Api,
|
||||
@@ -185,13 +186,22 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
// show/hide side menu in calendar
|
||||
const showSideMenu = ref(!isMobileMode.value)
|
||||
|
||||
// reactive ref for the selected date range - used in week view
|
||||
// reactive ref for the selected date range - used in week / 2week / 6week views.
|
||||
//
|
||||
// `start` is always the Monday of the active week. `end` is only meaningful
|
||||
// for the 1-week layout (Sunday after `start`) and is intentionally NOT
|
||||
// resized when 2week/6week is active — the WeekView renderer relies on the
|
||||
// `start + 6 days` semantics, and the multi-week consumers derive their
|
||||
// true end as `start + weeksInRange*7 - 1` on demand.
|
||||
//
|
||||
// `startOf('week')` returns Monday because `dayjs.updateLocale('en', {
|
||||
// weekStart: 1 })` is set globally in plugins/a.dayjs.ts.
|
||||
const selectedDateRange = ref<{
|
||||
start: dayjs.Dayjs
|
||||
end: dayjs.Dayjs
|
||||
}>({
|
||||
start: timezoneDayjs.dayjsTz(selectedDate.value)!.startOf('week'), // This will be the previous Monday
|
||||
end: timezoneDayjs.dayjsTz(selectedDate.value)!.startOf('week').add(6, 'day'), // This will be the following Sunday
|
||||
start: timezoneDayjs.dayjsTz(selectedDate.value)!.startOf('week'),
|
||||
end: timezoneDayjs.dayjsTz(selectedDate.value)!.startOf('week').add(6, 'day'),
|
||||
})
|
||||
|
||||
const defaultPageSize = 25
|
||||
@@ -204,7 +214,89 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
|
||||
const activeDates = ref<dayjs.Dayjs[]>([])
|
||||
|
||||
const activeCalendarView = ref<'month' | 'year' | 'day' | 'week'>((viewMetaProperties.value?.active_view as any) ?? 'month')
|
||||
// Per-view, per-user override for the active mode — kept in localStorage so a
|
||||
// reload or new tab restores the user's last choice without overwriting the
|
||||
// view creator's default (`viewMetaProperties.active_view`).
|
||||
const CALENDAR_MODE_STORAGE_PREFIX = 'nc-calendar-mode:'
|
||||
const validCalendarModes = ['day', 'week', '2week', 'month', '6week', 'year'] as const
|
||||
type CalendarMode = (typeof validCalendarModes)[number]
|
||||
|
||||
const calendarModeStorageKey = () => (viewMeta.value?.id ? `${CALENDAR_MODE_STORAGE_PREFIX}${viewMeta.value.id}` : null)
|
||||
|
||||
const readStoredCalendarMode = (): CalendarMode | null => {
|
||||
if (!isClient) return null
|
||||
const key = calendarModeStorageKey()
|
||||
if (!key) return null
|
||||
try {
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored && (validCalendarModes as readonly string[]).includes(stored)) {
|
||||
return stored as CalendarMode
|
||||
}
|
||||
} catch {
|
||||
// localStorage can throw in private mode / when quota exceeded — silent fallback.
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const writeStoredCalendarMode = (mode: CalendarMode) => {
|
||||
if (!isClient) return
|
||||
const key = calendarModeStorageKey()
|
||||
if (!key) return
|
||||
try {
|
||||
localStorage.setItem(key, mode)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const activeCalendarView = ref<CalendarMode>(
|
||||
readStoredCalendarMode() ?? (viewMetaProperties.value?.active_view as CalendarMode | undefined) ?? 'month',
|
||||
)
|
||||
|
||||
// On a cold page load `viewMeta.value` (and therefore its `id`) is often
|
||||
// undefined when the ref above is initialised, so the localStorage lookup
|
||||
// is skipped. Restore once the view id becomes available.
|
||||
//
|
||||
// Keyed by id (not a boolean) so that if the same store instance is reused
|
||||
// across view switches (eg. <keep-alive>, route param change), the new
|
||||
// view's stored mode is re-applied instead of leaking the previous view's.
|
||||
let restoredForCalendarId: string | null = viewMeta.value?.id ?? null
|
||||
if (restoredForCalendarId && readStoredCalendarMode() === null) {
|
||||
// viewMeta was ready at construction but storage was empty — nothing to
|
||||
// restore, mark as handled so the immediate watcher doesn't re-do it.
|
||||
} else {
|
||||
restoredForCalendarId = null
|
||||
}
|
||||
watch(
|
||||
() => viewMeta.value?.id,
|
||||
(id) => {
|
||||
if (!id || id === restoredForCalendarId) return
|
||||
const stored = readStoredCalendarMode()
|
||||
if (stored) {
|
||||
activeCalendarView.value = stored
|
||||
} else {
|
||||
const fromMeta = viewMetaProperties.value?.active_view as CalendarMode | undefined
|
||||
if (fromMeta && fromMeta !== activeCalendarView.value) {
|
||||
activeCalendarView.value = fromMeta
|
||||
}
|
||||
}
|
||||
restoredForCalendarId = id
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(activeCalendarView, (value) => {
|
||||
writeStoredCalendarMode(value)
|
||||
})
|
||||
|
||||
// Number of consecutive weeks rendered by the multi-week grid (week / 2week / 6week).
|
||||
const weeksInRange = computed(() => {
|
||||
if (activeCalendarView.value === '2week') return 2
|
||||
if (activeCalendarView.value === '6week') return 6
|
||||
return 1
|
||||
})
|
||||
|
||||
const isMultiWeekRange = computed(() => activeCalendarView.value === '2week' || activeCalendarView.value === '6week')
|
||||
|
||||
// The active filter in the sidebar
|
||||
const sideBarFilterOption = ref<string>(activeCalendarView.value ?? 'allRecords')
|
||||
@@ -295,6 +387,8 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
]
|
||||
} else if (
|
||||
sideBarFilterOption.value === 'week' ||
|
||||
sideBarFilterOption.value === '2week' ||
|
||||
sideBarFilterOption.value === '6week' ||
|
||||
sideBarFilterOption.value === 'month' ||
|
||||
sideBarFilterOption.value === 'day' ||
|
||||
sideBarFilterOption.value === 'year' ||
|
||||
@@ -315,11 +409,18 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
nextDate = selectedDate.value.add(1, 'day').startOf('day')
|
||||
break
|
||||
case 'week':
|
||||
case '2week':
|
||||
case '6week': {
|
||||
const weeks = sideBarFilterOption.value === '2week' ? 2 : sideBarFilterOption.value === '6week' ? 6 : 1
|
||||
fromDate = selectedDateRange.value.start.startOf('week')
|
||||
toDate = selectedDateRange.value.end.endOf('week')
|
||||
toDate = fromDate
|
||||
.clone()
|
||||
.add(weeks * 7 - 1, 'day')
|
||||
.endOf('day')
|
||||
prevDate = timezoneDayjs.timezonize(fromDate.subtract(1, 'day')).endOf('day')
|
||||
nextDate = timezoneDayjs.timezonize(toDate.add(1, 'day')).startOf('day')
|
||||
break
|
||||
}
|
||||
case 'month': {
|
||||
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.value.startOf('month'))
|
||||
const firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week'))
|
||||
@@ -540,7 +641,12 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
let toDate: dayjs.Dayjs | null | string = null
|
||||
let nextDate: string | null | dayjs.Dayjs = null
|
||||
|
||||
if (activeCalendarView.value === 'week' || activeCalendarView.value === 'day') {
|
||||
if (
|
||||
activeCalendarView.value === 'week' ||
|
||||
activeCalendarView.value === 'day' ||
|
||||
activeCalendarView.value === '2week' ||
|
||||
activeCalendarView.value === '6week'
|
||||
) {
|
||||
const startOfMonth = timezoneDayjs.timezonize(pageDate.value.startOf('month'))
|
||||
fromDate = timezoneDayjs.timezonize(startOfMonth.startOf('week'))
|
||||
toDate = timezoneDayjs.timezonize(pageDate.value.endOf('month').endOf('week'))
|
||||
@@ -600,7 +706,7 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
}
|
||||
|
||||
// Update the calendar view
|
||||
const changeCalendarView = async (view: 'month' | 'year' | 'day' | 'week') => {
|
||||
const changeCalendarView = async (view: 'month' | 'year' | 'day' | 'week' | '2week' | '6week') => {
|
||||
$e('c:calendar:change-calendar-view', view)
|
||||
|
||||
try {
|
||||
@@ -615,7 +721,7 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
})
|
||||
}
|
||||
|
||||
if (activeCalendarView.value === 'week') {
|
||||
if (activeCalendarView.value === 'week' || activeCalendarView.value === '2week' || activeCalendarView.value === '6week') {
|
||||
selectedTime.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -654,18 +760,24 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
toDate = selectedDate.value.endOf('day')
|
||||
break
|
||||
case 'week':
|
||||
case '2week':
|
||||
case '6week': {
|
||||
fromDate = selectedDateRange.value.start.startOf('week')
|
||||
toDate = selectedDateRange.value.end.endOf('week')
|
||||
toDate = fromDate
|
||||
.clone()
|
||||
.add(weeksInRange.value * 7 - 1, 'day')
|
||||
.endOf('day')
|
||||
|
||||
prevDate = timezoneDayjs.timezonize(fromDate.subtract(1, 'day')).endOf('day')
|
||||
nextDate = timezoneDayjs.timezonize(toDate.add(1, 'day')).startOf('day')
|
||||
|
||||
// Hide weekends
|
||||
if (viewMetaProperties.value?.hide_weekend) {
|
||||
// Hide weekends (only valid for the single-week layout)
|
||||
if (activeCalendarView.value === 'week' && viewMetaProperties.value?.hide_weekend) {
|
||||
toDate = timezoneDayjs.timezonize(toDate.subtract(2, 'day')).endOf('day')
|
||||
nextDate = timezoneDayjs.timezonize(nextDate!.subtract(2, 'day')).startOf('day')
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'month': {
|
||||
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.value.startOf('month'))
|
||||
const firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week'))
|
||||
@@ -722,7 +834,38 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
}
|
||||
}
|
||||
|
||||
const paginateCalendarView = async (action: 'next' | 'prev') => {
|
||||
const paginateCalendarView = async (action: 'next' | 'prev', step?: 'week') => {
|
||||
// Allow callers to override the natural step (e.g. Shift+Click in Month view
|
||||
// to step by 1 week instead of 1 month).
|
||||
if (step === 'week') {
|
||||
const dayShift = action === 'next' ? 7 : -7
|
||||
if (activeCalendarView.value === 'month') {
|
||||
selectedMonth.value = selectedMonth.value.add(dayShift, 'day')
|
||||
selectedDate.value = selectedDate.value.add(dayShift, 'day')
|
||||
if (pageDate.value.month() !== selectedMonth.value.month()) {
|
||||
pageDate.value = selectedMonth.value
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeCalendarView.value === 'day') {
|
||||
selectedDate.value = selectedDate.value.add(dayShift, 'day')
|
||||
selectedTime.value = selectedDate.value
|
||||
if (pageDate.value.month() !== selectedDate.value.month()) {
|
||||
pageDate.value = selectedDate.value
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeCalendarView.value === '2week' || activeCalendarView.value === '6week') {
|
||||
// Shift the multi-week window by exactly 1 week instead of the
|
||||
// natural 2/6-week step — useful when nudging a 6-week planning grid.
|
||||
selectedDateRange.value = {
|
||||
start: selectedDateRange.value.start.add(dayShift, 'day'),
|
||||
end: selectedDateRange.value.end.add(dayShift, 'day'),
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch (activeCalendarView.value) {
|
||||
case 'month':
|
||||
selectedMonth.value = action === 'next' ? selectedMonth.value.add(1, 'month') : selectedMonth.value.subtract(1, 'month')
|
||||
@@ -748,20 +891,25 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
}
|
||||
break
|
||||
case 'week':
|
||||
selectedDateRange.value =
|
||||
action === 'next'
|
||||
? {
|
||||
start: selectedDateRange.value.start.add(7, 'day'),
|
||||
end: selectedDateRange.value.end.add(7, 'day'),
|
||||
}
|
||||
: {
|
||||
start: selectedDateRange.value.start.subtract(7, 'day'),
|
||||
end: selectedDateRange.value.end.subtract(7, 'day'),
|
||||
}
|
||||
if (pageDate.value.month() !== selectedDateRange.value.end.month()) {
|
||||
case '2week':
|
||||
case '6week': {
|
||||
const dayShift = (action === 'next' ? 1 : -1) * weeksInRange.value * 7
|
||||
selectedDateRange.value = {
|
||||
start: selectedDateRange.value.start.add(dayShift, 'day'),
|
||||
// .end is kept as start + 6 days for back-compat with the WeekView
|
||||
// renderer; multi-week ranges derive their real end from
|
||||
// `start + weeksInRange*7 - 1` (see visibleRangeEnd below).
|
||||
end: selectedDateRange.value.end.add(dayShift, 'day'),
|
||||
}
|
||||
// Re-sync pageDate against the LAST visible day of the new window — not
|
||||
// selectedDateRange.end, which only covers the first week for 2/6-week
|
||||
// modes and would let pageDate drift up to 5 weeks behind the grid.
|
||||
const visibleRangeEnd = selectedDateRange.value.start.add(weeksInRange.value * 7 - 1, 'day')
|
||||
if (pageDate.value.month() !== visibleRangeEnd.month()) {
|
||||
pageDate.value = selectedDateRange.value.start
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,7 +1003,12 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
}
|
||||
|
||||
watch(selectedDate, async (value, oldValue) => {
|
||||
if (activeCalendarView.value === 'month' || activeCalendarView.value === 'week') {
|
||||
if (
|
||||
activeCalendarView.value === 'month' ||
|
||||
activeCalendarView.value === 'week' ||
|
||||
activeCalendarView.value === '2week' ||
|
||||
activeCalendarView.value === '6week'
|
||||
) {
|
||||
if (sideBarFilterOption.value === 'selectedDate' && showSideMenu.value) {
|
||||
await loadSidebarData()
|
||||
}
|
||||
@@ -890,12 +1043,14 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
})
|
||||
|
||||
watch(selectedDateRange, async () => {
|
||||
if (activeCalendarView.value !== 'week') return
|
||||
if (activeCalendarView.value !== 'week' && activeCalendarView.value !== '2week' && activeCalendarView.value !== '6week') {
|
||||
return
|
||||
}
|
||||
await Promise.all([loadCalendarData(), loadSidebarData()])
|
||||
})
|
||||
|
||||
watch(activeCalendarView, async (value, oldValue) => {
|
||||
if (oldValue === 'week') {
|
||||
if (oldValue === 'week' || oldValue === '2week' || oldValue === '6week') {
|
||||
pageDate.value = selectedDate.value
|
||||
selectedMonth.value = selectedDate.value ?? selectedDateRange.value.start
|
||||
selectedDate.value = selectedDate.value ?? selectedDateRange.value.start
|
||||
@@ -1319,6 +1474,8 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
|
||||
timezoneDayjs,
|
||||
timezone,
|
||||
isSyncedFromColumn,
|
||||
weeksInRange,
|
||||
isMultiWeekRange,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -703,6 +703,7 @@
|
||||
"days": "Days",
|
||||
"week": "Week",
|
||||
"twoWeek": "2 Weeks",
|
||||
"sixWeek": "6 Weeks",
|
||||
"month": "Month",
|
||||
"quarter": "Quarter",
|
||||
"sixMonth": "6 Months",
|
||||
@@ -3120,6 +3121,7 @@
|
||||
"goBackHome": "Go back home"
|
||||
},
|
||||
"tooltip": {
|
||||
"shiftClickWeekStep": "Hold Shift to step by 1 week",
|
||||
"milestone": "Milestone",
|
||||
"allowSyncDescription": "Let other bases sync data from this view",
|
||||
"enableAllowSyncOnView": "Enable \"Allow sync\" on this view first",
|
||||
|
||||
@@ -46,7 +46,7 @@ const isRowInCurrentDateRange = (
|
||||
id: string
|
||||
is_readonly: boolean
|
||||
}>,
|
||||
activeCalendarView: 'month' | 'year' | 'day' | 'week',
|
||||
activeCalendarView: 'month' | 'year' | 'day' | 'week' | '2week' | '6week',
|
||||
selectedDate: dayjs.Dayjs,
|
||||
selectedDateRange: { start: dayjs.Dayjs; end: dayjs.Dayjs },
|
||||
selectedMonth: dayjs.Dayjs,
|
||||
@@ -81,6 +81,16 @@ const isRowInCurrentDateRange = (
|
||||
viewStartDate = selectedDateRange.start.startOf('week')
|
||||
viewEndDate = selectedDateRange.end.endOf('week')
|
||||
break
|
||||
case '2week':
|
||||
case '6week': {
|
||||
const weeks = activeCalendarView === '2week' ? 2 : 6
|
||||
viewStartDate = selectedDateRange.start.startOf('week')
|
||||
viewEndDate = viewStartDate
|
||||
.clone()
|
||||
.add(weeks * 7 - 1, 'day')
|
||||
.endOf('day')
|
||||
break
|
||||
}
|
||||
case 'month': {
|
||||
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.startOf('month'))
|
||||
viewStartDate = timezoneDayjs.timezonize(startOfMonth.startOf('week'))
|
||||
@@ -162,6 +172,17 @@ const isRowMatchingSidebarFilter = (
|
||||
timezoneDayjs,
|
||||
)
|
||||
|
||||
case '2week':
|
||||
case '6week': {
|
||||
const weeks = sideBarFilterOption === '2week' ? 2 : 6
|
||||
const start = selectedDateRange.start.startOf('week')
|
||||
const end = start
|
||||
.clone()
|
||||
.add(weeks * 7 - 1, 'day')
|
||||
.endOf('day')
|
||||
return isRowInDateRange(rowData, start, end, calendarRange, timezoneDayjs)
|
||||
}
|
||||
|
||||
case 'month': {
|
||||
const startOfMonth = timezoneDayjs.timezonize(selectedMonth.startOf('month'))
|
||||
const firstDayToDisplay = timezoneDayjs.timezonize(startOfMonth.startOf('week'))
|
||||
|
||||
@@ -248,6 +248,7 @@ export class CalendarDatasService {
|
||||
context: NcContext,
|
||||
{
|
||||
viewId,
|
||||
from_date,
|
||||
next_date,
|
||||
prev_date,
|
||||
isDate,
|
||||
@@ -273,7 +274,10 @@ export class CalendarDatasService {
|
||||
if (isDate) {
|
||||
const regex = /^\d{4}-\d{2}-\d{2}/;
|
||||
next_date = next_date.match(regex)?.[0] || next_date;
|
||||
prev_date = prev_date.match(regex)?.[0] || prev_date;
|
||||
// Date-only columns lose the time portion when stripped, so `>= prev_date`
|
||||
// would include the entire day BEFORE the visible range. Use the stripped
|
||||
// from_date (first day of the range) as the inclusive lower bound instead.
|
||||
prev_date = from_date.match(regex)?.[0] || from_date;
|
||||
}
|
||||
|
||||
calendarRange?.ranges.forEach((range: CalendarRange) => {
|
||||
|
||||
Reference in New Issue
Block a user