This is an automated email from the ASF dual-hosted git repository. maximebeauchemin pushed a commit to branch template_less_preset_theme_editor in repository https://gitbox.apache.org/repos/asf/superset.git
commit 07a29c7aebcd0fde7ec8e775d1b8725b66326579 Author: Maxime Beauchemin <[email protected]> AuthorDate: Sun Mar 30 21:49:06 2025 -0700 A fancy ThemeEditor for Preset --- .../src/components/ThemeEditor/index.tsx | 334 ++++++++++++++------- 1 file changed, 232 insertions(+), 102 deletions(-) diff --git a/superset-frontend/src/components/ThemeEditor/index.tsx b/superset-frontend/src/components/ThemeEditor/index.tsx index 2c819393d1..cba265c10a 100644 --- a/superset-frontend/src/components/ThemeEditor/index.tsx +++ b/superset-frontend/src/components/ThemeEditor/index.tsx @@ -3,7 +3,7 @@ * 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 + * 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 * @@ -16,132 +16,262 @@ * specific language governing permissions and limitations * under the License. */ -import { Modal, Tooltip, Flex, Select } from 'antd-v5'; -import Button from 'src/components/Button'; -import { - themeObject, - exampleThemes, - SerializableThemeConfig, - SupersetTheme, -} from '@superset-ui/core'; import { useState } from 'react'; +import { + Modal, + Tooltip, + Card, + Input, + InputNumber, + Typography, + Form, + Collapse, + Switch, + Checkbox, + ColorPicker, +} from 'antd-v5'; +import Button from 'src/components/Button'; import { Icons } from 'src/components/Icons'; import { JsonEditor } from 'src/components/AsyncAceEditor'; +import { themeObject, t } from '@superset-ui/core'; +import { mergeWith } from 'lodash'; -interface ThemeEditorProps { - initialTheme?: SupersetTheme; - tooltipTitle?: string; - modalTitle?: string; -} - -const ThemeEditor: React.FC<ThemeEditorProps> = ({ - initialTheme = {}, - tooltipTitle = 'Edit Theme', - modalTitle = 'Theme Editor', -}) => { - const [isModalOpen, setIsModalOpen] = useState<boolean>(false); - const jsonTheme: string = themeObject.json(); - const [jsonMetadata, setJsonMetadata] = useState<string>(jsonTheme); - const [selectedTheme, setSelectedTheme] = useState<string | null>(null); +const { Title } = Typography; +const { Panel } = Collapse; - // Get theme names for the Select options - const themeOptions: { value: string; label: string }[] = Object.keys( - exampleThemes, - ).map(key => ({ - value: key, - label: key, - })); +// Manually curated list of seed tokens (excluding animation-related ones) +const seedTokenCategories: Record< + string, + { token: string; type: 'color' | 'number' | 'string' }[] +> = { + Colors: [ + { token: 'colorPrimary', type: 'color' }, + { token: 'colorSuccess', type: 'color' }, + { token: 'colorWarning', type: 'color' }, + { token: 'colorError', type: 'color' }, + { token: 'colorInfo', type: 'color' }, + { token: 'colorBgBase', type: 'color' }, + ], + Typography: [ + { token: 'fontFamily', type: 'string' }, + { token: 'fontFamilyCode', type: 'string' }, + { token: 'fontSize', type: 'number' }, + { token: 'fontWeightStrong', type: 'number' }, + { token: 'lineHeight', type: 'number' }, + ], + Layout: [ + { token: 'borderRadius', type: 'number' }, + { token: 'sizeUnit', type: 'number' }, + { token: 'controlHeight', type: 'number' }, + { token: 'zIndexBase', type: 'number' }, + { token: 'zIndexPopupBase', type: 'number' }, + ], +}; - const handleOpenModal = (): void => { - setIsModalOpen(true); - }; +export default function ThemeEditor() { + const initialTheme = themeObject.toSerializedConfig(); + const filteredKeys = Object.values(seedTokenCategories) + .flat() + .map(entry => entry.token); + const initialTokens = filteredKeys.reduce( + (acc, key) => { + acc[key] = themeObject.theme[key]; + return acc; + }, + {} as Record<string, any>, + ); - const handleCancel = (): void => { - setIsModalOpen(false); - }; + const { algorithm } = initialTheme; + const [tokens, setTokens] = useState<Record<string, any>>(initialTokens); + const [jsonOverrides, setJsonOverrides] = useState<string>('{}'); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDark, setIsDark] = useState(algorithm?.includes('dark')); + const [isCompact, setIsCompact] = useState(algorithm?.includes('compact')); - const handleSave = (): void => { + const getMergedTheme = () => { try { - const parsedTheme = JSON.parse(jsonMetadata); - console.log('Parsed theme:', parsedTheme); - themeObject.setConfig(parsedTheme); - setIsModalOpen(false); - } catch (error) { - console.error('Invalid JSON in theme editor:', error); - alert('Error parsing JSON. Please check your input.'); + const overrides = JSON.parse(jsonOverrides); + const merged = mergeWith({}, tokens, overrides); + merged.algorithm = [ + isDark ? 'dark' : 'default', + ...(isCompact ? ['compact'] : []), + ]; + return merged; + } catch (e) { + return tokens; } }; - const handleThemeChange = (value: string): void => { - setSelectedTheme(value); - // When a theme is selected, update the JSON editor with the theme definition - const themeData = exampleThemes[value] || ({} as SerializableThemeConfig); - setJsonMetadata(JSON.stringify(themeData, null, 2)); - }; - return ( <> - <Tooltip title={tooltipTitle} placement="bottom"> + <Tooltip title={t('Edit Theme')} placement="bottom"> <Button buttonStyle="link" - icon={ - <Icons.BgColorsOutlined - iconSize="l" - iconColor={themeObject.theme.colorPrimary} - /> - } - onClick={handleOpenModal} - aria-label="Edit theme" + icon={<Icons.BgColorsOutlined iconSize="l" />} + onClick={() => setIsModalOpen(true)} + aria-label={t('Edit theme')} size="large" /> </Tooltip> <Modal - title={modalTitle} + title={t('Theme Editor')} open={isModalOpen} - onCancel={handleCancel} + onCancel={() => setIsModalOpen(false)} + onOk={() => setIsModalOpen(false)} width={800} centered - styles={{ - body: { - padding: '24px', - }, - }} - footer={ - <Flex justify="end" gap="small"> - <Button onClick={handleCancel} buttonStyle="secondary"> - Cancel - </Button> - <Button type="primary" onClick={handleSave}> - Apply Theme - </Button> - </Flex> - } > - <Flex vertical gap="middle"> - <div> - Select a theme template: - <Select - placeholder="Choose a theme" - style={{ width: '100%', marginTop: '8px' }} - options={themeOptions} - onChange={handleThemeChange} - value={selectedTheme} - /> - </div> - <JsonEditor - showLoadingForImport - name="json_metadata" - value={jsonMetadata} - onChange={setJsonMetadata} - tabSize={2} - width="100%" - height="200px" - wrapEnabled - /> - </Flex> + <Collapse defaultActiveKey={['algorithms', 'Colors']}> + <Panel header={t('Algorithms')} key="algorithms"> + <Form layout="horizontal"> + <Form.Item label={t('Dark Mode')}> + <Switch + checked={isDark} + onChange={setIsDark} + checkedChildren={t('Dark')} + unCheckedChildren={t('Light')} + /> + </Form.Item> + <Form.Item label={t('Compact Mode')}> + <Checkbox + checked={isCompact} + onChange={e => setIsCompact(e.target.checked)} + /> + </Form.Item> + </Form> + </Panel> + + {Object.entries(seedTokenCategories).map(([section, items]) => ( + <Panel header={t(section)} key={section}> + <ThemeSection + layout={section === 'Colors' ? 'horizontal' : 'default'} + > + {items.map(({ token, type }) => ( + <ThemeToken + key={token} + token={token} + type={type} + tokens={tokens} + setTokens={setTokens} + /> + ))} + </ThemeSection> + </Panel> + ))} + + <Panel header={t('Raw JSON Overrides')} key="overrides"> + <Card bodyStyle={{ padding: 0 }}> + <JsonEditor + showLoadingForImport + name="json_overrides" + value={jsonOverrides} + onChange={setJsonOverrides} + tabSize={2} + width="100%" + height="200px" + wrapEnabled + /> + </Card> + </Panel> + + <Panel header={t('Resolved Theme Output')} key="resolved"> + <Card bodyStyle={{ padding: 12 }}> + <pre style={{ whiteSpace: 'pre-wrap', fontSize: 12 }}> + {JSON.stringify(getMergedTheme(), null, 2)} + </pre> + </Card> + </Panel> + </Collapse> </Modal> </> ); -}; +} +function ThemeSection({ + children, + layout, +}: { + children: React.ReactNode; + layout?: 'horizontal' | 'default'; +}) { + return layout === 'horizontal' ? ( + <Form layout="horizontal"> + <div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}> + {children} + </div> + </Form> + ) : ( + <Form layout="vertical"> + <div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}> + {children} + </div> + </Form> + ); +} + +function ThemeToken({ + token, + type, + tokens, + setTokens, +}: { + token: string; + type: 'color' | 'number' | 'string'; + tokens: Record<string, any>; + setTokens: React.Dispatch<React.SetStateAction<Record<string, any>>>; +}) { + const initialValue = tokens[token]; + const [value, setValue] = useState(initialValue); + + const handleChange = (val: any) => { + const normalized = + type === 'number' + ? typeof val === 'number' && !Number.isNaN(val) + ? val + : null + : val; + setValue(normalized); + setTokens(prev => ({ ...prev, [token]: normalized })); + }; + + const renderInput = () => { + switch (type) { + case 'color': + return ( + <ColorPicker + value={value} + onChange={(_, hex) => handleChange(hex)} + format="hex" + /> + ); + case 'number': + return ( + <InputNumber + style={{ width: '100%' }} + value={value} + onChange={handleChange} + /> + ); + case 'string': + default: + return ( + <Input value={value} onChange={e => handleChange(e.target.value)} /> + ); + } + }; -export default ThemeEditor; + const width = + type === 'string' + ? token === 'fontFamily' || token === 'fontFamilyCode' + ? 280 + : 240 + : 160; + + return ( + <div style={{ width, marginBottom: 0 }}> + <label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}> + {token} + </label> + {renderInput()} + </div> + ); +}
