refactor: use tanstack query to refactor menu related data fetching (#3714)

#### What type of PR is this?

/kind improvement

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

使用 [TanStack Query](https://github.com/TanStack/query) 重构菜单和菜单项的逻辑,修复菜单项和菜单关联可能发现混乱的问题。

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

Ref https://github.com/halo-dev/halo/issues/3360
Fixed https://github.com/halo-dev/halo/issues/3467

#### Special notes for your reviewer:

测试方式:

1. 创建若干菜单和菜单项,检查功能是否正常。

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

```release-note
修复 Console 端菜单项和菜单关联可能发现混乱的问题。
```
pull/3756/head^2
Ryan Wang 2023-04-16 23:12:13 +08:00 committed by GitHub
parent 8755c24b11
commit eb141e4966
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 107 additions and 156 deletions

View File

@ -14,7 +14,7 @@ import {
import MenuItemEditingModal from "./components/MenuItemEditingModal.vue";
import MenuItemListItem from "./components/MenuItemListItem.vue";
import MenuList from "./components/MenuList.vue";
import { onUnmounted, ref } from "vue";
import { computed, ref } from "vue";
import { apiClient } from "@/utils/api-client";
import type { Menu, MenuItem } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
@ -27,68 +27,49 @@ import {
resetMenuItemsTreePriority,
} from "./utils";
import { useDebounceFn } from "@vueuse/core";
import { onBeforeRouteLeave } from "vue-router";
import { useI18n } from "vue-i18n";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
const { t } = useI18n();
const queryClient = useQueryClient();
const menuItems = ref<MenuItem[]>([] as MenuItem[]);
const menuTreeItems = ref<MenuTreeItem[]>([] as MenuTreeItem[]);
const selectedMenu = ref<Menu>();
const selectedMenuItem = ref<MenuItem>();
const selectedParentMenuItem = ref<MenuItem>();
const loading = ref(false);
const menuListRef = ref();
const menuItemEditingModal = ref();
const refreshInterval = ref();
const handleFetchMenuItems = async (options?: { mute?: boolean }) => {
try {
clearInterval(refreshInterval.value);
if (!options?.mute) {
loading.value = true;
}
const {
data: menuItems,
isLoading,
refetch,
} = useQuery<MenuItem[]>({
queryKey: ["menu-items", selectedMenu],
queryFn: async () => {
if (!selectedMenu.value?.spec.menuItems) {
return;
return [];
}
const menuItemNames = Array.from(selectedMenu.value.spec.menuItems)?.map(
(item) => item
);
const menuItemNames = selectedMenu.value.spec.menuItems.filter(Boolean);
const { data } = await apiClient.extension.menuItem.listv1alpha1MenuItem({
page: 0,
size: 0,
fieldSelector: [`name=(${menuItemNames.join(",")})`],
});
menuItems.value = data.items;
// Build the menu tree
menuTreeItems.value = buildMenuItemsTree(data.items);
const deletedMenuItems = menuItems.value.filter(
return data.items;
},
onSuccess(data) {
menuTreeItems.value = buildMenuItemsTree(data);
},
refetchOnWindowFocus: false,
refetchInterval(data) {
const deletingMenuItems = data?.filter(
(menuItem) => !!menuItem.metadata.deletionTimestamp
);
if (deletedMenuItems.length) {
refreshInterval.value = setInterval(() => {
handleFetchMenuItems({ mute: true });
}, 3000);
}
await handleResetMenuItems();
} catch (e) {
console.error("Failed to fetch menu items", e);
} finally {
loading.value = false;
}
};
onUnmounted(() => {
clearInterval(refreshInterval.value);
});
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
return deletingMenuItems?.length ? 3000 : false;
},
enabled: computed(() => !!selectedMenu.value),
});
const handleOpenEditingModal = (menuItem: MenuTreeItem) => {
@ -120,11 +101,10 @@ const onMenuItemSaved = async (menuItem: MenuItem) => {
menuToUpdate &&
!menuToUpdate.spec.menuItems?.includes(menuItem.metadata.name)
) {
if (menuToUpdate.spec.menuItems) {
menuToUpdate.spec.menuItems.push(menuItem.metadata.name);
} else {
menuToUpdate.spec.menuItems = [menuItem.metadata.name];
}
menuToUpdate.spec.menuItems = [
...(menuToUpdate.spec.menuItems || []),
menuItem.metadata.name,
];
await apiClient.extension.menu.updatev1alpha1Menu({
name: menuToUpdate.metadata.name,
@ -132,8 +112,8 @@ const onMenuItemSaved = async (menuItem: MenuItem) => {
});
}
await menuListRef.value.handleFetchMenus();
await handleFetchMenuItems({ mute: true });
await queryClient.invalidateQueries({ queryKey: ["menus"] });
await refetch();
};
const handleUpdateInBatch = useDebounceFn(async () => {
@ -150,8 +130,8 @@ const handleUpdateInBatch = useDebounceFn(async () => {
} catch (e) {
console.error("Failed to update menu items", e);
} finally {
await menuListRef.value.handleFetchMenus();
await handleFetchMenuItems({ mute: true });
await queryClient.invalidateQueries({ queryKey: ["menus"] });
await refetch();
}
}, 300);
@ -178,33 +158,26 @@ const handleDelete = async (menuItem: MenuTreeItem) => {
await Promise.all(deleteChildrenRequests);
}
await handleFetchMenuItems();
await refetch();
// update items under menu
const menuToUpdate = cloneDeep(selectedMenu.value);
if (menuToUpdate) {
menuToUpdate.spec.menuItems = menuToUpdate.spec.menuItems?.filter(
(name) => ![menuItem.metadata.name, ...childrenNames].includes(name)
);
await apiClient.extension.menu.updatev1alpha1Menu({
name: menuToUpdate.metadata.name,
menu: menuToUpdate,
});
}
await queryClient.invalidateQueries({ queryKey: ["menus"] });
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleResetMenuItems = async () => {
if (!selectedMenu.value) {
return;
}
const menuToUpdate = cloneDeep(selectedMenu.value);
const menuItemNames = menuItems.value.map((menuItem) => {
return menuItem.metadata.name;
});
menuToUpdate.spec.menuItems = menuItemNames;
await apiClient.extension.menu.updatev1alpha1Menu({
name: menuToUpdate.metadata.name,
menu: menuToUpdate,
});
await menuListRef.value.handleFetchMenus({ mute: true });
};
</script>
<template>
<MenuItemEditingModal
@ -223,11 +196,7 @@ const handleResetMenuItems = async () => {
<div class="m-0 md:m-4">
<div class="flex flex-col gap-4 sm:flex-row">
<div class="w-96">
<MenuList
ref="menuListRef"
v-model:selected-menu="selectedMenu"
@select="handleFetchMenuItems()"
/>
<MenuList v-model:selected-menu="selectedMenu" @select="refetch()" />
</div>
<div class="flex-1">
<VCard :body-class="['!p-0']">
@ -256,15 +225,15 @@ const handleResetMenuItems = async () => {
</div>
</div>
</template>
<VLoading v-if="loading" />
<Transition v-else-if="!menuItems.length" appear name="fade">
<VLoading v-if="isLoading" />
<Transition v-else-if="!menuItems?.length" appear name="fade">
<VEmpty
:message="$t('core.menu.menu_item_empty.message')"
:title="$t('core.menu.menu_item_empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchMenuItems()">
<VButton @click="refetch()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton

View File

@ -14,23 +14,23 @@ import {
VDropdownItem,
} from "@halo-dev/components";
import MenuEditingModal from "./MenuEditingModal.vue";
import { onMounted, onUnmounted, ref } from "vue";
import { onMounted, ref } from "vue";
import type { Menu } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router";
import { usePermission } from "@/utils/permission";
import { onBeforeRouteLeave } from "vue-router";
import { useI18n } from "vue-i18n";
import { useQuery } from "@tanstack/vue-query";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const props = withDefaults(
defineProps<{
selectedMenu: Menu | null;
selectedMenu?: Menu;
}>(),
{
selectedMenu: null,
selectedMenu: undefined,
}
);
@ -39,56 +39,39 @@ const emit = defineEmits<{
(event: "update:selectedMenu", menu: Menu): void;
}>();
const menus = ref<Menu[]>([] as Menu[]);
const loading = ref(false);
const selectedMenuToUpdate = ref<Menu>();
const menuEditingModal = ref<boolean>(false);
const refreshInterval = ref();
const handleFetchMenus = async (options?: { mute?: boolean }) => {
selectedMenuToUpdate.value = undefined;
try {
clearInterval(refreshInterval.value);
if (!options?.mute) {
loading.value = true;
}
const { data } = await apiClient.extension.menu.listv1alpha1Menu();
menus.value = data.items;
// update selected menu
const {
data: menus,
isLoading,
refetch,
} = useQuery<Menu[]>({
queryKey: ["menus"],
queryFn: async () => {
const { data } = await apiClient.extension.menu.listv1alpha1Menu({
page: 0,
size: 0,
});
return data.items;
},
refetchOnWindowFocus: false,
onSuccess(data) {
if (props.selectedMenu) {
const updatedMenu = menus.value.find(
const updatedMenu = data?.find(
(menu) => menu.metadata.name === props.selectedMenu?.metadata.name
);
if (updatedMenu) {
emit("update:selectedMenu", updatedMenu);
}
}
const deletedMenus = menus.value.filter(
},
refetchInterval(data) {
const deletingMenus = data?.filter(
(menu) => !!menu.metadata.deletionTimestamp
);
if (deletedMenus.length) {
refreshInterval.value = setInterval(() => {
handleFetchMenus({ mute: true });
}, 3000);
}
} catch (e) {
console.error("Failed to fetch menus", e);
} finally {
loading.value = false;
}
};
onUnmounted(() => {
clearInterval(refreshInterval.value);
});
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
return deletingMenus?.length ? 3000 : false;
},
});
const menuQuery = useRouteQuery("menu");
@ -111,20 +94,23 @@ const handleDeleteMenu = async (menu: Menu) => {
name: menu.metadata.name,
});
const deleteItemsPromises = Array.from(menu.spec.menuItems || []).map(
(item) =>
apiClient.extension.menuItem.deletev1alpha1MenuItem({
name: item,
})
const deleteItemsPromises = menu.spec.menuItems?.map((item) =>
apiClient.extension.menuItem.deletev1alpha1MenuItem({
name: item,
})
);
if (!deleteItemsPromises) {
return;
}
await Promise.all(deleteItemsPromises);
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete menu", e);
} finally {
await handleFetchMenus();
await refetch();
}
},
});
@ -136,41 +122,39 @@ const handleOpenEditingModal = (menu?: Menu) => {
};
onMounted(async () => {
await handleFetchMenus();
await refetch();
if (menuQuery.value) {
const menu = menus.value.find((m) => m.metadata.name === menuQuery.value);
const menu = menus.value?.find((m) => m.metadata.name === menuQuery.value);
if (menu) {
handleSelect(menu);
}
return;
}
if (menus.value.length > 0) {
if (menus.value?.length) {
handleSelect(menus.value[0]);
}
});
defineExpose({
handleFetchMenus,
});
// primary menu
const primaryMenuName = ref<string>();
const { data: primaryMenuName, refetch: refetchPrimaryMenuName } = useQuery({
queryKey: ["primary-menu-name"],
queryFn: async () => {
const { data } = await apiClient.extension.configMap.getv1alpha1ConfigMap({
name: "system",
});
const handleFetchPrimaryMenuName = async () => {
const { data } = await apiClient.extension.configMap.getv1alpha1ConfigMap({
name: "system",
});
if (!data.data?.menu) {
return "";
}
if (!data.data?.menu) {
return;
}
const menuConfig = JSON.parse(data.data.menu);
const menuConfig = JSON.parse(data.data.menu);
primaryMenuName.value = menuConfig.primary;
};
return menuConfig.primary;
},
refetchOnWindowFocus: false,
});
const handleSetPrimaryMenu = async (menu: Menu) => {
const { data: systemConfigMap } =
@ -188,30 +172,28 @@ const handleSetPrimaryMenu = async (menu: Menu) => {
configMap: systemConfigMap,
});
}
await handleFetchPrimaryMenuName();
await refetchPrimaryMenuName();
Toast.success(t("core.menu.operations.set_primary.toast_success"));
};
onMounted(handleFetchPrimaryMenuName);
</script>
<template>
<MenuEditingModal
v-model:visible="menuEditingModal"
:menu="selectedMenuToUpdate"
@close="handleFetchMenus()"
@close="refetch()"
@created="handleSelect"
/>
<VCard :body-class="['!p-0']" :title="$t('core.menu.title')">
<VLoading v-if="loading" />
<Transition v-else-if="!menus.length" appear name="fade">
<VLoading v-if="isLoading" />
<Transition v-else-if="!menus?.length" appear name="fade">
<VEmpty
:message="$t('core.menu.empty.message')"
:title="$t('core.menu.empty.title')"
>
<template #actions>
<VSpace>
<VButton size="sm" @click="handleFetchMenus()">
<VButton size="sm" @click="refetch()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
</VSpace>