From 7f6847f7c7c21dd32e5d041876a051e5f3ce33bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Fri, 14 Apr 2023 17:04:48 -0700 Subject: [PATCH] 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 * Feedback and dark mode fixes * Feedback * Fixes * Update UserForm.tsx --------- Co-authored-by: Leo Giovanetti --- .../pages/settings/admin/users/[id]/edit.tsx | 1 + apps/web/pages/settings/admin/users/add.tsx | 1 + apps/web/pages/settings/admin/users/index.tsx | 1 + apps/web/public/static/locales/en/common.json | 1 + .../features/ee/users/components/UserForm.tsx | 250 ++++++++++++++++++ .../ee/users/components/UsersTable.tsx | 137 ++++++++++ .../ee/users/pages/users-add-view.tsx | 43 +++ .../ee/users/pages/users-edit-view.tsx | 70 +++++ .../ee/users/pages/users-listing-view.tsx | 31 +++ .../ee/users/schemas/userBodySchema.ts | 12 + .../features/ee/users/server/trpc-router.ts | 110 ++++++++ .../settings/layouts/SettingsLayout.tsx | 11 +- packages/prisma/zod-utils.ts | 22 ++ packages/trpc/server/routers/viewer.tsx | 2 + packages/ui/components/table/Table.tsx | 54 ++++ packages/ui/index.tsx | 1 + 16 files changed, 743 insertions(+), 4 deletions(-) create mode 100644 apps/web/pages/settings/admin/users/[id]/edit.tsx create mode 100644 apps/web/pages/settings/admin/users/add.tsx create mode 100644 apps/web/pages/settings/admin/users/index.tsx create mode 100644 packages/features/ee/users/components/UserForm.tsx create mode 100644 packages/features/ee/users/components/UsersTable.tsx create mode 100644 packages/features/ee/users/pages/users-add-view.tsx create mode 100644 packages/features/ee/users/pages/users-edit-view.tsx create mode 100644 packages/features/ee/users/pages/users-listing-view.tsx create mode 100644 packages/features/ee/users/schemas/userBodySchema.ts create mode 100644 packages/features/ee/users/server/trpc-router.ts create mode 100644 packages/ui/components/table/Table.tsx diff --git a/apps/web/pages/settings/admin/users/[id]/edit.tsx b/apps/web/pages/settings/admin/users/[id]/edit.tsx new file mode 100644 index 0000000000..318b3a052c --- /dev/null +++ b/apps/web/pages/settings/admin/users/[id]/edit.tsx @@ -0,0 +1 @@ +export { default } from "@calcom/features/ee/users/pages/users-edit-view"; diff --git a/apps/web/pages/settings/admin/users/add.tsx b/apps/web/pages/settings/admin/users/add.tsx new file mode 100644 index 0000000000..c6c8c3cc3f --- /dev/null +++ b/apps/web/pages/settings/admin/users/add.tsx @@ -0,0 +1 @@ +export { default } from "@calcom/features/ee/users/pages/users-add-view"; diff --git a/apps/web/pages/settings/admin/users/index.tsx b/apps/web/pages/settings/admin/users/index.tsx new file mode 100644 index 0000000000..699d3eb327 --- /dev/null +++ b/apps/web/pages/settings/admin/users/index.tsx @@ -0,0 +1 @@ +export { default } from "@calcom/features/ee/users/pages/users-listing-view"; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8f32bb29bf..50651d2c9b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/ee/users/components/UserForm.tsx b/packages/features/ee/users/components/UserForm.tsx new file mode 100644 index 0000000000..87f387cab9 --- /dev/null +++ b/packages/features/ee/users/components/UserForm.tsx @@ -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 = { + value: T; + label: string; +}; + +type OptionValues = { + locale: Option; + timeFormat: Option; + timeZone: string; + weekStart: Option; + role: Option; + identityProvider: Option; +}; + +type FormValues = Pick & OptionValues; + +export const UserForm = ({ + defaultValues, + localeProp = "en", + onSubmit = noop, + submitLabel = "save", +}: { + defaultValues?: Pick; + 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({ + 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 ( +
+ {/* TODO: Enable Avatar uploader in a follow up */} + {/*
+ ( + <> + +
+ { + form.setValue("avatar", newAvatar, { shouldDirty: true }); + }} + imageSrc={value || undefined} + /> +
+ + )} + /> +
*/} + ( +
+ + + value={value} + options={userRoleOptions} + onChange={onChange} + /> +
+ )} + /> + ( +
+ + + value={value} + options={identityProviderOptions} + onChange={onChange} + /> +
+ )} + /> + + + + + ( + <> + + + className="capitalize" + options={localeOptions} + value={value} + onChange={onChange} + /> + + )} + /> + ( + <> + + { + if (event) form.setValue("timeZone", event.value); + }} + /> + + )} + /> + ( + <> + + { + if (event) form.setValue("weekStart", { ...event }); + }} + /> + + )} + /> + +
+ + + ); +}; diff --git a/packages/features/ee/users/components/UsersTable.tsx b/packages/features/ee/users/components/UsersTable.tsx new file mode 100644 index 0000000000..688bf0b2b5 --- /dev/null +++ b/packages/features/ee/users/components/UsersTable.tsx @@ -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(null); + + return ( +
+ +
+ User + Timezone + Role + + Edit + +
+ + + {data.map((user) => ( + + + {/* + Using flexbox in a table will mess with the overflow, that's why I used position absolute + to position the image. + */} +
+ {`Avatar + +
+ {user.name} + /{user.username} +
+ {user.email} +
+
+
+ {user.timeZone} + + + {user.role.toLowerCase()} + + + +
+ setUserToDelete(user.id), + icon: Trash, + }, + ]} + /> +
+
+
+ ))} + +
+ setUserToDelete(null)} + onConfirm={() => { + if (!userToDelete) return; + mutation.mutate({ userId: userToDelete }); + }} + /> +
+ ); +} + +const DeleteUserDialog = ({ + user, + onConfirm, + onClose, +}: { + user: number | null; + onConfirm: () => void; + onClose: () => void; +}) => { + return ( + // eslint-disable-next-line @typescript-eslint/no-empty-function -- noop + (open ? () => {} : onClose())}> + +

Are you sure you want to delete this user?

+
+
+ ); +}; + +export const UsersTable = withLicenseRequired(UsersTableBare); diff --git a/packages/features/ee/users/pages/users-add-view.tsx b/packages/features/ee/users/pages/users-add-view.tsx new file mode 100644 index 0000000000..cce6458243 --- /dev/null +++ b/packages/features/ee/users/pages/users-add-view.tsx @@ -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 ( + + + { + const parser = getParserWithGeneric(userBodySchema); + const parsedValues = parser(values); + mutation.mutate(parsedValues); + }} + /> + + ); +}; + +UsersAddView.getLayout = getLayout; + +export default UsersAddView; diff --git a/packages/features/ee/users/pages/users-edit-view.tsx b/packages/features/ee/users/pages/users-edit-view.tsx new file mode 100644 index 0000000000..438ee9bfe0 --- /dev/null +++ b/packages/features/ee/users/pages/users-edit-view.tsx @@ -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
Invalid input
; + + return ; +}; + +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 ( + + + + { + const parser = getParserWithGeneric(userBodySchema); + const parsedValues = parser(values); + const data: Partial = { + ...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} + /> + + + ); +}; + +UsersEditPage.getLayout = getLayout; + +export default UsersEditPage; diff --git a/packages/features/ee/users/pages/users-listing-view.tsx b/packages/features/ee/users/pages/users-listing-view.tsx new file mode 100644 index 0000000000..7664995c0f --- /dev/null +++ b/packages/features/ee/users/pages/users-listing-view.tsx @@ -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 ( + + + {/* TODO: Add import users functionality */} + {/* */} + + + } + /> + + + + + ); +}; + +DeploymentUsersListPage.getLayout = getLayout; + +export default DeploymentUsersListPage; diff --git a/packages/features/ee/users/schemas/userBodySchema.ts b/packages/features/ee/users/schemas/userBodySchema.ts new file mode 100644 index 0000000000..ad733e9922 --- /dev/null +++ b/packages/features/ee/users/schemas/userBodySchema.ts @@ -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(); diff --git a/packages/features/ee/users/server/trpc-router.ts b/packages/features/ee/users/server/trpc-router.ts new file mode 100644 index 0000000000..3c4d761738 --- /dev/null +++ b/packages/features/ee/users/server/trpc-router.ts @@ -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; + +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(user: UserType, keys: Key[]): Omit { + 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` }; + }), +}); diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 2d86c2c9af..9f9e6028a9 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -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({
- {children} + + }>{children} +
diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 43fa49c74f..fb6734f615 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -450,6 +450,28 @@ export const getAccessLinkResponseSchema = z.object({ export type GetAccessLinkResponseSchema = z.infer; +/** Facilitates converting values from Select inputs to plain ones before submitting */ +export const optionToValueSchema = (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); + +/** + * Allows parsing without losing original data inference. + * @url https://github.com/colinhacks/zod/discussions/1655#discussioncomment-4367368 + */ +export const getParserWithGeneric = + (valueSchema: T) => + (data: Data) => { + type Output = z.infer; + 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(), diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index 1e230e1d99..290ef1728d 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -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, }) ); diff --git a/packages/ui/components/table/Table.tsx b/packages/ui/components/table/Table.tsx new file mode 100644 index 0000000000..8bfeef0062 --- /dev/null +++ b/packages/ui/components/table/Table.tsx @@ -0,0 +1,54 @@ +import { classNames } from "@calcom/lib"; + +interface TableProps { + children: React.ReactNode; +} + +interface DynamicWidth { + widthClassNames?: string; +} + +const Header = ({ children }: TableProps) => ( + + {children} + +); + +const ColumnTitle = ({ children, widthClassNames }: TableProps & DynamicWidth) => ( + + {children} + +); + +const Body = ({ children }: TableProps) => ( + {children} +); + +const Row = ({ children }: TableProps) => {children}; + +const Cell = ({ children, widthClassNames }: TableProps & DynamicWidth) => ( + + {children} + +); + +export const Table = ({ children }: TableProps) => ( +
+ {children}
+
+); + +Table.Header = Header; +Table.ColumnTitle = ColumnTitle; +Table.Body = Body; +Table.Row = Row; +Table.Cell = Cell; diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 5bda0c11da..707aa31923 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -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";