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>
+  );
+}

Reply via email to