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:
Anirban Singha
2026-06-29 20:25:39 +05:30
committed by GitHub
parent b97cd6203d
commit 5d6d4d30cc
3 changed files with 70 additions and 76 deletions
@@ -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)
})
})
+31 -54
View File
@@ -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;