diff --git a/.github/workflows/test_js.yml b/.github/workflows/test_js.yml new file mode 100644 index 0000000..aa86960 --- /dev/null +++ b/.github/workflows/test_js.yml @@ -0,0 +1,25 @@ +name: JS Tests + +on: [push] + +jobs: + test: + name: Test + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Corepack + run: | + npm install --global corepack@latest + corepack enable + + - name: Install + run: | + corepack enable + make nodejs + + - name: Run tests + run: make wtr \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test_py.yml similarity index 100% rename from .github/workflows/test.yml rename to .github/workflows/test_py.yml diff --git a/CHANGES.rst b/CHANGES.rst index 3c4e0a9..051361a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,10 @@ Changes ======= -1.1.1 (unreleased) +2.0.0 (unreleased) ------------------ -- Nothing changed yet. +- Bootstrap 5 Styles. 1.1.0 (2026-02-03) diff --git a/Makefile b/Makefile index 8f67f3d..29182e6 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ #: i18n.lingua #: js.nodejs #: js.rollup +#: js.wtr #: qa.coverage #: qa.test # @@ -82,6 +83,16 @@ NODEJS_OPT_PACKAGES?= # No default value. NODEJS_INSTALL_OPTS?= +## js.wtr + +# Web test runner config file. +# Default: wtr.config.mjs +WTR_CONFIG?=js/wtr.config.mjs + +# Web test runner additional command line options. +# Default: --coverage +WTR_OPTIONS?=--coverage + ## js.rollup # Rollup config file. @@ -313,6 +324,18 @@ INSTALL_TARGETS+=nodejs DIRTY_TARGETS+=nodejs-dirty CLEAN_TARGETS+=nodejs-clean +############################################################################## +# web test runner +############################################################################## + +NODEJS_DEV_PACKAGES+=\ + @web/test-runner \ + @web/dev-server-import-maps + +.PHONY: wtr +wtr: $(NODEJS_TARGET) + @web-test-runner $(WTR_OPTIONS) --config $(WTR_CONFIG) + ############################################################################## # rollup ############################################################################## diff --git a/js/rollup.conf.js b/js/rollup.conf.js index e22edef..ff2fac9 100644 --- a/js/rollup.conf.js +++ b/js/rollup.conf.js @@ -1,32 +1,40 @@ import cleanup from 'rollup-plugin-cleanup'; import terser from '@rollup/plugin-terser'; +import postcss from 'rollup-plugin-postcss'; +import resolve from '@rollup/plugin-node-resolve'; const out_dir = 'src/cone/calendar/browser/static/calendar'; +const out_dir_fullcalendar = 'src/cone/calendar/browser/static/fullcalendar'; export default args => { - let conf = { + let conf = []; + + //////////////////////////////////////////////////////////////////////////// + // BOOTSTRAP5 + //////////////////////////////////////////////////////////////////////////// + + let bundle_bs5 = { input: 'js/src/bundle.js', plugins: [ cleanup() ], output: [{ - file: `${out_dir}/cone.calendar.js`, name: 'cone_calendar', + file: `${out_dir}/cone.calendar.js`, format: 'iife', globals: { jquery: 'jQuery' }, - interop: 'default', - sourcemap: false + interop: 'default' }], external: [ 'jquery' ] }; if (args.configDebug !== true) { - conf.output.push({ - file: `${out_dir}/cone.calendar.min.js`, + bundle_bs5.output.push({ name: 'cone_calendar', + file: `${out_dir}/cone.calendar.min.js`, format: 'iife', plugins: [ terser() @@ -34,9 +42,56 @@ export default args => { globals: { jquery: 'jQuery' }, + interop: 'default' + }); + } + conf.push(bundle_bs5); + + let scss_bs5 = { + input: ['scss/styles.scss'], + output: [{ + file: `${out_dir}/cone.calendar.css`, + format: 'es', + plugins: [terser()], + }], + plugins: [ + postcss({ + extract: true, + minimize: true, + use: [ + ['sass', { outputStyle: 'compressed' }], + ], + }), + ], + }; + conf.push(scss_bs5); + + let bundle_fullcalendar = { + input: 'js/src/fullcalendar.js', + plugins: [ + resolve(), + cleanup() + ], + output: [{ + file: `${out_dir_fullcalendar}/fullcalendar.js`, + name: 'fullcalendar', + format: 'iife', + plugins: [ + // terser() + ], + globals: { + '@fullcalendar/core': 'FullCalendar', + '@fullcalendar/daygrid': 'FullCalendarDayGrid', + '@fullcalendar/timegrid': 'FullCalendarTimeGrid', + '@fullcalendar/list': 'FullCalendarList', + '@fullcalendar/interaction': 'FullCalendarInteraction', + '@fullcalendar/bootstrap5': 'FullCalendarBootstrap5', + }, interop: 'default', sourcemap: false - }); + }] } + conf.push(bundle_fullcalendar); + return conf; }; diff --git a/js/src/calendar.js b/js/src/calendar.js index 86e8965..c6b6df8 100644 --- a/js/src/calendar.js +++ b/js/src/calendar.js @@ -10,14 +10,23 @@ class EventSource { this.events = this.events.bind(this); } - events(start, end, timezone, callback) { - let calendar = this._calendar, - url = calendar.target + '/' + this._events_view; + events(info, successCallback, failureCallback) { + let calendar = this._calendar; + let url = calendar.target + '/' + this._events_view; + + // XXX: Have a feeling this can be better, included functionality let params = { - start: start.unix(), - end: end.unix() + start: Math.floor(info.start.getTime() / 1000), + end: Math.floor(info.end.getTime() / 1000), + timezone: info.timeZone || 'local' }; - calendar.json_request(url, params, callback, null); + + calendar.json_request(url, params, (events) => { + successCallback(events); + }, (error) => { + console.error('Error fetching events:', error); + failureCallback(error); + }); } } @@ -44,14 +53,73 @@ export class Calendar { $.extend(options, { eventSources: event_sources, eventClick: this.event_clicked.bind(this), - dayClick: this.day_clicked.bind(this), + dateClick: this.date_clicked.bind(this), eventDrop: this.event_drop.bind(this), - eventResize: this.event_resize.bind(this) - }); - elem.bind('reload', function() { - elem.fullCalendar('refetchEvents'); + eventResize: this.event_resize.bind(this), + moreLinkDidMount(arg) { + // move popover to left if space not sufficient + arg.el.addEventListener('click', () => { + // wait a tick for the popover to render + setTimeout(() => { + const popover = $('.fc-popover'); + if (popover) { + const w = elem.outerWidth(), + pw = popover.outerWidth(), + pl = parseInt(popover.css('left')); + if (pl + pw > w) { + popover.css('transform', 'translateX(-100%)'); + } + } + }, 0); + }); + }, + timeZone: 'UTC', + plugins: [ + fullcalendar.bootstrap5Plugin, + fullcalendar.dayGridPlugin, + fullcalendar.timeGridPlugin, + fullcalendar.listPlugin, + fullcalendar.interactionPlugin + ], + themeSystem: 'bootstrap5', + height: 'auto' }); - elem.fullCalendar(options); + this.close_on_outside_click = this.close_on_outside_click.bind(this); + const calendar = this.calendar = new fullcalendar.Calendar( + this.elem.get(0), + options + ); + + this.refetch_events = this.refetch_events.bind(this); + this.elem.on('reload', this.refetch_events); + calendar.render(); + this.on_resize = this.on_resize.bind(this); + $(window).on('resize', this.on_resize); + cone.global_events.on('on_sidebar_left_resize', this.on_resize); + cone.global_events.on('on_sidebar_right_resize', this.on_resize); + this.on_resize(); + + window.ts.ajax.attach(this, elem); + } + + refetch_events() { + this.calendar.refetchEvents(); + } + + on_resize(evt) { + const width = $(window).width(); + if (width <= 700 && (this._window_width > 700 || !evt)) { + this.calendar.setOption('height', 'auto'); + } else if (width > 700 && (this._window_width <= 700 || !evt)) { + this.calendar.setOption('height', undefined); + } + this.calendar.updateSize(); + if (this.elem.outerWidth() <= 700) { + this.calendar.setOption('dayMaxEventRows', 0); + } else { + this.calendar.setOption('dayMaxEventRows', 3); + } + this._window_width = width; } json_request(url, params, callback, errback) { @@ -74,32 +142,87 @@ export class Calendar { }); } + close_on_outside_click() { + const handler = (event) => { + const path = event.composedPath(); + const inside_event = path.some(el => + el.classList && el.classList.contains('fc-event') + ); + + if (!path.includes(this.menu) && !inside_event) { + this.menu.remove(); + document.removeEventListener('click', handler); + } + } + // delay adding listener to avoid immediately catching the open click + setTimeout(() => { + document.addEventListener('click', handler); + }); + } + create_context_menu(actions, x, y) { - let body = $('body', document); - let wrapper = $('
') + const body = $('body', document); + if (this.wrapper) { + this.wrapper.remove(); + } + const wrapper = this.wrapper = $('
') .attr('class', 'calendar-contextmenu-wrapper') .css('height', body.height() + 'px'); body.append(wrapper); - wrapper.on('click contextmenu', function(e) { + wrapper.on('click contextmenu', function (e) { e.preventDefault(); wrapper.remove(); }); - let menu = $('