mirror of
https://github.com/apache/superset.git
synced 2026-04-27 20:14:54 +00:00
531 lines
19 KiB
JavaScript
531 lines
19 KiB
JavaScript
/**
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import BrowserOnly from '@docusaurus/BrowserOnly';
|
|
|
|
// Lazy-loaded component registry - populated on first use in browser
|
|
let componentRegistry = null;
|
|
let SupersetProviders = null;
|
|
|
|
function getComponentRegistry() {
|
|
if (typeof window === 'undefined') {
|
|
return {}; // SSR - return empty
|
|
}
|
|
|
|
if (componentRegistry !== null) {
|
|
return componentRegistry; // Already loaded
|
|
}
|
|
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const antd = require('antd');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const SupersetComponents = require('@superset/components');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const CoreUI = require('@apache-superset/core/components');
|
|
|
|
// Build component registry with antd as base fallback layer.
|
|
// Some Superset components (e.g., Typography) use styled-components that may
|
|
// fail to initialize in the docs build. Antd originals serve as fallbacks.
|
|
componentRegistry = { ...antd, ...SupersetComponents, ...CoreUI };
|
|
|
|
return componentRegistry;
|
|
} catch (error) {
|
|
console.error('[StorybookWrapper] Failed to load components:', error);
|
|
componentRegistry = {};
|
|
return componentRegistry;
|
|
}
|
|
}
|
|
|
|
function getProviders() {
|
|
if (typeof window === 'undefined') {
|
|
return ({ children }) => children; // SSR
|
|
}
|
|
|
|
if (SupersetProviders !== null) {
|
|
return SupersetProviders;
|
|
}
|
|
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { themeObject } = require('@apache-superset/core/theme');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { App, ConfigProvider } = require('antd');
|
|
|
|
// Configure Ant Design to render portals (tooltips, dropdowns, etc.)
|
|
// inside the closest .storybook-example container instead of document.body
|
|
// This fixes positioning issues in the docs pages
|
|
const getPopupContainer = (triggerNode) => {
|
|
// Find the closest .storybook-example container
|
|
const container = triggerNode?.closest?.('.storybook-example');
|
|
return container || document.body;
|
|
};
|
|
|
|
SupersetProviders = ({ children }) => (
|
|
<themeObject.SupersetThemeProvider>
|
|
<ConfigProvider
|
|
getPopupContainer={getPopupContainer}
|
|
getTargetContainer={() => document.body}
|
|
>
|
|
<App>{children}</App>
|
|
</ConfigProvider>
|
|
</themeObject.SupersetThemeProvider>
|
|
);
|
|
return SupersetProviders;
|
|
} catch (error) {
|
|
console.error('[StorybookWrapper] Failed to load providers:', error);
|
|
return ({ children }) => children;
|
|
}
|
|
}
|
|
|
|
// Check if a value is a valid React component (function, forwardRef, memo, etc.)
|
|
function isReactComponent(value) {
|
|
if (!value) return false;
|
|
// Function/class components
|
|
if (typeof value === 'function') return true;
|
|
// forwardRef, memo, lazy — React wraps these as objects with $$typeof
|
|
if (typeof value === 'object' && value.$$typeof) return true;
|
|
return false;
|
|
}
|
|
|
|
// Resolve component from string name or React component
|
|
// Supports dot notation for nested components (e.g., 'Icons.InfoCircleOutlined')
|
|
function resolveComponent(component) {
|
|
if (!component) return null;
|
|
// If already a component (function/class/forwardRef), return as-is
|
|
if (isReactComponent(component)) return component;
|
|
// If string, look up in registry
|
|
if (typeof component === 'string') {
|
|
const registry = getComponentRegistry();
|
|
// Handle dot notation (e.g., 'Icons.InfoCircleOutlined')
|
|
if (component.includes('.')) {
|
|
const parts = component.split('.');
|
|
let current = registry[parts[0]];
|
|
for (let i = 1; i < parts.length && current; i++) {
|
|
current = current[parts[i]];
|
|
}
|
|
return isReactComponent(current) ? current : null;
|
|
}
|
|
return registry[component] || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Loading placeholder for SSR
|
|
function LoadingPlaceholder() {
|
|
return (
|
|
<div
|
|
style={{
|
|
border: '1px solid #e8e8e8',
|
|
borderRadius: '4px',
|
|
padding: '20px',
|
|
marginBottom: '20px',
|
|
minHeight: '100px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: '#999',
|
|
}}
|
|
>
|
|
Loading component...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// A simple component to display a story example
|
|
export function StoryExample({ component, props = {} }) {
|
|
return (
|
|
<BrowserOnly fallback={<LoadingPlaceholder />}>
|
|
{() => {
|
|
const Component = resolveComponent(component);
|
|
const Providers = getProviders();
|
|
const { children, restProps } = extractChildren(props);
|
|
return (
|
|
<Providers>
|
|
<div
|
|
className="storybook-example"
|
|
style={{
|
|
border: '1px solid #e8e8e8',
|
|
borderRadius: '4px',
|
|
padding: '20px',
|
|
marginBottom: '20px',
|
|
position: 'relative', // Required for portal positioning
|
|
}}
|
|
>
|
|
{Component ? (
|
|
<Component {...restProps}>{children}</Component>
|
|
) : (
|
|
<div style={{ color: '#999' }}>
|
|
Component "{String(component)}" not found
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Providers>
|
|
);
|
|
}}
|
|
</BrowserOnly>
|
|
);
|
|
}
|
|
|
|
// Props that should be rendered as children rather than passed as props
|
|
const CHILDREN_PROP_NAMES = ['label', 'children', 'text', 'content'];
|
|
|
|
// Extract children from props based on common conventions
|
|
function extractChildren(props) {
|
|
for (const propName of CHILDREN_PROP_NAMES) {
|
|
if (props[propName] !== undefined && props[propName] !== null && props[propName] !== '') {
|
|
const { [propName]: childContent, ...restProps } = props;
|
|
return { children: childContent, restProps };
|
|
}
|
|
}
|
|
return { children: null, restProps: props };
|
|
}
|
|
|
|
// Generate sample children for layout components
|
|
// Supports:
|
|
// - Array of strings: ['Item 1', 'Item 2'] - renders as styled divs
|
|
// - Array of component descriptors: [{ component: 'Button', props: { children: 'Click' } }]
|
|
// - Number: 3 - generates that many sample items
|
|
// - String: 'content' - renders as literal content
|
|
function generateSampleChildren(sampleChildren, sampleChildrenStyle) {
|
|
if (!sampleChildren) return null;
|
|
|
|
// Default style if none provided (minimal, just enough to see items)
|
|
const itemStyle = sampleChildrenStyle || {};
|
|
|
|
// If it's an array, check if items are component descriptors or strings
|
|
if (Array.isArray(sampleChildren)) {
|
|
return sampleChildren.map((item, i) => {
|
|
// Component descriptor: { component: 'Button', props: { ... } }
|
|
if (item && typeof item === 'object' && item.component) {
|
|
const ChildComponent = resolveComponent(item.component);
|
|
if (ChildComponent) {
|
|
return <ChildComponent key={i} {...item.props} />;
|
|
}
|
|
// Fallback if component not found
|
|
return <div key={i}>{item.props?.children || `Unknown: ${item.component}`}</div>;
|
|
}
|
|
// Simple string
|
|
return (
|
|
<div key={i} style={itemStyle}>
|
|
{item}
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
|
|
// If it's a number, generate that many sample items
|
|
if (typeof sampleChildren === 'number') {
|
|
return new Array(sampleChildren).fill(null).map((_, i) => (
|
|
<div key={i} style={itemStyle}>
|
|
Item {i + 1}
|
|
</div>
|
|
));
|
|
}
|
|
|
|
// If it's a string, treat as literal content
|
|
if (typeof sampleChildren === 'string') {
|
|
return sampleChildren;
|
|
}
|
|
|
|
return sampleChildren;
|
|
}
|
|
|
|
// Inner component for StoryWithControls (browser-only)
|
|
// renderComponent allows overriding which component to actually render (useful when the named
|
|
// component is a namespace object like Icons, not a React component)
|
|
// triggerProp: for components like Modal that need a trigger, specify the boolean prop that controls visibility
|
|
function StoryWithControlsInner({ component, renderComponent, props, controls, sampleChildren, sampleChildrenStyle, triggerProp, onHideProp }) {
|
|
// Use renderComponent if provided, otherwise use the main component name
|
|
const componentToRender = renderComponent || component;
|
|
const Component = resolveComponent(componentToRender);
|
|
const Providers = getProviders();
|
|
const [stateProps, setStateProps] = React.useState(props);
|
|
|
|
const updateProp = (key, value) => {
|
|
setStateProps(prev => ({
|
|
...prev,
|
|
[key]: value,
|
|
}));
|
|
};
|
|
|
|
// Extract children from props (label, children, text, content)
|
|
// When sampleChildren is explicitly provided, skip extraction so all props
|
|
// (like 'content') stay as component props rather than becoming children
|
|
const { children: propsChildren, restProps } = sampleChildren
|
|
? { children: null, restProps: stateProps }
|
|
: extractChildren(stateProps);
|
|
// Filter out undefined values so they don't override component defaults
|
|
const filteredProps = Object.fromEntries(
|
|
Object.entries(restProps).filter(([, v]) => v !== undefined)
|
|
);
|
|
|
|
// Resolve any prop values that are component descriptors
|
|
// e.g., { component: 'Button', props: { children: 'Click' } }
|
|
// Also resolves descriptors nested inside array items:
|
|
// e.g., items: [{ id: 'x', element: { component: 'div', props: { children: 'text' } } }]
|
|
Object.keys(filteredProps).forEach(key => {
|
|
const value = filteredProps[key];
|
|
if (value && typeof value === 'object' && !Array.isArray(value) && value.component) {
|
|
const PropComponent = resolveComponent(value.component);
|
|
if (PropComponent) {
|
|
filteredProps[key] = <PropComponent {...value.props} />;
|
|
}
|
|
}
|
|
if (Array.isArray(value)) {
|
|
filteredProps[key] = value.map((item, idx) => {
|
|
if (item && typeof item === 'object') {
|
|
const resolved = { ...item };
|
|
Object.keys(resolved).forEach(field => {
|
|
const fieldValue = resolved[field];
|
|
if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue) && fieldValue.component) {
|
|
const FieldComponent = resolveComponent(fieldValue.component);
|
|
if (FieldComponent) {
|
|
resolved[field] = React.createElement(FieldComponent, { key: `${key}-${idx}`, ...fieldValue.props });
|
|
}
|
|
}
|
|
});
|
|
return resolved;
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
});
|
|
|
|
// For List-like components with dataSource but no renderItem, provide a default
|
|
if (filteredProps.dataSource && !filteredProps.renderItem) {
|
|
const ListItem = resolveComponent('List')?.Item;
|
|
filteredProps.renderItem = (item) =>
|
|
ListItem
|
|
? React.createElement(ListItem, null, String(item))
|
|
: React.createElement('div', null, String(item));
|
|
}
|
|
|
|
// Use sample children if provided, otherwise use props children
|
|
const children = generateSampleChildren(sampleChildren, sampleChildrenStyle) || propsChildren;
|
|
|
|
// For components with a trigger (like Modal with show/onHide), add handlers.
|
|
// onHideProp supports comma-separated names for components with multiple close
|
|
// callbacks (e.g., "onHide,handleSave,onConfirmNavigation").
|
|
const triggerProps = {};
|
|
if (triggerProp && onHideProp) {
|
|
const closeHandler = () => updateProp(triggerProp, false);
|
|
onHideProp.split(',').forEach(prop => {
|
|
triggerProps[prop.trim()] = closeHandler;
|
|
});
|
|
}
|
|
|
|
// Get the Button component for trigger buttons
|
|
const ButtonComponent = resolveComponent('Button');
|
|
|
|
return (
|
|
<Providers>
|
|
<div className="storybook-with-controls">
|
|
<div
|
|
className="storybook-example"
|
|
style={{
|
|
border: '1px solid #e8e8e8',
|
|
borderRadius: '4px',
|
|
padding: '20px',
|
|
marginBottom: '20px',
|
|
position: 'relative', // Required for portal positioning
|
|
}}
|
|
>
|
|
{Component ? (
|
|
<>
|
|
{/* Show a trigger button for components like Modal */}
|
|
{triggerProp && ButtonComponent && (
|
|
<ButtonComponent onClick={() => updateProp(triggerProp, true)}>
|
|
Open {component}
|
|
</ButtonComponent>
|
|
)}
|
|
<Component {...filteredProps} {...triggerProps}>{children}</Component>
|
|
</>
|
|
) : (
|
|
<div style={{ color: '#999' }}>
|
|
Component "{String(componentToRender)}" not found
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{controls.length > 0 && (
|
|
<div
|
|
className="storybook-controls"
|
|
style={{
|
|
border: '1px solid #e8e8e8',
|
|
borderRadius: '4px',
|
|
padding: '20px',
|
|
marginBottom: '20px',
|
|
}}
|
|
>
|
|
<h4>Controls</h4>
|
|
{controls.map(control => (
|
|
<div key={control.name} style={{ marginBottom: '10px' }}>
|
|
<label style={{ display: 'block', marginBottom: '5px' }}>
|
|
{control.label || control.name}:
|
|
</label>
|
|
{control.type === 'select' ? (
|
|
<select
|
|
value={stateProps[control.name] ?? ''}
|
|
onChange={e => updateProp(control.name, e.target.value || undefined)}
|
|
style={{ width: '100%', padding: '5px' }}
|
|
>
|
|
<option value="">— None —</option>
|
|
{control.options?.map(option => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : control.type === 'inline-radio' || control.type === 'radio' ? (
|
|
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
|
{control.options?.map(option => (
|
|
<label
|
|
key={option}
|
|
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name={control.name}
|
|
value={option}
|
|
checked={stateProps[control.name] === option}
|
|
onChange={e => updateProp(control.name, e.target.value)}
|
|
/>
|
|
{option}
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : control.type === 'boolean' ? (
|
|
<input
|
|
type="checkbox"
|
|
checked={stateProps[control.name]}
|
|
onChange={e => updateProp(control.name, e.target.checked)}
|
|
/>
|
|
) : control.type === 'number' ? (
|
|
<input
|
|
type="number"
|
|
value={stateProps[control.name]}
|
|
onChange={e => updateProp(control.name, Number(e.target.value))}
|
|
style={{ width: '100%', padding: '5px' }}
|
|
/>
|
|
) : control.type === 'color' ? (
|
|
<input
|
|
type="color"
|
|
value={stateProps[control.name] || '#000000'}
|
|
onChange={e => updateProp(control.name, e.target.value)}
|
|
style={{
|
|
width: '50px',
|
|
height: '30px',
|
|
padding: '2px',
|
|
cursor: 'pointer',
|
|
}}
|
|
/>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={stateProps[control.name] ?? ''}
|
|
onChange={e => updateProp(control.name, e.target.value)}
|
|
style={{ width: '100%', padding: '5px' }}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Providers>
|
|
);
|
|
}
|
|
|
|
// A simple component to display a story with controls
|
|
// renderComponent: optional override for which component to render (e.g., 'Icons.InfoCircleOutlined' when component='Icons')
|
|
// triggerProp/onHideProp: for components like Modal that need a button to open (e.g., triggerProp="show", onHideProp="onHide")
|
|
export function StoryWithControls({ component: Component, renderComponent, props = {}, controls = [], sampleChildren, sampleChildrenStyle, triggerProp, onHideProp }) {
|
|
return (
|
|
<BrowserOnly fallback={<LoadingPlaceholder />}>
|
|
{() => (
|
|
<StoryWithControlsInner
|
|
component={Component}
|
|
renderComponent={renderComponent}
|
|
props={props}
|
|
controls={controls}
|
|
sampleChildren={sampleChildren}
|
|
sampleChildrenStyle={sampleChildrenStyle}
|
|
triggerProp={triggerProp}
|
|
onHideProp={onHideProp}
|
|
/>
|
|
)}
|
|
</BrowserOnly>
|
|
);
|
|
}
|
|
|
|
// Inner component for ComponentGallery (browser-only)
|
|
function ComponentGalleryInner({ component, sizes, styles, sizeProp, styleProp }) {
|
|
const Component = resolveComponent(component);
|
|
const Providers = getProviders();
|
|
|
|
if (!Component) {
|
|
return (
|
|
<div style={{ color: '#999' }}>
|
|
Component "{String(component)}" not found
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Providers>
|
|
<div className="component-gallery">
|
|
{sizes.map(size => (
|
|
<div key={size} style={{ marginBottom: 40 }}>
|
|
<h4 style={{ marginBottom: 16, color: '#666' }}>{size}</h4>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
|
|
{styles.map(style => (
|
|
<Component
|
|
key={`${style}_${size}`}
|
|
{...{ [sizeProp]: size, [styleProp]: style }}
|
|
>
|
|
{style}
|
|
</Component>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Providers>
|
|
);
|
|
}
|
|
|
|
// A component to display a gallery of all variants (sizes x styles)
|
|
export function ComponentGallery({ component, sizes = [], styles = [], sizeProp = 'size', styleProp = 'variant' }) {
|
|
return (
|
|
<BrowserOnly fallback={<LoadingPlaceholder />}>
|
|
{() => (
|
|
<ComponentGalleryInner
|
|
component={component}
|
|
sizes={sizes}
|
|
styles={styles}
|
|
sizeProp={sizeProp}
|
|
styleProp={styleProp}
|
|
/>
|
|
)}
|
|
</BrowserOnly>
|
|
);
|
|
}
|