diff --git a/.bitmap b/.bitmap index d06b8621fdfe..d7d3ee0ce2f3 100644 --- a/.bitmap +++ b/.bitmap @@ -1787,6 +1787,27 @@ "mainFile": "index.ts", "rootDir": "scopes/preview/ui/component-preview" }, + "ui/composition-compare": { + "name": "ui/composition-compare", + "scope": "teambit.compositions", + "version": "0.0.258", + "mainFile": "index.ts", + "rootDir": "components/ui/composition-compare" + }, + "ui/composition-compare-section": { + "name": "ui/composition-compare-section", + "scope": "teambit.compositions", + "version": "0.0.101", + "mainFile": "index.ts", + "rootDir": "components/ui/composition-compare-section" + }, + "ui/composition-live-controls": { + "name": "ui/composition-live-controls", + "scope": "teambit.compositions", + "version": "0.0.3", + "mainFile": "index.ts", + "rootDir": "components/ui/composition-live-controls" + }, "ui/compositions-app": { "name": "ui/compositions-app", "scope": "teambit.react", diff --git a/components/ui/composition-compare-section/composition.compare.section.ts b/components/ui/composition-compare-section/composition.compare.section.ts new file mode 100644 index 000000000000..1e5e988885d3 --- /dev/null +++ b/components/ui/composition-compare-section/composition.compare.section.ts @@ -0,0 +1,22 @@ +import type { CompositionsUI } from '@teambit/compositions'; +import type { Section } from '@teambit/component'; +import type { TabItem } from '@teambit/component.ui.component-compare.models.component-compare-props'; + +export class CompositionCompareSection implements Section, TabItem { + constructor(private ui: CompositionsUI) {} + + navigationLink = { + href: 'compositions', + children: 'Preview', + }; + + props = this.navigationLink; + + route: Section['route'] = { + path: 'compositions/*', + element: this.ui.getCompositionsCompare(), + }; + + order = 1; + id = 'preview'; +} diff --git a/components/ui/composition-compare-section/index.ts b/components/ui/composition-compare-section/index.ts new file mode 100644 index 000000000000..4941ae4caa37 --- /dev/null +++ b/components/ui/composition-compare-section/index.ts @@ -0,0 +1 @@ +export { CompositionCompareSection } from './composition.compare.section'; diff --git a/components/ui/composition-compare/composition-compare.context.tsx b/components/ui/composition-compare/composition-compare.context.tsx new file mode 100644 index 000000000000..b475d7ccf83a --- /dev/null +++ b/components/ui/composition-compare/composition-compare.context.tsx @@ -0,0 +1,14 @@ +import { useContext, createContext } from 'react'; +import { CompositionContentProps } from '@teambit/compositions'; + +export type CompositionCompareContextModel = { + compositionProps?: CompositionContentProps; + isBase?: boolean; + isCompare?: boolean; +}; + +export const CompositionCompareContext: React.Context = createContext< + CompositionCompareContextModel | undefined +>(undefined); + +export const useCompositionCompare = () => useContext(CompositionCompareContext); diff --git a/components/ui/composition-compare/composition-compare.module.scss b/components/ui/composition-compare/composition-compare.module.scss new file mode 100644 index 000000000000..03fc4a17f112 --- /dev/null +++ b/components/ui/composition-compare/composition-compare.module.scss @@ -0,0 +1,52 @@ +.toolbar { + display: flex; + padding: 4px; +} + +.left, +.right { + display: flex; + flex: 1; + padding: 4px; + background-color: var(--background-color); + align-items: center; + justify-content: space-between; +} + +.right { + justify-content: flex-end; +} + +.mainContainer { + display: flex; + height: 100%; + min-height: 80vh; +} + +.subContainerLeft { + flex: 1; +} + +.subContainerRight { + flex: 1; +} + +.subView { + height: 100%; + background-color: var(--background-color); +} + +.loader { + display: flex; + align-items: center; + height: 100%; +} + +.widgets { + display: flex; + padding: 4px; +} + +.dropdown { + display: flex; +} diff --git a/components/ui/composition-compare/composition-compare.tsx b/components/ui/composition-compare/composition-compare.tsx new file mode 100644 index 000000000000..e370b9f9854e --- /dev/null +++ b/components/ui/composition-compare/composition-compare.tsx @@ -0,0 +1,253 @@ +import React, { useMemo, useState } from 'react'; +import { useComponentCompare } from '@teambit/component.ui.component-compare.context'; +import { + CompositionContent, + LiveControlsRenderer, + type CompositionContentProps, + type EmptyStateSlot, +} from '@teambit/compositions'; +import { CompositionContextProvider } from '@teambit/compositions.ui.hooks.use-composition'; +import { + useCompareQueryParam, + useUpdatedUrlFromQuery, +} from '@teambit/component.ui.component-compare.hooks.use-component-compare-url'; +import { CompareSplitLayoutPreset } from '@teambit/component.ui.component-compare.layouts.compare-split-layout-preset'; +import { RoundLoader } from '@teambit/design.ui.round-loader'; +import queryString from 'query-string'; +import { CompositionDropdown } from './composition-dropdown'; +import { CompositionCompareContext } from './composition-compare.context'; +import { uniqBy } from 'lodash'; + +import styles from './composition-compare.module.scss'; + +export type CompositionCompareProps = { + emptyState?: EmptyStateSlot; + Widgets?: { + Right?: React.ReactNode; + Left?: React.ReactNode; + }; + previewViewProps?: CompositionContentProps; + PreviewView?: React.ComponentType; +}; + +export function CompositionCompare(props: CompositionCompareProps) { + const { emptyState, PreviewView = CompositionContent, Widgets, previewViewProps = {} } = props; + + const componentCompareContext = useComponentCompare(); + + const { base, compare, baseContext, compareContext } = componentCompareContext || {}; + + const baseCompositions = base?.model.compositions; + const compareCompositions = compare?.model.compositions; + + const selectedCompositionBaseFile = useCompareQueryParam('compositionBaseFile'); + const selectedCompositionCompareFile = useCompareQueryParam('compositionCompareFile'); + + const baseState = baseContext?.state?.preview; + const compareState = compareContext?.state?.preview; + const baseHooks = baseContext?.hooks?.preview; + const compareHooks = compareContext?.hooks?.preview; + const selectedBaseFromState = baseState?.id; + const selectedCompareFromState = compareState?.id; + + const selectedBaseCompositionId = selectedBaseFromState || selectedCompositionBaseFile; + const selectedCompositionCompareId = selectedCompareFromState || selectedCompositionCompareFile; + + const selectedBaseComp = + (selectedBaseCompositionId && + baseCompositions && + baseCompositions.find((c) => { + return c.identifier === selectedBaseCompositionId; + })) || + (baseCompositions && baseCompositions[0]); + + const selectedCompareComp = + (selectedCompositionCompareId && + compareCompositions && + compareCompositions.find((c) => { + return c.identifier === selectedCompositionCompareId; + })) || + (compareCompositions && compareCompositions[0]); + + // const baseCompositionDropdownSource = + // baseCompositions?.map((c) => { + // const href = !baseState?.controlled + // ? useUpdatedUrlFromQuery({ + // compositionBaseFile: c.identifier, + // compositionCompareFile: selectedCompareComp?.identifier, + // }) + // : useUpdatedUrlFromQuery({}); + + // const onClick = baseState?.controlled ? baseHooks?.onClick : undefined; + + // return { id: c.identifier, label: c.displayName, href, onClick }; + // }) || []; + + // const compareCompositionDropdownSource = + // compareCompositions?.map((c) => { + // const href = !compareState?.controlled + // ? useUpdatedUrlFromQuery({ + // compositionBaseFile: selectedBaseComp?.identifier, + // compositionCompareFile: c.identifier, + // }) + // : useUpdatedUrlFromQuery({}); + + // const onClick = compareState?.controlled ? () => compareHooks?.onClick : undefined; + + // return { id: c.identifier, label: c.displayName, href, onClick }; + // }) || []; + + const compositionsDropdownSource = uniqBy( + (baseCompositions || []).concat(compareCompositions || []), + 'identifier' + )?.map((c) => { + const href = !compareState?.controlled + ? useUpdatedUrlFromQuery({ + compositionBaseFile: selectedBaseComp?.identifier, + compositionCompareFile: c.identifier, + }) + : useUpdatedUrlFromQuery({}); + + const onClick = compareState?.controlled + ? (_, __) => { + compareHooks?.onClick?.(_, __); + baseHooks?.onClick?.(_, __); + } + : undefined; + return { id: c.identifier, label: c.displayName, href, onClick }; + }); + + const [baseCompositionParams, setBaseCompositionParams] = useState>({ + livecontrols: true, + }); + // eslint-disable-next-line no-console + console.log('🚀 ~ CompositionCompare ~ baseCompositionParams:', baseCompositionParams); + const baseCompQueryParams = useMemo(() => queryString.stringify(baseCompositionParams), [baseCompositionParams]); + + const [compareCompositionParams, setCompareCompositionParams] = useState>({ + livecontrols: true, + }); + // eslint-disable-next-line no-console + console.log('🚀 ~ CompositionCompare ~ compareCompositionParams:', compareCompositionParams); + const compareCompQueryParams = useMemo( + () => queryString.stringify(compareCompositionParams), + [compareCompositionParams] + ); + + // const selectedBaseDropdown = selectedBaseComp && { + // id: selectedBaseComp.identifier, + // label: selectedBaseComp.displayName, + // }; + + const selectedCompareDropdown = selectedCompareComp && { + id: selectedCompareComp.identifier, + label: selectedCompareComp.displayName, + }; + + const BaseLayout = useMemo(() => { + if (base === undefined) { + return null; + } + const baseCompModel = base.model; + const compositionProps = { + forceHeight: undefined, + innerBottomPadding: 50, + ...previewViewProps, + emptyState, + component: baseCompModel, + queryParams: baseCompQueryParams, + selected: selectedCompareComp, + }; + return ( +
+ + + + + +
+ ); + }, [base, selectedCompareComp?.identifier]); + + const CompareLayout = useMemo(() => { + if (compare === undefined) { + return null; + } + const compareCompModel = compare.model; + const compositionProps = { + forceHeight: undefined, + innerBottomPadding: 50, + ...previewViewProps, + emptyState, + component: compareCompModel, + // queryParams: compareCompQueryParams, + queryParams: 'livecontrols=true', + selected: selectedCompareComp, + }; + return ( +
+ + + + + +
+ ); + }, [compare, selectedCompareComp?.identifier]); + + const CompositionToolbar = () => { + if (!base && !compare) { + return null; + } + + return ( +
+
+
+ {compositionsDropdownSource.length > 0 && ( + + )} +
+
{Widgets?.Left}
+
+
+
{Widgets?.Right}
+
+
+ ); + }; + + const key = `${componentCompareContext?.base?.model.id.toString()}-${componentCompareContext?.compare?.model.id.toString()}-composition-compare`; + + return ( + + {componentCompareContext?.loading && ( +
+ +
+ )} + + + +
+ ); +} diff --git a/components/ui/composition-compare/composition-dropdown.module.scss b/components/ui/composition-compare/composition-dropdown.module.scss new file mode 100644 index 000000000000..1392b4f06edf --- /dev/null +++ b/components/ui/composition-compare/composition-dropdown.module.scss @@ -0,0 +1,26 @@ +@import '~@teambit/ui-foundation.ui.constants.z-indexes/z-indexes.module.scss'; + +.placeholder { + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + padding: 0 8px; + height: 30px; + border-radius: 6px; + user-select: none; + transition: background-color 300ms ease-in-out; + border: 1px solid var(--bit-border-color, #babec9); + background-color: var(--bit-bg-color, #ffffff); +} + +.menu { + font-size: var(--bit-p-xs); + border-radius: 6px; + max-height: 240px; + overflow-y: scroll; + max-width: 400px; + width: fit-content; + padding: 0px; + z-index: $modal-z-index; +} diff --git a/components/ui/composition-compare/composition-dropdown.tsx b/components/ui/composition-compare/composition-dropdown.tsx new file mode 100644 index 000000000000..c46e94c9f7f1 --- /dev/null +++ b/components/ui/composition-compare/composition-dropdown.tsx @@ -0,0 +1,63 @@ +import React, { useRef } from 'react'; +import { MenuLinkItem } from '@teambit/design.ui.surfaces.menu.link-item'; +import { Icon } from '@teambit/design.elements.icon'; +import { Dropdown } from '@teambit/evangelist.surfaces.dropdown'; + +import styles from './composition-dropdown.module.scss'; + +export type DropdownItem = { id: string; label: string; href?: string; onClick?: (id: string, e) => void }; + +export type CompositionDropdownProps = { + selected?: Omit; + dropdownItems: Array; +}; + +export function CompositionDropdown(props: CompositionDropdownProps) { + const { selected, dropdownItems: data } = props; + const key = (item: DropdownItem) => `${item.id}-${item.href}`; + + return ( + +
{selected && selected.label}
+ + + } + > + {data.map((item) => { + return ; + })} +
+ ); +} + +type MenuItemProps = { + selected?: Omit; + current: DropdownItem; +}; + +function MenuItem(props: MenuItemProps) { + const { selected, current } = props; + + // const isCurrent = selected?.id === current.id; + const currentVersionRef = useRef(null); + + // useEffect(() => { + // if (isCurrent) { + // currentVersionRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + // } + // }, [isCurrent]); + + const onClick = (!!current.onClick && ((e) => current.onClick?.(current.id, e))) || undefined; + + return ( +
+ {/* @ts-ignore */} + +
{current.label}
+
+
+ ); +} diff --git a/components/ui/composition-compare/index.ts b/components/ui/composition-compare/index.ts new file mode 100644 index 000000000000..f8c9f870bcad --- /dev/null +++ b/components/ui/composition-compare/index.ts @@ -0,0 +1,2 @@ +export { CompositionCompare } from './composition-compare'; +export { useCompositionCompare, CompositionCompareContext } from './composition-compare.context'; diff --git a/components/ui/composition-live-controls/composition-live-controls.docs.mdx b/components/ui/composition-live-controls/composition-live-controls.docs.mdx new file mode 100644 index 000000000000..0d92d1932636 --- /dev/null +++ b/components/ui/composition-live-controls/composition-live-controls.docs.mdx @@ -0,0 +1,131 @@ +--- +description: Utils for composition live controls. +--- + +Utils for composition live controls. + +## Usage + +Types for live controls. + +```ts +type Controls, + +type Control, +type ControlInputType, + +type ControlBase, +type ControlUnknown, +type ControlBoolean, +type ControlSelect, +type ControlMultiSelect, +type ControlText, +type ControlLongText, +type ControlNumber, +type ControlRange, +type ControlDate, +type ControlJSON, +type ControlColor, +type ControlCustom, + +type SelectOption, +``` + +Utils to resolve controls and values. + +```ts +/** + * Controls can be an array or a map. + * This function is designed to resolve controls into array. + */ +function resolveControls(controls: Controls): Control[]; + +/** + * Resolves controls from values. + * It will return an array of controls based on the type of values in each key. + */ +function resolveControlsFromValues(values: Record): Control[]; + +/** + * Applies default values from controls to props. + */ +function resolveValues(values: Record, controls: Control[]): Record; + +/** + * Resolves all the data from given values and controls. + */ +function resolveAll(values: Record, controls: Controls): [Record, Control[]]; +``` + +Communication between the preview and the control panel via postMessage. +Usually each message includes type and payload data. +All the utils use `JSON.parse(JSON.stringify())` to deep clone the data. +And all the listeners filter the messages by type. + +```ts +const BROADCAST_READY_KEY = 'composition-live-controls:ready' +const BROADCAST_UPDATE_KEY = 'composition-live-controls:update' +const BROADCAST_DESTROY_KEY = 'composition-live-controls:destroy' + +function broadcastReady( + target: Window, + id: number, + controls: Control[], + values: Record, +) + +function broadcastUpdate( + target: Window, + id: number, + values: Record, +) + +function broadcastDestroy( + target: Window, + id: number, +) + +type LiveControlReadyEventData, +type LiveControlUpdateEventData, +type LiveControlDestroyEventData, +type LiveControlEventData, + +function getReadyListener( + event: MessageEvent, + callback: (data: { + controls: Control[], + values: Record, + timestamp: number, + }) => void +) + +function getUpdateListener( + event: MessageEvent, + callback: (data: { + key: string, + value: any, + timestamp: number, + }) => void +) + +function getDestroyListener + event: MessageEvent, + callback: (data: { + timestamp: number, + }) => void +) +``` + +Generic type of live compositions. + +```ts +type LiveComposition +``` + +Detect whether live controls is needed according to the current URL + +``` +// e.g. `http://localhost:3000/preview/bitdev.react/react-env@xxx/#teambit.vite/examples/foo?preview=compositions&env=bitdev.react/react-env@xxx&name=BasicFoo&fullscreen=true&livecontrols=true` +// e.g. `http://xxx.bit.cloud/api/bitdev.react/react-env@xxx/#teambit.vite/examples/foo?preview=compositions&env=bitdev.react/react-env@xxx&name=BasicFoo&fullscreen=true&livecontrols=true` +function needLiveControls(loc: Location): boolean +``` diff --git a/components/ui/composition-live-controls/composition-live-controls.tsx b/components/ui/composition-live-controls/composition-live-controls.tsx new file mode 100644 index 000000000000..1de35661936a --- /dev/null +++ b/components/ui/composition-live-controls/composition-live-controls.tsx @@ -0,0 +1,394 @@ +export type ControlInputType = + | 'text' + | 'longtext' + | 'number' + | 'range' + | 'boolean' + | 'select' + | 'multiselect' + | 'date' + | 'color' + | 'json' + | 'custom'; + +export type SelectOption = + | string + | { + label: string; + value: string; + }; + +export type ControlBase = { + id: string; + input?: string; + defaultValue?: any; + label?: string; + type?: string | Function; // e.g. 'string', 'number', 'boolean', Date, Object, etc. +}; + +export type ControlUnknown = { + defaultValue?: any; +}; + +export type ControlBoolean = { + input: 'boolean'; // + defaultValue?: boolean; +}; + +export type ControlSelect = { + input: 'select'; // x n +}; + +export type ControlMultiSelect = { + input: 'multiselect'; // x n +}; + +export type ControlText = { + input: 'text'; // + defaultValue?: string; +}; + +export type ControlLongText = { + input: 'longtext'; //