Skip to content

Commit 936a54b

Browse files
Merge pull request #2166 from iamfaran/feat/theme-import
[Feat]: Theme import/export
2 parents 06e1ca9 + 69fb4aa commit 936a54b

8 files changed

Lines changed: 297 additions & 45 deletions

File tree

‎client/packages/lowcoder/src/i18n/locales/en.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3852,6 +3852,16 @@ export const en = {
38523852
"cancelDefaultTheme": "Unset Default Theme",
38533853
"setDefaultTheme": "Set as Default Theme",
38543854
"copyTheme": "Duplicate Theme",
3855+
"exportTheme": "Export Theme",
3856+
"importTheme": "Import Theme",
3857+
"importSuccessMsg": "Theme Imported Successfully",
3858+
"importError": "Failed to Import Theme: {message}",
3859+
"importParseError": "Invalid Theme File: Could Not Parse JSON",
3860+
"importFormatError": "Invalid Theme File: Missing Required Theme Data",
3861+
"importFileError": "Failed to Read Theme File",
3862+
"importNameRequired": "Theme Name Is Required",
3863+
"importSuffix": " Import",
3864+
"importDefaultName": "Imported Theme",
38553865
"setSuccessMsg": "Setting Succeeded",
38563866
"cancelSuccessMsg": "Unsetting Succeeded",
38573867
"deleteSuccessMsg": "Deletion Succeeded",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { default as AntUpload } from "antd/es/upload";
2+
import React from "react";
3+
import styled from "styled-components";
4+
5+
const Upload = styled(AntUpload)`
6+
.ant-upload-wrapper .ant-upload-select {
7+
display: block;
8+
}
9+
`;
10+
11+
type ThemeImportProps = {
12+
children: React.ReactNode;
13+
disabled?: boolean;
14+
onImport: (file: File) => void | Promise<void>;
15+
};
16+
17+
export function ThemeImport(props: ThemeImportProps) {
18+
const { children, disabled, onImport } = props;
19+
20+
return (
21+
<Upload
22+
accept=".json,application/json"
23+
showUploadList={false}
24+
disabled={disabled}
25+
customRequest={async ({ file, onSuccess, onError }) => {
26+
try {
27+
await onImport(file as File);
28+
onSuccess?.("ok");
29+
} catch (error) {
30+
onError?.(error as Error);
31+
}
32+
}}
33+
multiple={false}
34+
>
35+
{children}
36+
</Upload>
37+
);
38+
}

‎client/packages/lowcoder/src/pages/setting/theme/detail/index.tsx‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import { Card, Divider, Flex, List, Tooltip } from 'antd';
4444

4545
import { ThemeCompPanel } from "pages/setting/theme/ThemeCompPanel";
4646
import { JSONObject } from "@lowcoder-ee/util/jsonTypes";
47+
import { exportThemeAsJSONFile } from "../themeImportExport";
48+
import _ from "lodash";
4749

4850
const ThemeSettingsView = styled.div`
4951
font-size: 14px;
@@ -129,7 +131,7 @@ class ThemeDetailPage extends React.Component<ThemeDetailPageProps, ThemeDetailP
129131
}
130132

131133
componentDidUpdate(prevProps: ThemeDetailPageProps, prevState: ThemeDetailPageState) {
132-
if (prevProps.themeList?.length !== this.props.themeList?.length) {
134+
if (prevProps.themeList !== this.props.themeList && prevProps.themeList?.length !== this.props.themeList?.length) {
133135
this.findCurrentTheme();
134136
}
135137
}
@@ -169,10 +171,17 @@ class ThemeDetailPage extends React.Component<ThemeDetailPageProps, ThemeDetailP
169171
configChange(params: configChangeParams) {
170172
if (!this.state.theme) return;
171173

174+
const { themeSettingKey, ...rest } = params;
175+
const value = _.find(_.values(rest), (v) => v !== undefined);
176+
177+
if (_.isEqual(this.state.theme[themeSettingKey as keyof typeof this.state.theme], value)) {
178+
return;
179+
}
180+
172181
this.setState({
173182
theme: {
174183
...this.state.theme,
175-
[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,
184+
[themeSettingKey]: value,
176185
},
177186
});
178187
}
@@ -188,6 +197,18 @@ class ThemeDetailPage extends React.Component<ThemeDetailPageProps, ThemeDetailP
188197
history.push(THEME_SETTING);
189198
};
190199

200+
handleExport = () => {
201+
if (!this.state.name || !this.state.theme) {
202+
return;
203+
}
204+
exportThemeAsJSONFile({
205+
name: this.state.name,
206+
id: this.id,
207+
updateTime: Date.now(),
208+
theme: this.state.theme,
209+
});
210+
};
211+
191212
render() {
192213

193214
const colorSettings = [
@@ -898,6 +919,13 @@ class ThemeDetailPage extends React.Component<ThemeDetailPageProps, ThemeDetailP
898919
>
899920
{trans("theme.saveBtn")}
900921
</SaveButton>
922+
<ResetButton
923+
onClick={this.handleExport}
924+
disabled={!this.state.name || !this.state.theme}
925+
style={{ marginLeft: "auto" }}
926+
>
927+
{trans("theme.exportTheme")}
928+
</ResetButton>
901929
</Footer>
902930
</DetailContainer>
903931
</>

‎client/packages/lowcoder/src/pages/setting/theme/styledComponents.tsx‎

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export const Footer = styled.div`
124124
padding: 24px;
125125
position: fixed;
126126
bottom: 0;
127-
width: calc(100vw - 492px);
127+
width: calc(100% - 520px);
128128
background-color: #fff;
129129
z-index: 1000;
130130
margin-right: 10px;
@@ -659,44 +659,43 @@ export const CustomModalStyled = styled(CustomModal)`
659659
}
660660
`;
661661

662-
export const Margin = styled.div<{ $margin: string }>`
663-
> div {
664-
margin: 3px;
665-
overflow: hidden;
666-
> svg {
667-
fill: currentColor;
668-
}
669-
}
670-
}
671-
`;
672-
export const Padding = styled.div<{ $padding: string }>`
673-
> div {
674-
margin: 3px;
675-
overflow: hidden;
676-
> svg {
677-
fill: currentColor;
678-
}
679-
}
680-
}`
662+
export const Margin = styled.div<{ $margin: string }>`
663+
> div {
664+
margin: 3px;
665+
overflow: hidden;
666+
> svg {
667+
fill: currentColor;
668+
}
669+
}
670+
`;
671+
672+
export const Padding = styled.div<{ $padding: string }>`
673+
> div {
674+
margin: 3px;
675+
overflow: hidden;
676+
> svg {
677+
fill: currentColor;
678+
}
679+
}
680+
`;
681+
681682
// Added By Aqib Mirza
682-
export const GridColumns = styled.div<{ $gridColumns: string }>`
683+
export const GridColumns = styled.div<{ $gridColumns: string }>`
683684
> div {
684685
margin: 3px;
685686
overflow: hidden;
686-
> svg {
687-
fill: currentColor;
688-
}
687+
> svg {
688+
fill: currentColor;
689689
}
690690
}
691691
`;
692692

693-
export const BorderStyle = styled.div<{ $borderStyle: string }>`
693+
export const BorderStyle = styled.div<{ $borderStyle: string }>`
694694
> div {
695695
margin: 3px;
696696
overflow: hidden;
697-
> svg {
698-
fill: currentColor;
699-
}
697+
> svg {
698+
fill: currentColor;
700699
}
701700
}
702701
`;
@@ -705,9 +704,8 @@ export const BorderWidth = styled.div<{ $borderWidth: string }>`
705704
> div {
706705
margin: 3px;
707706
overflow: hidden;
708-
> svg {
709-
fill: currentColor;
710-
}
707+
> svg {
708+
fill: currentColor;
711709
}
712710
}
713711
`;

‎client/packages/lowcoder/src/pages/setting/theme/themeConstant.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export enum MENU_TYPE {
88
COPY = "copy",
99
EDIT = "edit",
1010
RENAME = "rename",
11+
EXPORT = "export",
1112
}
1213

1314
export enum DETAIL_TYPE {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { ThemeDetail, ThemeType } from "api/commonSettingApi";
2+
import { genQueryId } from "comps/utils/idGenerator";
3+
import { trans } from "i18n";
4+
import { saveDataAsFile } from "util/fileUtils";
5+
6+
export const THEME_EXPORT_VERSION = 1;
7+
export const THEME_EXPORT_TYPE = "lowcoder-theme";
8+
9+
export interface ThemeExportPayload {
10+
version: number;
11+
type: typeof THEME_EXPORT_TYPE;
12+
theme: ThemeType;
13+
}
14+
15+
function isThemeDetail(value: unknown): value is ThemeDetail {
16+
return typeof value === "object" && value !== null;
17+
}
18+
19+
function isThemeType(value: unknown): value is ThemeType {
20+
if (typeof value !== "object" || value === null) {
21+
return false;
22+
}
23+
const theme = value as ThemeType;
24+
return typeof theme.name === "string" && isThemeDetail(theme.theme);
25+
}
26+
27+
export function parseThemeImport(content: string): ThemeType {
28+
let data: unknown;
29+
try {
30+
data = JSON.parse(content);
31+
} catch {
32+
throw new Error(trans("theme.importParseError"));
33+
}
34+
35+
if (typeof data !== "object" || data === null) {
36+
throw new Error(trans("theme.importFormatError"));
37+
}
38+
39+
const payload = data as Record<string, unknown>;
40+
41+
if (payload.type === THEME_EXPORT_TYPE && isThemeType(payload.theme)) {
42+
return payload.theme;
43+
}
44+
45+
if (isThemeType(data)) {
46+
return data;
47+
}
48+
49+
throw new Error(trans("theme.importFormatError"));
50+
}
51+
52+
export function resolveUniqueThemeName(baseName: string, existingNames: Set<string>): string {
53+
const trimmed = baseName.trim();
54+
if (!trimmed) {
55+
throw new Error(trans("theme.importNameRequired"));
56+
}
57+
if (!existingNames.has(trimmed)) {
58+
return trimmed;
59+
}
60+
61+
const importSuffix = trans("theme.importSuffix");
62+
let candidate = trimmed + importSuffix;
63+
if (!existingNames.has(candidate)) {
64+
return candidate;
65+
}
66+
67+
let index = 1;
68+
while (existingNames.has(candidate + index)) {
69+
index += 1;
70+
}
71+
return candidate + index;
72+
}
73+
74+
export function prepareImportedTheme(
75+
source: ThemeType,
76+
existingThemes: ThemeType[],
77+
fallbackName?: string
78+
): ThemeType {
79+
const existingNames = new Set(existingThemes.map((theme) => theme.name));
80+
const name = resolveUniqueThemeName(
81+
source.name || fallbackName || trans("theme.importDefaultName"),
82+
existingNames
83+
);
84+
85+
return {
86+
name,
87+
id: genQueryId(),
88+
updateTime: Date.now(),
89+
theme: source.theme,
90+
};
91+
}
92+
93+
export function exportThemeAsJSONFile(theme: ThemeType) {
94+
const exportObj: ThemeExportPayload = {
95+
version: THEME_EXPORT_VERSION,
96+
type: THEME_EXPORT_TYPE,
97+
theme,
98+
};
99+
void saveDataAsFile({
100+
data: exportObj,
101+
filename: `${theme.name}.json`,
102+
fileType: "json",
103+
});
104+
}
105+
106+
export function readThemeFile(file: File): Promise<ThemeType> {
107+
return new Promise((resolve, reject) => {
108+
const reader = new FileReader();
109+
reader.readAsText(file, "UTF-8");
110+
reader.onload = (event) => {
111+
try {
112+
if (!event.target?.result) {
113+
throw new Error(trans("theme.importFileError"));
114+
}
115+
resolve(parseThemeImport(event.target.result.toString()));
116+
} catch (error) {
117+
reject(error);
118+
}
119+
};
120+
reader.onerror = () => reject(new Error(trans("theme.importFileError")));
121+
});
122+
}

‎client/packages/lowcoder/src/pages/setting/theme/themeList.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ function ThemeList(props: ThemeListProp) {
187187
label: trans("theme.copyTheme"),
188188
key: MENU_TYPE.COPY,
189189
},
190+
{
191+
label: trans("theme.exportTheme"),
192+
key: MENU_TYPE.EXPORT,
193+
},
190194
{
191195
label: trans("delete"),
192196
key: MENU_TYPE.DELETE,

0 commit comments

Comments
 (0)