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`