@bryntum/scheduler-react-thin 7.1.1

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.
Files changed (56) hide show
  1. package/README.md +52 -0
  2. package/lib/BryntumEventColorField.d.ts +232 -0
  3. package/lib/BryntumEventColorField.js +169 -0
  4. package/lib/BryntumEventColorField.js.map +1 -0
  5. package/lib/BryntumProjectCombo.d.ts +268 -0
  6. package/lib/BryntumProjectCombo.js +203 -0
  7. package/lib/BryntumProjectCombo.js.map +1 -0
  8. package/lib/BryntumResourceCombo.d.ts +268 -0
  9. package/lib/BryntumResourceCombo.js +203 -0
  10. package/lib/BryntumResourceCombo.js.map +1 -0
  11. package/lib/BryntumResourceFilter.d.ts +215 -0
  12. package/lib/BryntumResourceFilter.js +154 -0
  13. package/lib/BryntumResourceFilter.js.map +1 -0
  14. package/lib/BryntumScheduler.d.ts +2039 -0
  15. package/lib/BryntumScheduler.js +642 -0
  16. package/lib/BryntumScheduler.js.map +1 -0
  17. package/lib/BryntumSchedulerBase.d.ts +2038 -0
  18. package/lib/BryntumSchedulerBase.js +641 -0
  19. package/lib/BryntumSchedulerBase.js.map +1 -0
  20. package/lib/BryntumSchedulerDatePicker.d.ts +314 -0
  21. package/lib/BryntumSchedulerDatePicker.js +216 -0
  22. package/lib/BryntumSchedulerDatePicker.js.map +1 -0
  23. package/lib/BryntumSchedulerProjectModel.d.ts +91 -0
  24. package/lib/BryntumSchedulerProjectModel.js +98 -0
  25. package/lib/BryntumSchedulerProjectModel.js.map +1 -0
  26. package/lib/BryntumTimelineHistogram.d.ts +1185 -0
  27. package/lib/BryntumTimelineHistogram.js +448 -0
  28. package/lib/BryntumTimelineHistogram.js.map +1 -0
  29. package/lib/BryntumUndoRedo.d.ts +190 -0
  30. package/lib/BryntumUndoRedo.js +152 -0
  31. package/lib/BryntumUndoRedo.js.map +1 -0
  32. package/lib/BryntumViewPresetCombo.d.ts +216 -0
  33. package/lib/BryntumViewPresetCombo.js +158 -0
  34. package/lib/BryntumViewPresetCombo.js.map +1 -0
  35. package/lib/WrapperHelper.d.ts +26 -0
  36. package/lib/WrapperHelper.js +569 -0
  37. package/lib/WrapperHelper.js.map +1 -0
  38. package/lib/index.d.ts +11 -0
  39. package/lib/index.js +12 -0
  40. package/lib/index.js.map +1 -0
  41. package/license.pdf +0 -0
  42. package/licenses.md +310 -0
  43. package/package.json +25 -0
  44. package/src/BryntumEventColorField.tsx +996 -0
  45. package/src/BryntumProjectCombo.tsx +1233 -0
  46. package/src/BryntumResourceCombo.tsx +1236 -0
  47. package/src/BryntumResourceFilter.tsx +931 -0
  48. package/src/BryntumScheduler.tsx +5184 -0
  49. package/src/BryntumSchedulerBase.tsx +5182 -0
  50. package/src/BryntumSchedulerDatePicker.tsx +1365 -0
  51. package/src/BryntumSchedulerProjectModel.tsx +424 -0
  52. package/src/BryntumTimelineHistogram.tsx +3427 -0
  53. package/src/BryntumUndoRedo.tsx +886 -0
  54. package/src/BryntumViewPresetCombo.tsx +915 -0
  55. package/src/WrapperHelper.tsx +1125 -0
  56. package/src/index.ts +15 -0
