diff --git a/apps/calendar/examples/00-calendar-app.html b/apps/calendar/examples/00-calendar-app.html index e98c4009e..0f8cb0f4c 100644 --- a/apps/calendar/examples/00-calendar-app.html +++ b/apps/calendar/examples/00-calendar-app.html @@ -41,23 +41,23 @@

@@ -107,6 +107,10 @@ /> +
diff --git a/apps/calendar/examples/scripts/app.js b/apps/calendar/examples/scripts/app.js index 87ad19725..95d807ecf 100644 --- a/apps/calendar/examples/scripts/app.js +++ b/apps/calendar/examples/scripts/app.js @@ -17,6 +17,7 @@ var dropdownTrigger = $('.dropdown-trigger'); var dropdownTriggerIcon = $('.dropdown-icon'); var dropdownContent = $('.dropdown-content'); + var checkboxCollapse = $('.checkbox-collapse'); var sidebar = $('.sidebar'); // App State @@ -73,7 +74,7 @@ } function setAllCheckboxes(checked) { - var checkboxes = $$('input[type="checkbox"]'); + var checkboxes = $$('.sidebar-item > input[type="checkbox"]'); checkboxes.forEach(function (checkbox) { checkbox.checked = checked; @@ -125,13 +126,28 @@ }); dropdownContent.addEventListener('click', function (e) { + var targetViewName; + if ('viewName' in e.target.dataset) { - cal.changeView(e.target.dataset.viewName); + targetViewName = e.target.dataset.viewName; + cal.changeView(targetViewName); + checkboxCollapse.disabled = targetViewName === 'month'; toggleDropdownState(); update(); } }); + checkboxCollapse.addEventListener('change', function (e) { + if ('checked' in e.target) { + cal.setOptions({ + week: { + collapseDuplicateEvents: !!e.target.checked, + }, + useDetailPopup: !e.target.checked, + }); + } + }); + sidebar.addEventListener('click', function (e) { if ('value' in e.target) { if (e.target.value === 'all') { diff --git a/apps/calendar/examples/scripts/mock-data.js b/apps/calendar/examples/scripts/mock-data.js index 4dfe12d4a..d9fc021ca 100644 --- a/apps/calendar/examples/scripts/mock-data.js +++ b/apps/calendar/examples/scripts/mock-data.js @@ -147,14 +147,25 @@ function generateRandomEvent(calendar, renderStart, renderEnd) { } function generateRandomEvents(viewName, renderStart, renderEnd) { - var i; - var event; + var i, j; + var event, duplicateEvent; var events = []; MOCK_CALENDARS.forEach(function(calendar) { for (i = 0; i < chance.integer({ min: 20, max: 50 }); i += 1) { event = generateRandomEvent(calendar, renderStart, renderEnd); events.push(event); + + if (i % 5 === 0) { + for (j = 0; j < chance.integer({min: 0, max: 2}); j+= 1) { + duplicateEvent = JSON.parse(JSON.stringify(event)); + duplicateEvent.id += `-${j}`; + duplicateEvent.calendarId = chance.integer({min: 1, max: 5}).toString(); + duplicateEvent.goingDuration = 30 * chance.integer({min: 0, max: 4}); + duplicateEvent.comingDuration = 30 * chance.integer({min: 0, max: 4}); + events.push(duplicateEvent); + } + } } }); diff --git a/apps/calendar/examples/styles/app.css b/apps/calendar/examples/styles/app.css index adbc69e12..3bf0c968f 100644 --- a/apps/calendar/examples/styles/app.css +++ b/apps/calendar/examples/styles/app.css @@ -81,6 +81,15 @@ font-size: 1.25rem; } +.navbar .nav-checkbox { + margin-left: auto; +} + +input:disabled + label { + color: #ccc; + cursor: not-allowed; +} + .toastui-calendar-template-time strong { color: inherit; } @@ -93,7 +102,7 @@ position: relative; } -.checkbox:not(.checkbox-all)::before { +.checkbox-calendar::before { content: ""; position: absolute; left: -1.5rem; diff --git a/apps/calendar/playwright/configs.ts b/apps/calendar/playwright/configs.ts index 97a68fabb..dd4aded6e 100644 --- a/apps/calendar/playwright/configs.ts +++ b/apps/calendar/playwright/configs.ts @@ -7,10 +7,14 @@ export const DAY_VIEW_PAGE_URL = generatePageUrl('e2e-day-view--fixed-events'); export const WEEK_VIEW_PAGE_URL = generatePageUrl('e2e-week-view--fixed-events'); -export const MONTH_VIEW_EMPTY_PAGE_URL = generatePageUrl('e2e-month-view--empty'); - -export const MONTH_VIEW_PAGE_URL = generatePageUrl('e2e-month-view--fixed-events'); - export const WEEK_VIEW_TIMEZONE_PAGE_URL = generatePageUrl( 'e2e-week-view--different-primary-timezone' ); + +export const WEEK_VIEW_DUPLICATE_EVENTS_PAGE_URL = generatePageUrl( + 'e2e-week-view--duplicate-events' +); + +export const MONTH_VIEW_EMPTY_PAGE_URL = generatePageUrl('e2e-month-view--empty'); + +export const MONTH_VIEW_PAGE_URL = generatePageUrl('e2e-month-view--fixed-events'); diff --git a/apps/calendar/playwright/utils.ts b/apps/calendar/playwright/utils.ts index 4adbea223..ff32e637f 100644 --- a/apps/calendar/playwright/utils.ts +++ b/apps/calendar/playwright/utils.ts @@ -68,7 +68,7 @@ export async function getBoundingBox(locator: Locator): Promise { } export function getTimeEventSelector(title: string): string { - return `[data-testid^="time-event-${title}"]`; + return `[data-testid^="time-event-${title}-"]`; } export function getGuideTimeEventSelector(): string { diff --git a/apps/calendar/playwright/week/timeGridEventClick.e2e.ts b/apps/calendar/playwright/week/timeGridEventClick.e2e.ts index c35b557f8..d52884b2f 100644 --- a/apps/calendar/playwright/week/timeGridEventClick.e2e.ts +++ b/apps/calendar/playwright/week/timeGridEventClick.e2e.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { mockWeekViewEvents } from '../../stories/mocks/mockWeekViewEvents'; -import { WEEK_VIEW_PAGE_URL } from '../configs'; +import { WEEK_VIEW_DUPLICATE_EVENTS_PAGE_URL, WEEK_VIEW_PAGE_URL } from '../configs'; import { getBoundingBox, getTimeEventSelector } from '../utils'; test.beforeEach(async ({ page }) => { @@ -9,7 +9,6 @@ test.beforeEach(async ({ page }) => { }); const targetEvents = mockWeekViewEvents.filter(({ isAllday }) => !isAllday); - targetEvents.forEach(({ title }) => { test(`Click event: show popup when ${title} is clicked`, async ({ page }) => { // Given @@ -27,3 +26,122 @@ targetEvents.forEach(({ title }) => { await expect(detailPopup).toBeVisible(); }); }); + +test.describe('Collapse duplicate events', () => { + test.beforeEach(async ({ page }) => { + await page.goto(WEEK_VIEW_DUPLICATE_EVENTS_PAGE_URL); + }); + + const collapsedEvents = mockWeekViewEvents.filter(({ title }) => + title.match(/duplicate event(\s\d)?$/) + ); + const [collapsedEvent] = collapsedEvents; + + test('The duplicate events are sorted according to the result of getDuplicateEvents option.', ({ + page, + }) => { + // Given + // getDuplicateEvents: sort by calendarId in descending order + const sortedDuplicateEvents = mockWeekViewEvents + .filter(({ title }) => title.startsWith('duplicate event 2')) + .sort((a, b) => (b.calendarId > a.calendarId ? 1 : -1)); + let prevX = -1; + + // When + // Nothing + + // Then + sortedDuplicateEvents.forEach(async (event) => { + const eventLocator = page.locator(getTimeEventSelector(event.title)); + const { x } = await getBoundingBox(eventLocator); + expect(prevX).toBeLessThan(x); + + prevX = x; + }); + }); + + collapsedEvents.forEach((event) => { + test(`When clicking the collapsed duplicate event, it should be expanded. - ${event.title}`, async ({ + page, + }) => { + // Given + const collapsedEventLocator = page.locator(getTimeEventSelector(event.title)); + const { x, y, width: widthBeforeClick } = await getBoundingBox(collapsedEventLocator); + const mainEventLocator = page.locator(getTimeEventSelector(`${event.title} - main`)); + const { width: mainEventWidth } = await getBoundingBox(mainEventLocator); + + // When + await page.mouse.move(x + 2, y + 2); + await page.mouse.down(); + await page.mouse.up(); + + // Then + const { width: widthAfterClick } = await getBoundingBox(collapsedEventLocator); + expect(widthAfterClick).toBeGreaterThan(widthBeforeClick); + expect(widthAfterClick).toBeCloseTo(mainEventWidth, -1); + }); + }); + + const otherEvents = mockWeekViewEvents.filter(({ title }) => { + return ( + title === 'duplicate event with durations' || // duplicate event in the same duplicate event group + title === 'duplicate event 2' || // duplicate event but not in the same duplicate event group + title === 'short time event' // normal event + ); + }); + otherEvents.forEach((otherEvent) => { + test(`When clicking the other event (title: ${otherEvent.title}), the previous expanded event should be collapsed.`, async ({ + page, + }) => { + // Given + const collapsedEventLocator = page.locator(getTimeEventSelector(collapsedEvent.title)); + const { x, y, width: widthBeforeClick } = await getBoundingBox(collapsedEventLocator); + await page.mouse.move(x + 2, y + 2); + await page.mouse.down(); + await page.mouse.up(); + + // When + const otherEventLocator = page.locator(getTimeEventSelector(otherEvent.title)); + const { + x: otherX, + y: otherY, + width: otherWidthBeforeClick, + } = await getBoundingBox(otherEventLocator); + await page.mouse.move(otherX + 2, otherY + 2); + await page.mouse.down(); + await page.mouse.up(); + + // Then + const { width: widthAfterClick } = await getBoundingBox(collapsedEventLocator); + const { width: otherWidthAfterClick } = await getBoundingBox(otherEventLocator); + expect(widthAfterClick).toBeCloseTo(widthBeforeClick, -1); + + if (otherEvent.title.includes('duplicate')) { + // if the next clicked event is duplicate, it should be expanded. + expect(otherWidthAfterClick).toBeGreaterThan(otherWidthBeforeClick); + } + }); + }); + + test('When clicking one day of a two-day duplicate event, the other day also should be expanded.', async ({ + page, + }) => { + // Given + const longCollapsedEventTitle = 'duplicate long event'; + const longCollapsedEventLocator = page.locator(getTimeEventSelector(longCollapsedEventTitle)); + const firstDayLocator = longCollapsedEventLocator.first(); + const lastDayLocator = longCollapsedEventLocator.last(); + const { x, y, width: widthBeforeClick } = await getBoundingBox(firstDayLocator); + + // When + await page.mouse.move(x + 2, y + 2); + await page.mouse.down(); + await page.mouse.up(); + + // Then + const { width: widthAfterClick } = await getBoundingBox(firstDayLocator); + const { width: widthAfterClickOnLastDay } = await getBoundingBox(lastDayLocator); + expect(widthAfterClick).toBeGreaterThan(widthBeforeClick); + expect(widthAfterClickOnLastDay).toBeCloseTo(widthAfterClick); + }); +}); diff --git a/apps/calendar/src/components/events/timeEvent.tsx b/apps/calendar/src/components/events/timeEvent.tsx index fe6d3e8a9..9db6d8c8f 100644 --- a/apps/calendar/src/components/events/timeEvent.tsx +++ b/apps/calendar/src/components/events/timeEvent.tsx @@ -2,10 +2,12 @@ import { h } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; import { Template } from '@src/components/template'; +import { DEFAULT_DUPLICATE_EVENT_CID } from '@src/constants/layout'; +import { TIME_EVENT_CONTAINER_MARGIN_LEFT } from '@src/constants/style'; import { useDispatch, useStore } from '@src/contexts/calendarStore'; import { useEventBus } from '@src/contexts/eventBus'; import { useLayoutContainer } from '@src/contexts/layoutContainer'; -import { cls, getEventColors, toPercent } from '@src/helpers/css'; +import { cls, extractPercentPx, getEventColors, toPercent } from '@src/helpers/css'; import { DRAGGING_TYPE_CREATORS } from '@src/helpers/drag'; import { useCalendarColor } from '@src/hooks/calendar/useCalendarColor'; import { useDrag } from '@src/hooks/common/useDrag'; @@ -14,7 +16,7 @@ import type EventUIModel from '@src/model/eventUIModel'; import { dndSelector, optionsSelector } from '@src/selectors'; import { DraggingState } from '@src/slices/dnd'; import type TZDate from '@src/time/date'; -import { isPresent } from '@src/utils/type'; +import { isPresent, isString } from '@src/utils/type'; import type { StyleProp } from '@t/components/common'; import type { CalendarColor } from '@t/options'; @@ -35,6 +37,23 @@ interface Props { minHeight?: number; } +function getMarginLeft(left: number | string) { + const { percent, px } = extractPercentPx(`${left}`); + + return left > 0 || percent > 0 || px > 0 ? TIME_EVENT_CONTAINER_MARGIN_LEFT : 0; +} + +function getContainerWidth(width: number | string, marginLeft: number) { + if (isString(width)) { + return width; + } + if (width >= 0) { + return `calc(${toPercent(width)} - ${marginLeft}px)`; + } + + return ''; +} + function getStyles({ uiModel, isDraggingTarget, @@ -53,6 +72,8 @@ function getStyles({ left, height, width, + duplicateLeft, + duplicateWidth, goingDurationHeight, modelDurationHeight, comingDurationHeight, @@ -62,19 +83,18 @@ function getStyles({ // TODO: check and get theme values const travelBorderColor = 'white'; const borderRadius = 2; - const paddingLeft = 2; const defaultMarginBottom = 2; - const marginLeft = left > 0 ? paddingLeft : 0; + const marginLeft = getMarginLeft(left); const { color, backgroundColor, borderColor, dragBackgroundColor } = getEventColors( uiModel, calendarColor ); const containerStyle: StyleProp = { - width: width >= 0 ? `calc(${toPercent(width)} - ${marginLeft}px)` : '', + width: getContainerWidth(duplicateWidth || width, marginLeft), height: `calc(${toPercent(Math.max(height, minHeight))} - ${defaultMarginBottom}px)`, top: toPercent(top), - left: toPercent(left), + left: duplicateLeft || toPercent(left), borderRadius, borderLeft: `3px solid ${borderColor}`, marginLeft, @@ -83,6 +103,7 @@ function getStyles({ opacity: isDraggingTarget ? 0.5 : 1, zIndex: hasNextStartTime ? 1 : 0, }; + const goingDurationStyle = { height: toPercent(goingDurationHeight), borderBottom: `1px dashed ${travelBorderColor}`, @@ -135,12 +156,18 @@ export function TimeEvent({ isResizingGuide = false, minHeight = 0, }: Props) { - const { useDetailPopup, isReadOnly: isReadOnlyCalendar } = useStore(optionsSelector); + const { + useDetailPopup, + isReadOnly: isReadOnlyCalendar, + week: weekOptions, + } = useStore(optionsSelector); const calendarColor = useCalendarColor(uiModel.model); + const { collapseDuplicateEvents } = weekOptions; const layoutContainer = useLayoutContainer(); const { showDetailPopup } = useDispatch('popup'); const { setDraggingEventUIModel } = useDispatch('dnd'); + const { setSelectedDuplicateEventCid } = useDispatch('weekViewLayout'); const eventBus = useEventBus(); @@ -197,6 +224,12 @@ export function TimeEvent({ endDragEvent(classNames.moveEvent); const isClick = draggingState <= DraggingState.INIT; + if (isClick && collapseDuplicateEvents) { + const selectedDuplicateEventCid = + uiModel.duplicateEvents.length > 0 ? uiModel.cid() : DEFAULT_DUPLICATE_EVENT_CID; + setSelectedDuplicateEventCid(selectedDuplicateEventCid); + } + if (isClick && useDetailPopup && eventContainerRef.current) { showDetailPopup( { diff --git a/apps/calendar/src/components/timeGrid/timeGrid.tsx b/apps/calendar/src/components/timeGrid/timeGrid.tsx index 238447b07..448e32645 100644 --- a/apps/calendar/src/components/timeGrid/timeGrid.tsx +++ b/apps/calendar/src/components/timeGrid/timeGrid.tsx @@ -31,6 +31,7 @@ import { toEndOfDay, toStartOfDay, } from '@src/time/datetime'; +import type { CollapseDuplicateEventsOptions } from '@src/types/options'; import { first, last } from '@src/utils/array'; import { passConditionalProp } from '@src/utils/preact'; import { isPresent } from '@src/utils/type'; @@ -50,9 +51,12 @@ interface Props { export function TimeGrid({ timeGridData, events }: Props) { const { isReadOnly, - week: { narrowWeekend, startDayOfWeek }, + week: { narrowWeekend, startDayOfWeek, collapseDuplicateEvents }, } = useStore(optionsSelector); const showNowIndicator = useStore(showNowIndicatorOptionSelector); + const selectedDuplicateEventCid = useStore( + (state) => state.weekViewLayout.selectedDuplicateEventCid + ); const [, getNow] = usePrimaryTimezone(); const isMounted = useIsMounted(); @@ -79,10 +83,12 @@ export function TimeGrid({ timeGridData, events }: Props) { setRenderInfoOfUIModels( uiModelsByColumn, setTimeStrToDate(columns[columnIndex].date, first(rows).startTime), - setTimeStrToDate(columns[columnIndex].date, last(rows).endTime) + setTimeStrToDate(columns[columnIndex].date, last(rows).endTime), + selectedDuplicateEventCid, + collapseDuplicateEvents as CollapseDuplicateEventsOptions ) ), - [columns, rows, events] + [columns, rows, events, selectedDuplicateEventCid, collapseDuplicateEvents] ); const currentDateData = useMemo(() => { diff --git a/apps/calendar/src/constants/layout.ts b/apps/calendar/src/constants/layout.ts index 155c98882..72a5fffbe 100644 --- a/apps/calendar/src/constants/layout.ts +++ b/apps/calendar/src/constants/layout.ts @@ -1 +1,3 @@ export const DEFAULT_RESIZER_LENGTH = 3; + +export const DEFAULT_DUPLICATE_EVENT_CID = -1; diff --git a/apps/calendar/src/constants/style.ts b/apps/calendar/src/constants/style.ts index cafe836aa..20bc89d09 100644 --- a/apps/calendar/src/constants/style.ts +++ b/apps/calendar/src/constants/style.ts @@ -46,3 +46,7 @@ export const DEFAULT_EVENT_COLORS = { dragBackgroundColor: '#a1b56c', borderColor: '#000', }; + +export const TIME_EVENT_CONTAINER_MARGIN_LEFT = 2; + +export const COLLAPSED_DUPLICATE_EVENT_WIDTH_PX = 9; diff --git a/apps/calendar/src/controller/column.spec.ts b/apps/calendar/src/controller/column.spec.ts new file mode 100644 index 000000000..c7b3567de --- /dev/null +++ b/apps/calendar/src/controller/column.spec.ts @@ -0,0 +1,153 @@ +import { DEFAULT_DUPLICATE_EVENT_CID } from '@src/constants/layout'; +import { + COLLAPSED_DUPLICATE_EVENT_WIDTH_PX, + TIME_EVENT_CONTAINER_MARGIN_LEFT, +} from '@src/constants/style'; +import { setRenderInfoOfUIModels } from '@src/controller/column'; +import EventModel from '@src/model/eventModel'; +import EventUIModel from '@src/model/eventUIModel'; +import TZDate from '@src/time/date'; +import type { EventObject, EventObjectWithDefaultValues } from '@src/types/events'; +import type { CollapseDuplicateEventsOptions } from '@src/types/options'; + +function createEventUIModels(data: EventObject[]): EventUIModel[] { + return data.map((datum) => new EventUIModel(new EventModel(datum))); +} + +function createCurrentDateWithTime(h: number, m: number) { + const today = new TZDate(); + today.setHours(h, m, 0, 0); + + return today; +} + +describe('collapseDuplicateEvents option', () => { + let eventUIModels: EventUIModel[]; + + beforeEach(() => { + eventUIModels = createEventUIModels([ + { + id: '1', + calendarId: 'cal1', + title: 'duplicate event', + start: createCurrentDateWithTime(3, 0), + end: createCurrentDateWithTime(4, 0), + }, + { + id: '2', + calendarId: 'cal2', + title: 'duplicate event', + start: createCurrentDateWithTime(3, 0), + end: createCurrentDateWithTime(4, 0), + goingDuration: 60, + }, + { + id: '3', + calendarId: 'cal3', + title: 'duplicate event', + start: createCurrentDateWithTime(3, 0), + end: createCurrentDateWithTime(4, 0), + goingDuration: 30, + comingDuration: 60, + }, + ]); + }); + + const startColumnTime = createCurrentDateWithTime(0, 0); + const endColumnTime = createCurrentDateWithTime(24, 0); + + it('when it is false, duplicate events have the same widths.', () => { + // Given + // nothing + + // When + const result = setRenderInfoOfUIModels( + eventUIModels, + startColumnTime, + endColumnTime, + DEFAULT_DUPLICATE_EVENT_CID + ); + + // Then + result.forEach((uiModel) => { + expect(uiModel.width).toBeCloseTo(100 / result.length, 0); + }); + }); + + it('when it sets, the main event is expanded and the others are collapsed.', () => { + // Given + const mainEventId = eventUIModels[0].model.id; + const collapseDuplicateEventsOptions: CollapseDuplicateEventsOptions = { + getDuplicateEvents(targetEvent, events) { + return events + .filter((event) => event.title === targetEvent.title) + .sort((a, b) => (a.id > b.id ? 1 : -1)); + }, + getMainEvent(events) { + return events.find((event) => event.id === mainEventId) as EventObjectWithDefaultValues; + }, + }; + + // When + const result = setRenderInfoOfUIModels( + eventUIModels, + startColumnTime, + endColumnTime, + DEFAULT_DUPLICATE_EVENT_CID, + collapseDuplicateEventsOptions + ); + + // Then + result.forEach((uiModel) => { + const expected = + uiModel.model.id === mainEventId + ? `calc(100% - ${ + (COLLAPSED_DUPLICATE_EVENT_WIDTH_PX + TIME_EVENT_CONTAINER_MARGIN_LEFT) * + (result.length - 1) + + TIME_EVENT_CONTAINER_MARGIN_LEFT + }px)` + : `${COLLAPSED_DUPLICATE_EVENT_WIDTH_PX}px`; + + expect(uiModel.duplicateWidth).toBe(expected); + }); + }); + + it('when it sets and one of the duplicate events is selected, the selected event is expanded and the others are collapsed.', () => { + // Given + const selectedDuplicateEventCid = eventUIModels[1].cid(); + const mainEventId = eventUIModels[0].model.id; + const collapseDuplicateEventsOptions: CollapseDuplicateEventsOptions = { + getDuplicateEvents(targetEvent, events) { + return events + .filter((event) => event.title === targetEvent.title) + .sort((a, b) => (a.id > b.id ? 1 : -1)); + }, + getMainEvent(events) { + return events.find((event) => event.id === mainEventId) as EventObjectWithDefaultValues; + }, + }; + + // When + const result = setRenderInfoOfUIModels( + eventUIModels, + startColumnTime, + endColumnTime, + selectedDuplicateEventCid, + collapseDuplicateEventsOptions + ); + + // Then + result.forEach((uiModel) => { + const expected = + uiModel.cid() === selectedDuplicateEventCid + ? `calc(100% - ${ + (COLLAPSED_DUPLICATE_EVENT_WIDTH_PX + TIME_EVENT_CONTAINER_MARGIN_LEFT) * + (result.length - 1) + + TIME_EVENT_CONTAINER_MARGIN_LEFT + }px)` + : `${COLLAPSED_DUPLICATE_EVENT_WIDTH_PX}px`; + + expect(uiModel.duplicateWidth).toBe(expected); + }); + }); +}); diff --git a/apps/calendar/src/controller/column.ts b/apps/calendar/src/controller/column.ts index aabcfa129..ae8960640 100644 --- a/apps/calendar/src/controller/column.ts +++ b/apps/calendar/src/controller/column.ts @@ -1,11 +1,17 @@ +import { DEFAULT_DUPLICATE_EVENT_CID } from '@src/constants/layout'; +import { + COLLAPSED_DUPLICATE_EVENT_WIDTH_PX, + TIME_EVENT_CONTAINER_MARGIN_LEFT, +} from '@src/constants/style'; import { createEventCollection } from '@src/controller/base'; import { getCollisionGroup, getMatrices } from '@src/controller/core'; import { getTopHeightByTime } from '@src/controller/times'; -import { getCollides } from '@src/controller/week'; +import { extractPercentPx, toPercent, toPx } from '@src/helpers/css'; import { isTimeEvent } from '@src/model/eventModel'; import type EventUIModel from '@src/model/eventUIModel'; import type TZDate from '@src/time/date'; import { addMinutes, max, min } from '@src/time/datetime'; +import type { CollapseDuplicateEventsOptions } from '@src/types/options'; import array from '@src/utils/array'; const MIN_HEIGHT_PERCENT = 1; @@ -81,23 +87,72 @@ function setCroppedEdges(uiModel: EventUIModel, options: RenderInfoOptions) { } } +function getDuplicateLeft(uiModel: EventUIModel, baseLeft: number) { + const { duplicateEvents, duplicateEventIndex } = uiModel; + + const prevEvent = duplicateEvents[duplicateEventIndex - 1]; + let left: number | string = baseLeft; + if (prevEvent) { + // duplicateLeft = prevEvent.duplicateLeft + prevEvent.duplicateWidth + marginLeft + const { percent: leftPercent, px: leftPx } = extractPercentPx(`${prevEvent.duplicateLeft}`); + const { percent: widthPercent, px: widthPx } = extractPercentPx(`${prevEvent.duplicateWidth}`); + const percent = leftPercent + widthPercent; + const px = leftPx + widthPx + TIME_EVENT_CONTAINER_MARGIN_LEFT; + + if (percent !== 0) { + left = `calc(${toPercent(percent)} ${px > 0 ? '+' : '-'} ${toPx(Math.abs(px))})`; + } else { + left = toPx(px); + } + } else { + left = toPercent(left); + } + + return left; +} + +function getDuplicateWidth(uiModel: EventUIModel, baseWidth: number) { + const { collapse } = uiModel; + + // if it is collapsed, (COLLAPSED_DUPLICATE_EVENT_WIDTH_PX)px + // if it is expanded, (baseWidth)% - (other duplicate events' width + marginLeft)px - (its marginLeft)px + return collapse + ? `${COLLAPSED_DUPLICATE_EVENT_WIDTH_PX}px` + : `calc(${toPercent(baseWidth)} - ${toPx( + (COLLAPSED_DUPLICATE_EVENT_WIDTH_PX + TIME_EVENT_CONTAINER_MARGIN_LEFT) * + (uiModel.duplicateEvents.length - 1) + + TIME_EVENT_CONTAINER_MARGIN_LEFT + )})`; +} + function setDimension(uiModel: EventUIModel, options: RenderInfoOptions) { - const { renderStart, renderEnd, startColumnTime, endColumnTime, baseWidth, columnIndex } = + const { startColumnTime, endColumnTime, baseWidth, columnIndex, renderStart, renderEnd } = options; + const { duplicateEvents } = uiModel; const { top, height } = getTopHeightByTime( renderStart, renderEnd, startColumnTime, endColumnTime ); - const left = baseWidth * columnIndex; - uiModel.top = top; - uiModel.left = left; - uiModel.width = baseWidth; - uiModel.height = height < MIN_HEIGHT_PERCENT ? MIN_HEIGHT_PERCENT : height; + const dimension = { + top, + left: baseWidth * columnIndex, + width: baseWidth, + height: Math.max(MIN_HEIGHT_PERCENT, height), + duplicateLeft: '', + duplicateWidth: '', + }; + + if (duplicateEvents.length > 0) { + dimension.duplicateLeft = getDuplicateLeft(uiModel, dimension.left); + dimension.duplicateWidth = getDuplicateWidth(uiModel, dimension.width); + } + + uiModel.setUIProps(dimension); } -function setRenderInfo( +function getRenderInfoOptions( uiModel: EventUIModel, columnIndex: number, baseWidth: number, @@ -111,7 +166,8 @@ function setRenderInfo( const comingEnd = addMinutes(modelEnd, comingDuration); const renderStart = max(goingStart, startColumnTime); const renderEnd = min(comingEnd, endColumnTime); - const renderInfoOptions = { + + return { baseWidth, columnIndex, modelStart, @@ -122,13 +178,112 @@ function setRenderInfo( comingEnd, startColumnTime, endColumnTime, + duplicateEvents: uiModel.duplicateEvents, }; +} + +function setRenderInfo({ + uiModel, + columnIndex, + baseWidth, + startColumnTime, + endColumnTime, + isDuplicateEvent = false, +}: { + uiModel: EventUIModel; + columnIndex: number; + baseWidth: number; + startColumnTime: TZDate; + endColumnTime: TZDate; + isDuplicateEvent?: boolean; +}) { + if (!isDuplicateEvent && uiModel.duplicateEvents.length > 0) { + uiModel.duplicateEvents.forEach((event) => { + setRenderInfo({ + uiModel: event, + columnIndex, + baseWidth, + startColumnTime, + endColumnTime, + isDuplicateEvent: true, + }); + }); + + return; + } + + const renderInfoOptions = getRenderInfoOptions( + uiModel, + columnIndex, + baseWidth, + startColumnTime, + endColumnTime + ); setDimension(uiModel, renderInfoOptions); setInnerHeights(uiModel, renderInfoOptions); setCroppedEdges(uiModel, renderInfoOptions); } +function setDuplicateEvents( + uiModels: EventUIModel[], + options: CollapseDuplicateEventsOptions, + selectedDuplicateEventCid: number +) { + const { getDuplicateEvents, getMainEvent } = options; + + const eventObjects = uiModels.map((uiModel) => uiModel.model.toEventObject()); + + uiModels.forEach((targetUIModel) => { + if (targetUIModel.collapse || targetUIModel.duplicateEvents.length > 0) { + return; + } + + const duplicateEvents = getDuplicateEvents(targetUIModel.model.toEventObject(), eventObjects); + + if (duplicateEvents.length <= 1) { + return; + } + + const mainEvent = getMainEvent(duplicateEvents); + + const duplicateEventUIModels = duplicateEvents.map( + (event) => uiModels.find((uiModel) => uiModel.cid() === event.__cid) as EventUIModel + ); + const isSelectedGroup = !!( + selectedDuplicateEventCid > DEFAULT_DUPLICATE_EVENT_CID && + duplicateEvents.find((event) => event.__cid === selectedDuplicateEventCid) + ); + const duplicateStarts = duplicateEvents.reduce((acc, { start, goingDuration }) => { + const renderStart = addMinutes(start, -goingDuration); + return min(acc, renderStart); + }, duplicateEvents[0].start); + const duplicateEnds = duplicateEvents.reduce((acc, { end, comingDuration }) => { + const renderEnd = addMinutes(end, comingDuration); + return max(acc, renderEnd); + }, duplicateEvents[0].end); + + duplicateEventUIModels.forEach((event, index) => { + const isMain = event.cid() === mainEvent.__cid; + const collapse = !( + (isSelectedGroup && event.cid() === selectedDuplicateEventCid) || + (!isSelectedGroup && isMain) + ); + + event.setUIProps({ + duplicateEvents: duplicateEventUIModels, + duplicateEventIndex: index, + collapse, + isMain, + duplicateStarts, + duplicateEnds, + }); + }); + }); + + return uiModels; +} + /** * Convert to EventUIModel and make rendering information of events * @param {EventUIModel[]} events - event list @@ -138,24 +293,32 @@ function setRenderInfo( export function setRenderInfoOfUIModels( events: EventUIModel[], startColumnTime: TZDate, - endColumnTime: TZDate + endColumnTime: TZDate, + selectedDuplicateEventCid: number, + collapseDuplicateEventsOptions?: CollapseDuplicateEventsOptions ) { const uiModels: EventUIModel[] = events .filter(isTimeEvent) .filter(isBetween(startColumnTime, endColumnTime)) .sort(array.compare.event.asc); - const uiModelColl = createEventCollection(...uiModels); + + if (collapseDuplicateEventsOptions) { + setDuplicateEvents(uiModels, collapseDuplicateEventsOptions, selectedDuplicateEventCid); + } + const expandedEvents = uiModels.filter((uiModel) => !uiModel.collapse); + + const uiModelColl = createEventCollection(...expandedEvents); const usingTravelTime = true; - const collisionGroups = getCollisionGroup(uiModels, usingTravelTime); - const matrices = getCollides(getMatrices(uiModelColl, collisionGroups, usingTravelTime)); + const collisionGroups = getCollisionGroup(expandedEvents, usingTravelTime); + const matrices = getMatrices(uiModelColl, collisionGroups, usingTravelTime); matrices.forEach((matrix) => { const maxRowLength = Math.max(...matrix.map((row) => row.length)); - const baseWidth = 100 / maxRowLength; + const baseWidth = Math.round(100 / maxRowLength); matrix.forEach((row) => { - row.forEach((uiModel, col) => { - setRenderInfo(uiModel, col, baseWidth, startColumnTime, endColumnTime); + row.forEach((uiModel, columnIndex) => { + setRenderInfo({ uiModel, columnIndex, baseWidth, startColumnTime, endColumnTime }); }); }); }); diff --git a/apps/calendar/src/controller/week.spec.ts b/apps/calendar/src/controller/week.spec.ts index e633951ac..349149530 100644 --- a/apps/calendar/src/controller/week.spec.ts +++ b/apps/calendar/src/controller/week.spec.ts @@ -1,22 +1,13 @@ import { addToMatrix, createEvent, createEventCollection } from '@src/controller/base'; -import { - _makeHourRangeFilter, - findByDateRange, - generateTimeArrayInRow, - hasCollision, - splitEventByDateRange, -} from '@src/controller/week'; +import { _makeHourRangeFilter, findByDateRange, splitEventByDateRange } from '@src/controller/week'; import EventModel from '@src/model/eventModel'; import type EventUIModel from '@src/model/eventUIModel'; import TZDate from '@src/time/date'; -import { MS_EVENT_MIN_DURATION } from '@src/time/datetime'; import Collection from '@src/utils/collection'; import type { CalendarData, EventObject, Matrix3d, TimeGridEventMatrix } from '@t/events'; import type { Panel } from '@t/panel'; -const SCHEDULE_MIN_DURATION = MS_EVENT_MIN_DURATION; - describe('Base.Week', () => { let calendarData: CalendarData; let mockData: EventObject[]; @@ -108,66 +99,6 @@ describe('Base.Week', () => { ]; }); - describe('hasCollision()', () => { - let supplied: Array; - - beforeEach(() => { - supplied = [ - [2, 5], - [8, 11], - [14, 17], - ]; - }); - - it('return false when supplied empty array', () => { - expect(hasCollision([], 3, 4)).toBe(false); - }); - - it('calculate collision information properly.', () => { - expect(hasCollision(supplied, 6, 7)).toBe(false); - }); - }); - - describe('generateTimeArrayInRow()', () => { - /** - * |---|---| - * | 1 | 2 | - * |---|---| - * | 3 | 5 | - * |---|---| - * | 4 | | - * |---|---| - * - * to - * - * [ - * [[2.start, 2.end], [5.start, 5.end]] - * ] - */ - - let supplied: Array; - let expected: Array>; - - function getTime(start: number, end: number) { - return new EventModel({ start, end }); - } - - beforeEach(() => { - supplied = [[getTime(1, 2), getTime(1, 2)], [getTime(4, 5), getTime(5, 6)], [getTime(7, 8)]]; - - expected = [ - [ - [1, 2 + SCHEDULE_MIN_DURATION], - [5, 6 + SCHEDULE_MIN_DURATION], - ], - ]; - }); - - it('get rowmap properly.', () => { - expect(generateTimeArrayInRow(supplied)).toEqual(expected); - }); - }); - describe('findByDateRange', () => { let panels: Panel[]; diff --git a/apps/calendar/src/controller/week.ts b/apps/calendar/src/controller/week.ts index aeacb3fa7..63e0076c9 100644 --- a/apps/calendar/src/controller/week.ts +++ b/apps/calendar/src/controller/week.ts @@ -10,15 +10,7 @@ import { import type EventModel from '@src/model/eventModel'; import type EventUIModel from '@src/model/eventUIModel'; import TZDate from '@src/time/date'; -import { - makeDateRange, - millisecondsFrom, - MS_EVENT_MIN_DURATION, - MS_PER_DAY, - toEndOfDay, - toFormat, - toStartOfDay, -} from '@src/time/datetime'; +import { toEndOfDay, toFormat, toStartOfDay } from '@src/time/datetime'; import array from '@src/utils/array'; import type { Filter } from '@src/utils/collection'; import Collection from '@src/utils/collection'; @@ -29,7 +21,6 @@ import type { DayGridEventMatrix, EventGroupMap, IDS_OF_DAY, - Matrix, Matrix3d, } from '@t/events'; import type { WeekOptions } from '@t/options'; @@ -39,119 +30,6 @@ import type { Panel } from '@t/panel'; * TIME GRID VIEW **********/ -/** - * Make array with start and end times on events. - * @param {Matrix} matrix - matrix from controller. - * @returns {Matrix3d} starttime, endtime array (exclude first row's events) - */ -export function generateTimeArrayInRow(matrix: Matrix) { - const map: Matrix3d = []; - const maxColLen = Math.max(...matrix.map((col) => col.length)); - let cursor = []; - let row; - let col; - let event; - let start; - let end; - - for (col = 1; col < maxColLen; col += 1) { - row = 0; - event = matrix?.[row]?.[col]; - - while (event) { - const { goingDuration, comingDuration } = event.valueOf(); - start = event.getStarts().getTime() - millisecondsFrom('minute', goingDuration); - end = event.getEnds().getTime() + millisecondsFrom('minute', comingDuration); - - if (Math.abs(end - start) < MS_EVENT_MIN_DURATION) { - end += MS_EVENT_MIN_DURATION; - } - - cursor.push([start, end]); - - row += 1; - event = matrix?.[row]?.[col]; - } - - map.push(cursor); - cursor = []; - } - - return map; -} - -function searchFunc(index: number) { - return (block: any[]) => block[index]; -} - -/** - * Get collision information from list - * @param {array.} arr - list to detecting collision. [[start, end], [start, end]] - * @param {number} start - event start time that want to detect collisions. - * @param {number} end - event end time that want to detect collisions. - * @returns {boolean} target has collide in supplied array? - */ -export function hasCollision(arr: Array, start: number, end: number) { - if (!arr?.length) { - return false; - } - - const compare = array.compare.num.asc; - - const startStart = Math.abs(array.bsearch(arr, start, searchFunc(0), compare)); - const startEnd = Math.abs(array.bsearch(arr, start, searchFunc(1), compare)); - const endStart = Math.abs(array.bsearch(arr, end, searchFunc(0), compare)); - const endEnd = Math.abs(array.bsearch(arr, end, searchFunc(1), compare)); - - return !(startStart === startEnd && startEnd === endStart && endStart === endEnd); -} - -/** - * Initialize values to ui models for detect real collision at rendering phase. - * @param {array[]} matrices - Matrix data. - * @returns {array[]} matrices - Matrix data with collision information - */ -export function getCollides(matrices: Matrix3d) { - matrices.forEach((matrix) => { - const binaryMap = generateTimeArrayInRow(matrix); - const maxRowLength = Math.max(...matrix.map((row) => row.length)); - - matrix.forEach((row) => { - row.forEach((uiModel, col) => { - if (!uiModel) { - return; - } - - const { goingDuration, comingDuration } = uiModel.model; - let startTime = uiModel.getStarts().getTime(); - let endTime = uiModel.getEnds().getTime(); - - if (Math.abs(endTime - startTime) < MS_EVENT_MIN_DURATION) { - endTime += MS_EVENT_MIN_DURATION; - } - - startTime -= millisecondsFrom('minute', goingDuration); - endTime += millisecondsFrom('minute', comingDuration); - - endTime -= 1; - - for (let i = col + 1; i < maxRowLength; i += 1) { - const collided = hasCollision(binaryMap[i - 1], startTime, endTime); - - if (collided) { - uiModel.hasCollide = true; - break; - } - - uiModel.extraSpace += 1; - } - }); - }); - }); - - return matrices; -} - /** * make a filter function that is not included range of start, end hour * @param {number} hStart - hour start @@ -275,7 +153,7 @@ export function getUIModelForTimeView( const collisionGroups = getCollisionGroup(uiModels, usingTravelTime); const matrices = getMatrices(uiModelColl, collisionGroups, usingTravelTime); - result[ymd] = getCollides(matrices as Matrix3d); + result[ymd] = matrices as Matrix3d; }); return result; @@ -393,65 +271,3 @@ export function findByDateRange( } ); } - -function getYMD(date: TZDate, format = 'YYYYMMDD') { - return toFormat(date, format); -} - -/* eslint max-nested-callbacks: 0 */ -/** - * Make exceed date information - * @param {number} maxCount - exceed event count - * @param {Matrix3d} eventsInDateRange - matrix of EventUIModel - * @param {Array.} range - date range of one week - * @returns {object} exceedDate - */ -export function getExceedDate( - maxCount: number, - eventsInDateRange: Matrix3d, - range: TZDate[] -) { - const exceedDate: Record = {}; - - range.forEach((date) => { - const ymd = getYMD(date); - exceedDate[ymd] = 0; - }); - - eventsInDateRange.forEach((matrix) => { - matrix.forEach((column) => { - column.forEach((uiModel) => { - if (!uiModel || uiModel.top < maxCount) { - return; - } - - const period = makeDateRange(uiModel.getStarts(), uiModel.getEnds(), MS_PER_DAY); - - period.forEach((date) => { - const ymd = getYMD(date); - exceedDate[ymd] += 1; - }); - }); - }); - }); - - return exceedDate; -} - -/** - * Exclude overflow events from matrices - * @param {Matrix3d} matrices - The matrices for event placing. - * @param {number} visibleEventCount - maximum visible count on panel - * @returns {array} - The matrices for event placing except overflowed events. - */ -export function excludeExceedEvents(matrices: Matrix3d, visibleEventCount: number) { - return matrices.map((matrix) => { - return matrix.map((row) => { - if (row.length > visibleEventCount) { - return row.filter((item) => item.top < visibleEventCount); - } - - return row; - }); - }); -} diff --git a/apps/calendar/src/factory/__snapshots__/calendarCore.spec.tsx.snap b/apps/calendar/src/factory/__snapshots__/calendarCore.spec.tsx.snap index 7a0228436..50601a84e 100644 --- a/apps/calendar/src/factory/__snapshots__/calendarCore.spec.tsx.snap +++ b/apps/calendar/src/factory/__snapshots__/calendarCore.spec.tsx.snap @@ -198,6 +198,7 @@ Object { "useDetailPopup": false, "useFormPopup": false, "week": Object { + "collapseDuplicateEvents": false, "dayNames": Array [], "eventView": true, "hourEnd": 24, diff --git a/apps/calendar/src/factory/calendarCore.tsx b/apps/calendar/src/factory/calendarCore.tsx index f59cb3b29..0e6a2d18d 100644 --- a/apps/calendar/src/factory/calendarCore.tsx +++ b/apps/calendar/src/factory/calendarCore.tsx @@ -53,7 +53,7 @@ import type { ThemeState, ThemeStore } from '@t/theme'; /** * Timezone options of the calendar instance. * - * For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/ko/apis/options.md#timezone|Timezone options} in guide. + * For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/en/apis/options.md#timezone|Timezone options} in guide. * * @typedef {object} TimezoneOptions * @example @@ -127,7 +127,7 @@ import type { ThemeState, ThemeStore } from '@t/theme'; * @class CalendarCore * @mixes CustomEvents * @param {string|Element} container - container element or selector. - * @param {object} options - calendar options. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/ko/apis/calendar.md|Calendar options} in guide. + * @param {object} options - calendar options. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/en/apis/calendar.md|Calendar options} in guide. * @param {string} [options.defaultView="week"] - Initial view type. Available values are: 'day', 'week', 'month'. * @param {boolean} [options.useFormPopup=false] - Whether to use the default form popup when creating/modifying events. * @param {boolean} [options.useDetailPopup=false] - Whether to use the default detail popup when clicking events. @@ -147,6 +147,7 @@ import type { ThemeState, ThemeStore } from '@t/theme'; * @param {boolean} [options.week.narrowWeekend=false] - Whether to narrow down width of weekends to half. * @param {boolean|Array.} [options.week.eventView=true] - Determine which view to display events. Available values are 'allday' and 'time'. set to `false` to disable event view. * @param {boolean|Array.} [options.week.taskView=true] - Determine which view to display tasks. Available values are 'milestone' and 'task'. set to `false` to disable task view. + * @param {boolean|object} [options.week.collapseDuplicateEvents=false] - Whether to collapse duplicate events. If you want to filter duplicate events and choose the main event based on your requirements, set `getDuplicateEvents` and `getMainEvent`. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/en/apis/options.md#weekcollapseduplicateevents|Options} in guide. * @param {object} options.month - Month option of the calendar instance. * @param {number} [options.month.startDayOfWeek=0] - Start day of the week. Available values are 0 (Sunday) to 6 (Saturday). * @param {Array.} [options.month.dayNames] - Names of days of the week. Should be 7 items starting from Sunday to Saturday. If not specified, the default names are used. @@ -158,9 +159,9 @@ import type { ThemeState, ThemeStore } from '@t/theme'; * @param {boolean|object} [options.gridSelection=true] - Whether to enable grid selection. or it's option. it's enabled when the value is `true` and object and will be disabled when `isReadOnly` is true. * @param {boolean} options.gridSelection.enableDbClick - Whether to enable double click to select area. * @param {boolean} options.gridSelection.enableClick - Whether to enable click to select area. - * @param {TimezoneOptions} options.timezone - Timezone option of the calendar instance. For more information about timezone, check out the {@link https://github.com/nhn/tui.calendar/blob/main/docs/ko/apis/options.md|Options} in guide. - * @param {Theme} options.theme - Theme option of the calendar instance. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/ko/apis/theme.md|Theme} in guide. - * @param {TemplateConfig} options.template - Template option of the calendar instance. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/ko/apis/template.md|Template} in guide. + * @param {TimezoneOptions} options.timezone - Timezone option of the calendar instance. For more information about timezone, check out the {@link https://github.com/nhn/tui.calendar/blob/main/docs/en/apis/options.md|Options} in guide. + * @param {Theme} options.theme - Theme option of the calendar instance. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/en/apis/theme.md|Theme} in guide. + * @param {TemplateConfig} options.template - Template option of the calendar instance. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/en/apis/template.md|Template} in guide. */ export default abstract class CalendarCore implements EventBus @@ -723,7 +724,7 @@ export default abstract class CalendarCore /** * Set the theme of the calendar. * - * @param {Theme} theme - The theme object to apply. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/ko/apis/theme.md|Theme} in guide. + * @param {Theme} theme - The theme object to apply. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/en/apis/theme.md|Theme} in guide. * * @example * calendar.setTheme({ @@ -767,7 +768,7 @@ export default abstract class CalendarCore } /** - * Set options of calendar. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/ko/apis/options.md|Options} in guide. + * Set options of calendar. For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/en/apis/options.md|Options} in guide. * * @param {Options} options - The options to set */ diff --git a/apps/calendar/src/helpers/css.ts b/apps/calendar/src/helpers/css.ts index f7bc52b9c..d08f99436 100644 --- a/apps/calendar/src/helpers/css.ts +++ b/apps/calendar/src/helpers/css.ts @@ -42,6 +42,24 @@ export function toPx(value: number) { return `${value}px`; } +/** + * ex) + * extractPercentPx('calc(100% - 22px)') // { percent: 100, px: -22 } + * extractPercentPx('100%') // { percent: 100, px: 0 } + * extractPercentPx('-22px') // { percent: 0, px: -22 } + */ +export function extractPercentPx(value: string) { + const percentRegexp = /(\d+)%/; + const percentResult = value.match(percentRegexp); + const pxRegexp = /(-?)\s?(\d+)px/; + const pxResult = value.match(pxRegexp); + + return { + percent: percentResult ? parseInt(percentResult[1], 10) : 0, + px: pxResult ? parseInt(`${pxResult[1]}${pxResult[2]}`, 10) : 0, + }; +} + export function getEventColors(uiModel: EventUIModel, calendarColor: CalendarColor) { const eventColors = uiModel.model.getColors(); diff --git a/apps/calendar/src/model/eventModel.ts b/apps/calendar/src/model/eventModel.ts index 17765ef64..64f28cee0 100644 --- a/apps/calendar/src/model/eventModel.ts +++ b/apps/calendar/src/model/eventModel.ts @@ -13,7 +13,7 @@ import type { EventState, } from '@t/events'; -export default class EventModel implements EventObjectWithDefaultValues { +export default class EventModel implements Omit { id = ''; calendarId = ''; @@ -302,6 +302,7 @@ export default class EventModel implements EventObjectWithDefaultValues { return { id: this.id, calendarId: this.calendarId, + __cid: this.cid(), title: this.title, body: this.body, isAllday: this.isAllday, diff --git a/apps/calendar/src/model/eventUIModel.ts b/apps/calendar/src/model/eventUIModel.ts index 24c0e704a..6a7e109d9 100644 --- a/apps/calendar/src/model/eventUIModel.ts +++ b/apps/calendar/src/model/eventUIModel.ts @@ -8,9 +8,6 @@ interface EventUIProps { left: number; width: number; height: number; - hasCollide: boolean; - extraSpace: number; - hidden: boolean; exceedLeft: boolean; exceedRight: boolean; croppedStart: boolean; @@ -18,6 +15,14 @@ interface EventUIProps { goingDurationHeight: number; modelDurationHeight: number; comingDurationHeight: number; + duplicateEvents: EventUIModel[]; + duplicateEventIndex: number; + duplicateStarts?: TZDate; + duplicateEnds?: TZDate; + duplicateLeft: string; + duplicateWidth: string; + collapse: boolean; + isMain: boolean; } const eventUIPropsKey: (keyof EventUIProps)[] = [ @@ -25,9 +30,6 @@ const eventUIPropsKey: (keyof EventUIProps)[] = [ 'left', 'width', 'height', - 'hasCollide', - 'extraSpace', - 'hidden', 'exceedLeft', 'exceedRight', 'croppedStart', @@ -35,6 +37,14 @@ const eventUIPropsKey: (keyof EventUIProps)[] = [ 'goingDurationHeight', 'modelDurationHeight', 'comingDurationHeight', + 'duplicateEvents', + 'duplicateEventIndex', + 'duplicateStarts', + 'duplicateEnds', + 'duplicateLeft', + 'duplicateWidth', + 'collapse', + 'isMain', ]; /** @@ -47,34 +57,14 @@ export default class EventUIModel implements EventUIProps { top = 0; + // If it is one of duplicate events, represents the left value of a group of duplicate events. left = 0; + // If it is one of duplicate events, represents the width value of a group of duplicate events. width = 0; height = 0; - /** - * Represent event has collide with other events when rendering. - * @type {boolean} - */ - hasCollide = false; - - /** - * Extra space at right side of this event. - * @type {number} - */ - extraSpace = 0; - - /** - * represent this event block is not visible after rendered. - * - * in month view, some ui models in date need to hide when already rendered before dates. - * - * set true then it just shows empty space. - * @type {boolean} - */ - hidden = false; - /** * represent render start date used at rendering. * @@ -130,6 +120,63 @@ export default class EventUIModel implements EventUIProps { */ comingDurationHeight = 0; + /** + * the sorted list of duplicate events. + * @type {EventUIModel[]} + */ + duplicateEvents: EventUIModel[] = []; + + /** + * the index of this event among the duplicate events. + * @type {number} + */ + duplicateEventIndex = -1; + + /** + * represent the start date of a group of duplicate events. + * + * the earliest value among the duplicate events' starts and going durations. + * @type {TZDate} + */ + duplicateStarts?: TZDate; + + /** + * represent the end date of a group of duplicate events. + * + * the latest value among the duplicate events' ends and coming durations. + * @type {TZDate} + */ + duplicateEnds?: TZDate; + + /** + * represent the left value of a duplicate event. + * ex) calc(50% - 24px), calc(50%), ... + * + * @type {string} + */ + duplicateLeft = ''; + + /** + * represent the width value of a duplicate event. + * ex) calc(50% - 24px), 9px, ... + * + * @type {string} + */ + duplicateWidth = ''; + + /** + * whether the event is collapsed or not among the duplicate events. + * @type {boolean} + */ + collapse = false; + + /** + * whether the event is main or not. + * The main event is expanded on the initial rendering. + * @type {boolean} + */ + isMain = false; + constructor(event: EventModel) { this.model = event; } @@ -191,15 +238,38 @@ export default class EventUIModel implements EventUIProps { } collidesWith(uiModel: EventModel | EventUIModel, usingTravelTime = true) { + const infos: { start: TZDate; end: TZDate; goingDuration: number; comingDuration: number }[] = + []; + [this, uiModel].forEach((event) => { + const isDuplicateEvent = event instanceof EventUIModel && event.duplicateEvents.length > 0; + + if (isDuplicateEvent) { + infos.push({ + start: event.duplicateStarts as TZDate, + end: event.duplicateEnds as TZDate, + goingDuration: 0, + comingDuration: 0, + }); + } else { + infos.push({ + start: event.getStarts(), + end: event.getEnds(), + goingDuration: event.valueOf().goingDuration, + comingDuration: event.valueOf().comingDuration, + }); + } + }); + const [thisInfo, targetInfo] = infos; + return collidesWith({ - start: this.getStarts().getTime(), - end: this.getEnds().getTime(), - targetStart: uiModel.getStarts().getTime(), - targetEnd: uiModel.getEnds().getTime(), - goingDuration: this.model.goingDuration, - comingDuration: this.model.comingDuration, - targetGoingDuration: uiModel.valueOf().goingDuration, - targetComingDuration: uiModel.valueOf().comingDuration, + start: thisInfo.start.getTime(), + end: thisInfo.end.getTime(), + targetStart: targetInfo.start.getTime(), + targetEnd: targetInfo.end.getTime(), + goingDuration: thisInfo.goingDuration, + comingDuration: thisInfo.comingDuration, + targetGoingDuration: targetInfo.goingDuration, + targetComingDuration: targetInfo.comingDuration, usingTravelTime, // Daygrid does not use travelTime, TimeGrid uses travelTime. }); } diff --git a/apps/calendar/src/slices/layout.ts b/apps/calendar/src/slices/layout.ts index 11963143b..82cfc8c0a 100644 --- a/apps/calendar/src/slices/layout.ts +++ b/apps/calendar/src/slices/layout.ts @@ -1,6 +1,6 @@ import produce from 'immer'; -import { DEFAULT_RESIZER_LENGTH } from '@src/constants/layout'; +import { DEFAULT_DUPLICATE_EVENT_CID, DEFAULT_RESIZER_LENGTH } from '@src/constants/layout'; import { DEFAULT_PANEL_HEIGHT } from '@src/constants/style'; import type { CalendarState, CalendarStore, SetState } from '@t/store'; @@ -17,6 +17,7 @@ export type WeekViewLayoutSlice = { height: number; }; }; + selectedDuplicateEventCid: number; }; }; @@ -28,6 +29,7 @@ export type WeekViewLayoutDispatchers = { updateLayoutHeight: (height: number) => void; updateDayGridRowHeight: (params: UpdateGridRowHeightParams) => void; updateDayGridRowHeightByDiff: (params: UpdateGridRowHeightByDiffParams) => void; + setSelectedDuplicateEventCid: (cid?: number) => void; }; function getRestPanelHeight( @@ -50,6 +52,7 @@ export function createWeekViewLayoutSlice(): WeekViewLayoutSlice { weekViewLayout: { lastPanelType: null, dayGridRows: {} as WeekViewLayoutSlice['weekViewLayout']['dayGridRows'], + selectedDuplicateEventCid: DEFAULT_DUPLICATE_EVENT_CID, }, }; } @@ -120,5 +123,11 @@ export function createWeekViewLayoutDispatchers( } }) ), + setSelectedDuplicateEventCid: (cid) => + set( + produce((state) => { + state.weekViewLayout.selectedDuplicateEventCid = cid ?? DEFAULT_DUPLICATE_EVENT_CID; + }) + ), }; } diff --git a/apps/calendar/src/slices/options.ts b/apps/calendar/src/slices/options.ts index 56242f549..cf980834d 100644 --- a/apps/calendar/src/slices/options.ts +++ b/apps/calendar/src/slices/options.ts @@ -1,12 +1,18 @@ import produce from 'immer'; import { DEFAULT_DAY_NAMES } from '@src/helpers/dayName'; -import { Day } from '@src/time/datetime'; +import { compare, Day } from '@src/time/datetime'; +import { last } from '@src/utils/array'; import { mergeObject } from '@src/utils/object'; import { isBoolean } from '@src/utils/type'; -import type { EventObject } from '@t/events'; -import type { GridSelectionOptions, Options, TimezoneOptions } from '@t/options'; +import type { EventObject, EventObjectWithDefaultValues } from '@t/events'; +import type { + CollapseDuplicateEventsOptions, + GridSelectionOptions, + Options, + TimezoneOptions, +} from '@t/options'; import type { CalendarMonthOptions, CalendarState, @@ -15,8 +21,38 @@ import type { SetState, } from '@t/store'; +function initializeCollapseDuplicateEvents( + options: boolean | Partial +): boolean | CollapseDuplicateEventsOptions { + if (!options) { + return false; + } + + const initialCollapseDuplicateEvents = { + getDuplicateEvents: ( + targetEvent: EventObjectWithDefaultValues, + events: EventObjectWithDefaultValues[] + ) => + events + .filter( + (event: EventObjectWithDefaultValues) => + event.title === targetEvent.title && + compare(event.start, targetEvent.start) === 0 && + compare(event.end, targetEvent.end) === 0 + ) + .sort((a, b) => (a.calendarId > b.calendarId ? 1 : -1)), + getMainEvent: (events: EventObjectWithDefaultValues[]) => last(events), + }; + + if (isBoolean(options)) { + return initialCollapseDuplicateEvents; + } + + return { ...initialCollapseDuplicateEvents, ...options }; +} + function initializeWeekOptions(weekOptions: Options['week'] = {}): CalendarWeekOptions { - return { + const week: CalendarWeekOptions = { startDayOfWeek: Day.SUN, dayNames: [], narrowWeekend: false, @@ -28,8 +64,13 @@ function initializeWeekOptions(weekOptions: Options['week'] = {}): CalendarWeekO hourEnd: 24, eventView: true, taskView: true, + collapseDuplicateEvents: false, ...weekOptions, }; + + week.collapseDuplicateEvents = initializeCollapseDuplicateEvents(week.collapseDuplicateEvents); + + return week; } function initializeTimezoneOptions(timezoneOptions: Options['timezone'] = {}): TimezoneOptions { @@ -113,6 +154,16 @@ export function createOptionsDispatchers(set: SetState): OptionsD setOptions: (newOptions: Partial = {}) => set( produce((state) => { + if (newOptions.gridSelection) { + newOptions.gridSelection = initializeGridSelectionOptions(newOptions.gridSelection); + } + + if (newOptions.week?.collapseDuplicateEvents) { + newOptions.week.collapseDuplicateEvents = initializeCollapseDuplicateEvents( + newOptions.week.collapseDuplicateEvents + ); + } + mergeObject(state.options, newOptions); }) ), diff --git a/apps/calendar/src/time/date.ts b/apps/calendar/src/time/date.ts index af6e15029..8084d31b3 100644 --- a/apps/calendar/src/time/date.ts +++ b/apps/calendar/src/time/date.ts @@ -15,7 +15,7 @@ function getTZOffsetMSDifference(offset: number) { /** * Custom Date Class to handle timezone offset. * - * For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/ko/apis/tzdate.md|TZDate} in guide. + * For more information, see {@link https://github.com/nhn/tui.calendar/blob/main/docs/en/apis/tzdate.md|TZDate} in guide. * * @class TZDate * @param {number|TZDate|Date|string} date - date value to be converted. If date is number or string, it should be eligible to parse by Date constructor. diff --git a/apps/calendar/src/types/events.ts b/apps/calendar/src/types/events.ts index f7d692cad..548b07d94 100644 --- a/apps/calendar/src/types/events.ts +++ b/apps/calendar/src/types/events.ts @@ -44,6 +44,7 @@ export type EventObjectWithDefaultValues = MarkOptional< > & { start: TZDate; end: TZDate; + __cid: number; }; export interface EventObject { diff --git a/apps/calendar/src/types/options.ts b/apps/calendar/src/types/options.ts index 440545201..073908f0c 100644 --- a/apps/calendar/src/types/options.ts +++ b/apps/calendar/src/types/options.ts @@ -2,13 +2,21 @@ import type { ComponentType } from 'preact'; import type { DeepPartial } from 'ts-essentials'; -import type { EventObject } from '@t/events'; +import type { EventObject, EventObjectWithDefaultValues } from '@t/events'; import type { TemplateConfig } from '@t/template'; import type { ThemeState } from '@t/theme'; export type EventView = 'allday' | 'time'; export type TaskView = 'milestone' | 'task'; +export interface CollapseDuplicateEventsOptions { + getDuplicateEvents: ( + targetEvent: EventObjectWithDefaultValues, + events: EventObjectWithDefaultValues[] + ) => EventObjectWithDefaultValues[]; + getMainEvent: (events: EventObjectWithDefaultValues[]) => EventObjectWithDefaultValues; +} + export interface WeekOptions { startDayOfWeek?: number; dayNames?: [string, string, string, string, string, string, string] | []; @@ -21,6 +29,7 @@ export interface WeekOptions { hourEnd?: number; eventView?: boolean | EventView[]; taskView?: boolean | TaskView[]; + collapseDuplicateEvents?: boolean | Partial; } export interface MonthOptions { diff --git a/apps/calendar/stories/e2e/week.stories.tsx b/apps/calendar/stories/e2e/week.stories.tsx index bf81e5506..54a32bb8c 100644 --- a/apps/calendar/stories/e2e/week.stories.tsx +++ b/apps/calendar/stories/e2e/week.stories.tsx @@ -1,7 +1,9 @@ import { h } from 'preact'; import moment from 'moment-timezone'; +import { last } from '@src/utils/array'; +import { mockCalendars } from '@stories/mocks/mockCalendars'; import { mockWeekViewEvents } from '@stories/mocks/mockWeekViewEvents'; import type { CalendarExampleStory } from '@stories/util/calendarExample'; import { CalendarExample } from '@stories/util/calendarExample'; @@ -19,6 +21,7 @@ Template.args = { week: { showNowIndicator: false, }, + calendars: mockCalendars, }, containerHeight: '100vh', onInit: (cal) => { @@ -282,3 +285,23 @@ CustomTemplate.args = { }, }, }; + +export const DuplicateEvents = Template.bind({}); +DuplicateEvents.args = { + ...Template.args, + options: { + ...Template.args.options, + week: { + ...Template.args.options?.week, + collapseDuplicateEvents: { + getDuplicateEvents: (targetEvent, events) => + events + .filter((event) => event.id === targetEvent.id) + .sort((a, b) => (b.calendarId > a.calendarId ? 1 : -1)), // descending order + getMainEvent: (events) => + events.find(({ title }) => title.includes('- main')) || last(events), + }, + }, + useDetailPopup: false, + }, +}; diff --git a/apps/calendar/stories/mocks/mockCalendars.ts b/apps/calendar/stories/mocks/mockCalendars.ts new file mode 100644 index 000000000..108f8af33 --- /dev/null +++ b/apps/calendar/stories/mocks/mockCalendars.ts @@ -0,0 +1,22 @@ +import type { CalendarInfo } from '@src/types/options'; + +export const mockCalendars: CalendarInfo[] = [ + { + id: 'cal1', + name: 'First', + }, + { + id: 'cal2', + name: 'Second', + borderColor: '#00a9ff', + backgroundColor: '#00a9ff', + dragBackgroundColor: '#00a9ff', + }, + { + id: 'cal3', + name: 'Third', + borderColor: '#03bd9e', + backgroundColor: '#03bd9e', + dragBackgroundColor: '#03bd9e', + }, +]; diff --git a/apps/calendar/stories/mocks/mockWeekViewEvents.ts b/apps/calendar/stories/mocks/mockWeekViewEvents.ts index 41986ac23..3edbbf65a 100644 --- a/apps/calendar/stories/mocks/mockWeekViewEvents.ts +++ b/apps/calendar/stories/mocks/mockWeekViewEvents.ts @@ -87,4 +87,103 @@ export const mockWeekViewEvents: MockedWeekViewEvents[] = [ start: setTimeStrToDate(monday, '05:00'), end: setTimeStrToDate(monday, '05:00'), }, + { + id: '9', + calendarId: 'cal3', + title: 'duplicate event', + category: 'time', + isAllday: false, + start: setTimeStrToDate(monday, '06:00'), + end: setTimeStrToDate(monday, '07:00'), + }, + { + id: '9', + calendarId: 'cal2', + title: 'duplicate event with attendee', + category: 'time', + isAllday: false, + start: setTimeStrToDate(monday, '06:00'), + end: setTimeStrToDate(monday, '07:00'), + comingDuration: 30, + attendees: ['a', 'b', 'c'], + }, + { + id: '9', + calendarId: 'cal1', + title: 'duplicate event - main', + category: 'time', + isAllday: false, + start: setTimeStrToDate(monday, '06:00'), + end: setTimeStrToDate(monday, '07:00'), + goingDuration: 30, + comingDuration: 60, + }, + { + id: '9-1', + calendarId: 'cal1', + title: 'normal event', + category: 'time', + isAllday: false, + start: setTimeStrToDate(monday, '08:00'), + end: setTimeStrToDate(monday, '09:00'), + }, + { + id: '9-2', + calendarId: 'cal2', + title: 'other event', + category: 'time', + isAllday: false, + start: setTimeStrToDate(monday, '08:00'), + end: setTimeStrToDate(monday, '09:00'), + }, + { + id: '10', + calendarId: 'cal1', + title: 'duplicate event 2', + category: 'time', + isAllday: false, + start: setTimeStrToDate(monday, '09:00'), + end: setTimeStrToDate(monday, '10:00'), + }, + { + id: '10', + calendarId: 'cal2', + title: 'duplicate event 2 with attendee', + category: 'time', + isAllday: false, + start: setTimeStrToDate(monday, '09:00'), + end: setTimeStrToDate(monday, '10:00'), + attendees: ['a', 'b', 'c'], + goingDuration: 30, + }, + { + id: '10', + calendarId: 'cal3', + title: 'duplicate event 2 - main', + category: 'time', + isAllday: false, + start: setTimeStrToDate(monday, '09:00'), + end: setTimeStrToDate(monday, '10:00'), + goingDuration: 30, + comingDuration: 60, + }, + { + id: '11', + calendarId: 'cal1', + title: 'duplicate long event', + category: 'time', + isAllday: false, + start: setTimeStrToDate(wednesday, '07:00'), + end: setTimeStrToDate(thursday, '04:00'), + }, + { + id: '11', + calendarId: 'cal2', + title: 'duplicate long event - main', + category: 'time', + isAllday: false, + start: setTimeStrToDate(wednesday, '07:00'), + end: setTimeStrToDate(thursday, '04:00'), + attendees: ['a', 'b', 'c'], + }, ]; diff --git a/apps/calendar/stories/mocks/types.ts b/apps/calendar/stories/mocks/types.ts index 0b28d8774..d78113190 100644 --- a/apps/calendar/stories/mocks/types.ts +++ b/apps/calendar/stories/mocks/types.ts @@ -9,6 +9,7 @@ export type MockedWeekViewEvents = Required< end: TZDate; goingDuration?: number; comingDuration?: number; + attendees?: string[]; }; export type MockedMonthViewEvents = Omit; diff --git a/docs/assets/options_week-collapseDuplicateEvents-after.png b/docs/assets/options_week-collapseDuplicateEvents-after.png new file mode 100644 index 000000000..852d8e2f4 Binary files /dev/null and b/docs/assets/options_week-collapseDuplicateEvents-after.png differ diff --git a/docs/assets/options_week-collapseDuplicateEvents-before.png b/docs/assets/options_week-collapseDuplicateEvents-before.png new file mode 100644 index 000000000..4ba76a42f Binary files /dev/null and b/docs/assets/options_week-collapseDuplicateEvents-before.png differ diff --git a/docs/en/apis/options.md b/docs/en/apis/options.md index e0a36e222..82669b760 100644 --- a/docs/en/apis/options.md +++ b/docs/en/apis/options.md @@ -155,6 +155,10 @@ const calendar = new Calendar('#container', { ```ts type EventView = 'allday' | 'time'; type TaskView = 'milestone' | 'task'; +interface CollapseDuplicateEvents { + getDuplicateEvents: (targetEvent: EventObject, events: EventObject[]) => EventObject[]; + getMainEvent: (events: EventObject[]) => EventObject; +}; interface WeekOptions { startDayOfWeek?: number; @@ -168,6 +172,7 @@ interface WeekOptions { hourEnd?: number; eventView?: boolean | EventView[]; taskView?: boolean | TaskView[]; + collapseDuplicateEvents?: boolean | CollapseDuplicateEvents; } ``` @@ -184,6 +189,7 @@ const DEFAULT_WEEK_OPTIONS = { hourEnd: 24, eventView: true, taskView: true, + collapseDuplicateEvents: false, }; ``` @@ -432,6 +438,46 @@ calendar.setOptions({ [⬆️ Back to the list](#week) +#### week.collapseDuplicateEvents + +- Type: `boolean | CollapseDuplicateEventsOptions` +- Default: `false` + +```ts +interface CollapseDuplicateEventsOptions { + getDuplicateEvents: (targetEvent: EventObject, events: EventObject[]) => EventObject[]; + getMainEvent: (events: EventObject[]) => EventObject; +}; +``` + +You can collapse duplicate events in the daily/weekly view. The default value is `false`. The calendar handles duplicate events in the same way as normal events. When it is `true`, **events with the same `title`, `start`, and `end`** are classified as duplicate events, and **the last event** among them is expanded during initial rendering. If you want to filter duplicate events based on your requirements, set `getDuplicateEvents`. And if you want to choose which event is expanded during initial rendering, set `getMainEvent`. + +`getDuplicateEvents` should **return sorted duplicate events in the order you want them to appear**. The return value of `getDuplicateEvents` is the parameter of `getMainEvent`. + +```js +calendar.setOptions({ + week: { + collapseDuplicateEvents: { + getDuplicateEvents: (targetEvent, events) => + events + .filter((event) => + event.title === targetEvent.title && + event.start.getTime() === targetEvent.start.getTime() && + event.end.getTime() === targetEvent.end.getTime() + ) + .sort((a, b) => (a.calendarId > b.calendarId ? 1 : -1)), + getMainEvent: (events) => events[events.length - 1], // events are the return value of getDuplicateEvents() + } + }, +}); +``` + +| Default | Example | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| ![week-collapseDuplicateEvents-default](../../assets/options_week-collapseDuplicateEvents-before.png) | ![week-collapseDuplicateEvents-example](../../assets/options_week-collapseDuplicateEvents-after.png) | + +[⬆ Back to the list](#week) + ### month - Type: `MonthOptions` diff --git a/docs/ko/apis/options.md b/docs/ko/apis/options.md index 336311c1f..cf1f61670 100644 --- a/docs/ko/apis/options.md +++ b/docs/ko/apis/options.md @@ -156,6 +156,10 @@ const calendar = new Calendar('#container', { ```ts type EventView = 'allday' | 'time'; type TaskView = 'milestone' | 'task'; +interface CollapseDuplicateEvents { + getDuplicateEvents: (targetEvent: EventObject, events: EventObject[]) => EventObject[]; + getMainEvent: (events: EventObject[]) => EventObject; +}; interface WeekOptions { startDayOfWeek?: number; @@ -169,6 +173,7 @@ interface WeekOptions { hourEnd?: number; eventView?: boolean | EventView[]; taskView?: boolean | TaskView[]; + collapseDuplicateEvents?: boolean | CollapseDuplicateEvents; } ``` @@ -185,6 +190,7 @@ const DEFAULT_WEEK_OPTIONS = { hourEnd: 24, eventView: true, taskView: true, + collapseDuplicateEvents: false, }; ``` @@ -433,6 +439,46 @@ calendar.setOptions({ [⬆ 목록으로 돌아가기](#week) +#### week.collapseDuplicateEvents + +- 타입: `boolean | CollapseDuplicateEventsOptions` +- 기본값: `false` + +```ts +interface CollapseDuplicateEventsOptions { + getDuplicateEvents: (targetEvent: EventObject, events: EventObject[]) => EventObject[]; + getMainEvent: (events: EventObject[]) => EventObject; +}; +``` + +주간/일간뷰에서 중복된 일정을 겹치게 표시할 수 있다. 기본값은 `false`이며, 중복된 일정을 일반 일정과 동일하게 처리한다. `true`인 경우엔 **`title`, `start`, `end`가 같은 일정**을 중복된 일정으로 분류하고, 이 중 **마지막 일정**을 초기 렌더링 시 펼친다. 만약 자신만의 기준으로 중복된 일정을 필터링하고 싶다면 `getDuplicateEvents`를, 초기 렌더링 시 펼치고 싶은 일정을 정하고 싶다면 `getMainEvent`을 설정한다. + +`getDuplicateEvents`의 경우 **중복된 일정을 표시하고 싶은 순서대로 정렬하여 리턴**해야 한다. `getDuplicateEvents`의 리턴값이 `getMainEvent`의 파라미터로 사용된다. + +```js +calendar.setOptions({ + week: { + collapseDuplicateEvents: { + getDuplicateEvents: (targetEvent, events) => + events + .filter((event) => + event.title === targetEvent.title && + event.start.getTime() === targetEvent.start.getTime() && + event.end.getTime() === targetEvent.end.getTime() + ) + .sort((a, b) => (a.calendarId > b.calendarId ? 1 : -1)), + getMainEvent: (events) => events[events.length - 1], // events는 getDuplicateEvents()의 리턴값이다. + } + }, +}); +``` + +| 기본값 적용 | 예제 적용 | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| ![week-collapseDuplicateEvents-default](../../assets/options_week-collapseDuplicateEvents-before.png) | ![week-collapseDuplicateEvents-example](../../assets/options_week-collapseDuplicateEvents-after.png) | + +[⬆ 목록으로 돌아가기](#week) + ### month - 타입: `MonthOptions`