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
parent
a0057911c1
commit
95a793dd5a
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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")} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 && (
|
||||
|
|
Loading…
Reference in New Issue