diff --git a/.bitmap b/.bitmap index 8a92b31899d7..7c1183cb8cc2 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..c5c9af19defc --- /dev/null +++ b/components/ui/composition-compare/composition-compare.module.scss @@ -0,0 +1,165 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + + &.isResizing { + cursor: ns-resize; + + // Prevent iframes from capturing pointer events during resize + iframe { + pointer-events: none; + } + } +} + +.toolbar { + display: flex; + padding: 4px; + flex-shrink: 0; +} + +.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; +} + +.subView { + height: 100%; + background-color: var(--background-color); +} + +.loader { + display: flex; + align-items: center; + height: 100%; +} + +.widgets { + display: flex; + padding: 4px; +} + +.dropdown { + display: flex; +} + +.compareLayout { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + position: relative; +} + +.compareMain { + flex: 1 1 auto; + min-height: 150px; + min-width: 0; + overflow: hidden; +} + +.controlsPanel { + flex: 0 0 auto; + display: flex; + flex-direction: column; + background: var(--background-color); + min-height: 36px; + max-height: calc(100% - 150px); + overflow: hidden; + border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.08)); + contain: layout; +} + +.controlsResizeHandle { + height: 6px; + cursor: ns-resize; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + user-select: none; + touch-action: none; + + &::before { + content: ''; + display: block; + width: 32px; + height: 3px; + border-radius: 3px; + background: var(--border-medium-color, rgba(0, 0, 0, 0.12)); + } + + &:hover::before, + &:active::before { + background: var(--primary-color, #6a57fd); + } +} + +.controlsPanelHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + user-select: none; + background: var(--background-color); + flex-shrink: 0; + + &:hover { + background: var(--background-hover-color, rgba(0, 0, 0, 0.02)); + } +} + +.controlsArrow { + font-size: 10px; + color: var(--on-background-color, #222); + opacity: 0.5; +} + +.controlsPanelTitle { + font-size: var(--bit-p-xs, 13px); + font-weight: 500; + color: var(--on-background-color, #222); +} + +.controlsPanelContent { + flex: 1 1 auto; + overflow: auto; + padding: 12px 16px; + min-height: 0; +} + +.missingComposition { + margin: 24px; + padding: 20px; + border: 1px dashed var(--border-color, rgba(0, 0, 0, 0.12)); + border-radius: 10px; + background: var(--background-color, #ffffff); + text-align: center; +} + +.missingCompositionTitle { + font-size: 14px; + font-weight: 600; + color: var(--on-background-color, #222222); +} + +.missingCompositionSubtitle { + margin-top: 6px; + font-size: 12px; + color: var(--on-background-color, #222222); + opacity: 0.7; +} diff --git a/components/ui/composition-compare/composition-compare.tsx b/components/ui/composition-compare/composition-compare.tsx new file mode 100644 index 000000000000..d6edb4674ddc --- /dev/null +++ b/components/ui/composition-compare/composition-compare.tsx @@ -0,0 +1,424 @@ +import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react'; +import classNames from 'classnames'; +import { useComponentCompare } from '@teambit/component.ui.component-compare.context'; +import { + CompositionContent, + LiveControlsDiffPanel, + 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 { Icon } from '@teambit/evangelist.elements.icon'; +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'; + +const noop = () => {}; + +export type CompositionCompareProps = { + emptyState?: EmptyStateSlot; + Widgets?: { + Right?: React.ReactNode; + Left?: React.ReactNode; + }; + previewViewProps?: CompositionContentProps; + PreviewView?: React.ComponentType; +}; + +function MissingComposition({ compositionId, version }: { compositionId?: string; version: string }) { + const message = compositionId + ? `The selected composition "${compositionId}" does not exist for the ${version} version.` + : `The selected composition does not exist for the ${version} version.`; + return ( +
+
+
Composition not available
+
{message}
+
+
+ ); +} + +function getCompositionTag(hasInBase: boolean, hasInCompare: boolean): string | undefined { + if (hasInBase && hasInCompare) return undefined; + if (hasInBase) return 'Base only'; + if (hasInCompare) return 'Compare only'; + return undefined; +} + +function useResizePanel(initialHeight: number) { + const [panelHeight, setPanelHeight] = useState(initialHeight); + const [isResizing, setIsResizing] = useState(false); + const panelRef = useRef(null); + const isDragging = useRef(false); + + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + isDragging.current = true; + setIsResizing(true); + + const startY = e.clientY; + const startHeight = panelHeight; + + const handleMouseMove = (moveEvent: MouseEvent) => { + if (!isDragging.current) return; + moveEvent.preventDefault(); + const delta = startY - moveEvent.clientY; + const containerHeight = panelRef.current?.parentElement?.clientHeight || 600; + const maxHeight = Math.max(100, containerHeight - 200); + setPanelHeight(Math.max(60, Math.min(maxHeight, startHeight + delta))); + }; + + const handleMouseUp = () => { + isDragging.current = false; + setIsResizing(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.body.style.cursor = 'ns-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, + [panelHeight] + ); + + return { panelRef, panelHeight, isResizing, handleResizeStart }; +} + +function findComposition(compositions: any[] | undefined, id: string | undefined) { + if (!id || !compositions) return compositions?.[0]; + return compositions.find((c) => c.identifier === id) || undefined; +} + +function useCompositionSelection() { + const selectedCompositionBaseFile = useCompareQueryParam('compositionBaseFile'); + const selectedCompositionCompareFile = useCompareQueryParam('compositionCompareFile'); + return { selectedCompositionBaseFile, selectedCompositionCompareFile }; +} + +function buildChannelKey(prefix: string, idStr: string | undefined, compId: string | undefined): string | undefined { + if (!idStr || !compId) return undefined; + return `${prefix}:${idStr}:${compId}`; +} + +function buildQueryParams(channelKey: string | undefined) { + const params = { livecontrols: true, ...(channelKey ? { lcchannel: channelKey } : {}) }; + return { params, queryString: queryString.stringify(params) }; +} + +type CompositionLayoutProps = { + model: any; + selected: any; + queryParams: string; + compositionParams: Record; + previewViewProps: CompositionContentProps; + emptyState?: EmptyStateSlot; + PreviewView: React.ComponentType; + contextKey: string; + isBase?: boolean; + isCompare?: boolean; +}; + +function CompositionLayout({ + model, + selected, + queryParams, + compositionParams, + previewViewProps, + emptyState, + PreviewView, + contextKey, + isBase, + isCompare, +}: CompositionLayoutProps) { + return ( +
+ + + + + +
+ ); +} + +export function CompositionCompare(props: CompositionCompareProps) { + const { + emptyState, + PreviewView = CompositionContent, + Widgets, + previewViewProps = {} as CompositionContentProps, + } = props; + + const componentCompareContext = useComponentCompare(); + const { base, compare, baseContext, compareContext, loading: contextLoading } = componentCompareContext || {}; + + const [isControlsOpen, setControlsOpen] = useState(true); + const [controlsStatus, setControlsStatus] = useState<'loading' | 'available' | 'empty'>('loading'); + const [everHadControls, setEverHadControls] = useState(false); + const { panelRef, panelHeight, isResizing, handleResizeStart } = useResizePanel(200); + + const isStableData = !contextLoading && base !== undefined && compare !== undefined; + const baseCompositions = base?.model.compositions; + const compareCompositions = compare?.model.compositions; + + const { selectedCompositionBaseFile, selectedCompositionCompareFile } = useCompositionSelection(); + + const compareState = compareContext?.state?.preview; + const baseHooks = baseContext?.hooks?.preview; + const compareHooks = compareContext?.hooks?.preview; + + const explicitId = selectedCompositionCompareFile || selectedCompositionBaseFile; + const stateId = compareState?.id || baseContext?.state?.preview?.id; + const defaultId = compareCompositions?.[0]?.identifier || baseCompositions?.[0]?.identifier; + const requestedCompositionId = explicitId || stateId || defaultId; + + const selectedBaseComp = findComposition(baseCompositions, requestedCompositionId); + const selectedCompareComp = findComposition(compareCompositions, requestedCompositionId); + + const baseMissing = Boolean(requestedCompositionId && !selectedBaseComp); + const compareMissing = Boolean(requestedCompositionId && !selectedCompareComp); + + const baseCompositionIds = useMemo( + () => new Set((baseCompositions || []).map((c) => c.identifier)), + [baseCompositions] + ); + const compareCompositionIds = useMemo( + () => new Set((compareCompositions || []).map((c) => c.identifier)), + [compareCompositions] + ); + + const compositionsDropdownSource = useMemo(() => { + return uniqBy((baseCompositions || []).concat(compareCompositions || []), 'identifier')?.map((c) => { + const hasInBase = baseCompositionIds.has(c.identifier); + const hasInCompare = compareCompositionIds.has(c.identifier); + const tag = getCompositionTag(hasInBase, hasInCompare); + const href = !compareState?.controlled + ? useUpdatedUrlFromQuery({ compositionBaseFile: c.identifier, compositionCompareFile: c.identifier }) + : useUpdatedUrlFromQuery({}); + const onClick = compareState?.controlled + ? (id, e) => { + compareHooks?.onClick?.(id, e); + baseHooks?.onClick?.(id, e); + } + : undefined; + return { id: c.identifier, label: c.displayName, href, onClick, tag }; + }); + }, [baseCompositions, compareCompositions, baseCompositionIds, compareCompositionIds, compareState?.controlled]); + + const selectedCompareDropdown = useMemo(() => { + const found = + compositionsDropdownSource.find((item) => item.id === selectedCompareComp?.identifier) || + compositionsDropdownSource.find((item) => item.id === selectedBaseComp?.identifier); + if (found) return found; + if (requestedCompositionId) return { id: requestedCompositionId, label: requestedCompositionId, tag: 'Missing' }; + return undefined; + }, [ + compositionsDropdownSource, + selectedCompareComp?.identifier, + selectedBaseComp?.identifier, + requestedCompositionId, + ]); + + const baseIdStr = base?.model.id?.toString(); + const compareIdStr = compare?.model.id?.toString(); + const baseCompId = selectedBaseComp?.identifier || requestedCompositionId; + const compareCompId = selectedCompareComp?.identifier || requestedCompositionId; + + const baseChannelKey = useMemo(() => buildChannelKey('base', baseIdStr, baseCompId), [baseIdStr, baseCompId]); + const compareChannelKey = useMemo( + () => buildChannelKey('compare', compareIdStr, compareCompId), + [compareIdStr, compareCompId] + ); + + const baseQuery = useMemo(() => buildQueryParams(baseChannelKey), [baseChannelKey]); + const compareQuery = useMemo(() => buildQueryParams(compareChannelKey), [compareChannelKey]); + + const controlsResetKey = `${baseChannelKey || ''}-${compareChannelKey || ''}`; + + useEffect(() => { + setEverHadControls(false); + setControlsStatus('loading'); + }, [baseIdStr, compareIdStr]); + + const handleControlsStatusChange = useCallback((status: 'loading' | 'available' | 'empty') => { + setControlsStatus(status); + if (status === 'available') setEverHadControls(true); + }, []); + + const showControlsPanel = controlsStatus === 'available' || controlsStatus === 'loading' || everHadControls; + + const baseModel = base?.model; + const compareModel = compare?.model; + + const BaseLayout = useMemo(() => { + if (!isStableData || !baseChannelKey || !baseModel) return null; + if (baseMissing) return ; + return ( + + ); + }, [ + isStableData, + baseModel, + baseIdStr, + baseCompId, + baseChannelKey, + selectedBaseComp?.identifier, + baseQuery, + previewViewProps, + emptyState, + baseMissing, + requestedCompositionId, + ]); + + const CompareLayout = useMemo(() => { + if (!isStableData || !compareChannelKey || !compareModel) return null; + if (compareMissing) return ; + return ( + + ); + }, [ + isStableData, + compareModel, + compareIdStr, + compareCompId, + compareChannelKey, + selectedCompareComp?.identifier, + compareQuery, + previewViewProps, + emptyState, + compareMissing, + requestedCompositionId, + ]); + + const toggleControls = useCallback(() => setControlsOpen((x) => !x), []); + const handleHeaderKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') toggleControls(); + }, + [toggleControls] + ); + + if (!base && !compare) return null; + + const key = `${base?.model.id.toString()}-${compare?.model.id.toString()}-composition-compare`; + + return ( +
+ {contextLoading && ( +
+ +
+ )} +
+
+
+ {compositionsDropdownSource.length > 0 && ( + + )} +
+
{Widgets?.Left}
+
+
+
{Widgets?.Right}
+
+
+
+
+ +
+ {showControlsPanel && ( +
+
+
+ + Live controls +
+
+ +
+
+ )} +
+
+ ); +} 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..7997e3d03f2b --- /dev/null +++ b/components/ui/composition-compare/composition-dropdown.module.scss @@ -0,0 +1,54 @@ +@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); +} + +.placeholderText { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.placeholderLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menuItem { + display: flex; + align-items: center; + gap: 6px; +} + +.tag { + font-size: 11px; + line-height: 16px; + padding: 0 6px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.08); + color: var(--on-background-color, #222222); +} + +.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..b387d6931a9b --- /dev/null +++ b/components/ui/composition-compare/composition-dropdown.tsx @@ -0,0 +1,75 @@ +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; + tag?: string; +}; + +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} + {selected?.tag && {selected.tag}} +
+ +
+ } + > + {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} + {current.tag && {current.tag}} +
+
+
+ ); +} 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'; //