feat: create a configmap based on the setting definition when installing a theme (#2440)

#### What type of PR is this?
/kind improvement
/area core
/milestone 2.0
#### What this PR does / why we need it:
- 安装主题后通过读取主题 Setting 模型中的默认值初始化一个 ConfigMap
- 修改了 Setting 模型的 spec  结构(多了一层 forms 嵌套),配置示例如下:
```yaml
apiVersion: v1alpha1
kind: Setting
metadata:
  name: theme-anatole-setting
spec:
  forms:
    - group: basic
      label: 基本设置
      formSchema:
        - $formkit: select
          name: sidebar_width
          label: 侧边栏宽度
          options:
            "20%": 20%
            "30%": 30%
            "40%": 40%
            "50%": 50%
          value: "40%"
```

#### Which issue(s) this PR fixes:

Fixes #2414

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/2456/head
guqing 2022-09-22 16:26:13 +08:00 committed by GitHub
parent cda6402780
commit 95b7a273f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 411 additions and 195 deletions

View File

@ -21,17 +21,25 @@ public class Setting extends AbstractExtension {
public static final String KIND = "Setting"; public static final String KIND = "Setting";
@Schema(required = true, minLength = 1) @Schema(required = true)
private List<SettingSpec> spec; private SettingSpec spec;
@Data @Data
public static class SettingSpec { public static class SettingSpec {
@Schema(required = true, minLength = 1)
private List<SettingForm> forms;
}
@Data
public static class SettingForm {
@Schema(required = true) @Schema(required = true)
private String group; private String group;
private String label; private String label;
@Schema(required = true)
private List<Object> formSchema; private List<Object> formSchema;
} }
} }

View File

