refactor: app category metadata to use Record<AppCategories, AppCategoryEntry> for exhaustive type safety (#29558)
* refactor: derive category metadata from single Record source of truth * test: add test for category order and entries sync * fix: check for duplicates
This commit is contained in:
@@ -12,7 +12,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import type { Icon } from "@calcom/ui/components/icon";
|
||||
import { ShellSubHeading } from "@calcom/ui/components/layout";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import AppListCardWebWrapper from "@calcom/web/modules/apps/components/AppListCardWebWrapper";
|
||||
@@ -21,6 +20,7 @@ import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { useReducer } from "react";
|
||||
import { APP_CATEGORY_ENTRIES, ActiveAppCategoryKeys } from "@calcom/app-store/_utils/getAppCategories";
|
||||
|
||||
interface IntegrationsContainerProps {
|
||||
variant?: AppCategories;
|
||||
@@ -28,6 +28,10 @@ interface IntegrationsContainerProps {
|
||||
handleDisconnect: HandleDisconnect;
|
||||
}
|
||||
|
||||
const LEGACY_CATEGORY_MAP: Partial<Record<AppCategories, ActiveAppCategoryKeys>> = {
|
||||
video: "conferencing",
|
||||
};
|
||||
|
||||
const IntegrationsContainer = ({
|
||||
variant,
|
||||
exclude,
|
||||
@@ -48,6 +52,14 @@ const IntegrationsContainer = ({
|
||||
|
||||
const updateLocationsMutation = trpc.viewer.eventTypes.bulkUpdateToDefaultLocation.useMutation();
|
||||
|
||||
const isActiveCategory = (v: AppCategories): v is ActiveAppCategoryKeys => v in APP_CATEGORY_ENTRIES;
|
||||
|
||||
const resolveEmptyStateVariant = (v?: AppCategories): ActiveAppCategoryKeys => {
|
||||
if (!v) return "other";
|
||||
if (isActiveCategory(v)) return v;
|
||||
return LEGACY_CATEGORY_MAP[v] || "other";
|
||||
};
|
||||
|
||||
const { data: eventTypesQueryData, isFetching: isEventTypesFetching } =
|
||||
trpc.viewer.eventTypes.bulkEventFetch.useQuery();
|
||||
|
||||
@@ -95,41 +107,28 @@ const IntegrationsContainer = ({
|
||||
utils.viewer.apps.getUsersDefaultConferencingApp.invalidate();
|
||||
};
|
||||
|
||||
// TODO: Refactor and reuse getAppCategories?
|
||||
const emptyIcon: Record<AppCategories, React.ComponentProps<typeof Icon>["name"]> = {
|
||||
calendar: "calendar",
|
||||
conferencing: "video",
|
||||
automation: "share-2",
|
||||
analytics: "chart-bar",
|
||||
payment: "credit-card",
|
||||
other: "grid-3x3",
|
||||
web3: "credit-card", // deprecated
|
||||
video: "video", // deprecated
|
||||
messaging: "mail",
|
||||
crm: "contact",
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => {
|
||||
if (!data.items.length) {
|
||||
const emptyHeaderCategory = getAppCategoryTitle(variant || "other", true);
|
||||
const resolvedVariant = resolveEmptyStateVariant(variant);
|
||||
const emptyHeaderCategory = getAppCategoryTitle(resolvedVariant, true);
|
||||
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon={emptyIcon[variant || "other"]}
|
||||
Icon={APP_CATEGORY_ENTRIES[resolvedVariant].icon}
|
||||
headline={t("no_category_apps", {
|
||||
category: emptyHeaderCategory,
|
||||
})}
|
||||
description={t(`no_category_apps_description_${variant || "other"}`)}
|
||||
description={t(`no_category_apps_description_${resolvedVariant}`)}
|
||||
buttonRaw={
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid={`connect-${variant || "other"}-apps`}
|
||||
href={variant ? `/apps/categories/${variant}` : "/apps/categories/other"}>
|
||||
{t(`connect_${variant || "other"}_apps`)}
|
||||
data-testid={`connect-${resolvedVariant}-apps`}
|
||||
href={`/apps/categories/${resolvedVariant}`}>
|
||||
{t(`connect_${resolvedVariant}_apps`)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -258,4 +257,4 @@ export default function InstalledApps({ category, connectedCalendars, installedC
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { APP_CATEGORY_ENTRIES, CATEGORY_ORDER } from "./getAppCategories";
|
||||
|
||||
import type { ActiveAppCategoryKeys } from "./getAppCategories"
|
||||
|
||||
describe("getAppCategories", () => {
|
||||
it("should have CATEGORY_ORDER that includes all ActiveAppCategoryKeys", () => {
|
||||
const categoryEntriesKeys = Object.keys(APP_CATEGORY_ENTRIES) as ActiveAppCategoryKeys[]
|
||||
const categoryOrderKeys = CATEGORY_ORDER
|
||||
|
||||
const missingInOrder = categoryEntriesKeys.filter((key) => !categoryOrderKeys.includes(key))
|
||||
const missingInEntries = categoryOrderKeys.filter((key) => !categoryEntriesKeys.includes(key))
|
||||
|
||||
expect(categoryOrderKeys).toHaveLength(categoryEntriesKeys.length)
|
||||
expect(missingInEntries).toHaveLength(0)
|
||||
expect(missingInOrder).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,8 @@ function getHref(baseURL: string, category: string, useQueryParam: boolean) {
|
||||
return useQueryParam ? `${baseUrlParsed.toString()}` : `${baseURL}/${category}`;
|
||||
}
|
||||
|
||||
export type ActiveAppCategoryKeys = Exclude<AppCategories, "video" | "web3">;
|
||||
|
||||
type AppCategoryEntry = {
|
||||
name: AppCategories;
|
||||
href: string;
|
||||
@@ -15,59 +17,34 @@ type AppCategoryEntry = {
|
||||
"data-testid": string;
|
||||
};
|
||||
|
||||
const getAppCategories = (baseURL: string, useQueryParam: boolean): AppCategoryEntry[] => {
|
||||
// Manually sorted alphabetically, but leaving "Other" at the end
|
||||
// TODO: Refactor and type with Record<AppCategories, AppCategoryEntry> to enforce consistency
|
||||
return [
|
||||
{
|
||||
name: "analytics",
|
||||
href: getHref(baseURL, "analytics", useQueryParam),
|
||||
icon: "chart-bar",
|
||||
"data-testid": "analytics",
|
||||
},
|
||||
{
|
||||
name: "automation",
|
||||
href: getHref(baseURL, "automation", useQueryParam),
|
||||
icon: "share-2",
|
||||
"data-testid": "automation",
|
||||
},
|
||||
{
|
||||
name: "calendar",
|
||||
href: getHref(baseURL, "calendar", useQueryParam),
|
||||
icon: "calendar",
|
||||
"data-testid": "calendar",
|
||||
},
|
||||
{
|
||||
name: "conferencing",
|
||||
href: getHref(baseURL, "conferencing", useQueryParam),
|
||||
icon: "video",
|
||||
"data-testid": "conferencing",
|
||||
},
|
||||
{
|
||||
name: "crm",
|
||||
href: getHref(baseURL, "crm", useQueryParam),
|
||||
icon: "contact",
|
||||
"data-testid": "crm",
|
||||
},
|
||||
{
|
||||
name: "messaging",
|
||||
href: getHref(baseURL, "messaging", useQueryParam),
|
||||
icon: "mail",
|
||||
"data-testid": "messaging",
|
||||
},
|
||||
{
|
||||
name: "payment",
|
||||
href: getHref(baseURL, "payment", useQueryParam),
|
||||
icon: "credit-card",
|
||||
"data-testid": "payment",
|
||||
},
|
||||
{
|
||||
name: "other",
|
||||
href: getHref(baseURL, "other", useQueryParam),
|
||||
icon: "grid-3x3",
|
||||
"data-testid": "other",
|
||||
},
|
||||
];
|
||||
export const APP_CATEGORY_ENTRIES: Record<ActiveAppCategoryKeys, Omit<AppCategoryEntry, "name">> = {
|
||||
analytics: { href: "", icon: "chart-bar", "data-testid": "analytics" },
|
||||
automation: { href: "", icon: "share-2", "data-testid": "automation" },
|
||||
calendar: { href: "", icon: "calendar", "data-testid": "calendar" },
|
||||
conferencing: { href: "", icon: "video", "data-testid": "conferencing" },
|
||||
crm: { href: "", icon: "contact", "data-testid": "crm" },
|
||||
messaging: { href: "", icon: "mail", "data-testid": "messaging" },
|
||||
payment: { href: "", icon: "credit-card", "data-testid": "payment" },
|
||||
other: { href: "", icon: "grid-3x3", "data-testid": "other" },
|
||||
};
|
||||
|
||||
export default getAppCategories;
|
||||
export const CATEGORY_ORDER = [
|
||||
"analytics",
|
||||
"automation",
|
||||
"calendar",
|
||||
"conferencing",
|
||||
"crm",
|
||||
"messaging",
|
||||
"payment",
|
||||
"other",
|
||||
] as const satisfies readonly ActiveAppCategoryKeys[];
|
||||
|
||||
|
||||
const getAppCategories = (baseURL: string, useQueryParam: boolean): AppCategoryEntry[] =>
|
||||
CATEGORY_ORDER.map((name): AppCategoryEntry => ({
|
||||
name,
|
||||
...APP_CATEGORY_ENTRIES[name],
|
||||
href: getHref(baseURL, name, useQueryParam),
|
||||
}));
|
||||
|
||||
export default getAppCategories;
|
||||
Reference in New Issue
Block a user