@@ -0,0 +1,1125 @@
1
+ /**
2
+ * React widget helper
3
+ */
4
+ import React, { ReactElement } from 'react';
5
+ import ReactDOM, { flushSync } from 'react-dom';
6
+ import { DomHelper, StringHelper, Widget } from '@bryntum/core-thin';
7
+
8
+ declare global {
9
+
10
+ interface Window {
11
+ bryntum: {
12
+ isTestEnv?: boolean
13
+ react?: {
14
+ isReactElement?: (element: any) => boolean
15
+ handleReactElement?: (widget: Widget, element: any) => void
16
+ handleReactHeaderElement?: (column: { grid: any; id: string }, headerElement: HTMLElement, html: any) => void
17
+ }
18
+ }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Development warning. Showed when environment is set to 'development'
24
+ * @param {String} clsName react component instance
25
+ * @param {String} msg console message
26
+ */
27
+ function devWarning(clsName: string, msg: string): void {
28
+ // @ts-ignore
29
+ if (window.bryntum?.isTestEnv || process.env.NODE_ENV === 'development') {
30
+ console.warn(
31
+ `Bryntum${clsName}Component development warning!\n${msg}\n` +
32
+ 'Please check React integration guide: https://bryntum.com/products/scheduler/docs/guide/Scheduler/integration/react/guide'
33
+ );
34
+ }
35
+ }
36
+
37
+ function devWarningContainer(clsName: string, containerParam: string): void {
38
+ devWarning(
39
+ clsName,
40
+ `Using "${containerParam}" parameter for configuration is not recommended.\n` +
41
+ "Widget is placed automatically inside it's container element.\n" +
42
+ `Solution: remove "${containerParam}" parameter from configuration.`
43
+ );
44
+ }
45
+
46
+ function devWarningConfigProp(clsName: string, prop: string): void {
47
+ devWarning(
48
+ clsName,
49
+ `Using "${prop}" parameter for configuration is not recommended.\n` +
50
+ `Solution: Use separate parameter for each "${prop}" value to enable reactive updates of the API instance`
51
+ );
52
+ }
53
+
54
+ function devWarningProjectProp(clsName: string): void {
55
+ devWarning(
56
+ clsName,
57
+ 'Using the "project" prop with inner store configurations is not recommended.\n' +
58
+ `Solution: Use an instance of the 'ProjectModel' class or the '<Bryntum${clsName}ProjectModel/>' component`
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Returns `true` if the provided element is an instance of React Element.
64
+ * All React elements require an additional $$typeof: Symbol.for('react.element') field declared on the object for security reasons.
65
+ * The object, which React.createElement() return has $$typeof property equals to Symbol.for('react.element')
66
+ *
67
+ * Sources:
68
+ * https://reactjs.org/blog/2015/12/18/react-components-elements-and-instances.html
69
+ * https://github.com/facebook/react/pull/4832
70
+ *
71
+ * @param {*} element
72
+ * @returns {Boolean}
73
+ * @internal
74
+ */
75
+ function isReactElement(element: any): boolean {
76
+ return Boolean(element?.$$typeof === Symbol.for('react.element') || element?.$$typeof === Symbol.for('react.transitional.element'));
77
+ }
78
+
79
+ /**
80
+ * Creates bryntum component config from react component
81
+ * @param {Object} reactInstance react component instance
82
+ * @returns {Object} config object
83
+ */
84
+ function createConfig(reactInstance: any): object {
85
+ const
86
+ { element, props, constructor } = reactInstance,
87
+ { instanceClass, instanceName, isView } = constructor,
88
+ filter = (arr: any[]) => arr.filter(prop => props[prop] !== undefined),
89
+ configNames = filter(constructor.configNames || []),
90
+ propertyConfigNames = filter(constructor.propertyConfigNames || []),
91
+ propertyNames = filter(constructor.propertyNames || []),
92
+ featureNames = filter(constructor.featureNames || []),
93
+ bryntumConfig = {
94
+ adopt : undefined,
95
+ appendTo : undefined,
96
+ href : undefined,
97
+ reactComponent : reactInstance,
98
+ listeners : {},
99
+ features : {},
100
+ hasFrameworkRenderer : isView ? hasFrameworkRenderer : undefined,
101
+ processCellContent : isView ? processCellContent : undefined,
102
+ processCellEditor : isView ? processCellEditor : undefined,
103
+ processEventContent : isView ? processEventContent : undefined,
104
+ processTaskItemContent : isView ? processTaskItemContent : undefined,
105
+ processResourceHeader : isView ? processResourceHeader : undefined
106
+ } as any;
107
+
108
+ // Data store configs support reactive behavior
109
+ const isDataStoreConfig = (prop: any) => {
110
+ if (reactInstance.dataStores) {
111
+ const dataStoreNames = Object.values(reactInstance.dataStores);
112
+ return dataStoreNames.includes(prop) || dataStoreNames.includes(`${prop}Data`);
113
+ }
114
+ };
115
+
116
+ // Assign configs. Skip properties
117
+ configNames
118
+ .concat(propertyConfigNames)
119
+ .concat(featureNames)
120
+ .forEach(prop => {
121
+ applyPropValue(bryntumConfig, prop, props[prop]);
122
+ if (['features', 'config'].includes(prop) && !isDataStoreConfig(prop)) {
123
+ devWarningConfigProp(instanceClass.$name, prop);
124
+ }
125
+
126
+ // Warn when using project prop with inner stores configs
127
+ if (prop === 'project' && reactInstance.projectStores) {
128
+ // @ts-ignore
129
+ if (Object.values(reactInstance.dataStores).some(store => props[prop][store])) {
130
+ devWarningProjectProp(instanceClass.$name);
131
+ }
132
+ }
133
+ });
134
+
135
+ // Prepare watch arrays
136
+ reactInstance.configNames = configNames;
137
+ reactInstance.propertyNames = configNames
138
+ .concat(propertyNames)
139
+ .concat(propertyConfigNames)
140
+ .concat(featureNames);
141
+
142
+ // Handle inline data for stores
143
+ if (reactInstance.dataStores) {
144
+ Object.values<string>(reactInstance.dataStores).forEach((dataName: string) => {
145
+ if (props[dataName]) {
146
+ bryntumConfig[dataName] = props[dataName];
147
+ }
148
+ });
149
+ }
150
+
151
+ // Cleanup unused instance arrays
152
+ if (reactInstance.propertyConfigNames) {
153
+ delete reactInstance.propertyConfigNames;
154
+ }
155
+ if (reactInstance.featureNames) {
156
+ delete reactInstance.featureNames;
157
+ }
158
+
159
+ // If component has no container specified in config then use adopt to Wrapper's element
160
+ const containerParam = ['adopt', 'appendTo', 'insertAfter', 'insertBefore'].find(
161
+ prop => bryntumConfig[prop]
162
+ );
163
+ if (!containerParam) {
164
+ if (instanceName === 'Button') {
165
+ // Button should always be <a> or <button> inside owner element
166
+ bryntumConfig.appendTo = element;
167
+ }
168
+ else {
169
+ bryntumConfig.adopt = element;
170
+ }
171
+ }
172
+ else {
173
+ devWarningContainer(instanceClass.$name, containerParam);
174
+ }
175
+
176
+ return bryntumConfig;
177
+ }
178
+
179
+ /**
180
+ * Applies property value to Bryntum config or instance.
181
+ * @param {Object} configOrInstance target object
182
+ * @param {String} prop property name
183
+ * @param {Object} value value
184
+ * @param {Boolean} isConfig config setting mode
185
+ */
186
+ function applyPropValue(configOrInstance: any, prop: string, value: any, isConfig = true): void {
187
+ // Assigning React wrapper component instance
188
+ if (value?.current?.instance) {
189
+ value = value.current.instance;
190
+ }
191
+
192
+ if (prop === 'features' && typeof value === 'object') {
193
+ Object.keys(value).forEach(key =>
194
+ applyPropValue(configOrInstance, `${key}Feature`, value[key], isConfig)
195
+ );
196
+ }
197
+ else if (prop === 'config' && typeof value === 'object') {
198
+ Object.keys(value).forEach(key =>
199
+ applyPropValue(configOrInstance, key, value[key], isConfig)
200
+ );
201
+ }
202
+ else if (prop === 'columns' && !isConfig) {
203
+ configOrInstance.columns = value;
204
+ }
205
+ else if (prop.endsWith('Feature')) {
206
+ const
207
+ { features } = configOrInstance,
208
+ featureName = prop.replace('Feature', '');
209
+ if (isConfig) {
210
+ features[featureName] = value;
211
+ }
212
+ else {
213
+ const feature = features[featureName];
214
+ if (feature) {
215
+ feature.setConfig(value);
216
+ }
217
+ }
218
+ }
219
+ else {
220
+ configOrInstance[prop] = value;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Creates bryntum Widget from react component
226
+ * @param {*} component react component instance
227
+ * @returns {*} widget object
228
+ */
229
+ function createWidget(component: any): any {
230
+ const { instanceClass, isView } = component.constructor,
231
+ config = createConfig(component) as any,
232
+ instance = instanceClass.$name === 'Widget' ? Widget.create(config) : new instanceClass(config);
233
+
234
+ // Backwards compatibility for gridInstance, schedulerInstance etc.
235
+ if (isView) {
236
+ component[StringHelper.uncapitalize(instanceClass.$name) + 'Instance'] = instance;
237
+ }
238
+
239
+ if (isView) {
240
+ // Backwards compatibility for gridInstance, schedulerInstance etc.
241
+ component[StringHelper.uncapitalize(instanceClass.$name) + 'Instance'] = instance;
242
+
243
+ const subscribeStores = (storeInstance: { [x: string]: any }, stores: Record<string, unknown>) => {
244
+ if (stores) {
245
+ Object.keys(stores).forEach(storeName => {
246
+ const store = storeInstance[storeName];
247
+ if (store) {
248
+ // Default syncDataOnLoad to true if store is not configured with a readUrl (AjaxStore)
249
+ // and it doesn't belong to a project that has a loadUrl configured
250
+ if (store.syncDataOnLoad == null && !store.readUrl && !store.lazyLoad && (!store.crudManager || !store.crudManager.loadUrl)) {
251
+ store.syncDataOnLoad = true;
252
+ }
253
+
254
+ store.on('beforeRemove', (context: { records: any; removingAll: any }) => beforeRemoveRecords(component, context));
255
+ }
256
+ });
257
+ }
258
+ };
259
+
260
+ subscribeStores(component.projectStores ? instance.project : instance, component.dataStores);
261
+ }
262
+
263
+ // To be able to detect data changes later
264
+ if (config['data']) {
265
+ instance.lastDataset = config['data'].slice();
266
+ }
267
+
268
+ return instance;
269
+ }
270
+
271
+ /**
272
+ * Calculates the portalId from passed ids
273
+ * @param {String|Number} id
274
+ * @param {String|Number} columnId
275
+ * @returns {String} portalId as `portal-${id}-${columnId}`
276
+ */
277
+ function getPortalId(id: string | number, columnId: string | number): string {
278
+ return `portal-${id}-${columnId}`;
279
+ }
280
+
281
+ /**
282
+ * Delete portal and its container
283
+ * @param {*} component React Component, the wrapper itself
284
+ * @param {String} portalId As returned from getPortalId function
285
+ */
286
+ function deletePortal(component: any, portalId: string): void {
287
+ const portal = component.state.portals.get(portalId);
288
+ if (portal) {
289
+ const portalContainer = portal.containerInfo;
290
+
291
+ // remove portal from Map
292
+ component.state.portals.delete(portalId);
293
+
294
+ // cleanup portal container
295
+ portalContainer.parentElement?.removeChild(portalContainer);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Release (now only delete) React portal hosted in this cell
301
+ * @param {*} component React Component, the wrapper itself
302
+ * @param {HTMLElement} cellElement The grid cell to be freed of the React portal
303
+ */
304
+ function releaseReactCell(component: any, cellElement: any): void {
305
+ const { id, columnId, hasPortal } = cellElement._domData;
306
+
307
+ if (hasPortal) {
308
+ const portalId = getPortalId(id, columnId);
309
+ deletePortal(component, portalId);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Calls releaseReactCell that implements the cleanup
315
+ * @param {*} component React Component, the wrapper itself
316
+ * @param {Object} context
317
+ * @param {Core.data.Model[]} context.records Array of records that are going to be removed
318
+ */
319
+ function beforeRemoveRecords(component: any, { records, removingAll }: { records: any[]; removingAll: boolean }): void {
320
+ const { instance } = component;
321
+
322
+ if (removingAll) {
323
+ [...component.state.portals.keys()].forEach(portalId => deletePortal(component, portalId));
324
+ }
325
+ else {
326
+ records.forEach(record => {
327
+ // grid.getRowById is not defined in Calendar
328
+ const row = instance.getRowById ? instance.getRowById(record.id) : undefined;
329
+ if (row) {
330
+ row.cells.forEach((cell: any) => {
331
+ releaseReactCell(component, cell);
332
+ });
333
+ }
334
+ });
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Increments generation - necessary to trigger React updates
340
+ */
341
+ function updateGeneration(component: any, thisTick = false, callback = () => {}): void {
342
+ const updateState = () => {
343
+ component.setState((currentState: { generation: number; portals: Map<string, ReactElement> }) => {
344
+ return {
345
+ ...currentState,
346
+ generation : currentState.generation + 1
347
+ };
348
+ }, callback);
349
+ };
350
+ if (thisTick) {
351
+ // Update state in this tick
352
+ updateState();
353
+ }
354
+ else {
355
+ // React updates on next frame
356
+ requestAnimationFrame(updateState);
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Component about to be updated, from changing a prop using state.
362
+ * React to it depending on what changed and prevent react from re-rendering our component.
363
+ * @param {*} component react component instance
364
+ * @param nextProps
365
+ * @param nextState
366
+ * @returns {Boolean}
367
+ */
368
+ function shouldComponentUpdate(component: any, nextProps: Readonly<any>, nextState: Readonly<any>): boolean {
369
+ const { props, instance, propertyNames } = component;
370
+
371
+ propertyNames.forEach((prop: string) => {
372
+ if (props[prop] !== nextProps[prop]) {
373
+ // Check if property is not a config
374
+ applyPropValue(instance, prop, nextProps[prop], false);
375
+ }
376
+ });
377
+
378
+ // Reflect JSX cell changes
379
+ return nextState?.generation !== component.state?.generation;
380
+ }
381
+
382
+ /**
383
+ *
384
+ * @param { Object } context
385
+ * @param { * } context.cellContent Content to be rendered in cell (set by renderer)
386
+ * @returns { Boolean } `true` if there is a React Renderer in this cell, `false` otherwise
387
+ */
388
+ function hasFrameworkRenderer({ cellContent }: { cellContent: any }): boolean {
389
+ // @ts-ignore
390
+ return DomHelper.isReactElement(cellContent);
391
+ }
392
+
393
+ /**
394
+ * Hook called by instance when rendering cells within
395
+ * Row::renderCell(), creates portals for JSX supplied by renderers
396
+ * @param {Object} context
397
+ * @param {Object} context.rendererData Data passed from renderCell
398
+ * @param {Object} context.cellElementData Data passed from renderCell
399
+ */
400
+ function processCellContent(this: { reactComponent: any; isExporting: boolean; $isCollapsing: boolean; }, { rendererData, cellElementData, rendererHtml }: {
401
+ rendererData: any
402
+ cellElementData: any
403
+ rendererHtml: any
404
+ }): void {
405
+ // Collect variables
406
+ const
407
+ component = this.reactComponent,
408
+ { state, portalsCache, portalContainerClass } = component,
409
+ { cellElement, column, record } = rendererData,
410
+ portalId = getPortalId(record.id, column.id),
411
+ renderElement = cellElement.querySelector(column.editTargetSelector) || cellElement;
412
+
413
+ // Do nothing if we have no place to render to
414
+ if (!renderElement) {
415
+ return;
416
+ }
417
+ if (
418
+ rendererHtml &&
419
+ // @ts-ignore
420
+ DomHelper.isReactElement(rendererHtml) &&
421
+ !record.meta.specialRow
422
+ ) {
423
+ // Move React portal container out of the way if necessary
424
+ if (
425
+ renderElement.portalContainer &&
426
+ renderElement.portalContainer.dataset.portalId === portalId
427
+ ) {
428
+ portalsCache.appendChild(renderElement.portalContainer);
429
+ renderElement.portalContainer = null;
430
+ }
431
+
432
+ // Try to get portal from the portals Map
433
+ let portal = state.portals.get(portalId),
434
+ forceFlushSync = false;
435
+
436
+ // Handle measuring
437
+ if (rendererData.isMeasuring) {
438
+ if (portal) {
439
+ // Remember the original parent of portal and the cell element width
440
+ const
441
+ portalContainer = portal.containerInfo,
442
+ parent = portalContainer.parentNode;
443
+ cellElement.style.width = 'auto'; // element is re-used, need to reset width
444
+ const cellElementWidth = cellElement.offsetWidth;
445
+
446
+ // Append portal to the provided cell and get width
447
+ cellElement.appendChild(portalContainer);
448
+ const width = portalContainer.offsetWidth;
449
+
450
+ // Move the portal back to its original container
451
+ parent.appendChild(portalContainer);
452
+
453
+ // Set width of the cell. It will be processed by Column code.
454
+ cellElement.style.width = `${width + cellElementWidth}px`;
455
+ }
456
+ return;
457
+ }
458
+
459
+ // Check if record changed, delete portal and its container if yes
460
+ if (portal && (portal.generation !== record.generation || this.isExporting || this.$isCollapsing)) {
461
+ deletePortal(component, portalId);
462
+ portal = null;
463
+ forceFlushSync = true;
464
+ }
465
+
466
+ // Cleanup renderElement - necessary for grouping feature
467
+ const childPortalContainer = renderElement.querySelector(`.${portalContainerClass}`);
468
+ if (childPortalContainer && childPortalContainer.dataset.portalId !== portalId) {
469
+ portalsCache.appendChild(childPortalContainer);
470
+ }
471
+ if (renderElement.textContent && renderElement === cellElement) {
472
+ renderElement.textContent = ''; // group title can be still here
473
+ }
474
+
475
+ if (portal) {
476
+ // Move portal container back to the cell if we have one
477
+ renderElement.appendChild(portal.containerInfo);
478
+ renderElement.portalContainer = portal.containerInfo;
479
+ }
480
+ else {
481
+ // Create new portal container
482
+ const portalContainer = DomHelper.append(renderElement, {
483
+ tag : 'div',
484
+ className : portalContainerClass,
485
+ dataset : { portalId } // for reference in tests
486
+ });
487
+ renderElement.portalContainer = portalContainer;
488
+
489
+ // Create a new portal in the portal container
490
+ portal = ReactDOM.createPortal(rendererHtml, portalContainer, portalId);
491
+
492
+ // Add the new portal to Map
493
+ state.portals.set(portalId, portal);
494
+
495
+ // Trigger React redraw
496
+ // Update cell synchronously when exporting or when forced to
497
+ if (forceFlushSync) {
498
+ flushSync(() => updateGeneration(component, true));
499
+ forceFlushSync = false;
500
+ }
501
+ else {
502
+ updateGeneration(component, this.isExporting);
503
+ }
504
+ }
505
+
506
+ // Save data for use elsewhere
507
+ cellElementData.hasPortal = true;
508
+ portal.generation = record.generation;
509
+ }
510
+ else if (!rendererHtml && cellElementData.hasPortal) {
511
+ cellElement.portalContainer.remove();
512
+ cellElementData.hasPortal = false;
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Hook called by engine when requesting a cell editor
518
+ */
519
+ function processCellEditor({ editor, field }: { editor: any; field: string }): boolean | object | undefined {
520
+
521
+ // @ts-ignore
522
+ const component = this.reactComponent;
523
+
524
+ // String etc. handled by feature, only care about fns returning React components here
525
+ if (!component || typeof editor !== 'function') {
526
+ return;
527
+ }
528
+
529
+ // Wrap React editor in an empty widget, to match expectations from CellEdit/Editor and make alignment
530
+ // etc. work out of the box
531
+ const wrapperWidget = new Widget({
532
+ // For editor to be hooked up to field correctly,
533
+ // @ts-ignore
534
+ name : field,
535
+ // Prevent editor from swallowing mouse events
536
+ allowMouseEvents : true,
537
+ // Used by container, pass on to overridden setter
538
+ assignValue(values: any, key: any, value: any) {
539
+ // @ts-ignore
540
+ this.setValue(value);
541
+ }
542
+ }) as any;
543
+
544
+ // Ref for accessing the React editor later
545
+ const widgetRef = React.createRef();
546
+ wrapperWidget['reactRef'] = widgetRef;
547
+
548
+
549
+
550
+ // @ts-ignore
551
+ const editorComponent = editor(widgetRef, this);
552
+
553
+ // @ts-ignore
554
+ if (!DomHelper.isReactElement(editorComponent)) {
555
+ throw new Error('Expect a React element');
556
+ }
557
+
558
+ let editorValidityChecked = false;
559
+
560
+ wrapperWidget.setValue = async(value: any) => {
561
+ const widget: any = widgetRef.current;
562
+
563
+ // It may happen that set is called before the widget is created
564
+ if (widget) {
565
+ if (!editorValidityChecked) {
566
+ const
567
+ cellMethods = ['setValue', 'getValue', 'isValid', 'focus'],
568
+ misses = cellMethods.filter(fn => !(fn in widget));
569
+
570
+ if (misses.length > 0) {
571
+ throw new Error(
572
+ `Missing method(s) ${misses.join(', ')} in ${
573
+ widget.constructor.name
574
+ }. Cell editors must ${cellMethods.join(', ')}`
575
+ );
576
+ }
577
+ editorValidityChecked = true;
578
+ }
579
+
580
+ const context = wrapperWidget.owner['cellEditorContext'];
581
+ await widget.setValue(value, context);
582
+ }
583
+
584
+ else {
585
+ wrapperWidget.firstValue = value;
586
+ }
587
+ };
588
+
589
+ // Add getter/setter for value on the wrapper, relaying to getValue()/setValue() on the React editor
590
+ Object.defineProperty(wrapperWidget, 'value', {
591
+ enumerable : true,
592
+
593
+ configurable : true,
594
+
595
+ get() {
596
+ const widget: any = widgetRef.current;
597
+ return widget?.getValue();
598
+ }
599
+ });
600
+
601
+ // Add getter for isValid to the wrapper, mapping to isValid() on the React editor
602
+ Object.defineProperty(wrapperWidget, 'isValid', {
603
+ enumerable : true,
604
+
605
+ configurable : true,
606
+
607
+ get() {
608
+ const widget: any = widgetRef.current;
609
+ return widget?.isValid();
610
+ }
611
+ });
612
+
613
+ // Override widgets focus handling, relaying it to focus() on the React editor
614
+ wrapperWidget.focus = () => {
615
+ const widget: any = widgetRef.current;
616
+ if (!widget) {
617
+ wrapperWidget.focusPending = true;
618
+ }
619
+ else {
620
+ widget.focus?.();
621
+ }
622
+ };
623
+
624
+ // Create a portal, making the React editor belong to the React tree although displayed in a Widget
625
+ const portal = ReactDOM.createPortal(editorComponent, wrapperWidget.element);
626
+
627
+ wrapperWidget['reactPortal'] = portal;
628
+
629
+ const { state } = component;
630
+ // Store portal in state to let React keep track of it (inserted into the Bryntum component)
631
+ state.portals.set(`portal-${field}`, portal);
632
+ updateGeneration(component, true,
633
+
634
+ () => {
635
+ if (wrapperWidget.firstValue !== undefined) {
636
+ wrapperWidget.setValue(wrapperWidget.firstValue);
637
+ delete wrapperWidget.firstValue;
638
+ }
639
+ if (wrapperWidget.focusPending) {
640
+ wrapperWidget.focus();
641
+ delete wrapperWidget.focusPending;
642
+ }
643
+ }
644
+ );
645
+
646
+ return { editor : wrapperWidget };
647
+ }
648
+
649
+ // Creates portal in the widget contentElement and triggers its rendering.
650
+ // Called from Widget.updateHtml
651
+ function processWidgetContent({ reactElement, widget, reactComponent, contentElement }:
652
+ { reactElement: any; widget: any; reactComponent: any; contentElement: any }): React.ReactPortal | undefined {
653
+ const
654
+ portals = reactComponent?.state?.portals,
655
+ portalId = widget?.id;
656
+
657
+ if (isReactElement(reactElement)) {
658
+ // Ensure the contentElement is cleared of any previously rendered plain HTML before rendering a React portal.
659
+ // This prevents mixing old HTML content with new React-rendered content, ensuring only the JSX is displayed.
660
+ if (!portals.has(portalId) && widget.html) {
661
+ widget.contentElement.innerHTML = '';
662
+ }
663
+ const portal = ReactDOM.createPortal(reactElement, contentElement || widget.contentElement);
664
+
665
+ portals.set(portalId, portal);
666
+
667
+ // In Tooltip.js, we hide the tooltip if there’s no HTML content. However, when using JSX for eventResize
668
+ // and eventDrag, the portal content isn’t in the DOM yet when that “empty” check runs, causing the tooltip
669
+ // to be hidden prematurely. To prevent this, we now update the tooltip content synchronously.
670
+ // Note: flushSync is only called once when the tooltip instance is first created.
671
+ // Fixes https://github.com/bryntum/support/issues/10462
672
+ if (widget.isTooltip && !widget.html) {
673
+ flushSync(() => updateGeneration(reactComponent, true));
674
+ }
675
+ else {
676
+ updateGeneration(reactComponent, true);
677
+ }
678
+
679
+ return portal;
680
+ }
681
+ else if (portals?.has(portalId)) {
682
+ // If we replace the content with plain HTML (not via React), React is unaware and its virtual DOM may become out of sync
683
+ // with the real DOM. Later, when rendering a new portal into the same node, React may fail to properly re-attach or re-render,
684
+ // especially if the previous portal wasn't unmounted or the DOM node was mutated outside React's control.
685
+ // Therefore, before setting plain HTML, we delete the portal and update the DOM synchronously.
686
+ // See: https://github.com/bryntum/support/issues/9822
687
+ portals.delete(portalId);
688
+ flushSync(() => updateGeneration(reactComponent, true));
689
+ }
690
+ }
691
+
692
+ function processTaskItemContent({
693
+ jsx,
694
+ targetElement,
695
+ reactComponent,
696
+ domConfig
697
+ }: {
698
+ jsx: ReactElement
699
+ targetElement: HTMLElement
700
+ reactComponent: React.Component
701
+ domConfig: { [key: string]: any }
702
+ }): void {
703
+ if (!reactComponent || !jsx) {
704
+ return;
705
+ }
706
+ const
707
+ { state } = reactComponent,
708
+ // @ts-expect-error
709
+ { portals } = state,
710
+ cardElement = targetElement.closest('.b-task-board-card') as any,
711
+ portalId = `task-item-${domConfig.reference}-${cardElement?.elementData.taskId}`,
712
+ portal = ReactDOM.createPortal(jsx, targetElement, portalId);
713
+
714
+ portals.set(portalId, portal);
715
+
716
+ updateGeneration(reactComponent);
717
+ }
718
+
719
+ /**
720
+ * This method is called from processEventContent callback to
721
+ * handle JSX of Calendar events
722
+ */
723
+ function processCalendarEventContent(this: any, {
724
+ jsx, // React element to render in portal
725
+ action, // Rendering action
726
+ domConfig, // domConfig passed by DomSync callback
727
+ targetElement, // DOM element to create portal in
728
+ reactComponent // the React wrapper component
729
+ }: {
730
+ jsx: ReactElement
731
+ action: string
732
+ domConfig: any
733
+ targetElement: HTMLElement
734
+ reactComponent: React.Component & { syncContent: (fn: () => unknown) => void }
735
+
736
+ }): boolean {
737
+ const
738
+ me = this,
739
+ returnValue = false,
740
+ { state } = reactComponent,
741
+
742
+ // @ts-expect-error
743
+ { portals } = state,
744
+
745
+ wrap = targetElement.closest('.b-cal-event-wrap') as HTMLElement,
746
+ dataHolder = targetElement.closest('[data-date]') as HTMLElement,
747
+ refHolder = targetElement.closest('[data-ref]') as HTMLElement,
748
+ mode = typeof this.mode === 'string' ? this.mode : this.mode?.type?.slice(0, 4),
749
+ lastPortalId = wrap?.dataset.lastPortalId,
750
+ portalKey = domConfig.dataset?.date || (dataHolder)?.dataset.date,
751
+ containerRef = refHolder?.dataset?.ref;
752
+
753
+ reactComponent.syncContent = reactComponent.syncContent || function(fn: () => unknown) {
754
+ flushSync(fn);
755
+ };
756
+
757
+ let portalId = `portal-${mode}-${wrap?.dataset.eventId}`;
758
+
759
+ me.portalKey = portalKey === undefined ? me.portalKey : portalKey;
760
+ me.containerRef = containerRef === undefined ? me.containerRef : containerRef;
761
+
762
+ if (action === 'none' || action === 'reuseElement') {
763
+ return returnValue;
764
+ }
765
+
766
+ if (action === 'removeElement' && lastPortalId) {
767
+ portals.delete(lastPortalId);
768
+ updateGeneration(reactComponent);
769
+ }
770
+
771
+ if (jsx && wrap) {
772
+ portalId = `${portalId}-${me.portalKey}-${me.containerRef}`;
773
+
774
+ const parent = wrap.querySelector('.b-cal-event-desc') as HTMLElement;
775
+ parent!.innerHTML = '';
776
+
777
+ const
778
+ jsxContainer = DomHelper.createElement({
779
+ className : 'b-jsx-container',
780
+ parent,
781
+ retainElement : true
782
+ }) as HTMLElement,
783
+ portal = ReactDOM.createPortal(jsx, jsxContainer);
784
+
785
+ portals.set(portalId, portal);
786
+
787
+ reactComponent.syncContent(() => {
788
+ updateGeneration(reactComponent, true);
789
+ });
790
+
791
+ wrap.dataset.lastPortalId = portalId;
792
+ }
793
+ return returnValue;
794
+ }
795
+
796
+ /**
797
+ * Generate a unique portal ID for an event.
798
+ * @param {Object} assignmentRecord The assignment record
799
+ * @param {Object} [eventRecord] The event record
800
+ * @param {string} [segment] Segment identifier for split events
801
+ * @param {boolean} [isExporting] To check if exporting is in progress
802
+ * @returns {string} The generated portal ID
803
+ */
804
+ function generateEventPortalId(assignmentRecord: any, eventRecord?: any, segment?: string, isExporting?: boolean): string {
805
+ const isRecurring = eventRecord?.isRecurring || eventRecord?.isOccurrence;
806
+ return `assignment-${assignmentRecord?.id}${isRecurring ? '-' + (eventRecord.recurrence?.id || '') : ''}${segment ? '-' + segment : ''}${isExporting ? '-export' : ''}`;
807
+ }
808
+
809
+ /**
810
+ * Delete portals for all nested event children for a given parent event. When a parent event is released,
811
+ * DomSync only fires callback for it and not for children. The nested children are removed as part of
812
+ * removing the parent, so their callbacks don't fire individually. We must explicitly clean up their portals here.
813
+ * @param {*} component React Component, the wrapper itself
814
+ * @param {Object} parentEventRecord The parent event record
815
+ * @param {Object} resourceRecord The resource record (needed for assignment lookup)
816
+ * @param {boolean} isExporting Whether we're in export mode
817
+ */
818
+ function deleteNestedEventPortals(component: any, parentEventRecord: any, resourceRecord: any, isExporting: boolean): void {
819
+ const children = parentEventRecord?.children;
820
+
821
+ if (!children?.length) {
822
+ return;
823
+ }
824
+
825
+ const { portals } = component.state;
826
+
827
+ for (const childEvent of children) {
828
+ // Find the assignment for this child event and resource
829
+ const assignmentRecord = childEvent.assignments?.find((a: any) => a.resourceId === resourceRecord?.id);
830
+
831
+ if (assignmentRecord) {
832
+ const portalId = generateEventPortalId(assignmentRecord, null, '', isExporting);
833
+ if (portals.has(portalId)) {
834
+ deletePortal(component, portalId);
835
+ }
836
+ }
837
+
838
+ // Recursively handle nested children because nesting depth can be more than 1
839
+ if (childEvent.isParent && childEvent.children?.length) {
840
+ deleteNestedEventPortals(component, childEvent, resourceRecord, isExporting);
841
+ }
842
+ }
843
+ }
844
+
845
+ function processEventContent(this: any, {
846
+ jsx, // React element to render in portal
847
+ action, // Rendering action
848
+ targetElement, // DOM element to create portal in
849
+ isRelease, // true if releasing element
850
+ reactComponent, // the React wrapper component
851
+ scrolling,
852
+ domConfig
853
+ }: {
854
+ jsx: ReactElement
855
+ action: string
856
+ targetElement: HTMLElement
857
+ isRelease: boolean
858
+ reactComponent: React.Component & { syncContent: (fn: () => unknown) => void }
859
+ scrolling: boolean
860
+ domConfig: any
861
+ }): boolean {
862
+
863
+ if (this.type === 'calendar' && this.mode !== 'timeline' && this.mode?.type !== 'scheduler') {
864
+
865
+ return processCalendarEventContent.call(this, arguments[0]);
866
+ }
867
+
868
+ const { eventResize, eventDrag, eventEdit, nestedEvents } = this.features;
869
+
870
+
871
+
872
+ // @ts-ignore
873
+ if (!reactComponent || action === 'none' || (eventResize?.isResizing && !eventResize.dragging.completed && !scrolling)) {
874
+ return false;
875
+ }
876
+
877
+ // Vertical nests renderData, while horizontal does not
878
+ const domConfigData = this.isVertical ? domConfig?.elementData?.renderData : domConfig?.elementData;
879
+
880
+ let wrap: any = targetElement,
881
+ parent: any = null,
882
+ // Non-empty string only for split event segments
883
+ segment = '',
884
+ // True signals the caller to finish further processing of this event
885
+ returnValue = false;
886
+
887
+ // When passed jsx, we are event content
888
+ if (jsx) {
889
+ // Milestone has a dedicated label element used for padding
890
+ // We are looking here for `.b-sch-event-wrap`. This method is faster than `.closest(...)`
891
+ if (domConfig?.dataset?.isMilestone) {
892
+ wrap = targetElement.parentElement!.parentElement!.parentElement!;
893
+ parent = targetElement.parentElement!;
894
+ }
895
+ else {
896
+ wrap = targetElement.parentElement!.parentElement!;
897
+ parent = targetElement;
898
+ }
899
+
900
+ // If we are processing a split event (an event with segments) we don't have the proper wrap so find it
901
+ if (!wrap.elementData) {
902
+ wrap = wrap.closest('.b-sch-event-wrap');
903
+
904
+ // Set the segment id for portalId calculation
905
+ segment = parent.parentElement.dataset?.segment || '';
906
+ }
907
+ }
908
+
909
+ // When not passed jsx, we only care about continuing if we are the wrap, and not a ResourceTimeRange
910
+ else if (!domConfigData?.isWrap || domConfigData.eventRecord.isResourceTimeRange) {
911
+ return returnValue;
912
+ }
913
+
914
+ const
915
+ // Vertical nests renderData, while horizontal does not
916
+ wrapData = this.isVertical ? wrap.elementData.renderData : wrap.elementData,
917
+ // Use domConfigData when available to avoid stale data from reused elements. When elements are reused across
918
+ // recurring/non-recurring events, wrap.elementData may contain data from a different event type, causing
919
+ // incorrect isRecurring detection and wrong portalId calculation. This caused non-recurring events to
920
+ // render without JSX when scrolling back (when both recurring and non-recurring events are present).
921
+ // Fixes: https://github.com/bryntum/support/issues/10595
922
+ currentData = domConfigData ?? wrapData,
923
+ {
924
+ assignmentRecord,
925
+ eventRecord,
926
+ resourceRecord
927
+ } = currentData,
928
+ // Store portals in state to let React keep track of them
929
+ { state } = reactComponent,
930
+ // @ts-ignore
931
+ { portals } = state,
932
+ // Recurring events are handled a bit differently so get the flag
933
+ isRecurring = eventRecord.isRecurring || eventRecord.isOccurrence,
934
+ isExporting = this.isExporting,
935
+ // Portals are used for cells etc too, use a unique id for the map
936
+ portalId = generateEventPortalId(assignmentRecord, eventRecord, segment, isExporting);
937
+
938
+ // When a parent event is reused, its nested children DOM elements were removed (due to releaseThreshold: 0),
939
+ // but DomSync won't re-sync them because lastDomConfig appears unchanged. Clear it to force re-sync.
940
+ if (nestedEvents?.enabled && action === 'reuseOwnElement' && eventRecord.isParent) {
941
+ const eventElement = targetElement.firstElementChild;
942
+ if (eventElement) {
943
+ // @ts-ignore - lastDomConfig is a DomSync internal property
944
+ eventElement.lastDomConfig = null;
945
+ }
946
+ }
947
+
948
+ // Make this function available for testing. It should only be called on event drop.
949
+ reactComponent.syncContent = reactComponent.syncContent || function(fn: () => unknown) {
950
+ flushSync(fn);
951
+ };
952
+
953
+ // Don't delete portals of recurring events
954
+ if (isRelease && !isRecurring) {
955
+ let generationUpdateFlag = false;
956
+
957
+ if (portals.has(portalId)) {
958
+ deletePortal(reactComponent, portalId);
959
+ generationUpdateFlag = true;
960
+ }
961
+
962
+ // If nestedEvents feature is enabled and this is a event with nested children, need
963
+ // to clean up their portals. Because DomSync only fires callback for the parent when it's released,
964
+ // nested children are removed as part of removing the parent, so their callbacks don't fire.
965
+ if (nestedEvents?.enabled && eventRecord.children?.length) {
966
+ deleteNestedEventPortals(reactComponent, eventRecord, resourceRecord, isExporting);
967
+ generationUpdateFlag = true;
968
+ }
969
+
970
+ if (generationUpdateFlag) {
971
+ updateGeneration(reactComponent, true);
972
+ }
973
+ }
974
+ else {
975
+ // We also need to handle reusing own element containing a React element.
976
+ // The portal for it has been released and needs to be restored
977
+ jsx = jsx || (action === 'reuseOwnElement' && wrap.lastJSX);
978
+
979
+ if (jsx) {
980
+ parent = parent || wrap.querySelector('.b-sch-event-content');
981
+
982
+ const
983
+ portal = portals.get(portalId),
984
+ updateContent = () => {
985
+ // Clean-up the content element
986
+ parent.innerHTML = '';
987
+ const
988
+ jsxContainer = DomHelper.createElement({
989
+ className : 'b-jsx-container b-event-text-wrap',
990
+ // As stated above, we are at the wrapper, but we should render the JSX inside the content element
991
+ parent,
992
+
993
+ retainElement : true
994
+ }) as Element,
995
+ portal = ReactDOM.createPortal(jsx, jsxContainer);
996
+
997
+ // Store eventGeneration on the portal to easily determine later if it needs to be updated
998
+ // @ts-expect-error
999
+ portal.eventGeneration = wrap.elementData.eventGeneration;
1000
+
1001
+ // Store the React element so that we can reuse it when reusing wrap element
1002
+ wrap.lastJSX = jsx;
1003
+ // Store portal in map in state, so that React can keep track of it
1004
+ portals.set(portalId, portal);
1005
+
1006
+ // When exporting/printing, use "flushSync" to force React to render the portal content synchronously. During
1007
+ // export, the scheduler creates temporary DOM elements attached to document.body, renders events into them
1008
+ // via DomSync, and then captures the HTML via outerHTML. Without "flushSync", React's default async rendering
1009
+ // means the portal content wouldn't be in the DOM when outerHTML is captured, resulting in empty event content
1010
+ // in the printed output. "flushSync" forces React to process the state update and render the portal content
1011
+ // immediately, ensuring the JSX is converted to DOM elements before the HTML capture occurs.
1012
+ // Fixes https://github.com/bryntum/support/issues/12135
1013
+ if (isExporting) {
1014
+ flushSync(() => updateGeneration(reactComponent, true));
1015
+ }
1016
+ else {
1017
+ updateGeneration(reactComponent, true);
1018
+ }
1019
+ },
1020
+
1021
+ isEditing = eventEdit?.isEditing,
1022
+ isDragging = eventDrag?.isDragging;
1023
+
1024
+ // Recurring events need re-creating always:
1025
+ // - https://github.com/bryntum/support/issues/10595
1026
+ // Re-create portal only if the underlying eventRecord changed its generation or if exporting
1027
+ if (!portal || isRecurring || isExporting || portal.eventGeneration !== wrap.elementData.eventGeneration) {
1028
+ if ((scrolling || !isDragging) && !isEditing) {
1029
+ updateContent();
1030
+ }
1031
+ else {
1032
+
1033
+ reactComponent.syncContent(updateContent);
1034
+ }
1035
+ }
1036
+ returnValue = true;
1037
+ }
1038
+ }
1039
+ return returnValue;
1040
+ }
1041
+
1042
+ /**
1043
+ * Called from DomSync callback to handle JSX
1044
+ * returned from ResourceHeader headerRenderer
1045
+ */
1046
+ function processResourceHeader(
1047
+ { jsx, targetElement }:
1048
+ { jsx: ReactElement; targetElement: HTMLElement }): void {
1049
+
1050
+ if (!jsx) {
1051
+ return;
1052
+ }
1053
+
1054
+ const
1055
+ // @ts-ignore
1056
+ { reactComponent } = this,
1057
+ { state } = reactComponent,
1058
+ { portals } = state,
1059
+ portalId = `resource-header-${targetElement.dataset.resourceId}`;
1060
+
1061
+ // Delete portal if we already have one
1062
+ if (portals.has(portalId)) {
1063
+ portals.delete(portalId);
1064
+
1065
+ // Update the generation (force React re-rendering) in this tick
1066
+ updateGeneration(reactComponent, true);
1067
+ }
1068
+
1069
+ portals.set(portalId, ReactDOM.createPortal(jsx, targetElement));
1070
+
1071
+ // Trigger the React portals re-rendering in the next animation frame
1072
+ updateGeneration(reactComponent);
1073
+
1074
+ }
1075
+
1076
+ /**
1077
+ * Handles the content provided by a React component for the widget.
1078
+ * @param {Widget} widget Owner widget
1079
+ * @param {*} element React element
1080
+ */
1081
+ function handleReactElement(widget: Widget, element: any): void {
1082
+ const
1083
+ parent = widget.closest((c: any) => Boolean(c.reactComponent)),
1084
+ // Attempt to find a React component
1085
+
1086
+ reactComponent = (parent as any)?.reactComponent ||
1087
+ (Widget.query((c: any) => Boolean(c.reactComponent?.state)) as any)?.reactComponent;
1088
+
1089
+ reactComponent?.processWidgetContent({
1090
+ reactElement : element,
1091
+ widget,
1092
+ reactComponent
1093
+ });
1094
+ }
1095
+
1096
+ /**
1097
+ * Handles the React header element by processing JSX content within the widget.
1098
+ *
1099
+ * @param {Object} column Object containing grid and id properties
1100
+ * @param {HTMLElement} headerElement The header element to be processed
1101
+ * @param {Any} html JSX content to be processed
1102
+ */
1103
+ function handleReactHeaderElement(column: { grid: any; id: string }, headerElement: HTMLElement, html: any): void {
1104
+ const
1105
+ { reactComponent } = column.grid,
1106
+ contentElement = headerElement.querySelector('.b-grid-header-text-content');
1107
+
1108
+ // Process JSX as if it was in the widget.
1109
+ reactComponent?.processWidgetContent({
1110
+ reactElement : html,
1111
+ widget : { id : column.id },
1112
+ reactComponent,
1113
+ contentElement
1114
+ });
1115
+ }
1116
+
1117
+ export { createWidget, shouldComponentUpdate, processWidgetContent };
1118
+
1119
+ // Expose wrapper methods on window.bryntum
1120
+ window.bryntum = window.bryntum || {};
1121
+ window.bryntum.react = {
1122
+ isReactElement,
1123
+ handleReactHeaderElement,
1124
+ handleReactElement
1125
+ };