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";
@Schema(required = true, minLength = 1)
private List<SettingSpec> spec;
@Schema(required = true)
private SettingSpec spec;
@Data
public static class SettingSpec {
@Schema(required = true, minLength = 1)
private List<SettingForm> forms;
}
@Data
public static class SettingForm {
@Schema(required = true)
private String group;
private String label;
@Schema(required = true)
private List<Object> formSchema;
}
}

View File

@ -1,16 +1,26 @@
package run.halo.app.core.extension.reconciler;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
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.springframework.util.CollectionUtils;
import org.springframework.util.FileSystemUtils;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
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.Request;
import run.halo.app.infra.exception.ThemeUninstallException;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.ThemePathPolicy;
/**
@ -36,10 +46,77 @@ public class ThemeReconciler implements Reconciler<Request> {
if (isDeleted(theme)) {
reconcileThemeDeletion(theme);
}
themeSettingDefaultConfig(theme);
});
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) {
deleteThemeFiles(theme);
// delete theme setting form

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package run.halo.app.core.extension.reconciler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ -11,21 +12,28 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import org.json.JSONException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.ResourceUtils;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.ThemePathPolicy;
/**
@ -64,8 +72,9 @@ class ThemeReconcilerTest {
Files.createDirectory(testWorkDir);
when(haloProperties.getWorkDir()).thenReturn(testWorkDir);
ThemeReconciler themeReconciler = new ThemeReconciler(extensionClient, haloProperties);
ThemePathPolicy themePathPolicy = new ThemePathPolicy(testWorkDir);
final ThemeReconciler themeReconciler =
new ThemeReconciler(extensionClient, haloProperties);
final ThemePathPolicy themePathPolicy = new ThemePathPolicy(testWorkDir);
Theme theme = new Theme();
Metadata metadata = new Metadata();
@ -93,10 +102,127 @@ class ThemeReconcilerTest {
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
verify(extensionClient, times(1)).fetch(eq(Theme.class), eq(metadata.getName()));
verify(extensionClient, times(1)).fetch(eq(Setting.class), eq(themeSpec.getSettingName()));
verify(extensionClient, times(2)).fetch(eq(Theme.class), eq(metadata.getName()));
verify(extensionClient, times(2)).fetch(eq(Setting.class), eq(themeSpec.getSettingName()));
assertThat(Files.exists(testWorkDir)).isTrue();
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);
}
}