mirror of https://github.com/nocodb/nocodb
105 changed files with 10259 additions and 503 deletions
@ -0,0 +1,219 @@
|
||||
<script lang="ts" setup> |
||||
import dayjs from 'dayjs' |
||||
|
||||
interface Props { |
||||
selectedDate?: dayjs.Dayjs | null |
||||
isDisabled?: boolean |
||||
pageDate?: dayjs.Dayjs |
||||
activeDates?: Array<dayjs.Dayjs> |
||||
isMondayFirst?: boolean |
||||
isWeekPicker?: boolean |
||||
disablePagination?: boolean |
||||
selectedWeek?: { |
||||
start: dayjs.Dayjs |
||||
end: dayjs.Dayjs |
||||
} | null |
||||
} |
||||
|
||||
const props = withDefaults(defineProps<Props>(), { |
||||
selectedDate: null, |
||||
isDisabled: false, |
||||
isMondayFirst: true, |
||||
pageDate: dayjs(), |
||||
isWeekPicker: false, |
||||
disablePagination: false, |
||||
activeDates: [] as Array<dayjs.Dayjs>, |
||||
selectedWeek: null, |
||||
}) |
||||
const emit = defineEmits(['change', 'update:selectedDate', 'update:pageDate', 'update:selectedWeek']) |
||||
// Page date is the date we use to manage which month/date that is currently being displayed |
||||
const pageDate = useVModel(props, 'pageDate', emit) |
||||
|
||||
const selectedDate = useVModel(props, 'selectedDate', emit) |
||||
|
||||
const activeDates = useVModel(props, 'activeDates', emit) |
||||
|
||||
const selectedWeek = useVModel(props, 'selectedWeek', emit) |
||||
|
||||
const days = computed(() => { |
||||
if (props.isMondayFirst) { |
||||
return ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] |
||||
} else { |
||||
return ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] |
||||
} |
||||
}) |
||||
|
||||
// Used to display the current month and year |
||||
const currentMonthYear = computed(() => { |
||||
return dayjs(pageDate.value).format('MMMM YYYY') |
||||
}) |
||||
|
||||
const selectWeek = (date: dayjs.Dayjs) => { |
||||
const dayOffset = +props.isMondayFirst |
||||
const dayOfWeek = (date.day() - dayOffset + 7) % 7 |
||||
const startDate = date.subtract(dayOfWeek, 'day') |
||||
selectedWeek.value = { |
||||
start: startDate, |
||||
end: startDate.endOf('week'), |
||||
} |
||||
} |
||||
|
||||
// Generates all dates should be displayed in the calendar |
||||
// Includes all blank days at the start and end of the month |
||||
const dates = computed(() => { |
||||
const startOfMonth = dayjs(pageDate.value).startOf('month') |
||||
const dayOffset = +props.isMondayFirst |
||||
const firstDayOfWeek = startOfMonth.day() |
||||
const startDay = startOfMonth.subtract((firstDayOfWeek - dayOffset + 7) % 7, 'day') |
||||
|
||||
const datesArray = [] |
||||
for (let i = 0; i < 42; i++) { |
||||
datesArray.push(startDay.add(i, 'day')) |
||||
} |
||||
|
||||
return datesArray |
||||
}) |
||||
|
||||
// Check if the date is in the selected week |
||||
const isDateInSelectedWeek = (date: dayjs.Dayjs) => { |
||||
if (!selectedWeek.value) return false |
||||
return date.isBetween(selectedWeek.value.start, selectedWeek.value.end, 'day', '[]') |
||||
} |
||||
|
||||
// Used to check if two dates are the same |
||||
const isSameDate = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => { |
||||
if (!date1 || !date2) return false |
||||
return date1.isSame(date2, 'day') |
||||
} |
||||
|
||||
// Used in DatePicker for checking if the date is currently selected |
||||
const isSelectedDate = (dObj: dayjs.Dayjs) => { |
||||
if (!selectedDate.value) return false |
||||
const propDate = dayjs(selectedDate.value) |
||||
return props.selectedDate ? isSameDate(propDate, dObj) : false |
||||
} |
||||
|
||||
const isDayInPagedMonth = (date: dayjs.Dayjs) => { |
||||
return date.month() === dayjs(pageDate.value).month() |
||||
} |
||||
|
||||
// Since we are using the same component for week picker and date picker we need to handle the date selection differently |
||||
const handleSelectDate = (date: dayjs.Dayjs) => { |
||||
if (props.isWeekPicker) { |
||||
selectWeek(date) |
||||
} else { |
||||
if (!isDayInPagedMonth(date)) { |
||||
pageDate.value = date |
||||
emit('update:pageDate', date) |
||||
} |
||||
selectedDate.value = date |
||||
emit('update:selectedDate', date) |
||||
} |
||||
} |
||||
|
||||
// Used to check if a date is in the current month |
||||
const isDateInCurrentMonth = (date: dayjs.Dayjs) => { |
||||
return date.month() === dayjs(pageDate.value).month() |
||||
} |
||||
|
||||
// Used to Check if an event is in the date |
||||
const isActiveDate = (date: dayjs.Dayjs) => { |
||||
return activeDates.value.some((d) => isSameDate(d, date)) |
||||
} |
||||
|
||||
// Paginate the calendar |
||||
const paginate = (action: 'next' | 'prev') => { |
||||
let newDate = dayjs(pageDate.value) |
||||
if (action === 'next') { |
||||
newDate = newDate.add(1, 'month') |
||||
} else { |
||||
newDate = newDate.subtract(1, 'month') |
||||
} |
||||
pageDate.value = newDate |
||||
emit('update:pageDate', newDate) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="px-4 pt-3 pb-4 flex flex-col gap-4"> |
||||
<div |
||||
:class="{ |
||||
'justify-center': disablePagination, |
||||
'justify-between': !disablePagination, |
||||
}" |
||||
class="flex items-center" |
||||
> |
||||
<NcTooltip> |
||||
<NcButton v-if="!disablePagination" size="small" type="secondary" @click="paginate('prev')"> |
||||
<component :is="iconMap.doubleLeftArrow" class="h-4 w-4" /> |
||||
</NcButton> |
||||
<template #title> |
||||
<span>{{ $t('labels.previousMonth') }}</span> |
||||
</template> |
||||
</NcTooltip> |
||||
|
||||
<span class="font-bold text-gray-700">{{ currentMonthYear }}</span> |
||||
<NcTooltip> |
||||
<NcButton v-if="!disablePagination" size="small" type="secondary" @click="paginate('next')"> |
||||
<component :is="iconMap.doubleRightArrow" class="h-4 w-4" /> |
||||
</NcButton> |
||||
<template #title> |
||||
<span>{{ $t('labels.nextMonth') }}</span> |
||||
</template> |
||||
</NcTooltip> |
||||
</div> |
||||
<div class="border-1 border-gray-200 rounded-y-xl max-w-[320px]"> |
||||
<div class="flex flex-row bg-gray-100 gap-2 rounded-t-xl justify-between px-2"> |
||||
<span v-for="(day, index) in days" :key="index" class="h-9 flex items-center justify-center w-9 text-gray-500">{{ |
||||
day |
||||
}}</span> |
||||
</div> |
||||
<div class="grid grid-cols-7 gap-2 p-2"> |
||||
<span |
||||
v-for="(date, index) in dates" |
||||
:key="index" |
||||
:class="{ |
||||
'rounded-lg': !isWeekPicker, |
||||
'bg-brand-50 border-1 !border-brand-500': isSelectedDate(date) && !isWeekPicker && isDayInPagedMonth(date), |
||||
'hover:(border-1 border-gray-200 bg-gray-100)': !isSelectedDate(date) && !isWeekPicker, |
||||
'nc-selected-week z-1': isDateInSelectedWeek(date) && isWeekPicker, |
||||
'text-gray-400': !isDateInCurrentMonth(date), |
||||
'nc-selected-week-start': isSameDate(date, selectedWeek?.start), |
||||
'nc-selected-week-end': isSameDate(date, selectedWeek?.end), |
||||
'rounded-md bg-brand-50 text-brand-500': isSameDate(date, dayjs()) && isDateInCurrentMonth(date), |
||||
}" |
||||
class="h-9 w-9 px-1 py-2 relative font-medium flex items-center cursor-pointer justify-center" |
||||
data-testid="nc-calendar-date" |
||||
@click="handleSelectDate(date)" |
||||
> |
||||
<span v-if="isActiveDate(date)" class="absolute z-2 h-1.5 w-1.5 rounded-full bg-brand-500 top-1 right-1"></span> |
||||
<span class="z-2"> |
||||
{{ date.get('date') }} |
||||
</span> |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.nc-selected-week { |
||||
@apply relative; |
||||
} |
||||
|
||||
.nc-selected-week:before { |
||||
@apply absolute top-0 left-0 w-full h-full border-y-1 bg-brand-50 border-brand-500; |
||||
content: ''; |
||||
width: 124%; |
||||
height: 100%; |
||||
} |
||||
|
||||
.nc-selected-week-start:before { |
||||
@apply !border-l-1 !rounded-l-lg; |
||||
} |
||||
|
||||
.nc-selected-week-end:before { |
||||
width: 100%; |
||||
@apply !border-r-1 !rounded-r-lg; |
||||
} |
||||
</style> |
@ -0,0 +1,141 @@
|
||||
<script lang="ts" setup> |
||||
import dayjs from 'dayjs' |
||||
|
||||
interface Props { |
||||
selectedDate?: dayjs.Dayjs | null |
||||
isDisabled?: boolean |
||||
pageDate?: dayjs.Dayjs |
||||
isYearPicker?: boolean |
||||
} |
||||
|
||||
const props = withDefaults(defineProps<Props>(), { |
||||
selectedDate: null, |
||||
isDisabled: false, |
||||
pageDate: dayjs(), |
||||
isYearPicker: false, |
||||
}) |
||||
const emit = defineEmits(['update:selectedDate', 'update:pageDate']) |
||||
|
||||
const pageDate = useVModel(props, 'pageDate', emit) |
||||
|
||||
const selectedDate = useVModel(props, 'selectedDate', emit) |
||||
|
||||
const years = computed(() => { |
||||
const date = pageDate.value |
||||
const startOfYear = date.startOf('year') |
||||
const years: dayjs.Dayjs[] = [] |
||||
for (let i = 0; i < 12; i++) { |
||||
years.push(dayjs(startOfYear).add(i, 'year')) |
||||
} |
||||
return years |
||||
}) |
||||
|
||||
const months = computed(() => { |
||||
const months: dayjs.Dayjs[] = [] |
||||
for (let i = 0; i < 12; i++) { |
||||
months.push(pageDate.value.set('month', i)) |
||||
} |
||||
return months |
||||
}) |
||||
|
||||
const compareDates = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => { |
||||
if (!date1 || !date2) return false |
||||
return date1.isSame(date2, 'month') && date1.isSame(date2, 'year') |
||||
} |
||||
|
||||
const isMonthSelected = (date: dayjs.Dayjs) => { |
||||
if (!dayjs(selectedDate.value).isValid()) return false |
||||
return compareDates(date, selectedDate.value) |
||||
} |
||||
|
||||
const paginateMonth = (action: 'next' | 'prev') => { |
||||
let date = pageDate.value |
||||
if (action === 'next') { |
||||
date = date.add(1, 'year') |
||||
} else { |
||||
date = date.subtract(1, 'year') |
||||
} |
||||
pageDate.value = date |
||||
emit('update:pageDate', date) |
||||
} |
||||
|
||||
const paginateYear = (action: 'next' | 'prev') => { |
||||
let date = dayjs(pageDate.value) |
||||
if (action === 'next') { |
||||
date = date.add(12, 'year') |
||||
} else { |
||||
date = date.subtract(12, 'year') |
||||
} |
||||
pageDate.value = date |
||||
emit('update:pageDate', date) |
||||
} |
||||
|
||||
const paginate = (action: 'next' | 'prev') => { |
||||
if (props.isYearPicker) { |
||||
paginateYear(action) |
||||
} else { |
||||
paginateMonth(action) |
||||
} |
||||
} |
||||
|
||||
const compareYear = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => { |
||||
if (!date1 || !date2) return false |
||||
return date1.isSame(date2, 'year') |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="px-4 pt-3 pb-4 flex flex-col gap-4"> |
||||
<div class="flex justify-between items-center"> |
||||
<NcTooltip> |
||||
<NcButton size="small" type="secondary" @click="paginate('prev')"> |
||||
<component :is="iconMap.doubleLeftArrow" class="h-4 w-4" /> |
||||
</NcButton> |
||||
<template #title> |
||||
<span>{{ $t('labels.previous') }}</span> |
||||
</template> |
||||
</NcTooltip> |
||||
<span class="font-bold text-gray-700">{{ isYearPicker ? $t('labels.selectYear') : pageDate.year() }}</span> |
||||
<NcTooltip> |
||||
<NcButton size="small" type="secondary" @click="paginate('next')"> |
||||
<component :is="iconMap.doubleRightArrow" class="h-4 w-4" /> |
||||
</NcButton> |
||||
<template #title> |
||||
<span>{{ $t('labels.next') }}</span> |
||||
</template> |
||||
</NcTooltip> |
||||
</div> |
||||
<div class="rounded-y-xl max-w-[350px]"> |
||||
<div class="grid grid-cols-4 gap-2 p-2"> |
||||
<template v-if="!isYearPicker"> |
||||
<span |
||||
v-for="(month, id) in months" |
||||
:key="id" |
||||
:class="{ |
||||
'!bg-brand-50 border-1 !border-brand-500': isMonthSelected(month), |
||||
}" |
||||
class="h-9 rounded-lg flex font-medium items-center justify-center hover:(border-1 border-gray-200 bg-gray-100) text-gray-500 cursor-pointer" |
||||
@click="selectedDate = month" |
||||
> |
||||
{{ month.format('MMM') }} |
||||
</span> |
||||
</template> |
||||
<template v-else> |
||||
<span |
||||
v-for="(year, id) in years" |
||||
:key="id" |
||||
:class="{ |
||||
'!bg-brand-50 !border-1 !border-brand-500': compareYear(year, selectedDate), |
||||
}" |
||||
class="h-9 rounded-lg flex font-medium items-center justify-center hover:(border-1 border-gray-200 bg-gray-100) text-gray-500 cursor-pointer" |
||||
@click="selectedDate = year" |
||||
> |
||||
{{ year.format('YYYY') }} |
||||
</span> |
||||
</template> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped></style> |
@ -0,0 +1,49 @@
|
||||
<script lang="ts" setup> |
||||
import { |
||||
ActiveViewInj, |
||||
FieldsInj, |
||||
IsPublicInj, |
||||
MetaInj, |
||||
ReadonlyInj, |
||||
ReloadViewDataHookInj, |
||||
useProvideCalendarViewStore, |
||||
useProvideKanbanViewStore, |
||||
} from '#imports' |
||||
|
||||
const { sharedView, meta, nestedFilters } = useSharedView() |
||||
|
||||
const reloadEventHook = createEventHook() |
||||
|
||||
provide(ReloadViewDataHookInj, reloadEventHook) |
||||
|
||||
provide(ReadonlyInj, ref(true)) |
||||
|
||||
provide(MetaInj, meta) |
||||
|
||||
provide(ActiveViewInj, sharedView) |
||||
|
||||
provide(FieldsInj, ref(meta.value?.columns || [])) |
||||
|
||||
provide(IsPublicInj, ref(true)) |
||||
|
||||
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true) |
||||
|
||||
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) |
||||
|
||||
useProvideKanbanViewStore(meta, sharedView) |
||||
|
||||
useProvideCalendarViewStore(meta, sharedView, true, nestedFilters) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="nc-container h-full mt-1.5 px-12"> |
||||
<div class="flex flex-col h-full flex-1 min-w-0"> |
||||
<LazySmartsheetToolbar /> |
||||
<div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50"> |
||||
<LazySmartsheetCalendar /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped></style> |
@ -0,0 +1,236 @@
|
||||
<script lang="ts" setup> |
||||
import dayjs from 'dayjs' |
||||
import { UITypes, isVirtualCol } from 'nocodb-sdk' |
||||
import { type Row, computed, ref } from '#imports' |
||||
import { isRowEmpty } from '~/utils' |
||||
|
||||
const emit = defineEmits(['expand-record', 'new-record']) |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const container = ref() |
||||
|
||||
const { isUIAllowed } = useRoles() |
||||
|
||||
const { selectedDate, formattedData, formattedSideBarData, calendarRange, updateRowProperty, displayField } = |
||||
useCalendarViewStoreOrThrow() |
||||
|
||||
// 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[]>(() => { |
||||
let dayRecordCount = 0 |
||||
const perRecordHeight = 40 |
||||
|
||||
if (!calendarRange.value) return [] |
||||
|
||||
const recordsByRange: Array<Row> = [] |
||||
|
||||
calendarRange.value.forEach((range) => { |
||||
const fromCol = range.fk_from_col |
||||
const endCol = range.fk_to_col |
||||
if (fromCol && endCol) { |
||||
for (const record of formattedData.value) { |
||||
const startDate = dayjs(record.row[fromCol.title!]) |
||||
const endDate = dayjs(record.row[endCol.title!]) |
||||
|
||||
dayRecordCount++ |
||||
|
||||
const style: Partial<CSSStyleDeclaration> = { |
||||
top: `${(dayRecordCount - 1) * perRecordHeight}px`, |
||||
width: '100%', |
||||
} |
||||
|
||||
// This property is used to determine which side the record should be rounded. It can be left, right, both or none |
||||
let position = 'none' |
||||
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(selectedDate.value, 'day') |
||||
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 = 'leftRounded' |
||||
} else if (isBeforeSelectedDay(startDate) && isSelectedDay(endDate)) { |
||||
position = 'rightRounded' |
||||
} else { |
||||
position = 'none' |
||||
} |
||||
|
||||
recordsByRange.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
position, |
||||
style, |
||||
range: range as any, |
||||
}, |
||||
}) |
||||
} |
||||
} else if (fromCol) { |
||||
for (const record of formattedData.value) { |
||||
dayRecordCount++ |
||||
recordsByRange.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
range: range as any, |
||||
style: { |
||||
width: '100%', |
||||
left: '0', |
||||
top: `${(dayRecordCount - 1) * perRecordHeight}px`, |
||||
}, |
||||
position: 'rounded', |
||||
}, |
||||
}) |
||||
} |
||||
} |
||||
}) |
||||
return recordsByRange |
||||
}) |
||||
|
||||
const dragElement = ref<HTMLElement | null>(null) |
||||
|
||||
const hoverRecord = ref<string | null>(null) |
||||
|
||||
// We support drag and drop from the sidebar to the day view of the date field |
||||
const dropEvent = (event: DragEvent) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
event.preventDefault() |
||||
const data = event.dataTransfer?.getData('text/plain') |
||||
if (data) { |
||||
const { |
||||
record, |
||||
}: { |
||||
record: Row |
||||
initialClickOffsetY: number |
||||
initialClickOffsetX: number |
||||
} = JSON.parse(data) |
||||
|
||||
const fromCol = record.rowMeta.range?.fk_from_col |
||||
const toCol = record.rowMeta.range?.fk_to_col |
||||
|
||||
if (!fromCol) return |
||||
|
||||
const newStartDate = dayjs(selectedDate.value).startOf('day') |
||||
|
||||
let endDate |
||||
|
||||
const newRow = { |
||||
...record, |
||||
row: { |
||||
...record.row, |
||||
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
|
||||
const updateProperty = [fromCol.title!] |
||||
|
||||
if (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 |
||||
|
||||
if (fromDate && toDate) { |
||||
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day') |
||||
} else if (fromDate && !toDate) { |
||||
endDate = dayjs(newStartDate).endOf('day') |
||||
} else if (!fromDate && toDate) { |
||||
endDate = dayjs(newStartDate).endOf('day') |
||||
} else { |
||||
endDate = newStartDate.clone() |
||||
} |
||||
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ') |
||||
updateProperty.push(toCol.title!) |
||||
} |
||||
|
||||
if (!newRow) return |
||||
|
||||
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) |
||||
|
||||
if (dragElement.value) { |
||||
formattedData.value = formattedData.value.map((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk === newPk ? newRow : r |
||||
}) |
||||
} else { |
||||
formattedData.value = [...formattedData.value, newRow] |
||||
formattedSideBarData.value = formattedSideBarData.value.filter((r) => { |
||||
return extractPkFromRow(r.row, meta.value!.columns!) !== newPk |
||||
}) |
||||
} |
||||
|
||||
if (dragElement.value) { |
||||
dragElement.value.style.boxShadow = 'none' |
||||
dragElement.value = null |
||||
} |
||||
updateRowProperty(newRow, updateProperty, false) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
v-if="recordsAcrossAllRange.length" |
||||
ref="container" |
||||
class="w-full relative h-[calc(100vh-10.8rem)] overflow-y-auto nc-scrollbar-md" |
||||
data-testid="nc-calendar-day-view" |
||||
@drop="dropEvent" |
||||
> |
||||
<div |
||||
v-for="(record, rowIndex) in recordsAcrossAllRange" |
||||
:key="rowIndex" |
||||
:style="record.rowMeta.style" |
||||
class="absolute mt-2" |
||||
data-testid="nc-calendar-day-record-card" |
||||
@mouseleave="hoverRecord = null" |
||||
@mouseover="hoverRecord = record.rowMeta.id as string" |
||||
> |
||||
<LazySmartsheetRow :row="record"> |
||||
<LazySmartsheetCalendarRecordCard |
||||
:position="record.rowMeta.position" |
||||
:record="record" |
||||
:resize="false" |
||||
color="blue" |
||||
size="small" |
||||
@click="emit('expand-record', record)" |
||||
> |
||||
<template v-if="!isRowEmpty(record, displayField)"> |
||||
<div |
||||
:class="{ |
||||
'!mt-1.5 ml-1': displayField.uidt === UITypes.SingleLineText, |
||||
'!mt-1': displayField.uidt === UITypes.MultiSelect || displayField.uidt === UITypes.SingleSelect, |
||||
}" |
||||
> |
||||
<LazySmartsheetVirtualCell |
||||
v-if="isVirtualCol(displayField!)" |
||||
v-model="record.row[displayField!.title!]" |
||||
:column="displayField" |
||||
:row="record" |
||||
/> |
||||
|
||||
<LazySmartsheetCell |
||||
v-else |
||||
v-model="record.row[displayField!.title!]" |
||||
:column="displayField" |
||||
:edit-enabled="false" |
||||
:read-only="true" |
||||
/> |
||||
</div> |
||||
</template> |
||||
</LazySmartsheetCalendarRecordCard> |
||||
</LazySmartsheetRow> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
v-else |
||||
ref="container" |
||||
class="w-full h-full flex text-md font-bold text-gray-500 items-center justify-center" |
||||
@drop="dropEvent" |
||||
> |
||||
No records in this day |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped></style> |
@ -0,0 +1,742 @@
|
||||
<script lang="ts" setup> |
||||
import dayjs from 'dayjs' |
||||
import { UITypes, isVirtualCol } from 'nocodb-sdk' |
||||
import { type Row, computed, ref } from '#imports' |
||||
import { generateRandomNumber, isRowEmpty } from '~/utils' |
||||
|
||||
const emit = defineEmits(['expandRecord', 'new-record']) |
||||
|
||||
const { |
||||
selectedDate, |
||||
selectedTime, |
||||
formattedData, |
||||
calendarRange, |
||||
formattedSideBarData, |
||||
updateRowProperty, |
||||
displayField, |
||||
sideBarFilterOption, |
||||
showSideMenu, |
||||
} = useCalendarViewStoreOrThrow() |
||||
|
||||
const container = ref<null | HTMLElement>(null) |
||||
|
||||
const { isUIAllowed } = useRoles() |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const hours = computed(() => { |
||||
const hours: Array<dayjs.Dayjs> = [] |
||||
const _selectedDate = dayjs(selectedDate.value) |
||||
|
||||
for (let i = 0; i < 24; i++) { |
||||
hours.push(_selectedDate.clone().startOf('day').add(i, 'hour')) |
||||
} |
||||
return hours |
||||
}) |
||||
|
||||
const recordsAcrossAllRange = computed<{ |
||||
record: Row[] |
||||
count: { |
||||
[key: string]: { |
||||
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 = 80 |
||||
|
||||
let recordsByRange: Array<Row> = [] |
||||
|
||||
calendarRange.value.forEach((range) => { |
||||
const fromCol = range.fk_from_col |
||||
const endCol = range.fk_to_col |
||||
|
||||
// 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 |
||||
|
||||
if (fromCol && endCol) { |
||||
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null |
||||
const toDate = record.row[endCol.title!] ? dayjs(record.row[endCol.title!]) : null |
||||
|
||||
return fromDate && toDate?.isValid() ? fromDate.isBefore(toDate) : true |
||||
} else if (fromCol && !endCol) { |
||||
return !!fromDate |
||||
} |
||||
return false |
||||
}) |
||||
|
||||
// If there is a start and end column, we calculate the top and height of the record based on the start and end date |
||||
if (fromCol && endCol) { |
||||
for (const record of sortedFormattedData) { |
||||
// We use this id during the drag and drop operation and to keep track of the number of records that overlap at a given time |
||||
const id = generateRandomNumber() |
||||
let startDate = dayjs(record.row[fromCol.title!]) |
||||
let endDate = dayjs(record.row[endCol.title!]) |
||||
|
||||
// If no start date is provided or startDate is after the endDate, we skip the record |
||||
if (!startDate.isValid() || startDate.isAfter(endDate)) continue |
||||
|
||||
// If there is no end date, we add 30 minutes to the start date and use that as the end date |
||||
if (!endDate.isValid()) { |
||||
endDate = startDate.clone().add(30, '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.isBefore(scheduleStart, 'minutes')) { |
||||
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, 'minutes')) { |
||||
endDate = scheduleEnd |
||||
} |
||||
|
||||
// The top of the record is calculated based on the start hour and minute |
||||
const topInPixels = (startDate.hour() + startDate.minute() / 60) * 80 |
||||
|
||||
// A minimum height of 80px is set for each record |
||||
// The height of the record is calculated based on the difference between the start and end date |
||||
const heightInPixels = Math.max((endDate.diff(startDate, 'minute') / 60) * 80, perRecordHeight) |
||||
|
||||
const startHour = startDate.hour() |
||||
|
||||
let _startDate = startDate.clone() |
||||
|
||||
const style: Partial<CSSStyleDeclaration> = { |
||||
height: `${heightInPixels}px`, |
||||
top: `${topInPixels + 5 + startHour * 2}px`, |
||||
} |
||||
|
||||
// We loop through every 15 minutes between the start and end date and keep track of the number of records that overlap at a given time |
||||
// If the number of records exceeds 4, we hide the record and show a button to view more records |
||||
while (_startDate.isBefore(endDate)) { |
||||
const timeKey = _startDate.format('HH:mm') |
||||
if (!overlaps[timeKey]) { |
||||
overlaps[timeKey] = { |
||||
id: [], |
||||
overflow: false, |
||||
overflowCount: 0, |
||||
} |
||||
} |
||||
overlaps[timeKey].id.push(id) |
||||
|
||||
// If the number of records exceeds 4, we hide the record mark the time as overflow |
||||
if (overlaps[timeKey].id.length > 4) { |
||||
overlaps[timeKey].overflow = true |
||||
style.display = 'none' |
||||
overlaps[timeKey].overflowCount += 1 |
||||
} |
||||
_startDate = _startDate.add(15, 'minutes') |
||||
} |
||||
|
||||
// This property is used to determine which side the record should be rounded. It can be top, bottom, both or none |
||||
// We use the start and end date to determine the position of the record |
||||
let position = 'none' |
||||
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(selectedDate.value, 'day') |
||||
const isBeforeSelectedDay = (date: dayjs.Dayjs) => date.isBefore(selectedDate.value, 'day') |
||||
const isAfterSelectedDay = (date: dayjs.Dayjs) => date.isAfter(selectedDate.value, 'day') |
||||
|
||||
if (isSelectedDay(startDate) && isSelectedDay(endDate)) { |
||||
position = 'rounded' |
||||
} else if (isBeforeSelectedDay(startDate) && isAfterSelectedDay(endDate)) { |
||||
position = 'none' |
||||
} else if (isSelectedDay(startDate) && isAfterSelectedDay(endDate)) { |
||||
position = 'topRounded' |
||||
} else if (isBeforeSelectedDay(startDate) && isSelectedDay(endDate)) { |
||||
position = 'bottomRounded' |
||||
} else { |
||||
position = 'none' |
||||
} |
||||
|
||||
recordsByRange.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
position, |
||||
style, |
||||
id, |
||||
range: range as any, |
||||
}, |
||||
}) |
||||
} |
||||
} else if (fromCol) { |
||||
for (const record of sortedFormattedData) { |
||||
const id = generateRandomNumber() |
||||
|
||||
const startDate = dayjs(record.row[fromCol.title!]) |
||||
const endDate = dayjs(record.row[fromCol.title!]).add(15, 'minutes') |
||||
|
||||
const startHour = startDate.hour() |
||||
|
||||
let style: Partial<CSSStyleDeclaration> = {} |
||||
let _startDate = startDate.clone() |
||||
|
||||
// We loop through every 15 minutes between the start and end date and keep track of the number of records that overlap at a given time |
||||
while (_startDate.isBefore(endDate)) { |
||||
const timeKey = _startDate.startOf('hour').format('HH:mm') |
||||
|
||||
if (!overlaps[timeKey]) { |
||||
overlaps[timeKey] = { |
||||
id: [], |
||||
overflow: false, |
||||
overflowCount: 0, |
||||
} |
||||
} |
||||
overlaps[timeKey].id.push(id) |
||||
|
||||
// If the number of records exceeds 8, we hide the record and mark it as overflow |
||||
if (overlaps[timeKey].id.length > 8) { |
||||
overlaps[timeKey].overflow = true |
||||
overlaps[timeKey].overflowCount += 1 |
||||
style = { |
||||
...style, |
||||
display: 'none', |
||||
} |
||||
} |
||||
_startDate = _startDate.add(15, 'minutes') |
||||
} |
||||
|
||||
const topInPixels = (startDate.hour() + startDate.startOf('hour').minute() / 60) * 80 |
||||
|
||||
// A minimum height of 80px is set for each record |
||||
const heightInPixels = Math.max((endDate.diff(startDate, 'minute') / 60) * 80, perRecordHeight) |
||||
|
||||
const finalTopInPixels = topInPixels + startHour * 2 |
||||
|
||||
style = { |
||||
...style, |
||||
top: `${finalTopInPixels + 2}px`, |
||||
height: `${heightInPixels - 2}px`, |
||||
} |
||||
|
||||
recordsByRange.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
range: range as any, |
||||
style, |
||||
id, |
||||
position: 'rounded', |
||||
}, |
||||
}) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
// We can't calculate the width & left of the records without knowing the number of records that overlap at a given time |
||||
// So we loop through the records again and calculate the width & left of the records based on the number of records that overlap at a given time |
||||
recordsByRange = recordsByRange.map((record) => { |
||||
// MaxOverlaps is the number of records that overlap at a given time |
||||
// overlapIndex is the index of the record in the list of records that overlap at a given time |
||||
let maxOverlaps = 1 |
||||
let overlapIndex = 0 |
||||
for (const minutes in overlaps) { |
||||
if (overlaps[minutes].id.includes(record.rowMeta.id!)) { |
||||
maxOverlaps = Math.max(maxOverlaps, overlaps[minutes].id.length - overlaps[minutes].overflowCount) |
||||
overlapIndex = Math.max(overlaps[minutes].id.indexOf(record.rowMeta.id!), overlapIndex) |
||||
} |
||||
} |
||||
|
||||
const spacing = 0.25 |
||||
const widthPerRecord = (100 - spacing * (maxOverlaps - 1)) / maxOverlaps |
||||
|
||||
const leftPerRecord = (widthPerRecord + spacing) * overlapIndex |
||||
|
||||
record.rowMeta.style = { |
||||
...record.rowMeta.style, |
||||
left: `${leftPerRecord - 0.08}%`, |
||||
width: `calc(${widthPerRecord}%)`, |
||||
} |
||||
return record |
||||
}) |
||||
|
||||
return { |
||||
count: overlaps, |
||||
record: recordsByRange, |
||||
} |
||||
}) |
||||
|
||||
const dragRecord = ref<Row | null>(null) |
||||
|
||||
const isDragging = ref(false) |
||||
|
||||
const dragElement = ref<HTMLElement | null>(null) |
||||
|
||||
const resizeDirection = ref<'right' | 'left' | null>() |
||||
|
||||
const resizeRecord = ref<Row | null>() |
||||
|
||||
const dragTimeout = ref<ReturnType<typeof setTimeout>>() |
||||
|
||||
const hoverRecord = ref<string | null>(null) |
||||
|
||||
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => { |
||||
updateRowProperty(row, updateProperty, isDelete) |
||||
}, 500) |
||||
|
||||
// When the user is dragging a record, we calculate the new start and end date based on the mouse position |
||||
const calculateNewRow = (event: MouseEvent) => { |
||||
if (!container.value || !dragRecord.value) return { newRow: null, updateProperty: [] } |
||||
const { top } = container.value.getBoundingClientRect() |
||||
|
||||
const { scrollHeight } = container.value |
||||
|
||||
// We calculate the percentage of the mouse position in the scroll container |
||||
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight |
||||
|
||||
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col |
||||
const toCol = dragRecord.value.rowMeta.range?.fk_to_col |
||||
|
||||
// We calculate the hour based on the percentage of the mouse position in the scroll container |
||||
// It can be between 0 and 23 (inclusive) |
||||
const hour = Math.max(Math.floor(percentY * 23), 0) |
||||
|
||||
// We calculate the new startDate by adding the hour to the start of the selected date |
||||
const newStartDate = dayjs(selectedDate.value).startOf('day').add(hour, 'hour') |
||||
if (!newStartDate || !fromCol) return { newRow: null, updateProperty: [] } |
||||
|
||||
let endDate |
||||
|
||||
const newRow = { |
||||
...dragRecord.value, |
||||
row: { |
||||
...dragRecord.value.row, |
||||
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
|
||||
const updateProperty = [fromCol.title!] |
||||
|
||||
if (toCol) { |
||||
const fromDate = dragRecord.value.row[fromCol.title!] ? dayjs(dragRecord.value.row[fromCol.title!]) : null |
||||
const toDate = dragRecord.value.row[toCol.title!] ? dayjs(dragRecord.value.row[toCol.title!]) : null |
||||
|
||||
// If there is an end date, we calculate the new end date based on the new start date and add the difference between the start and end date to the new start date |
||||
if (fromDate && toDate) { |
||||
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'hour'), 'hour') |
||||
} else if (fromDate && !toDate) { |
||||
// If there is no end date, we set the end date to the end of the day |
||||
endDate = dayjs(newStartDate).endOf('hour') |
||||
} else if (!fromDate && toDate) { |
||||
// If there is no start date, we set the end date to the end of the day |
||||
endDate = dayjs(newStartDate).endOf('hour') |
||||
} else { |
||||
endDate = newStartDate.clone() |
||||
} |
||||
|
||||
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ') |
||||
|
||||
updateProperty.push(toCol.title!) |
||||
} |
||||
|
||||
if (!newRow) { |
||||
return { newRow: null, updateProperty: [] } |
||||
} |
||||
|
||||
// We use the primary key of the new row to find the old row in the formattedData array |
||||
// If the old row is found, we replace it with the new row in the formattedData array |
||||
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) |
||||
if (dragElement.value) { |
||||
formattedData.value = formattedData.value.map((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
if (pk === newPk) { |
||||
return newRow |
||||
} |
||||
return r |
||||
}) |
||||
} else { |
||||
// If the old row is not found, we add the new row to the formattedData array and remove the old row from the formattedSideBarData array |
||||
formattedData.value = [...formattedData.value, newRow] |
||||
formattedSideBarData.value = formattedSideBarData.value.filter((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk !== newPk |
||||
}) |
||||
} |
||||
return { newRow, updateProperty } |
||||
} |
||||
|
||||
const onResize = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
if (!container.value || !resizeRecord.value) return |
||||
const { top, bottom } = container.value.getBoundingClientRect() |
||||
|
||||
const { scrollHeight } = container.value |
||||
|
||||
// If the mouse position is near the top or bottom of the scroll container, we scroll the container |
||||
if (event.clientY > bottom - 20) { |
||||
container.value.scrollTop += 10 |
||||
} else if (event.clientY < top + 20) { |
||||
container.value.scrollTop -= 10 |
||||
} |
||||
|
||||
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight |
||||
|
||||
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col |
||||
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col |
||||
if (!fromCol || !toCol) return |
||||
|
||||
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!]) |
||||
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!]) |
||||
|
||||
const hour = Math.max(Math.floor(percentY * 23), 0) |
||||
|
||||
let newRow: Row | null = null |
||||
let updateProperty: string[] = [] |
||||
|
||||
if (resizeDirection.value === 'right') { |
||||
// If the user is resizing the record to the right, we calculate the new end date based on the mouse position |
||||
let newEndDate = dayjs(selectedDate.value).add(hour, 'hour') |
||||
|
||||
updateProperty = [toCol.title!] |
||||
|
||||
// If the new end date is before the start date, we set the new end date to the start date |
||||
// This is to ensure the end date is always same or after the start date |
||||
if (dayjs(newEndDate).isBefore(ogStartDate, 'day')) { |
||||
newEndDate = ogStartDate.clone() |
||||
} |
||||
|
||||
if (!newEndDate.isValid()) return |
||||
|
||||
newRow = { |
||||
...resizeRecord.value, |
||||
row: { |
||||
...resizeRecord.value.row, |
||||
[toCol.title!]: newEndDate.format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} else if (resizeDirection.value === 'left') { |
||||
let newStartDate = dayjs(selectedDate.value).add(hour, 'hour') |
||||
|
||||
updateProperty = [fromCol.title!] |
||||
|
||||
// If the new start date is after the end date, we set the new start date to the end date |
||||
// This is to ensure the start date is always before or same the end date |
||||
if (dayjs(newStartDate).isAfter(ogEndDate)) { |
||||
newStartDate = dayjs(dayjs(ogEndDate)).clone() |
||||
} |
||||
if (!newStartDate) return |
||||
|
||||
newRow = { |
||||
...resizeRecord.value, |
||||
row: { |
||||
...resizeRecord.value.row, |
||||
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} |
||||
if (!newRow) return |
||||
|
||||
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) |
||||
formattedData.value = formattedData.value.map((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk === newPk ? newRow : r |
||||
}) as Row[] |
||||
useDebouncedRowUpdate(newRow, updateProperty, false) |
||||
} |
||||
|
||||
const onResizeEnd = () => { |
||||
resizeDirection.value = null |
||||
resizeRecord.value = null |
||||
document.removeEventListener('mousemove', onResize) |
||||
document.removeEventListener('mouseup', onResizeEnd) |
||||
} |
||||
|
||||
const onResizeStart = (direction: 'right' | 'left', _event: MouseEvent, record: Row) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
resizeDirection.value = direction |
||||
resizeRecord.value = record |
||||
document.addEventListener('mousemove', onResize) |
||||
document.addEventListener('mouseup', onResizeEnd) |
||||
} |
||||
|
||||
const onDrag = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit') || !container.value || !dragRecord.value) return |
||||
const { top, bottom } = container.value.getBoundingClientRect() |
||||
|
||||
if (event.clientY > bottom - 20) { |
||||
container.value.scrollTop += 10 |
||||
} else if (event.clientY < top + 20) { |
||||
container.value.scrollTop -= 10 |
||||
} |
||||
calculateNewRow(event) |
||||
} |
||||
|
||||
const stopDrag = (event: MouseEvent) => { |
||||
event.preventDefault() |
||||
clearTimeout(dragTimeout.value!) |
||||
if (!isUIAllowed('dataEdit') || !isDragging.value || !container.value || !dragRecord.value) return |
||||
|
||||
const { newRow, updateProperty } = calculateNewRow(event) |
||||
if (!newRow && !updateProperty) return |
||||
|
||||
const allRecords = document.querySelectorAll('.draggable-record') |
||||
allRecords.forEach((el) => { |
||||
el.style.visibility = '' |
||||
el.style.opacity = '100%' |
||||
}) |
||||
|
||||
if (dragElement.value) { |
||||
dragElement.value.style.boxShadow = 'none' |
||||
dragElement.value = null |
||||
} |
||||
|
||||
if (!newRow) return |
||||
updateRowProperty(newRow, updateProperty, false) |
||||
|
||||
document.removeEventListener('mousemove', onDrag) |
||||
document.removeEventListener('mouseup', stopDrag) |
||||
} |
||||
|
||||
const dragStart = (event: MouseEvent, record: Row) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
let target = event.target as HTMLElement |
||||
|
||||
isDragging.value = false |
||||
|
||||
// We use a timeout to determine if the user is dragging or clicking on the record |
||||
dragTimeout.value = setTimeout(() => { |
||||
isDragging.value = true |
||||
while (!target.classList.contains('draggable-record')) { |
||||
target = target.parentElement as HTMLElement |
||||
} |
||||
|
||||
// When the user starts dragging a record, we reduce opacity of all other records |
||||
const allRecords = document.querySelectorAll('.draggable-record') |
||||
allRecords.forEach((el) => { |
||||
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) { |
||||
// el.style.visibility = 'hidden' |
||||
el.style.opacity = '30%' |
||||
} |
||||
}) |
||||
|
||||
dragRecord.value = record |
||||
|
||||
dragElement.value = target |
||||
dragRecord.value = record |
||||
|
||||
document.addEventListener('mousemove', onDrag) |
||||
document.addEventListener('mouseup', stopDrag) |
||||
}, 200) |
||||
|
||||
const onMouseUp = () => { |
||||
clearTimeout(dragTimeout.value!) |
||||
document.removeEventListener('mouseup', onMouseUp) |
||||
if (!isDragging.value) { |
||||
emit('expandRecord', record) |
||||
} |
||||
} |
||||
|
||||
document.addEventListener('mouseup', onMouseUp) |
||||
} |
||||
|
||||
const viewMore = (hour: dayjs.Dayjs) => { |
||||
sideBarFilterOption.value = 'selectedHours' |
||||
selectedTime.value = hour |
||||
showSideMenu.value = true |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
v-if="recordsAcrossAllRange.record.length" |
||||
ref="container" |
||||
class="w-full relative no-selection h-[calc(100vh-10rem)] overflow-y-auto nc-scrollbar-md" |
||||
data-testid="nc-calendar-day-view" |
||||
> |
||||
<div |
||||
v-for="(hour, index) in hours" |
||||
:key="index" |
||||
:class="{ |
||||
'!border-brand-500': hour.isSame(selectedTime), |
||||
}" |
||||
class="flex w-full min-h-20 relative border-1 group hover:bg-gray-50 border-white border-b-gray-100" |
||||
data-testid="nc-calendar-day-hour" |
||||
@click="selectedTime = hour" |
||||
> |
||||
<div class="pt-2 px-4 text-xs text-gray-500 font-semibold h-20"> |
||||
{{ dayjs(hour).format('H A') }} |
||||
</div> |
||||
<div></div> |
||||
<NcDropdown |
||||
v-if="calendarRange.length > 1" |
||||
:class="{ |
||||
'!block': hour.isSame(selectedTime), |
||||
'!hidden': !hour.isSame(selectedTime), |
||||
}" |
||||
auto-close |
||||
> |
||||
<NcButton |
||||
class="!group-hover:block mr-4 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute" |
||||
size="xsmall" |
||||
type="secondary" |
||||
> |
||||
<component :is="iconMap.plus" class="h-4 w-4" /> |
||||
</NcButton> |
||||
<template #overlay> |
||||
<NcMenu class="w-64"> |
||||
<NcMenuItem> Select date field to add </NcMenuItem> |
||||
<NcMenuItem |
||||
v-for="(range, calIndex) in calendarRange" |
||||
:key="calIndex" |
||||
class="text-gray-800 font-semibold text-sm" |
||||
@click=" |
||||
() => { |
||||
let record = { |
||||
row: { |
||||
[range.fk_from_col!.title!]: hour.format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
if (range.fk_to_col) { |
||||
record = { |
||||
row: { |
||||
...record.row, |
||||
[range.fk_to_col!.title!]: hour.add(1, 'hour').format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} |
||||
emit('new-record', record) |
||||
} |
||||
" |
||||
> |
||||
<div class="flex items-center gap-1"> |
||||
<LazySmartsheetHeaderCellIcon :column-meta="range.fk_from_col" /> |
||||
<span class="ml-1">{{ range.fk_from_col!.title! }}</span> |
||||
</div> |
||||
</NcMenuItem> |
||||
</NcMenu> |
||||
</template> |
||||
</NcDropdown> |
||||
<NcButton |
||||
v-else |
||||
:class="{ |
||||
'!block': hour.isSame(selectedTime), |
||||
'!hidden': !hour.isSame(selectedTime), |
||||
}" |
||||
class="!group-hover:block mr-4 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute" |
||||
size="xsmall" |
||||
type="secondary" |
||||
@click=" |
||||
() => { |
||||
let record = { |
||||
row: { |
||||
[calendarRange[0].fk_from_col!.title!]: hour.format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
|
||||
if (calendarRange[0].fk_to_col) { |
||||
record = { |
||||
row: { |
||||
...record.row, |
||||
[calendarRange[0].fk_to_col!.title!]: hour.add(1, 'hour').format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} |
||||
emit('new-record', record) |
||||
} |
||||
" |
||||
> |
||||
<component :is="iconMap.plus" class="h-4 w-4" /> |
||||
</NcButton> |
||||
|
||||
<NcButton |
||||
v-if="recordsAcrossAllRange?.count?.[hour.format('HH:mm')]?.overflow" |
||||
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"> |
||||
+ |
||||
{{ recordsAcrossAllRange?.count[hour.format('HH:mm')]?.overflowCount }} |
||||
more |
||||
</span> |
||||
</NcButton> |
||||
</div> |
||||
<div class="absolute inset-0 pointer-events-none"> |
||||
<div class="relative !ml-[60px]" data-testid="nc-calendar-day-record-container"> |
||||
<div |
||||
v-for="(record, rowIndex) in recordsAcrossAllRange.record" |
||||
:key="rowIndex" |
||||
:data-testid="`nc-calendar-day-record-${record.row[displayField!.title!]}`" |
||||
:data-unique-id="record.rowMeta.id" |
||||
:style="record.rowMeta.style" |
||||
class="absolute draggable-record group cursor-pointer pointer-events-auto" |
||||
@mousedown="dragStart($event, record)" |
||||
@mouseleave="hoverRecord = null" |
||||
@mouseover="hoverRecord = record.rowMeta.id as string" |
||||
@dragover.prevent |
||||
> |
||||
<LazySmartsheetRow :row="record"> |
||||
<LazySmartsheetCalendarVRecordCard |
||||
:hover="hoverRecord === record.rowMeta.id" |
||||
:position="record.rowMeta!.position" |
||||
:record="record" |
||||
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')" |
||||
color="blue" |
||||
@resize-start="onResizeStart" |
||||
> |
||||
<template v-if="!isRowEmpty(record, displayField)"> |
||||
<div |
||||
:class="{ |
||||
'!mt-2': displayField!.uidt === UITypes.SingleLineText, |
||||
'!mt-1': displayField!.uidt === UITypes.MultiSelect || displayField!.uidt === UITypes.SingleSelect, |
||||
}" |
||||
> |
||||
<LazySmartsheetVirtualCell |
||||
v-if="isVirtualCol(displayField!)" |
||||
v-model="record.row[displayField!.title!]" |
||||
:column="displayField" |
||||
:row="record" |
||||
/> |
||||
|
||||
<LazySmartsheetCell |
||||
v-else |
||||
v-model="record.row[displayField!.title!]" |
||||
:column="displayField" |
||||
:edit-enabled="false" |
||||
:read-only="true" |
||||
/> |
||||
</div> |
||||
</template> |
||||
</LazySmartsheetCalendarVRecordCard> |
||||
</LazySmartsheetRow> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div v-else class="w-full h-full flex text-md font-bold text-gray-500 items-center justify-center">No records in this day</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.no-selection { |
||||
-webkit-touch-callout: none; |
||||
-webkit-user-select: none; |
||||
-moz-user-select: none; |
||||
-ms-user-select: none; |
||||
user-select: none; |
||||
} |
||||
</style> |
@ -0,0 +1,815 @@
|
||||
<script lang="ts" setup> |
||||
import dayjs from 'dayjs' |
||||
|
||||
import { UITypes, isVirtualCol } from 'nocodb-sdk' |
||||
import type { Row } from '#imports' |
||||
import { generateRandomNumber, isRowEmpty } from '~/utils' |
||||
|
||||
const emit = defineEmits(['new-record', 'expandRecord']) |
||||
|
||||
const { |
||||
selectedDate, |
||||
selectedMonth, |
||||
formattedData, |
||||
formattedSideBarData, |
||||
sideBarFilterOption, |
||||
displayField, |
||||
calendarRange, |
||||
showSideMenu, |
||||
updateRowProperty, |
||||
} = useCalendarViewStoreOrThrow() |
||||
|
||||
const isMondayFirst = ref(true) |
||||
|
||||
const { isUIAllowed } = useRoles() |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const days = computed(() => { |
||||
if (isMondayFirst.value) { |
||||
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] |
||||
} else { |
||||
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] |
||||
} |
||||
}) |
||||
|
||||
const calendarGridContainer = ref() |
||||
|
||||
const { width: gridContainerWidth, height: gridContainerHeight } = useElementSize(calendarGridContainer) |
||||
|
||||
const isDayInPagedMonth = (date: dayjs.Dayjs) => { |
||||
return date.month() === selectedMonth.value.month() |
||||
} |
||||
|
||||
const dragElement = ref<HTMLElement | null>(null) |
||||
|
||||
const draggingId = ref<string | null>(null) |
||||
|
||||
const resizeInProgress = ref(false) |
||||
|
||||
const isDragging = ref(false) |
||||
|
||||
const dragRecord = ref<Row>() |
||||
|
||||
const hoverRecord = ref<string | null>() |
||||
|
||||
const dragTimeout = ref<ReturnType<typeof setTimeout>>() |
||||
|
||||
const focusedDate = ref<dayjs.Dayjs | null>(null) |
||||
|
||||
const resizeDirection = ref<'right' | 'left'>() |
||||
|
||||
const resizeRecord = ref<Row>() |
||||
|
||||
const dates = computed(() => { |
||||
const startOfMonth = selectedMonth.value.startOf('month') |
||||
const endOfMonth = selectedMonth.value.endOf('month') |
||||
|
||||
const firstDayToDisplay = startOfMonth.startOf('week').add(isMondayFirst.value ? 0 : -1, 'day') |
||||
const lastDayToDisplay = endOfMonth.endOf('week').add(isMondayFirst.value ? 0 : -1, 'day') |
||||
|
||||
const daysToDisplay = lastDayToDisplay.diff(firstDayToDisplay, 'day') + 1 |
||||
let numberOfRows = Math.ceil(daysToDisplay / 7) |
||||
numberOfRows = Math.max(numberOfRows, 5) |
||||
|
||||
const weeksArray: Array<Array<dayjs.Dayjs>> = [] |
||||
let currentDay = firstDayToDisplay |
||||
for (let week = 0; week < numberOfRows; week++) { |
||||
const weekArray = [] |
||||
for (let day = 0; day < 7; day++) { |
||||
weekArray.push(currentDay) |
||||
currentDay = currentDay.add(1, 'day') |
||||
} |
||||
weeksArray.push(weekArray) |
||||
} |
||||
|
||||
return weeksArray |
||||
}) |
||||
|
||||
const recordsToDisplay = computed<{ |
||||
records: Row[] |
||||
count: { [p: string]: { overflow: boolean; count: number; overflowCount: number } } |
||||
}>(() => { |
||||
if (!dates.value || !calendarRange.value) return [] |
||||
|
||||
const perWidth = gridContainerWidth.value / 7 |
||||
const perHeight = gridContainerHeight.value / dates.value.length |
||||
const perRecordHeight = 40 |
||||
|
||||
const spaceBetweenRecords = 35 |
||||
|
||||
// This object is used to keep track of the number of records in a day |
||||
// The key is the date in the format YYYY-MM-DD |
||||
const recordsInDay: { |
||||
[key: string]: { |
||||
overflow: boolean |
||||
count: number |
||||
overflowCount: number |
||||
} |
||||
} = {} |
||||
|
||||
if (!calendarRange.value) return [] |
||||
|
||||
const recordsToDisplay: Array<Row> = [] |
||||
calendarRange.value.forEach((range) => { |
||||
const startCol = range.fk_from_col |
||||
const endCol = range.fk_to_col |
||||
|
||||
// Filter out records that don't satisfy the range and sort them by start date |
||||
const sortedFormattedData = [...formattedData.value].filter((record) => { |
||||
const fromDate = record.row[startCol!.title!] ? dayjs(record.row[startCol!.title!]) : null |
||||
|
||||
if (startCol && endCol) { |
||||
const fromDate = record.row[startCol!.title!] ? dayjs(record.row[startCol!.title!]) : null |
||||
const toDate = record.row[endCol!.title!] ? dayjs(record.row[endCol!.title!]) : null |
||||
return fromDate && toDate && !toDate.isBefore(fromDate) |
||||
} else if (startCol && !endCol) { |
||||
return !!fromDate |
||||
} |
||||
return false |
||||
}) |
||||
|
||||
sortedFormattedData.forEach((record: Row) => { |
||||
if (!endCol && startCol) { |
||||
// If there is no end date, we just display the record on the start date |
||||
const startDate = dayjs(record.row[startCol!.title!]) |
||||
const dateKey = startDate.format('YYYY-MM-DD') |
||||
|
||||
if (!recordsInDay[dateKey]) { |
||||
recordsInDay[dateKey] = { overflow: false, count: 0, overflowCount: 0 } |
||||
} |
||||
recordsInDay[dateKey].count++ |
||||
const id = record.rowMeta.id ?? generateRandomNumber() |
||||
|
||||
// Find the index of the week from the dates array |
||||
const weekIndex = dates.value.findIndex((week) => week.some((day) => dayjs(day).isSame(startDate, 'day'))) |
||||
|
||||
// Find the index of the day from the dates array |
||||
const dayIndex = (dates.value[weekIndex] ?? []).findIndex((day) => { |
||||
return dayjs(day).isSame(startDate, 'day') |
||||
}) |
||||
|
||||
const style: Partial<CSSStyleDeclaration> = { |
||||
left: `${dayIndex * perWidth}px`, |
||||
width: `${perWidth}px`, |
||||
} |
||||
|
||||
// Number of records in that day |
||||
const recordIndex = recordsInDay[dateKey].count |
||||
|
||||
// The top is calculated from the week index and the record index |
||||
// If the record in 1st week and no record in that date them the top will be 0 |
||||
const top = weekIndex * perHeight + spaceBetweenRecords + (recordIndex - 1) * perRecordHeight |
||||
|
||||
// The 25 is obtained from the trial and error |
||||
const heightRequired = perRecordHeight * recordIndex + spaceBetweenRecords + 25 |
||||
|
||||
if (heightRequired > perHeight) { |
||||
style.display = 'none' |
||||
recordsInDay[dateKey].overflow = true |
||||
recordsInDay[dateKey].overflowCount++ |
||||
} else { |
||||
style.top = `${top}px` |
||||
} |
||||
|
||||
recordsToDisplay.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
style, |
||||
position: 'rounded', |
||||
range, |
||||
id, |
||||
}, |
||||
}) |
||||
} else if (startCol && endCol) { |
||||
// If the range specifies fromCol and endCol |
||||
const startDate = dayjs(record.row[startCol!.title!]) |
||||
const endDate = dayjs(record.row[endCol!.title!]) |
||||
let currentWeekStart = startDate.startOf('week') |
||||
|
||||
const id = record.rowMeta.id ?? generateRandomNumber() |
||||
// Since the records can span multiple weeks, to display, we render multiple records |
||||
// for each week the record spans. The id is used to identify the elements that belong to the same record |
||||
|
||||
while ( |
||||
currentWeekStart.isSameOrBefore(endDate, 'day') && |
||||
// If the current week start is before the last day of the last week |
||||
currentWeekStart.isBefore(dates.value[dates.value.length - 1][6]) |
||||
) { |
||||
// We update the record start to currentWeekStart if it is before the start date |
||||
// and record end to currentWeekEnd if it is after the end date |
||||
const currentWeekEnd = currentWeekStart.endOf('week') |
||||
const recordStart = currentWeekStart.isBefore(startDate) ? startDate : currentWeekStart |
||||
const recordEnd = currentWeekEnd.isAfter(endDate) ? endDate : currentWeekEnd |
||||
|
||||
// Update the recordsInDay object to keep track of the number of records in a day |
||||
let day = recordStart.clone() |
||||
while (day.isSameOrBefore(recordEnd)) { |
||||
const dateKey = day.format('YYYY-MM-DD') |
||||
|
||||
if (!recordsInDay[dateKey]) { |
||||
recordsInDay[dateKey] = { overflow: false, count: 0, overflowCount: 0 } |
||||
} |
||||
recordsInDay[dateKey].count++ |
||||
day = day.add(1, 'day') |
||||
} |
||||
|
||||
// Find the index of the week from the dates array |
||||
const weekIndex = Math.max( |
||||
dates.value.findIndex((week) => { |
||||
return ( |
||||
week.findIndex((day) => { |
||||
return dayjs(day).isSame(recordStart, 'day') |
||||
}) !== -1 |
||||
) |
||||
}), |
||||
0, |
||||
) |
||||
|
||||
let maxRecordCount = 0 |
||||
|
||||
// Find the maximum number of records in a day in that week |
||||
for (let i = 0; i < (dates.value[weekIndex] ?? []).length; i++) { |
||||
const day = dates.value[weekIndex][i] |
||||
|
||||
const dateKey = dayjs(day).format('YYYY-MM-DD') |
||||
if (!recordsInDay[dateKey]) { |
||||
recordsInDay[dateKey] = { |
||||
count: 0, |
||||
overflow: false, |
||||
overflowCount: 0, |
||||
} |
||||
} |
||||
const recordIndex = recordsInDay[dateKey].count |
||||
maxRecordCount = Math.max(maxRecordCount, recordIndex) |
||||
} |
||||
|
||||
const startDayIndex = Math.max( |
||||
(dates.value[weekIndex] ?? []).findIndex((day) => dayjs(day).isSame(recordStart, 'day')), |
||||
0, |
||||
) |
||||
const endDayIndex = Math.max( |
||||
(dates.value[weekIndex] ?? []).findIndex((day) => dayjs(day).isSame(recordEnd, 'day')), |
||||
0, |
||||
) |
||||
|
||||
// The left and width of the record is calculated based on the start and end day index |
||||
const style: Partial<CSSStyleDeclaration> = { |
||||
left: `${startDayIndex * perWidth}px`, |
||||
width: `${(endDayIndex - startDayIndex + 1) * perWidth}px`, |
||||
} |
||||
|
||||
// The top is calculated from the week index and the record index |
||||
// If the record in 1st week and no record in that date them the top will be 0 |
||||
// If the record in 1st week and 1 record in that date then the top will be perRecordHeight + spaceBetweenRecords |
||||
const top = weekIndex * perHeight + spaceBetweenRecords + Math.max(maxRecordCount - 1, 0) * perRecordHeight |
||||
const heightRequired = perRecordHeight * maxRecordCount + spaceBetweenRecords |
||||
|
||||
let position = 'rounded' |
||||
// Here we are checking if the startDay is before all the dates shown in UI rather that the current month |
||||
const isStartMonthBeforeCurrentWeek = dates.value[weekIndex - 1] |
||||
? dayjs(dates.value[weekIndex - 1][0]).isBefore(startDate, 'month') |
||||
: false |
||||
|
||||
if (startDate.isSame(currentWeekStart, 'week') && endDate.isSame(currentWeekEnd, 'week')) { |
||||
position = 'rounded' |
||||
} else if (startDate.isSame(recordStart, 'week')) { |
||||
if (isStartMonthBeforeCurrentWeek) { |
||||
if (endDate.isSame(currentWeekEnd, 'week')) { |
||||
position = 'rounded' |
||||
} else position = 'leftRounded' |
||||
} else position = 'leftRounded' |
||||
} else if (endDate.isSame(currentWeekEnd, 'week')) { |
||||
position = 'rightRounded' |
||||
} else { |
||||
position = 'none' |
||||
} |
||||
|
||||
// If the height required is more than the height of the week, we hide the record |
||||
// and update the recordsInDay object for all the spanned days |
||||
if (heightRequired + 15 > perHeight) { |
||||
style.display = 'none' |
||||
for (let i = startDayIndex; i <= endDayIndex; i++) { |
||||
const week = dates.value[weekIndex] |
||||
if (!week) continue |
||||
const day = week[i] |
||||
const dateKey = dayjs(day).format('YYYY-MM-DD') |
||||
if (!recordsInDay[dateKey]) continue |
||||
recordsInDay[dateKey].overflow = true |
||||
recordsInDay[dateKey].overflowCount++ |
||||
} |
||||
} else { |
||||
style.top = `${top}px` |
||||
} |
||||
|
||||
recordsToDisplay.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
position, |
||||
style, |
||||
range, |
||||
id, |
||||
}, |
||||
}) |
||||
currentWeekStart = currentWeekStart.add(1, 'week') |
||||
} |
||||
} |
||||
}) |
||||
}) |
||||
|
||||
return { |
||||
records: recordsToDisplay, |
||||
count: recordsInDay, |
||||
} |
||||
}) |
||||
|
||||
const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean) => { |
||||
const { top, height, width, left } = calendarGridContainer.value.getBoundingClientRect() |
||||
|
||||
const percentY = (event.clientY - top - window.scrollY) / height |
||||
const percentX = (event.clientX - left - window.scrollX) / width |
||||
|
||||
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col |
||||
const toCol = dragRecord.value.rowMeta.range?.fk_to_col |
||||
|
||||
const week = Math.floor(percentY * dates.value.length) |
||||
const day = Math.floor(percentX * 7) |
||||
|
||||
const newStartDate = dates.value[week] ? dayjs(dates.value[week][day]) : null |
||||
if (!newStartDate) return |
||||
|
||||
let endDate |
||||
|
||||
const newRow = { |
||||
...dragRecord.value, |
||||
row: { |
||||
...dragRecord.value.row, |
||||
[fromCol!.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
|
||||
const updateProperty = [fromCol!.title!] |
||||
|
||||
if (toCol) { |
||||
const fromDate = dragRecord.value.row[fromCol!.title!] ? dayjs(dragRecord.value.row[fromCol!.title!]) : null |
||||
const toDate = dragRecord.value.row[toCol!.title!] ? dayjs(dragRecord.value.row[toCol!.title!]) : null |
||||
|
||||
if (fromDate && toDate) { |
||||
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day') |
||||
} else if (fromDate && !toDate) { |
||||
endDate = dayjs(newStartDate).endOf('day') |
||||
} else if (!fromDate && toDate) { |
||||
endDate = dayjs(newStartDate).endOf('day') |
||||
} else { |
||||
endDate = newStartDate.clone() |
||||
} |
||||
|
||||
dragRecord.value = undefined |
||||
newRow.row[toCol!.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ') |
||||
updateProperty.push(toCol!.title!) |
||||
} |
||||
|
||||
if (!newRow) return |
||||
|
||||
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) |
||||
|
||||
if (updateSideBar) { |
||||
formattedData.value = [...formattedData.value, newRow] |
||||
formattedSideBarData.value = formattedSideBarData.value.filter((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk !== newPk |
||||
}) |
||||
} else { |
||||
formattedData.value = formattedData.value.map((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk === newPk ? newRow : r |
||||
}) |
||||
} |
||||
|
||||
return { |
||||
newRow, |
||||
updateProperty, |
||||
} |
||||
} |
||||
|
||||
const onDrag = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit') || !dragRecord.value) return |
||||
calculateNewRow(event, false) |
||||
} |
||||
|
||||
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => { |
||||
updateRowProperty(row, updateProperty, isDelete) |
||||
}, 500) |
||||
|
||||
const onResize = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit') || !resizeRecord.value) return |
||||
|
||||
const { top, height, width, left } = calendarGridContainer.value.getBoundingClientRect() |
||||
|
||||
const percentY = (event.clientY - top - window.scrollY) / height |
||||
const percentX = (event.clientX - left - window.scrollX) / width |
||||
|
||||
const ogEndDate = resizeRecord.value.row[resizeRecord.value.rowMeta!.range!.fk_to_col!.title!] |
||||
const ogStartDate = resizeRecord.value.row[resizeRecord.value.rowMeta!.range!.fk_from_col!.title!] |
||||
|
||||
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col |
||||
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col |
||||
|
||||
const week = Math.floor(percentY * dates.value.length) |
||||
const day = Math.floor(percentX * 7) |
||||
|
||||
let updateProperty: string[] = [] |
||||
let newRow: Row |
||||
|
||||
if (resizeDirection.value === 'right') { |
||||
let newEndDate = dates.value[week] ? dayjs(dates.value[week][day]).endOf('day') : null |
||||
updateProperty = [toCol!.title!] |
||||
|
||||
if (dayjs(newEndDate).isBefore(ogStartDate)) { |
||||
newEndDate = dayjs(ogStartDate).clone().endOf('day') |
||||
} |
||||
|
||||
if (!newEndDate) return |
||||
|
||||
newRow = { |
||||
...resizeRecord.value, |
||||
row: { |
||||
...resizeRecord.value.row, |
||||
[toCol!.title!]: dayjs(newEndDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} else if (resizeDirection.value === 'left') { |
||||
let newStartDate = dates.value[week] ? dayjs(dates.value[week][day]) : null |
||||
updateProperty = [fromCol!.title!] |
||||
|
||||
if (dayjs(newStartDate).isAfter(ogEndDate)) { |
||||
newStartDate = dayjs(ogEndDate).clone() |
||||
} |
||||
if (!newStartDate) return |
||||
|
||||
newRow = { |
||||
...resizeRecord.value, |
||||
row: { |
||||
...resizeRecord.value.row, |
||||
[fromCol!.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} |
||||
|
||||
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) |
||||
formattedData.value = formattedData.value.map((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
|
||||
return pk === newPk ? newRow : r |
||||
}) |
||||
|
||||
useDebouncedRowUpdate(newRow, updateProperty, false) |
||||
} |
||||
|
||||
const onResizeEnd = () => { |
||||
resizeInProgress.value = false |
||||
resizeDirection.value = undefined |
||||
resizeRecord.value = undefined |
||||
|
||||
document.removeEventListener('mousemove', onResize) |
||||
document.removeEventListener('mouseup', onResizeEnd) |
||||
} |
||||
|
||||
const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: Row) => { |
||||
if (!isUIAllowed('dataEdit') || draggingId.value) return |
||||
|
||||
selectedDate.value = null |
||||
resizeInProgress.value = true |
||||
resizeDirection.value = direction |
||||
resizeRecord.value = record |
||||
|
||||
document.addEventListener('mousemove', onResize) |
||||
document.addEventListener('mouseup', onResizeEnd) |
||||
} |
||||
|
||||
const stopDrag = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit') || !dragRecord.value || !isDragging.value) return |
||||
|
||||
event.preventDefault() |
||||
clearTimeout(dragTimeout.value) |
||||
|
||||
dragElement.value!.style.boxShadow = 'none' |
||||
|
||||
const { newRow, updateProperty } = calculateNewRow(event, false) |
||||
|
||||
const allRecords = document.querySelectorAll('.draggable-record') |
||||
allRecords.forEach((el) => { |
||||
el.style.visibility = '' |
||||
el.style.opacity = '100%' |
||||
}) |
||||
|
||||
if (dragElement.value) { |
||||
dragElement.value.style.boxShadow = 'none' |
||||
isDragging.value = false |
||||
draggingId.value = null |
||||
dragElement.value = null |
||||
} |
||||
|
||||
updateRowProperty(newRow, updateProperty, false) |
||||
focusedDate.value = null |
||||
|
||||
document.removeEventListener('mousemove', onDrag) |
||||
document.removeEventListener('mouseup', stopDrag) |
||||
} |
||||
|
||||
const dragStart = (event: MouseEvent, record: Row) => { |
||||
if (!isUIAllowed('dataEdit') || resizeInProgress.value || !record.rowMeta.id) return |
||||
let target = event.target as HTMLElement |
||||
isDragging.value = false |
||||
|
||||
dragTimeout.value = setTimeout(() => { |
||||
isDragging.value = true |
||||
|
||||
while (!target.classList.contains('draggable-record')) { |
||||
target = target.parentElement as HTMLElement |
||||
} |
||||
|
||||
const allRecords = document.querySelectorAll('.draggable-record') |
||||
allRecords.forEach((el) => { |
||||
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) { |
||||
// el.style.visibility = 'hidden' |
||||
el.style.opacity = '30%' |
||||
} |
||||
}) |
||||
|
||||
dragRecord.value = record |
||||
selectedDate.value = null |
||||
|
||||
isDragging.value = true |
||||
dragElement.value = target |
||||
draggingId.value = record.rowMeta!.id! |
||||
dragRecord.value = record |
||||
|
||||
document.addEventListener('mousemove', onDrag) |
||||
document.addEventListener('mouseup', stopDrag) |
||||
}, 200) |
||||
|
||||
const onMouseUp = () => { |
||||
clearTimeout(dragTimeout.value) |
||||
document.removeEventListener('mouseup', onMouseUp) |
||||
if (!isDragging.value) { |
||||
emit('expandRecord', record) |
||||
} |
||||
} |
||||
|
||||
document.addEventListener('mouseup', onMouseUp) |
||||
} |
||||
|
||||
const dropEvent = (event: DragEvent) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
event.preventDefault() |
||||
const data = event.dataTransfer?.getData('text/plain') |
||||
if (data) { |
||||
const { |
||||
record, |
||||
}: { |
||||
record: Row |
||||
} = JSON.parse(data) |
||||
|
||||
dragRecord.value = record |
||||
|
||||
const { newRow, updateProperty } = calculateNewRow(event, true) |
||||
|
||||
if (dragElement.value) { |
||||
dragElement.value.style.boxShadow = 'none' |
||||
dragElement.value = null |
||||
} |
||||
updateRowProperty(newRow, updateProperty, false) |
||||
} |
||||
} |
||||
|
||||
const selectDate = (date: dayjs.Dayjs) => { |
||||
dragRecord.value = undefined |
||||
draggingId.value = null |
||||
resizeRecord.value = undefined |
||||
resizeInProgress.value = false |
||||
resizeDirection.value = undefined |
||||
focusedDate.value = null |
||||
selectedDate.value = date |
||||
} |
||||
|
||||
const viewMore = (date: dayjs.Dayjs) => { |
||||
sideBarFilterOption.value = 'selectedDate' as const |
||||
selectedDate.value = date |
||||
showSideMenu.value = true |
||||
} |
||||
|
||||
const isDateSelected = (date: dayjs.Dayjs) => { |
||||
if (!selectedDate.value) return false |
||||
return dayjs(date).isSame(selectedDate.value, 'day') |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div v-if="calendarRange" class="h-full prevent-select relative" data-testid="nc-calendar-month-view"> |
||||
<div class="grid grid-cols-7"> |
||||
<div |
||||
v-for="(day, index) in days" |
||||
:key="index" |
||||
class="text-center bg-gray-50 py-1 text-sm border-b-1 border-r-1 last:border-r-0 border-gray-200 font-semibold text-gray-500" |
||||
> |
||||
{{ day }} |
||||
</div> |
||||
</div> |
||||
<div |
||||
ref="calendarGridContainer" |
||||
:class="{ |
||||
'grid-rows-5': dates.length === 5, |
||||
'grid-rows-6': dates.length === 6, |
||||
'grid-rows-7': dates.length === 7, |
||||
}" |
||||
class="grid h-full pb-7.5" |
||||
@drop="dropEvent" |
||||
> |
||||
<div v-for="(week, weekIndex) in dates" :key="weekIndex" class="grid grid-cols-7 grow" data-testid="nc-calendar-month-week"> |
||||
<div |
||||
v-for="(day, dateIndex) in week" |
||||
:key="`${weekIndex}-${dateIndex}`" |
||||
:class="{ |
||||
'border-brand-500 border-1 !border-r-1 border-b-1': |
||||
isDateSelected(day) || (focusedDate && dayjs(day).isSame(focusedDate, 'day')), |
||||
'!text-gray-400': !isDayInPagedMonth(day), |
||||
}" |
||||
class="text-right relative group last:border-r-0 text-sm h-full border-r-1 border-b-1 border-gray-200 font-medium hover:bg-gray-50 text-gray-800 bg-white" |
||||
data-testid="nc-calendar-month-day" |
||||
@click="selectDate(day)" |
||||
> |
||||
<div v-if="isUIAllowed('dataEdit')" class="flex justify-between p-1"> |
||||
<span |
||||
:class="{ |
||||
block: !isDateSelected(day), |
||||
hidden: isDateSelected(day), |
||||
}" |
||||
class="group-hover:hidden" |
||||
></span> |
||||
|
||||
<NcDropdown v-if="calendarRange.length > 1" auto-close> |
||||
<NcButton |
||||
:class="{ |
||||
'!block': isDateSelected(day), |
||||
'!hidden': !isDateSelected(day), |
||||
}" |
||||
class="!group-hover:block" |
||||
size="small" |
||||
type="secondary" |
||||
> |
||||
<component :is="iconMap.plus" class="h-4 w-4" /> |
||||
</NcButton> |
||||
<template #overlay> |
||||
<NcMenu class="w-64"> |
||||
<NcMenuItem> Select date field to add </NcMenuItem> |
||||
<NcMenuItem |
||||
v-for="(range, index) in calendarRange" |
||||
:key="index" |
||||
class="text-gray-800 font-semibold text-sm" |
||||
@click=" |
||||
() => { |
||||
const record = { |
||||
row: { |
||||
[range.fk_from_col!.title!]: dayjs(day).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
emit('new-record', record) |
||||
} |
||||
" |
||||
> |
||||
<div class="flex items-center gap-1"> |
||||
<LazySmartsheetHeaderCellIcon :column-meta="range.fk_from_col" /> |
||||
<span class="ml-1">{{ range.fk_from_col!.title }}</span> |
||||
</div> |
||||
</NcMenuItem> |
||||
</NcMenu> |
||||
</template> |
||||
</NcDropdown> |
||||
<NcButton |
||||
:class="{ |
||||
'!block': isDateSelected(day), |
||||
'!hidden': !isDateSelected(day), |
||||
}" |
||||
class="!group-hover:block" |
||||
size="small" |
||||
type="secondary" |
||||
@click=" |
||||
() => { |
||||
const record = { |
||||
row: { |
||||
[calendarRange[0].fk_from_col!.title!]: (day).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
emit('new-record', record) |
||||
} |
||||
" |
||||
> |
||||
<component :is="iconMap.plus" class="h-4 w-4" /> |
||||
</NcButton> |
||||
<span |
||||
:class="{ |
||||
'bg-brand-50 text-brand-500': day.isSame(dayjs(), 'date'), |
||||
}" |
||||
class="px-1.5 rounded-lg py-1 my-1" |
||||
> |
||||
{{ day.format('DD') }} |
||||
</span> |
||||
</div> |
||||
<div v-if="!isUIAllowed('dataEdit')" class="p-3">{{ dayjs(day).format('DD') }}</div> |
||||
|
||||
<NcButton |
||||
v-if=" |
||||
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')] && |
||||
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflow && |
||||
!draggingId |
||||
" |
||||
class="!absolute bottom-1 text-center w-15 mx-auto inset-x-0 z-3 text-gray-500" |
||||
size="xxsmall" |
||||
type="secondary" |
||||
@click="viewMore(day)" |
||||
> |
||||
<span class="text-xs"> + {{ recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflowCount }} more </span> |
||||
</NcButton> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="absolute inset-0 pointer-events-none mt-8 pb-7.5" data-testid="nc-calendar-month-record-container"> |
||||
<div |
||||
v-for="(record, recordIndex) in recordsToDisplay.records" |
||||
:key="recordIndex" |
||||
:data-testid="`nc-calendar-month-record-${record.row[displayField!.title!]}`" |
||||
:data-unique-id="record.rowMeta.id" |
||||
:style="{ |
||||
...record.rowMeta.style, |
||||
zIndex: record.rowMeta.id === draggingId ? 100 : 0, |
||||
boxShadow: |
||||
record.rowMeta.id === draggingId |
||||
? '0px 8px 8px -4px rgba(0, 0, 0, 0.04), 0px 20px 24px -4px rgba(0, 0, 0, 0.10)' |
||||
: 'none', |
||||
}" |
||||
class="absolute group draggable-record cursor-pointer pointer-events-auto" |
||||
@mouseleave="hoverRecord = null" |
||||
@mouseover="hoverRecord = record.rowMeta.id" |
||||
@mousedown.stop="dragStart($event, record)" |
||||
> |
||||
<LazySmartsheetRow :row="record"> |
||||
<LazySmartsheetCalendarRecordCard |
||||
:hover="hoverRecord === record.rowMeta.id" |
||||
:position="record.rowMeta.position" |
||||
:record="record" |
||||
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')" |
||||
:selected=" |
||||
dragRecord |
||||
? dragRecord.rowMeta.id === record.rowMeta.id |
||||
: resizeRecord |
||||
? resizeRecord.rowMeta.id === record.rowMeta.id |
||||
: false |
||||
" |
||||
@resize-start="onResizeStart" |
||||
@dblclick.stop="emit('expand-record', record)" |
||||
> |
||||
<template v-if="!isRowEmpty(record, displayField)"> |
||||
<div |
||||
:class="{ |
||||
'mt-1.4': displayField!.uidt === UITypes.SingleLineText, |
||||
'mt-1': displayField!.uidt === UITypes.MultiSelect || displayField!.uidt === UITypes.SingleSelect, |
||||
}" |
||||
> |
||||
<LazySmartsheetVirtualCell |
||||
v-if="isVirtualCol(displayField!)" |
||||
v-model="record.row[displayField!.title!]" |
||||
:column="displayField" |
||||
:row="record" |
||||
/> |
||||
|
||||
<LazySmartsheetCell |
||||
v-else |
||||
v-model="record.row[displayField!.title!]" |
||||
:column="displayField" |
||||
:edit-enabled="false" |
||||
:read-only="true" |
||||
/> |
||||
</div> |
||||
</template> |
||||
</LazySmartsheetCalendarRecordCard> |
||||
</LazySmartsheetRow> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.hide { |
||||
transition: 0.01s; |
||||
transform: translateX(-9999px); |
||||
} |
||||
|
||||
.prevent-select { |
||||
-webkit-user-select: none; /* Safari */ |
||||
-ms-user-select: none; /* IE 10 and IE 11 */ |
||||
user-select: none; /* Standard syntax */ |
||||
} |
||||
</style> |
@ -0,0 +1,101 @@
|
||||
<script lang="ts" setup> |
||||
import type { Row } from '~/lib' |
||||
|
||||
interface Props { |
||||
color?: string |
||||
resize?: boolean |
||||
hover?: boolean |
||||
record?: Row |
||||
selected?: boolean |
||||
size?: 'small' | 'medium' | 'large' | 'auto' |
||||
position?: 'leftRounded' | 'rightRounded' | 'rounded' | 'none' |
||||
} |
||||
|
||||
withDefaults(defineProps<Props>(), { |
||||
resize: true, |
||||
selected: false, |
||||
hover: false, |
||||
color: 'blue', |
||||
size: 'small', |
||||
position: 'rounded', |
||||
}) |
||||
|
||||
const emit = defineEmits(['resize-start']) |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
:class="{ |
||||
'min-h-9': size === 'small', |
||||
'h-full': size === 'auto', |
||||
'rounded-l-lg ml-1': position === 'leftRounded', |
||||
'rounded-r-lg mr-1': position === 'rightRounded', |
||||
'rounded-lg mx-1': position === 'rounded', |
||||
'rounded-none': position === 'none', |
||||
'bg-maroon-50': color === 'maroon', |
||||
'bg-blue-50': color === 'blue', |
||||
'bg-green-50': color === 'green', |
||||
'bg-yellow-50': color === 'yellow', |
||||
'bg-pink-50': color === 'pink', |
||||
'bg-purple-50': color === 'purple', |
||||
'group-hover:(border-brand-500 border-1)': resize, |
||||
'!border-brand-500 border-1': selected || hover, |
||||
}" |
||||
class="relative transition-all flex items-center px-1 group border-1 border-transparent" |
||||
> |
||||
<div |
||||
v-if="position === 'leftRounded' || position === 'rounded'" |
||||
:class="{ |
||||
'bg-maroon-500': color === 'maroon', |
||||
'bg-blue-500': color === 'blue', |
||||
'bg-green-500': color === 'green', |
||||
'bg-yellow-500': color === 'yellow', |
||||
'bg-pink-500': color === 'pink', |
||||
'bg-purple-500': color === 'purple', |
||||
}" |
||||
class="block h-full min-h-5 w-1 rounded" |
||||
></div> |
||||
|
||||
<div v-if="(position === 'leftRounded' || position === 'rounded') && resize" class="mt-0.1 h-7.1 absolute -left-4 resize"> |
||||
<NcButton |
||||
:class="{ |
||||
'!block z-1 !border-brand-500': selected || hover, |
||||
'!hidden': !selected && !hover, |
||||
}" |
||||
size="xsmall" |
||||
type="secondary" |
||||
@mousedown.stop="emit('resize-start', 'left', $event, record)" |
||||
> |
||||
<component :is="iconMap.drag" class="text-gray-400"></component> |
||||
</NcButton> |
||||
</div> |
||||
|
||||
<div class="overflow-hidden ml-2 h-8 absolute"> |
||||
<span v-if="position === 'rightRounded' || position === 'none'" class="mr-1"> .... </span> |
||||
<span class="text-sm !w-[80%] text-gray-800"> |
||||
<slot /> |
||||
</span> |
||||
<span v-if="position === 'leftRounded' || position === 'none'" class="absolute my-0 right-5"> .... </span> |
||||
</div> |
||||
|
||||
<div v-if="(position === 'rightRounded' || position === 'rounded') && resize" class="absolute mt-0.1 z-1 -right-4 resize"> |
||||
<NcButton |
||||
:class="{ |
||||
'!block !border-brand-500': selected || hover, |
||||
'!hidden': !selected && !hover, |
||||
}" |
||||
size="xsmall" |
||||
type="secondary" |
||||
@mousedown.stop="emit('resize-start', 'right', $event, record)" |
||||
> |
||||
<component :is="iconMap.drag" class="text-gray-400"></component> |
||||
</NcButton> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.resize { |
||||
cursor: ew-resize; |
||||
} |
||||
</style> |
@ -0,0 +1,456 @@
|
||||
<script lang="ts" setup> |
||||
import type { VNodeRef } from '@vue/runtime-core' |
||||
import { UITypes, isVirtualCol } from 'nocodb-sdk' |
||||
import dayjs from 'dayjs' |
||||
import { type Row, computed, iconMap, isRowEmpty, ref } from '#imports' |
||||
|
||||
const props = defineProps<{ |
||||
visible: boolean |
||||
}>() |
||||
|
||||
const emit = defineEmits(['expand-record', 'newRecord']) |
||||
|
||||
const INFINITY_SCROLL_THRESHOLD = 100 |
||||
|
||||
const { isUIAllowed } = useRoles() |
||||
|
||||
const { appInfo } = useGlobal() |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const { |
||||
pageDate, |
||||
displayField, |
||||
selectedDate, |
||||
selectedTime, |
||||
selectedMonth, |
||||
calendarRange, |
||||
selectedDateRange, |
||||
activeDates, |
||||
activeCalendarView, |
||||
isSidebarLoading, |
||||
formattedSideBarData, |
||||
calDataType, |
||||
loadMoreSidebarData, |
||||
searchQuery, |
||||
sideBarFilterOption, |
||||
} = useCalendarViewStoreOrThrow() |
||||
|
||||
const sideBarListRef = ref<VNodeRef | null>(null) |
||||
|
||||
const pushToArray = (arr: Array<Row>, record: Row, range) => { |
||||
arr.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
range, |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
const dragElement = ref<HTMLElement | null>(null) |
||||
|
||||
const dragStart = (event: DragEvent, record: Row) => { |
||||
dragElement.value = event.target as HTMLElement |
||||
|
||||
dragElement.value.style.boxShadow = '0px 8px 8px -4px rgba(0, 0, 0, 0.04), 0px 20px 24px -4px rgba(0, 0, 0, 0.10)' |
||||
const eventRect = dragElement.value.getBoundingClientRect() |
||||
|
||||
const initialClickOffsetX = event.clientX - eventRect.left |
||||
const initialClickOffsetY = event.clientY - eventRect.top |
||||
|
||||
event.dataTransfer?.setData( |
||||
'text/plain', |
||||
JSON.stringify({ |
||||
record, |
||||
initialClickOffsetY, |
||||
initialClickOffsetX, |
||||
}), |
||||
) |
||||
} |
||||
|
||||
const renderData = computed<Array<Row>>(() => { |
||||
if (!calendarRange.value) return [] |
||||
|
||||
const rangedData: Array<Row> = [] |
||||
|
||||
calendarRange.value.forEach((range) => { |
||||
const fromCol = range.fk_from_col |
||||
const toCol = range.fk_to_col |
||||
formattedSideBarData.value.forEach((record) => { |
||||
if (fromCol && toCol) { |
||||
const from = dayjs(record.row[fromCol.title!]) |
||||
const to = dayjs(record.row[toCol.title!]) |
||||
if (sideBarFilterOption.value === 'withoutDates') { |
||||
if (!from.isValid() || !to.isValid()) { |
||||
pushToArray(rangedData, record, range) |
||||
} |
||||
} else if (sideBarFilterOption.value === 'allRecords') { |
||||
pushToArray(rangedData, record, range) |
||||
} else if ( |
||||
sideBarFilterOption.value === 'month' || |
||||
sideBarFilterOption.value === 'year' || |
||||
sideBarFilterOption.value === 'selectedDate' || |
||||
sideBarFilterOption.value === 'selectedHours' || |
||||
sideBarFilterOption.value === 'week' || |
||||
sideBarFilterOption.value === 'day' |
||||
) { |
||||
let fromDate: dayjs.Dayjs | null = null |
||||
let toDate: dayjs.Dayjs | null = null |
||||
|
||||
switch (sideBarFilterOption.value) { |
||||
case 'month': |
||||
fromDate = dayjs(selectedMonth.value).startOf('month') |
||||
toDate = dayjs(selectedMonth.value).endOf('month') |
||||
break |
||||
case 'year': |
||||
fromDate = dayjs(selectedDate.value).startOf('year') |
||||
toDate = dayjs(selectedDate.value).endOf('year') |
||||
break |
||||
case 'selectedDate': |
||||
fromDate = dayjs(selectedDate.value).startOf('day') |
||||
toDate = dayjs(selectedDate.value).endOf('day') |
||||
break |
||||
case 'week': |
||||
fromDate = dayjs(selectedDateRange.value.start).startOf('week') |
||||
toDate = dayjs(selectedDateRange.value.end).endOf('week') |
||||
break |
||||
case 'day': |
||||
fromDate = dayjs(selectedDate.value).startOf('day') |
||||
toDate = dayjs(selectedDate.value).endOf('day') |
||||
break |
||||
case 'selectedHours': |
||||
fromDate = dayjs(selectedTime.value).startOf('hour') |
||||
toDate = dayjs(selectedTime.value).endOf('hour') |
||||
break |
||||
} |
||||
|
||||
if (from && to) { |
||||
if ( |
||||
(from.isSameOrAfter(fromDate) && to.isSameOrBefore(toDate)) || |
||||
(from.isSameOrBefore(fromDate) && to.isSameOrAfter(toDate)) || |
||||
(from.isSameOrBefore(fromDate) && to.isSameOrAfter(fromDate)) || |
||||
(from.isSameOrBefore(toDate) && to.isSameOrAfter(toDate)) |
||||
) { |
||||
pushToArray(rangedData, record, range) |
||||
} |
||||
} |
||||
} |
||||
} else if (fromCol) { |
||||
const from = dayjs(record.row[fromCol.title!]) |
||||
if (sideBarFilterOption.value === 'withoutDates') { |
||||
if (!from.isValid()) { |
||||
pushToArray(rangedData, record, range) |
||||
} |
||||
} else if (sideBarFilterOption.value === 'allRecords') { |
||||
pushToArray(rangedData, record, range) |
||||
} else if (sideBarFilterOption.value === 'selectedDate' || sideBarFilterOption.value === 'day') { |
||||
if (from.isSame(selectedDate.value, 'day')) { |
||||
pushToArray(rangedData, record, range) |
||||
} |
||||
} else if (sideBarFilterOption.value === 'selectedHours') { |
||||
if (from.isSame(selectedTime.value, 'hour')) { |
||||
pushToArray(rangedData, record, range) |
||||
} |
||||
} else if ( |
||||
sideBarFilterOption.value === 'week' || |
||||
sideBarFilterOption.value === 'month' || |
||||
sideBarFilterOption.value === 'year' |
||||
) { |
||||
let fromDate: dayjs.Dayjs |
||||
let toDate: dayjs.Dayjs |
||||
|
||||
switch (sideBarFilterOption.value) { |
||||
case 'week': |
||||
fromDate = dayjs(selectedDateRange.value.start).startOf('week') |
||||
toDate = dayjs(selectedDateRange.value.end).endOf('week') |
||||
break |
||||
case 'month': |
||||
fromDate = dayjs(selectedMonth.value).startOf('month') |
||||
toDate = dayjs(selectedMonth.value).endOf('month') |
||||
break |
||||
case 'year': |
||||
fromDate = dayjs(selectedDate.value).startOf('year') |
||||
toDate = dayjs(selectedDate.value).endOf('year') |
||||
break |
||||
} |
||||
|
||||
if (from.isSameOrAfter(fromDate) && from.isSameOrBefore(toDate)) { |
||||
pushToArray(rangedData, record, range) |
||||
} |
||||
} |
||||
} |
||||
}) |
||||
}) |
||||
|
||||
return rangedData |
||||
}) |
||||
|
||||
const options = computed(() => { |
||||
switch (activeCalendarView.value) { |
||||
case 'day' as const: |
||||
if (calDataType.value === UITypes.Date) { |
||||
return [ |
||||
{ label: 'In this day', value: 'day' }, |
||||
{ label: 'Without dates', value: 'withoutDates' }, |
||||
{ label: 'All records', value: 'allRecords' }, |
||||
] |
||||
} else { |
||||
return [ |
||||
{ label: 'In this day', value: 'day' }, |
||||
{ label: 'Without dates', value: 'withoutDates' }, |
||||
{ label: 'All records', value: 'allRecords' }, |
||||
{ label: 'In selected hours', value: 'selectedHours' }, |
||||
] |
||||
} |
||||
case 'week' as const: |
||||
if (calDataType.value === UITypes.Date) { |
||||
return [ |
||||
{ label: 'In selected date', value: 'selectedDate' }, |
||||
{ label: 'Without dates', value: 'withoutDates' }, |
||||
{ label: 'In selected week', value: 'week' }, |
||||
{ label: 'All records', value: 'allRecords' }, |
||||
] |
||||
} else { |
||||
return [ |
||||
{ label: 'Without dates', value: 'withoutDates' }, |
||||
{ label: 'All records', value: 'allRecords' }, |
||||
{ label: 'In selected hours', value: 'selectedHours' }, |
||||
{ label: 'In selected week', value: 'week' }, |
||||
{ label: 'In selected date', value: 'selectedDate' }, |
||||
] |
||||
} |
||||
case 'month' as const: |
||||
return [ |
||||
{ label: 'In this month', value: 'month' }, |
||||
{ label: 'Without dates', value: 'withoutDates' }, |
||||
{ label: 'All records', value: 'allRecords' }, |
||||
{ label: 'In selected date', value: 'selectedDate' }, |
||||
] |
||||
case 'year' as const: |
||||
return [ |
||||
{ label: 'In this year', value: 'year' }, |
||||
{ label: 'Without dates', value: 'withoutDates' }, |
||||
{ label: 'All records', value: 'allRecords' }, |
||||
{ label: 'In selected date', value: 'selectedDate' }, |
||||
] |
||||
} |
||||
}) |
||||
|
||||
const sideBarListScrollHandle = useDebounceFn(async (e: Event) => { |
||||
const target = e.target as HTMLElement |
||||
if (target.clientHeight + target.scrollTop + INFINITY_SCROLL_THRESHOLD >= target.scrollHeight) { |
||||
const pageSize = appInfo.value?.defaultLimit ?? 25 |
||||
const page = Math.ceil(formattedSideBarData.value.length / pageSize) |
||||
|
||||
await loadMoreSidebarData({ |
||||
offset: page * pageSize, |
||||
}) |
||||
} |
||||
}) |
||||
|
||||
const newRecord = () => { |
||||
const row = { |
||||
...rowDefaultData(meta.value?.columns), |
||||
} |
||||
|
||||
if (activeCalendarView.value === 'day') { |
||||
row[calendarRange.value[0]!.fk_from_col!.title!] = selectedDate.value.format('YYYY-MM-DD HH:mm:ssZ') |
||||
} else if (activeCalendarView.value === 'week') { |
||||
row[calendarRange.value[0]!.fk_from_col!.title!] = selectedDateRange.value.start.format('YYYY-MM-DD HH:mm:ssZ') |
||||
} else if (activeCalendarView.value === 'month') { |
||||
row[calendarRange.value[0]!.fk_from_col!.title!] = (selectedDate.value ?? selectedMonth.value).format('YYYY-MM-DD HH:mm:ssZ') |
||||
} else if (activeCalendarView.value === 'year') { |
||||
row[calendarRange.value[0]!.fk_from_col!.title!] = selectedDate.value.format('YYYY-MM-DD HH:mm:ssZ') |
||||
} |
||||
|
||||
emit('newRecord', { row, oldRow: {}, rowMeta: { new: true } }) |
||||
} |
||||
|
||||
const height = ref(0) |
||||
|
||||
const heightListener = () => { |
||||
height.value = window.innerHeight |
||||
} |
||||
|
||||
onMounted(() => { |
||||
window.addEventListener('resize', heightListener) |
||||
}) |
||||
|
||||
onUnmounted(() => { |
||||
window.removeEventListener('resize', heightListener) |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
:class="{ |
||||
'w-0': !props.visible, |
||||
'w-1/6 min-w-[22.1rem] nc-calendar-side-menu-open': props.visible, |
||||
}" |
||||
class="h-full border-l-1 border-gray-200 transition-all" |
||||
data-testid="nc-calendar-side-menu" |
||||
> |
||||
<div |
||||
:class="{ |
||||
'!hidden': height < 918, |
||||
}" |
||||
class="flex flex-col" |
||||
> |
||||
<NcDateWeekSelector |
||||
v-if="activeCalendarView === ('day' as const)" |
||||
v-model:active-dates="activeDates" |
||||
v-model:page-date="pageDate" |
||||
v-model:selected-date="selectedDate" |
||||
/> |
||||
<NcDateWeekSelector |
||||
v-else-if="activeCalendarView === ('week' as const)" |
||||
v-model:active-dates="activeDates" |
||||
v-model:page-date="pageDate" |
||||
v-model:selected-week="selectedDateRange" |
||||
is-week-picker |
||||
/> |
||||
<NcMonthYearSelector |
||||
v-else-if="activeCalendarView === ('month' as const)" |
||||
v-model:page-date="pageDate" |
||||
v-model:selected-date="selectedMonth" |
||||
/> |
||||
<NcMonthYearSelector |
||||
v-else-if="activeCalendarView === ('year' as const)" |
||||
v-model:page-date="pageDate" |
||||
v-model:selected-date="selectedDate" |
||||
is-year-picker |
||||
/> |
||||
</div> |
||||
|
||||
<div |
||||
:class="{ |
||||
'!border-t-0': height < 918, |
||||
}" |
||||
class="px-4 border-t-1 border-gray-200 relative flex flex-col gap-y-4 pt-3" |
||||
> |
||||
<div class="flex items-center gap-2"> |
||||
<a-input |
||||
v-model:value="searchQuery.value" |
||||
:class="{ |
||||
'!border-brand-500': searchQuery.value.length > 0, |
||||
}" |
||||
class="!rounded-lg !h-8 !border-gray-200 !px-4" |
||||
data-testid="nc-calendar-sidebar-search" |
||||
placeholder="Search records" |
||||
> |
||||
<template #prefix> |
||||
<component :is="iconMap.search" class="h-4 w-4 mr-1 text-gray-500" /> |
||||
</template> |
||||
</a-input> |
||||
<NcSelect v-model:value="sideBarFilterOption" class="min-w-38 !text-gray-800" data-testid="nc-calendar-sidebar-filter"> |
||||
<a-select-option v-for="option in options" :key="option.value" :value="option.value" class="!text-gray-800"> |
||||
<div class="flex items-center justify-between gap-2"> |
||||
<div class="truncate flex-1"> |
||||
<NcTooltip :title="option.label" placement="top" show-on-truncate-only> |
||||
<template #title>{{ option.label }}</template> |
||||
{{ option.label }} |
||||
</NcTooltip> |
||||
</div> |
||||
<component |
||||
:is="iconMap.check" |
||||
v-if="sideBarFilterOption === option.value" |
||||
id="nc-selected-item-icon" |
||||
class="text-primary w-4 h-4" |
||||
/> |
||||
</div> |
||||
</a-select-option> |
||||
</NcSelect> |
||||
</div> |
||||
|
||||
<div |
||||
v-if="calendarRange" |
||||
:ref="sideBarListRef" |
||||
:class="{ |
||||
'!h-[calc(100vh-10.5rem)]': height < 918, |
||||
'h-[calc(100vh-36.2rem)]': activeCalendarView === ('day' as const) || activeCalendarView === ('week' as const) && height > 918, |
||||
'h-[calc(100vh-25.1rem)]': activeCalendarView === ('month' as const) || activeCalendarView === ('year' as const) && height > 918, |
||||
}" |
||||
class="gap-2 flex flex-col nc-scrollbar-md overflow-y-auto nc-calendar-top-height" |
||||
data-testid="nc-calendar-side-menu-list" |
||||
@scroll="sideBarListScrollHandle" |
||||
> |
||||
<NcButton |
||||
v-if="isUIAllowed('dataEdit') && props.visible" |
||||
v-e="['c:calendar:calendar-new-record-btn']" |
||||
class="!absolute right-5 !border-brand-500 bottom-5 !h-12 !w-12" |
||||
data-testid="nc-calendar-side-menu-new-btn" |
||||
type="secondary" |
||||
@click="newRecord" |
||||
> |
||||
<div class="px-4 flex items-center gap-2 justify-center"> |
||||
<component :is="iconMap.plus" class="h-6 w-6 text-lg text-brand-500" /> |
||||
</div> |
||||
</NcButton> |
||||
<div v-if="renderData.length === 0 || isSidebarLoading" class="flex h-full items-center justify-center"> |
||||
<GeneralLoader v-if="isSidebarLoading" size="large" /> |
||||
|
||||
<div v-else class="text-gray-500"> |
||||
{{ t('msg.noRecordsFound') }} |
||||
</div> |
||||
</div> |
||||
<template v-else-if="renderData.length > 0"> |
||||
<LazySmartsheetRow v-for="(record, rowIndex) in renderData" :key="rowIndex" :row="record"> |
||||
<LazySmartsheetCalendarSideRecordCard |
||||
:draggable="sideBarFilterOption === 'withoutDates' && activeCalendarView !== 'year'" |
||||
:from-date=" |
||||
record.rowMeta.range?.fk_from_col |
||||
? calDataType === UITypes.Date |
||||
? dayjs(record.row[record.rowMeta.range.fk_from_col.title!]).format('DD MMM') |
||||
: dayjs(record.row[record.rowMeta.range.fk_from_col.title!]).format('DD MMM•HH:mm A') |
||||
: null |
||||
" |
||||
:invalid=" |
||||
record.rowMeta.range!.fk_to_col && |
||||
dayjs(record.row[record.rowMeta.range!.fk_from_col.title!]).isAfter( |
||||
dayjs(record.row[record.rowMeta.range!.fk_to_col.title!]), |
||||
) |
||||
" |
||||
:row="record" |
||||
:to-date=" |
||||
record.rowMeta.range!.fk_to_col |
||||
? calDataType === UITypes.Date |
||||
? dayjs(record.row[record.rowMeta.range!.fk_to_col.title!]).format('DD MMM') |
||||
: dayjs(record.row[record.rowMeta.range!.fk_to_col.title!]).format('DD MMM•HH:mm A') |
||||
: null |
||||
" |
||||
color="blue" |
||||
data-testid="nc-sidebar-record-card" |
||||
@click="emit('expand-record', record)" |
||||
@dragstart="dragStart($event, record)" |
||||
@dragover.prevent |
||||
> |
||||
<template v-if="!isRowEmpty(record, displayField)"> |
||||
<div :class="{}"> |
||||
<LazySmartsheetVirtualCell |
||||
v-if="isVirtualCol(displayField)" |
||||
v-model="record.row[displayField.title]" |
||||
:column="displayField" |
||||
:row="record" |
||||
/> |
||||
|
||||
<LazySmartsheetCell |
||||
v-else |
||||
v-model="record.row[displayField.title]" |
||||
:column="displayField" |
||||
:edit-enabled="false" |
||||
:read-only="true" |
||||
/> |
||||
</div> |
||||
</template> |
||||
</LazySmartsheetCalendarSideRecordCard> |
||||
</LazySmartsheetRow> |
||||
</template> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped></style> |
@ -0,0 +1,50 @@
|
||||
<script lang="ts" setup> |
||||
interface Props { |
||||
fromDate?: string |
||||
toDate?: string |
||||
color?: string |
||||
showDate?: boolean |
||||
invalid?: boolean |
||||
} |
||||
|
||||
const props = withDefaults(defineProps<Props>(), { |
||||
fromDate: '', |
||||
color: 'blue', |
||||
showDate: true, |
||||
invalid: false, |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="border-1 cursor-pointer border-gray-200 items-center px-2 py-3 rounded-lg"> |
||||
<div class="flex items-center gap-2"> |
||||
<span |
||||
:class="{ |
||||
'bg-maroon-500': props.color === 'maroon', |
||||
'bg-blue-500': props.color === 'blue', |
||||
'bg-green-500': props.color === 'green', |
||||
'bg-yellow-500': props.color === 'yellow', |
||||
'bg-pink-500': props.color === 'pink', |
||||
'bg-purple-500': props.color === 'purple', |
||||
}" |
||||
class="block h-10 w-1 rounded" |
||||
></span> |
||||
<div class="flex text-ellipsis gap-1 flex-col"> |
||||
<span class="text-sm max-w-40 truncate text-gray-800"> |
||||
<slot /> |
||||
</span> |
||||
<span v-if="showDate" class="text-xs text-gray-500">{{ fromDate }} {{ toDate ? ` - ${toDate}` : '' }}</span> |
||||
</div> |
||||
</div> |
||||
|
||||
<div v-if="invalid" class="gap-3 bg-yellow-50 mt-2 border-gray-200 border-1 rounded-lg p-2 flex"> |
||||
<component :is="iconMap.warning" class="text-yellow-500 h-4 w-4" /> |
||||
<div class="flex flex-col gap-1"> |
||||
<h1 class="text-gray-800 text-bold">Date mismatch</h1> |
||||
<p class="text-gray-500">Selected End date is before Start date.</p> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped></style> |
@ -0,0 +1,97 @@
|
||||
<script lang="ts" setup> |
||||
interface Props { |
||||
record: Record<string, string> |
||||
color?: string |
||||
resize?: boolean |
||||
selected?: boolean |
||||
hover?: boolean |
||||
position?: 'topRounded' | 'bottomRounded' | 'rounded' | 'none' |
||||
} |
||||
|
||||
withDefaults(defineProps<Props>(), { |
||||
resize: true, |
||||
selected: false, |
||||
hover: false, |
||||
color: 'blue', |
||||
position: 'rounded', |
||||
}) |
||||
|
||||
const emit = defineEmits(['resize-start']) |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
v-if="(position === 'topRounded' || position === 'rounded') && resize" |
||||
class="absolute flex items-center justify-center w-full -mt-4 h-7.1 hidden z-1 resize !group-hover:(flex rounded-lg)" |
||||
> |
||||
<NcButton |
||||
:class="{ |
||||
'!flex border-1 rounded-lg border-brand-500': selected || hover, |
||||
}" |
||||
class="!group-hover:(border-brand-500) !border-1 cursor-ns-resize" |
||||
size="xsmall" |
||||
type="secondary" |
||||
@mousedown.stop="emit('resize-start', 'left', $event, record)" |
||||
> |
||||
<component :is="iconMap.drag" class="text-gray-400"></component> |
||||
</NcButton> |
||||
</div> |
||||
<div |
||||
:class="{ |
||||
'rounded-t-lg': position === 'topRounded', |
||||
'rounded-b-lg': position === 'bottomRounded', |
||||
'rounded-lg': position === 'rounded', |
||||
'rounded-none': position === 'none', |
||||
'bg-maroon-50': color === 'maroon', |
||||
'bg-blue-50': color === 'blue', |
||||
'bg-green-50': color === 'green', |
||||
'bg-yellow-50': color === 'yellow', |
||||
'bg-pink-50': color === 'pink', |
||||
'bg-purple-50': color === 'purple', |
||||
'group-hover:(border-brand-500)': resize, |
||||
'!border-brand-500 border-1': selected || hover, |
||||
}" |
||||
class="relative h-full ml-0.25 border-1 border-gray-50" |
||||
> |
||||
<div class="h-full absolute py-2"> |
||||
<div |
||||
:class="{ |
||||
'bg-maroon-500': color === 'maroon', |
||||
'bg-blue-500': color === 'blue', |
||||
'bg-green-500': color === 'green', |
||||
'bg-yellow-500': color === 'yellow', |
||||
'bg-pink-500': color === 'pink', |
||||
'bg-purple-500': color === 'purple', |
||||
}" |
||||
class="block h-full min-h-5 ml-1 w-1 rounded mr-2" |
||||
></div> |
||||
</div> |
||||
|
||||
<div v-if="position === 'bottomRounded' || position === 'none'" class="ml-3">....</div> |
||||
|
||||
<div class="ml-3 pr-3 text-ellipsis overflow-hidden w-full h-8 absolute"> |
||||
<span class="text-sm text-gray-800"> |
||||
<slot /> |
||||
</span> |
||||
</div> |
||||
<div v-if="position === 'topRounded' || position === 'none'" class="h-full pb-7 flex items-end ml-3">....</div> |
||||
</div> |
||||
<div |
||||
v-if="(position === 'bottomRounded' || position === 'rounded') && resize" |
||||
class="absolute items-center justify-center w-full hidden h-7.1 -mt-4 !group-hover:(flex rounded-lg)" |
||||
> |
||||
<NcButton |
||||
:class="{ |
||||
'!flex border-1 rounded-lg z-1 cursor-ns-resize border-brand-500': selected || hover, |
||||
}" |
||||
class="!group-hover:(border-brand-500) !border-1" |
||||
size="xsmall" |
||||
type="secondary" |
||||
@mousedown.stop="emit('resize-start', 'right', $event, record)" |
||||
> |
||||
<component :is="iconMap.drag" class="text-gray-400"></component> |
||||
</NcButton> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped></style> |
@ -0,0 +1,605 @@
|
||||
<script lang="ts" setup> |
||||
import dayjs from 'dayjs' |
||||
import { UITypes, isVirtualCol } from 'nocodb-sdk' |
||||
import type { Row } from '~/lib' |
||||
import { ref } from '#imports' |
||||
import { generateRandomNumber, isRowEmpty } from '~/utils' |
||||
|
||||
const emits = defineEmits(['expandRecord']) |
||||
|
||||
const { selectedDateRange, formattedData, formattedSideBarData, calendarRange, selectedDate, displayField, updateRowProperty } = |
||||
useCalendarViewStoreOrThrow() |
||||
|
||||
const container = ref<null | HTMLElement>(null) |
||||
|
||||
const { width: containerWidth } = useElementSize(container) |
||||
|
||||
const { isUIAllowed } = useRoles() |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
// Calculate the dates of the week |
||||
const weekDates = computed(() => { |
||||
const startOfWeek = new Date(selectedDateRange.value.start!) |
||||
const endOfWeek = new Date(selectedDateRange.value.end!) |
||||
const datesArray = [] |
||||
while (startOfWeek.getTime() <= endOfWeek.getTime()) { |
||||
datesArray.push(new Date(startOfWeek)) |
||||
startOfWeek.setDate(startOfWeek.getDate() + 1) |
||||
} |
||||
return datesArray |
||||
}) |
||||
|
||||
// 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 calendarData = computed(() => { |
||||
if (!formattedData.value || !calendarRange.value) return [] |
||||
|
||||
// We use the recordsInDay object to keep track of which columns are occupied for each day |
||||
// This is used to calculate the position of the records in the calendar |
||||
// The key is the day index (0-6) and the value is an object with the row index as the key and a boolean as the value |
||||
// Since no hours are considered, the rowIndex will be sufficient to calculate the position |
||||
const recordsInDay: { |
||||
[key: number]: { |
||||
[key: number]: boolean |
||||
} |
||||
} = { |
||||
0: {}, |
||||
1: {}, |
||||
2: {}, |
||||
3: {}, |
||||
4: {}, |
||||
5: {}, |
||||
6: {}, |
||||
} |
||||
|
||||
const recordsInRange: Array<Row> = [] |
||||
const perDayWidth = containerWidth.value / 7 |
||||
|
||||
calendarRange.value.forEach((range) => { |
||||
const fromCol = range.fk_from_col |
||||
const toCol = range.fk_to_col |
||||
if (fromCol && toCol) { |
||||
// Filter out records that have an invalid date range |
||||
// i.e. the end date is before the start date |
||||
|
||||
for (const record of [...formattedData.value].filter((r) => { |
||||
const startDate = dayjs(r.row[fromCol.title!]) |
||||
const endDate = dayjs(r.row[toCol.title!]) |
||||
if (!startDate.isValid() || !endDate.isValid()) return false |
||||
return !endDate.isBefore(startDate) |
||||
})) { |
||||
// Generate a unique id for the record if it doesn't have one |
||||
const id = record.row.id ?? generateRandomNumber() |
||||
let startDate = dayjs(record.row[fromCol.title!]) |
||||
const ogStartDate = startDate.clone() |
||||
const endDate = dayjs(record.row[toCol.title!]) |
||||
|
||||
// If the start date is before the selected date range, we need to adjust the start date |
||||
if (startDate.isBefore(selectedDateRange.value.start)) { |
||||
startDate = dayjs(selectedDateRange.value.start) |
||||
} |
||||
|
||||
const startDaysDiff = startDate.diff(selectedDateRange.value.start, 'day') |
||||
|
||||
// Calculate the span of the record in days |
||||
let spanDays = Math.max(Math.min(endDate.diff(startDate, 'day'), 6) + 1, 1) |
||||
|
||||
// If the end date is after the month of the selected date range, we need to adjust the span |
||||
if (endDate.isAfter(startDate, 'month')) { |
||||
spanDays = 7 - startDaysDiff |
||||
} |
||||
|
||||
if (startDaysDiff > 0) { |
||||
spanDays = Math.min(spanDays, 7 - startDaysDiff) |
||||
} |
||||
const widthStyle = `calc(max(${spanDays} * ${perDayWidth}px, ${perDayWidth}px))` |
||||
|
||||
let suitableRow = -1 |
||||
// Find the first suitable row for the entire span |
||||
for (let i = 0; i < spanDays; i++) { |
||||
const dayIndex = startDaysDiff + i |
||||
|
||||
if (!recordsInDay[dayIndex]) { |
||||
recordsInDay[dayIndex] = {} |
||||
} |
||||
|
||||
if (suitableRow === -1) { |
||||
suitableRow = findFirstSuitableRow(recordsInDay, dayIndex, spanDays) |
||||
} |
||||
} |
||||
|
||||
// Mark the suitable column as occupied for the entire span |
||||
for (let i = 0; i < spanDays; i++) { |
||||
const dayIndex = startDaysDiff + i |
||||
recordsInDay[dayIndex][suitableRow] = true |
||||
} |
||||
|
||||
let position = 'none' |
||||
|
||||
const isStartInRange = |
||||
ogStartDate && ogStartDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]') |
||||
const isEndInRange = endDate && endDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]') |
||||
|
||||
// Calculate the position of the record in the calendar based on the start and end date |
||||
// The position can be 'none', 'leftRounded', 'rightRounded', 'rounded' |
||||
// This is used to assign the rounded corners to the records |
||||
if (isStartInRange && isEndInRange) { |
||||
position = 'rounded' |
||||
} else if ( |
||||
startDate && |
||||
endDate && |
||||
startDate.isBefore(selectedDateRange.value.start) && |
||||
endDate.isAfter(selectedDateRange.value.end) |
||||
) { |
||||
position = 'none' |
||||
} else if ( |
||||
startDate && |
||||
endDate && |
||||
(startDate.isAfter(selectedDateRange.value.end) || endDate.isBefore(selectedDateRange.value.start)) |
||||
) { |
||||
position = 'none' |
||||
} else if (isStartInRange) { |
||||
position = 'leftRounded' |
||||
} else if (isEndInRange) { |
||||
position = 'rightRounded' |
||||
} |
||||
|
||||
recordsInRange.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
range: range as any, |
||||
position, |
||||
id, |
||||
style: { |
||||
width: widthStyle, |
||||
left: `${startDaysDiff * perDayWidth}px`, |
||||
top: `${suitableRow * 40}px`, |
||||
}, |
||||
}, |
||||
}) |
||||
} |
||||
} else if (fromCol) { |
||||
for (const record of formattedData.value) { |
||||
const id = record.row.id ?? generateRandomNumber() |
||||
|
||||
const startDate = dayjs(record.row[fromCol.title!]) |
||||
const startDaysDiff = Math.max(startDate.diff(selectedDateRange.value.start, 'day'), 0) |
||||
|
||||
// Find the first suitable row for record. Here since the span is 1, we can use the findFirstSuitableRow function |
||||
const suitableRow = findFirstSuitableRow(recordsInDay, startDaysDiff, 1) |
||||
recordsInDay[startDaysDiff][suitableRow] = true |
||||
|
||||
recordsInRange.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
range: range as any, |
||||
id, |
||||
position: 'rounded', |
||||
style: { |
||||
width: `calc(${perDayWidth}px)`, |
||||
left: `${startDaysDiff * perDayWidth}px`, |
||||
top: `${suitableRow * 40}px`, |
||||
}, |
||||
}, |
||||
}) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
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 useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => { |
||||
updateRowProperty(row, updateProperty, isDelete) |
||||
}, 500) |
||||
|
||||
// This function is used to calculate the new start and end date of a record when resizing |
||||
const onResize = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit') || !container.value || !resizeRecord.value) return |
||||
|
||||
const { width, left } = container.value.getBoundingClientRect() |
||||
|
||||
// Calculate the percentage of the width based on the mouse position |
||||
const percentX = (event.clientX - left - window.scrollX) / width |
||||
|
||||
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col |
||||
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col |
||||
if (!fromCol || !toCol) return |
||||
|
||||
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!]) |
||||
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!]) |
||||
|
||||
const day = Math.floor(percentX * 7) |
||||
|
||||
let updateProperty: string[] = [] |
||||
let updateRecord: Row |
||||
|
||||
if (resizeDirection.value === 'right') { |
||||
// Calculate the new end date based on the day index by adding the day index to the start date of the selected date range |
||||
let newEndDate = dayjs(selectedDateRange.value.start).add(day, 'day') |
||||
updateProperty = [toCol.title!] |
||||
|
||||
// If the new end date is before the start date, we need to adjust the end date to the start date |
||||
if (dayjs(newEndDate).isBefore(ogStartDate, 'day')) { |
||||
newEndDate = ogStartDate.clone() |
||||
} |
||||
|
||||
if (!newEndDate.isValid()) return |
||||
|
||||
updateRecord = { |
||||
...resizeRecord.value, |
||||
row: { |
||||
...resizeRecord.value.row, |
||||
[toCol.title!]: newEndDate.format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} else if (resizeDirection.value === 'left') { |
||||
// Calculate the new start date based on the day index by adding the day index to the start date of the selected date range |
||||
let newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day') |
||||
updateProperty = [fromCol.title!] |
||||
|
||||
// If the new start date is after the end date, we need to adjust the start date to the end date |
||||
if (dayjs(newStartDate).isAfter(ogEndDate)) { |
||||
newStartDate = dayjs(dayjs(ogEndDate)).clone() |
||||
} |
||||
if (!newStartDate) return |
||||
|
||||
updateRecord = { |
||||
...resizeRecord.value, |
||||
row: { |
||||
...resizeRecord.value.row, |
||||
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} |
||||
|
||||
// Update the record in the store |
||||
const newPk = extractPkFromRow(updateRecord.row, meta.value!.columns!) |
||||
formattedData.value = formattedData.value.map((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
|
||||
return pk === newPk ? updateRecord : r |
||||
}) |
||||
useDebouncedRowUpdate(updateRecord, updateProperty, false) |
||||
} |
||||
|
||||
const onResizeEnd = () => { |
||||
resizeInProgress.value = false |
||||
resizeDirection.value = null |
||||
resizeRecord.value = null |
||||
document.removeEventListener('mousemove', onResize) |
||||
document.removeEventListener('mouseup', onResizeEnd) |
||||
} |
||||
|
||||
const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: Row) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
resizeInProgress.value = true |
||||
resizeDirection.value = direction |
||||
resizeRecord.value = record |
||||
document.addEventListener('mousemove', onResize) |
||||
document.addEventListener('mouseup', onResizeEnd) |
||||
} |
||||
|
||||
// This method is used to calculate the new start and end date of a record when dragging and dropping |
||||
const calculateNewRow = (event: MouseEvent, updateSideBarData?: boolean) => { |
||||
const { width, left } = container.value.getBoundingClientRect() |
||||
|
||||
// Calculate the percentage of the width based on the mouse position |
||||
// This is used to calculate the day index |
||||
const percentX = (event.clientX - left - window.scrollX) / width |
||||
|
||||
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col |
||||
const toCol = dragRecord.value.rowMeta.range?.fk_to_col |
||||
|
||||
if (!fromCol) return { updatedProperty: [], newRow: null } |
||||
|
||||
// Calculate the day index based on the percentage of the width |
||||
// The day index is a number between 0 and 6 |
||||
const day = Math.floor(percentX * 7) |
||||
|
||||
// Calculate the new start date based on the day index by adding the day index to the start date of the selected date range |
||||
const newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day') |
||||
if (!newStartDate) return { updatedProperty: [], newRow: null } |
||||
|
||||
let endDate |
||||
|
||||
const newRow = { |
||||
...dragRecord.value, |
||||
row: { |
||||
...dragRecord.value.row, |
||||
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
|
||||
const updateProperty = [fromCol.title!] |
||||
|
||||
if (toCol) { |
||||
const fromDate = dragRecord.value.row[fromCol.title!] ? dayjs(dragRecord.value.row[fromCol.title!]) : null |
||||
const toDate = dragRecord.value.row[toCol.title!] ? dayjs(dragRecord.value.row[toCol.title!]) : null |
||||
|
||||
// Calculate the new end date based on the day index by adding the day index to the start date of the selected date range |
||||
// If the record has an end date, we need to calculate the new end date based on the difference between the start and end date |
||||
// If the record doesn't have an end date, we need to calculate the new end date based on the start date |
||||
// If the record has an end date and no start Date, we set the end date to the start date |
||||
if (fromDate && toDate) { |
||||
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day') |
||||
} else if (fromDate && !toDate) { |
||||
endDate = dayjs(newStartDate).endOf('day') |
||||
} else if (!fromDate && toDate) { |
||||
endDate = dayjs(newStartDate).endOf('day') |
||||
} else { |
||||
endDate = newStartDate.clone() |
||||
} |
||||
|
||||
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ') |
||||
updateProperty.push(toCol.title!) |
||||
} |
||||
|
||||
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) |
||||
if (updateSideBarData) { |
||||
// If the record is being dragged from the sidebar, we need to remove the record from the sidebar data |
||||
// and add the new record to the calendar data |
||||
formattedData.value = [...formattedData.value, newRow] |
||||
formattedSideBarData.value = formattedSideBarData.value.filter((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk !== newPk |
||||
}) |
||||
} else { |
||||
// If the record is being dragged within the calendar, we need to update the record in the calendar data |
||||
formattedData.value = formattedData.value.map((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk === newPk ? newRow : r |
||||
}) |
||||
} |
||||
|
||||
return { updateProperty, newRow } |
||||
} |
||||
|
||||
const onDrag = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
if (!container.value || !dragRecord.value) return |
||||
calculateNewRow(event) |
||||
} |
||||
|
||||
const stopDrag = (event: MouseEvent) => { |
||||
event.preventDefault() |
||||
clearTimeout(dragTimeout.value!) |
||||
|
||||
if (!isUIAllowed('dataEdit')) return |
||||
if (!isDragging.value || !container.value || !dragRecord.value) return |
||||
|
||||
const { updateProperty, newRow } = calculateNewRow(event) |
||||
|
||||
if (!newRow) return |
||||
|
||||
// Open drop the record, we reset the opacity of the other records |
||||
const allRecords = document.querySelectorAll('.draggable-record') |
||||
allRecords.forEach((el) => { |
||||
el.style.visibility = '' |
||||
el.style.opacity = '100%' |
||||
}) |
||||
|
||||
if (dragElement.value) { |
||||
dragElement.value.style.boxShadow = 'none' |
||||
dragElement.value = null |
||||
} |
||||
|
||||
updateRowProperty(newRow, updateProperty, false) |
||||
|
||||
document.removeEventListener('mousemove', onDrag) |
||||
document.removeEventListener('mouseup', stopDrag) |
||||
} |
||||
|
||||
const dragStart = (event: MouseEvent, record: Row) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
if (resizeInProgress.value) return |
||||
let target = event.target as HTMLElement |
||||
|
||||
isDragging.value = false |
||||
|
||||
dragTimeout.value = setTimeout(() => { |
||||
isDragging.value = true |
||||
while (!target.classList.contains('draggable-record')) { |
||||
target = target.parentElement as HTMLElement |
||||
} |
||||
|
||||
const allRecords = document.querySelectorAll('.draggable-record') |
||||
allRecords.forEach((el) => { |
||||
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) { |
||||
el.style.opacity = '30%' |
||||
} |
||||
}) |
||||
|
||||
dragRecord.value = record |
||||
|
||||
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) { |
||||
emits('expandRecord', record) |
||||
} |
||||
} |
||||
|
||||
document.addEventListener('mouseup', onMouseUp) |
||||
} |
||||
|
||||
const dropEvent = (event: DragEvent) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
event.preventDefault() |
||||
|
||||
const data = event.dataTransfer?.getData('text/plain') |
||||
if (data) { |
||||
const { |
||||
record, |
||||
}: { |
||||
record: Row |
||||
} = JSON.parse(data) |
||||
|
||||
dragRecord.value = record |
||||
|
||||
const { updateProperty, newRow } = calculateNewRow(event, true) |
||||
|
||||
if (dragElement.value) { |
||||
dragElement.value.style.boxShadow = 'none' |
||||
dragElement.value = null |
||||
} |
||||
updateRowProperty(newRow, updateProperty, false) |
||||
|
||||
dragRecord.value = null |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flex relative flex-col prevent-select" data-testid="nc-calendar-week-view" @drop="dropEvent"> |
||||
<div class="flex"> |
||||
<div |
||||
v-for="(date, weekIndex) in weekDates" |
||||
:key="weekIndex" |
||||
:class="{ |
||||
'!border-brand-500 !border-b-gray-200': dayjs(date).isSame(selectedDate, 'day'), |
||||
}" |
||||
class="w-1/7 text-center text-sm text-gray-500 w-full py-1 border-gray-200 border-l-gray-50 border-t-gray-50 last:border-r-0 border-1 bg-gray-50" |
||||
> |
||||
{{ dayjs(date).format('DD ddd') }} |
||||
</div> |
||||
</div> |
||||
<div ref="container" class="flex h-[calc(100vh-11.6rem)]"> |
||||
<div |
||||
v-for="(date, dateIndex) in weekDates" |
||||
:key="dateIndex" |
||||
:class="{ |
||||
'!border-1 !border-t-0 border-brand-500': dayjs(date).isSame(selectedDate, 'day'), |
||||
}" |
||||
class="flex flex-col border-r-1 min-h-[100vh] last:border-r-0 items-center w-1/7" |
||||
data-testid="nc-calendar-week-day" |
||||
@click="selectedDate = dayjs(date)" |
||||
></div> |
||||
</div> |
||||
<div |
||||
class="absolute nc-scrollbar-md overflow-y-auto mt-9 pointer-events-none inset-0" |
||||
data-testid="nc-calendar-week-record-container" |
||||
> |
||||
<div |
||||
v-for="(record, id) in calendarData" |
||||
:key="id" |
||||
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`" |
||||
:data-unique-id="record.rowMeta.id" |
||||
:style="{ |
||||
...record.rowMeta.style, |
||||
boxShadow: |
||||
record.rowMeta.id === dragElement?.getAttribute('data-unique-id') |
||||
? '0px 8px 8px -4px rgba(0, 0, 0, 0.04), 0px 20px 24px -4px rgba(0, 0, 0, 0.10)' |
||||
: 'none', |
||||
}" |
||||
class="absolute group draggable-record pointer-events-auto nc-calendar-week-record-card" |
||||
@mousedown="dragStart($event, record)" |
||||
@mouseleave="hoverRecord = null" |
||||
@mouseover="hoverRecord = record.rowMeta.id" |
||||
> |
||||
<LazySmartsheetRow :row="record"> |
||||
<LazySmartsheetCalendarRecordCard |
||||
:hover="hoverRecord === record.rowMeta.id" |
||||
:position="record.rowMeta.position" |
||||
:record="record" |
||||
:selected=" |
||||
dragRecord |
||||
? dragRecord.rowMeta.id === record.rowMeta.id |
||||
: resizeRecord |
||||
? resizeRecord.rowMeta.id === record.rowMeta.id |
||||
: false |
||||
" |
||||
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')" |
||||
color="blue" |
||||
@dblclick="emits('expand-record', record)" |
||||
@resize-start="onResizeStart" |
||||
> |
||||
<template v-if="!isRowEmpty(record, displayField)"> |
||||
<div |
||||
:class="{ |
||||
'mt-2': displayField.uidt === UITypes.SingleLineText, |
||||
'mt-1': displayField.uidt === UITypes.MultiSelect || displayField.uidt === UITypes.SingleSelect, |
||||
}" |
||||
> |
||||
<LazySmartsheetVirtualCell |
||||
v-if="isVirtualCol(displayField)" |
||||
v-model="record.row[displayField.title]" |
||||
:column="displayField" |
||||
:row="record" |
||||
/> |
||||
|
||||
<LazySmartsheetCell |
||||
v-else |
||||
v-model="record.row[displayField.title]" |
||||
:column="displayField" |
||||
:edit-enabled="false" |
||||
:read-only="true" |
||||
/> |
||||
</div> |
||||
</template> |
||||
</LazySmartsheetCalendarRecordCard> |
||||
</LazySmartsheetRow> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.prevent-select { |
||||
-webkit-user-select: none; |
||||
-ms-user-select: none; |
||||
user-select: none; |
||||
} |
||||
</style> |
@ -0,0 +1,783 @@
|
||||
<script lang="ts" setup> |
||||
import dayjs from 'dayjs' |
||||
import { UITypes, isVirtualCol } from 'nocodb-sdk' |
||||
import type { Row } from '~/lib' |
||||
import { computed, ref } from '#imports' |
||||
import { generateRandomNumber, isRowEmpty } from '~/utils' |
||||
|
||||
const emits = defineEmits(['expandRecord']) |
||||
|
||||
const { |
||||
selectedDateRange, |
||||
formattedData, |
||||
formattedSideBarData, |
||||
calendarRange, |
||||
displayField, |
||||
selectedTime, |
||||
selectedDate, |
||||
updateRowProperty, |
||||
sideBarFilterOption, |
||||
showSideMenu, |
||||
} = useCalendarViewStoreOrThrow() |
||||
|
||||
const container = ref<null | HTMLElement>(null) |
||||
|
||||
const scrollContainer = ref<null | HTMLElement>(null) |
||||
|
||||
const { width: containerWidth } = useElementSize(container) |
||||
|
||||
const { isUIAllowed } = useRoles() |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
// Since it is a datetime Week view, we need to create a 2D array of dayjs objects to represent the hours in a day for each day in the week |
||||
const datesHours = computed(() => { |
||||
const datesHours: Array<Array<dayjs.Dayjs>> = [] |
||||
let startOfWeek = dayjs(selectedDateRange.value.start) ?? dayjs().startOf('week') |
||||
const endOfWeek = dayjs(selectedDateRange.value.end) ?? dayjs().endOf('week') |
||||
|
||||
while (startOfWeek.isSameOrBefore(endOfWeek)) { |
||||
const hours: Array<dayjs.Dayjs> = [] |
||||
for (let i = 0; i < 24; i++) { |
||||
hours.push( |
||||
dayjs() |
||||
.hour(i) |
||||
.minute(0) |
||||
.second(0) |
||||
.millisecond(0) |
||||
.year(startOfWeek.year()) |
||||
.month(startOfWeek.month()) |
||||
.date(startOfWeek.date()), |
||||
) |
||||
} |
||||
datesHours.push(hours) |
||||
startOfWeek = startOfWeek.add(1, 'day') |
||||
} |
||||
return datesHours |
||||
}) |
||||
|
||||
const recordsAcrossAllRange = computed<{ |
||||
records: Array<Row> |
||||
count: { |
||||
[key: string]: { |
||||
[key: string]: { |
||||
id: Array<string> |
||||
overflow: boolean |
||||
overflowCount: number |
||||
} |
||||
} |
||||
} |
||||
}>(() => { |
||||
if (!formattedData.value || !calendarRange.value || !container.value) return { records: [], count: {} } |
||||
|
||||
const { scrollHeight } = scrollContainer.value |
||||
|
||||
const perWidth = containerWidth.value / 7 |
||||
const perHeight = scrollHeight / 24 |
||||
|
||||
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 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 |
||||
} |
||||
} |
||||
} = {} |
||||
|
||||
let recordsToDisplay: Array<Row> = [] |
||||
|
||||
calendarRange.value.forEach((range) => { |
||||
const fromCol = range.fk_from_col |
||||
const toCol = range.fk_to_col |
||||
|
||||
// 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 |
||||
|
||||
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 startDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null |
||||
if (!startDate) return |
||||
|
||||
// Hour Key currently is set as start of the hour |
||||
// TODO: Need to work on the granularity of the hour |
||||
const dateKey = startDate?.format('YYYY-MM-DD') |
||||
const hourKey = startDate?.startOf('hour').format('HH:mm') |
||||
|
||||
const id = record.rowMeta.id ?? generateRandomNumber() |
||||
|
||||
let style: Partial<CSSStyleDeclaration> = {} |
||||
|
||||
// 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 |
||||
} |
||||
|
||||
// We calculate the index of the hour in the day and set the top and height of the record |
||||
const hourIndex = Math.min( |
||||
Math.max( |
||||
datesHours.value[dayIndex].findIndex((h) => h.startOf('hour').format('HH:mm') === hourKey), |
||||
0, |
||||
), |
||||
23, |
||||
) |
||||
|
||||
style = { |
||||
...style, |
||||
top: `${hourIndex * perHeight - hourIndex - hourIndex * 0.15}px`, |
||||
height: `${perHeight - 2}px`, |
||||
} |
||||
|
||||
recordsToDisplay.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
id, |
||||
position: 'rounded', |
||||
style, |
||||
range, |
||||
dayIndex, |
||||
}, |
||||
}) |
||||
} 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() |
||||
|
||||
// We loop through the start date to the end date and create a record for each day as it spans bottom to top |
||||
while (currentStartDate.isSameOrBefore(endDate!, 'day')) { |
||||
const currentEndDate = currentStartDate.clone().endOf('day') |
||||
const recordStart: dayjs.Dayjs = currentEndDate.isSame(startDate, 'day') ? startDate : currentStartDate |
||||
const recordEnd = currentEndDate.isSame(endDate, 'day') ? endDate : currentEndDate |
||||
|
||||
const 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 |
||||
} |
||||
|
||||
// We calculate the index of the start and end hour in the day |
||||
const startHourIndex = Math.max( |
||||
(datesHours.value[dayIndex] ?? []).findIndex((h) => h.format('HH:mm') === recordStart.format('HH:mm')), |
||||
0, |
||||
) |
||||
const endHourIndex = Math.max( |
||||
(datesHours.value[dayIndex] ?? []).findIndex((h) => h.format('HH:mm') === recordEnd?.startOf('hour').format('HH:mm')), |
||||
0, |
||||
) |
||||
|
||||
let position: 'topRounded' | 'bottomRounded' | 'rounded' | 'none' = 'rounded' |
||||
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(currentStartDate, 'day') |
||||
const isBeforeSelectedDay = (date: dayjs.Dayjs) => date.isBefore(currentStartDate, 'day') |
||||
const isAfterSelectedDay = (date: dayjs.Dayjs) => date.isAfter(currentStartDate, 'day') |
||||
|
||||
if (isSelectedDay(startDate) && isSelectedDay(endDate)) { |
||||
position = 'rounded' |
||||
} else if (isBeforeSelectedDay(startDate) && isAfterSelectedDay(endDate)) { |
||||
position = 'none' |
||||
} else if (isSelectedDay(startDate) && isAfterSelectedDay(endDate)) { |
||||
position = 'topRounded' |
||||
} else if (isBeforeSelectedDay(startDate) && isSelectedDay(endDate)) { |
||||
position = 'bottomRounded' |
||||
} else { |
||||
position = 'none' |
||||
} |
||||
|
||||
let _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 |
||||
|
||||
const height = (endHourIndex - startHourIndex + 1) * perHeight - spanHours - 5 |
||||
|
||||
style = { |
||||
...style, |
||||
top: `${top}px`, |
||||
height: `${height}px`, |
||||
} |
||||
|
||||
recordsToDisplay.push({ |
||||
...record, |
||||
rowMeta: { |
||||
...record.rowMeta, |
||||
id, |
||||
style, |
||||
range, |
||||
position, |
||||
dayIndex, |
||||
}, |
||||
}) |
||||
// We set the current start date to the next day |
||||
currentStartDate = currentStartDate.add(1, 'day').hour(0).minute(0) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
// 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!)) |
||||
} |
||||
} |
||||
const spacing = 1 |
||||
const widthPerRecord = (100 - spacing * (maxOverlaps - 1)) / maxOverlaps / 7 |
||||
const leftPerRecord = widthPerRecord * overlapIndex |
||||
|
||||
record.rowMeta.style = { |
||||
...record.rowMeta.style, |
||||
left: `calc(${dayIndex * perWidth}px + ${leftPerRecord}%)`, |
||||
width: `calc(${widthPerRecord}%)`, |
||||
} |
||||
return record |
||||
}) |
||||
}) |
||||
|
||||
return { |
||||
records: recordsToDisplay, |
||||
count: overlaps, |
||||
} |
||||
}) |
||||
|
||||
const dragElement = ref<HTMLElement | null>(null) |
||||
|
||||
const resizeInProgress = ref(false) |
||||
|
||||
const dragTimeout = ref<ReturnType<typeof setTimeout>>() |
||||
|
||||
const hoverRecord = ref<string | null>() |
||||
|
||||
const resizeDirection = ref<'right' | 'left' | null>() |
||||
|
||||
const resizeRecord = ref<Row | null>() |
||||
|
||||
const isDragging = ref(false) |
||||
|
||||
const dragRecord = ref<Row>() |
||||
|
||||
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => { |
||||
updateRowProperty(row, updateProperty, isDelete) |
||||
}, 500) |
||||
|
||||
const onResize = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit') || !container.value || !resizeRecord.value || !scrollContainer.value) return |
||||
|
||||
const { width, left, top, bottom } = container.value.getBoundingClientRect() |
||||
|
||||
const { scrollHeight } = container.value |
||||
|
||||
// If the mouse is near the bottom of the container, we scroll down |
||||
// If the mouse is near the top of the container, we scroll up |
||||
if (event.clientY > bottom - 20) { |
||||
container.value.scrollTop += 10 |
||||
} else if (event.clientY < top + 20) { |
||||
container.value.scrollTop -= 10 |
||||
} |
||||
|
||||
// We calculate the percentage of the mouse position in the container |
||||
// percentX is used for the day and percentY is used for the hour |
||||
const percentX = (event.clientX - left - window.scrollX) / width |
||||
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight |
||||
|
||||
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col |
||||
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col |
||||
|
||||
if (!fromCol || !toCol) return |
||||
|
||||
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!]) |
||||
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!]) |
||||
|
||||
const day = Math.floor(percentX * 7) |
||||
const hour = Math.floor(percentY * 23) |
||||
|
||||
let updateProperty: string[] = [] |
||||
let newRow: Row = resizeRecord.value |
||||
|
||||
if (resizeDirection.value === 'right') { |
||||
let newEndDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour') |
||||
updateProperty = [toCol.title!] |
||||
|
||||
// If the new end date is before the start date, we set the new end date to the start date |
||||
if (dayjs(newEndDate).isBefore(ogStartDate, 'day')) { |
||||
newEndDate = ogStartDate.clone() |
||||
} |
||||
|
||||
if (!newEndDate.isValid()) return |
||||
|
||||
newRow = { |
||||
...resizeRecord.value, |
||||
row: { |
||||
...resizeRecord.value.row, |
||||
[toCol.title!]: newEndDate.format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} else if (resizeDirection.value === 'left') { |
||||
let newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour') |
||||
updateProperty = [fromCol.title!] |
||||
|
||||
// If the new start date is after the end date, we set the new start date to the end date |
||||
if (dayjs(newStartDate).isAfter(ogEndDate)) { |
||||
newStartDate = dayjs(dayjs(ogEndDate)).clone() |
||||
} |
||||
if (!newStartDate) return |
||||
|
||||
newRow = { |
||||
...resizeRecord.value, |
||||
row: { |
||||
...resizeRecord.value.row, |
||||
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
} |
||||
|
||||
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) |
||||
|
||||
formattedData.value = formattedData.value.map((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk === newPk ? newRow : r |
||||
}) |
||||
useDebouncedRowUpdate(newRow, updateProperty, false) |
||||
} |
||||
|
||||
const onResizeEnd = () => { |
||||
resizeInProgress.value = false |
||||
resizeDirection.value = null |
||||
resizeRecord.value = null |
||||
document.removeEventListener('mousemove', onResize) |
||||
document.removeEventListener('mouseup', onResizeEnd) |
||||
} |
||||
const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: Row) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
resizeInProgress.value = true |
||||
resizeDirection.value = direction |
||||
resizeRecord.value = record |
||||
document.addEventListener('mousemove', onResize) |
||||
document.addEventListener('mouseup', onResizeEnd) |
||||
} |
||||
|
||||
// We calculate the new row based on the mouse position and update the record |
||||
// We also update the sidebar data if the dropped from the sidebar |
||||
const calculateNewRow = ( |
||||
event: MouseEvent, |
||||
updateSideBar?: boolean, |
||||
): { |
||||
newRow: Row | null |
||||
updatedProperty: string[] |
||||
} => { |
||||
if (!isUIAllowed('dataEdit') || !container.value || !dragRecord.value) return { newRow: null, updatedProperty: [] } |
||||
const { width, left, top } = container.value.getBoundingClientRect() |
||||
|
||||
const { scrollHeight } = container.value |
||||
|
||||
const percentX = (event.clientX - left - window.scrollX) / width |
||||
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight |
||||
|
||||
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col |
||||
const toCol = dragRecord.value.rowMeta.range?.fk_to_col |
||||
|
||||
if (!fromCol) return { newRow: null, updatedProperty: [] } |
||||
|
||||
const day = Math.max(0, Math.min(6, Math.floor(percentX * 7))) |
||||
const hour = Math.max(0, Math.min(23, Math.floor(percentY * 24))) |
||||
|
||||
const newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour') |
||||
if (!newStartDate) return { newRow: null, updatedProperty: [] } |
||||
|
||||
let endDate |
||||
const updatedProperty = [fromCol.title!] |
||||
|
||||
const newRow = { |
||||
...dragRecord.value, |
||||
row: { |
||||
...dragRecord.value.row, |
||||
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'), |
||||
}, |
||||
} |
||||
|
||||
if (toCol) { |
||||
const fromDate = dragRecord.value.row[fromCol.title!] ? dayjs(dragRecord.value.row[fromCol.title!]) : null |
||||
const toDate = dragRecord.value.row[toCol.title!] ? dayjs(dragRecord.value.row[toCol.title!]) : null |
||||
|
||||
if (fromDate && toDate) { |
||||
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day') |
||||
} else if (fromDate && !toDate) { |
||||
endDate = dayjs(newStartDate).endOf('day') |
||||
} else if (!fromDate && toDate) { |
||||
endDate = dayjs(newStartDate).endOf('day') |
||||
} else { |
||||
endDate = newStartDate.clone() |
||||
} |
||||
|
||||
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ') |
||||
updatedProperty.push(toCol.title!) |
||||
} |
||||
|
||||
if (!newRow) return { newRow: null, updatedProperty } |
||||
|
||||
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) |
||||
|
||||
if (updateSideBar) { |
||||
formattedData.value = [...formattedData.value, newRow] |
||||
formattedSideBarData.value = formattedSideBarData.value.filter((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk !== newPk |
||||
}) |
||||
} else { |
||||
formattedData.value = formattedData.value.map((r) => { |
||||
const pk = extractPkFromRow(r.row, meta.value!.columns!) |
||||
return pk === newPk ? newRow : r |
||||
}) |
||||
} |
||||
|
||||
return { newRow, updatedProperty } |
||||
} |
||||
|
||||
const onDrag = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit') || !scrollContainer.value || !dragRecord.value) return |
||||
|
||||
const containerRect = scrollContainer.value.getBoundingClientRect() |
||||
const scrollBottomThreshold = 20 |
||||
|
||||
if (event.clientY > containerRect.bottom - scrollBottomThreshold) { |
||||
scrollContainer.value.scrollTop += 10 |
||||
} else if (event.clientY < containerRect.top + scrollBottomThreshold) { |
||||
scrollContainer.value.scrollTop -= 10 |
||||
} |
||||
|
||||
calculateNewRow(event) |
||||
} |
||||
|
||||
const stopDrag = (event: MouseEvent) => { |
||||
if (!isUIAllowed('dataEdit') || !isDragging.value || !container.value || !dragRecord.value) return |
||||
|
||||
event.preventDefault() |
||||
clearTimeout(dragTimeout.value!) |
||||
|
||||
const { newRow, updatedProperty } = calculateNewRow(event, false) |
||||
|
||||
// We set the visibility and opacity of the records back to normal |
||||
const allRecords = document.querySelectorAll('.draggable-record') |
||||
allRecords.forEach((el) => { |
||||
el.style.visibility = '' |
||||
el.style.opacity = '100%' |
||||
}) |
||||
|
||||
if (dragElement.value) { |
||||
dragElement.value.style.boxShadow = 'none' |
||||
dragElement.value = null |
||||
} |
||||
|
||||
if (newRow) { |
||||
updateRowProperty(newRow, updatedProperty, false) |
||||
} |
||||
|
||||
document.removeEventListener('mousemove', onDrag) |
||||
document.removeEventListener('mouseup', stopDrag) |
||||
} |
||||
|
||||
const dragStart = (event: MouseEvent, record: Row) => { |
||||
if (!isUIAllowed('dataEdit')) return |
||||
if (resizeInProgress.value) return |
||||
let target = event.target as HTMLElement |
||||
|
||||
isDragging.value = false |
||||
|
||||
dragTimeout.value = setTimeout(() => { |
||||
isDragging.value = true |
||||
while (!target.classList.contains('draggable-record')) { |
||||
target = target.parentElement as HTMLElement |
||||
} |
||||
|
||||
const allRecords = document.querySelectorAll('.draggable-record') |
||||
allRecords.forEach((el) => { |
||||
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) { |
||||
// el.style.visibility = 'hidden' |
||||
el.style.opacity = '30%' |
||||
} |
||||
}) |
||||
|
||||
dragRecord.value = record |
||||
|
||||
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) { |
||||
emits('expandRecord', record) |
||||
} |
||||
} |
||||
|
||||
document.addEventListener('mouseup', onMouseUp) |
||||
} |
||||
|
||||
const dropEvent = (event: DragEvent) => { |
||||
if (!isUIAllowed('dataEdit') || !container.value) return |
||||
event.preventDefault() |
||||
|
||||
const data = event.dataTransfer?.getData('text/plain') |
||||
if (data) { |
||||
const { |
||||
record, |
||||
}: { |
||||
record: Row |
||||
} = JSON.parse(data) |
||||
|
||||
dragRecord.value = record |
||||
|
||||
const { newRow, updatedProperty } = calculateNewRow(event, true) |
||||
|
||||
if (newRow) { |
||||
updateRowProperty(newRow, updatedProperty, false) |
||||
} |
||||
} |
||||
} |
||||
|
||||
const viewMore = (hour: dayjs.Dayjs) => { |
||||
sideBarFilterOption.value = 'selectedHours' |
||||
selectedTime.value = hour |
||||
showSideMenu.value = true |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
ref="scrollContainer" |
||||
class="h-[calc(100vh-9.9rem)] prevent-select relative flex w-full overflow-y-auto nc-scrollbar-md" |
||||
data-testid="nc-calendar-week-view" |
||||
@drop="dropEvent" |
||||
> |
||||
<div class="flex sticky h-7.1 z-1 top-0 pl-16 bg-gray-50 w-full"> |
||||
<div |
||||
v-for="date in datesHours" |
||||
:key="date[0].toISOString()" |
||||
:class="{ |
||||
'text-brand-500': date[0].isSame(dayjs(), 'date'), |
||||
}" |
||||
class="w-1/7 text-center text-sm text-gray-500 w-full py-1 border-gray-200 last:border-r-0 border-b-0 border-l-1 border-r-0 bg-gray-50" |
||||
> |
||||
{{ dayjs(date[0]).format('DD ddd') }} |
||||
</div> |
||||
</div> |
||||
<div class="absolute bg-white w-16 z-1"> |
||||
<div |
||||
v-for="(hour, index) in datesHours[0]" |
||||
:key="index" |
||||
class="h-20 first:mt-0 pt-7.1 text-center text-xs text-gray-500 py-1" |
||||
> |
||||
{{ hour.format('h A') }} |
||||
</div> |
||||
</div> |
||||
<div ref="container" class="absolute ml-16 flex w-[calc(100%-64px)]"> |
||||
<div v-for="(date, index) in datesHours" :key="index" class="h-full w-1/7" data-testid="nc-calendar-week-day"> |
||||
<div |
||||
v-for="(hour, hourIndex) in date" |
||||
:key="hourIndex" |
||||
:class="{ |
||||
'border-1 !border-brand-500 bg-gray-50': hour.isSame(selectedTime, 'hour'), |
||||
}" |
||||
class="text-center relative first:mt-7.1 h-20 text-sm text-gray-500 w-full hover:bg-gray-50 py-1 border-transparent border-1 border-x-gray-200 border-t-gray-200" |
||||
data-testid="nc-calendar-week-hour" |
||||
@click=" |
||||
() => { |
||||
selectedTime = hour |
||||
selectedDate = hour |
||||
} |
||||
" |
||||
> |
||||
<NcButton |
||||
v-if="recordsAcrossAllRange?.count?.[hour.format('YYYY-MM-DD')]?.[hour.format('HH:mm')]?.overflow" |
||||
class="!absolute bottom-1 text-center w-15 ml-auto inset-x-0 z-3 text-gray-500" |
||||
size="xxsmall" |
||||
type="secondary" |
||||
@click="viewMore(hour)" |
||||
> |
||||
<span class="text-xs"> |
||||
+ |
||||
{{ recordsAcrossAllRange?.count[hour.format('YYYY-MM-DD')][hour.format('HH:mm')]?.overflowCount }} |
||||
more |
||||
</span> |
||||
</NcButton> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
class="absolute pointer-events-none inset-0 overflow-hidden !mt-[29px]" |
||||
data-testid="nc-calendar-week-record-container" |
||||
> |
||||
<div |
||||
v-for="(record, rowIndex) in recordsAcrossAllRange.records" |
||||
:key="rowIndex" |
||||
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`" |
||||
:data-unique-id="record.rowMeta!.id" |
||||
:style="record.rowMeta!.style " |
||||
class="absolute draggable-record w-1/7 group cursor-pointer pointer-events-auto" |
||||
@mousedown="dragStart($event, record)" |
||||
@mouseleave="hoverRecord = null" |
||||
@mouseover="hoverRecord = record.rowMeta.id" |
||||
@dragover.prevent |
||||
> |
||||
<LazySmartsheetRow :row="record"> |
||||
<LazySmartsheetCalendarVRecordCard |
||||
:hover="hoverRecord === record.rowMeta.id" |
||||
:position="record.rowMeta!.position" |
||||
:record="record" |
||||
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')" |
||||
color="blue" |
||||
> |
||||
<template v-if="!isRowEmpty(record, displayField)"> |
||||
<div |
||||
:class="{ |
||||
'!mt-2': displayField!.uidt === UITypes.SingleLineText, |
||||
'!mt-1': displayField!.uidt === UITypes.MultiSelect || displayField!.uidt === UITypes.SingleSelect, |
||||
}" |
||||
> |
||||
<LazySmartsheetVirtualCell |
||||
v-if="isVirtualCol(displayField!)" |
||||
v-model="record.row[displayField!.title!]" |
||||
:column="displayField" |
||||
:row="record" |
||||
/> |
||||
|
||||
<LazySmartsheetCell |
||||
v-else |
||||
v-model="record.row[displayField!.title!]" |
||||
:column="displayField" |
||||
:edit-enabled="false" |
||||
:read-only="true" |
||||
/> |
||||
</div> |
||||
</template> |
||||
</LazySmartsheetCalendarVRecordCard> |
||||
</LazySmartsheetRow> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.prevent-select { |
||||
-webkit-user-select: none; |
||||
-ms-user-select: none; |
||||
user-select: none; |
||||
} |
||||
</style> |
@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup> |
||||
const { selectedDate, activeDates } = useCalendarViewStoreOrThrow() |
||||
|
||||
const months = computed(() => { |
||||
const months = [] |
||||
for (let i = 0; i < 12; i++) { |
||||
months.push(selectedDate.value.set('month', i).set('date', 1)) |
||||
} |
||||
return months |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
class="flex flex-wrap gap-6 pb-4 items-center justify-center overflow-auto nc-scrollbar-md" |
||||
data-testid="nc-calendar-year-view" |
||||
> |
||||
<NcDateWeekSelector |
||||
v-for="(_, index) in months" |
||||
:key="index" |
||||
v-model:active-dates="activeDates" |
||||
v-model:page-date="months[index]" |
||||
v-model:selected-date="selectedDate" |
||||
class="max-w-[350px]" |
||||
data-testid="nc-calendar-year-view-month-selector" |
||||
disable-pagination |
||||
/> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped></style> |
@ -0,0 +1,320 @@
|
||||
<script lang="ts" setup> |
||||
import dayjs from 'dayjs' |
||||
import { UITypes } from 'nocodb-sdk' |
||||
import { |
||||
ActiveViewInj, |
||||
IsCalendarInj, |
||||
IsFormInj, |
||||
IsGalleryInj, |
||||
IsGridInj, |
||||
IsKanbanInj, |
||||
MetaInj, |
||||
ReloadViewDataHookInj, |
||||
ReloadViewMetaHookInj, |
||||
type Row as RowType, |
||||
computed, |
||||
extractPkFromRow, |
||||
inject, |
||||
provide, |
||||
ref, |
||||
rowDefaultData, |
||||
} from '#imports' |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const view = inject(ActiveViewInj, ref()) |
||||
|
||||
const reloadViewMetaHook = inject(ReloadViewMetaHookInj) |
||||
|
||||
const reloadViewDataHook = inject(ReloadViewDataHookInj) |
||||
|
||||
const { isMobileMode } = useGlobal() |
||||
|
||||
provide(IsFormInj, ref(false)) |
||||
|
||||
provide(IsGalleryInj, ref(false)) |
||||
|
||||
provide(IsGridInj, ref(false)) |
||||
|
||||
provide(IsKanbanInj, ref(false)) |
||||
|
||||
provide(IsCalendarInj, ref(true)) |
||||
|
||||
const { |
||||
calDataType, |
||||
loadCalendarMeta, |
||||
loadCalendarData, |
||||
loadSidebarData, |
||||
isCalendarDataLoading, |
||||
selectedDate, |
||||
selectedMonth, |
||||
activeDates, |
||||
pageDate, |
||||
showSideMenu, |
||||
selectedDateRange, |
||||
activeCalendarView, |
||||
paginateCalendarView, |
||||
} = useCalendarViewStoreOrThrow() |
||||
|
||||
const calendarRangeDropdown = ref(false) |
||||
|
||||
const router = useRouter() |
||||
|
||||
const route = useRoute() |
||||
|
||||
const expandedFormOnRowIdDlg = computed({ |
||||
get() { |
||||
return !!route.query.rowId |
||||
}, |
||||
set(val) { |
||||
if (!val) |
||||
router.push({ |
||||
query: { |
||||
...route.query, |
||||
rowId: undefined, |
||||
}, |
||||
}) |
||||
}, |
||||
}) |
||||
|
||||
const expandedFormDlg = ref(false) |
||||
|
||||
const expandedFormRow = ref<RowType>() |
||||
|
||||
const expandedFormRowState = ref<Record<string, any>>() |
||||
|
||||
const expandRecord = (row: RowType, state?: Record<string, any>) => { |
||||
const rowId = extractPkFromRow(row.row, meta.value!.columns!) |
||||
|
||||
if (rowId) { |
||||
router.push({ |
||||
query: { |
||||
...route.query, |
||||
rowId, |
||||
}, |
||||
}) |
||||
} else { |
||||
expandedFormRow.value = row |
||||
expandedFormRowState.value = state |
||||
expandedFormDlg.value = true |
||||
} |
||||
} |
||||
|
||||
const newRecord = (row: RowType) => { |
||||
// TODO: The default values has to be filled based on the active calendar view |
||||
// and selected sidebar filter option |
||||
expandRecord({ |
||||
row: { |
||||
...rowDefaultData(meta.value?.columns), |
||||
...row.row, |
||||
}, |
||||
oldRow: {}, |
||||
rowMeta: { |
||||
new: true, |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
onMounted(async () => { |
||||
await loadCalendarMeta() |
||||
await loadCalendarData() |
||||
if (!activeCalendarView.value) { |
||||
activeCalendarView.value = 'month' |
||||
} |
||||
}) |
||||
|
||||
reloadViewMetaHook?.on(async () => { |
||||
await loadCalendarMeta() |
||||
}) |
||||
|
||||
reloadViewDataHook?.on(async () => { |
||||
await Promise.all([loadCalendarData(), loadSidebarData()]) |
||||
}) |
||||
|
||||
const goToToday = () => { |
||||
selectedDate.value = dayjs() |
||||
pageDate.value = dayjs() |
||||
selectedMonth.value = dayjs() |
||||
selectedDateRange.value = { |
||||
start: dayjs().startOf('week'), |
||||
end: dayjs().endOf('week'), |
||||
} |
||||
} |
||||
|
||||
const headerText = computed(() => { |
||||
switch (activeCalendarView.value) { |
||||
case 'day': |
||||
return dayjs(selectedDate.value).format('D MMMM YYYY') |
||||
case 'week': |
||||
if (selectedDateRange.value.start.isSame(selectedDateRange.value.end, 'month')) { |
||||
return `${selectedDateRange.value.start.format('D')} - ${selectedDateRange.value.end.format('D MMM YY')}` |
||||
} else if (selectedDateRange.value.start.isSame(selectedDateRange.value.end, 'year')) { |
||||
return `${selectedDateRange.value.start.format('D MMM')} - ${selectedDateRange.value.end.format('D MMM YY')}` |
||||
} else { |
||||
return `${selectedDateRange.value.start.format('D MMM YY')} - ${selectedDateRange.value.end.format('D MMM YY')}` |
||||
} |
||||
case 'month': |
||||
return dayjs(selectedMonth.value).format('MMMM YYYY') |
||||
case 'year': |
||||
return dayjs(selectedDate.value).format('YYYY') |
||||
} |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flex h-full flex-row" data-testid="nc-calendar-wrapper"> |
||||
<div class="flex flex-col w-full"> |
||||
<div class="flex justify-between p-3 items-center border-b-1 border-gray-200" data-testid="nc-calendar-topbar"> |
||||
<div class="flex justify-start gap-3 items-center"> |
||||
<NcTooltip> |
||||
<template #title> {{ $t('labels.previous') }}</template> |
||||
<NcButton |
||||
v-e="`['c:calendar:calendar-${activeCalendarView}-prev-btn']`" |
||||
data-testid="nc-calendar-prev-btn" |
||||
size="small" |
||||
type="secondary" |
||||
@click="paginateCalendarView('prev')" |
||||
> |
||||
<component :is="iconMap.doubleLeftArrow" class="h-4 w-4" /> |
||||
</NcButton> |
||||
</NcTooltip> |
||||
|
||||
<NcDropdown v-model:visible="calendarRangeDropdown" :auto-close="false" :trigger="['click']"> |
||||
<NcButton :class="{ '!w-24': activeCalendarView === 'year' }" class="w-45" full-width size="small" type="secondary"> |
||||
<div class="flex w-full px-3 py-1 w-full items-center justify-between"> |
||||
<span class="font-bold text-center text-brand-500" data-testid="nc-calendar-active-date">{{ headerText }}</span> |
||||
<component :is="iconMap.arrowDown" class="h-4 w-4 text-gray-700" /> |
||||
</div> |
||||
</NcButton> |
||||
|
||||
<template #overlay> |
||||
<div v-if="calendarRangeDropdown" class="min-w-[22.1rem]" @click.stop> |
||||
<NcDateWeekSelector |
||||
v-if="activeCalendarView === ('day' as const)" |
||||
v-model:active-dates="activeDates" |
||||
v-model:page-date="pageDate" |
||||
v-model:selected-date="selectedDate" |
||||
/> |
||||
<NcDateWeekSelector |
||||
v-else-if="activeCalendarView === ('week' as const)" |
||||
v-model:active-dates="activeDates" |
||||
v-model:page-date="pageDate" |
||||
v-model:selected-week="selectedDateRange" |
||||
is-week-picker |
||||
/> |
||||
<NcMonthYearSelector |
||||
v-else-if="activeCalendarView === ('month' as const)" |
||||
v-model:page-date="pageDate" |
||||
v-model:selected-date="selectedMonth" |
||||
/> |
||||
<NcMonthYearSelector |
||||
v-else-if="activeCalendarView === ('year' as const)" |
||||
v-model:page-date="pageDate" |
||||
v-model:selected-date="selectedDate" |
||||
is-year-picker |
||||
/> |
||||
</div> |
||||
</template> |
||||
</NcDropdown> |
||||
<NcTooltip> |
||||
<template #title> {{ $t('labels.next') }}</template> |
||||
<NcButton |
||||
v-e="`['c:calendar:calendar-${activeCalendarView}-next-btn']`" |
||||
data-testid="nc-calendar-next-btn" |
||||
size="small" |
||||
type="secondary" |
||||
@click="paginateCalendarView('next')" |
||||
> |
||||
<component :is="iconMap.doubleRightArrow" class="h-4 w-4" /> |
||||
</NcButton> |
||||
</NcTooltip> |
||||
<NcButton |
||||
v-if="!isMobileMode" |
||||
v-e="`['c:calendar:calendar-${activeCalendarView}-today-btn']`" |
||||
data-testid="nc-calendar-today-btn" |
||||
size="small" |
||||
type="secondary" |
||||
@click="goToToday" |
||||
> |
||||
{{ $t('activity.goToToday') }} |
||||
</NcButton> |
||||
<span class="opacity-0" data-testid="nc-active-calendar-view"> |
||||
{{ activeCalendarView }} |
||||
</span> |
||||
</div> |
||||
<NcTooltip> |
||||
<template #title> {{ $t('activity.toggleSidebar') }}</template> |
||||
<NcButton |
||||
v-if="!isMobileMode" |
||||
v-e="`['c:calendar:calendar-${activeCalendarView}-toggle-sidebar']`" |
||||
data-testid="nc-calendar-side-bar-btn" |
||||
size="small" |
||||
type="secondary" |
||||
@click="showSideMenu = !showSideMenu" |
||||
> |
||||
<component :is="iconMap.sidebar" class="h-4 w-4 text-gray-700 transition-all" /> |
||||
</NcButton> |
||||
</NcTooltip> |
||||
</div> |
||||
<LazySmartsheetCalendarYearView v-if="activeCalendarView === 'year'" /> |
||||
<template v-if="!isCalendarDataLoading"> |
||||
<LazySmartsheetCalendarMonthView |
||||
v-if="activeCalendarView === 'month'" |
||||
@expand-record="expandRecord" |
||||
@new-record="newRecord" |
||||
/> |
||||
<LazySmartsheetCalendarWeekViewDateField |
||||
v-else-if="activeCalendarView === 'week' && calDataType === UITypes.Date" |
||||
@expand-record="expandRecord" |
||||
@new-record="newRecord" |
||||
/> |
||||
<LazySmartsheetCalendarWeekViewDateTimeField |
||||
v-else-if="activeCalendarView === 'week' && calDataType === UITypes.DateTime" |
||||
@expand-record="expandRecord" |
||||
@new-record="newRecord" |
||||
/> |
||||
<LazySmartsheetCalendarDayViewDateField |
||||
v-else-if="activeCalendarView === 'day' && calDataType === UITypes.Date" |
||||
@expand-record="expandRecord" |
||||
@new-record="newRecord" |
||||
/> |
||||
<LazySmartsheetCalendarDayViewDateTimeField |
||||
v-else-if="activeCalendarView === 'day' && calDataType === UITypes.DateTime" |
||||
@expand-record="expandRecord" |
||||
@new-record="newRecord" |
||||
/> |
||||
</template> |
||||
<div v-if="isCalendarDataLoading && activeCalendarView !== 'year'" class="flex w-full items-center h-full justify-center"> |
||||
<GeneralLoader size="xlarge" /> |
||||
</div> |
||||
</div> |
||||
<LazySmartsheetCalendarSideMenu |
||||
v-if="!isMobileMode" |
||||
:visible="showSideMenu" |
||||
@expand-record="expandRecord" |
||||
@new-record="newRecord" |
||||
/> |
||||
</div> |
||||
|
||||
<Suspense> |
||||
<LazySmartsheetExpandedForm |
||||
v-if="expandedFormRow && expandedFormDlg" |
||||
v-model="expandedFormDlg" |
||||
:meta="meta" |
||||
:row="expandedFormRow" |
||||
:state="expandedFormRowState" |
||||
:view="view" |
||||
/> |
||||
</Suspense> |
||||
|
||||
<Suspense> |
||||
<LazySmartsheetExpandedForm |
||||
v-if="expandedFormOnRowIdDlg" |
||||
v-model="expandedFormOnRowIdDlg" |
||||
:meta="meta" |
||||
:row="{ row: {}, oldRow: {}, rowMeta: {} }" |
||||
:row-id="route.query.rowId" |
||||
:view="view" |
||||
/> |
||||
</Suspense> |
||||
</template> |
@ -0,0 +1,103 @@
|
||||
<script lang="ts" setup> |
||||
import { useCalendarViewStoreOrThrow } from '#imports' |
||||
|
||||
const props = defineProps<{ |
||||
tab?: boolean |
||||
}>() |
||||
|
||||
const { changeCalendarView, activeCalendarView } = useCalendarViewStoreOrThrow() |
||||
|
||||
const highlightStyle = ref({ left: '0px' }) |
||||
|
||||
const setActiveCalendarMode = (mode: 'day' | 'week' | 'month' | 'year', event: MouseEvent) => { |
||||
changeCalendarView(mode) |
||||
const tabElement = event.target as HTMLElement |
||||
highlightStyle.value.left = `${tabElement.offsetLeft}px` |
||||
} |
||||
|
||||
const updateHighlightPosition = () => { |
||||
nextTick(() => { |
||||
const activeTab = document.querySelector('.nc-calendar-mode-tab .tab.active') as HTMLElement |
||||
if (activeTab) { |
||||
highlightStyle.value.left = `${activeTab.offsetLeft}px` |
||||
} |
||||
}) |
||||
} |
||||
|
||||
onMounted(() => { |
||||
updateHighlightPosition() |
||||
}) |
||||
|
||||
watch(activeCalendarView, () => { |
||||
if (!props.tab) return |
||||
updateHighlightPosition() |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div |
||||
v-if="props.tab" |
||||
class="flex flex-row relative p-1 mx-3 mt-3 mb-3 bg-gray-100 rounded-lg gap-x-0.5 nc-calendar-mode-tab" |
||||
data-testid="nc-calendar-view-mode" |
||||
> |
||||
<div :style="highlightStyle" class="highlight"></div> |
||||
<div |
||||
v-for="mode in ['day', 'week', 'month', 'year']" |
||||
:key="mode" |
||||
v-e="`['c:calendar:change-calendar-range-${mode}']`" |
||||
:class="{ active: activeCalendarView === mode }" |
||||
:data-testid="`nc-calendar-view-mode-${mode}`" |
||||
class="tab" |
||||
@click="setActiveCalendarMode(mode, $event)" |
||||
> |
||||
<div class="tab-title nc-tab">{{ $t(`objects.${mode}`) }}</div> |
||||
</div> |
||||
</div> |
||||
<div v-else> |
||||
<NcDropdown :trigger="['click']"> |
||||
<NcButton size="small" type="secondary"> |
||||
{{ $t(`objects.${activeCalendarView}`) }} |
||||
<component :is="iconMap.arrowDown" /> |
||||
</NcButton> |
||||
<template #overlay> |
||||
<NcMenu> |
||||
<NcMenuItem |
||||
v-for="mode in ['day', 'week', 'month', 'year']" |
||||
:key="mode" |
||||
v-e="`['c:calendar:change-calendar-range-${mode}']`" |
||||
@click="changeCalendarView(mode)" |
||||
> |
||||
{{ $t(`objects.${mode}`) }} |
||||
</NcMenuItem> |
||||
</NcMenu> |
||||
</template> |
||||
</NcDropdown> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.highlight { |
||||
@apply absolute h-7.5 w-20 shadow bg-white rounded-lg transition-all duration-300; |
||||
z-index: 0; |
||||
} |
||||
|
||||
.tab { |
||||
@apply flex items-center h-7.5 w-20 z-10 justify-center px-2 py-1 rounded-lg gap-x-1.5 text-gray-500 hover:text-black cursor-pointer transition-all duration-300 select-none; |
||||
} |
||||
|
||||
.tab .tab-title { |
||||
@apply min-w-0 pointer-events-none; |
||||
word-break: 'keep-all'; |
||||
white-space: 'nowrap'; |
||||
display: 'inline'; |
||||
line-height: 0.95; |
||||
} |
||||
|
||||
.active { |
||||
@apply text-black bg-transparent; |
||||
} |
||||
|
||||
.nc-calendar-mode-tab { |
||||
@apply mr-120 relative; |
||||
} |
||||
</style> |
@ -0,0 +1,237 @@
|
||||
<script lang="ts" setup> |
||||
import { UITypes, isSystemColumn } from 'nocodb-sdk' |
||||
import type { SelectProps } from 'ant-design-vue' |
||||
import { type CalendarRangeType } from '~/lib' |
||||
|
||||
import { |
||||
ActiveViewInj, |
||||
IsLockedInj, |
||||
IsPublicInj, |
||||
MetaInj, |
||||
computed, |
||||
iconMap, |
||||
inject, |
||||
ref, |
||||
useViewColumnsOrThrow, |
||||
watch, |
||||
} from '#imports' |
||||
|
||||
const meta = inject(MetaInj, ref()) |
||||
|
||||
const { $api } = useNuxtApp() |
||||
|
||||
const activeView = inject(ActiveViewInj, ref()) |
||||
|
||||
const isLocked = inject(IsLockedInj, ref(false)) |
||||
|
||||
const IsPublic = inject(IsPublicInj, ref(false)) |
||||
|
||||
const { loadViewColumns } = useViewColumnsOrThrow() |
||||
|
||||
const { loadCalendarMeta, loadCalendarData, loadSidebarData } = useCalendarViewStoreOrThrow() |
||||
|
||||
const calendarRangeDropdown = ref(false) |
||||
|
||||
watch( |
||||
() => activeView.value?.id, |
||||
async (newVal, oldVal) => { |
||||
if (newVal !== oldVal && meta.value) { |
||||
await loadViewColumns() |
||||
} |
||||
}, |
||||
{ immediate: true }, |
||||
) |
||||
|
||||
const calendarRange = computed<CalendarRangeType[]>(() => { |
||||
const tempCalendarRange: CalendarRangeType[] = [] |
||||
|
||||
if (!activeView.value || !activeView.value.view) return tempCalendarRange |
||||
activeView.value.view.calendar_range?.forEach((range) => { |
||||
tempCalendarRange.push({ |
||||
fk_from_column_id: range.fk_from_column_id, |
||||
fk_to_column_id: range.fk_to_column_id, |
||||
}) |
||||
}) |
||||
return tempCalendarRange |
||||
}) |
||||
|
||||
// We keep the calendar range here and update it when the user selects a new range |
||||
const _calendar_ranges = ref<CalendarRangeType[]>(calendarRange.value) |
||||
|
||||
const saveCalendarRanges = async () => { |
||||
if (activeView.value) { |
||||
try { |
||||
const calRanges = _calendar_ranges.value |
||||
.filter((range) => range.fk_from_column_id) |
||||
.map((range) => ({ |
||||
fk_from_column_id: range.fk_from_column_id, |
||||
fk_to_column_id: range.fk_to_column_id, |
||||
})) |
||||
await $api.dbView.calendarUpdate(activeView.value?.id as string, { |
||||
calendar_range: calRanges as CalendarRangeType[], |
||||
}) |
||||
await loadCalendarMeta() |
||||
await Promise.all([loadCalendarData(), loadSidebarData()]) |
||||
} catch (e) { |
||||
console.log(e) |
||||
message.error('There was an error while updating view!') |
||||
} |
||||
} else { |
||||
message.error('Please select a view first') |
||||
} |
||||
} |
||||
|
||||
const dateFieldOptions = computed<SelectProps['options']>(() => { |
||||
return ( |
||||
meta.value?.columns |
||||
?.filter((c) => c.uidt === UITypes.Date || (c.uidt === UITypes.DateTime && !isSystemColumn(c))) |
||||
.map((c) => ({ |
||||
label: c.title, |
||||
value: c.id, |
||||
uidt: c.uidt, |
||||
})) ?? [] |
||||
) |
||||
}) |
||||
|
||||
// To add new calendar range |
||||
const addCalendarRange = async () => { |
||||
_calendar_ranges.value.push({ |
||||
fk_from_column_id: dateFieldOptions.value![0].value as string, |
||||
fk_to_column_id: null, |
||||
}) |
||||
await saveCalendarRanges() |
||||
} |
||||
|
||||
const removeRange = async (id: number) => { |
||||
_calendar_ranges.value = _calendar_ranges.value.filter((_, i) => i !== id) |
||||
await saveCalendarRanges() |
||||
} |
||||
|
||||
const saveCalendarRange = async (range: CalendarRangeType, value?) => { |
||||
range.fk_to_column_id = value |
||||
await saveCalendarRanges() |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NcDropdown v-if="!IsPublic" v-model:visible="calendarRangeDropdown" :trigger="['click']" class="!xs:hidden"> |
||||
<div class="nc-calendar-btn"> |
||||
<a-button |
||||
v-e="['c:calendar:change-calendar-range']" |
||||
:disabled="isLocked" |
||||
class="nc-toolbar-btn" |
||||
data-testid="nc-calendar-range-btn" |
||||
> |
||||
<div class="flex items-center gap-2"> |
||||
<component :is="iconMap.calendar" class="h-4 w-4" /> |
||||
<span class="text-capitalize !text-sm font-medium"> |
||||
{{ $t('activity.viewSettings') }} |
||||
</span> |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
<template #overlay> |
||||
<div v-if="calendarRangeDropdown" class="w-full p-6 w-[22rem]" data-testid="nc-calendar-range-menu" @click.stop> |
||||
<div |
||||
v-for="(range, id) in _calendar_ranges" |
||||
:key="id" |
||||
class="flex w-full gap-2 mb-2 items-center" |
||||
data-testid="nc-calendar-range-option" |
||||
> |
||||
<span> |
||||
{{ $t('labels.organiseBy') }} |
||||
</span> |
||||
<NcSelect |
||||
v-model:value="range.fk_from_column_id" |
||||
:placeholder="$t('placeholder.notSelected')" |
||||
data-testid="nc-calendar-range-from-field-select" |
||||
@change="saveCalendarRanges" |
||||
> |
||||
<a-select-option |
||||
v-for="(option, opId) in [...dateFieldOptions].filter((r) => { |
||||
if (id === 0) return true |
||||
const firstRange = dateFieldOptions.find((f) => f.value === calendarRange[0].fk_from_column_id) |
||||
return firstRange?.uidt === r.uidt |
||||
})" |
||||
:key="opId" |
||||
:value="option.value" |
||||
> |
||||
<div class="flex items-center"> |
||||
<SmartsheetHeaderIcon :column="option" /> |
||||
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only> |
||||
<template #title>{{ option.label }}</template> |
||||
{{ option.label }} |
||||
</NcTooltip> |
||||
</div> |
||||
</a-select-option> |
||||
</NcSelect> |
||||
|
||||
<div |
||||
v-if="range.fk_to_column_id === null && isEeUI && false" |
||||
class="flex cursor-pointer flex text-gray-800 items-center gap-1" |
||||
data-testid="nc-calendar-range-add-end-date" |
||||
@click="saveCalendarRange(range, undefined)" |
||||
> |
||||
<component :is="iconMap.plus" class="h-4 w-4" /> |
||||
{{ $t('activity.addEndDate') }} |
||||
</div> |
||||
<template v-else-if="isEeUI && false"> |
||||
<span> |
||||
{{ $t('activity.withEndDate') }} |
||||
</span> |
||||
<div class="flex"> |
||||
<NcSelect |
||||
v-model:value="range.fk_to_column_id" |
||||
:disabled="!range.fk_from_column_id" |
||||
:placeholder="$t('placeholder.notSelected')" |
||||
class="!rounded-r-none nc-to-select" |
||||
data-testid="nc-calendar-range-to-field-select" |
||||
@change="saveCalendarRanges" |
||||
> |
||||
<a-select-option |
||||
v-for="(option, opId) in [...dateFieldOptions].filter((f) => { |
||||
const firstRange = dateFieldOptions.find((f) => f.value === calendarRange[0].fk_from_column_id) |
||||
return firstRange?.uidt === f.uidt |
||||
})" |
||||
:key="opId" |
||||
:value="option.value" |
||||
> |
||||
<div class="flex items-center"> |
||||
<SmartsheetHeaderIcon :column="option" /> |
||||
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only> |
||||
<template #title>{{ option.label }}</template> |
||||
{{ option.label }} |
||||
</NcTooltip> |
||||
</div> |
||||
</a-select-option> |
||||
</NcSelect> |
||||
<NcButton class="!rounded-l-none !border-l-0" size="small" type="secondary" @click="saveCalendarRange(range, null)"> |
||||
<component :is="iconMap.delete" class="h-4 w-4" /> |
||||
</NcButton> |
||||
</div> |
||||
</template> |
||||
<NcButton v-if="id !== 0" size="small" type="secondary" @click="removeRange(id)"> |
||||
<component :is="iconMap.close" /> |
||||
</NcButton> |
||||
</div> |
||||
<NcButton |
||||
v-if="false" |
||||
class="mt-2" |
||||
data-testid="nc-calendar-range-add-btn" |
||||
size="small" |
||||
type="secondary" |
||||
@click="addCalendarRange" |
||||
> |
||||
<component :is="iconMap.plus" /> |
||||
Add another date field |
||||
</NcButton> |
||||
</div> |
||||
</template> |
||||
</NcDropdown> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.nc-to-select .ant-select-selector { |
||||
@apply !rounded-r-none; |
||||
} |
||||
</style> |
@ -0,0 +1,879 @@
|
||||
import type { ComputedRef, Ref } from 'vue' |
||||
import { |
||||
type Api, |
||||
type CalendarType, |
||||
type ColumnType, |
||||
type PaginatedType, |
||||
type TableType, |
||||
UITypes, |
||||
type ViewType, |
||||
} from 'nocodb-sdk' |
||||
import dayjs from 'dayjs' |
||||
import { extractPkFromRow, extractSdkResponseErrorMsg, rowPkData } from '~/utils' |
||||
import { IsPublicInj, type Row, ref, storeToRefs, useBase, useInjectionState, useUndoRedo } from '#imports' |
||||
|
||||
const formatData = (list: Record<string, any>[]) => |
||||
list.map( |
||||
(row) => |
||||
({ |
||||
row: { ...row }, |
||||
oldRow: { ...row }, |
||||
rowMeta: {}, |
||||
} as Row), |
||||
) |
||||
|
||||
const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState( |
||||
( |
||||
meta: Ref<((CalendarType & { id: string }) | TableType) | undefined>, |
||||
viewMeta: Ref<(ViewType | CalendarType | undefined) & { id: string }> | ComputedRef<(ViewType & { id: string }) | undefined>, |
||||
shared = false, |
||||
where?: ComputedRef<string | undefined>, |
||||
) => { |
||||
if (!meta) { |
||||
throw new Error('Table meta is not available') |
||||
} |
||||
|
||||
const pageDate = ref<dayjs.Dayjs>(dayjs()) |
||||
|
||||
const { isUIAllowed } = useRoles() |
||||
|
||||
const displayField = computed(() => meta.value?.columns?.find((c) => c.pv)) |
||||
|
||||
const activeCalendarView = ref<'month' | 'year' | 'day' | 'week'>() |
||||
|
||||
const searchQuery = reactive({ |
||||
value: '', |
||||
field: '', |
||||
}) |
||||
|
||||
const selectedDate = ref<dayjs.Dayjs>(dayjs()) |
||||
|
||||
const selectedTime = ref<dayjs.Dayjs>(dayjs()) |
||||
|
||||
const selectedMonth = ref<dayjs.Dayjs>(dayjs()) |
||||
|
||||
const isCalendarDataLoading = ref<boolean>(false) |
||||
|
||||
const showSideMenu = ref(true) |
||||
|
||||
const selectedDateRange = ref<{ |
||||
start: dayjs.Dayjs |
||||
end: dayjs.Dayjs |
||||
}>({ |
||||
start: dayjs(selectedDate.value).startOf('week'), // This will be the previous Monday
|
||||
end: dayjs(selectedDate.value).startOf('week').add(6, 'day'), // This will be the following Sunday
|
||||
}) |
||||
|
||||
const defaultPageSize = 25 |
||||
|
||||
const formattedData = ref<Row[]>([]) |
||||
|
||||
const formattedSideBarData = ref<Row[]>([]) |
||||
|
||||
const isSidebarLoading = ref<boolean>(false) |
||||
|
||||
const activeDates = ref<dayjs.Dayjs[]>([]) |
||||
|
||||
const sideBarFilterOption = ref<string>(activeCalendarView.value ?? 'allRecords') |
||||
|
||||
const { api } = useApi() |
||||
|
||||
const { base } = storeToRefs(useBase()) |
||||
|
||||
const { $api } = useNuxtApp() |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const { addUndo, clone, defineViewScope } = useUndoRedo() |
||||
|
||||
const isPublic = ref(shared) || inject(IsPublicInj, ref(false)) |
||||
|
||||
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow() |
||||
|
||||
const { sharedView, fetchSharedViewData, fetchSharedViewActiveDate } = useSharedView() |
||||
|
||||
const calendarMetaData = ref<CalendarType>({}) |
||||
|
||||
const paginationData = ref<PaginatedType>({ page: 1, pageSize: defaultPageSize }) |
||||
|
||||
const queryParams = computed(() => ({ |
||||
limit: paginationData.value.pageSize ?? defaultPageSize, |
||||
where: where?.value ?? '', |
||||
})) |
||||
|
||||
const calendarRange = ref< |
||||
Array<{ |
||||
fk_from_col: ColumnType | null |
||||
fk_to_col: ColumnType | null |
||||
}> |
||||
>([]) |
||||
|
||||
const calDataType = computed(() => { |
||||
if (!calendarRange.value || !calendarRange.value[0]) return null |
||||
return calendarRange.value[0]!.fk_from_col!.uidt |
||||
}) |
||||
|
||||
const sideBarFilter = computed(() => { |
||||
let combinedFilters: any = [] |
||||
|
||||
if (sideBarFilterOption.value === 'allRecords') { |
||||
combinedFilters = [] |
||||
} else if (sideBarFilterOption.value === 'withoutDates') { |
||||
calendarRange.value.forEach((range) => { |
||||
const fromCol = range.fk_from_col |
||||
const toCol = range.fk_to_col |
||||
if (!fromCol) return |
||||
const filters = [] |
||||
|
||||
if (fromCol) { |
||||
filters.push({ |
||||
fk_column_id: fromCol.id, |
||||
logical_op: 'or', |
||||
comparison_op: 'blank', |
||||
}) |
||||
} |
||||
|
||||
if (toCol) { |
||||
filters.push({ |
||||
fk_column_id: toCol.id, |
||||
logical_op: 'or', |
||||
comparison_op: 'blank', |
||||
}) |
||||
} |
||||
// Combine the filters for this range with the overall filter array
|
||||
combinedFilters = [...combinedFilters, ...filters] |
||||
}) |
||||
// Wrap the combined filters in a group
|
||||
combinedFilters = [ |
||||
{ |
||||
is_group: true, |
||||
logical_op: 'or', |
||||
children: combinedFilters, |
||||
}, |
||||
] |
||||
} else if ( |
||||
sideBarFilterOption.value === 'week' || |
||||
sideBarFilterOption.value === 'month' || |
||||
sideBarFilterOption.value === 'day' || |
||||
sideBarFilterOption.value === 'year' || |
||||
sideBarFilterOption.value === 'selectedDate' || |
||||
sideBarFilterOption.value === 'selectedHours' |
||||
) { |
||||
let prevDate: string | null | dayjs.Dayjs = null |
||||
let fromDate: string | null | dayjs.Dayjs = null |
||||
let toDate: string | null | dayjs.Dayjs = null |
||||
let nextDate: string | null | dayjs.Dayjs = null |
||||
|
||||
switch (sideBarFilterOption.value) { |
||||
case 'day': |
||||
fromDate = selectedDate.value.startOf('day') |
||||
toDate = selectedDate.value.endOf('day') |
||||
prevDate = selectedDate.value.subtract(1, 'day').endOf('day') |
||||
nextDate = selectedDate.value.add(1, 'day').startOf('day') |
||||
break |
||||
case 'week': |
||||
fromDate = selectedDateRange.value.start.startOf('day') |
||||
toDate = selectedDateRange.value.end.endOf('day') |
||||
prevDate = selectedDateRange.value.start.subtract(1, 'day').endOf('day') |
||||
nextDate = selectedDateRange.value.end.add(1, 'day').startOf('day') |
||||
break |
||||
case 'month': { |
||||
const startOfMonth = selectedMonth.value.startOf('month') |
||||
const endOfMonth = selectedMonth.value.endOf('month') |
||||
|
||||
const daysToDisplay = Math.max(endOfMonth.diff(startOfMonth, 'day') + 1, 35) |
||||
fromDate = startOfMonth.subtract((startOfMonth.day() + 7) % 7, 'day').add(1, 'day') |
||||
toDate = fromDate.add(daysToDisplay, 'day').endOf('day') |
||||
|
||||
prevDate = fromDate.subtract(1, 'day').endOf('day') |
||||
nextDate = toDate.add(1, 'day').startOf('day') |
||||
break |
||||
} |
||||
case 'year': |
||||
fromDate = selectedDate.value.startOf('year') |
||||
toDate = selectedDate.value.endOf('year') |
||||
prevDate = fromDate.subtract(1, 'day').endOf('day') |
||||
nextDate = toDate.add(1, 'day').startOf('day') |
||||
break |
||||
case 'selectedDate': |
||||
fromDate = selectedDate.value.startOf('day') |
||||
toDate = selectedDate.value.endOf('day') |
||||
prevDate = selectedDate.value.subtract(1, 'day').endOf('day') |
||||
nextDate = selectedDate.value.add(1, 'day').startOf('day') |
||||
break |
||||
case 'selectedHours': |
||||
fromDate = (selectedTime.value ?? dayjs()).startOf('hour') |
||||
toDate = (selectedTime.value ?? dayjs()).endOf('hour') |
||||
prevDate = fromDate?.subtract(1, 'hour').endOf('hour') |
||||
nextDate = toDate?.add(1, 'hour').startOf('hour') |
||||
break |
||||
} |
||||
|
||||
fromDate = fromDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
toDate = toDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
prevDate = prevDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
nextDate = nextDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
|
||||
calendarRange.value.forEach((range) => { |
||||
const fromCol = range.fk_from_col |
||||
const toCol = range.fk_to_col |
||||
let rangeFilter: any = [] |
||||
|
||||
if (fromCol && toCol) { |
||||
rangeFilter = [ |
||||
{ |
||||
is_group: true, |
||||
logical_op: 'and', |
||||
children: [ |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'lt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: nextDate, |
||||
}, |
||||
{ |
||||
fk_column_id: toCol.id, |
||||
comparison_op: 'gt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: prevDate, |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'eq', |
||||
logical_op: 'or', |
||||
comparison_sub_op: 'exactDate', |
||||
value: fromDate, |
||||
}, |
||||
] |
||||
} else if (fromCol) { |
||||
rangeFilter = [ |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'lt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: nextDate, |
||||
}, |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'gt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: prevDate, |
||||
}, |
||||
] |
||||
} |
||||
if (rangeFilter.length > 0) { |
||||
combinedFilters.push({ |
||||
is_group: true, |
||||
logical_op: 'or', |
||||
children: rangeFilter, |
||||
}) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
if (displayField.value && searchQuery.value) { |
||||
if (combinedFilters.length > 0) { |
||||
combinedFilters = [ |
||||
{ |
||||
is_group: true, |
||||
logical_op: 'and', |
||||
children: [ |
||||
...combinedFilters, |
||||
{ |
||||
fk_column_id: displayField.value.id, |
||||
comparison_op: 'like', |
||||
value: searchQuery.value, |
||||
}, |
||||
], |
||||
}, |
||||
] |
||||
} else { |
||||
combinedFilters.push({ |
||||
fk_column_id: displayField.value.id, |
||||
comparison_op: 'like', |
||||
value: searchQuery.value, |
||||
}) |
||||
} |
||||
} |
||||
|
||||
return combinedFilters |
||||
}) |
||||
|
||||
async function loadMoreSidebarData(params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) { |
||||
if ((!base?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return |
||||
if (isSidebarLoading.value) return |
||||
try { |
||||
const response = !isPublic.value |
||||
? await api.dbViewRow.list('noco', base.value.id!, meta.value!.id!, viewMeta.value!.id, { |
||||
...params, |
||||
offset: params.offset, |
||||
...{}, |
||||
...{}, |
||||
...(isUIAllowed('filterSync') |
||||
? { filterArrJson: JSON.stringify([...sideBarFilter.value]) } |
||||
: { filterArrJson: JSON.stringify([nestedFilters.value, ...sideBarFilter.value]) }), |
||||
}) |
||||
: await fetchSharedViewData({ |
||||
...params, |
||||
sortsArr: sorts.value, |
||||
filtersArr: sideBarFilter.value, |
||||
offset: params.offset, |
||||
where: where?.value ?? '', |
||||
}) |
||||
formattedSideBarData.value = [...formattedSideBarData.value, ...formatData(response!.list)] |
||||
} catch (e) { |
||||
console.log(e) |
||||
} |
||||
} |
||||
|
||||
const filterJSON = computed(() => { |
||||
const combinedFilters: any = { |
||||
is_group: true, |
||||
logical_op: 'and', |
||||
children: [], |
||||
} |
||||
|
||||
let prevDate: string | null | dayjs.Dayjs = null |
||||
let fromDate: dayjs.Dayjs | null | string = null |
||||
let toDate: dayjs.Dayjs | null | string = null |
||||
let nextDate: string | null | dayjs.Dayjs = null |
||||
|
||||
switch (activeCalendarView.value) { |
||||
case 'week': |
||||
fromDate = selectedDateRange.value.start.startOf('day') |
||||
toDate = selectedDateRange.value.end.endOf('day') |
||||
prevDate = selectedDateRange.value.start.subtract(1, 'day').endOf('day') |
||||
nextDate = selectedDateRange.value.end.add(1, 'day').startOf('day') |
||||
break |
||||
case 'month': { |
||||
const startOfMonth = selectedMonth.value.startOf('month') |
||||
const endOfMonth = selectedMonth.value.endOf('month') |
||||
|
||||
const daysToDisplay = Math.max(endOfMonth.diff(startOfMonth, 'day') + 1, 35) |
||||
fromDate = startOfMonth.subtract((startOfMonth.day() + 7) % 7, 'day') |
||||
toDate = fromDate.add(daysToDisplay, 'day') |
||||
prevDate = fromDate.subtract(1, 'day').endOf('day') |
||||
nextDate = toDate.add(1, 'day').startOf('day') |
||||
break |
||||
} |
||||
case 'year': |
||||
fromDate = selectedDate.value.startOf('year') |
||||
toDate = selectedDate.value.endOf('year') |
||||
prevDate = fromDate.subtract(1, 'day').endOf('day') |
||||
nextDate = toDate.add(1, 'day').startOf('day') |
||||
break |
||||
case 'day': |
||||
fromDate = selectedDate.value.startOf('day') |
||||
toDate = selectedDate.value.endOf('day') |
||||
prevDate = selectedDate.value.subtract(1, 'day').endOf('day') |
||||
nextDate = selectedDate.value.add(1, 'day').startOf('day') |
||||
break |
||||
} |
||||
|
||||
fromDate = fromDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
toDate = toDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
prevDate = prevDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
nextDate = nextDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
|
||||
calendarRange.value.forEach((range) => { |
||||
const fromCol = range.fk_from_col |
||||
const toCol = range.fk_to_col |
||||
let rangeFilter: any = [] |
||||
|
||||
if (fromCol && toCol) { |
||||
rangeFilter = [ |
||||
{ |
||||
is_group: true, |
||||
logical_op: 'and', |
||||
children: [ |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'lt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: nextDate, |
||||
}, |
||||
{ |
||||
fk_column_id: toCol.id, |
||||
comparison_op: 'gt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: prevDate, |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'eq', |
||||
logical_op: 'or', |
||||
comparison_sub_op: 'exactDate', |
||||
value: fromDate, |
||||
}, |
||||
] |
||||
} else if (fromCol) { |
||||
rangeFilter = [ |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'lt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: nextDate, |
||||
}, |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'gt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: prevDate, |
||||
}, |
||||
] |
||||
} |
||||
if (rangeFilter.length > 0) { |
||||
combinedFilters.children.push(rangeFilter) |
||||
} |
||||
}) |
||||
return combinedFilters.children.length > 0 ? [combinedFilters] : [] |
||||
}) |
||||
|
||||
const fetchActiveDates = async () => { |
||||
if (!base?.value?.id || !meta.value?.id || !viewMeta.value?.id || !calendarRange.value) return |
||||
let prevDate: dayjs.Dayjs | string | null = null |
||||
let nextDate: dayjs.Dayjs | string | null = null |
||||
let fromDate: dayjs.Dayjs | string | null = null |
||||
|
||||
if (activeCalendarView.value === 'week' || activeCalendarView.value === 'day' || activeCalendarView.value === 'month') { |
||||
const startOfMonth = pageDate.value.startOf('month') |
||||
const endOfMonth = pageDate.value.endOf('month') |
||||
|
||||
const daysToDisplay = Math.max(endOfMonth.diff(startOfMonth, 'day') + 1, 35) |
||||
fromDate = startOfMonth.subtract((startOfMonth.day() + 7) % 7, 'day') |
||||
const toDate = fromDate.add(daysToDisplay, 'day') |
||||
prevDate = fromDate.subtract(1, 'day').endOf('day') |
||||
nextDate = toDate.add(1, 'day').startOf('day') |
||||
} else if (activeCalendarView.value === 'year') { |
||||
fromDate = selectedDate.value.startOf('year') |
||||
prevDate = selectedDate.value.startOf('year').subtract(1, 'day').endOf('day') |
||||
nextDate = selectedDate.value.endOf('year').add(1, 'day').startOf('day') |
||||
} |
||||
|
||||
prevDate = prevDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
nextDate = nextDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
fromDate = fromDate!.format('YYYY-MM-DD HH:mm:ssZ') |
||||
|
||||
const activeDateFilter: Array<any> = [] |
||||
|
||||
calendarRange.value.forEach((range) => { |
||||
const fromCol = range.fk_from_col |
||||
const toCol = range.fk_to_col |
||||
let rangeFilter: any = [] |
||||
|
||||
if (fromCol && toCol) { |
||||
rangeFilter = [ |
||||
{ |
||||
is_group: true, |
||||
logical_op: 'and', |
||||
children: [ |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'lt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: nextDate, |
||||
}, |
||||
{ |
||||
fk_column_id: toCol.id, |
||||
comparison_op: 'gt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: prevDate, |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'eq', |
||||
logical_op: 'or', |
||||
comparison_sub_op: 'exactDate', |
||||
value: fromDate, |
||||
}, |
||||
] |
||||
} else if (fromCol) { |
||||
rangeFilter = [ |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'lt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: nextDate, |
||||
}, |
||||
{ |
||||
fk_column_id: fromCol.id, |
||||
comparison_op: 'gt', |
||||
comparison_sub_op: 'exactDate', |
||||
value: prevDate, |
||||
}, |
||||
] |
||||
} |
||||
|
||||
activeDateFilter.push(rangeFilter) |
||||
}) |
||||
|
||||
if (!base?.value?.id || !meta.value?.id || !viewMeta.value?.id) return |
||||
const res = !isPublic.value |
||||
? await api.dbViewRow.calendarCount('noco', base.value.id!, meta.value!.id!, viewMeta.value.id, { |
||||
...queryParams.value, |
||||
...{}, |
||||
...{}, |
||||
...{ filterArrJson: JSON.stringify([...activeDateFilter]) }, |
||||
}) |
||||
: await fetchSharedViewActiveDate({ |
||||
sortsArr: sorts.value, |
||||
filtersArr: activeDateFilter, |
||||
}) |
||||
if (res) { |
||||
activeDates.value = res.map((dateObj: unknown) => dayjs(dateObj)) |
||||
} else { |
||||
activeDates.value = [] |
||||
} |
||||
} |
||||
|
||||
const changeCalendarView = async (view: 'month' | 'year' | 'day' | 'week') => { |
||||
try { |
||||
activeCalendarView.value = view |
||||
await updateCalendarMeta({ |
||||
meta: { |
||||
...(typeof calendarMetaData.value.meta === 'string' |
||||
? JSON.parse(calendarMetaData.value.meta) |
||||
: calendarMetaData.value.meta), |
||||
active_view: view, |
||||
}, |
||||
}) |
||||
if (activeCalendarView.value === 'week') { |
||||
selectedTime.value = null |
||||
} |
||||
} catch (e) { |
||||
message.error('Error changing calendar view') |
||||
console.log(e) |
||||
} |
||||
} |
||||
|
||||
async function loadCalendarMeta() { |
||||
if (!viewMeta?.value?.id || !meta?.value?.columns) return |
||||
const res = isPublic.value ? (sharedView.value?.view as CalendarType) : await $api.dbView.calendarRead(viewMeta.value.id) |
||||
calendarMetaData.value = res |
||||
const calMeta = typeof res.meta === 'string' ? JSON.parse(res.meta) : res.meta |
||||
activeCalendarView.value = calMeta?.active_view |
||||
if (!activeCalendarView.value) activeCalendarView.value = 'month' |
||||
calendarRange.value = res?.calendar_range!.map((range: any) => { |
||||
return { |
||||
fk_from_col: meta.value?.columns!.find((col) => col.id === range.fk_from_column_id), |
||||
fk_to_col: range.fk_to_column_id ? meta.value?.columns!.find((col) => col.id === range.fk_to_column_id) : null, |
||||
} |
||||
}) as any |
||||
} |
||||
|
||||
async function loadCalendarData() { |
||||
if ((!base?.value?.id || !meta.value?.id || !viewMeta.value?.id || !filterJSON.value) && !isPublic?.value) return |
||||
isCalendarDataLoading.value = true |
||||
const res = !isPublic.value |
||||
? await api.dbViewRow.list( |
||||
'noco', |
||||
base.value.id!, |
||||
meta.value!.id!, |
||||
viewMeta.value!.id!, |
||||
{ |
||||
...queryParams.value, |
||||
...(isUIAllowed('filterSync') |
||||
? { filterArrJson: JSON.stringify([...filterJSON.value]) } |
||||
: { filterArrJson: JSON.stringify([nestedFilters.value, ...filterJSON.value]) }), |
||||
where: where?.value ?? '', |
||||
}, |
||||
{ |
||||
headers: { |
||||
'xc-ignore-pagination': true, |
||||
}, |
||||
}, |
||||
) |
||||
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: filterJSON.value }) |
||||
formattedData.value = formatData(res!.list) |
||||
isCalendarDataLoading.value = false |
||||
} |
||||
|
||||
async function updateCalendarMeta(updateObj: Partial<CalendarType>) { |
||||
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit') || isPublic.value) return |
||||
try { |
||||
await $api.dbView.calendarUpdate(viewMeta.value.id, updateObj) |
||||
calendarMetaData.value = { |
||||
...calendarMetaData.value, |
||||
...updateObj, |
||||
} |
||||
} catch (e) { |
||||
message.error('Error updating changes') |
||||
console.log(e) |
||||
} |
||||
} |
||||
|
||||
function findRowInState(rowData: Record<string, any>) { |
||||
const pk: Record<string, string> = rowPkData(rowData, meta?.value?.columns as ColumnType[]) |
||||
for (const row of formattedData.value) { |
||||
if (Object.keys(pk).every((k) => pk[k] === row.row[k])) { |
||||
return row |
||||
} |
||||
} |
||||
} |
||||
|
||||
const paginateCalendarView = async (action: 'next' | 'prev') => { |
||||
switch (activeCalendarView.value) { |
||||
case 'month': |
||||
selectedMonth.value = action === 'next' ? selectedMonth.value.add(1, 'month') : selectedMonth.value.subtract(1, 'month') |
||||
pageDate.value = action === 'next' ? pageDate.value.add(1, 'month') : pageDate.value.subtract(1, 'month') |
||||
// selectedDate.value = action === 'next' ? addMonths(selectedDate.value, 1) : addMonths(selectedDate.value, -1)
|
||||
if (pageDate.value.year() !== selectedDate.value.year()) { |
||||
pageDate.value = selectedDate.value |
||||
} |
||||
break |
||||
case 'year': |
||||
selectedDate.value = action === 'next' ? selectedDate.value.add(1, 'year') : selectedDate.value.subtract(1, 'year') |
||||
if (pageDate.value.year() !== selectedDate.value.year()) { |
||||
pageDate.value = selectedDate.value |
||||
} |
||||
break |
||||
case 'day': |
||||
selectedDate.value = action === 'next' ? selectedDate.value.add(1, 'day') : selectedDate.value.subtract(1, 'day') |
||||
selectedTime.value = selectedDate.value |
||||
if (pageDate.value.year() !== selectedDate.value.year()) { |
||||
pageDate.value = selectedDate.value |
||||
} else if (pageDate.value.month() !== selectedDate.value.month()) { |
||||
pageDate.value = selectedDate.value |
||||
} |
||||
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()) { |
||||
pageDate.value = selectedDateRange.value.start |
||||
} |
||||
break |
||||
} |
||||
} |
||||
|
||||
const loadSidebarData = async () => { |
||||
if (!base?.value?.id || !meta.value?.id || !viewMeta.value?.id) return |
||||
isSidebarLoading.value = true |
||||
const res = !isPublic.value |
||||
? await api.dbViewRow.list('noco', base.value.id!, meta.value!.id!, viewMeta.value.id, { |
||||
...queryParams.value, |
||||
...{}, |
||||
...{}, |
||||
...{ filterArrJson: JSON.stringify([...sideBarFilter.value]) }, |
||||
}) |
||||
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: sideBarFilter.value }) |
||||
|
||||
formattedSideBarData.value = formatData(res!.list) |
||||
isSidebarLoading.value = false |
||||
} |
||||
|
||||
async function updateRowProperty(toUpdate: Row, property: string[], undo = false) { |
||||
try { |
||||
const id = extractPkFromRow(toUpdate.row, meta?.value?.columns as ColumnType[]) |
||||
|
||||
const updateObj = property.reduce( |
||||
( |
||||
acc: { |
||||
[x: string]: string |
||||
}, |
||||
curr, |
||||
) => { |
||||
acc[curr] = toUpdate.row[curr] |
||||
return acc |
||||
}, |
||||
{}, |
||||
) |
||||
|
||||
const updatedRowData = await $api.dbViewRow.update( |
||||
NOCO, |
||||
base?.value.id as string, |
||||
meta.value?.id as string, |
||||
viewMeta?.value?.id as string, |
||||
id, |
||||
updateObj, |
||||
{ |
||||
query: { ignoreWebhook: !undo }, |
||||
}, |
||||
// todo:
|
||||
// {
|
||||
// query: { ignoreWebhook: !saved }
|
||||
// }
|
||||
) |
||||
|
||||
if (!undo) { |
||||
addUndo({ |
||||
redo: { |
||||
fn: async (toUpdate: Row, property: string[]) => { |
||||
const updatedRow = await updateRowProperty(toUpdate, property, true) |
||||
const row = findRowInState(toUpdate.row) |
||||
if (row) { |
||||
Object.assign(row.row, updatedRow) |
||||
} |
||||
Object.assign(row?.oldRow, updatedRow) |
||||
}, |
||||
args: [clone(toUpdate), property], |
||||
}, |
||||
undo: { |
||||
fn: async (toUpdate: Row, property: string[]) => { |
||||
const updatedData = await updateRowProperty( |
||||
{ row: toUpdate.oldRow, oldRow: toUpdate.row, rowMeta: toUpdate.rowMeta }, |
||||
property, |
||||
true, |
||||
) |
||||
const row = findRowInState(toUpdate.row) |
||||
if (row) { |
||||
Object.assign(row.row, updatedData) |
||||
} |
||||
Object.assign(row!.oldRow, updatedData) |
||||
}, |
||||
args: [clone(toUpdate), property], |
||||
}, |
||||
scope: defineViewScope({ view: viewMeta.value as ViewType }), |
||||
}) |
||||
Object.assign(toUpdate.row, updatedRowData) |
||||
Object.assign(toUpdate.oldRow, updatedRowData) |
||||
} |
||||
|
||||
await fetchActiveDates() |
||||
return updatedRowData |
||||
} catch (e: any) { |
||||
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`) |
||||
} |
||||
} |
||||
|
||||
watch(selectedDate, async (value, oldValue) => { |
||||
if (activeCalendarView.value === 'month' || activeCalendarView.value === 'week') { |
||||
if (sideBarFilterOption.value === 'selectedDate') { |
||||
await loadSidebarData() |
||||
} |
||||
} else if (activeCalendarView.value === 'year') { |
||||
if (value.year() !== oldValue.year()) { |
||||
await Promise.all([loadCalendarData(), loadSidebarData(), await fetchActiveDates()]) |
||||
} else if (sideBarFilterOption.value === 'selectedDate') { |
||||
await loadSidebarData() |
||||
} |
||||
} else { |
||||
await Promise.all([loadSidebarData(), loadCalendarData()]) |
||||
} |
||||
|
||||
if (activeCalendarView.value === 'year' && value.year() !== oldValue.year()) { |
||||
await fetchActiveDates() |
||||
} |
||||
}) |
||||
|
||||
watch(selectedTime, async () => { |
||||
if (calDataType.value !== UITypes.Date) { |
||||
await loadSidebarData() |
||||
} |
||||
}) |
||||
|
||||
watch(selectedMonth, async (value, oldValue) => { |
||||
if (activeCalendarView.value !== 'month') return |
||||
if (value.month() !== oldValue.month()) { |
||||
await Promise.all([loadCalendarData(), loadSidebarData(), fetchActiveDates()]) |
||||
} |
||||
}) |
||||
|
||||
watch(selectedDateRange, async () => { |
||||
if (activeCalendarView.value !== 'week') return |
||||
await Promise.all([loadCalendarData(), loadSidebarData()]) |
||||
}) |
||||
|
||||
watch(activeCalendarView, async (value, oldValue) => { |
||||
if (oldValue === 'week') { |
||||
pageDate.value = selectedDate.value |
||||
selectedDate.value = selectedDateRange.value.start |
||||
selectedMonth.value = selectedDateRange.value.start |
||||
selectedTime.value = selectedDateRange.value.start |
||||
} else if (oldValue === 'month') { |
||||
selectedDate.value = selectedMonth.value |
||||
pageDate.value = selectedDate.value |
||||
selectedTime.value = selectedDate.value |
||||
selectedDateRange.value = { |
||||
start: selectedDate.value.startOf('week'), |
||||
end: selectedDate.value.endOf('week'), |
||||
} |
||||
} else if (oldValue === 'day') { |
||||
pageDate.value = selectedDate.value |
||||
selectedTime.value = selectedDate.value |
||||
selectedMonth.value = selectedDate.value |
||||
selectedDateRange.value = { |
||||
start: selectedDate.value.startOf('week'), |
||||
end: selectedDate.value.endOf('week'), |
||||
} |
||||
} else if (oldValue === 'year') { |
||||
selectedMonth.value = selectedDate.value |
||||
selectedTime.value = selectedDate.value |
||||
pageDate.value = selectedDate.value |
||||
selectedDateRange.value = { |
||||
start: selectedDate.value.startOf('week'), |
||||
end: selectedDate.value.endOf('week'), |
||||
} |
||||
} |
||||
sideBarFilterOption.value = activeCalendarView.value ?? 'allRecords' |
||||
await Promise.all([loadCalendarData(), loadSidebarData(), fetchActiveDates()]) |
||||
}) |
||||
|
||||
watch(sideBarFilterOption, async () => { |
||||
await loadSidebarData() |
||||
}) |
||||
|
||||
watch(searchQuery, async () => { |
||||
await loadSidebarData() |
||||
}) |
||||
|
||||
watch(pageDate, async () => { |
||||
if (activeCalendarView.value === 'year') return |
||||
await fetchActiveDates() |
||||
}) |
||||
|
||||
return { |
||||
formattedSideBarData, |
||||
loadMoreSidebarData, |
||||
loadSidebarData, |
||||
displayField, |
||||
sideBarFilterOption, |
||||
searchQuery, |
||||
activeDates, |
||||
isCalendarDataLoading, |
||||
changeCalendarView, |
||||
calDataType, |
||||
loadCalendarMeta, |
||||
calendarRange, |
||||
loadCalendarData, |
||||
formattedData, |
||||
isSidebarLoading, |
||||
showSideMenu, |
||||
selectedTime, |
||||
updateCalendarMeta, |
||||
calendarMetaData, |
||||
updateRowProperty, |
||||
activeCalendarView, |
||||
pageDate, |
||||
paginationData, |
||||
selectedDate, |
||||
selectedMonth, |
||||
selectedDateRange, |
||||
paginateCalendarView, |
||||
} |
||||
}, |
||||
) |
||||
|
||||
export { useProvideCalendarViewStore } |
||||
|
||||
export function useCalendarViewStoreOrThrow() { |
||||
const calendarViewStore = useCalendarViewStore() |
||||
|
||||
if (calendarViewStore == null) throw new Error('Please call `useProvideCalendarViewStore` on the appropriate parent component') |
||||
|
||||
return calendarViewStore |
||||
} |
@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup> |
||||
import { message } from 'ant-design-vue' |
||||
import { definePageMeta } from '#imports' |
||||
|
||||
definePageMeta({ |
||||
public: true, |
||||
requiresAuth: false, |
||||
layout: 'shared-view', |
||||
hasSidebar: false, |
||||
}) |
||||
|
||||
const route = useRoute() |
||||
|
||||
const { loadSharedView } = useSharedView() |
||||
|
||||
const showPassword = ref(false) |
||||
|
||||
try { |
||||
await loadSharedView(route.params.viewId as string) |
||||
} catch (e: any) { |
||||
if (e?.response?.status === 403) { |
||||
showPassword.value = true |
||||
} else { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div v-if="showPassword"> |
||||
<LazySharedViewAskPassword v-model="showPassword" /> |
||||
</div> |
||||
<LazySharedViewCalendar v-else /> |
||||
</template> |
@ -0,0 +1,21 @@
|
||||
import { Test } from '@nestjs/testing'; |
||||
import { CalendarsService } from '../services/calendars.service'; |
||||
import { CalendarsController } from './calendars.controller'; |
||||
import type { TestingModule } from '@nestjs/testing'; |
||||
|
||||
describe('CalendarsController', () => { |
||||
let controller: CalendarsController; |
||||
|
||||
beforeEach(async () => { |
||||
const module: TestingModule = await Test.createTestingModule({ |
||||
controllers: [CalendarsController], |
||||
providers: [CalendarsService], |
||||
}).compile(); |
||||
|
||||
controller = module.get<CalendarsController>(CalendarsController); |
||||
}); |
||||
|
||||
it('should be defined', () => { |
||||
expect(controller).toBeDefined(); |
||||
}); |
||||
}); |
@ -0,0 +1,70 @@
|
||||
import { |
||||
Body, |
||||
Controller, |
||||
Get, |
||||
HttpCode, |
||||
Param, |
||||
Patch, |
||||
Post, |
||||
Req, |
||||
UseGuards, |
||||
} from '@nestjs/common'; |
||||
import { Request } from 'express'; |
||||
import { ViewCreateReqType } from 'nocodb-sdk'; |
||||
import { GlobalGuard } from '~/guards/global/global.guard'; |
||||
import { CalendarsService } from '~/services/calendars.service'; |
||||
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; |
||||
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; |
||||
|
||||
@Controller() |
||||
@UseGuards(MetaApiLimiterGuard, GlobalGuard) |
||||
export class CalendarsController { |
||||
constructor(private readonly calendarsService: CalendarsService) {} |
||||
|
||||
@Get([ |
||||
'/api/v1/db/meta/calendars/:calendarViewId', |
||||
'/api/v2/meta/calendars/:calendarViewId', |
||||
]) |
||||
@Acl('calendarViewGet') |
||||
async calendarViewGet(@Param('calendarViewId') calendarViewId: string) { |
||||
return await this.calendarsService.calendarViewGet({ |
||||
calendarViewId, |
||||
}); |
||||
} |
||||
|
||||
@Post([ |
||||
'/api/v1/db/meta/tables/:tableId/calendars', |
||||
'/api/v2/meta/tables/:tableId/calendars', |
||||
]) |
||||
@HttpCode(200) |
||||
@Acl('calendarViewCreate') |
||||
async calendarViewCreate( |
||||
@Param('tableId') tableId: string, |
||||
@Body() body: ViewCreateReqType, |
||||
@Req() req: Request, |
||||
) { |
||||
return await this.calendarsService.calendarViewCreate({ |
||||
tableId, |
||||
calendar: body, |
||||
user: req.user, |
||||
req, |
||||
}); |
||||
} |
||||
|
||||
@Patch([ |
||||
'/api/v1/db/meta/calendars/:calendarViewId', |
||||
'/api/v2/meta/calendars/:calendarViewId', |
||||
]) |
||||
@Acl('calendarViewUpdate') |
||||
async calendarViewUpdate( |
||||
@Param('calendarViewId') calendarViewId: string, |
||||
@Body() body, |
||||
@Req() req: Request, |
||||
) { |
||||
return await this.calendarsService.calendarViewUpdate({ |
||||
calendarViewId, |
||||
calendar: body, |
||||
req, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,66 @@
|
||||
import type { Knex } from 'knex'; |
||||
import { MetaTable } from '~/utils/globals'; |
||||
|
||||
const up = async (knex: Knex) => { |
||||
await knex.schema.createTable(MetaTable.CALENDAR_VIEW, (table) => { |
||||
table.string('fk_view_id', 20).primary(); |
||||
|
||||
table.string('base_id', 20); |
||||
|
||||
table.string('source_id', 128); |
||||
|
||||
table.string('title'); |
||||
|
||||
table.string('fk_cover_image_col_id', 20); |
||||
|
||||
table.text('meta'); |
||||
|
||||
table.dateTime('created_at'); |
||||
table.dateTime('updated_at'); |
||||
}); |
||||
|
||||
await knex.schema.createTable(MetaTable.CALENDAR_VIEW_COLUMNS, (table) => { |
||||
table.string('id', 20).primary().notNullable(); |
||||
|
||||
table.string('base_id', 20); |
||||
table.string('source_id', 128); |
||||
|
||||
table.string('fk_view_id', 20); |
||||
|
||||
table.string('fk_column_id', 20); |
||||
|
||||
table.boolean('show'); |
||||
|
||||
table.boolean('bold'); |
||||
|
||||
table.boolean('underline'); |
||||
|
||||
table.boolean('italic'); |
||||
|
||||
table.float('order'); |
||||
|
||||
table.timestamps(true, true); |
||||
}); |
||||
|
||||
await knex.schema.createTable(MetaTable.CALENDAR_VIEW_RANGE, (table) => { |
||||
table.string('id', 20).primary().notNullable(); |
||||
|
||||
table.string('fk_view_id', 20); |
||||
|
||||
table.string('fk_to_column_id', 20); |
||||
|
||||
table.string('label', 40); |
||||
|
||||
table.string('fk_from_column_id', 20); |
||||
|
||||
table.timestamps(true, true); |
||||
}); |
||||
}; |
||||
|
||||
const down = async (knex: Knex) => { |
||||
await knex.schema.dropTable(MetaTable.CALENDAR_VIEW); |
||||
await knex.schema.dropTable(MetaTable.CALENDAR_VIEW_COLUMNS); |
||||
await knex.schema.dropTable(MetaTable.CALENDAR_VIEW_RANGE); |
||||
}; |
||||
|
||||
export { up, down }; |
@ -0,0 +1,122 @@
|
||||
import type { CalendarRangeType } from 'nocodb-sdk'; |
||||
import Noco from '~/Noco'; |
||||
import NocoCache from '~/cache/NocoCache'; |
||||
import { extractProps } from '~/helpers/extractProps'; |
||||
import { CacheDelDirection, CacheScope, MetaTable } from '~/utils/globals'; |
||||
|
||||
export default class CalendarRange implements CalendarRangeType { |
||||
id?: string; |
||||
fk_from_column_id?: string; |
||||
fk_view_id?: string; |
||||
|
||||
constructor(data: Partial<CalendarRange>) { |
||||
Object.assign(this, data); |
||||
} |
||||
|
||||
public static async bulkInsert( |
||||
data: Partial<CalendarRange>[], |
||||
ncMeta = Noco.ncMeta, |
||||
) { |
||||
let insertObj = []; |
||||
|
||||
for (const d of data) { |
||||
const tempObj = extractProps(d, ['fk_from_column_id', 'fk_view_id']); |
||||
insertObj.push(tempObj); |
||||
} |
||||
|
||||
if (!insertObj.length) return false; |
||||
|
||||
insertObj = insertObj[0]; |
||||
|
||||
const insertData = await ncMeta.metaInsert2( |
||||
null, |
||||
null, |
||||
MetaTable.CALENDAR_VIEW_RANGE, |
||||
insertObj, |
||||
); |
||||
|
||||
await NocoCache.deepDel( |
||||
`${CacheScope.CALENDAR_VIEW_RANGE}:${insertData.fk_view_id}:list`, |
||||
CacheDelDirection.PARENT_TO_CHILD, |
||||
); |
||||
|
||||
await NocoCache.set( |
||||
`${CacheScope.CALENDAR_VIEW_RANGE}:${insertData.id}`, |
||||
insertData, |
||||
); |
||||
|
||||
await NocoCache.appendToList( |
||||
CacheScope.CALENDAR_VIEW_RANGE, |
||||
[insertData.fk_view_id], |
||||
`${CacheScope.CALENDAR_VIEW_RANGE}:${insertData.id}`, |
||||
); |
||||
|
||||
return true; |
||||
} |
||||
|
||||
public static async read(fk_view_id: string, ncMeta = Noco.ncMeta) { |
||||
const cachedList = await NocoCache.getList(CacheScope.CALENDAR_VIEW_RANGE, [ |
||||
fk_view_id, |
||||
]); |
||||
let { list: ranges } = cachedList; |
||||
const { isNoneList } = cachedList; |
||||
if (!isNoneList && !ranges.length) { |
||||
ranges = await ncMeta.metaList2( |
||||
null, //,
|
||||
null, //model.db_alias,
|
||||
MetaTable.CALENDAR_VIEW_RANGE, |
||||
{ condition: { fk_view_id } }, |
||||
); |
||||
await NocoCache.setList( |
||||
CacheScope.CALENDAR_VIEW_RANGE, |
||||
[fk_view_id], |
||||
ranges.map(({ created_at, updated_at, ...others }) => others), |
||||
); |
||||
} |
||||
|
||||
return ranges?.length |
||||
? { |
||||
ranges: ranges.map( |
||||
({ created_at, updated_at, ...c }) => new CalendarRange(c), |
||||
), |
||||
} |
||||
: null; |
||||
} |
||||
|
||||
public static async find( |
||||
fk_view_id: string, |
||||
ncMeta = Noco.ncMeta, |
||||
): Promise<CalendarRange> { |
||||
const data = await ncMeta.metaGet2( |
||||
null, |
||||
null, |
||||
MetaTable.CALENDAR_VIEW_RANGE, |
||||
{ |
||||
fk_view_id, |
||||
}, |
||||
); |
||||
|
||||
return data && new CalendarRange(data); |
||||
} |
||||
|
||||
public static async IsColumnBeingUsedAsRange( |
||||
columnId: string, |
||||
ncMeta = Noco.ncMeta, |
||||
) { |
||||
return ( |
||||
( |
||||
await ncMeta.metaList2(null, null, MetaTable.CALENDAR_VIEW_RANGE, { |
||||
xcCondition: { |
||||
_or: [ |
||||
{ |
||||
fk_from_column_id: { |
||||
eq: columnId, |
||||
}, |
||||
}, |
||||
], |
||||
}, |
||||
}) |
||||
).length > 0 |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,135 @@
|
||||
import type { BoolType, MetaType } from 'nocodb-sdk'; |
||||
import type { CalendarType } from 'nocodb-sdk'; |
||||
import View from '~/models/View'; |
||||
import { extractProps } from '~/helpers/extractProps'; |
||||
import NocoCache from '~/cache/NocoCache'; |
||||
import Noco from '~/Noco'; |
||||
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals'; |
||||
import CalendarRange from '~/models/CalendarRange'; |
||||
|
||||
export default class CalendarView implements CalendarType { |
||||
fk_view_id: string; |
||||
title: string; |
||||
base_id?: string; |
||||
source_id?: string; |
||||
meta?: MetaType; |
||||
calendar_range?: Array<Partial<CalendarRange>>; |
||||
fk_cover_image_col_id?: string; |
||||
// below fields are not in use at this moment
|
||||
// keep them for time being
|
||||
show?: BoolType; |
||||
public?: BoolType; |
||||
password?: string; |
||||
show_all_fields?: BoolType; |
||||
|
||||
constructor(data: CalendarView) { |
||||
Object.assign(this, data); |
||||
} |
||||
|
||||
public static async get(viewId: string, ncMeta = Noco.ncMeta) { |
||||
let view = |
||||
viewId && |
||||
(await NocoCache.get( |
||||
`${CacheScope.CALENDAR_VIEW}:${viewId}`, |
||||
CacheGetType.TYPE_OBJECT, |
||||
)); |
||||
if (view) { |
||||
const calendarRange = await CalendarRange.read(viewId, ncMeta); |
||||
if (calendarRange) { |
||||
view.calendar_range = calendarRange.ranges; |
||||
} else { |
||||
view.calendar_range = []; |
||||
} |
||||
} else { |
||||
view = await ncMeta.metaGet2(null, null, MetaTable.CALENDAR_VIEW, { |
||||
fk_view_id: viewId, |
||||
}); |
||||
const calendarRange = await CalendarRange.read(viewId); |
||||
if (view && calendarRange) { |
||||
view.calendar_range = calendarRange.ranges; |
||||
} |
||||
await NocoCache.set(`${CacheScope.CALENDAR_VIEW}:${viewId}`, view); |
||||
} |
||||
|
||||
return view && new CalendarView(view); |
||||
} |
||||
|
||||
static async insert(view: Partial<CalendarView>, ncMeta = Noco.ncMeta) { |
||||
const insertObj = { |
||||
base_id: view.base_id, |
||||
source_id: view.source_id, |
||||
fk_view_id: view.fk_view_id, |
||||
meta: view.meta, |
||||
}; |
||||
|
||||
const viewRef = await View.get(view.fk_view_id); |
||||
|
||||
if (!(view.base_id && view.source_id)) { |
||||
insertObj.base_id = viewRef.base_id; |
||||
insertObj.source_id = viewRef.source_id; |
||||
} |
||||
|
||||
await ncMeta.metaInsert2( |
||||
null, |
||||
null, |
||||
MetaTable.CALENDAR_VIEW, |
||||
insertObj, |
||||
true, |
||||
); |
||||
|
||||
return this.get(view.fk_view_id, ncMeta); |
||||
} |
||||
|
||||
static async update( |
||||
calendarId: string, |
||||
body: Partial<CalendarView>, |
||||
ncMeta = Noco.ncMeta, |
||||
) { |
||||
// get existing cache
|
||||
const key = `${CacheScope.CALENDAR_VIEW}:${calendarId}`; |
||||
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT); |
||||
|
||||
const updateObj = extractProps(body, ['fk_cover_image_col_id', 'meta']); |
||||
|
||||
if (updateObj.meta && typeof updateObj.meta === 'object') { |
||||
updateObj.meta = JSON.stringify(updateObj.meta ?? {}); |
||||
} |
||||
|
||||
if (o) { |
||||
o = { ...o, ...updateObj }; |
||||
// set cache
|
||||
await NocoCache.set(key, o); |
||||
} |
||||
|
||||
if (body.calendar_range) { |
||||
await NocoCache.del(`${CacheScope.CALENDAR_VIEW}:${calendarId}`); |
||||
await ncMeta.metaDelete( |
||||
null, |
||||
null, |
||||
MetaTable.CALENDAR_VIEW_RANGE, |
||||
{}, |
||||
{ |
||||
fk_view_id: calendarId, |
||||
}, |
||||
); |
||||
await CalendarRange.bulkInsert( |
||||
body.calendar_range.map((range) => { |
||||
return { |
||||
fk_view_id: calendarId, |
||||
...range, |
||||
}; |
||||
}), |
||||
); |
||||
} |
||||
// update meta
|
||||
return await ncMeta.metaUpdate( |
||||
null, |
||||
null, |
||||
MetaTable.CALENDAR_VIEW, |
||||
updateObj, |
||||
{ |
||||
fk_view_id: calendarId, |
||||
}, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,182 @@
|
||||
import type { BoolType, MetaType } from 'nocodb-sdk'; |
||||
import View from '~/models/View'; |
||||
import Noco from '~/Noco'; |
||||
import NocoCache from '~/cache/NocoCache'; |
||||
import { extractProps } from '~/helpers/extractProps'; |
||||
import { deserializeJSON } from '~/utils/serialize'; |
||||
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals'; |
||||
|
||||
export default class CalendarViewColumn { |
||||
id?: string; |
||||
fk_view_id?: string; |
||||
fk_column_id?: string; |
||||
base_id?: string; |
||||
source_id?: string; |
||||
show?: BoolType; |
||||
underline?: BoolType; |
||||
bold?: BoolType; |
||||
italic?: BoolType; |
||||
order?: number; |
||||
meta?: MetaType; |
||||
|
||||
constructor(data: CalendarViewColumn) { |
||||
Object.assign(this, data); |
||||
} |
||||
|
||||
public static async get(calendarViewColumnId: string, ncMeta = Noco.ncMeta) { |
||||
let viewColumn = |
||||
calendarViewColumnId && |
||||
(await NocoCache.get( |
||||
`${CacheScope.CALENDAR_VIEW_COLUMN}:${calendarViewColumnId}`, |
||||
CacheGetType.TYPE_OBJECT, |
||||
)); |
||||
if (!viewColumn) { |
||||
viewColumn = await ncMeta.metaGet2( |
||||
null, |
||||
null, |
||||
MetaTable.CALENDAR_VIEW_COLUMNS, |
||||
calendarViewColumnId, |
||||
); |
||||
viewColumn.meta = |
||||
viewColumn.meta && typeof viewColumn.meta === 'string' |
||||
? JSON.parse(viewColumn.meta) |
||||
: viewColumn.meta; |
||||
|
||||
await NocoCache.set( |
||||
`${CacheScope.CALENDAR_VIEW_COLUMN}:${calendarViewColumnId}`, |
||||
viewColumn, |
||||
); |
||||
} |
||||
|
||||
return viewColumn && new CalendarViewColumn(viewColumn); |
||||
} |
||||
|
||||
static async insert( |
||||
column: Partial<CalendarViewColumn>, |
||||
ncMeta = Noco.ncMeta, |
||||
) { |
||||
const insertObj = extractProps(column, [ |
||||
'fk_view_id', |
||||
'fk_column_id', |
||||
'show', |
||||
'base_id', |
||||
'source_id', |
||||
'underline', |
||||
'bold', |
||||
'italic', |
||||
]); |
||||
|
||||
insertObj.order = await ncMeta.metaGetNextOrder( |
||||
MetaTable.CALENDAR_VIEW_COLUMNS, |
||||
{ |
||||
fk_view_id: insertObj.fk_view_id, |
||||
}, |
||||
); |
||||
|
||||
if (!(insertObj.base_id && insertObj.source_id)) { |
||||
const viewRef = await View.get(insertObj.fk_view_id, ncMeta); |
||||
insertObj.base_id = viewRef.base_id; |
||||
insertObj.source_id = viewRef.source_id; |
||||
} |
||||
|
||||
const { id, fk_column_id } = await ncMeta.metaInsert2( |
||||
null, |
||||
null, |
||||
MetaTable.CALENDAR_VIEW_COLUMNS, |
||||
insertObj, |
||||
); |
||||
|
||||
await NocoCache.set( |
||||
`${CacheScope.CALENDAR_VIEW_COLUMN}:${fk_column_id}`, |
||||
id, |
||||
); |
||||
|
||||
{ |
||||
const view = await View.get(column.fk_view_id, ncMeta); |
||||
await View.clearSingleQueryCache(view.fk_model_id, [view]); |
||||
} |
||||
|
||||
return this.get(id, ncMeta).then(async (viewColumn) => { |
||||
await NocoCache.appendToList( |
||||
CacheScope.CALENDAR_VIEW_COLUMN, |
||||
[column.fk_view_id], |
||||
`${CacheScope.CALENDAR_VIEW_COLUMN}:${id}`, |
||||
); |
||||
return viewColumn; |
||||
}); |
||||
} |
||||
|
||||
public static async list( |
||||
viewId: string, |
||||
ncMeta = Noco.ncMeta, |
||||
): Promise<CalendarViewColumn[]> { |
||||
const cachedList = await NocoCache.getList( |
||||
CacheScope.CALENDAR_VIEW_COLUMN, |
||||
[viewId], |
||||
); |
||||
let { list: viewColumns } = cachedList; |
||||
const { isNoneList } = cachedList; |
||||
if (!isNoneList && !viewColumns.length) { |
||||
viewColumns = await ncMeta.metaList2( |
||||
null, |
||||
null, |
||||
MetaTable.CALENDAR_VIEW_COLUMNS, |
||||
{ |
||||
condition: { |
||||
fk_view_id: viewId, |
||||
}, |
||||
orderBy: { |
||||
order: 'asc', |
||||
}, |
||||
}, |
||||
); |
||||
|
||||
for (const viewColumn of viewColumns) { |
||||
viewColumn.meta = deserializeJSON(viewColumn.meta); |
||||
} |
||||
|
||||
await NocoCache.setList( |
||||
CacheScope.CALENDAR_VIEW_COLUMN, |
||||
[viewId], |
||||
viewColumns, |
||||
); |
||||
} |
||||
viewColumns.sort( |
||||
(a, b) => |
||||
(a.order != null ? a.order : Infinity) - |
||||
(b.order != null ? b.order : Infinity), |
||||
); |
||||
return viewColumns?.map((v) => new CalendarViewColumn(v)); |
||||
} |
||||
|
||||
static async update( |
||||
columnId: string, |
||||
body: Partial<CalendarViewColumn>, |
||||
ncMeta = Noco.ncMeta, |
||||
) { |
||||
const updateObj = extractProps(body, [ |
||||
'show', |
||||
'order', |
||||
'underline', |
||||
'bold', |
||||
'italic', |
||||
]); |
||||
|
||||
// get existing cache
|
||||
const key = `${CacheScope.CALENDAR_VIEW_COLUMN}:${columnId}`; |
||||
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT); |
||||
if (o) { |
||||
Object.assign(o, updateObj); |
||||
// set cache
|
||||
await NocoCache.set(key, o); |
||||
} |
||||
// update meta
|
||||
return await ncMeta.metaUpdate( |
||||
null, |
||||
null, |
||||
MetaTable.CALENDAR_VIEW_COLUMNS, |
||||
updateObj, |
||||
columnId, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,93 @@
|
||||
import { Injectable } from '@nestjs/common'; |
||||
import { AppEvents, ViewTypes } from 'nocodb-sdk'; |
||||
import type { |
||||
CalendarUpdateReqType, |
||||
UserType, |
||||
ViewCreateReqType, |
||||
} from 'nocodb-sdk'; |
||||
import type { NcRequest } from '~/interface/config'; |
||||
import { AppHooksService } from '~/services/app-hooks/app-hooks.service'; |
||||
import { validatePayload } from '~/helpers'; |
||||
import { NcError } from '~/helpers/catchError'; |
||||
import { CalendarView, Model, View } from '~/models'; |
||||
import NocoCache from '~/cache/NocoCache'; |
||||
import { CacheScope } from '~/utils/globals'; |
||||
|
||||
@Injectable() |
||||
export class CalendarsService { |
||||
constructor(private readonly appHooksService: AppHooksService) {} |
||||
|
||||
async calendarViewGet(param: { calendarViewId: string }) { |
||||
return await CalendarView.get(param.calendarViewId); |
||||
} |
||||
|
||||
async calendarViewCreate(param: { |
||||
tableId: string; |
||||
calendar: ViewCreateReqType; |
||||
user: UserType; |
||||
req: NcRequest; |
||||
}) { |
||||
validatePayload( |
||||
'swagger.json#/components/schemas/ViewCreateReq', |
||||
param.calendar, |
||||
); |
||||
|
||||
const model = await Model.get(param.tableId); |
||||
|
||||
const { id } = await View.insertMetaOnly( |
||||
{ |
||||
...param.calendar, |
||||
fk_model_id: param.tableId, |
||||
type: ViewTypes.CALENDAR, |
||||
base_id: model.base_id, |
||||
source_id: model.source_id, |
||||
}, |
||||
model, |
||||
); |
||||
|
||||
const view = await View.get(id); |
||||
|
||||
await NocoCache.appendToList( |
||||
CacheScope.VIEW, |
||||
[view.fk_model_id], |
||||
`${CacheScope.VIEW}:${id}`, |
||||
); |
||||
|
||||
this.appHooksService.emit(AppEvents.VIEW_CREATE, { |
||||
view, |
||||
showAs: 'calendar', |
||||
user: param.user, |
||||
|
||||
req: param.req, |
||||
}); |
||||
|
||||
return view; |
||||
} |
||||
|
||||
async calendarViewUpdate(param: { |
||||
calendarViewId: string; |
||||
calendar: CalendarUpdateReqType; |
||||
req: NcRequest; |
||||
}) { |
||||
validatePayload( |
||||
'swagger.json#/components/schemas/CalendarUpdateReq', |
||||
param.calendar, |
||||
); |
||||
|
||||
const view = await View.get(param.calendarViewId); |
||||
|
||||
if (!view) { |
||||
NcError.badRequest('View not found'); |
||||
} |
||||
|
||||
const res = await CalendarView.update(param.calendarViewId, param.calendar); |
||||
|
||||
this.appHooksService.emit(AppEvents.VIEW_UPDATE, { |
||||
view, |
||||
showAs: 'calendar', |
||||
req: param.req, |
||||
}); |
||||
|
||||
return res; |
||||
} |
||||
} |
@ -0,0 +1,26 @@
|
||||
import BasePage from '../../Base'; |
||||
import { CalendarPage } from './index'; |
||||
import { expect } from '@playwright/test'; |
||||
|
||||
export class CalendarDayDatePage extends BasePage { |
||||
readonly parent: CalendarPage; |
||||
|
||||
constructor(parent: CalendarPage) { |
||||
super(parent.rootPage); |
||||
this.parent = parent; |
||||
} |
||||
|
||||
get() { |
||||
return this.rootPage.getByTestId('nc-calendar-day-view'); |
||||
} |
||||
|
||||
async verifyRecord(data: { records: string[] }) { |
||||
const records = await this.get().getByTestId('nc-calendar-day-record-card'); |
||||
|
||||
await expect(records).toHaveCount(data.records.length); |
||||
|
||||
for (let i = 0; i < data.records.length; i++) { |
||||
await expect(records.nth(i)).toContainText(data.records[i]); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,52 @@
|
||||
import BasePage from '../../Base'; |
||||
import { CalendarPage } from './index'; |
||||
|
||||
export class CalendarDayDateTimePage extends BasePage { |
||||
readonly parent: CalendarPage; |
||||
|
||||
constructor(parent: CalendarPage) { |
||||
super(parent.rootPage); |
||||
this.parent = parent; |
||||
} |
||||
|
||||
get() { |
||||
return this.rootPage.getByTestId('nc-calendar-day-view'); |
||||
} |
||||
|
||||
getRecordContainer() { |
||||
return this.get().getByTestId('nc-calendar-day-record-container'); |
||||
} |
||||
|
||||
async dragAndDrop({ record, hourIndex }: { record: string; hourIndex: number }) { |
||||
const recordContainer = this.getRecordContainer(); |
||||
const recordCard = recordContainer.getByTestId(`nc-calendar-day-record-${record}`); |
||||
const toDay = this.get().getByTestId('nc-calendar-day-hour').nth(hourIndex); |
||||
const cord = await toDay.boundingBox(); |
||||
|
||||
await recordCard.scrollIntoViewIfNeeded(); |
||||
await recordCard.hover(); |
||||
await this.rootPage.mouse.down(); |
||||
// Bit Flaky
|
||||
await this.rootPage.waitForTimeout(500); |
||||
|
||||
await this.rootPage.mouse.move(cord.x + cord.width / 2, cord.y + cord.height / 2, { |
||||
steps: 10, |
||||
}); |
||||
|
||||
// Bit Flaky
|
||||
await this.rootPage.waitForTimeout(500); |
||||
|
||||
await this.rootPage.mouse.up(); |
||||
} |
||||
|
||||
async selectHour({ hourIndex }: { hourIndex: number }) { |
||||
const hour = this.get().getByTestId('nc-calendar-day-hour').nth(hourIndex); |
||||
await hour.click({ |
||||
force: true, |
||||
position: { |
||||
x: 0, |
||||
y: 1, |
||||
}, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,51 @@
|
||||
import BasePage from '../../Base'; |
||||
import { CalendarPage } from './index'; |
||||
|
||||
export class CalendarMonthPage extends BasePage { |
||||
readonly parent: CalendarPage; |
||||
|
||||
constructor(parent: CalendarPage) { |
||||
super(parent.rootPage); |
||||
this.parent = parent; |
||||
} |
||||
|
||||
get() { |
||||
return this.rootPage.getByTestId('nc-calendar-month-view'); |
||||
} |
||||
|
||||
getRecordContainer() { |
||||
return this.get().getByTestId('nc-calendar-month-record-container'); |
||||
} |
||||
|
||||
async dragAndDrop({ record, to }: { record: string; to: { rowIndex: number; columnIndex: number } }) { |
||||
const recordContainer = this.getRecordContainer(); |
||||
const recordCard = recordContainer.getByTestId(`nc-calendar-month-record-${record}`); |
||||
const toDay = this.get() |
||||
.getByTestId('nc-calendar-month-week') |
||||
.nth(to.rowIndex) |
||||
.getByTestId('nc-calendar-month-day') |
||||
.nth(to.columnIndex); |
||||
const cord = await toDay.boundingBox(); |
||||
|
||||
await recordCard.hover(); |
||||
await this.rootPage.mouse.down(); |
||||
|
||||
await this.rootPage.waitForTimeout(500); |
||||
|
||||
await this.rootPage.mouse.move(cord.x + cord.width / 2, cord.y + cord.height / 2, { steps: 10 }); |
||||
await this.rootPage.mouse.up(); |
||||
} |
||||
|
||||
async selectDate({ rowIndex, columnIndex }: { rowIndex: number; columnIndex: number }) { |
||||
const week = this.get().getByTestId('nc-calendar-month-week'); |
||||
|
||||
const day = week.nth(rowIndex).getByTestId('nc-calendar-month-day').nth(columnIndex); |
||||
await day.click({ |
||||
force: true, |
||||
position: { |
||||
x: 0, |
||||
y: 1, |
||||
}, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,43 @@
|
||||
import { expect, Locator } from '@playwright/test'; |
||||
import BasePage from '../../Base'; |
||||
import { CalendarPage } from './index'; |
||||
|
||||
export class CalendarSideMenuPage extends BasePage { |
||||
readonly parent: CalendarPage; |
||||
|
||||
readonly new_record_btn: Locator; |
||||
|
||||
constructor(parent: CalendarPage) { |
||||
super(parent.rootPage); |
||||
this.parent = parent; |
||||
|
||||
this.new_record_btn = this.get().getByTestId('nc-calendar-side-menu-new-btn'); |
||||
} |
||||
|
||||
get() { |
||||
return this.rootPage.getByTestId('nc-calendar-side-menu'); |
||||
} |
||||
|
||||
async updateFilter({ filter }: { filter: string }) { |
||||
const filterInput = this.get().getByTestId('nc-calendar-sidebar-filter'); |
||||
await filterInput.click(); |
||||
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${filter}"`).click(); |
||||
} |
||||
|
||||
async searchRecord({ query }: { query: string }) { |
||||
const searchInput = this.get().getByTestId('nc-calendar-sidebar-search'); |
||||
await searchInput.fill(query); |
||||
} |
||||
|
||||
async verifySideBarRecords({ records }: { records: string[] }) { |
||||
const sideBar = this.get().getByTestId('nc-calendar-side-menu-list'); |
||||
|
||||
const sideBarRecords = await sideBar.getByTestId('nc-sidebar-record-card'); |
||||
|
||||
await expect(sideBarRecords).toHaveCount(records.length); |
||||
|
||||
for (let i = 0; i < records.length; i++) { |
||||
await expect(sideBarRecords.nth(i)).toContainText(records[i]); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,63 @@
|
||||
import { expect, Locator } from '@playwright/test'; |
||||
import BasePage from '../../Base'; |
||||
import { CalendarPage } from './index'; |
||||
|
||||
export class CalendarTopbarPage extends BasePage { |
||||
readonly parent: CalendarPage; |
||||
|
||||
readonly today_btn: Locator; |
||||
readonly prev_btn: Locator; |
||||
readonly next_btn: Locator; |
||||
|
||||
readonly side_bar_btn: Locator; |
||||
|
||||
constructor(parent: CalendarPage) { |
||||
super(parent.rootPage); |
||||
this.parent = parent; |
||||
|
||||
this.next_btn = this.get().getByTestId('nc-calendar-next-btn'); |
||||
this.prev_btn = this.get().getByTestId('nc-calendar-prev-btn'); |
||||
this.today_btn = this.get().getByTestId('nc-calendar-today-btn'); |
||||
|
||||
this.side_bar_btn = this.get().getByTestId('nc-calendar-side-bar-btn'); |
||||
} |
||||
|
||||
get() { |
||||
return this.rootPage.getByTestId('nc-calendar-topbar'); |
||||
} |
||||
|
||||
async getActiveDate() { |
||||
return this.get().getByTestId('nc-calendar-active-date').textContent(); |
||||
} |
||||
|
||||
async verifyActiveCalendarView({ view }: { view: string }) { |
||||
const activeView = this.get().getByTestId('nc-active-calendar-view'); |
||||
|
||||
await expect(activeView).toContainText(view); |
||||
} |
||||
|
||||
async clickPrev() { |
||||
await this.prev_btn.click(); |
||||
} |
||||
async clickNext() { |
||||
await this.next_btn.click(); |
||||
} |
||||
async clickToday() { |
||||
await this.today_btn.click(); |
||||
} |
||||
|
||||
async moveToDate({ date, action }: { date: string; action: 'prev' | 'next' }) { |
||||
while ((await this.getActiveDate()) !== date) { |
||||
if (action === 'prev') { |
||||
await this.clickPrev(); |
||||
} else { |
||||
await this.clickNext(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
async toggleSideBar() { |
||||
await this.side_bar_btn.click(); |
||||
await this.rootPage.waitForTimeout(500); |
||||
} |
||||
} |
@ -0,0 +1,45 @@
|
||||
import BasePage from '../../Base'; |
||||
import { CalendarPage } from './index'; |
||||
|
||||
export class CalendarWeekDatePage extends BasePage { |
||||
readonly parent: CalendarPage; |
||||
|
||||
constructor(parent: CalendarPage) { |
||||
super(parent.rootPage); |
||||
this.parent = parent; |
||||
} |
||||
|
||||
get() { |
||||
return this.rootPage.getByTestId('nc-calendar-week-view'); |
||||
} |
||||
|
||||
getRecordContainer() { |
||||
return this.get().getByTestId('nc-calendar-week-record-container'); |
||||
} |
||||
|
||||
async dragAndDrop({ record, dayIndex }: { record: string; dayIndex: number }) { |
||||
const recordContainer = this.getRecordContainer(); |
||||
const recordCard = recordContainer.getByTestId(`nc-calendar-week-record-${record}`); |
||||
const toDay = this.get().getByTestId('nc-calendar-week-day').nth(dayIndex); |
||||
const cord = await toDay.boundingBox(); |
||||
|
||||
await recordCard.hover(); |
||||
await this.rootPage.mouse.down(); |
||||
await this.rootPage.waitForTimeout(500); |
||||
|
||||
await this.rootPage.mouse.move(cord.x + cord.width / 2, cord.y + cord.height / 2); |
||||
await this.rootPage.mouse.up(); |
||||
} |
||||
|
||||
async selectDay({ dayIndex }: { dayIndex: number }) { |
||||
const day = this.get().getByTestId('nc-calendar-week-day').nth(dayIndex); |
||||
|
||||
await day.click({ |
||||
force: true, |
||||
position: { |
||||
x: 0, |
||||
y: 1, |
||||
}, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,59 @@
|
||||
import BasePage from '../../Base'; |
||||
import { CalendarPage } from './index'; |
||||
|
||||
export class CalendarWeekDateTimePage extends BasePage { |
||||
readonly parent: CalendarPage; |
||||
|
||||
constructor(parent: CalendarPage) { |
||||
super(parent.rootPage); |
||||
this.parent = parent; |
||||
} |
||||
|
||||
get() { |
||||
return this.rootPage.getByTestId('nc-calendar-week-view'); |
||||
} |
||||
|
||||
getRecordContainer() { |
||||
return this.get().getByTestId('nc-calendar-week-record-container'); |
||||
} |
||||
|
||||
async dragAndDrop({ |
||||
record, |
||||
to, |
||||
}: { |
||||
record: string; |
||||
to: { |
||||
dayIndex: number; |
||||
hourIndex: number; |
||||
}; |
||||
}) { |
||||
const recordContainer = this.getRecordContainer(); |
||||
const recordCard = recordContainer.getByTestId(`nc-calendar-week-record-${record}`); |
||||
const toDay = this.get() |
||||
.getByTestId('nc-calendar-week-day') |
||||
.nth(to.dayIndex) |
||||
.getByTestId('nc-calendar-week-hour') |
||||
.nth(to.hourIndex); |
||||
const cord = await toDay.boundingBox(); |
||||
|
||||
await recordCard.hover(); |
||||
await this.rootPage.mouse.down(); |
||||
await this.rootPage.waitForTimeout(500); |
||||
|
||||
await this.rootPage.mouse.move(cord.x + cord.width / 2, cord.y + cord.height / 2); |
||||
await this.rootPage.mouse.up(); |
||||
} |
||||
|
||||
async selectHour({ hourIndex, dayIndex }: { dayIndex: number; hourIndex: number }) { |
||||
const day = this.get().getByTestId('nc-calendar-week-day').nth(dayIndex); |
||||
|
||||
const hour = day.getByTestId('nc-calendar-week-hour').nth(hourIndex); |
||||
await hour.click({ |
||||
force: true, |
||||
position: { |
||||
x: -1, |
||||
y: -1, |
||||
}, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,26 @@
|
||||
import { expect, Locator } from '@playwright/test'; |
||||
import BasePage from '../../Base'; |
||||
import { CalendarPage } from './index'; |
||||
|
||||
export class CalendarYearPage extends BasePage { |
||||
readonly parent: CalendarPage; |
||||
|
||||
constructor(parent: CalendarPage) { |
||||
super(parent.rootPage); |
||||
this.parent = parent; |
||||
} |
||||
|
||||
get() { |
||||
return this.rootPage.getByTestId('nc-calendar-year-view'); |
||||
} |
||||
|
||||
getMonth({ index }: { index: number }) { |
||||
return this.get().getByTestId('nc-calendar-year-view-month-selector').nth(index); |
||||
} |
||||
|
||||
async selectDate({ monthIndex, dayIndex }: { monthIndex: number; dayIndex: number }) { |
||||
const month = this.getMonth({ index: monthIndex }); |
||||
const day = month.getByTestId('nc-calendar-date').nth(dayIndex); |
||||
await day.click({ force: true }); |
||||
} |
||||
} |
@ -0,0 +1,63 @@
|
||||
import { DashboardPage } from '..'; |
||||
import BasePage from '../../Base'; |
||||
import { ToolbarPage } from '../common/Toolbar'; |
||||
import { expect } from '@playwright/test'; |
||||
import { TopbarPage } from '../common/Topbar'; |
||||
import { CalendarTopbarPage } from './CalendarTopBar'; |
||||
import { CalendarSideMenuPage } from './CalendarSideMenu'; |
||||
import { CalendarMonthPage } from './CalendarMonth'; |
||||
import { CalendarYearPage } from './CalendarYear'; |
||||
import { CalendarDayDateTimePage } from './CalendarDayDateTime'; |
||||
import { CalendarWeekDateTimePage } from './CalendarWeekDateTime'; |
||||
import { CalendarDayDatePage } from './CalendarDayDate'; |
||||
import { CalendarWeekDatePage } from './CalendarWeekDate'; |
||||
|
||||
export class CalendarPage extends BasePage { |
||||
readonly dashboard: DashboardPage; |
||||
readonly toolbar: ToolbarPage; |
||||
readonly topbar: TopbarPage; |
||||
readonly calendarTopbar: CalendarTopbarPage; |
||||
readonly sideMenu: CalendarSideMenuPage; |
||||
readonly calendarMonth: CalendarMonthPage; |
||||
readonly calendarYear: CalendarYearPage; |
||||
readonly calendarDayDateTime: CalendarDayDateTimePage; |
||||
readonly calendarWeekDateTime: CalendarWeekDateTimePage; |
||||
readonly calendarDayDate: CalendarDayDatePage; |
||||
readonly calendarWeekDate: CalendarWeekDatePage; |
||||
|
||||
constructor(dashboard: DashboardPage) { |
||||
super(dashboard.rootPage); |
||||
this.dashboard = dashboard; |
||||
this.toolbar = new ToolbarPage(this); |
||||
this.topbar = new TopbarPage(this); |
||||
this.calendarTopbar = new CalendarTopbarPage(this); |
||||
this.sideMenu = new CalendarSideMenuPage(this); |
||||
this.calendarMonth = new CalendarMonthPage(this); |
||||
this.calendarYear = new CalendarYearPage(this); |
||||
this.calendarDayDateTime = new CalendarDayDateTimePage(this); |
||||
this.calendarWeekDateTime = new CalendarWeekDateTimePage(this); |
||||
this.calendarDayDate = new CalendarDayDatePage(this); |
||||
this.calendarWeekDate = new CalendarWeekDatePage(this); |
||||
} |
||||
|
||||
get() { |
||||
return this.dashboard.rootPage.getByTestId('nc-calendar-wrapper'); |
||||
} |
||||
|
||||
async verifySideBarClosed() { |
||||
const sideBar = this.get().getByTestId('nc-calendar-side-menu'); |
||||
const classList = await sideBar.evaluate(el => [...el.classList]); |
||||
expect(classList).not.toContain('nc-calendar-side-menu-open'); |
||||
} |
||||
|
||||
async verifySideBarOpen() { |
||||
const sideBar = this.get().getByTestId('nc-calendar-side-menu'); |
||||
|
||||
const classList = await sideBar.evaluate(el => [...el.classList]); |
||||
expect(classList).toContain('nc-calendar-side-menu-open'); |
||||
} |
||||
|
||||
async waitLoading() { |
||||
await this.rootPage.waitForTimeout(2000); |
||||
} |
||||
} |
@ -0,0 +1,44 @@
|
||||
import BasePage from '../../../Base'; |
||||
import { ToolbarPage } from './index'; |
||||
import { getTextExcludeIconText } from '../../../../tests/utils/general'; |
||||
import { expect } from '@playwright/test'; |
||||
|
||||
export class ToolbarCalendarRangePage extends BasePage { |
||||
readonly toolbar: ToolbarPage; |
||||
|
||||
constructor(toolbar: ToolbarPage) { |
||||
super(toolbar.rootPage); |
||||
this.toolbar = toolbar; |
||||
} |
||||
|
||||
get() { |
||||
return this.rootPage.getByTestId('nc-calendar-range-menu'); |
||||
} |
||||
|
||||
async click({ title }: { title: string }) { |
||||
await this.get().getByTestId('nc-calendar-range-from-field-select').click(); |
||||
await this.rootPage.locator('.ant-select-dropdown:visible').locator(`div[title="${title}"]`).click(); |
||||
} |
||||
|
||||
async newCalendarRange({ fromTitle, toTitle }: { fromTitle: string; toTitle?: string }) { |
||||
await this.get().getByTestId(`nc-calendar-range-from-field-select`).click(); |
||||
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${fromTitle}"`).click(); |
||||
if (toTitle) { |
||||
await this.get().getByTestId(`nc-calendar-range-to-field-select`).click(); |
||||
await this.rootPage.locator('.ant-select-item-option-content').locator(`:text("${toTitle}")`).click(); |
||||
} |
||||
} |
||||
|
||||
async verifyCalendarRange({ fromTitle, toTitle }: { fromTitle: string; toTitle?: string }) { |
||||
const from = await this.get().getByTestId('nc-calendar-range-from-field-select'); |
||||
|
||||
const fromFieldText = await getTextExcludeIconText(from); |
||||
expect(fromFieldText).toBe(fromTitle); |
||||
|
||||
if (toTitle) { |
||||
const to = await this.get().getByTestId('nc-calendar-range-to-field-select'); |
||||
const toFieldText = await getTextExcludeIconText(to); |
||||
expect(toFieldText).toBe(toTitle); |
||||
} |
||||
} |
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue