2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { AppCategories } from "@prisma/client";
import { usePathname, useRouter } from "next/navigation";
import type { UIEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { classNames } from "@calcom/lib";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
import { Icon } from "../..";
import { EmptyScreen } from "../empty-screen";
import { AppCard } from "./AppCard";
export function useShouldShowArrows() {
const ref = useRef<HTMLUListElement>(null);
const [showArrowScroll, setShowArrowScroll] = useState({
left: false,
right: false,
});
useEffect(() => {
const appCategoryList = ref.current;
if (appCategoryList && appCategoryList.scrollWidth > appCategoryList.clientWidth) {
setShowArrowScroll({ left: false, right: true });
}
}, []);
const calculateScroll = (e: UIEvent<HTMLUListElement>) => {
setShowArrowScroll({
left: e.currentTarget.scrollLeft > 0,
right:
Math.floor(e.currentTarget.scrollWidth) - Math.floor(e.currentTarget.offsetWidth) !==
Math.floor(e.currentTarget.scrollLeft),
});
};
return { ref, calculateScroll, leftVisible: showArrowScroll.left, rightVisible: showArrowScroll.right };
}
type AllAppsPropsType = {
apps: (App & { credentials?: Credential[] })[];
searchText?: string;
categories: string[];
userAdminTeams?: UserAdminTeams;
};
interface CategoryTabProps {
selectedCategory: string | null;
categories: string[];
searchText?: string;
}
function CategoryTab({ selectedCategory, categories, searchText }: CategoryTabProps) {
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
const { ref, calculateScroll, leftVisible, rightVisible } = useShouldShowArrows();
const handleLeft = () => {
if (ref.current) {
ref.current.scrollLeft -= 100;
}
};
const handleRight = () => {
if (ref.current) {
ref.current.scrollLeft += 100;
}
};
return (
<div className="relative mb-4 flex flex-col justify-between lg:flex-row lg:items-center">
<h2 className="text-emphasis hidden text-base font-semibold leading-none sm:block">
{searchText
? t("search")
: t("category_apps", {
category:
(selectedCategory && selectedCategory[0].toUpperCase() + selectedCategory.slice(1)) ||
t("all"),
})}
</h2>
{leftVisible && (
<button onClick={handleLeft} className="absolute bottom-0 flex md:-top-1 md:left-1/2">
<div className="bg-default flex h-12 w-5 items-center justify-end">
<Icon name="chevron-left" className="text-subtle h-4 w-4" />
</div>
<div className="to-default flex h-12 w-5 bg-gradient-to-l from-transparent" />
</button>
)}
<ul
className="no-scrollbar mt-3 flex max-w-full space-x-1 overflow-x-auto lg:mt-0 lg:max-w-[50%]"
onScroll={(e) => calculateScroll(e)}
ref={ref}>
<li
onClick={() => {
if (pathname !== null) {
router.replace(pathname);
}
}}
className={classNames(
selectedCategory === null ? "bg-emphasis text-default" : "bg-muted text-emphasis",
"hover:bg-emphasis min-w-max rounded-md px-4 py-2.5 text-sm font-medium transition hover:cursor-pointer"
)}>
{t("all")}
</li>
{categories.map((cat, pos) => (
<li
key={pos}
onClick={() => {
if (selectedCategory === cat) {
if (pathname !== null) {
router.replace(pathname);
}
} else {
const _searchParams = new URLSearchParams(searchParams ?? undefined);
_searchParams.set("category", cat);
router.replace(`${pathname}?${_searchParams.toString()}`);
}
}}
className={classNames(
selectedCategory === cat ? "bg-emphasis text-default" : "bg-muted text-emphasis",
"hover:bg-emphasis rounded-md px-4 py-2.5 text-sm font-medium transition hover:cursor-pointer"
)}>
{cat[0].toUpperCase() + cat.slice(1)}
</li>
))}
</ul>
{rightVisible && (
<button onClick={handleRight} className="absolute bottom-0 right-0 flex md:-top-1">
<div className="to-default flex h-12 w-5 bg-gradient-to-r from-transparent" />
<div className="bg-default flex h-12 w-5 items-center justify-end">
<Icon name="chevron-right" className="text-subtle h-4 w-4" />
</div>
</button>
)}
</div>
);
}
export function AllApps({ apps, searchText, categories, userAdminTeams }: AllAppsPropsType) {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [appsContainerRef, enableAnimation] = useAutoAnimate<HTMLDivElement>();
const categoryQuery = searchParams?.get("category");
if (searchText) {
enableAnimation && enableAnimation(false);
}
useEffect(() => {
const queryCategory =
typeof categoryQuery === "string" && categories.includes(categoryQuery) ? categoryQuery : null;
setSelectedCategory(queryCategory);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryQuery]);
const filteredApps = apps
.filter((app) =>
selectedCategory !== null
? app.categories
? app.categories.includes(selectedCategory as AppCategories)
: app.category === selectedCategory
: true
)
.filter((app) => (searchText ? app.name.toLowerCase().includes(searchText.toLowerCase()) : true))
.sort(function (a, b) {
if (a.name < b.name) return -1;
else if (a.name > b.name) return 1;
return 0;
});
return (
<div>
<CategoryTab selectedCategory={selectedCategory} searchText={searchText} categories={categories} />
{filteredApps.length ? (
<div
className="grid gap-3 lg:grid-cols-4 [@media(max-width:1270px)]:grid-cols-3 [@media(max-width:500px)]:grid-cols-1 [@media(max-width:730px)]:grid-cols-1"
ref={appsContainerRef}>
{filteredApps.map((app) => (
<AppCard
key={app.name}
app={app}
searchText={searchText}
credentials={app.credentials}
userAdminTeams={userAdminTeams}
/>
))}{" "}
</div>
) : (
<EmptyScreen
Icon="search"
headline={t("no_results")}
description={searchText ? searchText?.toString() : ""}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import classNames from "@calcom/lib/classNames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
import type { ButtonProps } from "@calcom/ui";
import { Badge, showToast } from "@calcom/ui";
import { Button } from "../button";
interface AppCardProps {
app: App;
credentials?: Credential[];
searchText?: string;
userAdminTeams?: UserAdminTeams;
}
export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCardProps) {
const { t } = useLocale();
const router = useRouter();
const allowedMultipleInstalls = app.categories && app.categories.indexOf("calendar") > -1;
const appAdded = (credentials && credentials.length) || 0;
const enabledOnTeams = doesAppSupportTeamInstall({
appCategories: app.categories,
concurrentMeetings: app.concurrentMeetings,
isPaid: !!app.paid,
});
const appInstalled = enabledOnTeams && userAdminTeams ? userAdminTeams.length < appAdded : appAdded > 0;
const mutation = useAddAppMutation(null, {
onSuccess: (data) => {
if (data?.setupPending) return;
setIsLoading(false);
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
setIsLoading(false);
},
});
const [searchTextIndex, setSearchTextIndex] = useState<number | undefined>(undefined);
/**
* @todo Refactor to eliminate the isLoading state by using mutation.isPending directly.
* Currently, the isLoading state is used to manage the loading indicator due to the delay in loading the next page,
* which is caused by heavy queries in getServersideProps. This causes the loader to turn off before the page changes.
*/
const [isLoading, setIsLoading] = useState<boolean>(mutation.isPending);
useEffect(() => {
setSearchTextIndex(searchText ? app.name.toLowerCase().indexOf(searchText.toLowerCase()) : undefined);
}, [app.name, searchText]);
const handleAppInstall = () => {
setIsLoading(true);
if (isConferencing(app.categories)) {
mutation.mutate({
type: app.type,
variant: app.variant,
slug: app.slug,
returnTo:
WEBAPP_URL +
getAppOnboardingUrl({
slug: app.slug,
step: AppOnboardingSteps.EVENT_TYPES_STEP,
}),
});
} else if (
!doesAppSupportTeamInstall({
appCategories: app.categories,
concurrentMeetings: app.concurrentMeetings,
isPaid: !!app.paid,
})
) {
mutation.mutate({ type: app.type });
} else {
router.push(getAppOnboardingUrl({ slug: app.slug, step: AppOnboardingSteps.ACCOUNTS_STEP }));
}
};
return (
<div className="border-subtle relative flex h-64 flex-col rounded-md border p-5">
<div className="flex">
<img
src={app.logo}
alt={`${app.name} Logo`}
className={classNames(
app.logo.includes("-dark") && "dark:invert",
"mb-4 h-12 w-12 rounded-sm" // TODO: Maybe find a better way to handle this @Hariom?
)}
/>
</div>
<div className="flex items-center">
<h3 className="text-emphasis font-medium">
{searchTextIndex != undefined && searchText ? (
<>
{app.name.substring(0, searchTextIndex)}
<span className="bg-yellow-300" data-testid="highlighted-text">
{app.name.substring(searchTextIndex, searchTextIndex + searchText.length)}
</span>
{app.name.substring(searchTextIndex + searchText.length)}
</>
) : (
app.name
)}
</h3>
</div>
{/* TODO: add reviews <div className="flex text-sm text-default">
<span>{props.rating} stars</span> <Icon name="star" className="ml-1 mt-0.5 h-4 w-4 text-yellow-600" />
<span className="pl-1 text-subtle">{props.reviews} reviews</span>
</div> */}
<p
className="text-default mt-2 flex-grow text-sm"
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: "3",
}}>
{app.description}
</p>
<div className="mt-5 flex max-w-full flex-row justify-between gap-2">
<Button
color="secondary"
className="flex w-32 flex-grow justify-center"
href={`/apps/${app.slug}`}
data-testid={`app-store-app-card-${app.slug}`}>
{t("details")}
</Button>
{app.isGlobal || (credentials && credentials.length > 0 && allowedMultipleInstalls)
? !app.isGlobal && (
<InstallAppButton
type={app.type}
teamsPlanRequired={app.teamsPlanRequired}
disableInstall={!!app.dependencies && !app.dependencyData?.some((data) => !data.installed)}
wrapperClassName="[@media(max-width:260px)]:w-full"
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
...props,
onClick: () => {
handleAppInstall();
},
loading: isLoading,
};
}
return <InstallAppButtonChild paid={app.paid} {...props} />;
}}
/>
)
: credentials &&
!appInstalled && (
<InstallAppButton
type={app.type}
wrapperClassName="[@media(max-width:260px)]:w-full"
disableInstall={!!app.dependencies && app.dependencyData?.some((data) => !data.installed)}
teamsPlanRequired={app.teamsPlanRequired}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
...props,
disabled: !!props.disabled,
onClick: () => {
handleAppInstall();
},
loading: isLoading,
};
}
return <InstallAppButtonChild paid={app.paid} {...props} />;
}}
/>
)}
</div>
<div className="max-w-44 absolute right-0 mr-4 flex flex-wrap justify-end gap-1">
{appAdded > 0 ? <Badge variant="green">{t("installed", { count: appAdded })}</Badge> : null}
{app.isTemplate && (
<span className="bg-error rounded-md px-2 py-1 text-sm font-normal text-red-800">Template</span>
)}
{(app.isDefault || (!app.isDefault && app.isGlobal)) && (
<span className="bg-subtle text-emphasis flex items-center rounded-md px-2 py-1 text-sm font-normal">
{t("default")}
</span>
)}
</div>
</div>
);
}
const InstallAppButtonChild = ({
paid,
...props
}: {
paid: App["paid"];
} & ButtonProps) => {
const { t } = useLocale();
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
if (paid) {
return (
<Button
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon="plus"
data-testid="install-app-button"
{...props}>
{paid.trial ? t("start_paid_trial") : t("subscribe")}
</Button>
);
}
return (
<Button
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon="plus"
data-testid="install-app-button"
{...props}
size="base">
{t("install")}
</Button>
);
};

View File

@@ -0,0 +1,30 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { AppStoreCategories as Categories } from "./Categories";
import { _SBAppCategoryList } from "./_storybookData";
<Meta title="UI/apps/Categories" component={Categories} />
<Title title="Categories" suffix="Brief" subtitle="Version 2.0 — Last Update: 03 Jan 2023" />
## Definition
Categories that is used in our appstore.
<CustomArgsTable of={Categories} />
## Examples
We don't currently mock translations in storybook so the stories will display placeholder text.
<Categories categories={_SBAppCategoryList} />

View File

@@ -0,0 +1,63 @@
import Image from "next/image";
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "../..";
import { SkeletonText } from "../skeleton";
import { Slider } from "./Slider";
export function AppStoreCategories({
categories,
}: {
categories: {
name: string;
count: number;
}[];
}) {
const { t, isLocaleReady } = useLocale();
return (
<div>
<Slider
title={t("featured_categories")}
items={categories}
itemKey={(category) => category.name}
options={{
perView: 5,
breakpoints: {
768 /* and below */: {
perView: 2,
},
},
}}
renderItem={(category) => (
<Link
key={category.name}
href={`/apps/categories/${category.name}`}
data-testid={`app-store-category-${category.name}`}
className="relative flex rounded-md"
style={{ background: "radial-gradient(farthest-side at top right, #a2abbe 0%, #E3E3E3 100%)" }}>
<div className="dark:bg-muted light:bg-[url('/noise.svg')] dark:from-subtle dark:to-muted w-full self-center bg-cover bg-center bg-no-repeat px-6 py-4 dark:bg-gradient-to-tr">
<Image
src={`/app-categories/${category.name}.svg`}
width={100}
height={100}
alt={category.name}
className="dark:invert"
/>
{isLocaleReady ? (
<h3 className="text-emphasis text-sm font-semibold capitalize">{category.name}</h3>
) : (
<SkeletonText invisible />
)}
<p className="text-subtle pt-2 text-sm font-medium">
{isLocaleReady ? t("number_apps", { count: category.count }) : <SkeletonText invisible />}{" "}
<Icon name="arrow-right" className="inline-block h-4 w-4" />
</p>
</div>
</Link>
)}
/>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import { AppCard } from "./AppCard";
import { Slider } from "./Slider";
export const PopularAppsSlider = <T extends App>({ items }: { items: T[] }) => {
const { t } = useLocale();
return (
<Slider<T>
title={t("most_popular")}
items={items.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))}
itemKey={(app) => app.name}
options={{
perView: 3,
breakpoints: {
768 /* and below */: {
perView: 1,
},
},
}}
renderItem={(app) => <AppCard app={app} />}
/>
);
};

