refactor: simplify the code of reply creation modal (#5972)

#### What type of PR is this?

/area ui
/kind improvement
/milestone 2.16.x

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

简化评论回复组件的代码。

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

```release-note
None 
```
pull/5975/head
Ryan Wang 2024-05-23 10:56:50 +08:00 committed by GitHub
parent 89b20bf5a3
commit 99eae2f31b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 88 additions and 134 deletions

View File

@ -1,18 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import {
Dialog, Dialog,
VAvatar,
VButton,
VEntity,
VEntityField,
VStatusDot,
VSpace,
VEmpty,
IconAddCircle, IconAddCircle,
IconExternalLinkLine, IconExternalLinkLine,
VLoading,
Toast, Toast,
VAvatar,
VButton,
VDropdownItem, VDropdownItem,
VEmpty,
VEntity,
VEntityField,
VLoading,
VSpace,
VStatusDot,
VTag, VTag,
} from "@halo-dev/components"; } from "@halo-dev/components";
import ReplyCreationModal from "./ReplyCreationModal.vue"; import ReplyCreationModal from "./ReplyCreationModal.vue";
@ -24,7 +24,7 @@ import type {
SinglePage, SinglePage,
} from "@halo-dev/api-client"; } from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { computed, provide, ref, onMounted, type Ref } from "vue"; import { computed, onMounted, provide, ref, type Ref } from "vue";
import ReplyListItem from "./ReplyListItem.vue"; import ReplyListItem from "./ReplyListItem.vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
@ -33,9 +33,9 @@ import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { usePluginModuleStore } from "@/stores/plugin"; import { usePluginModuleStore } from "@/stores/plugin";
import type { import type {
PluginModule,
CommentSubjectRefProvider, CommentSubjectRefProvider,
CommentSubjectRefResult, CommentSubjectRefResult,
PluginModule,
} from "@halo-dev/console-shared"; } from "@halo-dev/console-shared";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
@ -48,12 +48,10 @@ const props = withDefaults(
isSelected?: boolean; isSelected?: boolean;
}>(), }>(),
{ {
comment: undefined,
isSelected: false, isSelected: false,
} }
); );
const selectedReply = ref<ListedReply>();
const hoveredReply = ref<ListedReply>(); const hoveredReply = ref<ListedReply>();
const showReplies = ref(false); const showReplies = ref(false);
const replyModal = ref(false); const replyModal = ref(false);
@ -182,23 +180,14 @@ const handleToggleShowReplies = async () => {
} }
}; };
const handleTriggerReply = () => {
replyModal.value = true;
};
const onTriggerReply = (reply: ListedReply) => {
selectedReply.value = reply;
replyModal.value = true;
};
const onReplyCreationModalClose = () => { const onReplyCreationModalClose = () => {
selectedReply.value = undefined;
queryClient.invalidateQueries({ queryKey: ["comments"] }); queryClient.invalidateQueries({ queryKey: ["comments"] });
if (showReplies.value) { if (showReplies.value) {
refetch(); refetch();
} }
replyModal.value = false;
}; };
// Subject ref processing // Subject ref processing
@ -287,10 +276,8 @@ const subjectRefResult = computed(() => {
<template> <template>
<ReplyCreationModal <ReplyCreationModal
:key="comment?.comment.metadata.name" v-if="replyModal"
v-model:visible="replyModal"
:comment="comment" :comment="comment"
:reply="selectedReply"
@close="onReplyCreationModalClose" @close="onReplyCreationModalClose"
/> />
<VEntity :is-selected="isSelected" :class="{ 'hover:bg-white': showReplies }"> <VEntity :is-selected="isSelected" :class="{ 'hover:bg-white': showReplies }">
@ -364,7 +351,7 @@ const subjectRefResult = computed(() => {
/> />
<span <span
class="select-none text-gray-700 hover:text-gray-900" class="select-none text-gray-700 hover:text-gray-900"
@click="handleTriggerReply" @click="replyModal = true"
> >
{{ $t("core.comment.operations.reply.button") }} {{ $t("core.comment.operations.reply.button") }}
</span> </span>
@ -458,8 +445,8 @@ const subjectRefResult = computed(() => {
:key="reply.reply.metadata.name" :key="reply.reply.metadata.name"
:class="{ 'hover:bg-white': showReplies }" :class="{ 'hover:bg-white': showReplies }"
:reply="reply" :reply="reply"
:comment="comment"
:replies="replies" :replies="replies"
@reply="onTriggerReply"
></ReplyListItem> ></ReplyListItem>
</div> </div>
</Transition> </Transition>

View File

@ -1,11 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import {
VModal,
VSpace,
VButton,
IconMotionLine, IconMotionLine,
Toast, Toast,
VButton,
VDropdown, VDropdown,
VModal,
VSpace,
} from "@halo-dev/components"; } from "@halo-dev/components";
import SubmitButton from "@/components/button/SubmitButton.vue"; import SubmitButton from "@/components/button/SubmitButton.vue";
import type { import type {
@ -16,9 +16,7 @@ import type {
// @ts-ignore // @ts-ignore
import { Picker } from "emoji-mart"; import { Picker } from "emoji-mart";
import i18n from "@emoji-mart/data/i18n/zh.json"; import i18n from "@emoji-mart/data/i18n/zh.json";
import { computed, nextTick, ref, watch, watchEffect } from "vue"; import { onMounted, ref } from "vue";
import { reset } from "@formkit/core";
import { cloneDeep } from "lodash-es";
import { setFocus } from "@/formkit/utils/focus"; import { setFocus } from "@/formkit/utils/focus";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
@ -27,7 +25,6 @@ const { t } = useI18n();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
visible?: boolean;
comment?: ListedComment; comment?: ListedComment;
reply?: ListedReply; reply?: ListedReply;
}>(), }>(),
@ -39,76 +36,38 @@ const props = withDefaults(
); );
const emit = defineEmits<{ const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void; (event: "close"): void;
}>(); }>();
const initialFormState: ReplyRequest = { const modal = ref();
const formState = ref<ReplyRequest>({
raw: "", raw: "",
content: "", content: "",
allowNotification: true, allowNotification: true,
quoteReply: undefined, quoteReply: undefined,
}; });
const formState = ref<ReplyRequest>(cloneDeep(initialFormState));
const saving = ref(false); const saving = ref(false);
watch( onMounted(() => {
() => formState.value.raw, setFocus("content-input");
(newValue) => {
formState.value.content = newValue;
}
);
const formId = computed(() => {
return `comment-reply-form-${[
props.comment?.comment.metadata.name,
props.reply?.reply.metadata.name,
].join("-")}`;
}); });
const contentInputId = computed(() => {
return `content-input-${[
props.comment?.comment.metadata.name,
props.reply?.reply.metadata.name,
].join("-")}`;
});
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleResetForm = () => {
formState.value = cloneDeep(initialFormState);
reset(formId.value);
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
await nextTick();
setFocus(contentInputId.value);
} else {
handleResetForm();
}
}
);
const handleCreateReply = async () => { const handleCreateReply = async () => {
try { try {
saving.value = true; saving.value = true;
if (props.reply) { if (props.reply) {
formState.value.quoteReply = props.reply.reply.metadata.name; formState.value.quoteReply = props.reply.reply.metadata.name;
} }
formState.value.content = formState.value.raw;
await apiClient.comment.createReply({ await apiClient.comment.createReply({
name: props.comment?.comment.metadata.name as string, name: props.comment?.comment.metadata.name as string,
replyRequest: formState.value, replyRequest: formState.value,
}); });
onVisibleChange(false);
modal.value.close();
Toast.success( Toast.success(
t("core.comment.reply_modal.operations.submit.toast_success") t("core.comment.reply_modal.operations.submit.toast_success")
@ -123,57 +82,56 @@ const handleCreateReply = async () => {
// Emoji picker // Emoji picker
const emojiPickerRef = ref<HTMLElement | null>(null); const emojiPickerRef = ref<HTMLElement | null>(null);
const handleCreateEmojiPicker = () => { const handleCreateEmojiPicker = async () => {
if (emojiPickerRef.value?.childElementCount) {
return;
}
const data = await import("@emoji-mart/data");
const emojiPicker = new Picker({ const emojiPicker = new Picker({
data: async () => { data: Object.assign({}, data),
const data = await import("@emoji-mart/data");
return Object.assign({}, data);
},
theme: "light", theme: "light",
autoFocus: true, autoFocus: true,
i18n: i18n, i18n: i18n,
onEmojiSelect: onEmojiSelect, onEmojiSelect: onEmojiSelect,
}); });
emojiPickerRef.value?.appendChild(emojiPicker as unknown as Node); emojiPickerRef.value?.appendChild(emojiPicker as unknown as Node);
}; };
const onEmojiSelect = (emoji: { native: string }) => { const onEmojiSelect = (emoji: { native: string }) => {
formState.value.raw += emoji.native; formState.value.raw += emoji.native;
setFocus(contentInputId.value); setFocus("content-input");
}; };
watchEffect(() => {
if (emojiPickerRef.value) {
handleCreateEmojiPicker();
}
});
</script> </script>
<template> <template>
<VModal <VModal
ref="modal"
:title="$t('core.comment.reply_modal.title')" :title="$t('core.comment.reply_modal.title')"
:visible="visible"
:width="500" :width="500"
@update:visible="onVisibleChange" @close="emit('close')"
> >
<FormKit <FormKit
:id="formId" id="create-reply-form"
:name="formId" name="create-reply-form"
type="form" type="form"
:config="{ validationVisibility: 'submit' }" :config="{ validationVisibility: 'submit' }"
@submit="handleCreateReply" @submit="handleCreateReply"
> >
<FormKit <FormKit
:id="contentInputId" id="content-input"
v-model="formState.raw" v-model="formState.raw"
type="textarea" type="textarea"
:validation-label="$t('core.comment.reply_modal.fields.content.label')" :validation-label="$t('core.comment.reply_modal.fields.content.label')"
:rows="6" :rows="6"
value=""
validation="required|length:0,1024" validation="required|length:0,1024"
></FormKit> ></FormKit>
</FormKit> </FormKit>
<div class="mt-2 flex justify-end"> <div class="mt-2 flex justify-end">
<VDropdown :classes="['!p-0']"> <VDropdown :classes="['!p-0']" @show="handleCreateEmojiPicker">
<IconMotionLine <IconMotionLine
class="h-5 w-5 cursor-pointer text-gray-500 transition-all hover:text-gray-900" class="h-5 w-5 cursor-pointer text-gray-500 transition-all hover:text-gray-900"
/> />
@ -185,14 +143,13 @@ watchEffect(() => {
<template #footer> <template #footer>
<VSpace> <VSpace>
<SubmitButton <SubmitButton
v-if="visible"
:loading="saving" :loading="saving"
type="secondary" type="secondary"
:text="$t('core.common.buttons.submit')" :text="$t('core.common.buttons.submit')"
@submit="$formkit.submit(formId)" @submit="$formkit.submit('create-reply-form')"
> >
</SubmitButton> </SubmitButton>
<VButton @click="onVisibleChange(false)"> <VButton @click="modal.close()">
{{ $t("core.common.buttons.cancel_and_shortcut") }} {{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton> </VButton>
</VSpace> </VSpace>

View File

@ -1,28 +1,30 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import {
VAvatar,
VTag,
VEntityField,
VEntity,
Dialog, Dialog,
VStatusDot,
VDropdownItem,
IconReplyLine, IconReplyLine,
Toast, Toast,
VAvatar,
VDropdownItem,
VEntity,
VEntityField,
VStatusDot,
VTag,
} from "@halo-dev/components"; } from "@halo-dev/components";
import type { ListedReply } from "@halo-dev/api-client"; import type { ListedComment, ListedReply } from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { computed, inject, type Ref } from "vue"; import { computed, inject, ref, type Ref } from "vue";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query"; import { useQueryClient } from "@tanstack/vue-query";
import ReplyCreationModal from "./ReplyCreationModal.vue";
const { t } = useI18n(); const { t } = useI18n();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
comment: ListedComment;
reply: ListedReply; reply: ListedReply;
replies?: ListedReply[]; replies?: ListedReply[];
}>(), }>(),
@ -32,10 +34,6 @@ const props = withDefaults(
} }
); );
const emit = defineEmits<{
(event: "reply", reply: ListedReply): void;
}>();
const quoteReply = computed(() => { const quoteReply = computed(() => {
const { quoteReply: replyName } = props.reply.reply.spec; const { quoteReply: replyName } = props.reply.reply.spec;
@ -90,10 +88,6 @@ const handleApprove = async () => {
} }
}; };
const handleTriggerReply = () => {
emit("reply", props.reply);
};
// Show hovered reply // Show hovered reply
const hoveredReply = inject<Ref<ListedReply | undefined>>("hoveredReply"); const hoveredReply = inject<Ref<ListedReply | undefined>>("hoveredReply");
@ -108,9 +102,25 @@ const isHoveredReply = computed(() => {
hoveredReply?.value?.reply.metadata.name === props.reply.reply.metadata.name hoveredReply?.value?.reply.metadata.name === props.reply.reply.metadata.name
); );
}); });
// Create reply
const replyModal = ref(false);
function onReplyCreationModalClose() {
queryClient.invalidateQueries({
queryKey: ["comment-replies", props.comment.comment.metadata.name],
});
replyModal.value = false;
}
</script> </script>
<template> <template>
<ReplyCreationModal
v-if="replyModal"
:comment="comment"
:reply="reply"
@close="onReplyCreationModalClose"
/>
<VEntity class="!px-0 !py-2" :class="{ 'animate-breath': isHoveredReply }"> <VEntity class="!px-0 !py-2" :class="{ 'animate-breath': isHoveredReply }">
<template #start> <template #start>
<VEntityField> <VEntityField>
@ -150,7 +160,7 @@ const isHoveredReply = computed(() => {
<div class="flex items-center gap-3 text-xs"> <div class="flex items-center gap-3 text-xs">
<span <span
class="select-none text-gray-700 hover:text-gray-900" class="select-none text-gray-700 hover:text-gray-900"
@click="handleTriggerReply" @click="replyModal = true"
> >
{{ $t("core.comment.operations.reply.button") }} {{ $t("core.comment.operations.reply.button") }}
</span> </span>

View File

@ -47,7 +47,7 @@
"@codemirror/legacy-modes": "^6.3.0", "@codemirror/legacy-modes": "^6.3.0",
"@codemirror/state": "^6.1.4", "@codemirror/state": "^6.1.4",
"@codemirror/view": "^6.5.1", "@codemirror/view": "^6.5.1",
"@emoji-mart/data": "^1.0.8", "@emoji-mart/data": "^1.2.1",
"@formkit/core": "^1.5.9", "@formkit/core": "^1.5.9",
"@formkit/i18n": "^1.5.9", "@formkit/i18n": "^1.5.9",
"@formkit/inputs": "^1.5.9", "@formkit/inputs": "^1.5.9",
@ -81,7 +81,7 @@
"colorjs.io": "^0.4.3", "colorjs.io": "^0.4.3",
"cropperjs": "^1.5.13", "cropperjs": "^1.5.13",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"emoji-mart": "^5.3.3", "emoji-mart": "^5.6.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",

View File

@ -36,8 +36,8 @@ importers:
specifier: ^6.5.1 specifier: ^6.5.1
version: 6.5.1 version: 6.5.1
'@emoji-mart/data': '@emoji-mart/data':
specifier: ^1.0.8 specifier: ^1.2.1
version: 1.0.8 version: 1.2.1
'@formkit/core': '@formkit/core':
specifier: ^1.5.9 specifier: ^1.5.9
version: 1.5.9 version: 1.5.9
@ -138,8 +138,8 @@ importers:
specifier: ^1.11.7 specifier: ^1.11.7
version: 1.11.7 version: 1.11.7
emoji-mart: emoji-mart:
specifier: ^5.3.3 specifier: ^5.6.0
version: 5.3.3 version: 5.6.0
floating-vue: floating-vue:
specifier: ^5.2.2 specifier: ^5.2.2
version: 5.2.2(vue@3.4.27(typescript@5.3.3)) version: 5.2.2(vue@3.4.27(typescript@5.3.3))
@ -1923,8 +1923,8 @@ packages:
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
'@emoji-mart/data@1.0.8': '@emoji-mart/data@1.2.1':
resolution: {integrity: sha512-AMpqLrR80dHfj8ZA6xaf8/t9reBy88vz07fBdwKVVoUX6X2Wi+R2p2uhIZFqNZe8zRd6kga3hKheqK+deElEZw==} resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
'@emotion/use-insertion-effect-with-fallbacks@1.0.1': '@emotion/use-insertion-effect-with-fallbacks@1.0.1':
resolution: {integrity: sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==} resolution: {integrity: sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==}
@ -5721,8 +5721,8 @@ packages:
element-resize-detector@1.2.4: element-resize-detector@1.2.4:
resolution: {integrity: sha512-Fl5Ftk6WwXE0wqCgNoseKWndjzZlDCwuPTcoVZfCP9R3EHQF8qUtr3YUPNETegRBOKqQKPW3n4kiIWngGi8tKg==} resolution: {integrity: sha512-Fl5Ftk6WwXE0wqCgNoseKWndjzZlDCwuPTcoVZfCP9R3EHQF8qUtr3YUPNETegRBOKqQKPW3n4kiIWngGi8tKg==}
emoji-mart@5.3.3: emoji-mart@5.6.0:
resolution: {integrity: sha512-rr3wXUimYFQ5Mf50P/5UOsRibr5JSJE3Nj4zw0aDglb3GSHzn/wGKBoXoSkjtWaji8UcmXcYn3cdilD2Eix6iQ==} resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
emoji-regex@10.3.0: emoji-regex@10.3.0:
resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==}
@ -12690,7 +12690,7 @@ snapshots:
'@discoveryjs/json-ext@0.5.7': {} '@discoveryjs/json-ext@0.5.7': {}
'@emoji-mart/data@1.0.8': {} '@emoji-mart/data@1.2.1': {}
'@emotion/use-insertion-effect-with-fallbacks@1.0.1(react@18.2.0)': '@emotion/use-insertion-effect-with-fallbacks@1.0.1(react@18.2.0)':
dependencies: dependencies:
@ -17198,7 +17198,7 @@ snapshots:
dependencies: dependencies:
batch-processor: 1.0.0 batch-processor: 1.0.0
emoji-mart@5.3.3: {} emoji-mart@5.6.0: {}
emoji-regex@10.3.0: {} emoji-regex@10.3.0: {}