first commit
This commit is contained in:
202
calcom/packages/ui/components/apps/AllApps.tsx
Normal file
202
calcom/packages/ui/components/apps/AllApps.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
calcom/packages/ui/components/apps/AppCard.tsx
Normal file
233
calcom/packages/ui/components/apps/AppCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
calcom/packages/ui/components/apps/Categories.stories.mdx
Normal file
30
calcom/packages/ui/components/apps/Categories.stories.mdx
Normal 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} />
|
||||
63
calcom/packages/ui/components/apps/Categories.tsx
Normal file
63
calcom/packages/ui/components/apps/Categories.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
calcom/packages/ui/components/apps/PopularAppsSlider.tsx
Normal file
26
calcom/packages/ui/components/apps/PopularAppsSlider.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
28
calcom/packages/ui/components/apps/RecentAppsSlider.tsx
Normal file
28
calcom/packages/ui/components/apps/RecentAppsSlider.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
51
calcom/packages/ui/components/apps/SkeletonLoader.tsx
Normal file
51
calcom/packages/ui/components/apps/SkeletonLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
calcom/packages/ui/components/apps/Slider.tsx
Normal file
88
calcom/packages/ui/components/apps/Slider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
54
calcom/packages/ui/components/apps/_storybookData.ts
Normal file
54
calcom/packages/ui/components/apps/_storybookData.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
73
calcom/packages/ui/components/apps/appCard.test.tsx
Normal file
73
calcom/packages/ui/components/apps/appCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
8
calcom/packages/ui/components/apps/index.ts
Normal file
8
calcom/packages/ui/components/apps/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user