@teambit/compositions.ui.composition-compare 0.0.259 → 0.0.261

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.
@@ -1,20 +1,31 @@
1
- import React, { useMemo, useState } from 'react';
1
+ import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react';
2
+ import classNames from 'classnames';
2
3
  import { useComponentCompare } from '@teambit/component.ui.component-compare.context';
3
- import { CompositionContent, CompositionContentProps, EmptyStateSlot } from '@teambit/compositions';
4
- import { CompositionContextProvider } from '@teambit/compositions.ui.hooks.use-composition';
5
4
  import {
6
- useCompareQueryParam,
7
- useUpdatedUrlFromQuery,
8
- } from '@teambit/component.ui.component-compare.hooks.use-component-compare-url';
5
+ CompositionContent,
6
+ LiveControlsDiffPanel,
7
+ useDefaultControlsSchemaResponder,
8
+ type CompositionContentProps,
9
+ type EmptyStateSlot,
10
+ } from '@teambit/compositions';
11
+ import { CompositionContextProvider } from '@teambit/compositions.ui.hooks.use-composition';
12
+ import { useCompareQueryParam } from '@teambit/component.ui.component-compare.hooks.use-component-compare-url';
9
13
  import { CompareSplitLayoutPreset } from '@teambit/component.ui.component-compare.layouts.compare-split-layout-preset';
10
14
  import { RoundLoader } from '@teambit/design.ui.round-loader';
15
+ import { Icon } from '@teambit/evangelist.elements.icon';
16
+ import { useLocation } from '@teambit/base-react.navigation.link';
17
+ import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query';
11
18
  import queryString from 'query-string';
12
19
  import { CompositionDropdown } from './composition-dropdown';
13
20
  import { CompositionCompareContext } from './composition-compare.context';
14
21
  import { uniqBy } from 'lodash';
22
+ import * as semver from 'semver';
15
23
 
16
24
  import styles from './composition-compare.module.scss';
17
25
 
