In-app users admin (#8035)

* WIP

* WIP

* Update trpc-router.ts

* Fixes

* Update trpc-router.ts

* Update zod-utils.ts

* Apply suggestions from code review

Co-authored-by: Leo Giovanetti <hello@leog.me>

* Feedback and dark mode fixes

* Feedback

* Fixes

* Update UserForm.tsx

---------

Co-authored-by: Leo Giovanetti <hello@leog.me>
This commit is contained in:
Omar LópezandLeo Giovanetti authored and GitHub committed 2023-04-14 17:04:48 -07:00
1 parent 9f1fa83a97
commit 7f6847f7c7
16 files changed
+743 -4

No files matched your search

@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/users/pages/users-edit-view";
@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/users/pages/users-add-view";
@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/users/pages/users-listing-view";
@@ -1,4 +1,5 @@
{
"identity_provider": "Identity provider",
"trial_days_left": "You have $t(day, {\"count\": {{days}} }) left on your PRO trial",
"day_one": "{{count}} day",
"day_other": "{{count}} days",
@@ -0,0 +1,250 @@
import { noop } from "lodash";
import { useRouter } from "next/router";
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { nameOfDay } from "@calcom/lib/weekday";
import { Button, EmailField, Form, Label, Select, TextField, TimezoneSelect } from "@calcom/ui";
import type { UserAdminRouterOutputs } from "../server/trpc-router";
type User = UserAdminRouterOutputs["get"]["user"];
type Option<T extends string | number = string> = {
value: T;
label: string;
};
type OptionValues = {
locale: Option;
timeFormat: Option<number>;
timeZone: string;
weekStart: Option;
role: Option;
identityProvider: Option;
};
type FormValues = Pick<User, "avatar" | "name" | "username" | "email" | "bio"> & OptionValues;
export const UserForm = ({
defaultValues,
localeProp = "en",
onSubmit = noop,
submitLabel = "save",
}: {
defaultValues?: Pick<User, keyof FormValues>;
localeProp?: string;
onSubmit: (data: FormValues) => void;
submitLabel?: string;
}) => {
const router = useRouter();
const { t } = useLocale();
const localeOptions = useMemo(() => {
return (router.locales || []).map((locale) => ({
value: locale,
label: new Intl.DisplayNames(locale, { type: "language" }).of(locale) || "",
}));
}, [router.locales]);
const timeFormatOptions = [
{ value: 12, label: t("12_hour") },
{ value: 24, label: t("24_hour") },
];
const weekStartOptions = [
{ value: "Sunday", label: nameOfDay(localeProp, 0) },
{ value: "Monday", label: nameOfDay(localeProp, 1) },
{ value: "Tuesday", label: nameOfDay(localeProp, 2) },
{ value: "Wednesday", label: nameOfDay(localeProp, 3) },
{ value: "Thursday", label: nameOfDay(localeProp, 4) },
{ value: "Friday", label: nameOfDay(localeProp, 5) },
{ value: "Saturday", label: nameOfDay(localeProp, 6) },
];
const userRoleOptions = [
{ value: "USER", label: t("user") },
{ value: "ADMIN", label: t("admin") },
];
const identityProviderOptions = [
{ value: "CAL", label: "CAL" },
{ value: "GOOGLE", label: "GOOGLE" },
{ value: "SAML", label: "SAML" },
];
const form = useForm<FormValues>({
defaultValues: {
avatar: defaultValues?.avatar,
name: defaultValues?.name,
username: defaultValues?.username,
email: defaultValues?.email,
bio: defaultValues?.bio,
locale: {
value: defaultValues?.locale || localeOptions[0].value,
label: localeOptions.find((option) => option.value === localeProp)?.label || "",
},
timeFormat: {
value: defaultValues?.timeFormat || 12,
label: timeFormatOptions.find((option) => option.value === defaultValues?.timeFormat)?.label || "12",
},
timeZone: defaultValues?.timeZone || "",
weekStart: {
value: defaultValues?.weekStart || weekStartOptions[0].value,
label: nameOfDay(localeProp, defaultValues?.weekStart === "Sunday" ? 0 : 1),
},
role: {
value: defaultValues?.role || userRoleOptions[0].value,
label:
userRoleOptions.find((option) => option.value === defaultValues?.role)?.label ||
userRoleOptions[0].label,
},
identityProvider: {
value: defaultValues?.identityProvider || identityProviderOptions[0].value,
label:
identityProviderOptions.find((option) => option.value === defaultValues?.role)?.label ||
identityProviderOptions[0].label,
},
},
});
return (
<Form form={form} className="space-y-4" handleSubmit={onSubmit}>
{/* TODO: Enable Avatar uploader in a follow up */}
{/* <div className="flex items-center">
<Controller
control={form.control}
name="avatar"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={value} gravatarFallbackMd5="fallback" size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg="Change avatar"
handleAvatarChange={(newAvatar) => {
form.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
/>
</div>
</>
)}
/>
</div> */}
<Controller
name="role"
control={form.control}
render={({ field: { onChange, value } }) => (
<div>
<Label className="text-default font-medium" htmlFor="role">
{t("role")}
</Label>
<Select<{ label: string; value: string }>
value={value}
options={userRoleOptions}
onChange={onChange}
/>
</div>
)}
/>
<Controller
name="identityProvider"
control={form.control}
render={({ field: { onChange, value } }) => (
<div>
<Label className="text-default font-medium" htmlFor="identityProvider">
{t("identity_provider")}
</Label>
<Select<{ label: string; value: string }>
value={value}
options={identityProviderOptions}
onChange={onChange}
/>
</div>
)}
/>
<TextField label="Name" placeholder="example" required {...form.register("name")} />
<TextField label="Username" placeholder="example" required {...form.register("username")} />
<EmailField label="Email" placeholder="user@example.com" required {...form.register("email")} />
<TextField label="About" {...form.register("bio")} />
<Controller
name="locale"
render={({ field: { value, onChange } }) => (
<>
<Label className="text-default">
<>{t("language")}</>
</Label>
<Select<{ label: string; value: string }>
className="capitalize"
options={localeOptions}
value={value}
onChange={onChange}
/>
</>
)}
/>
<Controller
name="timeZone"
control={form.control}
render={({ field: { value } }) => (
<>
<Label className="text-default mt-8">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) form.setValue("timeZone", event.value);
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={form.control}
render={({ field: { value } }) => (
<>
<Label className="text-default mt-8">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) form.setValue("timeFormat", { ...event });
}}
/>
</>
)}
/>
<Controller
name="weekStart"
control={form.control}
render={({ field: { value } }) => (
<>
<Label className="text-default mt-8">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) form.setValue("weekStart", { ...event });
}}
/>
</>
)}
/>
<br />
<Button type="submit" color="primary">
{t(submitLabel)}
</Button>
</Form>
);
};
@@ -0,0 +1,137 @@
import { useState } from "react";
import { trpc } from "@calcom/trpc/react";
import { Badge, ConfirmationDialogContent, Dialog, DropdownActions, showToast, Table } from "@calcom/ui";
import { Edit, Trash } from "@calcom/ui/components/icon";
import { withLicenseRequired } from "../../common/components/v2/LicenseRequired";
const { Body, Cell, ColumnTitle, Header, Row } = Table;
function UsersTableBare() {
const utils = trpc.useContext();
const [data] = trpc.viewer.users.list.useSuspenseQuery();
const mutation = trpc.viewer.users.delete.useMutation({
onSuccess: async () => {
showToast("User has been deleted", "success");
await utils.viewer.users.list.invalidate();
},
onError: (err) => {
console.error(err.message);
showToast("There has been an error deleting this user.", "error");
},
onSettled: () => {
setUserToDelete(null);
},
});
const [userToDelete, setUserToDelete] = useState<number | null>(null);
return (
<div>
<Table>
<Header>
<ColumnTitle widthClassNames="w-auto">User</ColumnTitle>
<ColumnTitle>Timezone</ColumnTitle>
<ColumnTitle>Role</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">
<span className="sr-only">Edit</span>
</ColumnTitle>
</Header>
<Body>
{data.map((user) => (
<Row key={user.email}>
<Cell widthClassNames="w-auto">
{/*
Using flexbox in a table will mess with the overflow, that's why I used position absolute
to position the image.
*/}
<div className="min-h-10 relative pl-12">
<img
className="absolute left-0 top-1/2 h-10 w-10 -translate-y-1/2 rounded-full"
src={user.avatar}
alt={`Avatar of ${user.name}`}
/>
<div className="text-subtle ml-4 font-medium">
<span className="text-default">{user.name}</span>
<span className="ml-3">/{user.username}</span>
<br />
<span className="break-all">{user.email}</span>
</div>
</div>
</Cell>
<Cell>{user.timeZone}</Cell>
<Cell>
<Badge className="capitalize" variant={user.role === "ADMIN" ? "red" : "gray"}>
{user.role.toLowerCase()}
</Badge>
</Cell>
<Cell widthClassNames="w-auto">
<div className="flex w-full justify-end">
<DropdownActions
actions={[
{
id: "edit",
label: "Edit",
href: `/settings/admin/users/${user.id}/edit`,
icon: Edit,
},
// {
// id: "reset-password",
// label: "Reset Password",
// href: `/deployments/${router.query.deploymentId}/users/${user.id}/edit`,
// icon: FiLock,
// },
{
id: "delete",
label: "Delete",
color: "destructive",
onClick: () => setUserToDelete(user.id),
icon: Trash,
},
]}
/>
</div>
</Cell>
</Row>
))}
</Body>
</Table>
<DeleteUserDialog
user={userToDelete}
onClose={() => setUserToDelete(null)}
onConfirm={() => {
if (!userToDelete) return;
mutation.mutate({ userId: userToDelete });
}}
/>
</div>
);
}
const DeleteUserDialog = ({
user,
onConfirm,
onClose,
}: {
user: number | null;
onConfirm: () => void;
onClose: () => void;
}) => {
return (
// eslint-disable-next-line @typescript-eslint/no-empty-function -- noop
<Dialog name="delete-user" open={!!user} onOpenChange={(open) => (open ? () => {} : onClose())}>
<ConfirmationDialogContent
title="Delete User"
confirmBtnText="Delete"
cancelBtnText="Cancel"
variety="danger"
onConfirm={onConfirm}>
<p>Are you sure you want to delete this user?</p>
</ConfirmationDialogContent>
</Dialog>
);
};
export const UsersTable = withLicenseRequired(UsersTableBare);
@@ -0,0 +1,43 @@
import { useRouter } from "next/router";
import { getParserWithGeneric } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import { Meta, showToast } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import LicenseRequired from "../../common/components/v2/LicenseRequired";
import { UserForm } from "../components/UserForm";
import { userBodySchema } from "../schemas/userBodySchema";
const UsersAddView = () => {
const router = useRouter();
const utils = trpc.useContext();
const mutation = trpc.viewer.users.add.useMutation({
onSuccess: async () => {
showToast("User added successfully", "success");
await utils.viewer.users.list.invalidate();
router.replace(router.asPath.replace("/add", ""));
},
onError: (err) => {
console.error(err.message);
showToast("There has been an error adding this user.", "error");
},
});
return (
<LicenseRequired>
<Meta title="Add new user" description="Here you can add a new user." />
<UserForm
submitLabel="Add user"
onSubmit={async (values) => {
const parser = getParserWithGeneric(userBodySchema);
const parsedValues = parser(values);
mutation.mutate(parsedValues);
}}
/>
</LicenseRequired>
);
};
UsersAddView.getLayout = getLayout;
export default UsersAddView;
@@ -0,0 +1,70 @@
import { useRouter } from "next/router";
import { z } from "zod";
import NoSSR from "@calcom/core/components/NoSSR";
import { getParserWithGeneric } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import { Meta, showToast } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import LicenseRequired from "../../common/components/v2/LicenseRequired";
import { UserForm } from "../components/UserForm";
import { userBodySchema } from "../schemas/userBodySchema";
const userIdSchema = z.object({ id: z.coerce.number() });
const UsersEditPage = () => {
const router = useRouter();
const input = userIdSchema.safeParse(router.query);
if (!input.success) return <div>Invalid input</div>;
return <UsersEditView userId={input.data.id} />;
};
const UsersEditView = ({ userId }: { userId: number }) => {
const router = useRouter();
const [data] = trpc.viewer.users.get.useSuspenseQuery({ userId });
const { user } = data;
const utils = trpc.useContext();
const mutation = trpc.viewer.users.update.useMutation({
onSuccess: async () => {
await utils.viewer.users.list.invalidate();
await utils.viewer.users.get.invalidate();
showToast("User updated successfully", "success");
router.replace(`${router.asPath.split("/users/")[0]}/users`);
},
onError: (err) => {
console.error(err.message);
showToast("There has been an error updating this user.", "error");
},
});
return (
<LicenseRequired>
<Meta title={`Editing user: ${user.username}`} description="Here you can edit a current user." />
<NoSSR>
<UserForm
key={JSON.stringify(user)}
onSubmit={(values) => {
const parser = getParserWithGeneric(userBodySchema);
const parsedValues = parser(values);
const data: Partial<typeof parsedValues & { userId: number }> = {
...parsedValues,
userId: user.id,
};
// TODO: Add support for avatar in the API
delete data.avatar;
// Don't send username if it's the same as the current one
if (user.username === data.username) delete data.username;
mutation.mutate(data);
}}
defaultValues={user}
/>
</NoSSR>
</LicenseRequired>
);
};
UsersEditPage.getLayout = getLayout;
export default UsersEditPage;
@@ -0,0 +1,31 @@
import NoSSR from "@calcom/core/components/NoSSR";
import { Button, Meta } from "@calcom/ui";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import LicenseRequired from "../../common/components/v2/LicenseRequired";
import { UsersTable } from "../components/UsersTable";
const DeploymentUsersListPage = () => {
return (
<LicenseRequired>
<Meta
title="Users"
description="A list of all the users in your account including their name, title, email and role."
CTA={
<div className="mt-4 space-x-5 sm:mt-0 sm:ml-16 sm:flex-none">
{/* TODO: Add import users functionality */}
{/* <Button disabled>Import users</Button> */}
<Button href="/settings/admin/users/add">Add user</Button>
</div>
}
/>
<NoSSR>
<UsersTable />
</NoSSR>
</LicenseRequired>
);
};
DeploymentUsersListPage.getLayout = getLayout;
export default DeploymentUsersListPage;
@@ -0,0 +1,12 @@
import { z } from "zod";
import { optionToValueSchema } from "@calcom/prisma/zod-utils";
export const userBodySchema = z
.object({
locale: optionToValueSchema(z.string()),
role: optionToValueSchema(z.enum(["USER", "ADMIN"])),
weekStart: optionToValueSchema(z.string()),
timeFormat: optionToValueSchema(z.number()),
})
.passthrough();
@@ -0,0 +1,110 @@
import { z } from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage";
import { _UserModel as User } from "@calcom/prisma/zod";
import type { inferRouterOutputs } from "@calcom/trpc";
import { TRPCError } from "@calcom/trpc";
import { authedAdminProcedure, middleware, router } from "@calcom/trpc/server/trpc";
export type UserAdminRouter = typeof userAdminRouter;
export type UserAdminRouterOutputs = inferRouterOutputs<UserAdminRouter>;
const userIdSchema = z.object({ userId: z.coerce.number() });
const userBodySchema = User.pick({
name: true,
email: true,
username: true,
bio: true,
timeZone: true,
weekStart: true,
theme: true,
defaultScheduleId: true,
locale: true,
timeFormat: true,
// brandColor: true,
// darkBrandColor: true,
allowDynamicBooking: true,
// away: true,
role: true,
// @note: disallowing avatar changes via API for now. We can add it later if needed. User should upload image via UI.
// avatar: true,
});
/** This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url */
export function getAvatarUrlFromUser(user: {
avatar: string | null;
username: string | null;
email: string;
}) {
if (!user.avatar || !user.username) return defaultAvatarSrc({ email: user.email });
return `${WEBAPP_URL}/${user.username}/avatar.png`;
}
/** @see https://www.prisma.io/docs/concepts/components/prisma-client/excluding-fields#excluding-the-password-field */
function exclude<UserType, Key extends keyof UserType>(user: UserType, keys: Key[]): Omit<UserType, Key> {
for (const key of keys) {
delete user[key];
}
return user;
}
/** Reusable logic that checks for admin permissions and if the requested user exists */
const authedAdminWithUserMiddleware = middleware(async ({ ctx, next, rawInput }) => {
const { prisma } = ctx;
const parsed = userIdSchema.safeParse(rawInput);
if (!parsed.success) throw new TRPCError({ code: "BAD_REQUEST", message: "User id is required" });
const { userId: id } = parsed.data;
const user = await prisma.user.findUnique({ where: { id } });
if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
return next({
ctx: {
user: ctx.user,
requestedUser:
/** Don't leak the password */
exclude(user, ["password"]),
},
});
});
const authedAdminProcedureWithRequestedUser = authedAdminProcedure.use(authedAdminWithUserMiddleware);
export const userAdminRouter = router({
get: authedAdminProcedureWithRequestedUser.input(userIdSchema).query(async ({ ctx }) => {
const { requestedUser } = ctx;
return { user: requestedUser };
}),
list: authedAdminProcedure.query(async ({ ctx }) => {
const { prisma } = ctx;
// TODO: Add search, pagination, etc.
const users = await prisma.user.findMany();
return users.map((user) => ({
/** Don't leak the password */
...exclude(user, ["password"]),
/**
* FIXME: This should be either a prisma extension or middleware
* @see https://www.prisma.io/docs/concepts/components/prisma-client/middleware
* @see https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions/result
**/
avatar: getAvatarUrlFromUser(user),
}));
}),
add: authedAdminProcedure.input(userBodySchema).mutation(async ({ ctx, input }) => {
const { prisma } = ctx;
const user = await prisma.user.create({ data: input });
return { user, message: `User with id: ${user.id} added successfully` };
}),
update: authedAdminProcedureWithRequestedUser
.input(userBodySchema.partial())
.mutation(async ({ ctx, input }) => {
const { prisma, requestedUser } = ctx;
const user = await prisma.user.update({ where: { id: requestedUser.id }, data: input });
return { user, message: `User with id: ${user.id} updated successfully` };
}),
delete: authedAdminProcedureWithRequestedUser.input(userIdSchema).mutation(async ({ ctx }) => {
const { prisma, requestedUser } = ctx;
await prisma.user.delete({ where: { id: requestedUser.id } });
return { message: `User with id: ${requestedUser.id} deleted successfully` };
}),
});
@@ -4,7 +4,7 @@ import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import type { ComponentProps } from "react";
import React, { useEffect, useState } from "react";
import React, { Suspense, useEffect, useState } from "react";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
@@ -13,13 +13,14 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { VerticalTabItemProps } from "@calcom/ui";
import { Badge, Button, ErrorBoundary, VerticalTabItem, Skeleton, useMeta } from "@calcom/ui";
import { Badge, Button, ErrorBoundary, Skeleton, useMeta, VerticalTabItem } from "@calcom/ui";
import {
User,
Key,
CreditCard,
Terminal,
Users,
Loader,
Lock,
ArrowLeft,
ChevronDown,
@@ -87,7 +88,7 @@ const tabs: VerticalTabItemProps[] = [
{ name: "license", href: "/auth/setup?step=1" },
{ name: "impersonation", href: "/settings/admin/impersonation" },
{ name: "apps", href: "/settings/admin/apps/calendar" },
{ name: "users", href: "https://console.cal.com" },
{ name: "users", href: "/settings/admin/users" },
],
},
];
@@ -414,7 +415,9 @@ export default function SettingsLayout({
<div className="flex flex-1 [&>*]:flex-1">
<div className="mx-auto max-w-full justify-center md:max-w-3xl">
<ShellHeader />
<ErrorBoundary>{children}</ErrorBoundary>
<ErrorBoundary>
<Suspense fallback={<Loader />}>{children}</Suspense>
</ErrorBoundary>
</div>
</div>
</Shell>
+22
View File
@@ -450,6 +450,28 @@ export const getAccessLinkResponseSchema = z.object({
export type GetAccessLinkResponseSchema = z.infer<typeof getAccessLinkResponseSchema>;
/** Facilitates converting values from Select inputs to plain ones before submitting */
export const optionToValueSchema = <T extends z.ZodTypeAny>(valueSchema: T) =>
z
.object({
label: z.string(),
value: valueSchema,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.transform((foo) => (foo as any).value as z.infer<T>);
/**
* Allows parsing without losing original data inference.
* @url https://github.com/colinhacks/zod/discussions/1655#discussioncomment-4367368
*/
export const getParserWithGeneric =
<T extends z.ZodTypeAny>(valueSchema: T) =>
<Data>(data: Data) => {
type Output = z.infer<typeof valueSchema>;
return valueSchema.parse(data) as {
[key in keyof Data]: key extends keyof Output ? Output[key] : Data[key];
};
};
export const sendDailyVideoRecordingEmailsSchema = z.object({
recordingId: z.string(),
bookingUID: z.string(),
+2
View File
@@ -24,6 +24,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml";
import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router";
import { featureFlagRouter } from "@calcom/features/flags/server/router";
import { insightsRouter } from "@calcom/features/insights/server/trpc-router";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
@@ -1339,5 +1340,6 @@ export const viewerRouter = mergeRouters(
features: featureFlagRouter,
payments: paymentsRouter,
appsRouter,
users: userAdminRouter,
})
);
+54
View File
@@ -0,0 +1,54 @@
import { classNames } from "@calcom/lib";
interface TableProps {
children: React.ReactNode;
}
interface DynamicWidth {
widthClassNames?: string;
}
const Header = ({ children }: TableProps) => (
<thead className="rounded-md">
<tr className="bg-default">{children}</tr>
</thead>
);
const ColumnTitle = ({ children, widthClassNames }: TableProps & DynamicWidth) => (
<th
scope="col"
className={classNames(
"text-default p-3 text-left text-xs font-medium uppercase",
!widthClassNames ? "w-auto" : widthClassNames
)}>
{children}
</th>
);
const Body = ({ children }: TableProps) => (
<tbody className="divide-subtle divide-y rounded-md">{children}</tbody>
);
const Row = ({ children }: TableProps) => <tr>{children}</tr>;
const Cell = ({ children, widthClassNames }: TableProps & DynamicWidth) => (
<td
className={classNames(
"text-default relative py-2 px-3 text-sm font-medium",
!widthClassNames ? "w-auto" : widthClassNames
)}>
{children}
</td>
);
export const Table = ({ children }: TableProps) => (
<div className="bg-default border-subtle overflow-x-auto rounded-md border">
<table className="divide-subtle w-full divide-y rounded-md">{children}</table>
</div>
);
Table.Header = Header;
Table.ColumnTitle = ColumnTitle;
Table.Body = Body;
Table.Row = Row;
Table.Cell = Cell;
+1
View File
@@ -72,6 +72,7 @@ export {
export { TopBanner } from "./components/top-banner";
export type { TopBannerProps } from "./components/top-banner";
export { AnimatedPopover, MeetingTimeInTimezones } from "./components/popover";
export { Table } from "./components/table/Table";
export { TableActions, DropdownActions } from "./components/table/TableActions";
export type { ActionType } from "./components/table/TableActions";
export { ErrorBoundary } from "./components/errorBoundary";