Skeleton Loaders Implementation (#2596)

* Skeleton Loaders

* Remove Href

* Fix Height Jumping around

* Subtle Colors

* feedback by ciaran

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
pull/2475/head^2
sean-brydon 2022-04-25 18:01:51 +01:00 committed by GitHub
parent a0057911c1
commit 95a793dd5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 199 additions and 16 deletions

View File

@ -125,7 +125,7 @@ const Layout = ({
status,
plan,
...props
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan }) => {
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan; isLoading: boolean }) => {
const isEmbed = useIsEmbed();
const router = useRouter();
const { t } = useLocale();
@ -342,7 +342,7 @@ const Layout = ({
"px-4 sm:px-6 md:px-8",
props.flexChildrenContainer && "flex flex-1 flex-col"
)}>
{props.children}
{!props.isLoading ? props.children : props.customLoader}
</div>
{/* show bottom navigation for md and smaller (tablet and phones) */}
{status === "authenticated" && (
@ -403,6 +403,7 @@ type LayoutProps = {
// use when content needs to expand with flex
flexChildrenContainer?: boolean;
isPublic?: boolean;
customLoader?: ReactNode;
};
export default function Shell(props: LayoutProps) {
@ -423,8 +424,10 @@ export default function Shell(props: LayoutProps) {
const i18n = useViewerI18n();
const { status } = useSession();
if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) {
// show spinner whilst i18n is loading to avoid language flicker
const isLoading =
i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading;
if (isLoading && !props.customLoader) {
return (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
<Loader />
@ -437,7 +440,7 @@ export default function Shell(props: LayoutProps) {
return (
<>
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
<MemoizedLayout plan={user?.plan} status={status} {...props} />
<MemoizedLayout plan={user?.plan} status={status} {...props} isLoading={isLoading} />
</>
);
}

View File

@ -0,0 +1,39 @@
import React from "react";
import { ShellSubHeading } from "@components/Shell";
function SkeletonLoader() {
return (
<>
<ShellSubHeading title={<div className="h-6 w-32 rounded-sm bg-gray-100"></div>} />
<ul className="-mx-4 animate-pulse divide-y divide-neutral-200 rounded-sm border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
</>
);
}
export default SkeletonLoader;
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">
<div className="h-10 w-10 rounded-lg bg-gray-100"></div>
<div className="space-y-2">
<div className="h-4 w-32 rounded-md bg-gray-100"></div>
<div className="h-4 w-16 rounded-md bg-gray-100"></div>
</div>
</div>
</div>
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<div className="h-11 w-32 rounded-md bg-gray-100"></div>
</div>
</div>
</li>
);
}

View File

@ -0,0 +1,31 @@
import React from "react";
function SkeletonLoader() {
return (
<ul className="animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
);
}
export default SkeletonLoader;
function SkeletonItem() {
return (
<li className="group flex w-full items-center justify-between px-2 py-[23px] sm:px-6">
<div className="flex-grow truncate text-sm">
<div className="flex flex-col space-y-2">
<div className="h-4 w-32 rounded-md bg-gray-100"></div>
<div className="h-2 w-32 rounded-md bg-gray-100"></div>
</div>
</div>
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<div className="h-6 w-12 rounded-md bg-gray-100"></div>
</div>
</div>
</li>
);
}

View File

@ -0,0 +1,37 @@
import React from "react";
import BookingsShell from "@components/BookingsShell";
function SkeletonLoader() {
return (
<ul className="mt-6 animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
);
}
export default SkeletonLoader;
function SkeletonItem() {
return (
<li className="group flex w-full items-center justify-between px-2 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div className="flex">
<div className="flex flex-col space-y-2">
<div className="h-5 w-32 rounded-md bg-gray-100"></div>
<div className="h-4 w-16 rounded-md bg-gray-100"></div>
</div>
<div className="ml-4 h-5 w-24 rounded-md bg-gray-100"></div>
</div>
</div>
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<div className="h-6 w-16 rounded-md bg-gray-100"></div>
<div className="h-6 w-32 rounded-md bg-gray-100"></div>
</div>
</div>
</li>
);
}

View File

@ -0,0 +1,52 @@
import { LinkIcon } from "@heroicons/react/outline";
import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon, UserIcon } from "@heroicons/react/solid";
import React from "react";
function SkeletonLoader() {
return (
<ul className="animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
);
}
export default SkeletonLoader;
function SkeletonItem() {
return (
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div>
<div className="h-5 w-32 rounded-md bg-gray-100"></div>
</div>
<div className="text-neutral-500 dark:text-white">
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
<li className="flex items-center whitespace-nowrap">
<ClockIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></ClockIcon>
<div className="h-4 w-12 rounded-md bg-gray-100"></div>
</li>
<li className="flex items-center whitespace-nowrap">
<UserIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></UserIcon>
<div className="h-4 w-16 rounded-md bg-gray-100"></div>
</li>
</ul>
</div>
</div>
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
<div className="flex justify-between rtl:space-x-reverse">
<div className="btn-icon appearance-none">
<ExternalLinkIcon className="h-5 w-5" />
</div>
<div className="btn-icon appearance-none">
<LinkIcon className="h-5 w-5" />
</div>
<div className="btn-icon appearance-none">
<DotsHorizontalIcon className="h-5 w-5" />
</div>
</div>
</div>
</li>
);
}

View File

@ -1,3 +1,4 @@
import React, { ReactNode } from "react";
import {
QueryObserverIdleResult,
QueryObserverLoadingErrorResult,
@ -31,6 +32,7 @@ type JSXElementOrNull = JSX.Element | null;
interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
query: UseQueryResult<TData, TError>;
customLoader?: ReactNode;
error?: (
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
) => JSXElementOrNull;
@ -77,10 +79,10 @@ export function QueryCell<TData, TError extends ErrorLike>(
);
}
if (query.status === "loading") {
return opts.loading?.(query) ?? <Loader />;
return opts.loading?.(query) ?? opts.customLoader ? opts.customLoader : <Loader />;
}
if (query.status === "idle") {
return opts.idle?.(query) ?? <Loader />;
return opts.idle?.(query) ?? opts.customLoader ? opts.customLoader : <Loader />;
}
// impossible state
return null;
@ -108,6 +110,7 @@ const withQuery = <TPath extends keyof TQueryValues & string>(
>
) {
const query = trpc.useQuery(pathAndInput, params);
return <QueryCell query={query} {...opts} />;
};
};

View File

@ -18,8 +18,8 @@ import { trpc } from "@lib/trpc";
import AppsShell from "@components/AppsShell";
import { ClientSuspense } from "@components/ClientSuspense";
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
import Loader from "@components/Loader";
import Shell, { ShellSubHeading } from "@components/Shell";
import SkeletonLoader from "@components/apps/SkeletonLoader";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
import IntegrationListItem from "@components/integrations/IntegrationListItem";
@ -332,9 +332,13 @@ export default function IntegrationsPage() {
const { t } = useLocale();
return (
<Shell heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")} large>
<Shell
heading={t("installed_apps")}
subtitle={t("manage_your_connected_apps")}
large
customLoader={<SkeletonLoader />}>
<AppsShell>
<ClientSuspense fallback={<Loader />}>
<ClientSuspense fallback={<SkeletonLoader />}>
<IntegrationsContainer />
<CalendarListContainer />
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />

View File

@ -16,6 +16,7 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
import EmptyScreen from "@components/EmptyScreen";
import Shell from "@components/Shell";
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
import SkeletonLoader from "@components/availability/SkeletonLoader";
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
const { t, i18n } = useLocale();
@ -105,8 +106,12 @@ export default function AvailabilityPage() {
const { t } = useLocale();
return (
<div>
<Shell heading={t("availability")} subtitle={t("configure_availability")} CTA={<NewScheduleButton />}>
<WithQuery success={({ data }) => <AvailabilityList {...data} />} />
<Shell
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={<NewScheduleButton />}
customLoader={<SkeletonLoader />}>
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
</Shell>
</div>
);

View File

@ -12,9 +12,9 @@ import { inferQueryInput, trpc } from "@lib/trpc";
import BookingsShell from "@components/BookingsShell";
import EmptyScreen from "@components/EmptyScreen";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
@ -45,7 +45,10 @@ export default function Bookings() {
const isEmpty = !query.data?.pages[0]?.bookings.length;
return (
<Shell heading={t("bookings")} subtitle={t("bookings_description")}>
<Shell
heading={t("bookings")}
subtitle={t("bookings_description")}
customLoader={<SkeletonLoader></SkeletonLoader>}>
<WipeMyCalActionButton trpc={trpc} bookingStatus={status} bookingsEmpty={isEmpty} />
<BookingsShell>
<div className="-mx-4 flex flex-col sm:mx-auto">
@ -54,7 +57,7 @@ export default function Bookings() {
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "loading" || query.status === "idle") && <Loader />}
{(query.status === "loading" || query.status === "idle") && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<>
<div className="mt-6 overflow-hidden rounded-sm border border-b border-gray-200">

View File

@ -41,6 +41,7 @@ import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
import Avatar from "@components/ui/Avatar";
import AvatarGroup from "@components/ui/AvatarGroup";
import Badge from "@components/ui/Badge";
@ -523,8 +524,13 @@ const EventTypesPage = () => {
<title>Home | Cal.com</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")} CTA={<CTA />}>
<Shell
heading={t("event_types_page_title")}
subtitle={t("event_types_page_subtitle")}
CTA={<CTA />}
customLoader={<SkeletonLoader />}>
<WithQuery
customLoader={<SkeletonLoader />}
success={({ data }) => (
<>
{data.viewer.plan === "FREE" && !data.viewer.canAddEvents && (