diff --git a/apps/web/components/ui/LimitedBadges.tsx b/apps/web/components/ui/LimitedBadges.tsx new file mode 100644 index 00000000000000..b9359e425d8fd9 --- /dev/null +++ b/apps/web/components/ui/LimitedBadges.tsx @@ -0,0 +1,108 @@ +"use client"; + +import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover"; +import { useCallback, useMemo, useState, useEffect } from "react"; + +const MAX_VISIBLE_BADGES = 2; + +type BadgeItem = { + label: string; + variant?: + | "default" + | "warning" + | "orange" + | "success" + | "green" + | "gray" + | "blue" + | "red" + | "error" + | "grayWithoutHover" + | "purple"; + onClick?: () => void; +}; + +type LimitedBadgesProps = { + items: BadgeItem[]; + maxVisible?: number; + className?: string; +}; + +function LimitedBadges({ + items, + maxVisible = MAX_VISIBLE_BADGES, + className, +}: LimitedBadgesProps): JSX.Element | null { + const [isOpen, setIsOpen] = useState(false); + const isMobile = useMediaQuery("(max-width: 768px)"); + + const { visibleItems, hiddenItems } = useMemo( + () => ({ + visibleItems: items.slice(0, maxVisible), + hiddenItems: items.slice(maxVisible), + }), + [items, maxVisible] + ); + + const handleMouseEnter = useCallback(() => { + if (!isMobile) { + setIsOpen(true); + } + }, [isMobile]); + + const handleMouseLeave = useCallback(() => { + if (!isMobile) { + setIsOpen(false); + } + }, [isMobile]); + + if (items.length === 0) return null; + + const hasHiddenItems = hiddenItems.length > 0; + + return ( +
+ {visibleItems.map((item, index) => ( + + {item.label} + + ))} + {hasHiddenItems && ( + + + + + +
+ {hiddenItems.map((item, index) => ( + + {item.label} + + ))} +
+
+
+ )} +
+ ); +} + +export { LimitedBadges }; +export type { BadgeItem }; diff --git a/apps/web/modules/insights/components/ResponseValueCell.tsx b/apps/web/modules/insights/components/ResponseValueCell.tsx index d3369f52079974..e642af3c4bf435 100644 --- a/apps/web/modules/insights/components/ResponseValueCell.tsx +++ b/apps/web/modules/insights/components/ResponseValueCell.tsx @@ -1,60 +1,24 @@ -import { useId } from "react"; - -import { Badge } from "@calcom/ui/components/badge"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, - HoverCardPortal, -} from "@calcom/ui/components/hover-card"; +import { LimitedBadges } from "@calcom/web/components/ui/LimitedBadges"; import { CellWithOverflowX } from "./CellWithOverflowX"; export function ResponseValueCell({ optionMap, values, - rowId, }: { optionMap: Record; values: string[]; - rowId: number; -}) { - const cellId = useId(); +}): JSX.Element { if (values.length === 0) return
; return ( - {values.length > 2 ? ( - <> - {values.slice(0, 2).map((id: string, i: number) => ( - - {optionMap[id] ?? id} - - ))} - - - +{values.length - 2} - - - -
- {values.slice(2).map((id: string, i: number) => ( - - {optionMap[id] ?? id} - - ))} -
-
-
-
- - ) : ( - values.map((id: string, i: number) => ( - - {optionMap[id] ?? id} - - )) - )} + ({ + label: optionMap[id] ?? id, + variant: "gray" as const, + }))} + />
); } diff --git a/apps/web/modules/insights/hooks/useInsightsColumns.tsx b/apps/web/modules/insights/hooks/useInsightsColumns.tsx index 58e8ef8017b382..2c3b25565108b5 100644 --- a/apps/web/modules/insights/hooks/useInsightsColumns.tsx +++ b/apps/web/modules/insights/hooks/useInsightsColumns.tsx @@ -200,7 +200,6 @@ export const useInsightsColumns = ({ ) ); diff --git a/apps/web/modules/users/components/UserTable/UserListTable.tsx b/apps/web/modules/users/components/UserTable/UserListTable.tsx index 3412157b7f092b..74afe4913a7510 100644 --- a/apps/web/modules/users/components/UserTable/UserListTable.tsx +++ b/apps/web/modules/users/components/UserTable/UserListTable.tsx @@ -1,42 +1,57 @@ "use client"; -import { keepPreviousData } from "@tanstack/react-query"; -import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table"; -import { useSession } from "next-auth/react"; -import { usePathname } from "next/navigation"; -import { useQueryState, parseAsBoolean } from "nuqs"; -import { useMemo, useReducer, useState } from "react"; -import { createPortal } from "react-dom"; -import posthog from "posthog-js"; - import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; import { - DataTableProvider, - useColumnFilters, ColumnFilterType, convertFacetedValuesToMap, + DataTableProvider, + type FacetedValue, + useColumnFilters, useDataTable, } from "@calcom/features/data-table"; -import { DataTableWrapper, DataTableToolbar, DataTableFilters, DataTableSegment, DataTableSelectionBar } from "~/data-table/components"; import { useSegments } from "@calcom/features/data-table/hooks/useSegments"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { - generateCsvRawForMembersTable, - generateHeaderFromReactTable, -} from "@calcom/web/modules/users/lib/UserListTableUtils"; +import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { downloadAsCsv } from "@calcom/lib/csvUtils"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; +import type { FilterType } from "@calcom/types/data-table"; import classNames from "@calcom/ui/classNames"; import { Avatar } from "@calcom/ui/components/avatar"; import { Badge } from "@calcom/ui/components/badge"; import { Checkbox } from "@calcom/ui/components/form"; import { showToast } from "@calcom/ui/components/toast"; import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes"; - +import { LimitedBadges } from "@calcom/web/components/ui/LimitedBadges"; +import { + generateCsvRawForMembersTable, + generateHeaderFromReactTable, +} from "@calcom/web/modules/users/lib/UserListTableUtils"; +import { keepPreviousData } from "@tanstack/react-query"; +import { + type CellContext, + type ColumnDef, + getCoreRowModel, + getSortedRowModel, + type HeaderContext, + useReactTable, +} from "@tanstack/react-table"; +import { usePathname } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import posthog from "posthog-js"; +import { useMemo, useReducer, useState } from "react"; +import { createPortal } from "react-dom"; +import { + DataTableFilters, + DataTableSegment, + DataTableSelectionBar, + DataTableToolbar, + DataTableWrapper, +} from "~/data-table/components"; import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers"; import { DynamicLink } from "./BulkActions/DynamicLink"; import { EventTypesList } from "./BulkActions/EventTypesList"; @@ -47,9 +62,8 @@ import { DeleteMemberModal } from "./DeleteMemberModal"; import { EditUserSheet } from "./EditSheet/EditUserSheet"; import { ImpersonationMemberModal } from "./ImpersonationMemberModal"; import { InviteMemberModal } from "./InviteMemberModal"; +import type { UserTableAction, UserTableState, UserTableUser } from "./types"; import { TableActions } from "./UserTableActions"; -import type { UserTableState, UserTableAction, UserTableUser } from "./types"; -import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions"; const initialState: UserTableState = { changeMemberRole: { @@ -107,7 +121,7 @@ function reducer(state: UserTableState, action: UserTableAction): UserTableState } } -export type UserListTableProps = { +type UserListTableProps = { org: RouterOutputs["viewer"]["organizations"]["listCurrent"]; teams: RouterOutputs["viewer"]["organizations"]["getTeams"]; attributes?: RouterOutputs["viewer"]["attributes"]["list"]; @@ -125,7 +139,7 @@ export type UserListTableProps = { permissions?: MemberPermissions; }; -export function UserListTable(props: UserListTableProps) { +function UserListTable(props: UserListTableProps): JSX.Element | null { const pathname = usePathname(); if (!pathname) return null; return ( @@ -141,7 +155,7 @@ function UserListTableContent({ teams, facetedTeamValues, permissions, -}: UserListTableProps) { +}: UserListTableProps): JSX.Element { const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean); const orgBranding = useOrgBranding(); const domain = orgBranding?.fullDomain ?? WEBAPP_URL; @@ -183,7 +197,7 @@ function UserListTableContent({ canResendInvitation: permissions?.canInvite ?? adminOrOwner, canImpersonate: permissions?.canImpersonate ?? adminOrOwner, }; - const generateAttributeColumns = () => { + const generateAttributeColumns = (): ColumnDef[] => { if (!attributes?.length) { return []; } @@ -198,13 +212,14 @@ function UserListTableContent({ const isText = attribute.type === "TEXT"; const isSingleSelect = attribute.type === "SINGLE_SELECT"; // const isMultiSelect = attribute.type === "MULTI_SELECT"; - const filterType = isNumber - ? ColumnFilterType.NUMBER - : isText - ? ColumnFilterType.TEXT - : isSingleSelect - ? ColumnFilterType.SINGLE_SELECT - : ColumnFilterType.MULTI_SELECT; + let filterType: FilterType = ColumnFilterType.MULTI_SELECT; + if (isNumber) { + filterType = ColumnFilterType.NUMBER; + } else if (isText) { + filterType = ColumnFilterType.TEXT; + } else if (isSingleSelect) { + filterType = ColumnFilterType.SINGLE_SELECT; + } return { id: attribute.id, @@ -213,41 +228,42 @@ function UserListTableContent({ filter: { type: filterType }, }, size: 120, - accessorFn: (data) => data.attributes?.find((attr) => attr.attributeId === attribute.id)?.value, - cell: ({ row }) => { + accessorFn: (data: UserTableUser) => + data.attributes?.find((attr) => attr.attributeId === attribute.id)?.value, + cell: ({ row }: CellContext) => { const attributeValues = row.original.attributes?.filter( (attr) => attr.attributeId === attribute.id ); - if (attributeValues?.length === 0) return null; + if (!attributeValues || attributeValues.length === 0) return null; + return ( -
- {attributeValues?.map((attributeValue) => { + { const isAGroupOption = attributeValue.contains?.length > 0; - const suffix = attribute.isWeightsEnabled - ? `${attributeValue.weight || 100}%` - : undefined; - return ( -
- - {attributeValue.value} - - - {suffix ? ( - - {suffix} - - ) : null} -
- ); + let weight = ""; + if (attribute.isWeightsEnabled) { + weight = `${attributeValue.weight || 100}%`; + } + let groupIndicator = ""; + if (isAGroupOption) { + groupIndicator = " (group)"; + } + let label = attributeValue.value; + label = `${label} ${weight}`; + label = `${label}${groupIndicator}`; + + let variant: "orange" | "gray" = "gray"; + if (isAGroupOption) { + variant = "orange"; + } + + return { + label, + variant, + }; })} -
+ className={classNames(isNumber && "w-full justify-center")} + /> ); }, }; @@ -263,17 +279,17 @@ function UserListTableContent({ enableSorting: false, enableResizing: false, size: 30, - header: ({ table }) => ( + header: ({ table }: HeaderContext) => ( table.toggleAllPageRowsSelected(!!value)} + onCheckedChange={(value: boolean | "indeterminate") => table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" /> ), - cell: ({ row }) => ( + cell: ({ row }: CellContext) => ( row.toggleSelected(!!value)} + onCheckedChange={(value: boolean | "indeterminate") => row.toggleSelected(!!value)} aria-label="Select row" className="translate-y-[2px]" /> @@ -281,12 +297,12 @@ function UserListTableContent({ }, { id: "member", - accessorFn: (data) => data.email, + accessorFn: (data: UserTableUser) => data.email, enableHiding: false, enableColumnFilter: false, size: 200, header: t("members"), - cell: ({ row }) => { + cell: ({ row }: CellContext) => { const { username, email, avatarUrl } = row.original; return (
@@ -315,19 +331,23 @@ function UserListTableContent({ }, { id: "role", - accessorFn: (data) => data.role, + accessorFn: (data: UserTableUser) => data.role, header: t("role"), size: 100, meta: { filter: { type: ColumnFilterType.MULTI_SELECT }, }, - cell: ({ row, table }) => { + cell: ({ row, table }: CellContext) => { const { role, username, customRole } = row.original; const roleName = customRole?.name || role; + let roleVariant: "gray" | "blue" = "blue"; + if (role === "MEMBER") { + roleVariant = "gray"; + } return ( { table.getColumn("role")?.setFilterValue([role]); }}> @@ -338,18 +358,18 @@ function UserListTableContent({ }, { id: "teams", - accessorFn: (data) => data.teams.map((team) => team.name), + accessorFn: (data: UserTableUser) => data.teams.map((team) => team.name), header: t("teams"), size: 140, meta: { filter: { type: ColumnFilterType.MULTI_SELECT }, }, - cell: ({ row, table }) => { + cell: ({ row, table }: CellContext) => { const { teams, accepted, email, username } = row.original; - // TODO: Implement click to filter + return (
- {accepted ? null : ( + {!accepted && ( )} - {teams.map((team) => ( - { + ({ + label: team.name, + variant: "gray" as const, + onClick: () => { table.getColumn("teams")?.setFilterValue([team.name]); - }}> - {team.name} - - ))} + }, + }))} + />
); }, @@ -388,7 +407,7 @@ function UserListTableContent({ type: ColumnFilterType.DATE_RANGE, }, }, - cell: ({ row }) =>
{row.original.lastActiveAt}
, + cell: ({ row }: CellContext) =>
{row.original.lastActiveAt}
, }, { id: "createdAt", @@ -401,7 +420,7 @@ function UserListTableContent({ type: ColumnFilterType.DATE_RANGE, }, }, - cell: ({ row }) =>
{row.original.createdAt || ""}
, + cell: ({ row }: CellContext) =>
{row.original.createdAt || ""}
, }, { id: "updatedAt", @@ -414,7 +433,7 @@ function UserListTableContent({ type: ColumnFilterType.DATE_RANGE, }, }, - cell: ({ row }) =>
{row.original.updatedAt || ""}
, + cell: ({ row }: CellContext) =>
{row.original.updatedAt || ""}
, }, { id: "completedOnboarding", @@ -423,13 +442,15 @@ function UserListTableContent({ enableSorting: false, enableColumnFilter: false, size: 80, - cell: ({ row }) => { + cell: ({ row }: CellContext) => { const { completedOnboarding } = row.original; - return ( - - {completedOnboarding ? t("yes") : t("no")} - - ); + let onboardingVariant: "green" | "gray" = "gray"; + let onboardingText = t("no"); + if (completedOnboarding) { + onboardingVariant = "green"; + onboardingText = t("yes"); + } + return {onboardingText}; }, }, { @@ -440,16 +461,18 @@ function UserListTableContent({ enableSorting: false, enableColumnFilter: false, size: 80, - cell: ({ row }) => { + cell: ({ row }: CellContext) => { const { twoFactorEnabled } = row.original; if (!adminOrOwner || twoFactorEnabled === undefined) { return null; } - return ( - - {twoFactorEnabled ? t("enabled") : t("disabled")} - - ); + let twoFaVariant: "green" | "gray" = "gray"; + let twoFaText = t("disabled"); + if (twoFactorEnabled) { + twoFaVariant = "green"; + twoFaText = t("enabled"); + } + return {twoFaText}; }, }, { @@ -458,7 +481,7 @@ function UserListTableContent({ enableSorting: false, enableResizing: false, size: 80, - cell: ({ row }) => { + cell: ({ row }: CellContext) => { const user = row.original; const permissionsRaw = tablePermissions; const isSelf = user.id === session?.user.id; @@ -492,7 +515,7 @@ function UserListTableContent({ ]; return cols; - }, [session?.user.id, adminOrOwner, dispatch, domain, attributes, org?.canAdminImpersonate, permissions]); + }, [session?.user.id, adminOrOwner, domain, attributes, org?.canAdminImpersonate, permissions, t]); const table = useReactTable({ data: flatData, @@ -515,8 +538,8 @@ function UserListTableContent({ getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), onRowSelectionChange: setRowSelection, - getRowId: (row) => `${row.id}`, - getFacetedUniqueValues: (_, columnId) => () => { + getRowId: (row: UserTableUser) => `${row.id}`, + getFacetedUniqueValues: (_: unknown, columnId: string) => (): Map => { if (facetedTeamValues) { switch (columnId) { case "role": @@ -555,7 +578,7 @@ function UserListTableContent({ const numberOfSelectedRows = table.getSelectedRowModel().rows.length; - const handleDownload = async () => { + const handleDownload = async (): Promise => { try { if (!org?.slug || !org?.name) { throw new Error("Org slug or name is missing."); @@ -640,7 +663,7 @@ function UserListTableContent({

{t("number_selected", { count: numberOfSelectedRows })}

- {!isPlatformUser ? ( + {!isPlatformUser && ( <> {permissions?.canChangeMemberRole && } {numberOfSelectedRows >= 2 && ( @@ -658,7 +681,7 @@ function UserListTableContent({ )} - ) : null} + )} {(permissions?.canRemove ?? adminOrOwner) && ( row.original)} @@ -707,7 +730,7 @@ function UserListTableContent({ showModal: true, }, }); - posthog.capture("add_organization_member_clicked") + posthog.capture("add_organization_member_clicked"); }} data-testid="new-organization-member-button"> {t("add")} @@ -719,3 +742,6 @@ function UserListTableContent({ ); } + +export { UserListTable }; +export type { UserListTableProps };