feat: add AnnotationsForm Component to edit extension annotations (#770)

#### What type of PR is this?

/kind feature

#### What this PR does / why we need it:

提供一个 Annotations 编辑组件,支持由主题或者插件提供表单定义,也支持使用者自定义 key-value,用于扩展资源字段。

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

Fixes https://github.com/halo-dev/halo/issues/2858

#### Screenshots:

<img width="789" alt="image" src="https://user-images.githubusercontent.com/21301288/209517622-80111fd2-8e79-480f-8ca0-9f7073300b2b.png">


#### Special notes for your reviewer:

测试方式:

1. 将一下内容放到任意一个主题下,后缀为 `yaml`,文件名随意。
    ```yaml
    spec:
      targetRef:
        group: content.halo.run
        kind: Post
      formSchema:
        - $formkit: "text"
          name: "download"
          label: "下载地址"
        - $formkit: "text"
          name: "version"
          label: "版本"
    apiVersion: v1alpha1
    kind: AnnotationSetting
    metadata:
      generateName: annotation-
    ```
3. 后端需要使用 https://github.com/halo-dev/halo/pull/3028
4. 测试为文章设置 Annotations 和自定义的 Annotations。
5. 检查是否可以设置正常。


#### Does this PR introduce a user-facing change?

```release-note
文章设置支持设置元数据。
```
pull/802/head
Ryan Wang 2022-12-26 22:08:32 +08:00 committed by GitHub
parent eccb6e639a
commit d60931bae5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 308 additions and 5 deletions

View File

@ -39,8 +39,9 @@
"@formkit/inputs": "^1.0.0-beta.12",
"@formkit/themes": "^1.0.0-beta.12",
"@formkit/utils": "^1.0.0-beta.12",
"@formkit/validation": "1.0.0-beta.12",
"@formkit/vue": "^1.0.0-beta.12",
"@halo-dev/api-client": "^0.0.62",
"@halo-dev/api-client": "0.0.64",
"@halo-dev/components": "workspace:*",
"@halo-dev/console-shared": "workspace:*",
"@halo-dev/richtext-editor": "^0.0.0-alpha.17",

View File

@ -11,8 +11,9 @@ importers:
'@formkit/inputs': ^1.0.0-beta.12
'@formkit/themes': ^1.0.0-beta.12
'@formkit/utils': ^1.0.0-beta.12
'@formkit/validation': 1.0.0-beta.12
'@formkit/vue': ^1.0.0-beta.12
'@halo-dev/api-client': ^0.0.62
'@halo-dev/api-client': 0.0.64
'@halo-dev/components': workspace:*
'@halo-dev/console-shared': workspace:*
'@halo-dev/richtext-editor': ^0.0.0-alpha.17
@ -105,8 +106,9 @@ importers:
'@formkit/inputs': 1.0.0-beta.12-e579559
'@formkit/themes': 1.0.0-beta.12-e579559_tailwindcss@3.2.4
'@formkit/utils': 1.0.0-beta.12-e579559
'@formkit/validation': 1.0.0-beta.12
'@formkit/vue': 1.0.0-beta.12-e579559_ior6jr3fpijijuwpr34w2i25va
'@halo-dev/api-client': 0.0.62
'@halo-dev/api-client': 0.0.64
'@halo-dev/components': link:packages/components
'@halo-dev/console-shared': link:packages/shared
'@halo-dev/richtext-editor': 0.0.0-alpha.17_vue@3.2.45
@ -1874,6 +1876,12 @@ packages:
'@floating-ui/core': 0.3.1
dev: false
/@formkit/core/1.0.0-beta.12:
resolution: {integrity: sha512-/Pod7k4N58eDOG+0LE0ccV7BOYD5sEA4RqaRNJn9m6Jg9nF/JATmSNL46kVCH3mV6sSoV045L3Zir8//rya0Lw==}
dependencies:
'@formkit/utils': 1.0.0-beta.12
dev: false
/@formkit/core/1.0.0-beta.12-e579559:
resolution: {integrity: sha512-y90ubMcFr6WtAjZqUOLgA3p4jm024f6R7iDRVKHsmdwSKm1/GZl4D+Yo2k8DL40xM7NbZhIvK40MiEC0pNZ3ig==}
dependencies:
@ -1901,6 +1909,13 @@ packages:
'@formkit/core': 1.0.0-beta.12-e579559
dev: false
/@formkit/observer/1.0.0-beta.12:
resolution: {integrity: sha512-kXVWUkjbNsymwv50QuI5thjlCknGy8azPlzepTDj3fsuZANuHG8qaj3W5h9mY6cCgvil4d4WeBz5f54iJsaEXA==}
dependencies:
'@formkit/core': 1.0.0-beta.12
'@formkit/utils': 1.0.0-beta.12
dev: false
/@formkit/observer/1.0.0-beta.12-e579559:
resolution: {integrity: sha512-6Nki4VmUN3OyU70N6AIttvn/ScZXq5mwv11BIYLgfsfiMI5zoiflmNHrD7yf4KTUVvz16ThJB5nRGIXlnUnH3Q==}
dependencies:
@ -1934,10 +1949,21 @@ packages:
tailwindcss: 3.2.4_postcss@8.4.19
dev: false
/@formkit/utils/1.0.0-beta.12:
resolution: {integrity: sha512-L2P221teC+58Y1A4TZpJsLt/Y+WErLxbBt4hH6XhokssVX7mYR56YWienY9StN5jmiGVWtcK1+riXCb0n1aMOA==}
dev: false
/@formkit/utils/1.0.0-beta.12-e579559:
resolution: {integrity: sha512-4yTz4IRzPX3G08aAO+ZfEuBYfy6OSgk3csx3TBhOcL6NzyIbJkTc2ZsxaUYXgY6FOVclazLGMKM5+jBitMOL5Q==}
dev: false
/@formkit/validation/1.0.0-beta.12:
resolution: {integrity: sha512-ZSD5llsfSqXfIYMtSeeDwx7h421JCKobi00RxpGJkuHgpnAi3oxTJB7J6u+3pMrO9BqcidGtk5G/rk9F4fCNEw==}
dependencies:
'@formkit/core': 1.0.0-beta.12
'@formkit/observer': 1.0.0-beta.12
dev: false
/@formkit/validation/1.0.0-beta.12-e579559:
resolution: {integrity: sha512-f/S0LefikeBZRxKkjx1fD7RODXMHglg6+B1NUYEKsEKc3ySjH/V9/XroaPszXfkupAnVBSTjZXG7T9hmn39VVA==}
dependencies:
@ -1966,8 +1992,8 @@ packages:
- windicss
dev: false
/@halo-dev/api-client/0.0.62:
resolution: {integrity: sha512-LVoAH4/+8iHxqHf7X6Ax3wy+IRSB2Tm9IC3zhfGdnbrdgyn/NnnpRIhTCKo6cK0Sr2j/3W4Pvmc0xCWNVzNAyw==}
/@halo-dev/api-client/0.0.64:
resolution: {integrity: sha512-zzhTdRi4p7nRsWG7u85YCkoHZLHsbpuWeBCwLl5FSXxY8ruKuiOo0FypkS7Ll7505XASk4MJL6rsyeX+l7rLmQ==}
dev: false
/@halo-dev/richtext-editor/0.0.0-alpha.17_vue@3.2.45:

View File

@ -0,0 +1,231 @@
<script lang="ts" setup>
import {
reset,
submitForm,
type FormKitNode,
type FormKitSchemaCondition,
type FormKitSchemaNode,
} from "@formkit/core";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { apiClient } from "@/utils/api-client";
import type { AnnotationSetting } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
import { getValidationMessages } from "@formkit/validation";
import { useThemeStore } from "@/stores/theme";
const themeStore = useThemeStore();
function keyValidationRule(node: FormKitNode) {
return !annotations?.[node.value as string];
}
const props = withDefaults(
defineProps<{
group: string;
kind: string;
value?: {
[key: string]: string;
} | null;
}>(),
{
value: null,
}
);
const annotationSettings = ref<AnnotationSetting[]>([] as AnnotationSetting[]);
const avaliableAnnotationSettings = computed(() => {
return annotationSettings.value.filter((setting) => {
if (!setting.metadata.labels?.["theme.halo.run/theme-name"]) {
return true;
}
return (
setting.metadata.labels?.["theme.halo.run/theme-name"] ===
themeStore.activatedTheme?.metadata.name
);
});
});
const handleFetchAnnotationSettings = async () => {
try {
const { data } =
await apiClient.extension.annotationSetting.listv1alpha1AnnotationSetting(
{
labelSelector: [
`halo.run/target-ref=${[props.group, props.kind].join("/")}`,
],
}
);
annotationSettings.value = data.items;
} catch (error) {
console.log("Failed to fetch annotation settings", error);
}
};
const annotations = ref<{
[key: string]: string;
}>({});
const customAnnotationsState = ref<{ key: string; value: string }[]>([]);
const customAnnotations = computed(() => {
return customAnnotationsState.value.reduce((acc, cur) => {
acc[cur.key] = cur.value;
return acc;
}, {} as { [key: string]: string });
});
const handleProcessCustomAnnotations = () => {
let formSchemas: FormKitSchemaNode[] = [];
avaliableAnnotationSettings.value.forEach((annotationSetting) => {
formSchemas = formSchemas.concat(
annotationSetting.spec?.formSchema as FormKitSchemaNode[]
);
});
customAnnotationsState.value = Object.entries(props.value || {})
.map(([key, value]) => {
const fromThemeSpec = formSchemas.some((item) => {
if (typeof item === "object" && "$formkit" in item) {
return item.name === key;
}
return false;
});
if (!fromThemeSpec) {
return {
key,
value,
};
}
})
.filter((item) => item) as { key: string; value: string }[];
annotations.value = Object.entries(props.value || {})
.map(([key, value]) => {
const fromThemeSpec = formSchemas.some((item) => {
if (typeof item === "object" && "$formkit" in item) {
return item.name === key;
}
return false;
});
if (fromThemeSpec) {
return {
key,
value,
};
}
})
.filter((item) => item)
.reduce((acc, cur) => {
if (cur) {
acc[cur.key] = cur.value;
}
return acc;
}, {} as { [key: string]: string });
};
onMounted(async () => {
annotations.value = cloneDeep(props.value) || {};
await handleFetchAnnotationSettings();
handleProcessCustomAnnotations();
});
watch(
() => props.value,
(value) => {
reset("specForm");
reset("customForm");
annotations.value = cloneDeep(props.value) || {};
if (value) {
handleProcessCustomAnnotations();
}
}
);
// submit
const specFormInvalid = ref(true);
const customFormInvalid = ref(true);
const handleSubmit = async () => {
submitForm("specForm");
submitForm("customForm");
await nextTick();
};
const onSpecFormSubmitCheck = async (node?: FormKitNode) => {
if (!node) {
return;
}
const validations = getValidationMessages(node);
specFormInvalid.value = validations.size > 0;
};
const onCustomFormSubmitCheck = async (node?: FormKitNode) => {
if (!node) {
return;
}
const validations = getValidationMessages(node);
customFormInvalid.value = validations.size > 0;
};
defineExpose({
handleSubmit,
specFormInvalid,
customFormInvalid,
annotations,
customAnnotations,
});
</script>
<template>
<div class="flex flex-col gap-3 divide-y divide-gray-100">
<FormKit
v-if="annotations && avaliableAnnotationSettings.length > 0"
id="specForm"
v-model="annotations"
type="form"
:preserve="true"
@submit-invalid="onSpecFormSubmitCheck"
@submit="specFormInvalid = false"
>
<template
v-for="(annotationSetting, index) in avaliableAnnotationSettings"
>
<FormKitSchema
v-if="annotationSetting.spec?.formSchema"
:key="index"
:schema="annotationSetting.spec?.formSchema as (FormKitSchemaCondition| FormKitSchemaNode[])"
/>
</template>
</FormKit>
<FormKit
v-if="annotations"
id="customForm"
type="form"
:preserve="true"
:form-class="`${avaliableAnnotationSettings.length ? 'py-4' : ''}`"
@submit-invalid="onCustomFormSubmitCheck"
@submit="customFormInvalid = false"
>
<FormKit v-model="customAnnotationsState" type="repeater" label="自定义">
<FormKit
type="text"
label="Key"
name="key"
validation="required|keyValidationRule"
:validation-rules="{ keyValidationRule }"
:validation-messages="{
keyValidationRule: '当前 Key 已被占用',
}"
></FormKit>
<FormKit
type="text"
label="Value"
name="value"
validation="required"
></FormKit>
</FormKit>
</FormKit>
</div>
</template>

View File

@ -8,6 +8,7 @@ import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/
import { postLabels } from "@/constants/labels";
import { randomUUID } from "@/utils/id";
import { toDatetimeLocal, toISOString } from "@/utils/date";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { submitForm } from "@formkit/core";
const initialFormState: Post = {
@ -107,6 +108,21 @@ const handlePublishClick = () => {
};
const handleSave = async () => {
annotationsFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } =
annotationsFormRef.value || {};
if (customFormInvalid || specFormInvalid) {
return;
}
formState.value.metadata.annotations = {
...annotations,
...customAnnotations,
};
if (props.onlyEmit) {
emit("saved", formState.value);
return;
@ -204,6 +220,8 @@ const publishTime = computed(() => {
const onPublishTimeChange = (value: string) => {
formState.value.spec.publishTime = value ? toISOString(value) : undefined;
};
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
</script>
<template>
<VModal
@ -350,6 +368,27 @@ const onPublishTimeChange = (value: string) => {
</div>
</FormKit>
<div class="py-5">
<div class="border-t border-gray-200"></div>
</div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900"> 元数据 </span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<AnnotationsForm
:key="formState.metadata.name"
ref="annotationsFormRef"
:value="formState.metadata.annotations"
kind="Post"
group="content.halo.run"
/>
</div>
</div>
<template #footer>
<VSpace>
<template v-if="publishSupport">

View File

@ -31,6 +31,7 @@ import {
V1alpha1RoleBindingApi,
V1alpha1SettingApi,
V1alpha1UserApi,
V1alpha1AnnotationSettingApi,
} from "@halo-dev/api-client";
import type { AxiosError, AxiosInstance } from "axios";
import axios from "axios";
@ -157,6 +158,11 @@ function setupApiClient(axios: AxiosInstance) {
axios
),
},
annotationSetting: new V1alpha1AnnotationSettingApi(
undefined,
baseURL,
axios
),
},
// custom endpoints
user: new ApiConsoleHaloRunV1alpha1UserApi(undefined, baseURL, axios),