26
+ const noop = () => {};
27
+ const LOCAL_VERSION = 'workspace';
28
+
18
29
  export type CompositionCompareProps = {
19
30
  emptyState?: EmptyStateSlot;
20
31
  Widgets?: {
@@ -25,180 +36,591 @@ export type CompositionCompareProps = {
25
36
  PreviewView?: React.ComponentType<CompositionContentProps>;
26
37
  };
27
38
 
39
+ type ControlsStatus = 'loading' | 'available' | 'empty';
40
+
41
+ function MissingCompositionTemplate({ message, title }: { message?: string; title?: string } = {}) {
42
+ return (
43
+ <div className={styles.subView}>
44
+ <div className={styles.missingComposition}>
45
+ <div className={styles.missingCompositionTitle}>{title || 'Composition not available'}</div>
46
+ {message && <div className={styles.missingCompositionSubtitle}>{message}</div>}
47
+ </div>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ function MissingComposition({ compositionId, version }: { compositionId?: string; version: string }) {
53
+ const message = compositionId
54
+ ? `The selected composition "${compositionId}" does not exist for the ${version} version.`
55
+ : `The selected composition does not exist for the ${version} version.`;
56
+ return <MissingCompositionTemplate message={message} />;
57
+ }
58
+
59
+ function getCompositionTag(hasInBase: boolean, hasInCompare: boolean): string | undefined {
60
+ if (hasInBase && hasInCompare) return undefined;
61
+ if (hasInBase) return 'Base only';
62
+ if (hasInCompare) return 'Compare only';
63
+ return undefined;
64
+ }
65
+
66
+ function useResizePanel(initialHeight: number) {
67
+ const MIN_PANEL_HEIGHT = 80;
68
+ const MIN_COMPARE_HEIGHT = 150;
69
+ const DEFAULT_PANEL_RATIO = 0.34;
70
+ const [panelHeight, setPanelHeight] = useState(initialHeight);
71
+ const [isResizing, setIsResizing] = useState(false);
72
+ const panelRef = useRef<HTMLDivElement>(null);
73
+ const isDragging = useRef(false);
74
+ const panelHeightRef = useRef(initialHeight);
75
+ const removeListenersRef = useRef<(() => void) | null>(null);
76
+ const rafRef = useRef<number | null>(null);
77
+ const initializedRef = useRef(false);
78
+ const userResizedRef = useRef(false);
79
+
80
+ const getMaxPanelHeight = useCallback(() => {
81
+ const containerHeight = panelRef.current?.parentElement?.clientHeight;
82
+ if (!containerHeight) return Math.max(MIN_PANEL_HEIGHT, initialHeight);
83
+ return Math.max(MIN_PANEL_HEIGHT, containerHeight - MIN_COMPARE_HEIGHT);
84
+ }, [MIN_COMPARE_HEIGHT, MIN_PANEL_HEIGHT, initialHeight]);
85
+
86
+ const getDefaultPanelHeight = useCallback(() => {
87
+ const containerHeight = panelRef.current?.parentElement?.clientHeight;
88
+ if (!containerHeight || containerHeight <= 0) return initialHeight;
89
+ return Math.max(initialHeight, Math.round(containerHeight * DEFAULT_PANEL_RATIO));
90
+ }, [initialHeight, DEFAULT_PANEL_RATIO]);
91
+
92
+ const clampPanelHeight = useCallback(
93
+ (height: number) => {
94
+ const maxHeight = getMaxPanelHeight();
95
+ return Math.max(MIN_PANEL_HEIGHT, Math.min(maxHeight, height));
96
+ },
97
+ [MIN_PANEL_HEIGHT, getMaxPanelHeight]
98
+ );
99
+
100
+ const applyPanelHeight = useCallback(
101
+ (height: number, commitToState = false) => {
102
+ const clamped = clampPanelHeight(height);
103
+ panelHeightRef.current = clamped;
104
+ if (panelRef.current) panelRef.current.style.height = `${clamped}px`;
105
+ if (commitToState) setPanelHeight(clamped);
106
+ return clamped;
107
+ },
108
+ [clampPanelHeight]
109
+ );
110
+
111
+ const syncPanelHeight = useCallback(() => {
112
+ if (isDragging.current) return;
113
+ const nextHeight = userResizedRef.current ? panelHeightRef.current : getDefaultPanelHeight();
114
+ applyPanelHeight(nextHeight, true);
115
+ }, [applyPanelHeight, getDefaultPanelHeight]);
116
+
117
+ useEffect(() => {
118
+ if (initializedRef.current) return;
119
+ initializedRef.current = true;
120
+ syncPanelHeight();
121
+ }, [syncPanelHeight]);
122
+
123
+ useEffect(() => {
124
+ const handleWindowResize = () => {
125
+ syncPanelHeight();
126
+ };
127
+
128
+ handleWindowResize();
129
+ window.addEventListener('resize', handleWindowResize);
130
+ return () => window.removeEventListener('resize', handleWindowResize);
131
+ }, [syncPanelHeight]);
132
+
133
+ useEffect(() => {
134
+ if (typeof ResizeObserver === 'undefined') return;
135
+ const parent = panelRef.current?.parentElement;
136
+ if (!parent) return;
137
+
138
+ const observer = new ResizeObserver(() => {
139
+ syncPanelHeight();
140
+ });
141
+ observer.observe(parent);
142
+
143
+ return () => observer.disconnect();
144
+ }, [syncPanelHeight]);
145
+
146
+ const handleResizeStart = useCallback(
147
+ (e: React.MouseEvent) => {
148
+ e.preventDefault();
149
+ e.stopPropagation();
150
+ isDragging.current = true;
151
+ setIsResizing(true);
152
+
153
+ const startY = e.clientY;
154
+ const startHeight = panelHeightRef.current;
155
+
156
+ const handleMouseMove = (moveEvent: MouseEvent) => {
157
+ if (!isDragging.current) return;
158
+ moveEvent.preventDefault();
159
+ const delta = startY - moveEvent.clientY;
160
+ const targetHeight = startHeight + delta;
161
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
162
+ rafRef.current = requestAnimationFrame(() => {
163
+ applyPanelHeight(targetHeight);
164
+ rafRef.current = null;
165
+ });
166
+ };
167
+
168
+ const handleMouseUp = () => {
169
+ if (rafRef.current !== null) {
170
+ cancelAnimationFrame(rafRef.current);
171
+ rafRef.current = null;
172
+ }
173
+ userResizedRef.current = true;
174
+ isDragging.current = false;
175
+ applyPanelHeight(panelHeightRef.current, true);
176
+ setIsResizing(false);
177
+ window.removeEventListener('mousemove', handleMouseMove);
178
+ window.removeEventListener('mouseup', handleMouseUp);
179
+ window.removeEventListener('blur', handleMouseUp);
180
+ document.body.style.cursor = '';
181
+ document.body.style.userSelect = '';
182
+ };
183
+
184
+ removeListenersRef.current?.();
185
+ document.body.style.cursor = 'ns-resize';
186
+ document.body.style.userSelect = 'none';
187
+ window.addEventListener('mousemove', handleMouseMove);
188
+ window.addEventListener('mouseup', handleMouseUp);
189
+ window.addEventListener('blur', handleMouseUp);
190
+ removeListenersRef.current = () => {
191
+ window.removeEventListener('mousemove', handleMouseMove);
192
+ window.removeEventListener('mouseup', handleMouseUp);
193
+ window.removeEventListener('blur', handleMouseUp);
194
+ };
195
+ },
196
+ [applyPanelHeight]
197
+ );
198
+
199
+ useEffect(() => {
200
+ return () => {
201
+ isDragging.current = false;
202
+ if (rafRef.current !== null) {
203
+ cancelAnimationFrame(rafRef.current);
204
+ rafRef.current = null;
205
+ }
206
+ removeListenersRef.current?.();
207
+ removeListenersRef.current = null;
208
+ document.body.style.cursor = '';
209
+ document.body.style.userSelect = '';
210
+ };
211
+ }, []);
212
+
213
+ return { panelRef, panelHeight, isResizing, handleResizeStart };
214
+ }
215
+
216
+ function findComposition(compositions: any[] | undefined, id: string | undefined) {
217
+ if (!id || !compositions) return compositions?.[0];
218
+ return compositions.find((c) => c.identifier === id) || undefined;
219
+ }
220
+
221
+ function useCompositionSelection() {
222
+ const selectedCompositionBaseFile = useCompareQueryParam('compositionBaseFile');
223
+ const selectedCompositionCompareFile = useCompareQueryParam('compositionCompareFile');
224
+ return { selectedCompositionBaseFile, selectedCompositionCompareFile };
225
+ }
226
+
227
+ function buildChannelKey(prefix: string, idStr: string | undefined, compId: string | undefined): string | undefined {
228
+ if (!idStr || !compId) return undefined;
229
+ return `${prefix}:${idStr}:${compId}`;
230
+ }
231
+
232
+ function buildQueryParams(channelKey: string | undefined) {
233
+ const params = { livecontrols: true, ...(channelKey ? { lcchannel: channelKey } : {}) };
234
+ return { params, queryString: queryString.stringify(params) };
235
+ }
236
+
237
+ function buildUpdatedUrlFromQuery(
238
+ query: URLSearchParams,
239
+ pathname: string,
240
+ queryParams: {
241
+ compositionBaseFile?: string;
242
+ compositionCompareFile?: string;
243
+ }
244
+ ) {
245
+ const queryObj = Object.fromEntries(query.entries());
246
+ const updatedObj = { ...queryObj, ...queryParams };
247
+ const updatedQueryString = new URLSearchParams(updatedObj).toString();
248
+ return `${pathname}?${updatedQueryString}`;
249
+ }
250
+
251
+ function resolveVersion(model: any): string | undefined {
252
+ const id = model?.id;
253
+ const versionFromField = id?.version?.toString?.();
254
+ const versionFromToString = typeof id?.toString === 'function' ? id.toString().split('@')[1] : undefined;
255
+ return versionFromField || versionFromToString;
256
+ }
257
+
258
+ function hasStableCompareData(contextLoading: boolean | undefined, base: any, compare: any) {
259
+ return !contextLoading && base !== undefined && compare !== undefined;
260
+ }
261
+
262
+ function resolveRequestedCompositionId({
263
+ selectedCompositionCompareFile,
264
+ selectedCompositionBaseFile,
265
+ compareStateId,
266
+ baseStateId,
267
+ compareCompositions,
268
+ baseCompositions,
269
+ }: {
270
+ selectedCompositionCompareFile?: string;
271
+ selectedCompositionBaseFile?: string;
272
+ compareStateId?: string;
273
+ baseStateId?: string;
274
+ compareCompositions?: any[];
275
+ baseCompositions?: any[];
276
+ }) {
277
+ const explicitId = selectedCompositionCompareFile || selectedCompositionBaseFile;
278
+ const stateId = compareStateId || baseStateId;
279
+ const defaultId = compareCompositions?.[0]?.identifier || baseCompositions?.[0]?.identifier;
280
+ return explicitId || stateId || defaultId;
281
+ }
282
+
283
+ function resolveCompositionId(selectedComposition: any, requestedCompositionId?: string) {
284
+ return selectedComposition?.identifier || requestedCompositionId;
285
+ }
286
+
287
+ function hasMissingComposition(requestedCompositionId: string | undefined, selectedComposition: any) {
288
+ return Boolean(!requestedCompositionId || !selectedComposition);
289
+ }
290
+
291
+ function buildControlsResetKey(baseChannelKey: string | undefined, compareChannelKey: string | undefined) {
292
+ return `${baseChannelKey || ''}-${compareChannelKey || ''}`;
293
+ }
294
+
295
+ function formatVersionForLabel(version: string | undefined, opts?: { forceWorkspace?: boolean }) {
296
+ if (opts?.forceWorkspace) return LOCAL_VERSION;
297
+ if (!version) return undefined;
298
+ if (version === LOCAL_VERSION) return LOCAL_VERSION;
299
+ return semver.valid(version) ? version : version.slice(0, 6);
300
+ }
301
+
302
+ function useStableControlLabels(baseModel: any, compareModel: any, compareHasLocalChanges?: boolean) {
303
+ const baseVersion = useMemo(() => formatVersionForLabel(resolveVersion(baseModel)), [baseModel]);
304
+ const compareVersion = useMemo(
305
+ () => formatVersionForLabel(resolveVersion(compareModel), { forceWorkspace: compareHasLocalChanges }),
306
+ [compareModel, compareHasLocalChanges]
307
+ );
308
+ const [stableVersions, setStableVersions] = useState<{
309
+ base?: string;
310
+ compare?: string;
311
+ }>({
312
+ base: baseVersion,
313
+ compare: compareVersion,
314
+ });
315
+
316
+ useEffect(() => {
317
+ setStableVersions((prev) => {
318
+ const nextBase = baseVersion ?? prev.base;
319
+ const nextCompare = compareVersion ?? prev.compare;
320
+ if (nextBase === prev.base && nextCompare === prev.compare) return prev;
321
+ return { base: nextBase, compare: nextCompare };
322
+ });
323
+ }, [baseVersion, compareVersion]);
324
+
325
+ const effectiveBaseVersion = baseVersion ?? stableVersions.base;
326
+ const effectiveCompareVersion = compareVersion ?? stableVersions.compare;
327
+
328
+ return useMemo(
329
+ () => ({
330
+ common: 'Common',
331
+ base: effectiveBaseVersion ? `Base version: ${effectiveBaseVersion}` : 'Base version',
332
+ compare: effectiveCompareVersion ? `Compare version: ${effectiveCompareVersion}` : 'Compare version',
333
+ }),
334
+ [effectiveBaseVersion, effectiveCompareVersion]
335
+ );
336
+ }
337
+
338
+ type CompositionLayoutProps = {
339
+ model: any;
340
+ selected: any;
341
+ queryParams: string;
342
+ compositionParams: Record<string, any>;
343
+ previewViewProps: CompositionContentProps;
344
+ emptyState?: EmptyStateSlot;
345
+ PreviewView: React.ComponentType<CompositionContentProps>;
346
+ contextKey: string;
347
+ isBase?: boolean;
348
+ isCompare?: boolean;
349
+ };
350
+
351
+ function CompositionLayout({
352
+ model,
353
+ selected,
354
+ queryParams,
355
+ compositionParams,
356
+ previewViewProps,
357
+ emptyState,
358
+ PreviewView,
359
+ contextKey,
360
+ isBase,
361
+ isCompare,
362
+ }: CompositionLayoutProps) {
363
+ return (
364
+ <div className={styles.subView}>
365
+ <CompositionCompareContext.Provider
366
+ value={{
367
+ compositionProps: {
368
+ forceHeight: undefined,
369
+ innerBottomPadding: 50,
370
+ ...previewViewProps,
371
+ emptyState,
372
+ component: model,
373
+ queryParams,
374
+ selected,
375
+ },
376
+ isBase,
377
+ isCompare,
378
+ }}
379
+ >
380
+ <CompositionContextProvider queryParams={compositionParams} setQueryParams={noop}>
381
+ <PreviewView
382
+ key={contextKey}
383
+ forceHeight={undefined}
384
+ innerBottomPadding={50}
385
+ {...previewViewProps}
386
+ emptyState={emptyState}
387
+ component={model}
388
+ selected={selected}
389
+ queryParams={queryParams}
390
+ />
391
+ </CompositionContextProvider>
392
+ </CompositionCompareContext.Provider>
393
+ </div>
394
+ );
395
+ }
396
+
28
397
  export function CompositionCompare(props: CompositionCompareProps) {
29
- const { emptyState, PreviewView = CompositionContent, Widgets, previewViewProps = {} } = props;
398
+ const {
399
+ emptyState,
400
+ PreviewView = CompositionContent,
401
+ Widgets,
402
+ previewViewProps = {} as CompositionContentProps,
403
+ } = props;
30
404
 
31
405
  const componentCompareContext = useComponentCompare();
406
+ const query = useQuery();
407
+ const location = useLocation() || { pathname: '/' };
408
+ const { base, compare, baseContext, compareContext, loading: contextLoading } = componentCompareContext || {};
32
409
 
33
- const { base, compare, baseContext, compareContext } = componentCompareContext || {};
410
+ const [isControlsOpen, setControlsOpen] = useState(true);
411
+ const [controlsStatus, setControlsStatus] = useState<ControlsStatus>('loading');
412
+ const [everHadControls, setEverHadControls] = useState(false);
413
+ const { panelRef, panelHeight, isResizing, handleResizeStart } = useResizePanel(240);
34
414
 
415
+ const isStableData = hasStableCompareData(contextLoading, base, compare);
35
416
  const baseCompositions = base?.model.compositions;
36
417
  const compareCompositions = compare?.model.compositions;
37
418
 
38
- const selectedCompositionBaseFile = useCompareQueryParam('compositionBaseFile');
39
- const selectedCompositionCompareFile = useCompareQueryParam('compositionCompareFile');
419
+ const { selectedCompositionBaseFile, selectedCompositionCompareFile } = useCompositionSelection();
40
420
 
41
- const baseState = baseContext?.state?.preview;
42
421
  const compareState = compareContext?.state?.preview;
43
422
  const baseHooks = baseContext?.hooks?.preview;
44
423
  const compareHooks = compareContext?.hooks?.preview;
45
- const selectedBaseFromState = baseState?.id;
46
- const selectedCompareFromState = compareState?.id;
47
-
48
- const selectedBaseCompositionId = selectedBaseFromState || selectedCompositionBaseFile;
49
- const selectedCompositionCompareId = selectedCompareFromState || selectedCompositionCompareFile;
50
-
51
- const selectedBaseComp =
52
- (selectedBaseCompositionId &&
53
- baseCompositions &&
54
- baseCompositions.find((c) => {
55
- return c.identifier === selectedBaseCompositionId;
56
- })) ||
57
- (baseCompositions && baseCompositions[0]);
58
-
59
- const selectedCompareComp =
60
- (selectedCompositionCompareId &&
61
- compareCompositions &&
62
- compareCompositions.find((c) => {
63
- return c.identifier === selectedCompositionCompareId;
64
- })) ||
65
- (compareCompositions && compareCompositions[0]);
66
-
67
- // const baseCompositionDropdownSource =
68
- // baseCompositions?.map((c) => {
69
- // const href = !baseState?.controlled
70
- // ? useUpdatedUrlFromQuery({
71
- // compositionBaseFile: c.identifier,
72
- // compositionCompareFile: selectedCompareComp?.identifier,
73
- // })
74
- // : useUpdatedUrlFromQuery({});
75
-
76
- // const onClick = baseState?.controlled ? baseHooks?.onClick : undefined;
77
-
78
- // return { id: c.identifier, label: c.displayName, href, onClick };
79
- // }) || [];
80
-
81
- // const compareCompositionDropdownSource =
82
- // compareCompositions?.map((c) => {
83
- // const href = !compareState?.controlled
84
- // ? useUpdatedUrlFromQuery({
85
- // compositionBaseFile: selectedBaseComp?.identifier,
86
- // compositionCompareFile: c.identifier,
87
- // })
88
- // : useUpdatedUrlFromQuery({});
89
-
90
- // const onClick = compareState?.controlled ? () => compareHooks?.onClick : undefined;
91
-
92
- // return { id: c.identifier, label: c.displayName, href, onClick };
93
- // }) || [];
94
-
95
- const compositionsDropdownSource = uniqBy((baseCompositions || []).concat(compareCompositions || []), 'identifier')?.map((c) => {
96
- const href = !compareState?.controlled
97
- ? useUpdatedUrlFromQuery({
98
- compositionBaseFile: selectedBaseComp?.identifier,
99
- compositionCompareFile: c.identifier,
100
- })
101
- : useUpdatedUrlFromQuery({});
102
-
103
- const onClick = compareState?.controlled ? (_, __) => {
104
- compareHooks?.onClick?.(_, __);
105
- baseHooks?.onClick?.(_, __);
106
- } : undefined;
107
- return { id: c.identifier, label: c.displayName, href, onClick };
108
- });
109
424
 
110
- const [baseCompositionParams, setBaseCompositionParams] = useState<Record<string, any>>({});
111
- const baseCompQueryParams = useMemo(() => queryString.stringify(baseCompositionParams), [baseCompositionParams]);
425
+ const requestedCompositionId = useMemo(
426
+ () =>
427
+ resolveRequestedCompositionId({
428
+ selectedCompositionCompareFile,
429
+ selectedCompositionBaseFile,
430
+ compareStateId: compareState?.id,
431
+ baseStateId: baseContext?.state?.preview?.id,
432
+ compareCompositions,
433
+ baseCompositions,
434
+ }),
435
+ [
436
+ selectedCompositionCompareFile,
437
+ selectedCompositionBaseFile,
438
+ compareState?.id,
439
+ baseContext?.state?.preview?.id,
440
+ compareCompositions,
441
+ baseCompositions,
442
+ ]
443
+ );
444
+
445
+ const selectedBaseComp = findComposition(baseCompositions, requestedCompositionId);
446
+ const selectedCompareComp = findComposition(compareCompositions, requestedCompositionId);
447
+
448
+ const baseMissingSelectedCompoisition = hasMissingComposition(requestedCompositionId, selectedBaseComp);
449
+ const compareMissingSelectedComposition = hasMissingComposition(requestedCompositionId, selectedCompareComp);
112
450
 
113
- const [compareCompositionParams, setCompareCompositionParams] = useState<Record<string, any>>({});
114
- const compareCompQueryParams = useMemo(
115
- () => queryString.stringify(compareCompositionParams),
116
- [compareCompositionParams]
451
+ const baseCompositionIds = useMemo(
452
+ () => new Set((baseCompositions || []).map((c) => c.identifier)),
453
+ [baseCompositions]
454
+ );
455
+ const compareCompositionIds = useMemo(
456
+ () => new Set((compareCompositions || []).map((c) => c.identifier)),
457
+ [compareCompositions]
117
458
  );
118
459
 
119
- const selectedBaseDropdown = selectedBaseComp && {
120
- id: selectedBaseComp.identifier,
121
- label: selectedBaseComp.displayName,
122
- };
460
+ const compositionsDropdownSource = useMemo(() => {
461
+ return uniqBy((baseCompositions || []).concat(compareCompositions || []), 'identifier')?.map((c) => {
462
+ const hasInBase = baseCompositionIds.has(c.identifier);
463
+ const hasInCompare = compareCompositionIds.has(c.identifier);
464
+ const tag = getCompositionTag(hasInBase, hasInCompare);
465
+ const href = !compareState?.controlled
466
+ ? buildUpdatedUrlFromQuery(query, location.pathname, {
467
+ compositionBaseFile: c.identifier,
468
+ compositionCompareFile: c.identifier,
469
+ })
470
+ : buildUpdatedUrlFromQuery(query, location.pathname, {});
471
+ const onClick = compareState?.controlled
472
+ ? (id, e) => {
473
+ compareHooks?.onClick?.(id, e);
474
+ baseHooks?.onClick?.(id, e);
475
+ }
476
+ : undefined;
477
+ return { id: c.identifier, label: c.displayName, href, onClick, tag };
478
+ });
479
+ }, [
480
+ baseCompositions,
481
+ compareCompositions,
482
+ baseCompositionIds,
483
+ compareCompositionIds,
484
+ compareState?.controlled,
485
+ compareHooks,
486
+ baseHooks,
487
+ query,
488
+ location.pathname,
489
+ ]);
123
490
 
124
- const selectedCompareDropdown = selectedCompareComp && {
125
- id: selectedCompareComp.identifier,
126
- label: selectedCompareComp.displayName,
127
- };
491
+ const selectedCompareDropdown = useMemo(() => {
492
+ const found =
493
+ compositionsDropdownSource.find((item) => item.id === selectedCompareComp?.identifier) ||
494
+ compositionsDropdownSource.find((item) => item.id === selectedBaseComp?.identifier);
495
+ if (found) return found;
496
+ if (requestedCompositionId) return { id: requestedCompositionId, label: requestedCompositionId, tag: 'Missing' };
497
+ return undefined;
498
+ }, [
499
+ compositionsDropdownSource,
500
+ selectedCompareComp?.identifier,
501
+ selectedBaseComp?.identifier,
502
+ requestedCompositionId,
503
+ ]);
504
+
505
+ const baseIdStr = base?.model.id?.toString();
506
+ const compareIdStr = compare?.model.id?.toString();
507
+ useDefaultControlsSchemaResponder(baseIdStr, Boolean(baseIdStr));
508
+ useDefaultControlsSchemaResponder(compareIdStr, Boolean(compareIdStr));
509
+
510
+ const baseCompId = resolveCompositionId(selectedBaseComp, requestedCompositionId);
511
+ const compareCompId = resolveCompositionId(selectedCompareComp, requestedCompositionId);
512
+
513
+ const baseChannelKey = useMemo(() => buildChannelKey('base', baseIdStr, baseCompId), [baseIdStr, baseCompId]);
514
+ const compareChannelKey = useMemo(
515
+ () => buildChannelKey('compare', compareIdStr, compareCompId),
516
+ [compareIdStr, compareCompId]
517
+ );
518
+
519
+ const baseQuery = useMemo(() => buildQueryParams(baseChannelKey), [baseChannelKey]);
520
+ const compareQuery = useMemo(() => buildQueryParams(compareChannelKey), [compareChannelKey]);
521
+
522
+ const controlsResetKey = buildControlsResetKey(baseChannelKey, compareChannelKey);
523
+ const hasControlChannels = Boolean(baseChannelKey && compareChannelKey);
524
+
525
+ useEffect(() => {
526
+ setEverHadControls(false);
527
+ setControlsStatus('loading');
528
+ }, [controlsResetKey]);
529
+
530
+ const handleControlsStatusChange = useCallback((status: ControlsStatus) => {
531
+ setControlsStatus(status);
532
+ if (status === 'available') setEverHadControls(true);
533
+ }, []);
534
+
535
+ const showControlsPanel = controlsStatus === 'available' || everHadControls;
536
+
537
+ const baseModel = base?.model;
538
+ const compareModel = compare?.model;
539
+ const stableLabels = useStableControlLabels(baseModel, compareModel, compare?.hasLocalChanges);
128
540
 
129
541
  const BaseLayout = useMemo(() => {
130
- if (base === undefined) {
131
- return null;
132
- }
133
- const baseCompModel = base.model;
134
- const compositionProps = {
135
- forceHeight: undefined,
136
- innerBottomPadding: 50,
137
- ...previewViewProps,
138
- emptyState,
139
- component: baseCompModel,
140
- queryParams: baseCompQueryParams,
141
- selected: selectedCompareComp,
142
- }
542
+ if (!isStableData) return null;
543
+ if (baseMissingSelectedCompoisition)
544
+ return <MissingComposition compositionId={requestedCompositionId} version="base" />;
143
545
  return (
144
- <div className={styles.subView}>
145
- <CompositionCompareContext.Provider value={{ compositionProps, isBase: true }}>
146
- <CompositionContextProvider queryParams={baseCompositionParams} setQueryParams={setBaseCompositionParams}>
147
- <PreviewView
148
- forceHeight={undefined}
149
- innerBottomPadding={50}
150
- {...previewViewProps}
151
- emptyState={emptyState}
152
- component={baseCompModel}
153
- selected={selectedCompareComp}
154
- queryParams={baseCompQueryParams}
155
- />
156
- </CompositionContextProvider>
157
- </CompositionCompareContext.Provider>
158
- </div>
546
+ <CompositionLayout
547
+ model={baseModel}
548
+ selected={selectedBaseComp}
549
+ queryParams={baseQuery.queryString}
550
+ compositionParams={baseQuery.params}
551
+ previewViewProps={previewViewProps}
552
+ emptyState={emptyState}
553
+ PreviewView={PreviewView}
554
+ contextKey={`base-${baseIdStr}-${baseCompId}`}
555
+ isBase
556
+ />
159
557
  );
160
- }, [base, selectedCompareComp?.identifier]);
558
+ }, [
559
+ isStableData,
560
+ baseModel,
561
+ baseIdStr,
562
+ baseCompId,
563
+ baseChannelKey,
564
+ selectedBaseComp?.identifier,
565
+ baseQuery,
566
+ previewViewProps,
567
+ emptyState,
568
+ baseMissingSelectedCompoisition,
569
+ requestedCompositionId,
570
+ ]);
161
571
 
162
572
  const CompareLayout = useMemo(() => {
163
- if (compare === undefined) {
164
- return null;
165
- }
166
- const compareCompModel = compare.model;
167
- const compositionProps = {
168
- forceHeight: undefined,
169
- innerBottomPadding: 50,
170
- ...previewViewProps,
171
- emptyState,
172
- component: compareCompModel,
173
- queryParams: compareCompQueryParams,
174
- selected: selectedCompareComp,
175
- }
573
+ if (!isStableData) return null;
574
+ if (compareMissingSelectedComposition)
575
+ return <MissingComposition compositionId={requestedCompositionId} version="compare" />;
176
576
  return (
177
- <div className={styles.subView}>
178
- <CompositionCompareContext.Provider value={{ compositionProps, isCompare: true }}>
179
- <CompositionContextProvider queryParams={compareCompositionParams} setQueryParams={setCompareCompositionParams}>
180
- <PreviewView
181
- forceHeight={undefined}
182
- innerBottomPadding={50}
183
- {...previewViewProps}
184
- emptyState={emptyState}
185
- component={compareCompModel}
186
- queryParams={compareCompQueryParams}
187
- selected={selectedCompareComp}
188
- />
189
- </CompositionContextProvider>
190
- </CompositionCompareContext.Provider>
191
-
192
- </div>
577
+ <CompositionLayout
578
+ model={compareModel}
579
+ selected={selectedCompareComp}
580
+ queryParams={compareQuery.queryString}
581
+ compositionParams={compareQuery.params}
582
+ previewViewProps={previewViewProps}
583
+ emptyState={emptyState}
584
+ PreviewView={PreviewView}
585
+ contextKey={`compare-${compareIdStr}-${compareCompId}`}
586
+ isCompare
587
+ />
193
588
  );
194
- }, [compare, selectedCompareComp?.identifier]);
589
+ }, [
590
+ isStableData,
591
+ compareModel,
592
+ compareIdStr,
593
+ compareCompId,
594
+ compareChannelKey,
595
+ selectedCompareComp?.identifier,
596
+ compareQuery,
597
+ previewViewProps,
598
+ emptyState,
599
+ compareMissingSelectedComposition,
600
+ requestedCompositionId,
601
+ ]);
602
+
603
+ const toggleControls = useCallback(() => setControlsOpen((x) => !x), []);
604
+ const handleHeaderKeyDown = useCallback(
605
+ (e: React.KeyboardEvent) => {
606
+ if (e.key === 'Enter' || e.key === ' ') toggleControls();
607
+ },
608
+ [toggleControls]
609
+ );
195
610
 
196
- const CompositionToolbar = () => {
197
- if (!base && !compare) {
198
- return null;
199
- }
611
+ const key = `${base?.model.id.toString()}-${compare?.model.id.toString()}-composition-compare`;
200
612
 
201
- return (
613
+ if (!contextLoading && !baseCompositions?.length && !compareCompositions?.length) {
614
+ return <MissingCompositionTemplate title={`No Compositions Available`} />;
615
+ }
616
+
617
+ return (
618
+ <div key={key} className={classNames(styles.container, { [styles.isResizing]: isResizing })}>
619
+ {contextLoading && (
620
+ <div className={styles.loader}>
621
+ <RoundLoader />
622
+ </div>
623
+ )}
202
624
  <div className={styles.toolbar}>
203
625
  <div className={styles.left}>
204
626
  <div className={styles.dropdown}>
@@ -212,22 +634,47 @@ export function CompositionCompare(props: CompositionCompareProps) {
212
634
  <div className={styles.widgets}>{Widgets?.Right}</div>
213
635
  </div>
214
636
  </div>
215
- );
216
- };
217
-
218
- const key = `${componentCompareContext?.base?.model.id.toString()}-${componentCompareContext?.compare?.model.id.toString()}-composition-compare`;
219
-
220
- return (
221
- <React.Fragment key={key}>
222
- {componentCompareContext?.loading && (
223
- <div className={styles.loader}>
224
- <RoundLoader />
637
+ <div className={styles.compareLayout}>
638
+ <div className={styles.compareMain}>
639
+ <CompareSplitLayoutPreset base={BaseLayout} compare={CompareLayout} />
225
640
  </div>
226
- )}
227
- <CompositionToolbar />
228
- <CompareSplitLayoutPreset base={BaseLayout} compare={CompareLayout}
229
- />
230
- </React.Fragment>
641
+ {hasControlChannels && (
642
+ <div
643
+ ref={panelRef}
644
+ className={styles.controlsPanel}
645
+ style={showControlsPanel ? (isControlsOpen ? { height: panelHeight } : undefined) : { display: 'none' }}
646
+ >
647
+ <div
648
+ className={styles.controlsResizeHandle}
649
+ onMouseDown={handleResizeStart}
650
+ role="separator"
651
+ aria-orientation="horizontal"
652
+ />
653
+ <div
654
+ className={styles.controlsPanelHeader}
655
+ onClick={toggleControls}
656
+ onKeyDown={handleHeaderKeyDown}
657
+ role="button"
658
+ tabIndex={0}
659
+ >
660
+ <Icon of={isControlsOpen ? 'fat-arrow-down' : 'fat-arrow-up'} className={styles.controlsArrow} />
661
+ <span className={styles.controlsPanelTitle}>Live controls</span>
662
+ </div>
663
+ <div className={styles.controlsPanelContent} style={{ display: isControlsOpen ? undefined : 'none' }}>
664
+ <LiveControlsDiffPanel
665
+ resetKey={controlsResetKey}
666
+ baseChannel={baseChannelKey}
667
+ compareChannel={compareChannelKey}
668
+ commonLabel={stableLabels.common}
669
+ baseLabel={stableLabels.base}
670
+ compareLabel={stableLabels.compare}
671
+ showEmptyState={false}
672
+ onStatusChange={handleControlsStatusChange}
673
+ />
674
+ </div>
675
+ </div>
676
+ )}
677
+ </div>
678
+ </div>
231
679
  );
232
680
  }
233
-