Repository: nhn/tui.calendar Branch: main Commit: b53e765e8d89 Files: 455 Total size: 1.5 MB Directory structure: gitextract_fijh9az9/ ├── .eslintignore ├── .eslintrc.js ├── .github/ │ ├── composite-actions/ │ │ └── install-dependencies/ │ │ └── action.yml │ └── workflows/ │ ├── publish-calendar.yml │ ├── publish-docs.yml │ ├── publish-wrappers.yml │ └── test.yml ├── .gitignore ├── .husky/ │ ├── .gitignore │ └── pre-commit ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── apps/ │ ├── calendar/ │ │ ├── .browserslistrc │ │ ├── .lintstagedrc.js │ │ ├── .storybook/ │ │ │ ├── main.js │ │ │ ├── manager.js │ │ │ ├── preview.js │ │ │ └── theme.js │ │ ├── README.md │ │ ├── examples/ │ │ │ ├── 00-calendar-app.html │ │ │ ├── 01-monthly-view-basic.html │ │ │ ├── 02-monthly-view-2weeks.html │ │ │ ├── 03-monthly-view-3weeks.html │ │ │ ├── 04-weekly-view.html │ │ │ ├── 05-weekly-view-no-event-view.html │ │ │ ├── 06-daily-view.html │ │ │ ├── 07-narrow-weekends.html │ │ │ ├── 08-hidden-weekends.html │ │ │ ├── 09-timezone.html │ │ │ ├── 10-theme-common.html │ │ │ ├── 11-theme-monthly.html │ │ │ ├── 12-theme-weekly.html │ │ │ ├── 13-template-monthly.html │ │ │ ├── 14-template-weekly.html │ │ │ ├── 15-template-popup.html │ │ │ ├── scripts/ │ │ │ │ ├── app.js │ │ │ │ ├── mock-data.js │ │ │ │ └── utils.js │ │ │ └── styles/ │ │ │ ├── app.css │ │ │ ├── icons.css │ │ │ └── reset.css │ │ ├── jest.config.js │ │ ├── jsdoc.conf.json │ │ ├── package.json │ │ ├── playwright/ │ │ │ ├── assertions.ts │ │ │ ├── configs.ts │ │ │ ├── constants.ts │ │ │ ├── day/ │ │ │ │ ├── timeGridEventMoving.e2e.ts │ │ │ │ ├── timeGridEventResizing.e2e.ts │ │ │ │ ├── timeGridScrollSync.e2e.ts │ │ │ │ └── timeGridSelection.e2e.ts │ │ │ ├── month/ │ │ │ │ ├── accumulatedGridSelection.e2e.ts │ │ │ │ ├── eventMoving.e2e.ts │ │ │ │ ├── eventResizing.e2e.ts │ │ │ │ ├── gridSelection.e2e.ts │ │ │ │ ├── seeMoreEventsPopup.e2e.ts │ │ │ │ └── visibleEventCount.e2e.ts │ │ │ ├── playwright-env.d.ts │ │ │ ├── types.ts │ │ │ ├── utils.ts │ │ │ └── week/ │ │ │ ├── alldayGridEventMoving.e2e.ts │ │ │ ├── alldayGridEventResizing.e2e.ts │ │ │ ├── dayGridSelection.e2e.ts │ │ │ ├── hourStartOption.e2e.ts │ │ │ ├── primaryTimezone.e2e.ts │ │ │ ├── timeGridEventClick.e2e.ts │ │ │ ├── timeGridEventMoving.e2e.ts │ │ │ ├── timeGridEventResizing.e2e.ts │ │ │ ├── timeGridScrollSync.e2e.ts │ │ │ └── timeGridSelection.e2e.ts │ │ ├── postcss.config.js │ │ ├── scripts/ │ │ │ ├── publishToCDN.js │ │ │ └── updateWrapper.js │ │ ├── src/ │ │ │ ├── calendarContainer.tsx │ │ │ ├── components/ │ │ │ │ ├── dayGridCommon/ │ │ │ │ │ ├── dayName.tsx │ │ │ │ │ ├── gridHeader.tsx │ │ │ │ │ └── gridSelection.tsx │ │ │ │ ├── dayGridMonth/ │ │ │ │ │ ├── accumulatedGridSelection.tsx │ │ │ │ │ ├── cellHeader.tsx │ │ │ │ │ ├── dayGridMonth.tsx │ │ │ │ │ ├── gridCell.tsx │ │ │ │ │ ├── gridRow.tsx │ │ │ │ │ ├── gridSelectionByRow.tsx │ │ │ │ │ ├── monthEvents.tsx │ │ │ │ │ ├── moreEventsButton.tsx │ │ │ │ │ ├── movingEventShadow.tsx │ │ │ │ │ └── resizingGuideByRow.tsx │ │ │ │ ├── dayGridWeek/ │ │ │ │ │ ├── alldayGridRow.tsx │ │ │ │ │ ├── alldayGridSelection.tsx │ │ │ │ │ ├── gridCell.tsx │ │ │ │ │ ├── gridCells.tsx │ │ │ │ │ ├── movingEventShadow.tsx │ │ │ │ │ ├── otherGridRow.tsx │ │ │ │ │ └── resizingEventShadow.tsx │ │ │ │ ├── events/ │ │ │ │ │ ├── backgroundEvent.tsx │ │ │ │ │ ├── horizontalEvent.spec.tsx │ │ │ │ │ ├── horizontalEvent.tsx │ │ │ │ │ ├── horizontalEventResizeIcon.tsx │ │ │ │ │ ├── timeEvent.spec.tsx │ │ │ │ │ └── timeEvent.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── panel.tsx │ │ │ │ ├── panelResizer.tsx │ │ │ │ ├── popup/ │ │ │ │ │ ├── calendarDropdownMenu.tsx │ │ │ │ │ ├── calendarSelector.tsx │ │ │ │ │ ├── closePopupButton.tsx │ │ │ │ │ ├── confirmPopupButton.tsx │ │ │ │ │ ├── dateSelector.tsx │ │ │ │ │ ├── eventDetailPopup.spec.tsx │ │ │ │ │ ├── eventDetailPopup.tsx │ │ │ │ │ ├── eventDetailSectionDetail.tsx │ │ │ │ │ ├── eventDetailSectionHeader.tsx │ │ │ │ │ ├── eventFormPopup.spec.tsx │ │ │ │ │ ├── eventFormPopup.tsx │ │ │ │ │ ├── eventStateSelector.tsx │ │ │ │ │ ├── locationInputBox.tsx │ │ │ │ │ ├── popupOverlay.tsx │ │ │ │ │ ├── popupSection.tsx │ │ │ │ │ ├── seeMoreEventsPopup.tsx │ │ │ │ │ ├── stateDropdownMenu.tsx │ │ │ │ │ └── titleInputBox.tsx │ │ │ │ ├── template.tsx │ │ │ │ ├── timeGrid/ │ │ │ │ │ ├── column.tsx │ │ │ │ │ ├── gridLines.tsx │ │ │ │ │ ├── gridSelectionByColumn.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── movingEventShadow.tsx │ │ │ │ │ ├── nowIndicator.tsx │ │ │ │ │ ├── nowIndicatorLabel.tsx │ │ │ │ │ ├── resizingGuideByColumn.tsx │ │ │ │ │ ├── timeColumn.tsx │ │ │ │ │ ├── timeGrid.spec.tsx │ │ │ │ │ ├── timeGrid.tsx │ │ │ │ │ ├── timezoneCollapseButton.tsx │ │ │ │ │ ├── timezoneCollpaseButton.spec.tsx │ │ │ │ │ └── timezoneLabels.tsx │ │ │ │ └── view/ │ │ │ │ ├── day.spec.tsx │ │ │ │ ├── day.tsx │ │ │ │ ├── main.tsx │ │ │ │ ├── month.spec.tsx │ │ │ │ ├── month.tsx │ │ │ │ ├── week.spec.tsx │ │ │ │ └── week.tsx │ │ │ ├── constants/ │ │ │ │ ├── error.ts │ │ │ │ ├── grid.ts │ │ │ │ ├── keyboard.ts │ │ │ │ ├── layout.ts │ │ │ │ ├── message.ts │ │ │ │ ├── mouse.ts │ │ │ │ ├── popup.ts │ │ │ │ ├── statistics.ts │ │ │ │ ├── style.ts │ │ │ │ ├── theme.ts │ │ │ │ └── view.ts │ │ │ ├── contexts/ │ │ │ │ ├── calendarStore.ts │ │ │ │ ├── eventBus.spec.tsx │ │ │ │ ├── eventBus.tsx │ │ │ │ ├── floatingLayer.tsx │ │ │ │ ├── layoutContainer.tsx │ │ │ │ └── themeStore.tsx │ │ │ ├── controller/ │ │ │ │ ├── base.spec.ts │ │ │ │ ├── base.ts │ │ │ │ ├── column.spec.ts │ │ │ │ ├── column.ts │ │ │ │ ├── core.spec.ts │ │ │ │ ├── core.ts │ │ │ │ ├── month.spec.ts │ │ │ │ ├── month.ts │ │ │ │ ├── times.spec.ts │ │ │ │ ├── times.ts │ │ │ │ ├── week.spec.ts │ │ │ │ └── week.ts │ │ │ ├── css/ │ │ │ │ ├── common.css │ │ │ │ ├── daygrid/ │ │ │ │ │ ├── dayGrid.css │ │ │ │ │ ├── dayNames.css │ │ │ │ │ └── index.css │ │ │ │ ├── events/ │ │ │ │ │ ├── background.css │ │ │ │ │ ├── grid.css │ │ │ │ │ ├── index.css │ │ │ │ │ └── time.css │ │ │ │ ├── icons.css │ │ │ │ ├── index.css │ │ │ │ ├── layout.css │ │ │ │ ├── panel/ │ │ │ │ │ ├── allday.css │ │ │ │ │ └── index.css │ │ │ │ ├── popup/ │ │ │ │ │ ├── common.css │ │ │ │ │ ├── detail.css │ │ │ │ │ ├── form.css │ │ │ │ │ ├── index.css │ │ │ │ │ └── seeMore.css │ │ │ │ └── timegrid/ │ │ │ │ ├── column.css │ │ │ │ ├── index.css │ │ │ │ ├── timeColumn.css │ │ │ │ └── timegrid.css │ │ │ ├── factory/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── calendarCore.spec.tsx.snap │ │ │ │ ├── calendar.tsx │ │ │ │ ├── calendarCore.spec.tsx │ │ │ │ ├── calendarCore.tsx │ │ │ │ ├── day.tsx │ │ │ │ ├── month.spec.tsx │ │ │ │ ├── month.tsx │ │ │ │ ├── week.spec.tsx │ │ │ │ └── week.tsx │ │ │ ├── helpers/ │ │ │ │ ├── css.spec.ts │ │ │ │ ├── css.ts │ │ │ │ ├── dayName.ts │ │ │ │ ├── drag.ts │ │ │ │ ├── events.spec.ts │ │ │ │ ├── events.ts │ │ │ │ ├── grid.spec.ts │ │ │ │ ├── grid.ts │ │ │ │ ├── gridSelection.ts │ │ │ │ ├── popup.ts │ │ │ │ └── view.ts │ │ │ ├── hooks/ │ │ │ │ ├── calendar/ │ │ │ │ │ ├── useCalendarById.ts │ │ │ │ │ ├── useCalendarColor.ts │ │ │ │ │ └── useCalendarData.ts │ │ │ │ ├── common/ │ │ │ │ │ ├── useClickPrevention.spec.tsx │ │ │ │ │ ├── useClickPrevention.ts │ │ │ │ │ ├── useDOMNode.ts │ │ │ │ │ ├── useDrag.spec.tsx │ │ │ │ │ ├── useDrag.ts │ │ │ │ │ ├── useDropdownState.ts │ │ │ │ │ ├── useInterval.spec.ts │ │ │ │ │ ├── useInterval.ts │ │ │ │ │ ├── useIsMounted.spec.ts │ │ │ │ │ ├── useIsMounted.ts │ │ │ │ │ ├── useKeydownEvent.ts │ │ │ │ │ ├── useTransientUpdate.ts │ │ │ │ │ └── useWhen.ts │ │ │ │ ├── dayGridMonth/ │ │ │ │ │ ├── useDayGridMonthEventMove.ts │ │ │ │ │ └── useDayGridMonthEventResize.ts │ │ │ │ ├── dayGridWeek/ │ │ │ │ │ ├── useAlldayGridRowEventMove.ts │ │ │ │ │ ├── useAlldayGridRowEventResize.ts │ │ │ │ │ └── useGridRowHeightController.ts │ │ │ │ ├── event/ │ │ │ │ │ ├── useCurrentPointerPositionInGrid.ts │ │ │ │ │ └── useDraggingEvent.ts │ │ │ │ ├── gridSelection/ │ │ │ │ │ ├── useGridSelection.spec.tsx │ │ │ │ │ └── useGridSelection.ts │ │ │ │ ├── popup/ │ │ │ │ │ └── useFormState.ts │ │ │ │ ├── template/ │ │ │ │ │ └── useStringOnlyTemplate.ts │ │ │ │ ├── timeGrid/ │ │ │ │ │ ├── useTimeGridEventMove.spec.tsx │ │ │ │ │ ├── useTimeGridEventMove.ts │ │ │ │ │ ├── useTimeGridEventResize.ts │ │ │ │ │ ├── useTimeGridScrollSync.ts │ │ │ │ │ └── useTimezoneLabelsTop.ts │ │ │ │ └── timezone/ │ │ │ │ ├── useEventsWithTimezone.ts │ │ │ │ ├── usePrimaryTimezone.ts │ │ │ │ ├── useTZConverter.spec.ts │ │ │ │ └── useTZConverter.ts │ │ │ ├── index.ts │ │ │ ├── jest.d.ts │ │ │ ├── model/ │ │ │ │ ├── eventModel.spec.ts │ │ │ │ ├── eventModel.ts │ │ │ │ └── eventUIModel.ts │ │ │ ├── selectors/ │ │ │ │ ├── index.ts │ │ │ │ ├── options.ts │ │ │ │ ├── popup.ts │ │ │ │ ├── theme.ts │ │ │ │ └── timezone.ts │ │ │ ├── setupTests.ts │ │ │ ├── slices/ │ │ │ │ ├── calendar.ts │ │ │ │ ├── dnd.ts │ │ │ │ ├── gridSelection.ts │ │ │ │ ├── layout.ts │ │ │ │ ├── options.ts │ │ │ │ ├── popup.ts │ │ │ │ ├── template.ts │ │ │ │ ├── view.spec.ts │ │ │ │ └── view.ts │ │ │ ├── store/ │ │ │ │ ├── index.spec.tsx │ │ │ │ ├── index.ts │ │ │ │ └── internal.ts │ │ │ ├── template/ │ │ │ │ ├── default.tsx │ │ │ │ ├── index.ts │ │ │ │ └── template.spec.tsx │ │ │ ├── test/ │ │ │ │ ├── cssFileMock.ts │ │ │ │ ├── helpers.ts │ │ │ │ ├── matchers.ts │ │ │ │ ├── testIds.ts │ │ │ │ └── utils.tsx │ │ │ ├── theme/ │ │ │ │ ├── common.ts │ │ │ │ ├── dispatch.spec.tsx │ │ │ │ ├── dispatch.ts │ │ │ │ ├── month.ts │ │ │ │ └── week.ts │ │ │ ├── time/ │ │ │ │ ├── date.spec.ts │ │ │ │ ├── date.ts │ │ │ │ ├── datetime.spec.ts │ │ │ │ ├── datetime.ts │ │ │ │ ├── timezone.spec.ts │ │ │ │ └── timezone.ts │ │ │ ├── tui-code-snippet.d.ts │ │ │ ├── types/ │ │ │ │ ├── components/ │ │ │ │ │ ├── common.ts │ │ │ │ │ └── gridSelection.ts │ │ │ │ ├── drag.ts │ │ │ │ ├── eventBus.ts │ │ │ │ ├── events.ts │ │ │ │ ├── grid.ts │ │ │ │ ├── mouse.ts │ │ │ │ ├── options.ts │ │ │ │ ├── panel.ts │ │ │ │ ├── store.ts │ │ │ │ ├── template.ts │ │ │ │ ├── theme.ts │ │ │ │ ├── time/ │ │ │ │ │ └── datetime.ts │ │ │ │ └── util.ts │ │ │ └── utils/ │ │ │ ├── array.spec.ts │ │ │ ├── array.ts │ │ │ ├── collection.spec.ts │ │ │ ├── collection.ts │ │ │ ├── dom.spec.ts │ │ │ ├── dom.ts │ │ │ ├── error.ts │ │ │ ├── eventBus.ts │ │ │ ├── keyboard.ts │ │ │ ├── logger.ts │ │ │ ├── math.spec.ts │ │ │ ├── math.ts │ │ │ ├── noop.ts │ │ │ ├── object.spec.ts │ │ │ ├── object.ts │ │ │ ├── preact.ts │ │ │ ├── requestTimeout.spec.ts │ │ │ ├── requestTimeout.ts │ │ │ ├── sanitizer.ts │ │ │ ├── stamp.ts │ │ │ ├── string.spec.ts │ │ │ ├── string.ts │ │ │ ├── type.spec.ts │ │ │ └── type.ts │ │ ├── stories/ │ │ │ ├── column.stories.tsx │ │ │ ├── data/ │ │ │ │ └── events.json │ │ │ ├── dayGridMonth.stories.tsx │ │ │ ├── dayView.stories.tsx │ │ │ ├── e2e/ │ │ │ │ ├── day.stories.tsx │ │ │ │ ├── month.stories.tsx │ │ │ │ └── week.stories.tsx │ │ │ ├── eventDetailPopup.stories.tsx │ │ │ ├── eventFormPopup.stories.tsx │ │ │ ├── events.stories.tsx │ │ │ ├── gridHeader.stories.tsx │ │ │ ├── gridRow.stories.tsx │ │ │ ├── helper/ │ │ │ │ └── event.ts │ │ │ ├── layout.stories.tsx │ │ │ ├── main.stories.tsx │ │ │ ├── mocks/ │ │ │ │ ├── mockCalendars.ts │ │ │ │ ├── mockDayViewEvents.ts │ │ │ │ ├── mockMonthViewEvents.ts │ │ │ │ ├── mockWeekViewEvents.ts │ │ │ │ └── types.ts │ │ │ ├── monthView.stories.tsx │ │ │ ├── timegrid.stories.tsx │ │ │ ├── util/ │ │ │ │ ├── calendarExample.tsx │ │ │ │ ├── mockCalendarDates.ts │ │ │ │ ├── mockCalendars.ts │ │ │ │ ├── providerWrapper.tsx │ │ │ │ └── randomEvents.ts │ │ │ └── weekView.stories.tsx │ │ ├── stylelint.config.js │ │ ├── tsconfig.declaration.json │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ ├── tuidoc.config.json │ │ ├── vite.config.ts │ │ └── webpack.config.js │ ├── react-calendar/ │ │ ├── .browserslistrc │ │ ├── .eslintrc.js │ │ ├── .gitignore │ │ ├── README.md │ │ ├── docs/ │ │ │ ├── README.md │ │ │ ├── en/ │ │ │ │ └── guide/ │ │ │ │ ├── getting-started.md │ │ │ │ └── migration-guide-v2.md │ │ │ └── ko/ │ │ │ ├── README.md │ │ │ └── guide/ │ │ │ ├── getting-started.md │ │ │ └── migration-guide-v2.md │ │ ├── example/ │ │ │ ├── app.css │ │ │ ├── app.tsx │ │ │ ├── index.html │ │ │ ├── main.tsx │ │ │ ├── theme.ts │ │ │ └── utils.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.tsx │ │ │ └── isEqual.ts │ │ ├── tsconfig.declaration.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── vue-calendar/ │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .lintstagedrc.js │ ├── README.md │ ├── docs/ │ │ ├── README.md │ │ ├── en/ │ │ │ └── guide/ │ │ │ ├── getting-started.md │ │ │ └── migration-guide-v2.md │ │ └── ko/ │ │ ├── README.md │ │ └── guide/ │ │ ├── getting-started.md │ │ └── migration-guide-v2.md │ ├── example/ │ │ ├── App.vue │ │ ├── app.css │ │ ├── index.html │ │ ├── main.js │ │ ├── mock-data.js │ │ ├── theme.js │ │ └── utils.js │ ├── index.d.ts │ ├── package.json │ ├── src/ │ │ └── Calendar.js │ └── vite.config.js ├── babel.config.json ├── docs/ │ ├── COMMIT_MESSAGE_CONVENTION.md │ ├── ISSUE_TEMPLATE.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── README.md │ ├── en/ │ │ ├── apis/ │ │ │ ├── calendar.md │ │ │ ├── event-object.md │ │ │ ├── options.md │ │ │ ├── template.md │ │ │ ├── theme.md │ │ │ └── tzdate.md │ │ └── guide/ │ │ ├── getting-started.md │ │ └── migration-guide-v2.md │ └── ko/ │ ├── README.md │ ├── apis/ │ │ ├── calendar.md │ │ ├── event-object.md │ │ ├── options.md │ │ ├── template.md │ │ ├── theme.md │ │ └── tzdate.md │ └── guide/ │ ├── getting-started.md │ └── migration-guide-v2.md ├── jest.config.js ├── libs/ │ └── date/ │ ├── .eslintrc.js │ ├── index.d.ts │ ├── jest.config.js │ ├── package.json │ ├── src/ │ │ ├── index.js │ │ ├── localDate.js │ │ ├── momentDate.js │ │ └── utcDate.js │ ├── test/ │ │ ├── localDate.spec.js │ │ ├── momentDate.moment-timezone.spec.js │ │ ├── momentDate.moment.spec.js │ │ └── utcDate.spec.js │ ├── tsBannerGenerator.js │ ├── tuidoc.config.json │ └── webpack.config.js ├── package.json ├── playwright.config.ts └── scripts/ └── replaceLinkInReadme.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ build/ dist/ node_modules/ perf/ ================================================ FILE: .eslintrc.js ================================================ const defaultExtends = [ 'tui', 'prettier', 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended', 'plugin:prettier/recommended', ]; module.exports = { root: true, env: { browser: true, es6: true, node: true, }, parser: '@typescript-eslint/parser', parserOptions: { parser: 'typescript-eslint-parser', ecmaVersion: 2018, sourceType: 'module', }, plugins: [ 'unused-imports', 'simple-import-sort', 'prettier', 'react', 'react-hooks', '@typescript-eslint', 'jest', ], extends: defaultExtends, settings: { react: { pragma: 'h', version: '16.13', }, }, globals: { fixture: true, }, ignorePatterns: ['**/*.d.ts'], rules: { '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/ban-types': 0, '@typescript-eslint/no-use-before-define': 0, '@typescript-eslint/explicit-function-return-type': 0, '@typescript-eslint/no-explicit-any': 0, '@typescript-eslint/no-shadow': 'error', '@typescript-eslint/consistent-type-imports': 'error', 'no-duplicate-imports': 'off', '@typescript-eslint/no-duplicate-imports': 'error', 'no-shadow': 'off', 'no-use-before-define': 0, // use unused-imports plugin '@typescript-eslint/no-unused-vars': 'off', 'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-vars': [ 'warn', { args: 'after-used', argsIgnorePattern: '^_', ignoreRestSiblings: true }, ], 'react/prop-types': 0, 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'error', 'jest/no-conditional-expect': 0, 'simple-import-sort/imports': [ 'warn', { groups: [ // Side effect imports. ['^\\u0000'], // Preact. ['^preact'], // Any other packages. ['^(\\w|@)(?!src/|test/|stories/|t/)'], // Source files. ['^@src/'], // stories files ['^@stories/'], // Types. ['^@t/'], // Absolute imports and other imports such as Vue-style `@/foo`. // Anything not matched in another group. ['^'], // Relative imports. // Anything that starts with a dot. ['^\\.'], ], }, ], 'simple-import-sort/exports': 'warn', complexity: ['error', { max: 8 }], 'no-warning-comments': 0, }, overrides: [ { files: ['*.spec.ts', '*.spec.tsx'], extends: ['plugin:jest/recommended'], rules: { 'max-nested-callbacks': ['error', { max: 5 }], 'jest/expect-expect': [ 'warn', { assertFunctionNames: ['expect', 'assert*'], }, ], 'jest/no-conditional-expect': 'warn', }, }, { files: ['apps/calendar/playwright/**/*.ts'], extends: ['plugin:playwright/playwright-test'], rules: { 'playwright/no-force-option': 'off', 'max-nested-callbacks': ['error', { max: 5 }], 'dot-notation': ['error', { allowKeywords: true }], }, }, ], }; ================================================ FILE: .github/composite-actions/install-dependencies/action.yml ================================================ name: 'Install root dependencies using cache' description: 'Set Node.js and install dependencies using cache' runs: using: 'composite' steps: - name: Setup timezone uses: szenius/set-timezone@v1.0 with: timezoneLinux: 'Asia/Seoul' - name: Use Node.js 16 uses: actions/setup-node@v3 with: node-version: '16' registry-url: https://registry.npmjs.org/ cache: 'npm' - name: Install dependencies run: npm ci shell: bash ================================================ FILE: .github/workflows/publish-calendar.yml ================================================ name: Publish calendar on: [workflow_dispatch] env: WORKING_DIRECTORY: ./apps/calendar jobs: check-version: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v3 - name: Check the package version id: cpv uses: PostHog/check-package-version@v2 with: path: ${{ env.WORKING_DIRECTORY }} - name: Log when unchanged if: steps.cpv.outputs.is-new-version == 'false' run: 'echo "No version change"' - name: Cancel workflow if: steps.cpv.outputs.is-new-version == 'false' uses: andymckay/cancel-action@0.2 publish-npm: needs: check-version runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install dependencies uses: ./.github/composite-actions/install-dependencies - name: Build calendar run: | npm run build:calendar - name: Check package version id: cpv uses: PostHog/check-package-version@v2 with: path: ${{ env.WORKING_DIRECTORY }} - name: Create new version tag run: | git tag calendar@${{ steps.cpv.outputs.committed-version }} - name: Push new version tag run: | git push origin calendar@${{ steps.cpv.outputs.committed-version }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Update the relative links in README run: | PACKAGES=core npm run update:readme - name: Publish the package to npm (Calendar) working-directory: ${{ env.WORKING_DIRECTORY }} run: | npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish-cdn: needs: publish-npm runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v3 - uses: ./.github/composite-actions/install-dependencies - name: Upload files to CDN working-directory: ${{ env.WORKING_DIRECTORY }} run: | npm run build npm run publish:cdn env: TOAST_CLOUD_TENANTID: ${{ secrets.TOAST_CLOUD_TENANTID }} TOAST_CLOUD_STORAGEID: ${{ secrets.TOAST_CLOUD_STORAGEID }} TOAST_CLOUD_USERNAME: ${{ secrets.TOAST_CLOUD_USERNAME }} TOAST_CLOUD_PASSWORD: ${{ secrets.TOAST_CLOUD_PASSWORD }} ================================================ FILE: .github/workflows/publish-docs.yml ================================================ name: Publish docs on: [workflow_dispatch] env: WORKING_DIRECTORY: ./apps/calendar jobs: publish-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Get the package version id: cpv uses: PostHog/check-package-version@v2 with: path: ${{ env.WORKING_DIRECTORY }} - name: Install dependencies uses: ./.github/composite-actions/install-dependencies - name: Update the relative links in README run: | PACKAGES=core npm run update:readme - name: Prebuild docs working-directory: ${{ env.WORKING_DIRECTORY }} run: | npm run docs:prebuild - name: Change node version to 10 uses: actions/setup-node@v3 with: node-version: '10' registry-url: https://registry.npmjs.org/ - name: Install tuidoc run: | npm install -g @toast-ui/doc shell: bash - name: Build docs working-directory: ${{ env.WORKING_DIRECTORY }} run: | tuidoc shell: bash - name: Switch branch run: | git stash git switch gh-pages - name: Remove previous docs run: | rm -rf latest rm -rf ${{ steps.cpv.outputs.committed-version }} - name: Commit docs working-directory: ${{ env.WORKING_DIRECTORY }} run: | git config --local user.email "dohyung.ahn@nhn.com" git config --local user.name "Dohyung Ahn" mv -i _latest ../../latest mv _${{ steps.cpv.outputs.committed-version }} ../../${{ steps.cpv.outputs.committed-version }} git add -A git commit -m ${{ steps.cpv.outputs.committed-version }} - name: Publish new docs uses: ad-m/github-push-action@master with: github_token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages - name: Back to main branch run: | git switch main - name: Restore Node.js version to 16 uses: actions/setup-node@v3 with: node-version: '16' registry-url: https://registry.npmjs.org/ cache: 'npm' ================================================ FILE: .github/workflows/publish-wrappers.yml ================================================ name: Publish wrapper on: [workflow_dispatch] env: WORKING_DIRECTORY: ./apps/calendar REACT_WRAPPER_DIRECTORY: ./apps/react-calendar VUE_WRAPPER_DIRECTORY: ./apps/vue-calendar jobs: publish-wrapper: runs-on: ubuntu-latest steps: - name: Checkout branch uses: actions/checkout@v3 - name: Install root dependencies uses: ./.github/composite-actions/install-dependencies - name: Use Node.js 16.x uses: actions/setup-node@v1 with: node-version: '16.x' registry-url: https://registry.npmjs.org/ - name: Build core working-directory: ${{ env.WORKING_DIRECTORY }} run: | npm run build - name: Get the package version id: version uses: PostHog/check-package-version@v2 with: path: ${{ env.WORKING_DIRECTORY }}/ - name: Update version of wrappers in package.json working-directory: ${{ env.WORKING_DIRECTORY }} run: | npm run update:wrapper - name: Update lock file of react wrapper working-directory: ${{ env.REACT_WRAPPER_DIRECTORY }} run: | npm install - name: Build react wrapper working-directory: ${{ env.REACT_WRAPPER_DIRECTORY }} run: | npm run build - name: Update lock file of Vue wrapper working-directory: ${{ env.VUE_WRAPPER_DIRECTORY }} run: | npm install - name: Build vue wrapper working-directory: ${{ env.VUE_WRAPPER_DIRECTORY }} run: | npm run build - name: Commit files run: | rm -rf ./apps/react-calendar/package-lock.json rm -rf ./apps/vue-calendar/package-lock.json git config --local user.name "lja1018" git config --local user.email "jaeeon.lim@nhn.com" git add . git commit -m "chore: update version of wrappers to v${{ steps.version.outputs.committed-version }}" - name: Push version update commit uses: ad-m/github-push-action@master with: github_token: ${{ secrets.GITHUB_TOKEN }} branch: main - name: Push new version tags run: | git tag react-calendar@${{ steps.version.outputs.committed-version }} git tag vue-calendar@${{ steps.version.outputs.committed-version }} git push origin react-calendar@${{ steps.version.outputs.committed-version }} git push origin vue-calendar@${{ steps.version.outputs.committed-version }} - name: Update the relative links in README run: | PACKAGES=react,vue npm run update:readme - name: Publish react wrapper working-directory: ${{ env.REACT_WRAPPER_DIRECTORY }} run: | npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish vue wrapper working-directory: ${{ env.VUE_WRAPPER_DIRECTORY }} run: | npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .github/workflows/test.yml ================================================ name: Lint and test on: pull_request: branches: [main] workflow_call: jobs: lint: name: Lint & Type Checking runs-on: ubuntu-latest steps: - name: Checkout next branch uses: actions/checkout@v3 - name: Install dependencies uses: ./.github/composite-actions/install-dependencies - name: Lint & Type Checking run: | npm run lint test-jest: name: Test jest needs: [lint] runs-on: ubuntu-latest steps: - name: Checkout next branch uses: actions/checkout@v3 - name: Install dependencies uses: ./.github/composite-actions/install-dependencies - name: run unit test run: | npm run test test-playwright: name: Test playwright needs: [lint] runs-on: ubuntu-latest steps: - name: Checkout next branch uses: actions/checkout@v3 - name: Install dependencies uses: ./.github/composite-actions/install-dependencies - name: Install playwright dependencies run: | npx playwright install --with-deps - name: Build storybook run: | npm run storybook:build -w "@toast-ui/calendar" - name: Run E2E test run: | npm run test:playwright ================================================ FILE: .gitignore ================================================ # Logs logs *.log # Runtime data pids *.pid *.seed # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage screenshots # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directory node_modules # Bower Components bower_components lib # IDEA .idea *.iml # Window Thumbs.db Desktop.ini # MAC .DS_Store # SVN .svn # eclipse .project .metadata # build build/ storybook-static/ dist/ apps/calendar/types/ tmpdoc/ # etc *.swp etc temp api doc report karma.conf.local.js .tern-project .tern-port *.vim .\#* .vscode/ test-results/ stats.json ================================================ FILE: .husky/.gitignore ================================================ _ ================================================ FILE: .husky/pre-commit ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npx lint-staged ================================================ FILE: .prettierignore ================================================ *.md *.html ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "printWidth": 100, "tabWidth": 2, "useTabs": false, "semi": true, "quoteProps": "as-needed", "jsxSingleQuote": false, "trailingComma": "es5", "arrowParens": "always", "endOfLine": "lf", "bracketSpacing": true, "requirePragma": false, "insertPragma": false, "proseWrap": "preserve", "vueIndentScriptAndStyle": false } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at dl_javascript@nhn.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to TOAST UI First off, thanks for taking the time to contribute! 🎉 😘 ✨ The following is a set of guidelines for contributing to TOAST UI. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. ## Reporting Bugs Bugs are tracked as [GitHub issues](https://github.com/nhn/tui.calendar/issues). Search the issue list and try to reproduce on [demo](https://nhn.github.io/tui.calendar/latest/tutorial-00-calendar-app) before you create an issue. When you create an issue, please provide the following information by filling in the template. Explain the problem and include additional details to help maintainers reproduce the problem: * **Use a clear and descriptive title** for the issue to identify the problem. * **Describe the exact steps which reproduce the problem** in as many details as possible. Don't just say what you did, but explain how you did it. For example, if you moved the cursor to the end of a line, explain if you used a mouse or a keyboard. * **Provide specific examples to demonstrate the steps.** Include links to files or GitHub projects, or copy/paste-able snippets, which you use in those examples. If you're providing snippets on the issue, use Markdown code blocks. * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. * **Explain which behavior you expected to see instead and why.** * **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. ## Suggesting Enhancements In case you want to suggest for TOAST UI Calendar, please follow this guideline to help maintainers and the community understand your suggestion. Before creating suggestions, please check [issue list](https://github.com/nhn/tui.calendar/labels/Type:%20Enhancement) if there's already a request. Create an issue and provide the following information: * **Use a clear and descriptive title** for the issue to identify the suggestion. * **Provide a step-by-step description of the suggested enhancement** in as many details as possible. * **Provide specific examples to demonstrate the steps.** Include copy/paste-able snippets which you use in those examples, as Markdown code blocks. * **Include screenshots and animated GIFs** which helps demonstrate the steps or point out the part of TOAST UI Calendar which the suggestion is related to. * **Explain why this enhancement would be useful** to most TOAST UI users. * **List some other applications where this enhancement exists.** ## First Code Contribution Unsure where to begin contributing to TOAST UI? You can start by looking through these `document`, `good first issue` and `help wanted` issues: * **document issues**: issues which should be reviewed or improved. * **good first issues**: issues which should only require a few lines of code, and a test or two. * **help wanted issues**: issues which should be a bit more involved than beginner issues. ## Pull Requests ### Development WorkFlow - Set up your development environment - Make change from a right branch - Be sure the code passes `npm run lint`, `npm run test`, `npm run test:playwright` - Make a pull request ### Development environment - Prepare your machine Node.js 16+ and it's packages installed. - Checkout to the right branch - Install dependencies by `npm install` - Start development by `npm run develop` - For wrappers, `npm run develop --workspace @toast-ui/react-calendar` or `npm run develop --workspace @toast-ui/vue-calendar` ### Make changes #### Checkout a branch - **main**: PR base branch. merge features, updates for next minor or major release. - **v1**: Legacy version of the project. - **gh-pages**: API docs, examples and demo #### Check Code Style Run `npm run lint` and make sure all the tests pass. #### Test Run `npm run test` and verify all the tests pass. If you are adding new commands or features, they must include tests. If you are changing functionality, update the tests if you need to. #### Commit Follow our [commit message conventions](./docs/COMMIT_MESSAGE_CONVENTION.md). ### Yes! Pull request Make your pull request, then describe your changes. #### Title Follow other PR title format on below. ``` : Short Description (fix #111) : Short Description (fix #123, #111, #122) : Short Description (ref #111) ``` * capitalize first letter of Type * use present tense: 'change' not 'changed' or 'changes' #### Description If it has related to issues, add links to the issues(like `#123`) in the description. Fill in the [Pull Request Template](./docs/PULL_REQUEST_TEMPLATE.md) by check your case. ## Code of Conduct This project and everyone participating in it is governed by the [Code of Conduct](./CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to dl_javascript@github.com. > This Guide is base on [atom contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md), [CocoaPods](http://guides.cocoapods.org/contributing/contribute-to-cocoapods.html) and [ESLint](http://eslint.org/docs/developer-guide/contributing/pull-requests) [demo]:https://nhn.github.io/tui.calendar/latest ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 NHN Corp. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ![TOAST UI Calendar](https://user-images.githubusercontent.com/26706716/39230183-7f8ff186-48a0-11e8-8d9c-9699d2d0e471.png) > 🍞📅 A JavaScript calendar that is full featured. Now your service just got the customizable calendar. [![npm](https://img.shields.io/npm/v/@toast-ui/calendar.svg)](https://www.npmjs.com/package/@toast-ui/calendar) [![GitHub license](https://img.shields.io/github/license/nhn/tui.calendar.svg)](https://github.com/nhn/tui.calendar/blob/main/LICENSE) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-ff69b4.svg)](https://github.com/nhn/tui.calendar/labels/help%20wanted) [![code with hearth by NHN Cloud](https://img.shields.io/badge/%3C%2F%3E%20with%20%E2%99%A5%20by-NHN_Cloud-ff1414.svg)](https://github.com/nhn) ## 🚩 Table of Contents - [📦 Packages](#-packages) - [📙 Documents](#-documents) - [Collect statistics on the use of open source](#collect-statistics-on-the-use-of-open-source) - [📅 Features](#-features) - [✨ Monthly, Weekly, Daily and Various View Types](#-monthly-weekly-daily-and-various-view-types) - [Easy to Use: Dragging and Resizing a Schedule](#easy-to-use-dragging-and-resizing-a-schedule) - [Ready to Use: Default Popups](#ready-to-use-default-popups) - [🎨 Other Features](#-other-features) - [💬 Contributing](#-contributing) - [🌏 Browser Support](#-browser-support) - [🔩 Dependencies](#-dependencies) - [🍞 TOAST UI Family](#-toast-ui-family) - [🚀 Used By](#-used-by) - [📜 License](#-license) ## 📦 Packages The functionality of TOAST UI Calendar is available when using the Plain JavaScript, React, Vue Component. - [@toast-ui/calendar](/apps/calendar) - Plain JavaScript component implemented by [NHN Cloud](https://github.com/nhn). - [@toast-ui/react-calendar](/apps/react-calendar) - React wrapper component implemented by [NHN Cloud](https://github.com/nhn). - [@toast-ui/vue-calendar](/apps/vue-calendar) - Vue wrapper component implemented by [NHN Cloud](https://github.com/nhn). ## 📙 Documents - [English](./docs/README.md) - [Korean](./docs/ko/README.md) ## Collect statistics on the use of open source TOAST UI Calendar applies Google Analytics (GA) to collect statistics on the use of open source, in order to identify how widely TOAST UI Calendar is used throughout the world. It also serves as important index to determine the future course of projects. location.hostname (e.g. > “ui.toast.com") is to be collected and the sole purpose is nothing but to measure statistics on the usage. To disable GA, refer to the docs below. - [TOAST UI Calendar](/docs/en/guide/getting-started.md#disable-to-collect-hostname-for-google-analyticsga) - [TOAST UI Calendar for React](/apps/react-calendar/docs/en/guide/getting-started.md#disable-to-collect-hostname-for-google-analyticsga) - [TOAST UI Calendar for Vue](/apps/vue-calendar/docs/en/guide/getting-started.md#disable-to-collect-hostname-for-google-analyticsga) ## 📅 Features ### ✨ Monthly, Weekly, Daily and Various View Types | Monthly | Weekly | | --- | --- | | ![image](https://user-images.githubusercontent.com/26706716/39230396-4d79a592-48a1-11e8-9849-08e80f1bedf6.png) | ![image](https://user-images.githubusercontent.com/26706716/39230459-83beac38-48a1-11e8-8cd4-11b97817f1f8.png) | | Daily | 2 Weeks | | --- | --- | | ![image](https://user-images.githubusercontent.com/26706716/39230685-60a2a1d6-48a2-11e8-9d46-ce5693277a64.png) | ![image](https://user-images.githubusercontent.com/26706716/39230638-281d5266-48a2-11e8-84d8-ab289f372051.png) | ### Easy to Use: Dragging and Resizing a Schedule | Dragging | Resizing | | --- | --- | | ![image](https://user-images.githubusercontent.com/26706716/39230930-591031f8-48a3-11e8-8f62-e12e6c19920c.gif) | ![image](https://user-images.githubusercontent.com/26706716/39231671-c926d0da-48a5-11e8-959d-35fd32f2c522.gif) | ### Ready to Use: Default Popups | Creation Popup | Detail Popup | | --- | --- | | ![image](https://user-images.githubusercontent.com/26706716/39230798-d151a9ae-48a2-11e8-842d-b19b40432f48.png) | ![image](https://user-images.githubusercontent.com/26706716/39230820-e73fa11c-48a2-11e8-9348-8e3d81979a78.png) | ## 🎨 Other Features - Supports various view types: daily, weekly, monthly(6 weeks, 2 weeks, 3 weeks) - Supports efficient management of milestone and task schedules - Supports the narrow width of weekend - Supports changing start day of week - Supports customizing the date and schedule information UI(including a header and a footer of grid cell) - Supports adjusting a schedule by mouse dragging - Supports customizing UI by theme ## 💬 Contributing - [Code of Conduct](/CODE_OF_CONDUCT.md) - [Contributing Guidelines](/CONTRIBUTING.md) - [Commit Message Convention](/docs/COMMIT_MESSAGE_CONVENTION.md) - [Issue Guidelines](/docs/ISSUE_TEMPLATE.md) ## 🌏 Browser Support | Chrome Chrome | IE Internet Explorer | Edge Edge | Safari Safari | Firefox Firefox | | :---------: | :---------: | :---------: | :---------: | :---------: | | Latest | 11+ | Latest | Latest | Latest | ## 🔩 Dependencies - [Preact](https://github.com/preactjs/preact) - [Immer](https://github.com/immerjs/immer) - [DOMPurify](https://github.com/cure53/DOMPurify) - (Optional) [tui-date-picker](https://github.com/nhn/tui.date-picker) - (Optional) [tui-time-picker](https://github.com/nhn/tui.time-picker) ## 🍞 TOAST UI Family - [TOAST UI Grid](https://github.com/nhn/tui.grid) - [TOAST UI Chart](https://github.com/nhn/tui.chart) - [TOAST UI Editor](https://github.com/nhn/tui.editor) - [TOAST UI Image-Editor](https://github.com/nhn/tui.image-editor) - [TOAST UI Components](https://github.com/nhn?q=tui) ## 🚀 Used By - [NHN Dooray! - Collaboration Service (Project, Messenger, Mail, Calendar, Drive, Wiki, Contacts)](https://dooray.com) - [NCP - Commerce Platform](https://www.e-ncp.com/) - [shopby](https://www.godo.co.kr/shopby/main.gd) - [payco-shopping](https://shopping.payco.com/) - [iamTeacher](https://teacher.iamservice.net) - [linder](https://www.linder.kr) ## 📜 License This software is licensed under the [MIT](/LICENSE) © [NHN Cloud](https://github.com/nhn). ================================================ FILE: apps/calendar/.browserslistrc ================================================ > 1% last 2 versions not ie <= 10 ================================================ FILE: apps/calendar/.lintstagedrc.js ================================================ module.exports = { '**/*.js': 'eslint --fix', '**/*.{ts,tsx}': [() => 'npm run check-types', 'eslint --fix', 'jest --bail --findRelatedTests'], '**/*.css': 'stylelint', }; ================================================ FILE: apps/calendar/.storybook/main.js ================================================ const path = require('path'); module.exports = { core: { builder: 'webpack5', }, stories: ['../**/*.stories.@(ts|tsx)'], babel: async (config) => { // Replace storybook babel preset & plugins with custom ones config.presets.splice(config.presets.length - 1, 1, [ require.resolve('@babel/preset-typescript'), { jsxPragma: 'h', jsxPragmaFrag: 'Fragment' }, ]); config.plugins.splice(config.plugins.length - 1, 1, [ require.resolve('@babel/plugin-transform-react-jsx'), { pragma: 'h', pragmaFrag: 'Fragment' }, 'preset', ]); return config; }, webpackFinal: async (config) => { config.module.rules = config.module.rules .filter((rule) => !(rule?.test?.test('.css') ?? false)) .concat([ { test: /\.css$/, include: /node_modules/, use: [ require.resolve('style-loader'), { loader: require.resolve('css-loader'), options: { importLoaders: 1, }, }, ], }, { test: /\.css$/, exclude: /node_modules/, sideEffects: true, use: [ require.resolve('style-loader'), { loader: require.resolve('css-loader'), options: { importLoaders: 1, }, }, require.resolve('postcss-loader'), ], }, ]); Object.assign(config.resolve.alias, { 'core-js/modules': path.resolve(__dirname, '../../../node_modules/core-js/modules'), '@src': path.resolve(__dirname, '../src/'), '@t': path.resolve(__dirname, '../src/types/'), '@stories': path.resolve(__dirname, '../stories/'), }); return config; }, }; ================================================ FILE: apps/calendar/.storybook/manager.js ================================================ import { addons } from '@storybook/addons'; import calendarTheme from './theme'; addons.setConfig({ theme: calendarTheme, }); ================================================ FILE: apps/calendar/.storybook/preview.js ================================================ import 'preact/debug'; import '@src/css/index.css'; import 'tui-date-picker/dist/tui-date-picker.css'; import 'tui-time-picker/dist/tui-time-picker.css'; export const parameters = { layout: 'fullscreen', }; ================================================ FILE: apps/calendar/.storybook/theme.js ================================================ import { create } from '@storybook/theming'; export default create({ base: 'light', brandTitle: 'TOAST UI Calendar', brandUrl: 'https://ui.toast.com/tui-calendar', brandImage: 'https://user-images.githubusercontent.com/26706716/39230183-7f8ff186-48a0-11e8-8d9c-9699d2d0e471.png', }); ================================================ FILE: apps/calendar/README.md ================================================ # ![TOAST UI Calendar](https://user-images.githubusercontent.com/26706716/39230183-7f8ff186-48a0-11e8-8d9c-9699d2d0e471.png) > A JavaScript calendar that is full featured. Now your service just got the customizable calendar. [![npm](https://img.shields.io/npm/v/@toast-ui/calendar.svg)](https://www.npmjs.com/package/@toast-ui/calendar) [![license](https://img.shields.io/github/license/nhn/tui.calendar.svg)](https://github.com/nhn/tui.calendar/blob/master/LICENSE) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-ff69b4.svg)](https://github.com/nhn/tui.calendar/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) [![code with hearth by NHN Cloud](https://img.shields.io/badge/%3C%2F%3E%20with%20%E2%99%A5%20by-NHN_Cloud-ff1414.svg)](https://github.com/nhn) ## 🚩 Table of Contents - [📙 Documents](#-documents) - [Collect statistics on the use of open source](#collect-statistics-on-the-use-of-open-source) - [💾 Install](#-install) - [Using npm](#using-npm) - [Via Contents Delivery Network (CDN)](#via-contents-delivery-network-cdn) - [Download Source Files](#download-source-files) - [📅 Usage](#-usage) - [Load](#load) - [Implement](#implement) - [🔧 Pull Request Steps](#-pull-request-steps) - [Setup](#setup) - [Develop](#develop) - [Pull Request](#pull-request) - [💬 Contributing](#-contributing) - [📜 License](#-license) ## 📙 Documents - [English](/docs/README.md) - [Korean](/docs/ko/README.md) ## Collect statistics on the use of open source TOAST UI Calendar applies Google Analytics (GA) to collect statistics on the use of open source, in order to identify how widely TOAST UI Calendar is used throughout the world. It also serves as important index to determine the future course of projects. location.hostname (e.g. > “ui.toast.com") is to be collected and the sole purpose is nothing but to measure statistics on the usage. To disable GA, set the [`usageStatistics` option](/docs/en/apis/options.md#usagestatistics) to `false`: ```js const calendar = new Calendar('#calendar', { usageStatistics: false }); ``` ## 💾 Install ### Using npm ```sh npm install --save @toast-ui/calendar ``` ### Via Contents Delivery Network (CDN) TOAST UI products are available over the CDN powered by [NHN Cloud](https://www.toast.com). ```html ``` If you want to use a specific version, use the tag name instead of `latest` in the url's path. The CDN directory has the following structure. ``` - uicdn.toast.com/ ├─ calendar/ │ ├─ latest │ │ ├─ toastui-calendar.css │ │ ├─ toastui-calendar.js │ │ ├─ toastui-calendar.min.css │ │ ├─ toastui-calendar.min.js │ │ ├─ toastui-calendar.ie11.js │ │ ├─ toastui-calendar.ie11.min.js │ │ │ toastui-calendar.mjs │ ├─ v2.0.0/ ``` ### Download Source Files - [Download all sources for each version](https://github.com/nhn/tui.calendar/releases) ## 📅 Usage ### Load TOAST UI Calendar can be instantiated through the constructor function. There are three ways to access the constructor function depending on the environment. ```js /* ES6 module in Node.js environment */ import Calendar from '@toast-ui/calendar'; import '@toast-ui/calendar/dist/toastui-calendar.min.css'; ``` ```js /* CommonJS in Node.js environment */ const Calendar = require('@toast-ui/calendar'); require('@toast-ui/calendar/dist/toastui-calendar.min.css'); ``` ```js /* in the browser environment namespace */ const Calendar = tui.Calendar; ``` ### Implement ```html
``` ```javascript const calendar = new Calendar('#calendar', { defaultView: 'week', template: { time(event) { const { start, end, title } = event; return `${formatTime(start)}~${formatTime(end)} ${title}`; }, allday(event) { return `${event.title}`; }, }, calendars: [ { id: 'cal1', name: 'Personal', backgroundColor: '#03bd9e', }, { id: 'cal2', name: 'Work', backgroundColor: '#00a9ff', }, ], }); ``` ## 🔧 Pull Request Steps TOAST UI products are open source, so you can create a pull request(PR) after you fix issues. Run npm scripts and develop yourself with the following process. ### Setup Fork `main` branch into your personal repository. Clone it to local computer. Install node modules. Before starting development, you should check to have any errors. ``` sh git clone https://github.com/{your-personal-repo}/[[repo name]].git cd [[repo name]] npm install ``` ### Develop Let's start development! ### Pull Request Before PR, check to test lastly and then check any errors. If it has no error, commit and then push it! For more information on PR's step, please see links of Contributing section. ## 💬 Contributing - [Code of Conduct](/CODE_OF_CONDUCT.md) - [Contributing Guidelines](/CONTRIBUTING.md) - [Commit Message Convention](/docs/COMMIT_MESSAGE_CONVENTION.md) ## 📜 License This software is licensed under the [MIT](/LICENSE) © [NHN Cloud](https://github.com/nhn). ================================================ FILE: apps/calendar/examples/00-calendar-app.html ================================================ TOAST UI Calendar App Demo
TOAST UI Calendar Brand Image
================================================ FILE: apps/calendar/examples/01-monthly-view-basic.html ================================================ TOAST UI Calendar Example - Monthly View Basic
================================================ FILE: apps/calendar/examples/02-monthly-view-2weeks.html ================================================ TOAST UI Calendar Example - Monthly View Basic
================================================ FILE: apps/calendar/examples/03-monthly-view-3weeks.html ================================================ TOAST UI Calendar Example - Monthly View Basic
================================================ FILE: apps/calendar/examples/04-weekly-view.html ================================================ TOAST UI Calendar Example - Weekly View
================================================ FILE: apps/calendar/examples/05-weekly-view-no-event-view.html ================================================ TOAST UI Calendar Example - Weekly View (No Event View)
================================================ FILE: apps/calendar/examples/06-daily-view.html ================================================ TOAST UI Calendar Example - Daily View
================================================ FILE: apps/calendar/examples/07-narrow-weekends.html ================================================ TOAST UI Calendar Example - Narrow Weekends
================================================ FILE: apps/calendar/examples/08-hidden-weekends.html ================================================ TOAST UI Calendar Example - Hidden Weekends
================================================ FILE: apps/calendar/examples/09-timezone.html ================================================ TOAST UI Calendar Example - Timezone (Weekly View)
================================================ FILE: apps/calendar/examples/10-theme-common.html ================================================ TOAST UI Calendar Example - Common Theme
================================================ FILE: apps/calendar/examples/11-theme-monthly.html ================================================ TOAST UI Calendar Example - Theme for Weekly View
================================================ FILE: apps/calendar/examples/12-theme-weekly.html ================================================ TOAST UI Calendar Example - Theme for Weekly View
================================================ FILE: apps/calendar/examples/13-template-monthly.html ================================================ TOAST UI Calendar Example - Templates for Monthly View
================================================ FILE: apps/calendar/examples/14-template-weekly.html ================================================ TOAST UI Calendar Example - Template for Weekly View
================================================ FILE: apps/calendar/examples/15-template-popup.html ================================================ TOAST UI Calendar Example - Theme for Popup
================================================ FILE: apps/calendar/examples/scripts/app.js ================================================ /* eslint-disable no-var,prefer-destructuring,prefer-template,no-undef,object-shorthand,no-console */ // for testing IE11 compatibility, this file doesn't use ES6 syntax. (function (Calendar) { var cal; // Constants var CALENDAR_CSS_PREFIX = 'toastui-calendar-'; var cls = function (className) { return CALENDAR_CSS_PREFIX + className; }; // Elements var navbarRange = $('.navbar--range'); var prevButton = $('.prev'); var nextButton = $('.next'); var todayButton = $('.today'); var dropdown = $('.dropdown'); var dropdownTrigger = $('.dropdown-trigger'); var dropdownTriggerIcon = $('.dropdown-icon'); var dropdownContent = $('.dropdown-content'); var checkboxCollapse = $('.checkbox-collapse'); var sidebar = $('.sidebar'); // App State var appState = { activeCalendarIds: MOCK_CALENDARS.map(function (calendar) { return calendar.id; }), isDropdownActive: false, }; // functions to handle calendar behaviors function reloadEvents() { var randomEvents; cal.clear(); randomEvents = generateRandomEvents( cal.getViewName(), cal.getDateRangeStart(), cal.getDateRangeEnd() ); cal.createEvents(randomEvents); } function getReadableViewName(viewType) { switch (viewType) { case 'month': return 'Monthly'; case 'week': return 'Weekly'; case 'day': return 'Daily'; default: throw new Error('no view type'); } } function displayRenderRange() { var rangeStart = cal.getDateRangeStart(); var rangeEnd = cal.getDateRangeEnd(); navbarRange.textContent = getNavbarRange(rangeStart, rangeEnd, cal.getViewName()); } function setDropdownTriggerText() { var viewName = cal.getViewName(); var buttonText = $('.dropdown .button-text'); buttonText.textContent = getReadableViewName(viewName); } function toggleDropdownState() { appState.isDropdownActive = !appState.isDropdownActive; dropdown.classList.toggle('is-active', appState.isDropdownActive); dropdownTriggerIcon.classList.toggle(cls('open'), appState.isDropdownActive); } function setAllCheckboxes(checked) { var checkboxes = $$('.sidebar-item > input[type="checkbox"]'); checkboxes.forEach(function (checkbox) { checkbox.checked = checked; setCheckboxBackgroundColor(checkbox); }); } function setCheckboxBackgroundColor(checkbox) { var calendarId = checkbox.value; var label = checkbox.nextElementSibling; var calendarInfo = MOCK_CALENDARS.find(function (calendar) { return calendar.id === calendarId; }); if (!calendarInfo) { calendarInfo = { backgroundColor: '#2a4fa7', }; } label.style.setProperty( '--checkbox-' + calendarId, checkbox.checked ? calendarInfo.backgroundColor : '#fff' ); } function update() { setDropdownTriggerText(); displayRenderRange(); reloadEvents(); } function bindAppEvents() { dropdownTrigger.addEventListener('click', toggleDropdownState); prevButton.addEventListener('click', function () { cal.prev(); update(); }); nextButton.addEventListener('click', function () { cal.next(); update(); }); todayButton.addEventListener('click', function () { cal.today(); update(); }); dropdownContent.addEventListener('click', function (e) { var targetViewName; if ('viewName' in e.target.dataset) { 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') { if (appState.activeCalendarIds.length > 0) { cal.setCalendarVisibility(appState.activeCalendarIds, false); appState.activeCalendarIds = []; setAllCheckboxes(false); } else { appState.activeCalendarIds = MOCK_CALENDARS.map(function (calendar) { return calendar.id; }); cal.setCalendarVisibility(appState.activeCalendarIds, true); setAllCheckboxes(true); } } else if (appState.activeCalendarIds.indexOf(e.target.value) > -1) { appState.activeCalendarIds.splice(appState.activeCalendarIds.indexOf(e.target.value), 1); cal.setCalendarVisibility(e.target.value, false); setCheckboxBackgroundColor(e.target); } else { appState.activeCalendarIds.push(e.target.value); cal.setCalendarVisibility(e.target.value, true); setCheckboxBackgroundColor(e.target); } } }); } function bindInstanceEvents() { cal.on({ clickMoreEventsBtn: function (btnInfo) { console.log('clickMoreEventsBtn', btnInfo); }, clickEvent: function (eventInfo) { console.log('clickEvent', eventInfo); }, clickDayName: function (dayNameInfo) { console.log('clickDayName', dayNameInfo); }, selectDateTime: function (dateTimeInfo) { console.log('selectDateTime', dateTimeInfo); }, beforeCreateEvent: function (event) { console.log('beforeCreateEvent', event); event.id = chance.guid(); cal.createEvents([event]); cal.clearGridSelections(); }, beforeUpdateEvent: function (eventInfo) { var event, changes; console.log('beforeUpdateEvent', eventInfo); event = eventInfo.event; changes = eventInfo.changes; cal.updateEvent(event.id, event.calendarId, changes); }, beforeDeleteEvent: function (eventInfo) { console.log('beforeDeleteEvent', eventInfo); cal.deleteEvent(eventInfo.id, eventInfo.calendarId); }, }); } function initCheckbox() { var checkboxes = $$('input[type="checkbox"]'); checkboxes.forEach(function (checkbox) { setCheckboxBackgroundColor(checkbox); }); } function getEventTemplate(event, isAllday) { var html = []; var start = moment(event.start.toDate().toUTCString()); if (!isAllday) { html.push('' + start.format('HH:mm') + ' '); } if (event.isPrivate) { html.push(''); html.push(' Private'); } else { if (event.recurrenceRule) { html.push(''); } else if (event.attendees.length > 0) { html.push(''); } else if (event.location) { html.push(''); } html.push(' ' + event.title); } return html.join(''); } // Calendar instance with options // eslint-disable-next-line no-undef cal = new Calendar('#app', { calendars: MOCK_CALENDARS, useFormPopup: true, useDetailPopup: true, eventFilter: function (event) { var currentView = cal.getViewName(); if (currentView === 'month') { return ['allday', 'time'].includes(event.category) && event.isVisible; } return event.isVisible; }, template: { allday: function (event) { return getEventTemplate(event, true); }, time: function (event) { return getEventTemplate(event, false); }, }, }); // Init bindInstanceEvents(); bindAppEvents(); initCheckbox(); update(); })(tui.Calendar); ================================================ FILE: apps/calendar/examples/scripts/mock-data.js ================================================ /* eslint-disable */ var MOCK_CALENDARS = [ { id: '1', name: 'My Calendar', color: '#ffffff', borderColor: '#9e5fff', backgroundColor: '#9e5fff', dragBackgroundColor: '#9e5fff', }, { id: '2', name: 'Work', color: '#ffffff', borderColor: '#00a9ff', backgroundColor: '#00a9ff', dragBackgroundColor: '#00a9ff', }, { id: '3', name: 'Family', color: '#ffffff', borderColor: '#DB473F', backgroundColor: '#DB473F', dragBackgroundColor: '#DB473F', }, { id: '4', name: 'Friends', color: '#ffffff', borderColor: '#03bd9e', backgroundColor: '#03bd9e', dragBackgroundColor: '#03bd9e', }, { id: '5', name: 'Travel', color: '#ffffff', borderColor: '#bbdc00', backgroundColor: '#bbdc00', dragBackgroundColor: '#bbdc00', }, ]; var EVENT_CATEGORIES = ['milestone', 'task']; function generateRandomEvent(calendar, renderStart, renderEnd) { function generateTime(event, renderStart, renderEnd) { var startDate = moment(renderStart.getTime()); var endDate = moment(renderEnd.getTime()); var diffDate = endDate.diff(startDate, 'days'); event.isAllday = chance.bool({ likelihood: 30 }); if (event.isAllday) { event.category = 'allday'; } else if (chance.bool({ likelihood: 30 })) { event.category = EVENT_CATEGORIES[chance.integer({ min: 0, max: 1 })]; if (event.category === EVENT_CATEGORIES[1]) { event.dueDateClass = 'morning'; } } else { event.category = 'time'; } startDate.add(chance.integer({ min: 0, max: diffDate }), 'days'); startDate.hours(chance.integer({ min: 0, max: 23 })); startDate.minutes(chance.bool() ? 0 : 30); event.start = startDate.toDate(); endDate = moment(startDate); if (event.isAllday) { endDate.add(chance.integer({ min: 0, max: 3 }), 'days'); } event.end = endDate.add(chance.integer({ min: 1, max: 4 }), 'hour').toDate(); if (!event.isAllday && chance.bool({ likelihood: 20 })) { event.goingDuration = chance.integer({ min: 30, max: 120 }); event.comingDuration = chance.integer({ min: 30, max: 120 }); if (chance.bool({ likelihood: 50 })) { event.end = event.start; } } } function generateNames() { var names = []; var i = 0; var length = chance.integer({ min: 1, max: 10 }); for (; i < length; i += 1) { names.push(chance.name()); } return names; } var id = chance.guid(); var calendarId = calendar.id; var title = chance.sentence({ words: 3 }); var body = chance.bool({ likelihood: 20 }) ? chance.sentence({ words: 10 }) : ''; var isReadOnly = chance.bool({ likelihood: 20 }); var isPrivate = chance.bool({ likelihood: 20 }); var location = chance.address(); var attendees = chance.bool({ likelihood: 70 }) ? generateNames() : []; var recurrenceRule = ''; var state = chance.bool({ likelihood: 50 }) ? 'Busy' : 'Free'; var goingDuration = chance.bool({likelihood: 20}) ? chance.integer({ min: 30, max: 120 }) : 0; var comingDuration = chance.bool({likelihood: 20}) ? chance.integer({ min: 30, max: 120 }) : 0; var raw = { memo: chance.sentence(), creator: { name: chance.name(), avatar: chance.avatar(), email: chance.email(), phone: chance.phone(), }, }; var event = { id: id, calendarId: calendarId, title: title, body: body, isReadOnly: isReadOnly, isPrivate: isPrivate, location: location, attendees: attendees, recurrenceRule: recurrenceRule, state: state, goingDuration: goingDuration, comingDuration: comingDuration, raw: raw, } generateTime(event, renderStart, renderEnd); if (event.category === 'milestone') { event.color = '#000' event.backgroundColor = 'transparent'; event.borderColor = 'transparent'; event.dragBackgroundColor = 'transparent'; } return event; } function generateRandomEvents(viewName, renderStart, renderEnd) { 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); } } } }); return events; } ================================================ FILE: apps/calendar/examples/scripts/utils.js ================================================ /* eslint-disable no-var,prefer-template,no-undef */ var $ = function (selector) { return document.querySelector(selector); }; var $$ = function (selector) { return Array.prototype.slice.call(document.querySelectorAll(selector)); }; function getNavbarRange(tzStart, tzEnd, viewType) { var start = tzStart.toDate(); var end = tzEnd.toDate(); var middle; if (viewType === 'month') { middle = new Date(start.getTime() + (end.getTime() - start.getTime()) / 2); return moment(middle).format('YYYY-MM'); } if (viewType === 'day') { return moment(start).format('YYYY-MM-DD'); } if (viewType === 'week') { return moment(start).format('YYYY-MM-DD') + ' ~ ' + moment(end).format('YYYY-MM-DD'); } throw new Error('no view type'); } ================================================ FILE: apps/calendar/examples/styles/app.css ================================================ @import "https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"; .header { padding: 1rem; border-bottom: 1px solid #bbb; } .app-container { display: flex; flex-direction: column; align-items: stretch; height: 100%; } .content { display: flex; height: 100%; } .sidebar { display: flex; flex: 0 1 12rem; flex-direction: column; padding: 1.25rem; background-color: #fafafa; border-right: 1px solid #d5d5d5; } .sidebar hr { margin: 1rem 0; } .sidebar-item + .sidebar-item { margin-top: 0.75rem; } .sidebar .app-footer { margin-top: auto; font-size: 0.75rem; } .app-column { display: flex; flex-direction: column; flex: 1 0 auto; } .app-column nav { flex: 0 1 4rem; border-bottom: 1px solid #e5e5e5; } #app { flex: 1 0 auto; } .navbar { display: flex; align-items: center; padding: 1rem; } .navbar .dropdown { margin-right: 1rem; } .navbar .dropdown .toastui-calendar-icon { margin-left: 0.5rem; } .button.prev, .button.next { padding: 0.8rem; } .navbar .button + .button { margin-left: 0.25rem; } .navbar .navbar--range { margin-left: 1rem; 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; } .sidebar-item input[type="checkbox"]:not(.checkbox-all) { visibility: hidden; } .checkbox { position: relative; } .checkbox-calendar::before { content: ""; position: absolute; left: -1.5rem; width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 1px solid #ddd; } .checkbox.checkbox-1::before { background-color: var(--checkbox-1); } .checkbox.checkbox-2::before { background-color: var(--checkbox-2); } .checkbox.checkbox-3::before { background-color: var(--checkbox-3); } .checkbox.checkbox-4::before { background-color: var(--checkbox-4); } .checkbox.checkbox-5::before { background-color: var(--checkbox-5); } ================================================ FILE: apps/calendar/examples/styles/icons.css ================================================ /* font icons */ @font-face { font-family: 'tui-calendar-font-icon'; src: url('../fonts/icon.eot') format('embedded-opentype'), url('../fonts/icon.ttf') format('truetype'), url('../fonts/icon.woff') format('woff'), url('../fonts/icon.svg') format('svg'); } .calendar-icon { width: 14px; height: 14px; display: inline-block; vertical-align: middle; } .calendar-font-icon { font-family: 'tui-calendar-font-icon', sans-serif; font-size: 10px; font-weight: normal; } .img-bi { background: url('../images/img-bi.png') no-repeat; width: 215px; height: 16px; } .ic_view_month { background: url('../images/ic-view-month.png') no-repeat; } .ic_view_week { background: url('../images/ic-view-week.png') no-repeat; } .ic_view_day { background: url('../images/ic-view-day.png') no-repeat; } .ic-arrow-line-left { background: url('../images/ic-arrow-line-left.png') no-repeat; } .ic-arrow-line-right { background: url('../images/ic-arrow-line-right.png') no-repeat; } .ic-travel-time { background: url('../images/ic-traveltime-w.png') no-repeat; } /* font icons */ .ic-location-b:before { content: '\e900'; } .ic-lock-b:before { content: '\e901'; } .ic-milestone-b:before { content: '\e902'; } .ic-readonly-b:before { content: '\e903'; } .ic-repeat-b:before { content: '\e904'; } .ic-state-b:before { content: '\e905'; } .ic-user-b:before { content: '\e906'; } ================================================ FILE: apps/calendar/examples/styles/reset.css ================================================ @import url(https://fonts.googleapis.com/css?family=Noto+Sans); *, *::before, *::after { box-sizing: border-box; } * { margin: 0; } html, body { height: 100%; } body { line-height: 1.5; -webkit-font-smoothing: antialiased; font-family: 'Noto Sans', sans-serif; } img, picture, video, canvas, svg { display: block; max-width: 100%; } input, button, textarea, select { font: inherit; } p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; } ================================================ FILE: apps/calendar/jest.config.js ================================================ module.exports = { testEnvironment: 'jsdom', clearMocks: true, preset: 'ts-jest', moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node', 'd.ts'], moduleNameMapper: { '\\.(css)$': '/src/test/cssFileMock.ts', '^@src/(.*)$': '/src/$1', '^@stories/(.*)$': '/stories/$1', }, globals: { 'ts-jest': { tsconfig: '/tsconfig.test.json' } }, watchPathIgnorePatterns: ['/.storybook', '/.stories', '/node_modules/'], testPathIgnorePatterns: ['/node_modules/', '/dist/', '/playwright/'], setupFilesAfterEnv: ['/src/setupTests.ts', '/src/test/matchers.ts'], }; ================================================ FILE: apps/calendar/jsdoc.conf.json ================================================ { "source": { "include": [ "src/js/factory/calendar.js", "src/js/theme/themeConfig.js", "src/js/common/timezone.js", "README.md" ], "exclude": [], "includePattern": ".+\\.js(doc)?$", "excludePattern": "(^|\\/|\\\\)_" }, "plugins": [ "plugins/markdown" ], "templates": { "name": "Calendar", "logo": { "url": "https://cloud.githubusercontent.com/assets/12269563/20029815/01133928-a39a-11e6-80f3-12500a91c755.png", "width": "150px", "height": "13px", "link": "https://github.com/nhn/tui.jsdoc-template" } }, "opts": { "private": false, "recurse": true, "destination": "doc", "tutorials": "examples", "template": "../../node_modules/tui-jsdoc-template", "package": "package.json" } } ================================================ FILE: apps/calendar/package.json ================================================ { "name": "@toast-ui/calendar", "author": "NHN Cloud FE Development Lab ", "version": "2.1.3", "main": "./dist/toastui-calendar.js", "types": "./types/index.d.ts", "sideEffects": [ "*.css" ], "module": "./dist/toastui-calendar.mjs", "exports": { ".": { "import": "./dist/toastui-calendar.mjs", "require": "./dist/toastui-calendar.js" }, "./ie11": "./dist/toastui-calendar.ie11.js", "./esm": "./dist/toastui-calendar.mjs", "./toastui-calendar.css": "./dist/toastui-calendar.css", "./toastui-calendar.min.css": "./dist/toastui-calendar.min.css", "./dist/*": "./dist/*" }, "typesVersions": { "*": { "*": [ "./types/index.d.ts" ] } }, "license": "MIT", "description": "TOAST UI Calendar", "repository": { "type": "git", "url": "https://github.com/nhn/tui.calendar.git" }, "keywords": [ "nhn", "toast", "toastui", "toast-ui", "calendar", "fullcalendar", "daily", "weekly", "monthly", "business week", "milestone", "task", "allday" ], "files": [ "dist", "types/index.d.ts", "types/factory", "types/time/date.d.ts", "types/types/@(events|options|template|theme|eventBus).d.ts" ], "dependencies": { "immer": "^9.0.15", "isomorphic-dompurify": "^0.20.0", "preact": "^10.10.0", "preact-render-to-string": "^5.2.1", "tui-date-picker": "^4.0.1", "tui-time-picker": "^2.0.1" }, "devDependencies": { "@storybook/addons": "^6.5.9", "@storybook/builder-webpack5": "^6.5.9", "@storybook/core": "^6.5.9", "@storybook/manager-webpack5": "^6.5.9", "@storybook/preact": "^6.5.9", "@storybook/theming": "^6.5.9", "@types/chance": "^1.1.3", "chance": "^1.1.8", "css-loader": "^6.7.1", "css-minimizer-webpack-plugin": "^3.4.1", "eslint-webpack-plugin": "^3.2.0", "postcss": "^8.4.14", "postcss-loader": "^6.2.1", "postcss-prefixer": "^2.1.3", "storybook": "^6.5.9", "style-loader": "^3.3.1", "stylelint": "^14.9.1", "stylelint-config-recommended": "^8.0.0", "stylelint-webpack-plugin": "^3.3.0", "terser-webpack-plugin": "^5.3.3", "webpack-bundle-analyzer": "^4.5.0", "webpack-inject-plugin": "^1.5.5" }, "scripts": { "check-types": "tsc -p ./tsconfig.json --noEmit", "lint": "npm run check-types && eslint .", "release-note": "tuie", "build": "rimraf dist/ && concurrently 'npm:build:*'", "build:modern": "webpack --config webpack.config.js && webpack --config webpack.config.js --env minify", "build:ie11": "webpack --config webpack.config.js --env ie11 && webpack --config webpack.config.js --env minify ie11", "build:esm": "vite build", "build:types": "rimraf types/ && tsc -p ./tsconfig.declaration.json", "analyze": "webpack --config webpack.config.js --env --profile --json > stats.json && webpack-bundle-analyzer stats.json ./dist", "develop": "npm run storybook", "storybook": "start-storybook -p 6006", "storybook:build": "build-storybook", "docs:prebuild": "npm run build && tsc --outDir tmpdoc --sourceMap false", "docs:dev": "rimraf tmpdoc/ && npm run docs:prebuild && source ~/.nvm/nvm.sh && nvm use 10 && tuidoc --serv", "docs:build": "rimraf tmpdoc/ && npm run docs:prebuild && source ~/.nvm/nvm.sh && nvm use 10 && tuidoc", "publish:cdn": "node scripts/publishToCDN.js", "update:wrapper": "node scripts/updateWrapper.js" } } ================================================ FILE: apps/calendar/playwright/assertions.ts ================================================ import type { Locator, Page } from '@playwright/test'; import { expect } from '@playwright/test'; import type { FormattedTimeString } from '@t/time/datetime'; import type { BoundingBox } from './types'; import { getBoundingBox } from './utils'; export async function assertDayGridSelectionMatching( page: Page, startIdx: number, endIdx: number, cellClassName: string, selectionClassName: string ) { const startCellLocator = page.locator(cellClassName).nth(startIdx); const endCellLocator = page.locator(cellClassName).nth(endIdx); const selectionLocator = page.locator(selectionClassName); const selectionStartLocator = selectionLocator.first(); const selectionEndLocator = selectionLocator.last(); const [ startCellBoundingBox, endCellBoundingBox, selectionStartBoundingBox, selectionEndBoundingBox, ] = await Promise.all([ getBoundingBox(startCellLocator), getBoundingBox(endCellLocator), getBoundingBox(selectionStartLocator), getBoundingBox(selectionEndLocator), ]); expect(selectionStartBoundingBox.x).toBeCloseTo(startCellBoundingBox.x, -1); expect(selectionStartBoundingBox.y).toBeCloseTo(startCellBoundingBox.y, -1); expect(selectionEndBoundingBox.x + selectionEndBoundingBox.width).toBeCloseTo( endCellBoundingBox.x + endCellBoundingBox.width, -1 ); expect(selectionEndBoundingBox.y).toBeCloseTo(endCellBoundingBox.y, -1); const totalCellCount = endIdx - startIdx + 1; const totalSelectionWidth = await selectionLocator.evaluateAll((selections) => (selections as HTMLElement[]).reduce( (total, selectionRow) => selectionRow.getBoundingClientRect().width + total, 0 ) ); expect(Math.floor(totalSelectionWidth / totalCellCount)).toBeCloseTo( startCellBoundingBox.width, -1 ); } export async function assertAccumulatedDayGridSelectionMatching( page: Page, startIdx: number, endIdx: number, nthSelection: number, isAcrossWeeks: boolean ) { const cellClassName = '.toastui-calendar-daygrid-cell'; const selectionClassName = '.toastui-calendar-accumulated-grid-selection .toastui-calendar-grid-selection'; const startCellLocator = page.locator(cellClassName).nth(startIdx); const endCellLocator = page.locator(cellClassName).nth(endIdx); const selectionLocator = page.locator(selectionClassName); const selectionStartLocator = selectionLocator.nth(nthSelection); const selectionEndLocator = selectionLocator.nth(isAcrossWeeks ? nthSelection + 1 : nthSelection); const [ startCellBoundingBox, endCellBoundingBox, selectionStartBoundingBox, selectionEndBoundingBox, ] = await Promise.all([ getBoundingBox(startCellLocator), getBoundingBox(endCellLocator), getBoundingBox(selectionStartLocator), getBoundingBox(selectionEndLocator), ]); expect(selectionStartBoundingBox.x).toBeCloseTo(startCellBoundingBox.x, -1); expect(selectionStartBoundingBox.y).toBeCloseTo(startCellBoundingBox.y, -1); expect(selectionEndBoundingBox.x + selectionEndBoundingBox.width).toBeCloseTo( endCellBoundingBox.x + endCellBoundingBox.width, -1 ); expect(selectionEndBoundingBox.y).toBeCloseTo(endCellBoundingBox.y, -1); const totalCellCount = endIdx - startIdx + 1; const startSelectionWidth = await selectionStartLocator.evaluateAll((selections) => (selections as HTMLElement[]).reduce( (total, selectionRow) => selectionRow.getBoundingClientRect().width + total, 0 ) ); const endSelectionWidth = await selectionEndLocator.evaluateAll((selections) => (selections as HTMLElement[]).reduce( (total, selectionRow) => selectionRow.getBoundingClientRect().width + total, 0 ) ); const totalSelectionWidth = isAcrossWeeks ? startSelectionWidth + endSelectionWidth : (startSelectionWidth + endSelectionWidth) / 2; expect(Math.floor(totalSelectionWidth / totalCellCount)).toBeCloseTo( startCellBoundingBox.width, -1 ); } export function assertBoundingBoxIncluded(targetBox: BoundingBox, wrappingBox: BoundingBox) { expect(targetBox.x).toBeGreaterThanOrEqual(wrappingBox.x); expect(targetBox.y).toBeGreaterThanOrEqual(wrappingBox.y); expect(targetBox.x + targetBox.width).toBeLessThanOrEqual(wrappingBox.x + wrappingBox.width); expect(targetBox.y + targetBox.height).toBeLessThanOrEqual(wrappingBox.y + wrappingBox.height); } export async function assertTimeGridSelection( selectionLocator: Locator, expected: { startTop: number; endBottom: number; totalElements?: number; // not used in day view tests formattedTimes: FormattedTimeString[]; } ) { const timeGridSelectionElements = (await selectionLocator.evaluateAll( (selection) => selection )) as HTMLElement[]; const expectedFormattedTime = expected.formattedTimes.join(' - '); if (expected.totalElements) { expect(timeGridSelectionElements).toHaveLength(expected.totalElements); } await expect(selectionLocator.first()).toHaveText(expectedFormattedTime); const firstElementBoundingBox = await getBoundingBox(selectionLocator.first()); expect(firstElementBoundingBox.y).toBeCloseTo(expected.startTop, 0); const lastElementBoundingBox = await getBoundingBox(selectionLocator.last()); expect(lastElementBoundingBox.y + lastElementBoundingBox.height).toBeCloseTo( expected.endBottom, 0 ); } ================================================ FILE: apps/calendar/playwright/configs.ts ================================================ const PORT = process.env.CI ? 8080 : 6006; const generatePageUrl = (viewId: string) => `http://localhost:${PORT}/iframe.html?id=${viewId}&args=&viewMode=story`; 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 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 WEEK_VIEW_HOUR_START_OPTION_PAGE_URL = generatePageUrl( 'e2e-week-view--hour-start-option' ); export const MONTH_VIEW_EMPTY_PAGE_URL = generatePageUrl('e2e-month-view--empty'); export const MONTH_VIEW_PAGE_URL = generatePageUrl('e2e-month-view--fixed-events'); ================================================ FILE: apps/calendar/playwright/constants.ts ================================================ export enum ClickDelay { Immediate = 1, Short = 100, Long = 300, } ================================================ FILE: apps/calendar/playwright/day/timeGridEventMoving.e2e.ts ================================================ import { expect, test } from '@playwright/test'; import type { Matchers } from '@playwright/test/types/expect-types'; import type TZDate from '../../src/time/date'; import { addHours, isSameDate, setTimeStrToDate } from '../../src/time/datetime'; import type { FormattedTimeString } from '../../src/types/time/datetime'; import { mockDayViewEvents } from '../../stories/mocks/mockDayViewEvents'; import { DAY_VIEW_PAGE_URL } from '../configs'; import { dragAndDrop, getBoundingBox, getGuideTimeEventSelector, getTimeEventSelector, getTimeGridLineSelector, getTimeStrFromDate, waitForSingleElement, } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(DAY_VIEW_PAGE_URL); }); const MOVE_EVENT_SELECTOR = '[class*="dragging--move-event"]'; // Every time grid events in mockDayViewEvents should include DRAG_START_TIME. const DRAG_START_TIME = '04:00'; const cases: { title: string; step: number; matcherToCompare: Extract, 'toBeGreaterThan' | 'toBeLessThan'>; }[] = [ { title: 'to the top', step: -3, // move to 3 hours back matcherToCompare: 'toBeLessThan', }, { title: 'to the bottom', step: 5, // move to 5 hours later matcherToCompare: 'toBeGreaterThan', }, ]; const timeEvents = mockDayViewEvents.filter(({ isAllday }) => !isAllday); const [, SHORT_TIME_EVENT] = timeEvents; timeEvents.forEach(({ title: eventTitle, start, end }) => { test.describe(`Move the ${eventTitle} event in the time grid`, () => { cases.forEach(({ title, step }) => { test(`${title}`, async ({ page }) => { // Given const targetEventSelector = `[data-testid*="time-event-${eventTitle}"]`; const eventLocator = page.locator(targetEventSelector); const eventBoundingBoxBeforeMove = await getBoundingBox(eventLocator); const dragStartRowLocator = page.locator(getTimeGridLineSelector(DRAG_START_TIME)); const dragStartRowBoundingBox = await getBoundingBox(dragStartRowLocator); const targetTime = getTimeStrFromDate( addHours(setTimeStrToDate(end, DRAG_START_TIME), step) ) as FormattedTimeString; const targetRowLocator = page.locator(getTimeGridLineSelector(targetTime)); const expectedStartTimeAfterMove = getTimeStrFromDate(addHours(start, step)); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetRowLocator, options: { sourcePosition: { x: 1, y: dragStartRowBoundingBox.y - eventBoundingBoxBeforeMove.y + 1, }, targetPosition: { y: 1, x: 1, }, }, }); await waitForSingleElement(eventLocator); // Then await expect .poll(() => eventLocator.textContent()) .toMatch(new RegExp(expectedStartTimeAfterMove)); }); }); }); }); test('When pressing down the ESC key, the moving event resets to the initial position.', async ({ page, }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const eventBoundingBoxBeforeMove = await getBoundingBox(eventLocator); const targetStartTime = getTimeStrFromDate( addHours(SHORT_TIME_EVENT.end as TZDate, 1) ) as FormattedTimeString; const targetRowLocator = page.locator(getTimeGridLineSelector(targetStartTime)); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetRowLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const eventBoundingBoxAfterMove = await getBoundingBox(eventLocator); expect(eventBoundingBoxAfterMove).toEqual(eventBoundingBoxBeforeMove); }); test.describe('CSS class for a move event', () => { test('should be applied depending on a dragging state.', async ({ page }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const eventBoundingBox = await getBoundingBox(eventLocator); const moveEventClassLocator = page.locator(MOVE_EVENT_SELECTOR); // When (a drag has not started yet) await page.mouse.move(eventBoundingBox.x + 10, eventBoundingBox.y + 10); await page.mouse.down(); // Then expect(await moveEventClassLocator.count()).toBe(0); // When (a drag is working) await page.mouse.move(eventBoundingBox.x + 10, eventBoundingBox.y + 50); // Then expect(await moveEventClassLocator.count()).toBe(1); // When (a drag is finished) await page.mouse.up(); // Then expect(await moveEventClassLocator.count()).toBe(0); }); test('should not be applied when a drag is canceled.', async ({ page }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const moveEventClassLocator = page.locator(MOVE_EVENT_SELECTOR); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: eventLocator, options: { targetPosition: { x: 10, y: 30 }, }, hold: true, }); await page.keyboard.down('Escape'); // Then expect(await moveEventClassLocator.count()).toBe(0); }); }); const [LONG_TIME_EVENT] = mockDayViewEvents.filter(({ title }) => title === 'long time'); test.describe(`Calibrate event's height while dragging`, () => { cases.forEach(({ title, step, matcherToCompare }) => { test(`${title}`, async ({ page }) => { // Given const targetEventSelector = `[data-testid*="time-event-${LONG_TIME_EVENT.title}"]`; const eventLocator = page.locator(targetEventSelector); const eventBoundingBoxBeforeMove = await getBoundingBox(eventLocator); const targetTime = getTimeStrFromDate( addHours(setTimeStrToDate(LONG_TIME_EVENT.end, DRAG_START_TIME), step) ) as FormattedTimeString; const targetRowLocator = page.locator(getTimeGridLineSelector(targetTime)); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetRowLocator, hold: true, }); // Then await expect .poll(async () => { const guideBoundingBox = await getBoundingBox(eventLocator.first()); return guideBoundingBox.height; }) [matcherToCompare](eventBoundingBoxBeforeMove.height); }); }); }); const ONE_DAY_TIME_EVENTS = mockDayViewEvents.filter( ({ isAllday, start, end }) => !isAllday && isSameDate(start, end) ); ONE_DAY_TIME_EVENTS.forEach(({ title }) => { test(`The height of guide element should be same as the event element. - ${title}`, async ({ page, }) => { // Given const eventLocator = page.locator(getTimeEventSelector(title)); const eventBoundingBox = await getBoundingBox(eventLocator); const targetRowLocator = page.locator(getTimeGridLineSelector('02:00')); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetRowLocator, options: { sourcePosition: { x: 5, y: 5, }, targetPosition: { y: 5, x: 5, }, }, hold: true, }); // Then const guideLocator = page.locator(getGuideTimeEventSelector()); const guideBoundingBox = await getBoundingBox(guideLocator); expect(guideBoundingBox.height).toBeCloseTo(eventBoundingBox.height, 0); }); }); ================================================ FILE: apps/calendar/playwright/day/timeGridEventResizing.e2e.ts ================================================ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import type { Matchers } from '@playwright/test/types/expect-types'; import type TZDate from '../../src/time/date'; import { addHours, addMinutes } from '../../src/time/datetime'; import type { FormattedTimeString } from '../../src/types/time/datetime'; import { mockDayViewEvents } from '../../stories/mocks/mockDayViewEvents'; import { DAY_VIEW_PAGE_URL } from '../configs'; import { dragAndDrop, getBoundingBox, getTimeEventSelector, getTimeGridLineSelector, getTimeStrFromDate, waitForSingleElement, } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(DAY_VIEW_PAGE_URL); }); const RESIZE_HANDLER_SELECTOR = '[class*="resize-handler"]'; const RESIZE_EVENT_SELECTOR = '[class*="dragging--resize-vertical-event"]'; const cases: { title: string; step: number; matcherToCompare: Extract, 'toBeGreaterThan' | 'toBeLessThan'>; }[] = [ { title: 'to the top', step: -1, // move the end time to 1 hour back matcherToCompare: 'toBeLessThan', }, { title: 'to the bottom', step: 2, // move the end time to 2 hours later matcherToCompare: 'toBeGreaterThan', }, ]; async function setup({ page, targetEventTitle, targetEndTime, }: { page: Page; targetEventTitle: string; targetEndTime: FormattedTimeString; }) { // Given const targetEventSelector = `[data-testid*="time-event-${targetEventTitle}"]`; const eventLocator = page.locator(targetEventSelector); const eventBoundingBoxBeforeResize = await getBoundingBox(eventLocator); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const targetRowLocator = page.locator(getTimeGridLineSelector(targetEndTime)); const targetRowBoundingBox = await getBoundingBox(targetRowLocator); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: targetRowLocator, options: { sourcePosition: { x: 1, y: 1, }, targetPosition: { x: 1, y: targetRowBoundingBox.height / 2, }, }, }); await waitForSingleElement(eventLocator); const eventBoundingBoxAfterResize = await getBoundingBox(eventLocator); return { eventLocator, eventBoundingBoxBeforeResize, eventBoundingBoxAfterResize, targetRowBoundingBox, }; } const timeEvents = mockDayViewEvents.filter( ({ isAllday, goingDuration, comingDuration }) => !isAllday && !goingDuration && !comingDuration ); const [, SHORT_TIME_EVENT] = timeEvents; timeEvents.forEach(({ title: eventTitle, start, end }) => { test.describe(`Resize the ${eventTitle} event in the time grid`, () => { cases.forEach(({ title, step, matcherToCompare: compareAssertion }) => { test(`${title}`, async ({ page }) => { const targetEndTime = getTimeStrFromDate( addMinutes(end, (step * 2 - 1) * 30) ) as FormattedTimeString; const { eventLocator, eventBoundingBoxBeforeResize, eventBoundingBoxAfterResize, targetRowBoundingBox, } = await setup({ page, targetEventTitle: eventTitle, targetEndTime, }); // Then expect(eventBoundingBoxAfterResize.height)[compareAssertion]( eventBoundingBoxBeforeResize.height ); await expect.poll(() => eventLocator.textContent()).toContain(getTimeStrFromDate(start)); expect( eventBoundingBoxAfterResize.height - eventBoundingBoxBeforeResize.height ).toBeCloseTo(targetRowBoundingBox.height * step * 2, -1); }); }); test(`then it should have a minimum height(=1 row) even if the event is resized to before the start time`, async ({ page, }) => { const { eventBoundingBoxAfterResize, targetRowBoundingBox } = await setup({ page, targetEventTitle: eventTitle, targetEndTime: '00:00', }); // Then expect(eventBoundingBoxAfterResize.height).toBeCloseTo(targetRowBoundingBox.height, -1); }); }); }); test('When pressing down the ESC key, the resizing event resets to the initial size.', async ({ page, }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const eventBoundingBoxBeforeResize = await getBoundingBox(eventLocator); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const targetStartTime = getTimeStrFromDate( addHours(SHORT_TIME_EVENT.end as TZDate, 1) ) as FormattedTimeString; const targetRowLocator = page.locator(getTimeGridLineSelector(targetStartTime)); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: targetRowLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const eventBoundingBoxAfterResize = await getBoundingBox(eventLocator); expect(eventBoundingBoxAfterResize).toEqual(eventBoundingBoxBeforeResize); }); test.describe('CSS class for a resize event', () => { test('should be applied depending on a dragging state.', async ({ page }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const resizeHandlerBoundingBox = await getBoundingBox(resizeHandlerLocator); const resizeEventClassLocator = page.locator(RESIZE_EVENT_SELECTOR); // When (a drag has not started yet) await page.mouse.move(resizeHandlerBoundingBox.x + 10, resizeHandlerBoundingBox.y + 3); await page.mouse.down(); // Then expect(await resizeEventClassLocator.count()).toBe(0); // When (a drag is working) await page.mouse.move(resizeHandlerBoundingBox.x + 10, resizeHandlerBoundingBox.y + 50); // Then expect(await resizeEventClassLocator.count()).toBe(1); // When (a drag is finished) await page.mouse.up(); // Then expect(await resizeEventClassLocator.count()).toBe(0); }); test('should not be applied when a drag is canceled.', async ({ page }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const resizeEventClassLocator = page.locator(RESIZE_EVENT_SELECTOR); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: resizeHandlerLocator, options: { targetPosition: { x: 10, y: 30 }, }, hold: true, }); await page.keyboard.down('Escape'); // Then expect(await resizeEventClassLocator.count()).toBe(0); }); }); ================================================ FILE: apps/calendar/playwright/day/timeGridScrollSync.e2e.ts ================================================ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { mockDayViewEvents } from '../../stories/mocks/mockDayViewEvents'; import { DAY_VIEW_PAGE_URL } from '../configs'; import { getBoundingBox, getPrefixedClassName } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(DAY_VIEW_PAGE_URL); }); // NOTE: Syncing scroll only happens when the mousemove event is fired // and cannot use `dragAndDrop` because it's better to be manually controlled. function getScrollTop(el: HTMLElement) { return el.scrollTop; } test.describe('Scroll syncing in time grid when selecting grid', () => { /** * Top right of the column should be empty */ async function setup(page: Page) { const timeGridContainerLocator = page.locator( `${getPrefixedClassName('panel')}${getPrefixedClassName('time')}` ); const targetColumnLocator = page.locator('[data-testid*=timegrid-column]'); const containerBoundingBox = await getBoundingBox(timeGridContainerLocator); const columnBoundingBox = await getBoundingBox(targetColumnLocator); return { targetColumnLocator, timeGridContainerLocator, containerBoundingBox, columnBoundingBox, }; } test('it should sync scroll while dragging down to the bottom', async ({ page }) => { // Given const { targetColumnLocator, columnBoundingBox, timeGridContainerLocator, containerBoundingBox, } = await setup(page); const scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); // When await targetColumnLocator.hover({ position: { x: columnBoundingBox.width - 10, y: 10, }, force: true, }); await page.mouse.down(); await page.mouse.move( columnBoundingBox.x + columnBoundingBox.width / 2, containerBoundingBox.y + containerBoundingBox.height - 10 ); // Then await expect .poll(async () => { const scrollTopAfterSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopAfterSync; }) .toBeGreaterThan(scrollTopBeforeSync); }); test('it should sync scroll while dragging up to the top', async ({ page }) => { // Given const { targetColumnLocator, columnBoundingBox, timeGridContainerLocator, containerBoundingBox, } = await setup(page); // Middle of the column const xPosition = columnBoundingBox.x + columnBoundingBox.width / 2; // Scroll down to the bottom of the column await targetColumnLocator.hover(); await page.mouse.wheel(0, containerBoundingBox.height); let scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); await expect .poll(async () => { scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopBeforeSync; }) .toBeCloseTo(containerBoundingBox.height, -2); // When // drag up to the top of the column await page.mouse.move(xPosition, containerBoundingBox.y + containerBoundingBox.height - 10); await page.mouse.down(); await expect .poll(async () => { await page.mouse.move(xPosition, containerBoundingBox.y); // Then const scrollTopAfterSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopAfterSync; }) .toBeLessThan(scrollTopBeforeSync); }); }); mockDayViewEvents .filter(({ isAllday }) => !isAllday) .forEach(({ title: eventTitle }) => { test.describe(`Scroll syncing in time grid when moving the ${eventTitle} event`, () => { async function setup(page: Page) { const timeGridContainerLocator = page.locator( `${getPrefixedClassName('panel')}${getPrefixedClassName('time')}` ); const targetEventLocator = page.locator(`[data-testid*="time-event-${eventTitle}"]`); const containerBoundingBox = await getBoundingBox(timeGridContainerLocator); const eventBoundingBox = await getBoundingBox(targetEventLocator); return { timeGridContainerLocator, targetEventLocator, containerBoundingBox, eventBoundingBox, }; } test('it should sync scroll while moving event to the edge of the bottom', async ({ page, }) => { // Given const { timeGridContainerLocator, targetEventLocator, containerBoundingBox, eventBoundingBox, } = await setup(page); const scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); // When await targetEventLocator.hover({ position: { x: eventBoundingBox.width / 2, y: 3, }, force: true, }); await page.mouse.down(); await page.mouse.move( eventBoundingBox.x + eventBoundingBox.width / 2, containerBoundingBox.y + containerBoundingBox.height - 10 ); await page.mouse.up(); // Then await expect .poll(async () => { const scrollTopAfterSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopAfterSync; }) .toBeGreaterThan(scrollTopBeforeSync); }); test('it should sync scroll while moving event to the edge of the top', async ({ page }) => { // Given const { timeGridContainerLocator, targetEventLocator, containerBoundingBox, eventBoundingBox, } = await setup(page); // Let's move the event to the bottom first. const middleXOfEvent = eventBoundingBox.x + eventBoundingBox.width / 2; await targetEventLocator.hover({ position: { x: eventBoundingBox.width / 2, y: 3, }, force: true, }); await page.mouse.down(); await page.mouse.move( middleXOfEvent, containerBoundingBox.y + containerBoundingBox.height - 10 ); await page.mouse.up(); // Then scroll down a little. await page.mouse.wheel(0, containerBoundingBox.height / 2); let scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); await expect .poll(async () => { scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopBeforeSync; }) .toBeGreaterThan(containerBoundingBox.height / 2); // When await targetEventLocator.hover({ position: { x: eventBoundingBox.width / 2, y: 3, }, force: true, }); await page.mouse.down(); await expect .poll(async () => { await page.mouse.move(middleXOfEvent, containerBoundingBox.y); // Then const scrollTopAfterSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopAfterSync; }) .toBeLessThan(scrollTopBeforeSync); }); }); }); ================================================ FILE: apps/calendar/playwright/day/timeGridSelection.e2e.ts ================================================ import { expect, test } from '@playwright/test'; import { assertTimeGridSelection } from '../assertions'; import { DAY_VIEW_PAGE_URL } from '../configs'; import { ClickDelay } from '../constants'; import { dragAndDrop, getBoundingBox, getTimeGridLineSelector, waitForSingleElement, } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(DAY_VIEW_PAGE_URL); }); const GRID_SELECTION_SELECTOR = '[data-testid*="time-grid-selection"]'; // NOTE: Only firefox automatically scrolls into view at some random tests, so narrowing the range of movement. // Maybe `scrollIntoViewIfNeeded` is not supported in the firefox? // reference: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded test.describe('TimeGrid Selection', () => { const SELECT_START_TIME = '03:00'; test('should be able to select a time slot with clicking', async ({ page }) => { // Given const startGridLineLocator = page.locator(getTimeGridLineSelector(SELECT_START_TIME)); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); // When await startGridLineLocator.click({ force: true, delay: ClickDelay.Long }); await waitForSingleElement(timeGridSelectionLocator); // Test for debounced click handler. // Then await assertTimeGridSelection(timeGridSelectionLocator, { startTop: startGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, formattedTimes: ['03:00', '03:30'], }); }); test.fixme('should be able to select a time slot with double clicking', async ({ page }) => { // Given const startGridLineLocator = page.locator(getTimeGridLineSelector(SELECT_START_TIME)); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); // When await startGridLineLocator.dblclick({ force: true, delay: ClickDelay.Immediate }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { startTop: startGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, formattedTimes: ['03:00', '03:30'], }); }); test('should be able to select a range of time from top to bottom', async ({ page }) => { // Given const startGridLineLocator = page.locator(getTimeGridLineSelector(SELECT_START_TIME)); const targetGridLineLocator = page.locator(getTimeGridLineSelector('05:00')); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { formattedTimes: ['03:00', '05:30'], startTop: startGridLineBoundingBox.y, endBottom: targetGridLineBoundingBox.y + targetGridLineBoundingBox.height, }); }); test('should be able to select a range of time from bottom to top', async ({ page }) => { // Given const startGridLineLocator = page.locator(getTimeGridLineSelector(SELECT_START_TIME)); const targetGridLineLocator = page.locator(getTimeGridLineSelector('01:00')); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { formattedTimes: ['01:00', '03:30'], startTop: targetGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, }); }); }); test('When pressing down the ESC key, the grid selection is canceled.', async ({ page }) => { // Given const startGridLineLocator = page.locator(getTimeGridLineSelector('03:00')); const targetGridLineLocator = page.locator(getTimeGridLineSelector('05:00')); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const gridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); expect(await gridSelectionLocator.count()).toBe(0); }); ================================================ FILE: apps/calendar/playwright/month/accumulatedGridSelection.e2e.ts ================================================ import { test } from '@playwright/test'; import { assertAccumulatedDayGridSelectionMatching } from '../assertions'; import { MONTH_VIEW_EMPTY_PAGE_URL } from '../configs'; import { selectMonthGridCells } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(MONTH_VIEW_EMPTY_PAGE_URL); }); test('select 2 cells in each week', async ({ page }) => { await selectMonthGridCells(page, 21, 23); await selectMonthGridCells(page, 28, 30); await assertAccumulatedDayGridSelectionMatching(page, 21, 23, 0, false); }); test('select 2 cells across 2 weeks', async ({ page }) => { await selectMonthGridCells(page, 13, 14); await selectMonthGridCells(page, 20, 21); await assertAccumulatedDayGridSelectionMatching(page, 13, 14, 0, true); }); test('select cell across 2 weeks and select cell in 1 week', async ({ page }) => { await selectMonthGridCells(page, 13, 14); await selectMonthGridCells(page, 24, 25); await assertAccumulatedDayGridSelectionMatching(page, 13, 14, 0, true); }); ================================================ FILE: apps/calendar/playwright/month/eventMoving.e2e.ts ================================================ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import type { EventObject } from '../../src/types/events'; import { mockMonthViewEventsFixed } from '../../stories/mocks/mockMonthViewEvents'; import { MONTH_VIEW_PAGE_URL } from '../configs'; import { Direction } from '../types'; import { dragAndDrop, getBoundingBox, getCellSelector, getHorizontalEventSelector, waitForSingleElement, } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(MONTH_VIEW_PAGE_URL); }); const MOVE_EVENT_SELECTOR = '[class*="dragging--move-event"]'; const [TARGET_EVENT1, TARGET_EVENT2, TARGET_EVENT3] = mockMonthViewEventsFixed; const testCases: { event: EventObject; startCellIndex: number; endCellIndex: number; directions: Direction[]; }[] = [ { event: TARGET_EVENT1, startCellIndex: 7, endCellIndex: 16, directions: [Direction.Up, Direction.Right, Direction.Down], }, { event: TARGET_EVENT2, startCellIndex: 16, endCellIndex: 18, directions: [Direction.Up, Direction.Right, Direction.Down, Direction.Left], }, { event: TARGET_EVENT3, startCellIndex: 25, endCellIndex: 27, directions: [Direction.Up, Direction.Right, Direction.Down, Direction.Left], }, ]; const rightDirectionTestCases = testCases.filter((testCase) => testCase.directions.includes(Direction.Right) ); const leftDirectionTestCases = testCases.filter((testCase) => testCase.directions.includes(Direction.Left) ); const lowerDirectionTestCases = testCases.filter((testCase) => testCase.directions.includes(Direction.Down) ); const upperDirectionTestCases = testCases.filter((testCase) => testCase.directions.includes(Direction.Up) ); async function setup(page: Page, event: EventObject, targetCellIndex: number) { const targetCellLocator = page.locator(getCellSelector(targetCellIndex)); const targetCellBoundingBox = await getBoundingBox(targetCellLocator); const eventLocator = page.locator(getHorizontalEventSelector(event)).first(); const boundingBoxBeforeMoving = await getBoundingBox(eventLocator); await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetCellLocator }); await waitForSingleElement(eventLocator); let boundingBoxAfterMoving = await getBoundingBox(eventLocator); await expect .poll(async () => { boundingBoxAfterMoving = await getBoundingBox(eventLocator); return boundingBoxAfterMoving; }) .not.toEqual(boundingBoxBeforeMoving); return { targetCellBoundingBox, boundingBoxBeforeMoving, boundingBoxAfterMoving, }; } test.describe('event moving', () => { /** * Suppose we have the following cells in the month view. * Each number represents the index of the cell. * * [ * [ 0, 1, 2, 3, 4, 5, 6], * [ 7, 8, 9, 10, 11, 12, 13], * [14, 15, 16, 17, 18, 19 ,20], * [21, 22, 23, 24, 25, 26, 27], * [28, 29, 30, 31, 32, 33, 34], * ] */ rightDirectionTestCases.forEach(({ event, startCellIndex }) => { const getRightCellIndex = (cellIndex: number) => cellIndex + 1; test(`moving month event ${event.title} for direction right`, async ({ page }) => { // Given const rightCellIndex = getRightCellIndex(startCellIndex); // When const { targetCellBoundingBox, boundingBoxBeforeMoving, boundingBoxAfterMoving } = await setup(page, event, rightCellIndex); // Then expect(boundingBoxAfterMoving.x).toBeGreaterThan(boundingBoxBeforeMoving.x); expect(boundingBoxAfterMoving.x).toBeCloseTo(targetCellBoundingBox.x, 1); expect(boundingBoxAfterMoving.x).toBeLessThan( targetCellBoundingBox.x + targetCellBoundingBox.width ); }); }); leftDirectionTestCases.forEach(({ event, startCellIndex }) => { const getLeftCellIndex = (cellIndex: number) => cellIndex - 1; test(`moving month event ${event.title} for direction left`, async ({ page }) => { // Given const leftCellIndex = getLeftCellIndex(startCellIndex); // When const { targetCellBoundingBox, boundingBoxBeforeMoving, boundingBoxAfterMoving } = await setup(page, event, leftCellIndex); // Then expect(boundingBoxAfterMoving.x).toBeLessThan(boundingBoxBeforeMoving.x); expect(boundingBoxAfterMoving.x).toBeCloseTo(targetCellBoundingBox.x, 1); expect(boundingBoxAfterMoving.x).toBeLessThan( targetCellBoundingBox.x + targetCellBoundingBox.width ); }); }); lowerDirectionTestCases.forEach(({ event, startCellIndex }) => { const getDownCellIndex = (cellIndex: number) => cellIndex + 7; test(`moving month event ${event.title} for direction down`, async ({ page }) => { // Given const downCellIndex = getDownCellIndex(startCellIndex); // When const { targetCellBoundingBox, boundingBoxBeforeMoving, boundingBoxAfterMoving } = await setup(page, event, downCellIndex); // Then expect(boundingBoxAfterMoving.y).toBeGreaterThan(boundingBoxBeforeMoving.y); expect(boundingBoxAfterMoving.width).toBeCloseTo(boundingBoxBeforeMoving.width); expect(boundingBoxAfterMoving.y).toBeGreaterThan(targetCellBoundingBox.y); expect(boundingBoxAfterMoving.y).toBeLessThan( targetCellBoundingBox.y + targetCellBoundingBox.height ); }); }); upperDirectionTestCases.forEach(({ event, startCellIndex }) => { const getUpCellIndex = (cellIndex: number) => cellIndex - 7; test(`moving month event ${event.title} for direction up`, async ({ page }) => { // Given const upCellIndex = getUpCellIndex(startCellIndex); // When const { targetCellBoundingBox, boundingBoxBeforeMoving, boundingBoxAfterMoving } = await setup(page, event, upCellIndex); // Then expect(boundingBoxAfterMoving.y).toBeLessThan(boundingBoxBeforeMoving.y); expect(boundingBoxAfterMoving.width).toBeCloseTo(boundingBoxBeforeMoving.width); expect(boundingBoxAfterMoving.y).toBeGreaterThan(targetCellBoundingBox.y); expect(boundingBoxAfterMoving.y).toBeLessThan( targetCellBoundingBox.y + targetCellBoundingBox.height ); }); }); test('moving month grid event to end of week', async ({ page }) => { // Given const eventLocator = page.locator(getHorizontalEventSelector(TARGET_EVENT2)); const endOfWeekCellLocator = page.locator(getCellSelector(20)); const endOfWeekCellBoundingBox = await getBoundingBox(endOfWeekCellLocator); const secondOfWeekCellLocator = page.locator(getCellSelector(22)); const secondOfWeekCellBoundingBox = await getBoundingBox(secondOfWeekCellLocator); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: endOfWeekCellLocator }); // Then await expect.poll(() => eventLocator.evaluateAll((events) => events.length)).toBe(2); const targetEventLength = await eventLocator.evaluateAll((events) => (events as HTMLElement[]).reduce( (total, eventRow) => eventRow.getBoundingClientRect().width + total, 0 ) ); const firstEventLocator = eventLocator.first(); const lastEventLocator = eventLocator.last(); const firstEventBoundingBox = await getBoundingBox(firstEventLocator); const lastEventBoundingBox = await getBoundingBox(lastEventLocator); expect(firstEventBoundingBox.x).toBeCloseTo(endOfWeekCellBoundingBox.x, 3); expect(lastEventBoundingBox.x).toBeLessThan( secondOfWeekCellBoundingBox.x + secondOfWeekCellBoundingBox.width ); expect(firstEventBoundingBox.y).toBeLessThan(secondOfWeekCellBoundingBox.y); expect(lastEventBoundingBox.y).toBeGreaterThan(secondOfWeekCellBoundingBox.y); expect(targetEventLength).toBeCloseTo(endOfWeekCellBoundingBox.width * 3, 1); }); }); test('When pressing down the ESC key, the moving event resets to the initial position.', async ({ page, }) => { // Given const eventLocator = page.locator(getHorizontalEventSelector(TARGET_EVENT2)).first(); const eventBoundingBoxBeforeMove = await getBoundingBox(eventLocator); const targetCellLocator = page.locator(getCellSelector(20)); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetCellLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const eventBoundingBoxAfterMove = await getBoundingBox(eventLocator); expect(eventBoundingBoxAfterMove).toEqual(eventBoundingBoxBeforeMove); }); test.describe('CSS class for a move event', () => { test('should be applied depending on a dragging state.', async ({ page }) => { // Given const eventLocator = page.locator(getHorizontalEventSelector(TARGET_EVENT2)).first(); const eventBoundingBox = await getBoundingBox(eventLocator); const moveEventClassLocator = page.locator(MOVE_EVENT_SELECTOR); // When (a drag has not started yet) await page.mouse.move(eventBoundingBox.x + 10, eventBoundingBox.y + 10); await page.mouse.down(); // Then expect(await moveEventClassLocator.count()).toBe(0); // When (a drag is working) await page.mouse.move(eventBoundingBox.x + 10, eventBoundingBox.y + 50); // Then expect(await moveEventClassLocator.count()).toBe(1); // When (a drag is finished) await page.mouse.up(); // Then expect(await moveEventClassLocator.count()).toBe(0); }); test('should not be applied when a drag is canceled.', async ({ page }) => { // Given const eventLocator = page.locator(getHorizontalEventSelector(TARGET_EVENT2)).first(); const moveEventClassLocator = page.locator(MOVE_EVENT_SELECTOR); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: eventLocator, options: { targetPosition: { x: 10, y: 30 }, }, hold: true, }); await page.keyboard.down('Escape'); // Then expect(await moveEventClassLocator.count()).toBe(0); }); }); ================================================ FILE: apps/calendar/playwright/month/eventResizing.e2e.ts ================================================ import type { Locator } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { mockMonthViewEventsFixed } from '../../stories/mocks/mockMonthViewEvents'; import { assertBoundingBoxIncluded } from '../assertions'; import { MONTH_VIEW_PAGE_URL } from '../configs'; import { dragAndDrop, getBoundingBox, getCellSelector, getHorizontalEventSelector, waitForSingleElement, } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(MONTH_VIEW_PAGE_URL); }); const RESIZE_EVENT_SELECTOR = '[class*="dragging--resize-horizontal-event"]'; const [TARGET_EVENT1, TARGET_EVENT2] = mockMonthViewEventsFixed; function getResizeIconLocatorOfEvent(eventLocator: Locator) { return eventLocator.last().locator('data-testid=horizontal-event-resize-icon'); } test.describe('event resizing', () => { /** * Suppose we have the following cells in the month view. * Each number represents the index of the cell. * * [ * [ 0, 1, 2, 3, 4, 5, 6], * [ 7, 8, 9, 10, 11, 12, 13], * [14, 15, 16, 17, 18, 19 ,20], * [21, 22, 23, 24, 25, 26, 27], * [28, 29, 30, 31, 32, 33, 34], * ] */ // target event is rendered from #7 to #16 const RESIZE_TARGET_SELECTOR = getHorizontalEventSelector(TARGET_EVENT1); test('resize event to the right in the same row', async ({ page }) => { // Given const eventsLocator = page.locator(RESIZE_TARGET_SELECTOR); const resizeIconLocator = getResizeIconLocatorOfEvent(eventsLocator); const targetCellLocator = page.locator(getCellSelector(19)); const eventBoundingBoxBeforeResizing = await getBoundingBox(eventsLocator.last()); // When await dragAndDrop({ page, sourceLocator: resizeIconLocator, targetLocator: targetCellLocator }); // Then await expect.poll(() => eventsLocator.count()).toBe(2); await expect .poll(async () => { const eventBoundingBoxAfterResizing = await getBoundingBox(eventsLocator.last()); return eventBoundingBoxAfterResizing.width; }) .toBeGreaterThan(eventBoundingBoxBeforeResizing.width); const targetCellBoundingBox = await getBoundingBox(targetCellLocator); const resizeIconBoundingBoxAfterResizing = await getBoundingBox(resizeIconLocator); assertBoundingBoxIncluded(resizeIconBoundingBoxAfterResizing, targetCellBoundingBox); }); test('resize event to the left in the same row', async ({ page }) => { // Given const eventsLocator = page.locator(RESIZE_TARGET_SELECTOR); const resizeIconLocator = getResizeIconLocatorOfEvent(eventsLocator); const targetCellLocator = page.locator(getCellSelector(14)); const eventBoundingBoxBeforeResizing = await getBoundingBox(eventsLocator.last()); // When await dragAndDrop({ page, sourceLocator: resizeIconLocator, targetLocator: targetCellLocator }); // Then await expect.poll(() => eventsLocator.count()).toBe(2); await expect .poll(async () => { const eventBoundingBoxAfterResizing = await getBoundingBox(eventsLocator.last()); return eventBoundingBoxAfterResizing.width; }) .toBeLessThan(eventBoundingBoxBeforeResizing.width); const targetCellBoundingBox = await getBoundingBox(targetCellLocator); const resizeIconBoundingBoxAfterResizing = await getBoundingBox(resizeIconLocator); assertBoundingBoxIncluded(resizeIconBoundingBoxAfterResizing, targetCellBoundingBox); }); test('resize event to the right in the next two rows', async ({ page }) => { // Given const eventsLocator = page.locator(RESIZE_TARGET_SELECTOR); const resizeIconLocator = getResizeIconLocatorOfEvent(eventsLocator); const targetCellLocator = page.locator(getCellSelector(31)); // When await dragAndDrop({ page, sourceLocator: resizeIconLocator, targetLocator: targetCellLocator }); // Then await expect.poll(() => eventsLocator.count()).toBe(4); const resizeIconBoundingBoxAfterResizing = await getBoundingBox(resizeIconLocator); const targetCellBoundingBox = await getBoundingBox(targetCellLocator); assertBoundingBoxIncluded(resizeIconBoundingBoxAfterResizing, targetCellBoundingBox); }); test('resize event to the left in the next two rows', async ({ page }) => { // Given const eventsLocator = page.locator(RESIZE_TARGET_SELECTOR); const resizeIconLocator = getResizeIconLocatorOfEvent(eventsLocator); const targetCellLocator = page.locator(getCellSelector(28)); // When await dragAndDrop({ page, sourceLocator: resizeIconLocator, targetLocator: targetCellLocator }); // Then await expect.poll(() => eventsLocator.count()).toBe(4); const resizeIconBoundingBoxAfterResizing = await getBoundingBox(resizeIconLocator); const targetCellBoundingBox = await getBoundingBox(targetCellLocator); assertBoundingBoxIncluded(resizeIconBoundingBoxAfterResizing, targetCellBoundingBox); }); test('shrink event - to the end of the first row of rendered events', async ({ page }) => { // Given const eventsLocator = page.locator(RESIZE_TARGET_SELECTOR); const resizeIconLocator = getResizeIconLocatorOfEvent(eventsLocator); const targetCellLocator = page.locator(getCellSelector(13)); // When await dragAndDrop({ page, sourceLocator: resizeIconLocator, targetLocator: targetCellLocator }); await waitForSingleElement(eventsLocator); // Then const resizeIconBoundingBoxAfterResizing = await getBoundingBox(resizeIconLocator); const targetCellBoundingBox = await getBoundingBox(targetCellLocator); assertBoundingBoxIncluded(resizeIconBoundingBoxAfterResizing, targetCellBoundingBox); }); test('shrink event - to take place of just one cell', async ({ page }) => { // Given const eventsLocator = page.locator(RESIZE_TARGET_SELECTOR); const resizeIconLocator = getResizeIconLocatorOfEvent(eventsLocator); const targetCellLocator = page.locator(getCellSelector(7)); // When await dragAndDrop({ page, sourceLocator: resizeIconLocator, targetLocator: targetCellLocator }); await waitForSingleElement(eventsLocator); // Then const resizeIconBoundingBoxAfterResizing = await getBoundingBox(resizeIconLocator); const targetCellBoundingBox = await getBoundingBox(targetCellLocator); assertBoundingBoxIncluded(resizeIconBoundingBoxAfterResizing, targetCellBoundingBox); }); test('prevent resizing when dragging to above the first row of the event or left of the first cell of the event', async ({ page, }) => { // Given const eventsLocator = page.locator(RESIZE_TARGET_SELECTOR); const resizeIconLocator = getResizeIconLocatorOfEvent(eventsLocator); const targetCellLocator = page.locator(getCellSelector(0)); const expectedCellLocator = page.locator(getCellSelector(16)); // When await dragAndDrop({ page, sourceLocator: resizeIconLocator, targetLocator: targetCellLocator }); // Then await expect.poll(() => eventsLocator.count()).toBe(2); const resizeIconBoundingBoxAfterResizing = await getBoundingBox(resizeIconLocator); const expectedCellBoundingBox = await getBoundingBox(expectedCellLocator); assertBoundingBoxIncluded(resizeIconBoundingBoxAfterResizing, expectedCellBoundingBox); }); }); test('When pressing down the ESC key, the resizing event resets to the initial size.', async ({ page, }) => { // Given const eventLocator = page.locator(getHorizontalEventSelector(TARGET_EVENT2)); const eventBoundingBoxBeforeResize = await getBoundingBox(eventLocator.last()); const resizeHandlerLocator = getResizeIconLocatorOfEvent(eventLocator); const targetCellLocator = page.locator(getCellSelector(20)); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: targetCellLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const eventBoundingBoxAfterResize = await getBoundingBox(eventLocator); expect(eventBoundingBoxAfterResize).toEqual(eventBoundingBoxBeforeResize); }); test.describe('CSS class for a resize event', () => { test('should be applied depending on a dragging state.', async ({ page }) => { // Given const eventLocator = page.locator(getHorizontalEventSelector(TARGET_EVENT2)); const resizeHandlerLocator = getResizeIconLocatorOfEvent(eventLocator); const resizeHandlerBoundingBox = await getBoundingBox(resizeHandlerLocator); const resizeEventClassLocator = page.locator(RESIZE_EVENT_SELECTOR); // When (a drag has not started yet) await page.mouse.move(resizeHandlerBoundingBox.x + 1, resizeHandlerBoundingBox.y + 3); await page.mouse.down(); // Then expect(await resizeEventClassLocator.count()).toBe(0); // When (a drag is working) await page.mouse.move(resizeHandlerBoundingBox.x + 10, resizeHandlerBoundingBox.y + 50); // Then expect(await resizeEventClassLocator.count()).toBe(1); // When (a drag is finished) await page.mouse.up(); // Then expect(await resizeEventClassLocator.count()).toBe(0); }); test('should not be applied when a drag is canceled.', async ({ page }) => { // Given const eventLocator = page.locator(getHorizontalEventSelector(TARGET_EVENT2)); const resizeHandlerLocator = getResizeIconLocatorOfEvent(eventLocator); const resizeEventClassLocator = page.locator(RESIZE_EVENT_SELECTOR); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: resizeHandlerLocator, options: { targetPosition: { x: 10, y: 30 }, }, hold: true, }); await page.keyboard.down('Escape'); // Then expect(await resizeEventClassLocator.count()).toBe(0); }); }); ================================================ FILE: apps/calendar/playwright/month/gridSelection.e2e.ts ================================================ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { assertDayGridSelectionMatching } from '../assertions'; import { MONTH_VIEW_PAGE_URL } from '../configs'; import { ClickDelay } from '../constants'; import { dragAndDrop, getPrefixedClassName, selectMonthGridCells } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(MONTH_VIEW_PAGE_URL); }); const MONTH_GRID_CELL_SELECTOR = getPrefixedClassName('daygrid-cell'); const GRID_SELECTION_SELECTOR = `${getPrefixedClassName('weekday')} > ${getPrefixedClassName( 'grid-selection' )}`; /** * Suppose we have the following cells in the month view. * Each number represents the index of the cell. * * [ * [ 0, 1, 2, 3, 4, 5, 6], * [ 7, 8, 9, 10, 11, 12, 13], * [14, 15, 16, 17, 18, 19 ,20], * [21, 22, 23, 24, 25, 26, 27], * [28, 29, 30, 31, 32, 33, 34], * ] */ function assertMonthGridSelectionMatching(page: Page, startIndex: number, endIndex: number) { return assertDayGridSelectionMatching( page, startIndex, endIndex, MONTH_GRID_CELL_SELECTOR, GRID_SELECTION_SELECTOR ); } test('select a cell by clicking.', async ({ page }) => { // Given const monthGridCellLocator = page.locator(MONTH_GRID_CELL_SELECTOR).nth(31); // When await monthGridCellLocator.click({ delay: ClickDelay.Short }); // Then await assertMonthGridSelectionMatching(page, 31, 31); }); // It looks like triple click happens. // Affected by auto clearing grid selection when form popup closed. test.fixme('select a cell by double clicking.', async ({ page }) => { // Given const monthGridCellLocator = page.locator(MONTH_GRID_CELL_SELECTOR).nth(31); // When await monthGridCellLocator.dblclick({ delay: ClickDelay.Immediate }); // Then await assertMonthGridSelectionMatching(page, 31, 31); }); test('select a cell by drag and drop.', async ({ page }) => { await selectMonthGridCells(page, 31, 31); await assertMonthGridSelectionMatching(page, 31, 31); }); test('select 2 cells from left to right', async ({ page }) => { await selectMonthGridCells(page, 31, 32); await assertMonthGridSelectionMatching(page, 31, 32); }); test('select 2 cells from right to left(reverse)', async ({ page }) => { await selectMonthGridCells(page, 32, 31); await assertMonthGridSelectionMatching(page, 31, 32); }); test('select 2 rows from top to bottom', async ({ page }) => { await selectMonthGridCells(page, 25, 32); await assertMonthGridSelectionMatching(page, 25, 32); }); test('select 2 rows from bottom to top(reverse)', async ({ page }) => { await selectMonthGridCells(page, 32, 25); await assertMonthGridSelectionMatching(page, 25, 32); }); test('select entire row', async ({ page }) => { await selectMonthGridCells(page, 28, 34); await assertMonthGridSelectionMatching(page, 28, 34); }); test('event form popup with grid selection', async ({ page }) => { await selectMonthGridCells(page, 28, 34); const floatingLayer = page.locator('css=[role=dialog]'); expect(floatingLayer).not.toBeNull(); }); test('When pressing down the ESC key, the grid selection is canceled.', async ({ page }) => { // Given const startCellLocator = page.locator(MONTH_GRID_CELL_SELECTOR).nth(31); const targetCellLocator = page.locator(MONTH_GRID_CELL_SELECTOR).nth(32); // When await dragAndDrop({ page, sourceLocator: startCellLocator, targetLocator: targetCellLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const gridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); expect(await gridSelectionLocator.count()).toBe(0); }); ================================================ FILE: apps/calendar/playwright/month/seeMoreEventsPopup.e2e.ts ================================================ import { expect, test } from '@playwright/test'; import { MONTH_VIEW_PAGE_URL } from '../configs'; test.beforeEach(async ({ page }) => { await page.goto(MONTH_VIEW_PAGE_URL); }); test.describe('more events popup', () => { test('when clicking on "more events" button, popup should be visible', async ({ page }) => { await page.click('text=/\\d+ more/i >> nth=0'); const popupLocator = page.locator('css=[role=dialog]'); expect(await popupLocator.isVisible()).toBe(true); const listLocator = popupLocator.locator('css=[class*=list]'); const listItemCount = await listLocator.evaluate((list) => list.children.length); expect(listItemCount).toBeGreaterThan(0); }); }); ================================================ FILE: apps/calendar/playwright/month/visibleEventCount.e2e.ts ================================================ import { expect, test } from '@playwright/test'; import { MONTH_VIEW_PAGE_URL } from '../configs'; import { getCellSelector, getPrefixedClassName } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(MONTH_VIEW_PAGE_URL); }); const eventsRowClassName = getPrefixedClassName('weekday-events'); test.describe('visibleEventCount option', () => { // In the default viewport, 3 event blocks are visible. // 16th cell has 12 events. test('when visibleEventCount is set to 0, no events should be visible', async ({ page }) => { // Given const targetCell = page.locator(getCellSelector(16)); const events = page.locator(eventsRowClassName).nth(2); // When await page.evaluate(() => { window.$cal.setOptions({ month: { visibleEventCount: 0, }, }); }); // Then expect(await events.evaluate((_events) => _events.children.length)).toBe(0); const moreButton = targetCell.locator('button'); await expect(moreButton).toHaveText('12 more'); }); test('when visibleEventCount is bigger than the content area, it only shows events within the content area', async ({ page, }) => { // Given const targetCell = page.locator(getCellSelector(16)); const events = page.locator(eventsRowClassName).nth(2); // When await page.evaluate(() => { window.$cal.setOptions({ month: { visibleEventCount: 10, }, }); }); // Then expect(await events.evaluate((_events) => _events.children.length)).toBe(3); const moreButton = targetCell.locator('button'); await expect(moreButton).toHaveText('9 more'); }); }); ================================================ FILE: apps/calendar/playwright/playwright-env.d.ts ================================================ import type Calendar from '../src/factory/calendar'; declare global { interface Window { $cal: Calendar; } } ================================================ FILE: apps/calendar/playwright/types.ts ================================================ export type BoundingBox = { x: number; y: number; width: number; height: number; }; export enum Direction { Up = 0, UpperRight = 1, Right = 2, LowerRight = 3, Down = 4, LowerLeft = 5, Left = 6, UpperLeft = 7, } ================================================ FILE: apps/calendar/playwright/utils.ts ================================================ import type { Locator, Page } from '@playwright/test'; import { expect } from '@playwright/test'; import type TZDate from '../src/time/date'; import type { EventObject } from '../src/types/events'; import type { FormattedTimeString } from '../src/types/time/datetime'; import type { BoundingBox } from './types'; export function getPrefixedClassName(className: string) { return `.toastui-calendar-${className}`; } export async function dragAndDrop({ page, sourceLocator, targetLocator, options = {}, hold = false, }: { page: Page; sourceLocator: Locator; targetLocator: Locator; options?: Parameters[1]; hold?: boolean; }) { const sourceBoundingBox = await getBoundingBox(sourceLocator); const targetBoundingBox = await getBoundingBox(targetLocator); const sourceX = sourceBoundingBox.x + (options?.sourcePosition?.x ?? sourceBoundingBox.width / 2); const sourceY = sourceBoundingBox.y + (options?.sourcePosition?.y ?? sourceBoundingBox.height / 2); const targetX = targetBoundingBox.x + (options?.targetPosition?.x ?? targetBoundingBox.width / 2); const targetY = targetBoundingBox.y + (options?.targetPosition?.y ?? targetBoundingBox.height / 2); await page.mouse.move(sourceX, sourceY); await page.mouse.down(); await page.mouse.move(targetX, targetY, { steps: 4 }); if (!hold) { await page.mouse.up(); } } export async function selectGridCells( page: Page, startCellIdx: number, endCellIdx: number, className: string ) { const startCellLocator = page.locator(className).nth(startCellIdx); const endCellLocator = page.locator(className).nth(endCellIdx); await dragAndDrop({ page, sourceLocator: startCellLocator, targetLocator: endCellLocator }); } export function selectMonthGridCells(page: Page, startCellIndex: number, endCellIndex: number) { return selectGridCells(page, startCellIndex, endCellIndex, '.toastui-calendar-daygrid-cell'); } export async function getBoundingBox(locator: Locator): Promise { const boundingBox = await locator.boundingBox(); if (!boundingBox) { throw new Error(`BoundingBox of ${locator} is not found`); } return boundingBox; } export function getTimeEventSelector(title: string): string { return `[data-testid^="time-event-${title}-"]`; } export function getGuideTimeEventSelector(): string { return `[data-testid^="guide-time-event"]`; } export function getHorizontalEventSelector(event: EventObject): string { return `data-testid=${event.calendarId}-${event.id}-${event.title}`; } export function getTimeGridLineSelector(start: FormattedTimeString): string { return `[data-testid*="gridline-${start}"]`; } export function getCellSelector(cellIndex: number): string { return `.toastui-calendar-daygrid-cell >> nth=${cellIndex}`; } export function getTimeStrFromDate(d: TZDate) { const fixToTwoDigits = (num: number) => num.toString().padStart(2, '0'); const hour = d.getHours(); const minute = d.getMinutes(); return `${fixToTwoDigits(hour)}:${fixToTwoDigits(minute)}`; } export function waitForSingleElement(locator: Locator) { return expect.poll(() => locator.count()).toBe(1); } /** * Get locator matches testId. */ export function queryLocatorByTestId(page: Page, testId: string) { return page.locator(`[data-testid*="${testId}"]`); } ================================================ FILE: apps/calendar/playwright/week/alldayGridEventMoving.e2e.ts ================================================ import type { Locator } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { mockWeekViewEvents } from '../../stories/mocks/mockWeekViewEvents'; import { WEEK_VIEW_PAGE_URL } from '../configs'; import { dragAndDrop, getBoundingBox, getHorizontalEventSelector, getPrefixedClassName, } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_PAGE_URL); }); const ALL_DAY_GRID_CELL_SELECTOR = `${getPrefixedClassName( 'panel' )}:has-text("All Day") ${getPrefixedClassName('panel-grid')}`; const MOVE_EVENT_SELECTOR = '[class*="dragging--move-event"]'; const [TARGET_EVENT] = mockWeekViewEvents.filter(({ isAllday }) => isAllday); const TARGET_EVENT_SELECTOR = getHorizontalEventSelector(TARGET_EVENT); async function getX(locator: Locator) { const boundingBox = await getBoundingBox(locator); return boundingBox.x; } async function getWidth(locator: Locator) { const boundingBox = await getBoundingBox(locator); return boundingBox.width; } /** * Suppose we have the following cells in the week view. * Each number represents the index of the cell. * * [ 0, 1, 2, 3, 4, 5, 6] */ test('moving allday grid row event from left to right', async ({ page }) => { // Given const targetEventLocator = page.locator(TARGET_EVENT_SELECTOR); const boundingBoxBeforeMoving = await getBoundingBox(targetEventLocator); const fifthOfWeekCellLocator = page.locator(ALL_DAY_GRID_CELL_SELECTOR).nth(4); // When await dragAndDrop({ page, sourceLocator: targetEventLocator, targetLocator: fifthOfWeekCellLocator, }); // Then await expect.poll(() => getX(targetEventLocator)).toBeGreaterThan(boundingBoxBeforeMoving.x); await expect .poll(() => getWidth(targetEventLocator)) .toBeCloseTo(boundingBoxBeforeMoving.width, 3); }); test.describe('moving allday grid row event when moving by holding the middle or end', () => { test('holding middle of event', async ({ page }) => { // Given const targetEventLocator = page.locator(TARGET_EVENT_SELECTOR); const boundingBoxBeforeMoving = await getBoundingBox(targetEventLocator); const fourthOfWeekCellLocator = page.locator(ALL_DAY_GRID_CELL_SELECTOR).nth(3); // When await dragAndDrop({ page, sourceLocator: targetEventLocator, targetLocator: fourthOfWeekCellLocator, }); // Then await expect .poll(() => getX(targetEventLocator)) .toBeCloseTo(boundingBoxBeforeMoving.x + (boundingBoxBeforeMoving.width * 2) / 3, 1); await expect .poll(() => getX(targetEventLocator)) .toBeLessThan(boundingBoxBeforeMoving.x + boundingBoxBeforeMoving.width); }); test('holding end of event', async ({ page }) => { // Given const targetEventLocator = page.locator(TARGET_EVENT_SELECTOR); const boundingBoxBeforeMoving = await getBoundingBox(targetEventLocator); const fourthOfWeekCellLocator = page.locator(ALL_DAY_GRID_CELL_SELECTOR).nth(3); // When await dragAndDrop({ page, sourceLocator: targetEventLocator, targetLocator: fourthOfWeekCellLocator, options: { sourcePosition: { x: (boundingBoxBeforeMoving.width * 5) / 6, y: boundingBoxBeforeMoving.height / 2, }, }, }); // Then await expect .poll(() => getX(targetEventLocator)) .toBeCloseTo(boundingBoxBeforeMoving.x + boundingBoxBeforeMoving.width / 3, 1); await expect .poll(() => getX(targetEventLocator)) .toBeLessThan(boundingBoxBeforeMoving.x + boundingBoxBeforeMoving.width); }); }); test('When pressing down the ESC key, the moving event resets to the initial position.', async ({ page, }) => { // Given const eventLocator = page.locator(TARGET_EVENT_SELECTOR); const eventBoundingBoxBeforeMove = await getBoundingBox(eventLocator); const targetCellLocator = page.locator(ALL_DAY_GRID_CELL_SELECTOR).nth(4); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetCellLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const eventBoundingBoxAfterMove = await getBoundingBox(eventLocator); expect(eventBoundingBoxAfterMove).toEqual(eventBoundingBoxBeforeMove); }); test.describe('CSS class for a move event', () => { test('should be applied depending on a dragging state.', async ({ page }) => { // Given const eventLocator = page.locator(TARGET_EVENT_SELECTOR); const eventBoundingBox = await getBoundingBox(eventLocator); const moveEventClassLocator = page.locator(MOVE_EVENT_SELECTOR); // When (a drag has not started yet) await page.mouse.move(eventBoundingBox.x + 10, eventBoundingBox.y + 10); await page.mouse.down(); // Then expect(await moveEventClassLocator.count()).toBe(0); // When (a drag is working) await page.mouse.move(eventBoundingBox.x + 10, eventBoundingBox.y + 50); // Then expect(await moveEventClassLocator.count()).toBe(1); // When (a drag is finished) await page.mouse.up(); // Then expect(await moveEventClassLocator.count()).toBe(0); }); test('should not be applied when a drag is canceled.', async ({ page }) => { // Given const eventLocator = page.locator(TARGET_EVENT_SELECTOR); const moveEventClassLocator = page.locator(MOVE_EVENT_SELECTOR); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: eventLocator, options: { targetPosition: { x: 10, y: 30 }, }, }); await page.keyboard.down('Escape'); // Then expect(await moveEventClassLocator.count()).toBe(0); }); }); ================================================ FILE: apps/calendar/playwright/week/alldayGridEventResizing.e2e.ts ================================================ import type { Locator } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { mockWeekViewEvents } from '../../stories/mocks/mockWeekViewEvents'; import { WEEK_VIEW_PAGE_URL } from '../configs'; import { dragAndDrop, getBoundingBox, getHorizontalEventSelector, getPrefixedClassName, } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_PAGE_URL); }); const ALL_DAY_GRID_CELL_SELECTOR = `${getPrefixedClassName( 'panel' )}:has-text("All Day") ${getPrefixedClassName('panel-grid')}`; const RESIZE_HANDLER_SELECTOR = getPrefixedClassName('handle-y'); const RESIZE_EVENT_SELECTOR = '[class*="dragging--resize-horizontal-event"]'; const [TARGET_EVENT] = mockWeekViewEvents.filter(({ isAllday }) => isAllday); const TARGET_EVENT_SELECTOR = getHorizontalEventSelector(TARGET_EVENT); async function getWidth(locator: Locator) { const boundingBox = await getBoundingBox(locator); return boundingBox.width; } /** * Suppose we have the following cells in the week view. * Each number represents the index of the cell. * * [ 0, 1, 2, 3, 4, 5, 6] */ test('resizing allday grid row event from left to right', async ({ page }) => { // Given const targetEventLocator = page.locator(TARGET_EVENT_SELECTOR); const boundingBoxBeforeResizing = await getBoundingBox(targetEventLocator); const resizerLocator = targetEventLocator.locator(RESIZE_HANDLER_SELECTOR); const endOfWeekCellLocator = page.locator(ALL_DAY_GRID_CELL_SELECTOR).last(); // When await dragAndDrop({ page, sourceLocator: resizerLocator, targetLocator: endOfWeekCellLocator }); // Then await expect .poll(() => getWidth(targetEventLocator)) .toBeGreaterThan(boundingBoxBeforeResizing.width); }); test.describe('When pressing down the ESC key', () => { test('the resizing event resets to the initial size.', async ({ page }) => { // Given const eventLocator = page.locator(TARGET_EVENT_SELECTOR); const eventBoundingBoxBeforeResize = await getBoundingBox(eventLocator); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const targetCellLocator = page.locator(ALL_DAY_GRID_CELL_SELECTOR).nth(4); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: targetCellLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const eventBoundingBoxAfterResize = await getBoundingBox(eventLocator); expect(eventBoundingBoxAfterResize).toEqual(eventBoundingBoxBeforeResize); }); }); test.describe('CSS class for a resize event', () => { test('should be applied depending on a dragging state.', async ({ page }) => { // Given const eventLocator = page.locator(TARGET_EVENT_SELECTOR); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const resizeHandlerBoundingBox = await getBoundingBox(resizeHandlerLocator); const resizeEventClassLocator = page.locator(RESIZE_EVENT_SELECTOR); // When (a drag has not started yet) await page.mouse.move(resizeHandlerBoundingBox.x + 1, resizeHandlerBoundingBox.y + 3); await page.mouse.down(); // Then expect(await resizeEventClassLocator.count()).toBe(0); // When (a drag is working) await page.mouse.move(resizeHandlerBoundingBox.x + 10, resizeHandlerBoundingBox.y + 50); // Then expect(await resizeEventClassLocator.count()).toBe(1); // When (a drag is finished) await page.mouse.up(); // Then expect(await resizeEventClassLocator.count()).toBe(0); }); test('should not be applied when a drag is canceled.', async ({ page }) => { // Given const eventLocator = page.locator(TARGET_EVENT_SELECTOR); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const resizeEventClassLocator = page.locator(RESIZE_EVENT_SELECTOR); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: resizeHandlerLocator, options: { targetPosition: { x: 10, y: 30 }, }, hold: true, }); await page.keyboard.down('Escape'); // Then expect(await resizeEventClassLocator.count()).toBe(0); }); }); ================================================ FILE: apps/calendar/playwright/week/dayGridSelection.e2e.ts ================================================ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { assertDayGridSelectionMatching } from '../assertions'; import { WEEK_VIEW_PAGE_URL } from '../configs'; import { ClickDelay } from '../constants'; import { dragAndDrop, getPrefixedClassName, selectGridCells } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_PAGE_URL); }); const WEEK_GRID_CELL_SELECTOR = getPrefixedClassName('panel-grid'); const DAY_GRID_SELECTION_SELECTOR = getPrefixedClassName('grid-selection'); function selectWeekGridCells(page: Page, startCellIndex: number, endCellIndex: number) { return selectGridCells(page, startCellIndex, endCellIndex, WEEK_GRID_CELL_SELECTOR); } function assertWeekGridSelectionMatching(page: Page, startIndex: number, endIndex: number) { return assertDayGridSelectionMatching( page, startIndex, endIndex, WEEK_GRID_CELL_SELECTOR, DAY_GRID_SELECTION_SELECTOR ); } test('select a cell by clicking.', async ({ page }) => { // Given const weekGridCellLocator = page.locator(WEEK_GRID_CELL_SELECTOR).nth(14); // When await weekGridCellLocator.click({ delay: ClickDelay.Short }); // Then await assertWeekGridSelectionMatching(page, 14, 14); }); test('select a cell by double clicking.', async ({ page }) => { // Given const weekGridCellLocator = page.locator(WEEK_GRID_CELL_SELECTOR).nth(14); // When await weekGridCellLocator.dblclick({ delay: ClickDelay.Immediate }); // Then await assertWeekGridSelectionMatching(page, 14, 14); }); test('select 2 cells from left to right', async ({ page }) => { await selectWeekGridCells(page, 14, 15); await assertWeekGridSelectionMatching(page, 14, 15); }); test('select 2 cells from right to left(reverse)', async ({ page }) => { await selectWeekGridCells(page, 15, 14); await assertWeekGridSelectionMatching(page, 14, 15); }); test('event form popup with grid selection', async ({ page }) => { await selectWeekGridCells(page, 14, 15); const floatingLayer = page.locator('css=[role=dialog]'); expect(floatingLayer).not.toBeNull(); }); test('When pressing down the ESC key, the grid selection is canceled.', async ({ page }) => { // Given const startCellLocator = page.locator(WEEK_GRID_CELL_SELECTOR).nth(14); const targetCellLocator = page.locator(WEEK_GRID_CELL_SELECTOR).nth(15); // When await dragAndDrop({ page, sourceLocator: startCellLocator, targetLocator: targetCellLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const gridSelectionLocator = page.locator(DAY_GRID_SELECTION_SELECTOR); expect(await gridSelectionLocator.count()).toBe(0); }); ================================================ FILE: apps/calendar/playwright/week/hourStartOption.e2e.ts ================================================ import { expect, test } from '@playwright/test'; import { WEEK_VIEW_PAGE_URL } from '../configs'; import { dragAndDrop, getBoundingBox, getTimeGridLineSelector } from '../utils'; // Regression test for #1228 test.describe('Moving events with week.hourStart option', () => { test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_PAGE_URL); }); test('it should be able to move an event when the week.hourStart option is set', async ({ page, }) => { // Given await page.evaluate(() => { window.$cal.setOptions({ week: { hourStart: 4, }, }); }); const targetHourTextToMove = '08:00'; const targetEventLocator = page.locator('text=/short time event/'); const targetRowLocator = page.locator(getTimeGridLineSelector(targetHourTextToMove)); const { y: rowY } = await getBoundingBox(targetRowLocator); const { x: eventX } = await getBoundingBox(targetEventLocator); // When await dragAndDrop({ page, sourceLocator: targetEventLocator, targetLocator: targetRowLocator, options: { targetPosition: { x: eventX + 10, y: 10, }, }, }); // Then const eventBoundingBoxAfterMoving = await getBoundingBox(targetEventLocator); expect(eventBoundingBoxAfterMoving.y).toBeCloseTo(rowY, -1); const parentText = await targetEventLocator.evaluate((el) => el?.parentElement?.textContent); expect(parentText).toContain(targetHourTextToMove); }); }); ================================================ FILE: apps/calendar/playwright/week/primaryTimezone.e2e.ts ================================================ import { expect, test } from '@playwright/test'; import { mockWeekViewEvents } from '../../stories/mocks/mockWeekViewEvents'; import { WEEK_VIEW_TIMEZONE_PAGE_URL } from '../configs'; import { queryLocatorByTestId } from '../utils'; const [mockEvent] = mockWeekViewEvents.filter(({ title }) => title.includes('short')); // From local timezone (+09:00) to target timezone (+05:00) const TARGET_TIMEZONE_HOUR_DIFFERENCE = 4; test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_TIMEZONE_PAGE_URL); }); test.describe('Primary Timezone', () => { test(`${mockEvent.title} should be rendered at the top of the time grid in Pakistan Standard Time`, async ({ page, }) => { // Given const targetEventLocator = queryLocatorByTestId(page, `time-event-${mockEvent.title}`); const originalStartTime = mockEvent.start.toDate(); const originalStartHour = originalStartTime.getHours(); const originalStartMinute = originalStartTime.getMinutes(); const expectedEventStartTimeStr = `${String( originalStartHour - TARGET_TIMEZONE_HOUR_DIFFERENCE ).padStart(2, '0')}:${String(originalStartMinute).padStart(2, '0')}`; // When // Rendered // Then await expect(targetEventLocator).toHaveText(new RegExp(expectedEventStartTimeStr)); }); }); ================================================ FILE: apps/calendar/playwright/week/timeGridEventClick.e2e.ts ================================================ import { expect, test } from '@playwright/test'; import type { BoundingBox } from 'playwright/types'; import { mockWeekViewEvents } from '../../stories/mocks/mockWeekViewEvents'; import { WEEK_VIEW_DUPLICATE_EVENTS_PAGE_URL, WEEK_VIEW_PAGE_URL } from '../configs'; import { getBoundingBox, getTimeEventSelector } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_PAGE_URL); }); const targetEvents = mockWeekViewEvents.filter(({ isAllday }) => !isAllday); targetEvents.forEach(({ title }) => { test(`Click event: show popup when ${title} is clicked`, async ({ page }) => { // Given const targetEventSelector = getTimeEventSelector(title); const targetEventLocator = page.locator(targetEventSelector).last(); const targetEventBoundingBox = await getBoundingBox(targetEventLocator); // When await page.mouse.move(targetEventBoundingBox.x + 2, targetEventBoundingBox.y + 2); await page.mouse.down(); await page.mouse.up(); // Then const detailPopup = page.locator('css=[role=dialog]'); 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.', async ({ 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)); // When // Nothing // Then const promiseBoundingBoxes: Promise[] = []; sortedDuplicateEvents.forEach((event) => { const eventLocator = page.locator(getTimeEventSelector(event.title)); promiseBoundingBoxes.push(getBoundingBox(eventLocator)); }); await Promise.all(promiseBoundingBoxes).then((eventBoundingBoxes) => { let prevX = -1; eventBoundingBoxes.forEach(({ x }) => { 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); }); }); ================================================ FILE: apps/calendar/playwright/week/timeGridEventMoving.e2e.ts ================================================ import type { Locator } from '@playwright/test'; import { expect, test } from '@playwright/test'; import type TZDate from '../../src/time/date'; import { addHours, isSameDate } from '../../src/time/datetime'; import type { FormattedTimeString } from '../../src/types/time/datetime'; import { mockWeekViewEvents } from '../../stories/mocks/mockWeekViewEvents'; import { WEEK_VIEW_PAGE_URL } from '../configs'; import { Direction } from '../types'; import { dragAndDrop, getBoundingBox, getGuideTimeEventSelector, getTimeEventSelector, getTimeGridLineSelector, getTimeStrFromDate, } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_PAGE_URL); }); const TIME_EVENTS = mockWeekViewEvents.filter(({ isAllday }) => !isAllday); const [TWO_VIEW_EVENT, SHORT_TIME_EVENT, LONG_TIME_EVENT] = TIME_EVENTS; const MOVE_EVENT_SELECTOR = '[class*="dragging--move-event"]'; /** * 8 Directions for testing * * 7 0 1 * 6 2 * 5 4 3 */ const cases: { title: string; eventColumnIndex: number; directionInfo: { direction: Direction; startTimeAfterMoving: FormattedTimeString; timeToDrop: FormattedTimeString; }[]; }[] = [ { title: TWO_VIEW_EVENT.title, eventColumnIndex: 0, directionInfo: [ { direction: Direction.Right, startTimeAfterMoving: '10:00', timeToDrop: '00:00', }, { direction: Direction.LowerRight, startTimeAfterMoving: '12:00', timeToDrop: '02:00', }, { direction: Direction.Down, startTimeAfterMoving: '12:00', timeToDrop: '02:00', }, ], }, { title: SHORT_TIME_EVENT.title, eventColumnIndex: 3, directionInfo: [ { direction: Direction.Up, startTimeAfterMoving: '02:00', timeToDrop: '02:00', }, { direction: Direction.UpperRight, startTimeAfterMoving: '02:00', timeToDrop: '02:00', }, { direction: Direction.Right, startTimeAfterMoving: '04:00', timeToDrop: '04:00', }, { direction: Direction.LowerRight, startTimeAfterMoving: '06:00', timeToDrop: '06:00', }, { direction: Direction.Down, startTimeAfterMoving: '06:00', timeToDrop: '06:00', }, { direction: Direction.LowerLeft, startTimeAfterMoving: '06:00', timeToDrop: '06:00', }, { direction: Direction.Left, startTimeAfterMoving: '04:00', timeToDrop: '04:00', }, { direction: Direction.UpperLeft, startTimeAfterMoving: '02:00', timeToDrop: '02:00', }, ], }, { title: LONG_TIME_EVENT.title, eventColumnIndex: 6, directionInfo: [ { direction: Direction.Down, startTimeAfterMoving: '12:00', timeToDrop: '02:00', }, { direction: Direction.LowerLeft, startTimeAfterMoving: '12:00', timeToDrop: '02:00', }, { direction: Direction.Left, startTimeAfterMoving: '10:00', timeToDrop: '00:00', }, ], }, ]; cases.forEach(({ title, eventColumnIndex, directionInfo }) => { directionInfo.forEach(({ direction, startTimeAfterMoving, timeToDrop }) => { test(`Moving event: ${title} for ${direction}`, async ({ page }) => { // Given const eventLocator = page.locator(getTimeEventSelector(title)).last(); const targetRowLocator = page.locator(getTimeGridLineSelector(timeToDrop)); const targetColumnLocator = page.locator(`data-testid=timegrid-column-${eventColumnIndex}`); const targetColumnBoundingBox = await getBoundingBox(targetColumnLocator); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetRowLocator, options: { sourcePosition: { x: 5, y: 5, }, targetPosition: { y: 5, x: targetColumnBoundingBox.x - 5, }, }, }); // Then const eventBoundingBoxAfterMove = await getBoundingBox(eventLocator); expect(eventBoundingBoxAfterMove.x).toBeCloseTo(targetColumnBoundingBox.x, -1); await expect.poll(() => eventLocator.textContent()).toMatch(new RegExp(startTimeAfterMoving)); }); }); }); test('When pressing down the ESC key, the moving event resets to the initial position.', async ({ page, }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const eventBoundingBoxBeforeMove = await getBoundingBox(eventLocator); const targetStartTime = getTimeStrFromDate( addHours(SHORT_TIME_EVENT.end as TZDate, 1) ) as FormattedTimeString; const targetRowLocator = page.locator(getTimeGridLineSelector(targetStartTime)); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetRowLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const eventBoundingBoxAfterMove = await getBoundingBox(eventLocator); expect(eventBoundingBoxAfterMove).toEqual(eventBoundingBoxBeforeMove); }); test.describe('CSS class for a move event', () => { test('should be applied depending on a dragging state.', async ({ page }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const eventBoundingBox = await getBoundingBox(eventLocator); const moveEventClassLocator = page.locator(MOVE_EVENT_SELECTOR); // When (a drag has not started yet) await page.mouse.move(eventBoundingBox.x + 10, eventBoundingBox.y + 10); await page.mouse.down(); // Then expect(await moveEventClassLocator.count()).toBe(0); // When (a drag is working) await page.mouse.move(eventBoundingBox.x + 10, eventBoundingBox.y + 50); // Then expect(await moveEventClassLocator.count()).toBe(1); // When (a drag is finished) await page.mouse.up(); // Then expect(await moveEventClassLocator.count()).toBe(0); }); test('should not be applied when a drag is canceled.', async ({ page }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const moveEventClassLocator = page.locator(MOVE_EVENT_SELECTOR); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: eventLocator, options: { targetPosition: { x: 10, y: 30 }, }, hold: true, }); await page.keyboard.down('Escape'); // Then expect(await moveEventClassLocator.count()).toBe(0); }); }); test.describe(`Calibrate event's height while dragging`, () => { let lowerLongTimeEventLocator: Locator; let upperLongTimeEventLocator: Locator; let guideLocator: Locator; test.beforeEach(({ page }) => { const targetEventSelector = getTimeEventSelector(LONG_TIME_EVENT.title); lowerLongTimeEventLocator = page.locator(targetEventSelector).first(); upperLongTimeEventLocator = page.locator(targetEventSelector).last(); guideLocator = page.locator(getGuideTimeEventSelector()); }); test('lower long time event become longer while drag to upper side', async ({ page }) => { // Given const eventBoundingBox = await getBoundingBox(lowerLongTimeEventLocator); // When await dragAndDrop({ page, sourceLocator: lowerLongTimeEventLocator, targetLocator: lowerLongTimeEventLocator, options: { sourcePosition: { x: 10, y: 10 }, targetPosition: { x: 10, y: -10 }, }, hold: true, }); // Then const guideBoundingBox = await getBoundingBox(guideLocator); expect(guideBoundingBox.y).toBeLessThan(eventBoundingBox.y); expect(guideBoundingBox.height).toBeGreaterThan(eventBoundingBox.height); }); test('lower long time event become shorter while drag to lower side', async ({ page }) => { // Given const eventBoundingBox = await getBoundingBox(lowerLongTimeEventLocator); // When await dragAndDrop({ page, sourceLocator: lowerLongTimeEventLocator, targetLocator: lowerLongTimeEventLocator, options: { sourcePosition: { x: 10, y: 10 }, targetPosition: { x: 10, y: 30 }, }, hold: true, }); // Then const guideBoundingBox = await getBoundingBox(guideLocator); // NOTE: the guide event's height is greater than event's height, but it looks like it isn't. // height is truncated because of stacking context. expect(guideBoundingBox.y).toBeGreaterThan(eventBoundingBox.y); }); test('upper long time event become longer while drag to lower side', async ({ page }) => { // Given const eventBoundingBox = await getBoundingBox(upperLongTimeEventLocator); // When await dragAndDrop({ page, sourceLocator: upperLongTimeEventLocator, targetLocator: upperLongTimeEventLocator, options: { sourcePosition: { x: 10, y: 10 }, targetPosition: { x: 10, y: 30 }, }, hold: true, }); // Then const guideBoundingBox = await getBoundingBox(guideLocator); expect(guideBoundingBox.height).toBeGreaterThan(eventBoundingBox.height); }); test('upper long time event become shorter while drag to upper side', async ({ page }) => { // Given const eventBoundingBox = await getBoundingBox(upperLongTimeEventLocator); // When await dragAndDrop({ page, sourceLocator: upperLongTimeEventLocator, targetLocator: upperLongTimeEventLocator, options: { sourcePosition: { x: 10, y: 100 }, targetPosition: { x: 10, y: 50 }, }, hold: true, }); // Then const guideBoundingBox = await getBoundingBox(guideLocator); expect(guideBoundingBox.height).toBeLessThan(eventBoundingBox.height); }); }); const ONE_DAY_TIME_EVENTS = mockWeekViewEvents.filter( ({ isAllday, start, end }) => !isAllday && isSameDate(start, end) ); ONE_DAY_TIME_EVENTS.forEach(({ title }) => { test(`The height of guide element should be same as the event element. - ${title}`, async ({ page, }) => { // Given const eventLocator = page.locator(getTimeEventSelector(title)); const eventBoundingBox = await getBoundingBox(eventLocator); const targetRowLocator = page.locator(getTimeGridLineSelector('02:00')); const targetColumnLocator = page.locator('data-testid=timegrid-column-2'); const targetColumnBoundingBox = await getBoundingBox(targetColumnLocator); // When await dragAndDrop({ page, sourceLocator: eventLocator, targetLocator: targetRowLocator, options: { sourcePosition: { x: 5, y: 5, }, targetPosition: { y: 5, x: targetColumnBoundingBox.x - 5, }, }, hold: true, }); // Then const guideLocator = page.locator(getGuideTimeEventSelector()); const guideBoundingBox = await getBoundingBox(guideLocator); expect(guideBoundingBox.height).toBeCloseTo(eventBoundingBox.height, 0); }); }); ================================================ FILE: apps/calendar/playwright/week/timeGridEventResizing.e2e.ts ================================================ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import type { Matchers } from '@playwright/test/types/expect-types'; import type TZDate from '../../src/time/date'; import { addHours } from '../../src/time/datetime'; import type { FormattedTimeString } from '../../src/types/time/datetime'; import { mockWeekViewEvents } from '../../stories/mocks/mockWeekViewEvents'; import { WEEK_VIEW_PAGE_URL } from '../configs'; import { dragAndDrop, getBoundingBox, getGuideTimeEventSelector, getTimeEventSelector, getTimeGridLineSelector, getTimeStrFromDate, queryLocatorByTestId, waitForSingleElement, } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_PAGE_URL); }); const RESIZE_HANDLER_SELECTOR = '[class*="resize-handler"]'; const RESIZE_EVENT_SELECTOR = '[class*="dragging--resize-vertical-event"]'; function getHourDifference(minuend: FormattedTimeString, subtrahend: FormattedTimeString) { return Number(minuend.split(':')[0]) - Number(subtrahend.split(':')[0]); } interface EventInfo { title: string; startTime: FormattedTimeString; endTime: FormattedTimeString; endDateColumnIndex: number; } const [TWO_VIEW_EVENT, SHORT_TIME_EVENT, LONG_TIME_EVENT] = mockWeekViewEvents.filter( ({ isAllday }) => !isAllday ); const targetEvents: EventInfo[] = [ { title: TWO_VIEW_EVENT.title, startTime: '10:00', endTime: '06:00', endDateColumnIndex: 0, }, { title: SHORT_TIME_EVENT.title, startTime: '04:00', endTime: '06:00', endDateColumnIndex: 3, }, { title: LONG_TIME_EVENT.title, startTime: '10:00', endTime: '06:00', endDateColumnIndex: 6, }, ]; const cases: { title: string; targetEndTime: FormattedTimeString; targetColumnIndex?: number; matcherToCompare: Extract, 'toBeGreaterThan' | 'toBeLessThan'>; }[] = [ { title: 'to the bottom', targetEndTime: '08:00', matcherToCompare: 'toBeGreaterThan', }, { title: 'to the top', targetEndTime: '04:00', matcherToCompare: 'toBeLessThan', }, { title: 'up to the y of the cursor when the target column is to the right of the current column', targetEndTime: '08:00', targetColumnIndex: 5, matcherToCompare: 'toBeGreaterThan', }, { title: 'up to the y of the cursor when the target column is to the left of the current column', targetEndTime: '04:00', targetColumnIndex: 0, matcherToCompare: 'toBeLessThan', }, ]; async function setup({ page, targetEventTitle, targetEndTime, targetColumnIndex, }: { page: Page; targetEventTitle: string; targetEndTime: FormattedTimeString; targetColumnIndex: number; }) { // Given const eventLocator = queryLocatorByTestId(page, `time-event-${targetEventTitle}`).last(); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const targetRowLocator = page.locator(getTimeGridLineSelector(targetEndTime)); const targetColumnLocator = queryLocatorByTestId(page, `timegrid-column-${targetColumnIndex}`); const eventBoundingBoxBeforeResize = await getBoundingBox(eventLocator); const targetRowBoundingBox = await getBoundingBox(targetRowLocator); const targetColumnBoundingBox = await getBoundingBox(targetColumnLocator); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: targetRowLocator, options: { sourcePosition: { x: 1, y: 1, }, targetPosition: { x: targetColumnBoundingBox.x + 1, y: targetRowBoundingBox.height / 2, }, }, }); await waitForSingleElement(eventLocator); let eventBoundingBoxAfterResize = await getBoundingBox(eventLocator); await expect .poll(async () => { eventBoundingBoxAfterResize = await getBoundingBox(eventLocator); return eventBoundingBoxAfterResize; }) .not.toEqual(eventBoundingBoxBeforeResize); return { eventLocator, eventBoundingBoxBeforeResize, eventBoundingBoxAfterResize, targetRowBoundingBox, }; } targetEvents.forEach(({ title: eventTitle, startTime, endTime, endDateColumnIndex }) => { test.describe(`Resize a ${eventTitle} in the time grid`, () => { cases.forEach( ({ title, targetEndTime, targetColumnIndex, matcherToCompare: compareAssertion }) => { test(`${title}`, async ({ page }) => { // Given // When const { eventLocator, eventBoundingBoxBeforeResize, eventBoundingBoxAfterResize, targetRowBoundingBox, } = await setup({ page, targetEventTitle: eventTitle, targetEndTime, targetColumnIndex: targetColumnIndex || endDateColumnIndex, }); // Then expect(eventBoundingBoxAfterResize.height)[compareAssertion]( eventBoundingBoxBeforeResize.height ); await expect(eventLocator).toContainText(startTime); const rowCount = getHourDifference(targetEndTime, endTime) * 2 + 1; expect( eventBoundingBoxAfterResize.height - eventBoundingBoxBeforeResize.height ).toBeCloseTo(targetRowBoundingBox.height * rowCount, -1); }); } ); test(`then it should have a minimum height(=1 row) even if the event is resized to before the start time`, async ({ page, }) => { // Given // When const { eventBoundingBoxAfterResize, targetRowBoundingBox } = await setup({ page, targetEventTitle: eventTitle, targetEndTime: '00:00', targetColumnIndex: endDateColumnIndex, }); // Then expect(eventBoundingBoxAfterResize.height).toBeCloseTo(targetRowBoundingBox.height, -1); }); }); }); test('When pressing down the ESC key, the resizing event resets to the initial size.', async ({ page, }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const eventBoundingBoxBeforeResize = await getBoundingBox(eventLocator); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const targetStartTime = getTimeStrFromDate( addHours(SHORT_TIME_EVENT.end as TZDate, 1) ) as FormattedTimeString; const targetRowLocator = page.locator(getTimeGridLineSelector(targetStartTime)); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: targetRowLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const eventBoundingBoxAfterResize = await getBoundingBox(eventLocator); expect(eventBoundingBoxAfterResize).toEqual(eventBoundingBoxBeforeResize); }); test.describe('CSS class for a resize event', () => { test('should be applied depending on a dragging state.', async ({ page }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const resizeHandlerBoundingBox = await getBoundingBox(resizeHandlerLocator); const resizeEventClassLocator = page.locator(RESIZE_EVENT_SELECTOR); // When (a drag has not started yet) await page.mouse.move(resizeHandlerBoundingBox.x + 10, resizeHandlerBoundingBox.y + 3); await page.mouse.down(); // Then expect(await resizeEventClassLocator.count()).toBe(0); // When (a drag is working) await page.mouse.move(resizeHandlerBoundingBox.x + 10, resizeHandlerBoundingBox.y + 50); // Then expect(await resizeEventClassLocator.count()).toBe(1); // When (a drag is finished) await page.mouse.up(); // Then expect(await resizeEventClassLocator.count()).toBe(0); }); test('should not be applied when a drag is canceled.', async ({ page }) => { // Given const eventLocator = page.locator(getTimeEventSelector(SHORT_TIME_EVENT.title)); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const resizeEventClassLocator = page.locator(RESIZE_EVENT_SELECTOR); // When await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: resizeHandlerLocator, options: { targetPosition: { x: 10, y: 50 }, }, }); await page.keyboard.down('Escape'); // Then expect(await resizeEventClassLocator.count()).toBe(0); }); }); test.describe('Resizing the event which has a going or coming duration', () => { const [event] = mockWeekViewEvents.filter( ({ isAllday, goingDuration, comingDuration }) => !isAllday && (goingDuration || comingDuration) ); async function setupResizingGoingOrComingDuration( page: Page, targetEndTime: FormattedTimeString, hold?: boolean ) { const eventLocator = queryLocatorByTestId(page, `time-event-${event.title}`).last(); const travelTimeLocator = eventLocator.locator('[class*="travel-time"]'); const goingDurationLocator = travelTimeLocator.first(); const goingDurationBoundingBoxBeforeResize = await getBoundingBox(goingDurationLocator); const comingDurationLocator = travelTimeLocator.last(); const comingDurationBoundingBoxBeforeResize = await getBoundingBox(comingDurationLocator); const eventTimeLocator = eventLocator.locator('[class*="event-time"]'); const modelDurationBoundingBoxBeforeResize = await getBoundingBox(eventTimeLocator); const resizeHandlerLocator = eventLocator.locator(RESIZE_HANDLER_SELECTOR); const targetRowLocator = page.locator(getTimeGridLineSelector(targetEndTime)); const targetRowBoundingBox = await getBoundingBox(targetRowLocator); await dragAndDrop({ page, sourceLocator: resizeHandlerLocator, targetLocator: targetRowLocator, options: { sourcePosition: { x: 1, y: 1, }, targetPosition: { x: targetRowBoundingBox.width / 2, y: targetRowBoundingBox.height / 2, }, }, hold, }); let draggingGoingDurationBoundingBox = null; let draggingComingDurationBoundingBox = null; let draggingModelDurationBoundingBox = null; if (hold) { const draggingEventLocator = page.locator(getGuideTimeEventSelector()); const draggingTravelTimeLocator = draggingEventLocator.locator('[class*="travel-time"]'); const draggingGoingDurationLocator = draggingTravelTimeLocator.first(); draggingGoingDurationBoundingBox = await getBoundingBox(draggingGoingDurationLocator); const draggingComingDurationLocator = draggingTravelTimeLocator.last(); draggingComingDurationBoundingBox = await getBoundingBox(draggingComingDurationLocator); const draggingEventTimeLocator = draggingEventLocator.locator('[class*="event-time"]'); draggingModelDurationBoundingBox = await getBoundingBox(draggingEventTimeLocator); } const goingDurationBoundingBoxAfterResize = await getBoundingBox(goingDurationLocator); const comingDurationBoundingBoxAfterResize = await getBoundingBox(comingDurationLocator); const modelDurationBoundingBoxAfterResize = await getBoundingBox(eventTimeLocator); return { goingDurationBoundingBoxBeforeResize, goingDurationBoundingBoxAfterResize, comingDurationBoundingBoxBeforeResize, comingDurationBoundingBoxAfterResize, modelDurationBoundingBoxBeforeResize, modelDurationBoundingBoxAfterResize, draggingGoingDurationBoundingBox, draggingComingDurationBoundingBox, draggingModelDurationBoundingBox, rowHeight: targetRowBoundingBox.height, }; } test('should change only the end time.', async ({ page }) => { // Given const targetEndTime = '10:00'; // When const { goingDurationBoundingBoxBeforeResize, goingDurationBoundingBoxAfterResize, comingDurationBoundingBoxBeforeResize, comingDurationBoundingBoxAfterResize, modelDurationBoundingBoxBeforeResize, modelDurationBoundingBoxAfterResize, } = await setupResizingGoingOrComingDuration(page, targetEndTime); // Then expect(goingDurationBoundingBoxAfterResize.height).toBeCloseTo( goingDurationBoundingBoxBeforeResize.height, -1 ); expect(comingDurationBoundingBoxAfterResize.height).toBeCloseTo( comingDurationBoundingBoxBeforeResize.height, -1 ); expect(modelDurationBoundingBoxAfterResize.height).toBeGreaterThan( modelDurationBoundingBoxBeforeResize.height ); }); test('the dragging element should have a minimum height(=1 row) without going and coming durations.', async ({ page, }) => { // Given const targetEndTime = '04:00'; // When const { goingDurationBoundingBoxBeforeResize, comingDurationBoundingBoxBeforeResize, draggingGoingDurationBoundingBox, draggingComingDurationBoundingBox, draggingModelDurationBoundingBox, rowHeight, } = await setupResizingGoingOrComingDuration(page, targetEndTime, true); // Then expect(draggingGoingDurationBoundingBox?.height).toBeCloseTo( goingDurationBoundingBoxBeforeResize.height, -1 ); expect(draggingComingDurationBoundingBox?.height).toBeCloseTo( comingDurationBoundingBoxBeforeResize.height, -1 ); expect(draggingModelDurationBoundingBox?.height).toBeCloseTo(rowHeight, -1); }); test('the result of resizing should have a minimum height(=1 row) without going and coming durations.', async ({ page, }) => { // Given const targetEndTime = '04:00'; // When const { goingDurationBoundingBoxBeforeResize, goingDurationBoundingBoxAfterResize, comingDurationBoundingBoxBeforeResize, comingDurationBoundingBoxAfterResize, modelDurationBoundingBoxAfterResize, rowHeight, } = await setupResizingGoingOrComingDuration(page, targetEndTime); // Then expect(goingDurationBoundingBoxAfterResize.height).toBeCloseTo( goingDurationBoundingBoxBeforeResize.height, -1 ); expect(comingDurationBoundingBoxAfterResize.height).toBeCloseTo( comingDurationBoundingBoxBeforeResize.height, -1 ); expect(modelDurationBoundingBoxAfterResize.height).toBeCloseTo(rowHeight, -1); }); }); ================================================ FILE: apps/calendar/playwright/week/timeGridScrollSync.e2e.ts ================================================ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { WEEK_VIEW_PAGE_URL } from '../configs'; import { getBoundingBox, getPrefixedClassName } from '../utils'; test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_PAGE_URL); }); // NOTE: Syncing scroll only happens when the mousemove event is fired // and cannot use `dragAndDrop` because it's better to be manually controlled. function getScrollTop(el: HTMLElement) { return el.scrollTop; } test.describe('Scroll syncing in time grid when selecting grid', () => { /** * The third column should be empty */ async function setup(page: Page) { const timeGridContainerLocator = page.locator( `${getPrefixedClassName('panel')}${getPrefixedClassName('time')}` ); const targetColumnLocator = page.locator('data-testid=timegrid-column-2'); const containerBoundingBox = await getBoundingBox(timeGridContainerLocator); const columnBoundingBox = await getBoundingBox(targetColumnLocator); return { targetColumnLocator, timeGridContainerLocator, containerBoundingBox, columnBoundingBox, }; } test('it should sync scroll while dragging down to the bottom', async ({ page }) => { // Given const { targetColumnLocator, columnBoundingBox, timeGridContainerLocator, containerBoundingBox, } = await setup(page); const scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); // When await targetColumnLocator.hover({ position: { x: columnBoundingBox.width / 2, y: 10, }, force: true, }); await page.mouse.down(); await page.mouse.move( columnBoundingBox.x + columnBoundingBox.width / 2, containerBoundingBox.y + containerBoundingBox.height - 10 ); // Then const scrollTopAfterSync = await timeGridContainerLocator.evaluate(getScrollTop); expect(scrollTopAfterSync).toBeGreaterThan(scrollTopBeforeSync); }); test('it should sync scroll while dragging up to the top', async ({ page }) => { // Given const { targetColumnLocator, columnBoundingBox, timeGridContainerLocator, containerBoundingBox, } = await setup(page); // Middle of the column const xPosition = columnBoundingBox.x + columnBoundingBox.width / 2; // Scroll down to the bottom of the column await targetColumnLocator.hover(); await page.mouse.wheel(0, containerBoundingBox.height); let scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); await expect .poll(async () => { scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopBeforeSync; }) .toBeCloseTo(containerBoundingBox.height, -2); // When // drag up to the top of the column await page.mouse.move(xPosition, containerBoundingBox.y + containerBoundingBox.height - 10); await page.mouse.down(); await expect .poll(async () => { await page.mouse.move(xPosition, containerBoundingBox.y); // Then const scrollTopAfterSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopAfterSync; }) .toBeLessThan(scrollTopBeforeSync); }); }); test.describe('Scroll syncing in time grid when moving event', () => { async function setup(page: Page) { const timeGridContainerLocator = page.locator( `${getPrefixedClassName('panel')}${getPrefixedClassName('time')}` ); const targetEventLocator = page.locator( '[data-testid*="time-event"]:has-text("short time event")' ); const containerBoundingBox = await getBoundingBox(timeGridContainerLocator); const eventBoundingBox = await getBoundingBox(targetEventLocator); return { timeGridContainerLocator, targetEventLocator, containerBoundingBox, eventBoundingBox, }; } test('it should sync scroll while moving event to the edge of the bottom', async ({ page }) => { // Given const { timeGridContainerLocator, targetEventLocator, containerBoundingBox, eventBoundingBox } = await setup(page); const scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); // When await targetEventLocator.hover({ position: { x: eventBoundingBox.width / 2, y: 3, }, force: true, }); await page.mouse.down(); await page.mouse.move( eventBoundingBox.x + eventBoundingBox.width / 2, containerBoundingBox.y + containerBoundingBox.height - 10 ); await page.mouse.up(); // Then const scrollTopAfterSync = await timeGridContainerLocator.evaluate(getScrollTop); expect(scrollTopAfterSync).toBeGreaterThan(scrollTopBeforeSync); }); test('it should sync scroll while moving event to the edge of the top', async ({ page }) => { // Given const { timeGridContainerLocator, targetEventLocator, containerBoundingBox, eventBoundingBox } = await setup(page); // Let's move the event to the bottom first. const middleXOfEvent = eventBoundingBox.x + eventBoundingBox.width / 2; await targetEventLocator.hover({ position: { x: eventBoundingBox.width / 2, y: 3, }, force: true, }); await page.mouse.down(); await page.mouse.move( middleXOfEvent, containerBoundingBox.y + containerBoundingBox.height - 10 ); await page.mouse.up(); // Then scroll down a little. await page.mouse.wheel(0, containerBoundingBox.height / 2); let scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); await expect .poll(async () => { scrollTopBeforeSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopBeforeSync; }) .toBeCloseTo(containerBoundingBox.height / 2, -2); // When await targetEventLocator.hover({ position: { x: eventBoundingBox.width / 2, y: 3, }, force: true, }); await page.mouse.down(); await expect .poll(async () => { await page.mouse.move(middleXOfEvent, containerBoundingBox.y - 10); // Then const scrollTopAfterSync = await timeGridContainerLocator.evaluate(getScrollTop); return scrollTopAfterSync; }) .toBeLessThan(scrollTopBeforeSync); }); }); ================================================ FILE: apps/calendar/playwright/week/timeGridSelection.e2e.ts ================================================ import { expect, test } from '@playwright/test'; import { assertTimeGridSelection } from '../assertions'; import { WEEK_VIEW_HOUR_START_OPTION_PAGE_URL, WEEK_VIEW_PAGE_URL } from '../configs'; import { ClickDelay } from '../constants'; import { dragAndDrop, getBoundingBox, getTimeGridLineSelector } from '../utils'; const GRID_SELECTION_SELECTOR = '[data-testid*="time-grid-selection"]'; test.describe('Time Grid Selection - Default Options', () => { test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_PAGE_URL); }); // NOTE: Only firefox automatically scrolls into view at some random tests, so narrowing the range of movement. // Maybe `scrollIntoViewIfNeeded` is not supported in the firefox? // reference: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded const BASE_GRIDLINE_SELECTOR = 'data-testid=gridline-03:00-03:30'; test('should be able to select a time slot with clicking', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); // When await startGridLineLocator.click({ force: true, delay: ClickDelay.Short }); await timeGridSelectionLocator.waitFor(); // Test for debounced click handler. // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 1, formattedTimes: ['03:00', '03:30'], startTop: startGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, }); }); // FIXME: it fails on safari & firefox test.fixme('should be able to select a time slot with double clicking', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); // When await startGridLineLocator.dblclick({ force: true, delay: ClickDelay.Immediate }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 1, formattedTimes: ['03:00', '03:30'], startTop: startGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, }); }); test('should be able to select a range of time from bottom to top', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const targetGridLineLocator = page.locator(getTimeGridLineSelector('01:00')); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 1, formattedTimes: ['01:00', '03:30'], startTop: targetGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, }); }); test('should be able to select a range of time to upper right', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const targetGridLineLocator = page.locator(getTimeGridLineSelector('01:00')); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, options: { targetPosition: { x: targetGridLineBoundingBox.width, y: startGridLineBoundingBox.height, }, }, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 4, formattedTimes: ['03:00'], startTop: startGridLineBoundingBox.y, endBottom: targetGridLineBoundingBox.y + targetGridLineBoundingBox.height, }); }); test('should be able to select a range of time to right', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: startGridLineLocator, options: { targetPosition: { x: startGridLineBoundingBox.width, y: 1, }, }, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 4, formattedTimes: ['03:00'], startTop: startGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, }); }); test('should be able to select a range of time to lower right', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const targetGridLineLocator = page.locator(getTimeGridLineSelector('05:00')); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, options: { targetPosition: { x: targetGridLineBoundingBox.width, y: targetGridLineBoundingBox.height, }, }, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 4, formattedTimes: ['03:00'], startTop: startGridLineBoundingBox.y, endBottom: targetGridLineBoundingBox.y + targetGridLineBoundingBox.height, }); }); test('should be able to select a range of time from top to bottom', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const targetGridLineLocator = page.locator(getTimeGridLineSelector('05:00')); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 1, formattedTimes: ['03:00', '05:30'], startTop: startGridLineBoundingBox.y, endBottom: targetGridLineBoundingBox.y + targetGridLineBoundingBox.height, }); }); test('should be able to select a range of time to lower left', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const targetGridLineLocator = page.locator(getTimeGridLineSelector('05:00')); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, options: { targetPosition: { x: 0, y: targetGridLineBoundingBox.height, }, }, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 4, formattedTimes: ['05:00'], startTop: targetGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, }); }); test('should be able to select a range of time to left', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: startGridLineLocator, options: { targetPosition: { x: 1, y: 1, }, }, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 4, formattedTimes: ['03:00'], startTop: startGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, }); }); test('should be able to select a range of time to upper left', async ({ page }) => { // Given const startGridLineLocator = page.locator(BASE_GRIDLINE_SELECTOR); const targetGridLineLocator = page.locator(getTimeGridLineSelector('01:00')); const timeGridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, options: { targetPosition: { x: 1, y: 1, }, }, }); // Then await assertTimeGridSelection(timeGridSelectionLocator, { totalElements: 4, formattedTimes: ['01:00'], startTop: targetGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, }); }); test('When pressing down the ESC key, the grid selection is canceled.', async ({ page }) => { // Given const startGridLineLocator = page.locator(getTimeGridLineSelector('03:00')); const targetGridLineLocator = page.locator(getTimeGridLineSelector('05:00')); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, hold: true, }); await page.keyboard.down('Escape'); // Then const gridSelectionLocator = page.locator(GRID_SELECTION_SELECTOR); expect(await gridSelectionLocator.count()).toBe(0); }); }); // Regression test for #1238 // Since the most of the test duplicated with the normal one, check for a few cases including horizontal selections only. test.describe('Time Grid Selection - With different hourStart and hourEnd', () => { test.beforeEach(async ({ page }) => { await page.goto(WEEK_VIEW_HOUR_START_OPTION_PAGE_URL); }); const getColumnSelector = (columnIndex: number) => `data-testid=timegrid-column-${columnIndex}`; test('should be able to select a range of time to lower right', async ({ page }) => { // Given const startGridLineLocator = page.locator(getTimeGridLineSelector('06:00')); const targetGridLineLocator = page.locator(getTimeGridLineSelector('08:00')); const startColumnLocator = page.locator(getColumnSelector(4)); const targetColumnLocator = page.locator(getColumnSelector(5)); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); const startColumnBoundingBox = await getBoundingBox(startColumnLocator); const endColumnBoundingBox = await getBoundingBox(targetColumnLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, options: { sourcePosition: { x: startColumnBoundingBox.x, y: 5, }, targetPosition: { x: endColumnBoundingBox.x, y: 5, }, }, }); // Then await assertTimeGridSelection(page.locator(GRID_SELECTION_SELECTOR), { totalElements: 2, formattedTimes: ['06:00'], startTop: startGridLineBoundingBox.y, endBottom: targetGridLineBoundingBox.y + targetGridLineBoundingBox.height, }); }); test('should be able to select a range of time to upper left', async ({ page }) => { // Given const startGridLineLocator = page.locator(getTimeGridLineSelector('08:00')); const targetGridLineLocator = page.locator(getTimeGridLineSelector('06:00')); const startColumnLocator = page.locator(getColumnSelector(5)); const targetColumnLocator = page.locator(getColumnSelector(4)); const startGridLineBoundingBox = await getBoundingBox(startGridLineLocator); const targetGridLineBoundingBox = await getBoundingBox(targetGridLineLocator); const startColumnBoundingBox = await getBoundingBox(startColumnLocator); const endColumnBoundingBox = await getBoundingBox(targetColumnLocator); // When await dragAndDrop({ page, sourceLocator: startGridLineLocator, targetLocator: targetGridLineLocator, options: { sourcePosition: { x: startColumnBoundingBox.x, y: 5, }, targetPosition: { x: endColumnBoundingBox.x, y: 5, }, }, }); // Then await assertTimeGridSelection(page.locator(GRID_SELECTION_SELECTOR), { totalElements: 2, formattedTimes: ['06:00'], startTop: targetGridLineBoundingBox.y, endBottom: startGridLineBoundingBox.y + startGridLineBoundingBox.height, }); }); }); ================================================ FILE: apps/calendar/postcss.config.js ================================================ module.exports = { plugins: [ // eslint-disable-next-line @typescript-eslint/no-var-requires require('postcss-prefixer')({ prefix: 'toastui-calendar-', }), ], }; ================================================ FILE: apps/calendar/scripts/publishToCDN.js ================================================ /* eslint-disable */ const path = require('path'); const fs = require('fs'); const fetch = require('node-fetch'); const pkg = require('../package.json'); const LOCAL_DIST_PATH = path.join(__dirname, '../dist'); const STORAGE_API_URL = 'https://api-storage.cloud.toast.com/v1'; const IDENTITY_API_URL = 'https://api-identity.infrastructure.cloud.toast.com/v2.0'; const TOAST_CLOUD_TENANTID = process.env.TOAST_CLOUD_TENANTID; const TOAST_CLOUD_STORAGEID = process.env.TOAST_CLOUD_STORAGEID; const TOAST_CLOUD_USERNAME = process.env.TOAST_CLOUD_USERNAME; const TOAST_CLOUD_PASSWORD = process.env.TOAST_CLOUD_PASSWORD; async function getTOASTCloudContainer(token) { const response = await fetch(`${STORAGE_API_URL}/${TOAST_CLOUD_STORAGEID}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token, }, }); const container = await response.text(); return `${container.trim()}/calendar`; } async function getTOASTCloudToken() { const data = { auth: { tenantId: TOAST_CLOUD_TENANTID, passwordCredentials: { username: TOAST_CLOUD_USERNAME, password: TOAST_CLOUD_PASSWORD, }, }, }; const response = await fetch(`${IDENTITY_API_URL}/tokens`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const result = await response.json(); return result.access.token.id; } function publishToCdn(token, localPath, cdnPath) { const files = fs.readdirSync(localPath); files.forEach((fileName) => { const objectPath = `${cdnPath}/${fileName}`; if (fileName.match(/.(js|css|svg)$/)) { const readStream = fs.createReadStream(`${localPath}/${fileName}`); const contentType = /css$/.test(fileName) ? 'text/css' : /js$/.test(fileName) ? 'text/javascript' : 'image/svg+xml'; fetch(`${STORAGE_API_URL}/${objectPath}`, { method: 'PUT', headers: { 'Content-Type': contentType, 'X-Auth-Token': token, }, body: readStream, }); } else { publishToCdn(token, `${localPath}/${fileName}`, objectPath); } }); } async function publish() { const token = await getTOASTCloudToken(); const container = await getTOASTCloudContainer(token); const cdnPath = `${TOAST_CLOUD_STORAGEID}/${container}`; [`v${pkg.version}`, 'latest'].forEach((dir) => { publishToCdn(token, LOCAL_DIST_PATH, `${cdnPath}/${dir}`); }); } publish(); ================================================ FILE: apps/calendar/scripts/updateWrapper.js ================================================ /* eslint-disable */ const path = require('path'); const fs = require('fs'); const CORE_PACKAGE_JSON_PATH = path.join(__dirname, '../package.json'); const REACT_PACKAGE_JSON_PATH = path.join(__dirname, '../../react-calendar/package.json'); const VUE_PACKAGE_JSON_PATH = path.join(__dirname, '../../vue-calendar/package.json'); const corePackage = require(CORE_PACKAGE_JSON_PATH); const reactPackage = require(REACT_PACKAGE_JSON_PATH); const vuePackage = require(VUE_PACKAGE_JSON_PATH); const version = corePackage.version; reactPackage.version = version; reactPackage.dependencies['@toast-ui/calendar'] = `^${version}`; fs.writeFileSync(REACT_PACKAGE_JSON_PATH, `${JSON.stringify(reactPackage, null, 2)}\n`); vuePackage.version = version; vuePackage.dependencies['@toast-ui/calendar'] = `^${version}`; fs.writeFileSync(VUE_PACKAGE_JSON_PATH, `${JSON.stringify(vuePackage, null, 2)}\n`); ================================================ FILE: apps/calendar/src/calendarContainer.tsx ================================================ import { h } from 'preact'; import { StoreProvider } from '@src/contexts/calendarStore'; import { EventBusProvider } from '@src/contexts/eventBus'; import { FloatingLayerProvider } from '@src/contexts/floatingLayer'; import { ThemeProvider } from '@src/contexts/themeStore'; import type { EventBus } from '@src/utils/eventBus'; import type { PropsWithChildren } from '@t/components/common'; import type { ExternalEventTypes } from '@t/eventBus'; import type { CalendarStore, InternalStoreAPI } from '@t/store'; import type { ThemeStore } from '@t/theme'; interface Props { theme: InternalStoreAPI; store: InternalStoreAPI; eventBus: EventBus; } export function CalendarContainer({ theme, store, eventBus, children }: PropsWithChildren) { return ( {children} ); } ================================================ FILE: apps/calendar/src/components/dayGridCommon/dayName.tsx ================================================ import { h } from 'preact'; import type { DayNameThemes } from '@src/components/dayGridCommon/gridHeader'; import { Template } from '@src/components/template'; import { useEventBus } from '@src/contexts/eventBus'; import { cls } from '@src/helpers/css'; import { getDayName } from '@src/helpers/dayName'; import { usePrimaryTimezone } from '@src/hooks/timezone/usePrimaryTimezone'; import type { TemplateName } from '@src/template/default'; import type TZDate from '@src/time/date'; import { isSameDate, isSaturday, isSunday, isWeekend, toFormat } from '@src/time/datetime'; import type { CalendarViewType, StyleProp } from '@t/components/common'; import type { TemplateMonthDayName, TemplateWeekDayName } from '@t/template'; interface Props { type: CalendarViewType; dayName: TemplateWeekDayName | TemplateMonthDayName; style: StyleProp; theme: DayNameThemes; } function isWeekDayName( type: 'week' | 'month', dayName: Props['dayName'] ): dayName is TemplateWeekDayName { return type === 'week'; } function getWeekDayNameColor({ dayName, theme, today, }: { dayName: TemplateWeekDayName; theme: Props['theme']; today: TZDate; }) { const { day, dateInstance } = dayName; const isToday = isSameDate(today, dateInstance); const isPastDay = !isToday && dateInstance < today; if (isSunday(day)) { return theme.common.holiday.color; } if (isPastDay) { return theme.week?.pastDay.color; } if (isSaturday(day)) { return theme.common.saturday.color; } if (isToday) { return theme.week?.today.color; } return theme.common.dayName.color; } function getMonthDayNameColor({ dayName, theme, }: { dayName: TemplateMonthDayName; theme: Props['theme']; }) { const { day } = dayName; if (isSunday(day)) { return theme.common.holiday.color; } if (isSaturday(day)) { return theme.common.saturday.color; } return theme.common.dayName.color; } export function DayName({ dayName, style, type, theme }: Props) { const eventBus = useEventBus(); const [, getNow] = usePrimaryTimezone(); const today = getNow(); const { day } = dayName; const color = type === 'week' ? getWeekDayNameColor({ dayName: dayName as TemplateWeekDayName, theme, today }) : getMonthDayNameColor({ dayName: dayName as TemplateMonthDayName, theme }); const templateType = `${type}DayName` as TemplateName; const handleClick = () => { if (isWeekDayName(type, dayName)) { eventBus.fire('clickDayName', { date: toFormat(dayName.dateInstance, 'YYYY-MM-DD') }); } }; return (