From 046cee39e5fcd8ae9b0cb7ed48ec329748fa2b49 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 7 Jul 2026 00:39:40 +0500 Subject: [PATCH 1/2] add import/export theme --- .../packages/lowcoder/src/i18n/locales/en.ts | 10 ++ .../src/pages/setting/theme/ThemeImport.tsx | 38 ++++++ .../src/pages/setting/theme/detail/index.tsx | 17 +++ .../src/pages/setting/theme/themeConstant.tsx | 1 + .../pages/setting/theme/themeImportExport.ts | 129 ++++++++++++++++++ .../src/pages/setting/theme/themeList.tsx | 4 + .../src/pages/setting/theme/themePage.tsx | 75 ++++++++-- 7 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 client/packages/lowcoder/src/pages/setting/theme/ThemeImport.tsx create mode 100644 client/packages/lowcoder/src/pages/setting/theme/themeImportExport.ts diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index ee3b6a47d5..9882eea560 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -3451,6 +3451,16 @@ export const en = { "cancelDefaultTheme": "Unset Default Theme", "setDefaultTheme": "Set as Default Theme", "copyTheme": "Duplicate Theme", + "exportTheme": "Export Theme", + "importTheme": "Import Theme", + "importSuccessMsg": "Theme Imported Successfully", + "importError": "Failed to Import Theme: {{message}}", + "importParseError": "Invalid Theme File: Could Not Parse JSON", + "importFormatError": "Invalid Theme File: Missing Required Theme Data", + "importFileError": "Failed to Read Theme File", + "importNameRequired": "Theme Name Is Required", + "importSuffix": " Import", + "importDefaultName": "Imported Theme", "setSuccessMsg": "Setting Succeeded", "cancelSuccessMsg": "Unsetting Succeeded", "deleteSuccessMsg": "Deletion Succeeded", diff --git a/client/packages/lowcoder/src/pages/setting/theme/ThemeImport.tsx b/client/packages/lowcoder/src/pages/setting/theme/ThemeImport.tsx new file mode 100644 index 0000000000..75e3d10ee6 --- /dev/null +++ b/client/packages/lowcoder/src/pages/setting/theme/ThemeImport.tsx @@ -0,0 +1,38 @@ +import { default as AntUpload } from "antd/es/upload"; +import React from "react"; +import styled from "styled-components"; + +const Upload = styled(AntUpload)` + .ant-upload-wrapper .ant-upload-select { + display: block; + } +`; + +type ThemeImportProps = { + children: React.ReactNode; + disabled?: boolean; + onImport: (file: File) => void | Promise; +}; + +export function ThemeImport(props: ThemeImportProps) { + const { children, disabled, onImport } = props; + + return ( + { + try { + await onImport(file as File); + onSuccess?.("ok"); + } catch (error) { + onError?.(error as Error); + } + }} + multiple={false} + > + {children} + + ); +} diff --git a/client/packages/lowcoder/src/pages/setting/theme/detail/index.tsx b/client/packages/lowcoder/src/pages/setting/theme/detail/index.tsx index 476173e912..4439112cf4 100644 --- a/client/packages/lowcoder/src/pages/setting/theme/detail/index.tsx +++ b/client/packages/lowcoder/src/pages/setting/theme/detail/index.tsx @@ -44,6 +44,7 @@ import { Card, Divider, Flex, List, Tooltip } from 'antd'; import { ThemeCompPanel } from "pages/setting/theme/ThemeCompPanel"; import { JSONObject } from "@lowcoder-ee/util/jsonTypes"; +import { exportThemeAsJSONFile } from "../themeImportExport"; const ThemeSettingsView = styled.div` font-size: 14px; @@ -188,6 +189,18 @@ class ThemeDetailPage extends React.Component { + if (!this.state.name || !this.state.theme) { + return; + } + exportThemeAsJSONFile({ + name: this.state.name, + id: this.id, + updateTime: Date.now(), + theme: this.state.theme, + }); + }; + render() { const colorSettings = [ @@ -891,10 +904,14 @@ class ThemeDetailPage extends React.Component {trans("reset")} + + {trans("theme.exportTheme")} + this.handleSave()} + style={{ marginLeft: "auto" }} > {trans("theme.saveBtn")} diff --git a/client/packages/lowcoder/src/pages/setting/theme/themeConstant.tsx b/client/packages/lowcoder/src/pages/setting/theme/themeConstant.tsx index b0a15315af..69269c7190 100644 --- a/client/packages/lowcoder/src/pages/setting/theme/themeConstant.tsx +++ b/client/packages/lowcoder/src/pages/setting/theme/themeConstant.tsx @@ -8,6 +8,7 @@ export enum MENU_TYPE { COPY = "copy", EDIT = "edit", RENAME = "rename", + EXPORT = "export", } export enum DETAIL_TYPE { diff --git a/client/packages/lowcoder/src/pages/setting/theme/themeImportExport.ts b/client/packages/lowcoder/src/pages/setting/theme/themeImportExport.ts new file mode 100644 index 0000000000..47745269a2 --- /dev/null +++ b/client/packages/lowcoder/src/pages/setting/theme/themeImportExport.ts @@ -0,0 +1,129 @@ +import { ThemeDetail, ThemeType } from "api/commonSettingApi"; +import { genQueryId } from "comps/utils/idGenerator"; +import { trans } from "i18n"; + +export const THEME_EXPORT_VERSION = 1; +export const THEME_EXPORT_TYPE = "lowcoder-theme"; + +export interface ThemeExportPayload { + version: number; + type: typeof THEME_EXPORT_TYPE; + theme: ThemeType; +} + +function isThemeDetail(value: unknown): value is ThemeDetail { + return typeof value === "object" && value !== null; +} + +function isThemeType(value: unknown): value is ThemeType { + if (typeof value !== "object" || value === null) { + return false; + } + const theme = value as ThemeType; + return typeof theme.name === "string" && isThemeDetail(theme.theme); +} + +export function parseThemeImport(content: string): ThemeType { + let data: unknown; + try { + data = JSON.parse(content); + } catch { + throw new Error(trans("theme.importParseError")); + } + + if (typeof data !== "object" || data === null) { + throw new Error(trans("theme.importFormatError")); + } + + const payload = data as Record; + + if (payload.type === THEME_EXPORT_TYPE && isThemeType(payload.theme)) { + return payload.theme; + } + + if (isThemeType(data)) { + return data; + } + + throw new Error(trans("theme.importFormatError")); +} + +export function resolveUniqueThemeName(baseName: string, existingNames: Set): string { + const trimmed = baseName.trim(); + if (!trimmed) { + throw new Error(trans("theme.importNameRequired")); + } + if (!existingNames.has(trimmed)) { + return trimmed; + } + + const importSuffix = trans("theme.importSuffix"); + let candidate = trimmed + importSuffix; + if (!existingNames.has(candidate)) { + return candidate; + } + + let index = 1; + while (existingNames.has(candidate + index)) { + index += 1; + } + return candidate + index; +} + +export function prepareImportedTheme( + source: ThemeType, + existingThemes: ThemeType[], + fallbackName?: string +): ThemeType { + const existingNames = new Set(existingThemes.map((theme) => theme.name)); + const name = resolveUniqueThemeName( + source.name || fallbackName || trans("theme.importDefaultName"), + existingNames + ); + + return { + name, + id: genQueryId(), + updateTime: Date.now(), + theme: source.theme, + }; +} + +export function exportThemeAsJSONFile(theme: ThemeType) { + const exportObj: ThemeExportPayload = { + version: THEME_EXPORT_VERSION, + type: THEME_EXPORT_TYPE, + theme, + }; + + const link = document.createElement("a"); + const blob = new Blob([JSON.stringify(exportObj, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + link.href = url; + const safeName = theme.name.replace(/[^\w\s-]/g, "").trim() || "theme"; + link.download = `${safeName}.json`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +export function readThemeFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsText(file, "UTF-8"); + reader.onload = (event) => { + try { + if (!event.target?.result) { + throw new Error(trans("theme.importFileError")); + } + resolve(parseThemeImport(event.target.result.toString())); + } catch (error) { + reject(error); + } + }; + reader.onerror = () => reject(new Error(trans("theme.importFileError"))); + }); +} diff --git a/client/packages/lowcoder/src/pages/setting/theme/themeList.tsx b/client/packages/lowcoder/src/pages/setting/theme/themeList.tsx index fe1e5ed314..cd27fd3c78 100644 --- a/client/packages/lowcoder/src/pages/setting/theme/themeList.tsx +++ b/client/packages/lowcoder/src/pages/setting/theme/themeList.tsx @@ -187,6 +187,10 @@ function ThemeList(props: ThemeListProp) { label: trans("theme.copyTheme"), key: MENU_TYPE.COPY, }, + { + label: trans("theme.exportTheme"), + key: MENU_TYPE.EXPORT, + }, { label: trans("delete"), key: MENU_TYPE.DELETE, diff --git a/client/packages/lowcoder/src/pages/setting/theme/themePage.tsx b/client/packages/lowcoder/src/pages/setting/theme/themePage.tsx index d5976a94a4..9d72e0fa71 100644 --- a/client/packages/lowcoder/src/pages/setting/theme/themePage.tsx +++ b/client/packages/lowcoder/src/pages/setting/theme/themePage.tsx @@ -19,6 +19,20 @@ import { genQueryId } from "comps/utils/idGenerator"; import { trans } from "i18n"; import { Level1SettingPageTitleWithBtn } from "../styled"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; +import { ThemeImport } from "./ThemeImport"; +import { + exportThemeAsJSONFile, + prepareImportedTheme, + readThemeFile, +} from "./themeImportExport"; +import { default as Button } from "antd/es/button"; +import styled from "styled-components"; + +const HeaderActions = styled.div` + display: flex; + gap: 8px; + align-items: center; +`; type ThemeProps = { setCommonSettings: (params: SetCommonSettingPayload) => void; @@ -142,6 +156,13 @@ class ThemePage extends React.Component { case MENU_TYPE.EDIT: history.push(`${THEME_DETAIL}/${info.themeId}`) break; + case MENU_TYPE.EXPORT: { + const theme = this.props.themeList?.find((item) => item.id === info.themeId); + if (theme) { + exportThemeAsJSONFile(theme); + } + break; + } case MENU_TYPE.RENAME: this.setCommonSettings( "themeList", @@ -160,6 +181,31 @@ class ThemePage extends React.Component { } } + importTheme = async (file: File) => { + try { + const parsed = await readThemeFile(file); + const fallbackName = file.name?.split(".").slice(0, -1).join("."); + this.props.fetchCommonSettings(this.props.orgId, ({ themeList }) => { + const imported = prepareImportedTheme(parsed, themeList || [], fallbackName); + const list = [...(themeList || []), imported]; + this.props.setCommonSettings({ + orgId: this.props.orgId, + data: { + key: "themeList", + value: list, + }, + onSuccess: () => { + messageInstance.success(trans("theme.importSuccessMsg")); + this.props.fetchCommonSettings(this.props.orgId); + }, + }); + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + messageInstance.error(trans("theme.importError", { message })); + } + }; + render() { const { defaultTheme, isAdmin, themeList } = this.props; @@ -167,18 +213,23 @@ class ThemePage extends React.Component { {trans("theme.title")} - } - onClick={() => - this.setState({ - modalVisible: true, - }) - } - > - {trans("theme.createTheme")} - + + + + + } + onClick={() => + this.setState({ + modalVisible: true, + }) + } + > + {trans("theme.createTheme")} + + Date: Tue, 7 Jul 2026 22:56:50 +0500 Subject: [PATCH 2/2] fix theme footer styles, and infinite rerendering issue --- .../packages/lowcoder/src/i18n/locales/en.ts | 2 +- .../src/pages/setting/theme/detail/index.tsx | 23 +++++-- .../pages/setting/theme/styledComponents.tsx | 60 +++++++++---------- .../pages/setting/theme/themeImportExport.ts | 17 ++---- 4 files changed, 52 insertions(+), 50 deletions(-) diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 9882eea560..aad9de625c 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -3454,7 +3454,7 @@ export const en = { "exportTheme": "Export Theme", "importTheme": "Import Theme", "importSuccessMsg": "Theme Imported Successfully", - "importError": "Failed to Import Theme: {{message}}", + "importError": "Failed to Import Theme: {message}", "importParseError": "Invalid Theme File: Could Not Parse JSON", "importFormatError": "Invalid Theme File: Missing Required Theme Data", "importFileError": "Failed to Read Theme File", diff --git a/client/packages/lowcoder/src/pages/setting/theme/detail/index.tsx b/client/packages/lowcoder/src/pages/setting/theme/detail/index.tsx index 4439112cf4..de7df675ce 100644 --- a/client/packages/lowcoder/src/pages/setting/theme/detail/index.tsx +++ b/client/packages/lowcoder/src/pages/setting/theme/detail/index.tsx @@ -45,6 +45,7 @@ import { Card, Divider, Flex, List, Tooltip } from 'antd'; import { ThemeCompPanel } from "pages/setting/theme/ThemeCompPanel"; import { JSONObject } from "@lowcoder-ee/util/jsonTypes"; import { exportThemeAsJSONFile } from "../themeImportExport"; +import _ from "lodash"; const ThemeSettingsView = styled.div` font-size: 14px; @@ -130,7 +131,7 @@ class ThemeDetailPage extends React.Component v !== undefined); + + if (_.isEqual(this.state.theme[themeSettingKey as keyof typeof this.state.theme], value)) { + return; + } + this.setState({ theme: { ...this.state.theme, - [params.themeSettingKey]: params.color || params.radius || params.chart || params.margin || params.padding || params.borderWidth || params.borderStyle || params.fontFamily || params.showComponentLoadingIndicators || params.showDataLoadingIndicators || params.dataLoadingIndicator || params.gridColumns || params.gridRowHeight || params.gridRowCount || params.gridPaddingX || params.gridPaddingY || params.gridBgImage || params.gridBgImageRepeat || params.gridBgImageSize || params.gridBgImagePosition || params.gridBgImageOrigin, + [themeSettingKey]: value, }, }); } @@ -904,17 +912,20 @@ class ThemeDetailPage extends React.Component {trans("reset")} - - {trans("theme.exportTheme")} - this.handleSave()} - style={{ marginLeft: "auto" }} > {trans("theme.saveBtn")} + + {trans("theme.exportTheme")} + diff --git a/client/packages/lowcoder/src/pages/setting/theme/styledComponents.tsx b/client/packages/lowcoder/src/pages/setting/theme/styledComponents.tsx index 42513c1434..71850d421a 100644 --- a/client/packages/lowcoder/src/pages/setting/theme/styledComponents.tsx +++ b/client/packages/lowcoder/src/pages/setting/theme/styledComponents.tsx @@ -124,7 +124,7 @@ export const Footer = styled.div` padding: 24px; position: fixed; bottom: 0; - width: calc(100vw - 492px); + width: calc(100% - 520px); background-color: #fff; z-index: 1000; margin-right: 10px; @@ -659,44 +659,43 @@ export const CustomModalStyled = styled(CustomModal)` } `; -export const Margin = styled.div<{ $margin: string }>` -> div { - margin: 3px; - overflow: hidden; - > svg { - fill: currentColor; - } - } -} -`; -export const Padding = styled.div<{ $padding: string }>` -> div { - margin: 3px; - overflow: hidden; - > svg { - fill: currentColor; - } - } -}` +export const Margin = styled.div<{ $margin: string }>` + > div { + margin: 3px; + overflow: hidden; + > svg { + fill: currentColor; + } + } +`; + +export const Padding = styled.div<{ $padding: string }>` + > div { + margin: 3px; + overflow: hidden; + > svg { + fill: currentColor; + } + } +`; + // Added By Aqib Mirza -export const GridColumns = styled.div<{ $gridColumns: string }>` +export const GridColumns = styled.div<{ $gridColumns: string }>` > div { margin: 3px; overflow: hidden; - > svg { - fill: currentColor; - } + > svg { + fill: currentColor; } } `; -export const BorderStyle = styled.div<{ $borderStyle: string }>` +export const BorderStyle = styled.div<{ $borderStyle: string }>` > div { margin: 3px; overflow: hidden; - > svg { - fill: currentColor; - } + > svg { + fill: currentColor; } } `; @@ -705,9 +704,8 @@ export const BorderWidth = styled.div<{ $borderWidth: string }>` > div { margin: 3px; overflow: hidden; - > svg { - fill: currentColor; - } + > svg { + fill: currentColor; } } `; diff --git a/client/packages/lowcoder/src/pages/setting/theme/themeImportExport.ts b/client/packages/lowcoder/src/pages/setting/theme/themeImportExport.ts index 47745269a2..6c949bdd9a 100644 --- a/client/packages/lowcoder/src/pages/setting/theme/themeImportExport.ts +++ b/client/packages/lowcoder/src/pages/setting/theme/themeImportExport.ts @@ -1,6 +1,7 @@ import { ThemeDetail, ThemeType } from "api/commonSettingApi"; import { genQueryId } from "comps/utils/idGenerator"; import { trans } from "i18n"; +import { saveDataAsFile } from "util/fileUtils"; export const THEME_EXPORT_VERSION = 1; export const THEME_EXPORT_TYPE = "lowcoder-theme"; @@ -95,19 +96,11 @@ export function exportThemeAsJSONFile(theme: ThemeType) { type: THEME_EXPORT_TYPE, theme, }; - - const link = document.createElement("a"); - const blob = new Blob([JSON.stringify(exportObj, null, 2)], { - type: "application/json", + void saveDataAsFile({ + data: exportObj, + filename: `${theme.name}.json`, + fileType: "json", }); - const url = URL.createObjectURL(blob); - link.href = url; - const safeName = theme.name.replace(/[^\w\s-]/g, "").trim() || "theme"; - link.download = `${safeName}.json`; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); } export function readThemeFile(file: File): Promise {