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,6 +12,7 @@ kind: Setting
metadata:
name: local-policy-template-setting
spec:
forms:
- group: default
label: Default
formSchema:

View File

@ -3,6 +3,7 @@ kind: Setting
metadata:
name: system
spec:
forms:
- group: basic
label: 基本设置
formSchema:

View File

@ -27,6 +27,7 @@ class SettingTest {
metadata:
name: setting-name
spec:
forms:
- group: basic
label:
formSchema:
@ -55,7 +56,8 @@ class SettingTest {
assertThat(setting).isNotNull();
JSONAssert.assertEquals("""
{
"spec": [
"spec": {
"forms": [
{
"group": "basic",
"label": "基本设置",
@ -86,7 +88,8 @@ class SettingTest {
}
]
}
],
]
},
"apiVersion": "v1alpha1",
"kind": "Setting",
"metadata": {

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