├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ └── config.yml ├── .gitignore ├── .npmrc ├── .vscode └── extensions.json ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── karma.config.cjs ├── package.json ├── pnpm-lock.yaml ├── rollup.config.js ├── src ├── FullCalendar.ts ├── index.ts └── options.ts ├── tests ├── DynamicEvent.vue └── index.js ├── tsconfig.json └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | quote_type = single 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Bug Report 4 | url: https://github.com/fullcalendar/fullcalendar/issues/new?template=bug-report.yml 5 | about: Create a ticket on the main issue tracker. Choose the "Vue" connector 6 | - name: Feature Request 7 | url: https://github.com/fullcalendar/fullcalendar/issues/new?template=feature-request.yml 8 | about: Create a ticket on the main issue tracker. Choose the "Vue" connector 9 | - name: Getting Help 10 | url: https://stackoverflow.com/questions/tagged/fullcalendar%20vue.js 11 | about: Stuck on a tough problem? Visit the StackOverflow fullcalendar + vue.js tags 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Package manager 3 | node_modules 4 | 5 | # Generated 6 | dist 7 | 8 | # Outer monorepo 9 | .turbo 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = true 2 | use-node-version = 20.14.0 3 | link-workspace-packages = true 4 | prefer-workspace-packages = true 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Note 2 | 3 | This changelog does not mention all releases. 4 | Visit the github releases page as well as the main fullcalendar repo. 5 | 6 | ## 5.11.1 (2022-05-10) 7 | 8 | - FIX: Cannot target calendar api with several instances (#155) 9 | 10 | ## 5.7.1 (2021-06-02) 11 | 12 | - support for Vue 3 13 | - KNOWN BUG: templates within slots do not inherit App's 14 | mixins, directives, filters, and components (like #123) 15 | 16 | ## 5.4.0 (2020-11-11) 17 | 18 | - no longer expose component as 'fullcalendar' when used globally 19 | - eventContent slot doesn't properly destroy the Vue components inside (#111) 20 | - global mixins, directives, filters & components work inside slots (#105) 21 | - better compatibility with Webpack 5, deeming `resolve.fullySpecified` unnecessary ([core-5822]) 22 | - dist files now include a CJS file. ESM is still used by default in most environments ([core-5929]) 23 | - webpack upgrade note: use style-loader instead of vue-style-loader 24 | 25 | [core-5822]: https://github.com/fullcalendar/fullcalendar/issues/5822 26 | [core-5929]: https://github.com/fullcalendar/fullcalendar/issues/5929 27 | 28 | ## 5.2.0 (2020-07-30) 29 | 30 | - pre-built release of the Vue component (#61) 31 | - using the component through a CDN (#28) 32 | - Build errors due to missing types in main.ts (#101) 33 | - when appropriate, expose as 'fullcalendar' component, for DOM templates 34 | 35 | ## 4.3.1 (2019-08-12) 36 | 37 | - fix regression where object props wrongly forcing rerenders (#11, #34) 38 | 39 | ## 4.2.2 (2019-06-04) 40 | 41 | Emergency bugfix: event objects with Date objects wouldn't render 42 | 43 | ## 4.2.1 (2019-06-04) 44 | 45 | Fixed bugs surfaced in issue #32: 46 | - event/resource-fetching *functions* don't work 47 | - event/resource *computed properties* don't work 48 | - removed `deep-copy` as a dependency 49 | 50 | ## 4.2.0 (2019-06-02) 51 | 52 | - nested props data mutations, like events being updated, 53 | will now be rendred on the calendar (#9) 54 | - added missing props (#25, #29) 55 | - the following emitted events are now deprecated. 56 | use *props* instead. pass in a function as the prop: 57 | - `datesRender` 58 | - `datesDestroy` 59 | - `dayRender` 60 | - `eventRender` 61 | - `eventDestroy` 62 | - `viewSkeletonRender` 63 | - `viewSkeletonDestroy` 64 | - `resourceRender` 65 | Allows returning false/DOM nodes (#27) 66 | - no unnecessary rerendering of calendar caused by header/footer 67 | props being specified as literals (#11) 68 | - new dependency: fast-deep-equal 69 | automatically bundled with UMD dist 70 | 71 | ## 4.1.1 (2019-05-14) 72 | 73 | Fix missing option `googleCalendarApiKey` (#12) 74 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Adam Shaw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # FullCalendar Vue 3 Component 3 | 4 | The official [Vue 3](https://vuejs.org/) component for [FullCalendar](https://fullcalendar.io) 5 | 6 | ## Installation 7 | 8 | Install the Vue 3 connector, the core package, and any plugins (like [daygrid](https://fullcalendar.io/docs/month-view)): 9 | 10 | ```sh 11 | npm install @fullcalendar/vue3 @fullcalendar/core @fullcalendar/daygrid 12 | ``` 13 | 14 | ## Usage 15 | 16 | Render a `FullCalendar` component, supplying an [options](https://fullcalendar.io/docs#toc) object: 17 | 18 | ```vue 19 | 41 | 42 | 46 | ``` 47 | 48 | You can even supply [named-slot](https://vuejs.org/guide/components/slots.html#named-slots) templates: 49 | 50 | ```vue 51 | 60 | ``` 61 | 62 | ## Links 63 | 64 | - [Documentation](https://fullcalendar.io/docs/vue) 65 | - [Example Project](https://github.com/fullcalendar/fullcalendar-examples/tree/main/vue3) 66 | 67 | ## Development 68 | 69 | You must install this repo with [PNPM](https://pnpm.io/): 70 | 71 | ``` 72 | pnpm install 73 | ``` 74 | 75 | Available scripts (via `pnpm run 18 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | import { nextTick, defineAsyncComponent, h } from 'vue' 2 | import { createI18n } from 'vue-i18n' 3 | import { mount as _mount } from '@vue/test-utils' 4 | import FullCalendar from '../dist/index.js' 5 | import dayGridPlugin from '@fullcalendar/daygrid' 6 | 7 | const INITIAL_DATE = '2019-05-15' 8 | 9 | const DEFAULT_OPTIONS = { 10 | initialDate: INITIAL_DATE, 11 | initialView: 'dayGridMonth', 12 | timeZone: 'UTC', 13 | plugins: [ dayGridPlugin ] 14 | } 15 | 16 | let currentWrapper 17 | let currentContainerEl 18 | 19 | function mount(component, options = {}) { 20 | if (options.attachTo === undefined) { 21 | currentContainerEl = document.body.appendChild(document.createElement('div')) 22 | options = { 23 | ...options, 24 | attachTo: currentContainerEl 25 | } 26 | } 27 | 28 | currentWrapper = _mount(component, options) 29 | return currentWrapper 30 | } 31 | 32 | afterEach(function() { 33 | if (currentWrapper) { 34 | currentWrapper.unmount() 35 | currentWrapper = null 36 | } 37 | if (currentContainerEl) { 38 | currentContainerEl.remove() 39 | currentContainerEl = null 40 | } 41 | }) 42 | 43 | 44 | it('renders', async () => { 45 | let wrapper = mount(FullCalendar, { propsData: { options: DEFAULT_OPTIONS } }) 46 | expect(isSkeletonRendered(wrapper)).toEqual(true) 47 | }) 48 | 49 | it('unmounts and calls destroy', async () => { 50 | let unmounted = false 51 | let options = { 52 | ...DEFAULT_OPTIONS, 53 | viewWillUnmount() { 54 | unmounted = true 55 | } 56 | } 57 | 58 | let wrapper = mount(FullCalendar, { propsData: { options } }) 59 | wrapper.unmount() 60 | expect(unmounted).toBeTruthy() 61 | }) 62 | 63 | it('handles a single prop change', async () => { 64 | let options = { 65 | ...DEFAULT_OPTIONS, 66 | weekends: true 67 | } 68 | let wrapper = mount(FullCalendar, { 69 | propsData: { options } 70 | }) 71 | expect(isWeekendsRendered(wrapper)).toEqual(true) 72 | 73 | // it's easy for the component to detect this change because the whole options object changes. 74 | // a more difficult scenario is when a component updates its own nested prop. 75 | // there's a test for that below (COMPONENT_FOR_OPTION_MANIP). 76 | wrapper.setProps({ 77 | options: { 78 | ...options, 79 | weekends: false // good idea to test a falsy prop 80 | } 81 | }) 82 | await nextTick() 83 | expect(isWeekendsRendered(wrapper)).toEqual(false) 84 | }) 85 | 86 | it('renders events with Date objects', async () => { // necessary to test copy util 87 | let wrapper = mount(FullCalendar, { 88 | propsData: { 89 | options: { 90 | ...DEFAULT_OPTIONS, 91 | events: [ 92 | { title: 'event', start: new Date(INITIAL_DATE) }, 93 | { title: 'event', start: new Date(INITIAL_DATE) } 94 | ] 95 | } 96 | } 97 | }) 98 | 99 | expect(getRenderedEventCount(wrapper)).toEqual(2) 100 | }) 101 | 102 | it('handles multiple prop changes, include event reset', async () => { 103 | let viewMountCnt = 0 104 | let eventRenderCnt = 0 105 | let options = { 106 | ...DEFAULT_OPTIONS, 107 | events: buildEvents(1), 108 | viewDidMount() { 109 | viewMountCnt++ 110 | }, 111 | eventContent() { 112 | eventRenderCnt++ 113 | } 114 | } 115 | 116 | let wrapper = mount(FullCalendar, { 117 | propsData: { options } 118 | }) 119 | 120 | expect(getRenderedEventCount(wrapper)).toEqual(1) 121 | expect(isWeekendsRendered(wrapper)).toEqual(true) 122 | expect(viewMountCnt).toEqual(1) 123 | expect(eventRenderCnt).toEqual(1) 124 | 125 | viewMountCnt = 0 126 | eventRenderCnt = 0 127 | 128 | wrapper.setProps({ 129 | options: { 130 | ...options, 131 | direction: 'rtl', 132 | weekends: false, 133 | events: buildEvents(2) 134 | } 135 | }) 136 | 137 | await nextTick() 138 | expect(getRenderedEventCount(wrapper)).toEqual(2) 139 | expect(isWeekendsRendered(wrapper)).toEqual(false) 140 | expect(viewMountCnt).toEqual(0) 141 | expect(eventRenderCnt).toEqual(2) // TODO: get this down to 1 (only 1 new event rendered) 142 | }) 143 | 144 | it('should expose an API', async () => { 145 | let wrapper = mount(FullCalendar, { propsData: { options: DEFAULT_OPTIONS } }) 146 | let calendarApi = wrapper.vm.getApi() 147 | expect(calendarApi).toBeTruthy() 148 | 149 | let newDate = new Date(Date.UTC(2000, 0, 1)) 150 | calendarApi.gotoDate(newDate) 151 | expect(calendarApi.getDate().valueOf()).toEqual(newDate.valueOf()) 152 | }) 153 | 154 | 155 | const COMPONENT_FOR_API = { 156 | components: { 157 | FullCalendar 158 | }, 159 | template: ` 160 |
161 | 162 |
163 | `, 164 | data() { 165 | return { 166 | calendarOptions: DEFAULT_OPTIONS 167 | } 168 | }, 169 | methods: { 170 | gotoDate(newDate) { 171 | let calendarApi = this.$refs.fullCalendar.getApi() 172 | calendarApi.gotoDate(newDate) 173 | }, 174 | getDate() { 175 | let calendarApi = this.$refs.fullCalendar.getApi() 176 | return calendarApi.getDate() 177 | } 178 | } 179 | } 180 | 181 | it('should expose an API in $refs', async () => { 182 | let wrapper = mount(COMPONENT_FOR_API) 183 | let newDate = new Date(Date.UTC(2000, 0, 1)) 184 | 185 | wrapper.vm.gotoDate(newDate) 186 | expect(wrapper.vm.getDate().valueOf()).toEqual(newDate.valueOf()) 187 | }) 188 | 189 | 190 | it('should handle multiple refs $refs', async () => { 191 | let wrapper = mount({ 192 | components: { 193 | FullCalendar 194 | }, 195 | template: ` 196 |
197 | 198 | 199 | 200 |
201 | `, 202 | data() { 203 | return { 204 | calendarOptions0: DEFAULT_OPTIONS, 205 | calendarOptions1: DEFAULT_OPTIONS, 206 | calendarOptions2: DEFAULT_OPTIONS, 207 | } 208 | }, 209 | methods: { 210 | check() { 211 | let ref0 = this.$refs.fullCalendar0.getApi() 212 | let ref1 = this.$refs.fullCalendar1.getApi() 213 | let ref2 = this.$refs.fullCalendar2.getApi() 214 | expect(ref0).not.toEqual(ref1) 215 | expect(ref1).not.toEqual(ref2) 216 | expect(ref2).not.toEqual(ref0) 217 | } 218 | } 219 | }) 220 | wrapper.vm.check() 221 | }) 222 | 223 | 224 | // toolbar/event non-reactivity 225 | 226 | const COMPONENT_FOR_OPTION_MANIP = { 227 | props: [ 'calendarViewDidMount', 'calendarEventContent' ], 228 | components: { 229 | FullCalendar 230 | }, 231 | template: ` 232 |
233 |
calendarHeight: {{ calendarOptions.height }}
234 | 235 |
236 | `, 237 | data() { 238 | return { 239 | something: 0, 240 | calendarOptions: { 241 | ...DEFAULT_OPTIONS, 242 | viewDidMount: this.calendarViewDidMount, // pass the prop 243 | eventContent: this.calendarEventContent, // pass the prop 244 | headerToolbar: buildToolbar(), 245 | events: buildEvents(1), 246 | weekends: true // needs to be initially present if we plan on changing it (a Vue concept) 247 | } 248 | } 249 | }, 250 | methods: { 251 | changeSomething() { 252 | this.something++ 253 | }, 254 | disableWeekends() { 255 | this.calendarOptions.weekends = false 256 | } 257 | } 258 | } 259 | 260 | it('handles an object change when prop is reassigned', async () => { 261 | let wrapper = mount(COMPONENT_FOR_OPTION_MANIP) 262 | expect(isWeekendsRendered(wrapper)).toEqual(true) 263 | 264 | wrapper.vm.disableWeekends() 265 | await nextTick() 266 | expect(isWeekendsRendered(wrapper)).toEqual(false) 267 | }) 268 | 269 | it('avoids rerendering unchanged toolbar/events', async () => { 270 | let viewMountCnt = 0 271 | let eventRenderCnt = 0 272 | 273 | let wrapper = mount(COMPONENT_FOR_OPTION_MANIP, { 274 | propsData: { 275 | calendarViewDidMount() { 276 | viewMountCnt++ 277 | }, 278 | calendarEventContent() { 279 | eventRenderCnt++ 280 | } 281 | } 282 | }) 283 | 284 | expect(viewMountCnt).toEqual(1) 285 | expect(eventRenderCnt).toEqual(1) 286 | 287 | viewMountCnt = 0 288 | eventRenderCnt = 0 289 | 290 | wrapper.vm.changeSomething() 291 | expect(viewMountCnt).toEqual(0) 292 | expect(eventRenderCnt).toEqual(0) 293 | }) 294 | 295 | 296 | // event reactivity 297 | 298 | const COMPONENT_FOR_EVENT_MANIP = { 299 | components: { 300 | FullCalendar 301 | }, 302 | template: ` 303 | 304 | `, 305 | data() { 306 | return { 307 | calendarOptions: { 308 | ...DEFAULT_OPTIONS, 309 | events: buildEvents(1) 310 | } 311 | } 312 | }, 313 | methods: { 314 | addEvent() { 315 | this.calendarOptions.events.push(buildEvent(1)) 316 | }, 317 | updateTitle(title) { 318 | this.calendarOptions.events[0].title = title 319 | } 320 | } 321 | } 322 | 323 | it('reacts to event adding', async () => { 324 | let wrapper = mount(COMPONENT_FOR_EVENT_MANIP) 325 | expect(getRenderedEventCount(wrapper)).toEqual(1) 326 | 327 | wrapper.vm.addEvent() 328 | await nextTick() 329 | expect(getRenderedEventCount(wrapper)).toEqual(2) 330 | }) 331 | 332 | it('reacts to event property changes', async () => { 333 | let wrapper = mount(COMPONENT_FOR_EVENT_MANIP) 334 | expect(getFirstEventTitle(wrapper)).toEqual('event0') 335 | wrapper.vm.updateTitle('another title') 336 | 337 | await nextTick() 338 | expect(getFirstEventTitle(wrapper)).toEqual('another title') 339 | }) 340 | 341 | 342 | // event reactivity with fetch function 343 | 344 | const EVENT_FUNC_COMPONENT = { 345 | components: { 346 | FullCalendar 347 | }, 348 | template: ` 349 | 350 | `, 351 | data() { 352 | return { 353 | calendarOptions: { 354 | ...DEFAULT_OPTIONS, 355 | events: this.fetchEvents 356 | } 357 | } 358 | }, 359 | methods: { 360 | fetchEvents(fetchInfo, successCallback) { 361 | setTimeout(() => { 362 | successCallback(buildEvents(2)) 363 | }, 0) 364 | } 365 | } 366 | } 367 | 368 | it('can receive an async event function', function(done) { 369 | let wrapper = mount(EVENT_FUNC_COMPONENT) 370 | setTimeout(() => { 371 | expect(getRenderedEventCount(wrapper)).toEqual(2) 372 | done() 373 | }, 100) // more than event function's setTimeout 374 | }) 375 | 376 | 377 | // event reactivity with computed prop 378 | 379 | const EVENT_COMP_PROP_COMPONENT = { 380 | components: { 381 | FullCalendar 382 | }, 383 | template: ` 384 | 385 | `, 386 | data() { 387 | return { 388 | first: true 389 | } 390 | }, 391 | computed: { 392 | calendarOptions() { 393 | return { 394 | ...DEFAULT_OPTIONS, 395 | events: this.first ? [] : buildEvents(2) 396 | } 397 | } 398 | }, 399 | methods: { 400 | markNotFirst() { 401 | this.first = false 402 | } 403 | } 404 | } 405 | 406 | it('reacts to computed events prop', async () => { 407 | let wrapper = mount(EVENT_COMP_PROP_COMPONENT) 408 | expect(getRenderedEventCount(wrapper)).toEqual(0) 409 | 410 | wrapper.vm.markNotFirst() 411 | await nextTick() 412 | expect(getRenderedEventCount(wrapper)).toEqual(2) 413 | }) 414 | 415 | 416 | // component with vue slots 417 | 418 | const COMPONENT_WITH_SLOTS = { 419 | components: { 420 | FullCalendar 421 | }, 422 | template: ` 423 | 424 | 428 | 429 | `, 430 | data() { 431 | return { 432 | calendarOptions: { 433 | ...DEFAULT_OPTIONS, 434 | events: buildEvents(1) 435 | } 436 | } 437 | }, 438 | methods: { 439 | resetEvents() { 440 | this.calendarOptions.events = buildEvents(1) 441 | } 442 | } 443 | } 444 | 445 | it('renders and rerenders a custom slot', async () => { 446 | let wrapper = mount(COMPONENT_WITH_SLOTS) 447 | await nextTick() 448 | 449 | let eventEl = getRenderedEventEls(wrapper)[0] 450 | expect(eventEl.findAll('i').length).toEqual(1) 451 | 452 | wrapper.vm.resetEvents() 453 | await nextTick() 454 | eventEl = getRenderedEventEls(wrapper)[0] 455 | expect(eventEl.findAll('i').length).toEqual(1) 456 | }) 457 | 458 | it('calls nested vue lifecycle methods when in custom content', async () => { 459 | let mountedCalled = false 460 | let beforeUnmountCalled = false 461 | let unmountedCalled = false 462 | let wrapper = mount({ 463 | components: { 464 | FullCalendar, 465 | EventContent: { 466 | props: { 467 | event: { type: Object, required: true } 468 | }, 469 | template: ` 470 |
{{ event.title }}
471 | `, 472 | mounted() { 473 | mountedCalled = true 474 | }, 475 | beforeUnmount() { 476 | beforeUnmountCalled = true 477 | }, 478 | unmounted() { 479 | unmountedCalled = true 480 | }, 481 | } 482 | }, 483 | template: ` 484 | 485 | 488 | 489 | `, 490 | data() { 491 | return { 492 | calendarOptions: { 493 | ...DEFAULT_OPTIONS, 494 | events: buildEvents(1) 495 | } 496 | } 497 | } 498 | }) 499 | await nextTick() 500 | expect(mountedCalled).toEqual(true) 501 | 502 | wrapper.unmount() 503 | await nextTick() 504 | expect(beforeUnmountCalled).toEqual(true) 505 | expect(unmountedCalled).toEqual(true) 506 | }) 507 | 508 | // component with eventContent (two multi-day event) 509 | 510 | const COMPONENT_WITH_SLOTS_MULTIDAY_EVENTS = { 511 | components: { 512 | FullCalendar 513 | }, 514 | template: ` 515 | 516 | 520 | 521 | `, 522 | data() { 523 | return { 524 | calendarOptions: { 525 | ...DEFAULT_OPTIONS, 526 | events: [ 527 | { title: 'all-day 1', start: INITIAL_DATE, end: '2019-05-18' }, // 3 day 528 | { title: 'all-day 2', start: INITIAL_DATE, end: '2019-05-17' }, // 2 day 529 | ] 530 | } 531 | } 532 | } 533 | } 534 | 535 | it('renders two multi-day events positioned correctly', async () => { 536 | let wrapper = mount(COMPONENT_WITH_SLOTS_MULTIDAY_EVENTS) 537 | await nextTick() 538 | 539 | let eventEls = getRenderedEventEls(wrapper).map((wrapper) => wrapper.element) 540 | expect(eventEls.length).toBe(2) 541 | expect(anyElsIntersect(eventEls)).toBe(false) 542 | }) 543 | 544 | // component with eventContent (multi-day & timed) 545 | 546 | const COMPONENT_WITH_SLOTS_MULTIDAY_AND_TIMED = { 547 | components: { 548 | FullCalendar 549 | }, 550 | template: ` 551 | 552 | 556 | 557 | `, 558 | data() { 559 | return { 560 | calendarOptions: { 561 | ...DEFAULT_OPTIONS, 562 | events: [ 563 | { title: 'all-day 1', start: INITIAL_DATE, end: '2019-05-18' }, // 3 day 564 | { title: 'all-day 2', start: INITIAL_DATE + 'T12:00:00' }, 565 | ] 566 | } 567 | } 568 | } 569 | } 570 | 571 | it('renders a multi-day and timed event positioned correctly', async () => { 572 | let wrapper = mount(COMPONENT_WITH_SLOTS_MULTIDAY_AND_TIMED) 573 | await nextTick() 574 | 575 | let eventEls = getRenderedEventEls(wrapper).map((wrapper) => wrapper.element) 576 | expect(eventEls.length).toBe(2) 577 | expect(anyElsIntersect(eventEls)).toBe(false) 578 | }) 579 | 580 | // component with vue slots AND custom render func 581 | 582 | const COMPONENT_WITH_SLOTS2 = { 583 | components: { 584 | FullCalendar 585 | }, 586 | template: ` 587 | 588 | 592 | 593 | `, 594 | data() { 595 | return { 596 | calendarOptions: { 597 | ...DEFAULT_OPTIONS, 598 | events: buildEvents(1), 599 | eventContent: (eventArg) => { 600 | return h('i', {}, eventArg.event.title) 601 | } 602 | } 603 | } 604 | } 605 | } 606 | 607 | it('render function can return jsx', async () => { 608 | let wrapper = mount(COMPONENT_WITH_SLOTS2) 609 | await nextTick() 610 | 611 | let eventEl = getRenderedEventEls(wrapper)[0] 612 | expect(eventEl.findAll('i').length).toEqual(1) 613 | }) 614 | 615 | // component with vue slots AND custom render func that returns vanilla-js-style objects 616 | 617 | const COMPONENT_WITH_SLOTS3 = { 618 | components: { 619 | FullCalendar 620 | }, 621 | template: ` 622 | 623 | `, 624 | data() { 625 | return { 626 | calendarOptions: { 627 | ...DEFAULT_OPTIONS, 628 | events: buildEvents(1), 629 | eventContent: (eventArg) => { 630 | return { html: `${eventArg.event.title}` } 631 | } 632 | } 633 | } 634 | } 635 | } 636 | 637 | it('render function can returns vanilla-js-style objects', async () => { 638 | let wrapper = mount(COMPONENT_WITH_SLOTS3) 639 | await nextTick() 640 | 641 | let eventEl = getRenderedEventEls(wrapper)[0] 642 | expect(eventEl.findAll('i').length).toEqual(1) 643 | }) 644 | 645 | // event rendering and did-mount hooks 646 | 647 | ;['auto', 'background'].forEach((eventDisplay) => { 648 | it(`during ${eventDisplay} custom event rendering, receives el`, async () => { 649 | let eventDidMountCalled = false 650 | 651 | mount({ 652 | components: { 653 | FullCalendar 654 | }, 655 | template: ` 656 | 657 | 660 | 661 | `, 662 | data() { 663 | return { 664 | calendarOptions: { 665 | ...DEFAULT_OPTIONS, 666 | events: [ 667 | { 668 | title: 'Event 1', 669 | start: INITIAL_DATE, 670 | display: eventDisplay, 671 | }, 672 | ], 673 | eventDidMount: (eventInfo) => { 674 | expect(eventInfo.el).toBeTruthy() 675 | eventDidMountCalled = true 676 | } 677 | } 678 | } 679 | } 680 | }) 681 | 682 | await nextTick() 683 | expect(eventDidMountCalled).toBe(true) 684 | }) 685 | }) 686 | 687 | // 688 | 689 | const OTHER_COMPONENT = { 690 | template: 'other component' 691 | } 692 | 693 | const COMPONENT_USING_ROOT_OPTIONS_IN_SLOT = { 694 | components: { 695 | FullCalendar, 696 | OtherComponent: OTHER_COMPONENT 697 | }, 698 | template: ` 699 | 700 | 703 | 704 | `, 705 | data() { 706 | return { 707 | calendarOptions: { 708 | ...DEFAULT_OPTIONS, 709 | events: buildEvents(1) 710 | } 711 | } 712 | }, 713 | } 714 | 715 | it('can use component defined in higher contexts', async () => { 716 | let wrapper = mount(COMPONENT_USING_ROOT_OPTIONS_IN_SLOT) 717 | let eventEl = getRenderedEventEls(wrapper)[0] 718 | 719 | await nextTick() 720 | expect(eventEl.findAll('i').length).toEqual(1) 721 | }) 722 | 723 | 724 | it('allows plugin access for slots', async () => { 725 | let helloJp = 'こんにちは、世界' 726 | let i18n = createI18n({ 727 | locale: 'ja', 728 | messages: { 729 | ja: { 730 | message: { 731 | hello: helloJp 732 | } 733 | } 734 | } 735 | }) 736 | let Component = { 737 | components: { 738 | FullCalendar, 739 | }, 740 | template: ` 741 | 742 | 745 | 746 | `, 747 | data() { 748 | return { 749 | calendarOptions: { 750 | ...DEFAULT_OPTIONS, 751 | events: buildEvents(1) 752 | } 753 | } 754 | }, 755 | } 756 | let wrapper = mount(Component, { 757 | global: { 758 | plugins: [i18n] 759 | } 760 | }) 761 | 762 | await nextTick() 763 | let eventEl = getRenderedEventEls(wrapper)[0] 764 | expect(eventEl.text()).toEqual(helloJp) 765 | }) 766 | 767 | 768 | // dynamic events 769 | 770 | const DynamicEvent = defineAsyncComponent(() => import('./DynamicEvent.vue')) 771 | 772 | const COMPONENT_WITH_DYNAMIC_SLOTS = { 773 | components: { 774 | FullCalendar, 775 | DynamicEvent 776 | }, 777 | template: ` 778 | 779 | 782 | 783 | `, 784 | data() { 785 | return { 786 | calendarOptions: { 787 | ...DEFAULT_OPTIONS, 788 | events: buildEvents(1) 789 | } 790 | } 791 | } 792 | } 793 | 794 | // https://github.com/fullcalendar/fullcalendar-vue/issues/122 795 | it('renders dynamically imported event', (done) => { 796 | let wrapper = mount(COMPONENT_WITH_DYNAMIC_SLOTS) 797 | let eventEl = getRenderedEventEls(wrapper).at(0) 798 | 799 | setTimeout(() => { 800 | expect(eventEl.findAll('.dynamic-event').length).toEqual(1) 801 | done() 802 | }, 100) 803 | }) 804 | 805 | 806 | // slots data binding 807 | 808 | it('slot rendering reacts to bound parent state', async () => { 809 | let wrapper = mount({ 810 | components: { 811 | FullCalendar, 812 | }, 813 | template: ` 814 | 815 | 820 | 821 | `, 822 | data() { 823 | return { 824 | isBold: false, 825 | calendarOptions: { 826 | ...DEFAULT_OPTIONS, 827 | events: buildEvents(1) 828 | } 829 | } 830 | }, 831 | methods: { 832 | turnBold() { 833 | this.isBold = true 834 | } 835 | } 836 | }) 837 | let eventEl = getRenderedEventEls(wrapper).at(0) 838 | 839 | await nextTick() 840 | expect(eventEl.findAll('b').length).toEqual(0) 841 | expect(eventEl.findAll('i').length).toEqual(1) 842 | wrapper.vm.turnBold() 843 | 844 | await nextTick() 845 | expect(eventEl.findAll('b').length).toEqual(1) 846 | expect(eventEl.findAll('i').length).toEqual(0) 847 | }) 848 | 849 | 850 | // FullCalendar options utils 851 | 852 | function buildEvents(length) { 853 | let events = [] 854 | 855 | for (let i = 0; i < length; i++) { 856 | events.push(buildEvent(i)) 857 | } 858 | 859 | return events 860 | } 861 | 862 | function buildEvent(i) { 863 | return { title: 'event' + i, start: INITIAL_DATE } 864 | } 865 | 866 | function buildToolbar() { 867 | return { 868 | left: 'prev,next today', 869 | center: 'title', 870 | right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek' 871 | } 872 | } 873 | 874 | 875 | // DOM querying utils 876 | 877 | function isSkeletonRendered(wrapper) { 878 | return wrapper.find('.fc').exists() 879 | } 880 | 881 | function isWeekendsRendered(wrapper) { 882 | return wrapper.find('.fc-day-sat').exists() 883 | } 884 | 885 | function getRenderedEventEls(wrapper) { 886 | return wrapper.findAll('.fc-event') 887 | } 888 | 889 | function getRenderedEventCount(wrapper) { 890 | return getRenderedEventEls(wrapper).length 891 | } 892 | 893 | function getFirstEventTitle(wrapper) { 894 | return wrapper.find('.fc-event-title').text() 895 | } 896 | 897 | 898 | // DOM geometry utils 899 | 900 | function anyElsIntersect(els) { 901 | let rects = els.map((el) => el.getBoundingClientRect()) 902 | 903 | for (let i = 0; i < rects.length; i += 1) { 904 | for (let j = i + 1; j < rects.length; j += 1) { 905 | if (rectsIntersect(rects[i], rects[j])) { 906 | return [els[i], els[j]] 907 | } 908 | } 909 | } 910 | 911 | return false 912 | } 913 | 914 | function rectsIntersect(rect0, rect1) { 915 | return rect0.left < rect1.right && rect0.right > rect1.left && rect0.top < rect1.bottom && rect0.bottom > rect1.top 916 | } 917 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "node16", 7 | "strict": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "outDir": "dist" 12 | }, 13 | "include": [ 14 | "./src/**/*" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue' 2 | import sourcemaps from 'rollup-plugin-sourcemaps' 3 | 4 | /* 5 | Tests 6 | */ 7 | export default { 8 | root: './tests/', // all other paths are relative to this 9 | define: { 10 | 'process.env.NODE_ENV': JSON.stringify('development'), 11 | }, 12 | resolve: { 13 | alias: { 14 | 'vue': 'vue/dist/vue.esm-bundler.js' // for runtime vue templates 15 | } 16 | }, 17 | build: { 18 | sourcemap: 'inline', // gets fed into karma-sourcemap-loader 19 | lib: { 20 | entry: 'index.js', 21 | fileName: 'index', 22 | formats: ['iife'], // produces .iife.js 23 | name: 'FullCalendarVueTests' // necessary, but not used 24 | }, 25 | minify: false 26 | }, 27 | plugins: [ 28 | vue(), 29 | sourcemaps() // for READING sourcemaps 30 | ] 31 | } 32 | --------------------------------------------------------------------------------