@ -1,16 +1,26 @@
package run.halo.app.core.extension.reconciler; package run.halo.app.core.extension.reconciler;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.FileSystemUtils; import org.springframework.util.FileSystemUtils;
import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.infra.exception.ThemeUninstallException; import run.halo.app.infra.exception.ThemeUninstallException;
import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.ThemePathPolicy; import run.halo.app.theme.ThemePathPolicy;
/** /**
@ -36,10 +46,77 @@ public class ThemeReconciler implements Reconciler<Request> {
if (isDeleted(theme)) { if (isDeleted(theme)) {
reconcileThemeDeletion(theme); reconcileThemeDeletion(theme);
} }
themeSettingDefaultConfig(theme);
}); });
return new Result(false, null); return new Result(false, null);
} }
private void themeSettingDefaultConfig(Theme theme) {
if (StringUtils.isBlank(theme.getSpec().getSettingName())) {
return;
}
final String userDefinedConfigMapName = theme.getSpec().getConfigMapName();
final String newConfigMapName = UUID.randomUUID().toString();
if (StringUtils.isBlank(userDefinedConfigMapName)) {
client.fetch(Theme.class, theme.getMetadata().getName())
.ifPresent(themeToUse -> {
Theme oldTheme = JsonUtils.deepCopy(themeToUse);
themeToUse.getSpec().setConfigMapName(newConfigMapName);
if (!oldTheme.equals(themeToUse)) {
client.update(themeToUse);
}
});
}
final String configMapNameToUse =
StringUtils.defaultIfBlank(userDefinedConfigMapName, newConfigMapName);
boolean existConfigMap = client.fetch(ConfigMap.class, configMapNameToUse)
.isPresent();
if (existConfigMap) {
return;
}
client.fetch(Setting.class, theme.getSpec().getSettingName())
.ifPresent(setting -> {
Map<String, String> data = settingDefinedDefaultValueMap(setting);
if (CollectionUtils.isEmpty(data)) {
return;
}
ConfigMap configMap = new ConfigMap();
configMap.setMetadata(new Metadata());
configMap.getMetadata().setName(configMapNameToUse);
configMap.setData(data);
client.create(configMap);
});
}
Map<String, String> settingDefinedDefaultValueMap(Setting setting) {
final String defaultValueField = "value";
final String nameField = "name";
List<Setting.SettingForm> forms = setting.getSpec().getForms();
if (CollectionUtils.isEmpty(forms)) {
return null;
}
Map<String, String> data = new LinkedHashMap<>();
for (Setting.SettingForm form : forms) {
String group = form.getGroup();
Map<String, JsonNode> groupValue = form.getFormSchema().stream()
.map(o -> JsonUtils.DEFAULT_JSON_MAPPER.convertValue(o, JsonNode.class))
.filter(jsonNode -> jsonNode.isObject() && jsonNode.has(nameField)
&& jsonNode.has(defaultValueField))
.map(jsonNode -> {
String name = jsonNode.findValue(nameField).asText();
JsonNode value = jsonNode.findValue(defaultValueField);
return Map.entry(name, value);
})
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
data.put(group, JsonUtils.objectToJson(groupValue));
}
return data;
}
private void reconcileThemeDeletion(Theme theme) { private void reconcileThemeDeletion(Theme theme) {
deleteThemeFiles(theme); deleteThemeFiles(theme);
// delete theme setting form // delete theme setting form

View File

@ -12,10 +12,11 @@ kind: Setting
metadata: metadata:
name: local-policy-template-setting name: local-policy-template-setting
spec: spec:
- group: default forms:
label: Default - group: default
formSchema: label: Default
- $formkit: text formSchema:
name: location - $formkit: text
label: 存储位置 name: location
help: ~/halo-next/attachments 下的子目录 label: 存储位置
help: ~/halo-next/attachments 下的子目录

View File

@ -3,130 +3,131 @@ kind: Setting
metadata: metadata:
name: system name: system
spec: spec:
- group: basic forms:
label: 基本设置 - group: basic
formSchema: label: 基本设置
- $formkit: text formSchema:
label: "站点标题" - $formkit: text
name: title label: "站点标题"
validation: required name: title
- $formkit: text validation: required
label: "站点副标题" - $formkit: text
name: subtitle label: "站点副标题"
validation: required name: subtitle
- $formkit: text validation: required
label: Logo - $formkit: text
name: logo label: Logo
- $formkit: text name: logo
label: Favicon - $formkit: text
name: favicon label: Favicon
- group: user name: favicon
label: 用户设置 - group: user
formSchema: label: 用户设置
- $formkit: checkbox formSchema:
label: "是否公开注册" - $formkit: checkbox
value: false label: "是否公开注册"
name: allowRegistration value: false
validation: required name: allowRegistration
- $formkit: text validation: required
label: "默认角色" - $formkit: text
name: defaultRole label: "默认角色"
validation: required name: defaultRole
- group: routeRules validation: required
label: 主题模板路由设置 - group: routeRules
formSchema: label: 主题模板路由设置
- $formkit: text formSchema:
label: "分类页路由前缀" - $formkit: text
value: "categories" label: "分类页路由前缀"
name: categories value: "categories"
validation: required | alphanumeric name: categories
- $formkit: text validation: required | alphanumeric
label: "标签页路由前缀" - $formkit: text
value: "tags" label: "标签页路由前缀"
name: tags value: "tags"
validation: required | alphanumeric name: tags
- $formkit: text validation: required | alphanumeric
label: "归档页路由前缀" - $formkit: text
value: "archives" label: "归档页路由前缀"
name: archives value: "archives"
validation: required | alphanumeric name: archives
- $formkit: select validation: required | alphanumeric
label: "文章详情页访问规则" - $formkit: select
value: "/archives/{slug}" label: "文章详情页访问规则"
options: value: "/archives/{slug}"
- /archives/{slug} options:
- /archives/{name} - /archives/{slug}
- /?p={name} - /archives/{name}
- /?p={slug} - /?p={name}
- /{year:\d{4}}/{slug} - /?p={slug}
- /{year:\d{4}}/{month:\d{2}}/{slug} - /{year:\d{4}}/{slug}
- /{year:\d{4}}/{month:\d{2}}/{day:\d{2}}/{slug} - /{year:\d{4}}/{month:\d{2}}/{slug}
name: post - /{year:\d{4}}/{month:\d{2}}/{day:\d{2}}/{slug}
validation: required name: post
- group: post validation: required
label: 文章设置 - group: post
formSchema: label: 文章设置
- $formkit: select formSchema:
label: "列表排序方式" - $formkit: select
name: sortOrder label: "列表排序方式"
value: "publishTime" name: sortOrder
options: value: "publishTime"
visitCount: "浏览量" options:
publishTime: "发布时间" visitCount: "浏览量"
updateTime: "更新时间" publishTime: "发布时间"
validation: required updateTime: "更新时间"
- $formkit: number validation: required
label: "列表显示条数" - $formkit: number
name: pageSize label: "列表显示条数"
value: 10 name: pageSize
min: 1 value: 10
max: 100 min: 1
validation: required max: 100
- $formkit: checkbox validation: required
label: "新文章审核" - $formkit: checkbox
value: false label: "新文章审核"
name: review value: false
help: "用户发布文章是否需要管理员审核" name: review
- group: seo help: "用户发布文章是否需要管理员审核"
label: SEO 设置 - group: seo
formSchema: label: SEO 设置
- $formkit: checkbox formSchema:
name: blockSpiders - $formkit: checkbox
label: "屏蔽搜索引擎" name: blockSpiders
value: false label: "屏蔽搜索引擎"
- $formkit: textarea value: false
name: keywords - $formkit: textarea
label: "站点关键词" name: keywords
- $formkit: textarea label: "站点关键词"
name: description - $formkit: textarea
label: "站点描述" name: description
- group: comment label: "站点描述"
label: 评论设置 - group: comment
formSchema: label: 评论设置
- $formkit: checkbox formSchema:
name: enable - $formkit: checkbox
value: true name: enable
label: "启用评论" value: true
- $formkit: checkbox label: "启用评论"
name: requireReviewForNew - $formkit: checkbox
value: true name: requireReviewForNew
label: "新评论审核" value: true
- $formkit: checkbox label: "新评论审核"
name: systemUserOnly - $formkit: checkbox
value: true name: systemUserOnly
label: "仅允许注册用户评论" value: true
- group: codeInjection label: "仅允许注册用户评论"
label: 代码注入 - group: codeInjection
formSchema: label: 代码注入
- $formkit: textarea formSchema:
label: "全局 head" - $formkit: textarea
name: globalHead label: "全局 head"
help: "插入代码到所有页面的 head 标签部分" name: globalHead
- $formkit: textarea help: "插入代码到所有页面的 head 标签部分"
label: "内容页 head" - $formkit: textarea
name: contentHead label: "内容页 head"
help: "插入代码到文章页面和自定义页面的 head 标签部分" name: contentHead
- $formkit: textarea help: "插入代码到文章页面和自定义页面的 head 标签部分"
label: "页脚" - $formkit: textarea
name: footer label: "页脚"
help: "插入代码到所有页面的页脚部分" name: footer
help: "插入代码到所有页面的页脚部分"

View File

@ -27,24 +27,25 @@ class SettingTest {
metadata: metadata:
name: setting-name name: setting-name
spec: spec:
- group: basic forms:
label: - group: basic
formSchema: label:
- $el: h1 formSchema:
children: Register - $el: h1
- $formkit: text children: Register
help: This will be used for your account. - $formkit: text
label: Email help: This will be used for your account.
name: email label: Email
validation: required|email name: email
- group: sns validation: required|email
label: - group: sns
formSchema: label:
- $formkit: text formSchema:
help: This will be used for your theme. - $formkit: text
label: color help: This will be used for your theme.
name: color label: color
validation: required name: color
validation: required
"""; """;
List<Unstructured> unstructureds = List<Unstructured> unstructureds =
new YamlUnstructuredLoader(new InMemoryResource(settingYaml)).load(); new YamlUnstructuredLoader(new InMemoryResource(settingYaml)).load();
@ -55,43 +56,45 @@ class SettingTest {
assertThat(setting).isNotNull(); assertThat(setting).isNotNull();
JSONAssert.assertEquals(""" JSONAssert.assertEquals("""
{ {
"spec": [ "spec": {
{ "forms": [
"group": "basic", {
"label": "基本设置", "group": "basic",
"formSchema": [ "label": "基本设置",
{ "formSchema": [
"$el": "h1", {
"children": "Register" "$el": "h1",
}, "children": "Register"
{ },
"$formkit": "text", {
"help": "This will be used for your account.", "$formkit": "text",
"label": "Email", "help": "This will be used for your account.",
"name": "email", "label": "Email",
"validation": "required|email" "name": "email",
} "validation": "required|email"
] }
}, ]
{ },
"group": "sns", {
"label": "社交资料", "group": "sns",
"formSchema": [ "label": "社交资料",
{ "formSchema": [
"$formkit": "text", {
"help": "This will be used for your theme.", "$formkit": "text",
"label": "color", "help": "This will be used for your theme.",
"name": "color", "label": "color",
"validation": "required" "name": "color",
} "validation": "required"
] }
} ]
], }
"apiVersion": "v1alpha1", ]
"kind": "Setting", },
"metadata": { "apiVersion": "v1alpha1",
"name": "setting-name" "kind": "Setting",
} "metadata": {
"name": "setting-name"
}
} }
""", """,
JsonUtils.objectToJson(setting), false); JsonUtils.objectToJson(setting), false);

View File

@ -1,6 +1,7 @@
package run.halo.app.core.extension.reconciler; package run.halo.app.core.extension.reconciler;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ -11,21 +12,28 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import org.json.JSONException;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.util.FileSystemUtils; import org.springframework.util.FileSystemUtils;
import org.springframework.util.ResourceUtils; import org.springframework.util.ResourceUtils;
import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata; import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.ThemePathPolicy; import run.halo.app.theme.ThemePathPolicy;
/** /**
@ -64,8 +72,9 @@ class ThemeReconcilerTest {
Files.createDirectory(testWorkDir); Files.createDirectory(testWorkDir);
when(haloProperties.getWorkDir()).thenReturn(testWorkDir); when(haloProperties.getWorkDir()).thenReturn(testWorkDir);
ThemeReconciler themeReconciler = new ThemeReconciler(extensionClient, haloProperties); final ThemeReconciler themeReconciler =
ThemePathPolicy themePathPolicy = new ThemePathPolicy(testWorkDir); new ThemeReconciler(extensionClient, haloProperties);
final ThemePathPolicy themePathPolicy = new ThemePathPolicy(testWorkDir);
Theme theme = new Theme(); Theme theme = new Theme();
Metadata metadata = new Metadata(); Metadata metadata = new Metadata();
@ -93,10 +102,127 @@ class ThemeReconcilerTest {
themeReconciler.reconcile(new Reconciler.Request(metadata.getName())); themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
verify(extensionClient, times(1)).fetch(eq(Theme.class), eq(metadata.getName())); verify(extensionClient, times(2)).fetch(eq(Theme.class), eq(metadata.getName()));
verify(extensionClient, times(1)).fetch(eq(Setting.class), eq(themeSpec.getSettingName())); verify(extensionClient, times(2)).fetch(eq(Setting.class), eq(themeSpec.getSettingName()));
assertThat(Files.exists(testWorkDir)).isTrue(); assertThat(Files.exists(testWorkDir)).isTrue();
assertThat(Files.exists(defaultThemePath)).isFalse(); assertThat(Files.exists(defaultThemePath)).isFalse();
} }
@Test
void themeSettingDefaultValue() throws IOException, JSONException {
Path testWorkDir = tempDirectory.resolve("reconcile-setting-value");
Files.createDirectory(testWorkDir);
when(haloProperties.getWorkDir()).thenReturn(testWorkDir);
final ThemeReconciler themeReconciler =
new ThemeReconciler(extensionClient, haloProperties);
Theme theme = new Theme();
Metadata metadata = new Metadata();
metadata.setName("theme-test");
theme.setMetadata(metadata);
theme.setKind(Theme.KIND);
theme.setApiVersion("theme.halo.run/v1alpha1");
Theme.ThemeSpec themeSpec = new Theme.ThemeSpec();
themeSpec.setSettingName(null);
theme.setSpec(themeSpec);
when(extensionClient.fetch(eq(Theme.class), eq(metadata.getName())))
.thenReturn(Optional.of(theme));
Reconciler.Result reconcile =
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
assertThat(reconcile.reEnqueue()).isFalse();
verify(extensionClient, times(1)).fetch(eq(Theme.class), eq(metadata.getName()));
// setting exists
themeSpec.setSettingName("theme-test-setting");
when(extensionClient.fetch(eq(ConfigMap.class), any()))
.thenReturn(Optional.of(Mockito.mock(ConfigMap.class)));
assertThat(theme.getSpec().getConfigMapName()).isNull();
ArgumentCaptor<Theme> captor = ArgumentCaptor.forClass(Theme.class);
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
verify(extensionClient, times(3))
.fetch(eq(Theme.class), eq(metadata.getName()));
verify(extensionClient, times(1))
.update(captor.capture());
Theme value = captor.getValue();
assertThat(value.getSpec().getConfigMapName()).isNotNull();
// populate setting name and configMap name and configMap not exists
themeSpec.setSettingName("theme-test-setting");
themeSpec.setConfigMapName("theme-test-configmap");
when(extensionClient.fetch(eq(ConfigMap.class), any()))
.thenReturn(Optional.empty());
when(extensionClient.fetch(eq(Setting.class), eq(themeSpec.getSettingName())))
.thenReturn(Optional.of(getFakeSetting()));
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
verify(extensionClient, times(1))
.fetch(eq(Setting.class), eq(themeSpec.getSettingName()));
ArgumentCaptor<ConfigMap> configMapCaptor = ArgumentCaptor.forClass(ConfigMap.class);
verify(extensionClient, times(1)).create(any(ConfigMap.class));
verify(extensionClient, times(1)).create(configMapCaptor.capture());
ConfigMap defaultValueConfigMap = configMapCaptor.getValue();
Map<String, String> data = defaultValueConfigMap.getData();
JSONAssert.assertEquals("""
{
"sns": "{\\"email\\":\\"example@exmple.com\\"}"
}
""",
JsonUtils.objectToJson(data),
true);
}
@Test
void settingDefinedDefaultValueMap() throws JSONException {
Setting setting = getFakeSetting();
when(haloProperties.getWorkDir()).thenReturn(tempDirectory);
Map<String, String> map = new ThemeReconciler(extensionClient, haloProperties)
.settingDefinedDefaultValueMap(setting);
JSONAssert.assertEquals("""
{
"sns": "{\\"email\\":\\"example@exmple.com\\"}"
}
""",
JsonUtils.objectToJson(map),
true);
}
private static Setting getFakeSetting() {
String settingJson = """
{
"apiVersion": "v1alpha1",
"kind": "Setting",
"metadata": {
"name": "theme-default-setting"
},
"spec": {
"forms": [{
"formSchema": [
{
"$el": "h1",
"children": "Register"
},
{
"$formkit": "text",
"label": "Email",
"name": "email",
"value": "example@exmple.com"
},
{
"$formkit": "password",
"label": "Password",
"name": "password",
"validation": "required|length:5,16",
"value": null
}
],
"group": "sns",
"label": "社交资料"
}]
}
}
""";
return JsonUtils.jsonToObject(settingJson, Setting.class);
}
} }