View File

@@ -0,0 +1,28 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import { AppCard } from "./AppCard";
import { Slider } from "./Slider";
export const RecentAppsSlider = <T extends App>({ items }: { items: T[] }) => {
const { t } = useLocale();
return (
<Slider<T>
title={t("recently_added")}
items={items.sort(
(a, b) => new Date(b?.createdAt || 0).valueOf() - new Date(a?.createdAt || 0).valueOf()
)}
itemKey={(app) => app.name}
options={{
perView: 3,
breakpoints: {
768 /* and below */: {
perView: 1,
},
},
}}
renderItem={(app) => <AppCard app={app} />}
/>
);
};

View File

@@ -0,0 +1,51 @@
import { ShellSubHeading } from "../layout";
import Meta from "../meta/Meta";
import { SkeletonText } from "../skeleton";
export function SkeletonLoader({
className,
title,
description,
}: {
className?: string;
title?: string;
description?: string;
}) {
return (
<>
<ShellSubHeading title={<div className="bg-subtle h-6 w-32" />} {...{ className }} />
{title && description && <Meta title={title} description={description} />}
<ul className="bg-default border-subtle divide-subtle -mx-4 animate-pulse divide-y rounded-md border sm:mx-0 sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
</>
);
}
function SkeletonItem() {
return (
<li className="group flex w-full items-center justify-between p-3">
<div className="flex-grow truncate text-sm">
<div className="flex justify-start space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-10 w-10" />
<div className="space-y-1">
<div>
<SkeletonText className="h-4 w-16" />
</div>
<div>
<SkeletonText className="h-4 w-32" />
</div>
</div>
</div>
</div>
<div className="mt-4 hidden flex-shrink-0 sm:ml-5 sm:mt-0 lg:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-11 w-32" />
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,88 @@
import type { Options } from "@glidejs/glide";
import Glide from "@glidejs/glide";
import "@glidejs/glide/dist/css/glide.core.min.css";
import "@glidejs/glide/dist/css/glide.theme.min.css";
import type { ComponentProps, FC } from "react";
import { useEffect, useRef } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "../..";
import { SkeletonText } from "../skeleton";
const SliderButton: FC<ComponentProps<"button">> = (props) => {
const { children, ...rest } = props;
return (
<button className="hover:bg-subtle text-default rounded p-2.5 transition" {...rest}>
{children}
</button>
);
};
export const Slider = <T extends string | unknown>({
title = "",
className = "",
items,
itemKey = (item) => `${item}`,
renderItem,
options = {},
}: {
title?: string;
className?: string;
items: T[];
itemKey?: (item: T) => string;
renderItem?: (item: T) => JSX.Element;
options?: Options;
}) => {
const glide = useRef(null);
const slider = useRef<Glide.Properties | null>(null);
const { isLocaleReady } = useLocale();
useEffect(() => {
if (glide.current) {
slider.current = new Glide(glide.current, {
type: "carousel",
...options,
}).mount();
}
return () => slider.current?.destroy();
}, [options]);
return (
<div className={`mb-2 ${className}`}>
<div className="glide" ref={glide}>
<div className="flex cursor-default items-center pb-3">
{isLocaleReady ? (
title && (
<div>
<h2 className="text-emphasis mt-0 text-base font-semibold leading-none">{title}</h2>
</div>
)
) : (
<SkeletonText className="h-4 w-24" />
)}
<div className="glide__arrows ml-auto flex items-center gap-x-1" data-glide-el="controls">
<SliderButton data-glide-dir="<">
<Icon name="arrow-left" className="h-5 w-5" />
</SliderButton>
<SliderButton data-glide-dir=">">
<Icon name="arrow-right" className="h-5 w-5" />
</SliderButton>
</div>
</div>
<div className="glide__track" data-glide-el="track">
<ul className="glide__slides">
{items.map((item) => {
if (typeof renderItem !== "function") return null;
return (
<li key={itemKey(item)} className="glide__slide h-auto pl-0">
{renderItem(item)}
</li>
);
})}
</ul>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
import type { AppFrontendPayload as App } from "@calcom/types/App";
export const _SBApps: App[] = [
{
name: "Google Calendar",
description: "Google Calendar",
installed: true,
type: "google_calendar",
title: "Google Calendar",
variant: "calendar",
category: "calendar",
categories: ["calendar"],
logo: "/api/app-store/googlecalendar/icon.svg",
publisher: "BLS media",
slug: "google-calendar",
url: "https://bls.media/",
email: "hello@bls-media.de",
dirName: "googlecalendar",
},
{
name: "Zoom Video",
description: "Zoom Video",
type: "zoom_video",
categories: ["video"],
variant: "conferencing",
logo: "/api/app-store/zoomvideo/icon.svg",
publisher: "BLS media",
url: "https://zoom.us/",
category: "video",
slug: "zoom",
title: "Zoom Video",
email: "hello@bls-media.de",
appData: {
location: {
default: false,
linkType: "dynamic",
type: "integrations:zoom",
label: "Zoom Video",
},
},
dirName: "zoomvideo",
},
];
export const _SBAppCategoryList = [
{
name: "Calendar",
count: 1,
},
{
name: "Video",
count: 5,
},
];

View File

@@ -0,0 +1,73 @@
/* eslint-disable playwright/missing-playwright-await */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type { AppFrontendPayload } from "@calcom/types/App";
import { AppCard } from "./AppCard";
describe("Tests for AppCard component", () => {
const mockApp: AppFrontendPayload = {
logo: "/path/to/logo.png",
name: "Test App",
slug: "test-app",
description: "Test description for the app.",
categories: ["calendar"],
concurrentMeetings: true,
teamsPlanRequired: { upgradeUrl: "test" },
type: "test_calendar",
variant: "calendar",
publisher: "test",
url: "test",
email: "test",
};
// Abstracted render function
const renderAppCard = (appProps = {}, appCardProps = {}) => {
const appData = { ...mockApp, ...appProps };
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AppCard app={appData} {...appCardProps} />;
</QueryClientProvider>
);
};
describe("Tests for app description", () => {
test("Should render the app name correctly and display app logo with correct alt text", () => {
renderAppCard();
const appLogo = screen.getByAltText("Test App Logo");
const appName = screen.getByText("Test App");
expect(appLogo).toBeInTheDocument();
expect(appLogo.getAttribute("src")).toBe("/path/to/logo.png");
expect(appName).toBeInTheDocument();
});
test("Should render details button with correct href", () => {
renderAppCard();
const detailsButton = screen.getByText("details");
expect(detailsButton).toBeInTheDocument();
expect(detailsButton.closest("a")).toHaveAttribute("href", "/apps/test-app");
});
test("Should highlight the app name based on searchText", () => {
renderAppCard({}, { searchText: "test" });
const highlightedText = screen.getByTestId("highlighted-text");
expect(highlightedText).toBeInTheDocument();
});
});
describe("Tests for app categories", () => {
test("Should show 'Template' badge if app is a template", () => {
renderAppCard({ isTemplate: true });
const templateBadge = screen.getByText("Template");
expect(templateBadge).toBeInTheDocument();
});
test("Should show 'default' badge if app is default or global", () => {
renderAppCard({ isDefault: true });
const defaultBadge = screen.getByText("default");
expect(defaultBadge).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,8 @@
export { useShouldShowArrows, AllApps } from "./AllApps";
export { AppCard } from "./AppCard";
export { Slider } from "./Slider";
export { SkeletonLoader as AppSkeletonLoader } from "./SkeletonLoader";
export { SkeletonLoader } from "./SkeletonLoader";
export { PopularAppsSlider } from "./PopularAppsSlider";
export { RecentAppsSlider } from "./RecentAppsSlider";
export { AppStoreCategories } from "./Categories";