@forcecalendar/interface 1.0.14 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/interface",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "type": "module",
5
5
  "description": "Official interface layer for forceCalendar Core - Enterprise calendar components",
6
6
  "main": "dist/force-calendar-interface.umd.js",
@@ -9,6 +9,7 @@ import StateManager from '../core/StateManager.js';
9
9
  import eventBus from '../core/EventBus.js';
10
10
  import { StyleUtils } from '../utils/StyleUtils.js';
11
11
  import { DateUtils } from '../utils/DateUtils.js';
12
+ import { DOMUtils } from '../utils/DOMUtils.js';
12
13
 
13
14
  // Import view components
14
15
  import { MonthView } from './views/MonthView.js';
@@ -38,6 +39,8 @@ export class ForceCalendar extends BaseComponent {
38
39
  super();
39
40
  this.stateManager = null;
40
41
  this.currentView = null;
42
+ this._hasRendered = false; // Track if initial render is complete
43
+ this._cachedStyles = null; // Cache styles to avoid recreation
41
44
  }
42
45
 
43
46
  initialize() {
@@ -82,13 +85,105 @@ export class ForceCalendar extends BaseComponent {
82
85
  }
83
86
 
84
87
  handleStateChange(newState, oldState) {
88
+ // If not yet rendered, do nothing (mount will handle initial render)
89
+ if (!this._hasRendered) {
90
+ return;
91
+ }
92
+
93
+ // Check what changed
94
+ const viewChanged = newState.view !== oldState?.view;
95
+ const dateChanged = newState.currentDate?.getTime() !== oldState?.currentDate?.getTime();
96
+ const eventsChanged = newState.events !== oldState?.events;
97
+ const loadingChanged = newState.loading !== oldState?.loading;
98
+ const errorChanged = newState.error !== oldState?.error;
99
+
100
+ // For loading/error state changes, do full re-render (rare)
101
+ if (loadingChanged || errorChanged) {
102
+ this.render();
103
+ return;
104
+ }
105
+
85
106
  // Update local view reference if needed
86
- if (newState.view !== oldState?.view) {
107
+ if (viewChanged) {
87
108
  this.currentView = newState.view;
88
109
  }
89
110
 
90
- // Re-render to update header title, active buttons, and child view
91
- this.render();
111
+ // Targeted updates based on what changed
112
+ if (viewChanged) {
113
+ // View changed: update title, buttons, and switch view
114
+ this._updateTitle();
115
+ this._updateViewButtons();
116
+ this._switchView();
117
+ } else if (dateChanged) {
118
+ // Date changed: update title and re-render view
119
+ this._updateTitle();
120
+ this._updateViewContent();
121
+ } else if (eventsChanged) {
122
+ // Events changed: only re-render view content
123
+ this._updateViewContent();
124
+ }
125
+ // Selection changes are handled by the view internally, no action needed here
126
+ }
127
+
128
+ /**
129
+ * Update only the title text (no DOM recreation)
130
+ */
131
+ _updateTitle() {
132
+ const titleEl = this.$('.fc-title');
133
+ if (titleEl) {
134
+ const state = this.stateManager.getState();
135
+ titleEl.textContent = this.getTitle(state.currentDate, state.view);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Update view button active states (no DOM recreation)
141
+ */
142
+ _updateViewButtons() {
143
+ const state = this.stateManager.getState();
144
+ this.$$('[data-view]').forEach(button => {
145
+ const isActive = button.dataset.view === state.view;
146
+ button.classList.toggle('active', isActive);
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Switch to a different view type
152
+ */
153
+ _switchView() {
154
+ const container = this.$('#calendar-view-container');
155
+ if (!container) return;
156
+
157
+ // Clean up previous view
158
+ if (this._currentViewInstance) {
159
+ if (this._currentViewInstance.cleanup) {
160
+ this._currentViewInstance.cleanup();
161
+ }
162
+ }
163
+
164
+ // Create new view
165
+ try {
166
+ const viewRenderer = this._createViewRenderer(this.currentView);
167
+ if (viewRenderer) {
168
+ viewRenderer._viewType = this.currentView;
169
+ this._currentViewInstance = viewRenderer;
170
+ viewRenderer.stateManager = this.stateManager;
171
+ viewRenderer.container = container;
172
+ viewRenderer.render();
173
+ // Note: No subscription - handleStateChange manages all view updates
174
+ }
175
+ } catch (err) {
176
+ console.error('[ForceCalendar] Error switching view:', err);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Re-render only the view content (not header)
182
+ */
183
+ _updateViewContent() {
184
+ if (this._currentViewInstance && this._currentViewInstance.render) {
185
+ this._currentViewInstance.render();
186
+ }
92
187
  }
93
188
 
94
189
  mount() {
@@ -501,7 +596,7 @@ export class ForceCalendar extends BaseComponent {
501
596
  return `
502
597
  <div class="force-calendar">
503
598
  <div class="fc-error">
504
- <p><strong>Error:</strong> ${error.message || 'An error occurred'}</p>
599
+ <p><strong>Error:</strong> ${DOMUtils.escapeHTML(error.message || 'An error occurred')}</p>
505
600
  </div>
506
601
  </div>
507
602
  `;
@@ -570,13 +665,11 @@ export class ForceCalendar extends BaseComponent {
570
665
  afterRender() {
571
666
  // Manually instantiate and mount view component (bypasses Locker Service)
572
667
  const container = this.$('#calendar-view-container');
573
- console.log('[ForceCalendar] afterRender - container:', !!container, 'stateManager:', !!this.stateManager, 'currentView:', this.currentView);
574
668
 
575
669
  // Only create view once per view type change
576
670
  if (container && this.stateManager && this.currentView) {
577
671
  // Check if container actually has content (render() clears shadow DOM)
578
672
  if (this._currentViewInstance && this._currentViewInstance._viewType === this.currentView && container.children.length > 0) {
579
- console.log('[ForceCalendar] View already exists with content, skipping creation');
580
673
  return;
581
674
  }
582
675
 
@@ -591,8 +684,6 @@ export class ForceCalendar extends BaseComponent {
591
684
  }
592
685
  }
593
686
 
594
- console.log('[ForceCalendar] Creating view for:', this.currentView);
595
-
596
687
  // Create a simple view renderer that doesn't use custom elements
597
688
  try {
598
689
  const viewRenderer = this._createViewRenderer(this.currentView);
@@ -601,21 +692,9 @@ export class ForceCalendar extends BaseComponent {
601
692
  this._currentViewInstance = viewRenderer;
602
693
  viewRenderer.stateManager = this.stateManager;
603
694
  viewRenderer.container = container;
604
-
605
- console.log('[ForceCalendar] Calling viewRenderer.render()');
606
695
  viewRenderer.render();
607
- console.log('[ForceCalendar] viewRenderer.render() completed');
608
-
609
- // Subscribe to state changes (store unsubscribe function)
610
- this._viewUnsubscribe = this.stateManager.subscribe((newState, oldState) => {
611
- // Only re-render on data changes, not view changes
612
- if (newState.events !== oldState?.events ||
613
- newState.currentDate !== oldState?.currentDate) {
614
- if (viewRenderer && viewRenderer.render) {
615
- viewRenderer.render();
616
- }
617
- }
618
- });
696
+ // Note: No subscription here - handleStateChange manages all view updates
697
+ // via _updateViewContent(), _switchView(), or full re-render
619
698
  }
620
699
  } catch (err) {
621
700
  console.error('[ForceCalendar] Error creating/rendering view:', err);
@@ -663,6 +742,9 @@ export class ForceCalendar extends BaseComponent {
663
742
  });
664
743
  });
665
744
  }
745
+
746
+ // Mark initial render as complete for targeted updates
747
+ this._hasRendered = true;
666
748
  }
667
749
 
668
750
  _createViewRenderer(viewName) {
@@ -677,6 +759,11 @@ export class ForceCalendar extends BaseComponent {
677
759
  _listeners: [],
678
760
  _scrolled: false,
679
761
 
762
+ _escapeHTML(str) {
763
+ if (str == null) return '';
764
+ return DOMUtils.escapeHTML(String(str));
765
+ },
766
+
680
767
  cleanup() {
681
768
  this._listeners.forEach(({ element, event, handler }) => {
682
769
  element.removeEventListener(event, handler);
@@ -758,8 +845,8 @@ export class ForceCalendar extends BaseComponent {
758
845
  <div class="fc-day-number" style="font-size: 13px; font-weight: 500; color: ${dayNumColor}; padding: 2px 4px; margin-bottom: 4px; ${todayStyle}">${day.dayOfMonth}</div>
759
846
  <div class="fc-day-events" style="display: flex; flex-direction: column; gap: 2px;">
760
847
  ${visibleEvents.map(evt => `
761
- <div class="fc-event" data-event-id="${evt.id}" style="background-color: ${evt.backgroundColor || '#2563eb'}; font-size: 11px; padding: 2px 6px; border-radius: 3px; color: white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;">
762
- ${evt.title}
848
+ <div class="fc-event" data-event-id="${this._escapeHTML(evt.id)}" style="background-color: ${evt.backgroundColor || '#2563eb'}; font-size: 11px; padding: 2px 6px; border-radius: 3px; color: white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;">
849
+ ${this._escapeHTML(evt.title)}
763
850
  </div>
764
851
  `).join('')}
765
852
  ${moreCount > 0 ? `<div class="fc-more-events" style="font-size: 10px; color: #6b7280; padding: 2px 4px; font-weight: 500;">+${moreCount} more</div>` : ''}
@@ -818,8 +905,8 @@ export class ForceCalendar extends BaseComponent {
818
905
  ${processedDays.map(day => `
819
906
  <div style="border-right: 1px solid #e5e7eb; padding: 4px; display: flex; flex-direction: column; gap: 2px;">
820
907
  ${day.allDayEvents.map(evt => `
821
- <div class="fc-event" data-event-id="${evt.id}" style="background-color: ${evt.backgroundColor || '#2563eb'}; font-size: 10px; padding: 2px 4px; border-radius: 2px; color: white; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
822
- ${evt.title}
908
+ <div class="fc-event" data-event-id="${this._escapeHTML(evt.id)}" style="background-color: ${evt.backgroundColor || '#2563eb'}; font-size: 10px; padding: 2px 4px; border-radius: 2px; color: white; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
909
+ ${this._escapeHTML(evt.title)}
823
910
  </div>
824
911
  `).join('')}
825
912
  </div>
@@ -919,8 +1006,8 @@ export class ForceCalendar extends BaseComponent {
919
1006
  <div style="font-size: 9px; color: #6b7280; display: flex; align-items: center; justify-content: center; border-right: 1px solid #e5e7eb; text-transform: uppercase; font-weight: 700;">All day</div>
920
1007
  <div style="padding: 6px 12px; display: flex; flex-wrap: wrap; gap: 4px;">
921
1008
  ${allDayEvents.map(evt => `
922
- <div class="fc-event" data-event-id="${evt.id}" style="background-color: ${evt.backgroundColor || '#2563eb'}; font-size: 12px; padding: 4px 8px; border-radius: 4px; color: white; cursor: pointer; font-weight: 500;">
923
- ${evt.title}
1009
+ <div class="fc-event" data-event-id="${this._escapeHTML(evt.id)}" style="background-color: ${evt.backgroundColor || '#2563eb'}; font-size: 12px; padding: 4px 8px; border-radius: 4px; color: white; cursor: pointer; font-weight: 500;">
1010
+ ${this._escapeHTML(evt.title)}
924
1011
  </div>
925
1012
  `).join('')}
926
1013
  </div>
@@ -965,12 +1052,12 @@ export class ForceCalendar extends BaseComponent {
965
1052
  const color = event.backgroundColor || '#2563eb';
966
1053
 
967
1054
  return `
968
- <div class="fc-event" data-event-id="${event.id}"
1055
+ <div class="fc-event" data-event-id="${this._escapeHTML(event.id)}"
969
1056
  style="position: absolute; top: ${startMinutes}px; height: ${durationMinutes}px; left: 2px; right: 2px;
970
1057
  background-color: ${color}; border-radius: 4px; padding: 4px 8px; font-size: 11px;
971
1058
  font-weight: 500; color: white; overflow: hidden; box-shadow: 0 1px 2px rgba(0,0,0,0.1);
972
1059
  cursor: pointer; z-index: 5;">
973
- <div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${event.title}</div>
1060
+ <div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${this._escapeHTML(event.title)}</div>
974
1061
  <div style="font-size: 10px; opacity: 0.9;">${this._formatTime(start)}</div>
975
1062
  </div>
976
1063
  `;
@@ -984,12 +1071,12 @@ export class ForceCalendar extends BaseComponent {
984
1071
  const color = event.backgroundColor || '#2563eb';
985
1072
 
986
1073
  return `
987
- <div class="fc-event" data-event-id="${event.id}"
1074
+ <div class="fc-event" data-event-id="${this._escapeHTML(event.id)}"
988
1075
  style="position: absolute; top: ${startMinutes}px; height: ${durationMinutes}px; left: 12px; right: 24px;
989
1076
  background-color: ${color}; border-radius: 6px; padding: 8px 12px; font-size: 13px;
990
1077
  font-weight: 500; color: white; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);
991
1078
  cursor: pointer; z-index: 5;">
992
- <div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${event.title}</div>
1079
+ <div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${this._escapeHTML(event.title)}</div>
993
1080
  <div style="font-size: 11px; opacity: 0.9;">${this._formatTime(start)} - ${this._formatTime(end)}</div>
994
1081
  </div>
995
1082
  `;
@@ -39,6 +39,32 @@ class StateManager {
39
39
  this.subscribe = this.subscribe.bind(this);
40
40
  this.unsubscribe = this.unsubscribe.bind(this);
41
41
  this.setState = this.setState.bind(this);
42
+
43
+ // Initial sync of events from Core (in case events were pre-loaded)
44
+ this._syncEventsFromCore({ silent: true });
45
+ }
46
+
47
+ /**
48
+ * Sync state.events from Core calendar (single source of truth)
49
+ * This ensures state.events always matches Core's event store
50
+ */
51
+ _syncEventsFromCore(options = {}) {
52
+ const coreEvents = this.calendar.getEvents() || [];
53
+ // Only update if different to avoid unnecessary re-renders
54
+ if (this.state.events.length !== coreEvents.length ||
55
+ !this._eventsMatch(this.state.events, coreEvents)) {
56
+ this.setState({ events: [...coreEvents] }, options);
57
+ }
58
+ return coreEvents;
59
+ }
60
+
61
+ /**
62
+ * Check if two event arrays have the same events (by id)
63
+ */
64
+ _eventsMatch(arr1, arr2) {
65
+ if (arr1.length !== arr2.length) return false;
66
+ const ids1 = new Set(arr1.map(e => e.id));
67
+ return arr2.every(e => ids1.has(e.id));
42
68
  }
43
69
 
44
70
  // State management
@@ -150,14 +176,16 @@ class StateManager {
150
176
  eventBus.emit('event:error', { action: 'add', event, error: 'Failed to add event' });
151
177
  return null;
152
178
  }
153
- // Create new array to avoid mutation before setState
154
- const newEvents = [...this.state.events, addedEvent];
155
- this.setState({ events: newEvents });
179
+ // Sync from Core to ensure consistency (single source of truth)
180
+ this._syncEventsFromCore();
156
181
  eventBus.emit('event:added', { event: addedEvent });
157
182
  return addedEvent;
158
183
  }
159
184
 
160
185
  updateEvent(eventId, updates) {
186
+ // First, ensure state is in sync with Core (recover from any prior desync)
187
+ this._syncEventsFromCore({ silent: true });
188
+
161
189
  const event = this.calendar.updateEvent(eventId, updates);
162
190
  if (!event) {
163
191
  console.error(`Failed to update event: ${eventId}`);
@@ -165,37 +193,39 @@ class StateManager {
165
193
  return null;
166
194
  }
167
195
 
168
- const index = this.state.events.findIndex(e => e.id === eventId);
169
- if (index === -1) {
170
- console.error(`Event ${eventId} not found in state`);
171
- eventBus.emit('event:error', { action: 'update', eventId, error: 'Event not found in state' });
172
- return null;
173
- }
174
-
175
- // Create new array to avoid mutation before setState
176
- const newEvents = [...this.state.events];
177
- newEvents[index] = event;
178
- this.setState({ events: newEvents });
196
+ // Sync from Core to ensure consistency (single source of truth)
197
+ this._syncEventsFromCore();
179
198
  eventBus.emit('event:updated', { event });
180
199
  return event;
181
200
  }
182
201
 
183
202
  deleteEvent(eventId) {
203
+ // First, ensure state is in sync with Core (recover from any prior desync)
204
+ this._syncEventsFromCore({ silent: true });
205
+
184
206
  const deleted = this.calendar.removeEvent(eventId);
185
207
  if (!deleted) {
186
208
  console.error(`Failed to delete event: ${eventId}`);
187
209
  eventBus.emit('event:error', { action: 'delete', eventId, error: 'Event not found' });
188
210
  return false;
189
211
  }
190
- // Create new array to avoid mutation before setState
191
- const newEvents = this.state.events.filter(e => e.id !== eventId);
192
- this.setState({ events: newEvents });
212
+ // Sync from Core to ensure consistency (single source of truth)
213
+ this._syncEventsFromCore();
193
214
  eventBus.emit('event:deleted', { eventId });
194
215
  return true;
195
216
  }
196
217
 
197
218
  getEvents() {
198
- return this.calendar.getEvents();
219
+ // Return from Core (source of truth)
220
+ return this.calendar.getEvents() || [];
221
+ }
222
+
223
+ /**
224
+ * Force sync state.events from Core calendar
225
+ * Use this if you've modified events directly on the Core calendar
226
+ */
227
+ syncEvents() {
228
+ return this._syncEventsFromCore();
199
229
  }
200
230
 
201
231
  getEventsForDate(date) {