chore: UX Fixes (#26643)
* Copy changes
* Move search bar inline with new button
* Get rid of no more results message
* Change hidden badge to (hidden)
* Remove Cal.ai badge from sidebar
* Add dropdown to create button when there is multiple options
* Fix delete dialog
* Saved filters updates
* More string fixes
* Switch members table to use names
* Fix member spacing
* Fix routing form identifier field
* Fix routing forms stuff
* Only show SMS hint on SMS options
* Make workflow delete button minimal
* Fix padding on workflow steps
* Remove min width on workflow title
* Fix delete workflow PR
* Fix org profile buttons
* Fix org profile screen partially scrolled down
* Improve logos & banner uploads
* Personal profile fixes
* Fix settings general view stuff
* Sentence case consistency
* Fix stuff I broke
* Fix fab
* Fix hidden translation string
* Fix text fields
* Make button small for solo users too
* fix: update E2E tests to match sentence case labels in routing forms
* fix: update tests to match sentence case label changes
- insights.e2e.ts: chart titles (14 strings)
- event-types.e2e.ts: Organizer phone number location
- EditLocationDialog.test.tsx: phone number labels
* fix: address Cubic AI review feedback (confidence 9+)
- Replace hardcoded text-gray-500 with text-muted in TextField.tsx hint section
- Replace text locator with data-testid in E2E test for location select
Co-Authored-By: unknown <>
* fix: update E2E tests for sentence case label changes
- Use data-testid selectors for location options (more reliable than text)
- Update field identifiers in routing-forms tests to match new labels
- Fix Long text selector in manage-booking-questions test
* fix: replace text locator with data-testid in manage-booking-questions E2E test
Replace fragile text="Long text" locator with resilient
page.getByTestId("select-option-textarea") selector per E2E best practices.
Addresses Cubic AI review feedback (confidence 9/10).
Co-Authored-By: unknown <>
* fix: use .last() for multiple location select items
---------
Co-authored-by: Pedro Castro <pedro@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
unknown <>
Pedro Castro
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
32d97fbea4
commit
7c66f33de2
@@ -6,7 +6,13 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Sheet, SheetContent, SheetBody, SheetHeader, SheetFooter } from "@calcom/ui/components/sheet";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetBody,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
} from "@calcom/ui/components/sheet";
|
||||
import { Loader } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import type { UserTableAction, UserTableState } from "../types";
|
||||
@@ -36,15 +42,19 @@ export function EditUserSheet({
|
||||
const { t } = useLocale();
|
||||
const { user: selectedUser } = state.editSheet;
|
||||
const orgBranding = useOrgBranding();
|
||||
const [editMode, setEditMode] = useEditMode((state) => [state.editMode, state.setEditMode], shallow);
|
||||
const { data: loadedUser, isPending } = trpc.viewer.organizations.getUser.useQuery(
|
||||
{
|
||||
userId: selectedUser?.id,
|
||||
},
|
||||
{
|
||||
enabled: !!selectedUser?.id,
|
||||
}
|
||||
const [editMode, setEditMode] = useEditMode(
|
||||
(state) => [state.editMode, state.setEditMode],
|
||||
shallow
|
||||
);
|
||||
const { data: loadedUser, isPending } =
|
||||
trpc.viewer.organizations.getUser.useQuery(
|
||||
{
|
||||
userId: selectedUser?.id,
|
||||
},
|
||||
{
|
||||
enabled: !!selectedUser?.id,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: usersAttributes, isPending: usersAttributesPending } =
|
||||
trpc.viewer.attributes.getByUserId.useQuery(
|
||||
@@ -57,11 +67,15 @@ export function EditUserSheet({
|
||||
}
|
||||
);
|
||||
|
||||
const avatarURL = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${loadedUser?.username}/avatar.png`;
|
||||
const avatarURL = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
|
||||
loadedUser?.username
|
||||
}/avatar.png`;
|
||||
|
||||
const schedulesNames = loadedUser?.schedules && loadedUser?.schedules.map((s) => s.name);
|
||||
const schedulesNames =
|
||||
loadedUser?.schedules && loadedUser?.schedules.map((s) => s.name);
|
||||
const teamNames =
|
||||
loadedUser?.teams && loadedUser?.teams.map((t) => `${t.name} ${!t.accepted ? "(pending)" : ""}`);
|
||||
loadedUser?.teams &&
|
||||
loadedUser?.teams.map((t) => `${t.name} ${!t.accepted ? "(pending)" : ""}`);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
@@ -69,19 +83,25 @@ export function EditUserSheet({
|
||||
onOpenChange={() => {
|
||||
setEditMode(false);
|
||||
dispatch({ type: "CLOSE_MODAL" });
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<SheetContent className="bg-default">
|
||||
{!isPending && loadedUser ? (
|
||||
<>
|
||||
{!editMode ? (
|
||||
<>
|
||||
<SheetHeader showCloseButton={false} className="w-full">
|
||||
<div className="border-sublte bg-default w-full rounded-xl border p-4">
|
||||
<div className="border-subtle bg-default w-full rounded-xl border p-4">
|
||||
<OrganizationBanner />
|
||||
<div className="bg-default ml-3 w-fit translate-y-[-50%] rounded-full p-1 ring-1 ring-[#0000000F]">
|
||||
<Avatar asChild size="lg" alt={`${loadedUser?.name} avatar`} imageSrc={avatarURL} />
|
||||
<Avatar
|
||||
asChild
|
||||
size="lg"
|
||||
alt={`${loadedUser?.name} avatar`}
|
||||
imageSrc={avatarURL}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-emphasis font-sans text-2xl font-semibold">
|
||||
<h2 className="text-emphasis font-sans text-2xl font-semibold -mt-8">
|
||||
{loadedUser?.name || "Nameless User"}
|
||||
</h2>
|
||||
<p className="text-subtle max-h-[3em] overflow-hidden text-ellipsis text-sm font-normal">
|
||||
@@ -91,49 +111,79 @@ export function EditUserSheet({
|
||||
</SheetHeader>
|
||||
<SheetBody className="stack-y-4 flex flex-col p-4">
|
||||
<div className="stack-y-4 mb-4 flex flex-col">
|
||||
<h3 className="text-emphasis mb-1 text-base font-semibold">{t("profile")}</h3>
|
||||
<h3 className="text-emphasis mb-1 text-base font-semibold">
|
||||
{t("profile")}
|
||||
</h3>
|
||||
<DisplayInfo
|
||||
label="Cal"
|
||||
value={removeProtocol(
|
||||
`${orgBranding?.fullDomain ?? WEBAPP_URL}/${loadedUser?.username}`
|
||||
`${orgBranding?.fullDomain ?? WEBAPP_URL}/${
|
||||
loadedUser?.username
|
||||
}`
|
||||
)}
|
||||
icon="external-link"
|
||||
/>
|
||||
<DisplayInfo label={t("email")} value={loadedUser?.email ?? ""} icon="at-sign" />
|
||||
<DisplayInfo label={t("role")} value={[loadedUser?.role ?? ""]} icon="fingerprint" />
|
||||
<DisplayInfo label={t("timezone")} value={loadedUser?.timeZone ?? ""} icon="clock" />
|
||||
<DisplayInfo
|
||||
label={t("email")}
|
||||
value={loadedUser?.email ?? ""}
|
||||
icon="at-sign"
|
||||
/>
|
||||
<DisplayInfo
|
||||
label={t("role")}
|
||||
value={[loadedUser?.role ?? ""]}
|
||||
icon="fingerprint"
|
||||
/>
|
||||
<DisplayInfo
|
||||
label={t("timezone")}
|
||||
value={loadedUser?.timeZone ?? ""}
|
||||
icon="clock"
|
||||
/>
|
||||
<DisplayInfo
|
||||
label={t("teams")}
|
||||
value={!teamNames || teamNames.length === 0 ? "" : teamNames}
|
||||
value={
|
||||
!teamNames || teamNames.length === 0 ? "" : teamNames
|
||||
}
|
||||
icon="users"
|
||||
coloredBadges
|
||||
/>
|
||||
<DisplayInfo
|
||||
label={t("availability")}
|
||||
value={!schedulesNames || schedulesNames.length === 0 ? "" : schedulesNames}
|
||||
value={
|
||||
!schedulesNames || schedulesNames.length === 0
|
||||
? ""
|
||||
: schedulesNames
|
||||
}
|
||||
icon="calendar"
|
||||
/>
|
||||
</div>
|
||||
{canViewAttributes && usersAttributes && usersAttributes?.length > 0 && (
|
||||
<div className="mt-4 flex flex-col">
|
||||
<h3 className="text-emphasis mb-5 text-base font-semibold">{t("attributes")}</h3>
|
||||
<div className="stack-y-4 flex flex-col">
|
||||
{usersAttributes.map((attribute, index) => (
|
||||
<>
|
||||
<DisplayInfo
|
||||
key={index}
|
||||
label={attribute.name}
|
||||
value={
|
||||
["TEXT", "NUMBER", "SINGLE_SELECT"].includes(attribute.type)
|
||||
? attribute.options[0].value
|
||||
: attribute.options.map((option) => option.value)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{canViewAttributes &&
|
||||
usersAttributes &&
|
||||
usersAttributes?.length > 0 && (
|
||||
<div className="mt-4 flex flex-col">
|
||||
<h3 className="text-emphasis mb-5 text-base font-semibold">
|
||||
{t("attributes")}
|
||||
</h3>
|
||||
<div className="stack-y-4 flex flex-col">
|
||||
{usersAttributes.map((attribute, index) => (
|
||||
<>
|
||||
<DisplayInfo
|
||||
key={index}
|
||||
label={attribute.name}
|
||||
value={
|
||||
["TEXT", "NUMBER", "SINGLE_SELECT"].includes(
|
||||
attribute.type
|
||||
)
|
||||
? attribute.options[0].value
|
||||
: attribute.options.map(
|
||||
(option) => option.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
<SheetFooterControls
|
||||
|
||||
@@ -95,7 +95,10 @@ const initalColumnVisibility = {
|
||||
actions: true,
|
||||
};
|
||||
|
||||
function reducer(state: UserTableState, action: UserTableAction): UserTableState {
|
||||
function reducer(
|
||||
state: UserTableState,
|
||||
action: UserTableAction
|
||||
): UserTableState {
|
||||
switch (action.type) {
|
||||
case "SET_CHANGE_MEMBER_ROLE_ID":
|
||||
return { ...state, changeMemberRole: action.payload };
|
||||
@@ -143,7 +146,11 @@ function UserListTable(props: UserListTableProps): JSX.Element | null {
|
||||
const pathname = usePathname();
|
||||
if (!pathname) return null;
|
||||
return (
|
||||
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} defaultPageSize={25}>
|
||||
<DataTableProvider
|
||||
tableIdentifier={pathname}
|
||||
useSegments={useSegments}
|
||||
defaultPageSize={25}
|
||||
>
|
||||
<UserListTableContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
@@ -305,12 +312,13 @@ function UserListTableContent({
|
||||
size: 200,
|
||||
header: t("members"),
|
||||
cell: ({ row }: CellContext<UserTableUser, unknown>) => {
|
||||
const { username, email, avatarUrl } = row.original;
|
||||
const { username, name, email, avatarUrl } = row.original;
|
||||
const displayName = name || username || "No username";
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
size="sm"
|
||||
alt={username || email}
|
||||
alt={displayName}
|
||||
imageSrc={getUserAvatarUrl({
|
||||
avatarUrl,
|
||||
})}
|
||||
@@ -318,12 +326,14 @@ function UserListTableContent({
|
||||
<div className="">
|
||||
<div
|
||||
data-testid={`member-${username}-username`}
|
||||
className="text-emphasis text-sm font-medium leading-none">
|
||||
{username || "No username"}
|
||||
className="text-emphasis text-sm font-medium leading-none"
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
<div
|
||||
data-testid={`member-${username}-email`}
|
||||
className="text-subtle mt-1 text-sm leading-none">
|
||||
className="text-subtle mt-1 text-sm leading-none"
|
||||
>
|
||||
{email}
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,7 +362,8 @@ function UserListTableContent({
|
||||
variant={roleVariant}
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue([role]);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{roleName}
|
||||
</Badge>
|
||||
);
|
||||
@@ -379,7 +390,8 @@ function UserListTableContent({
|
||||
data-testid={`email-${email.replace("@", "")}-pending`}
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue(["PENDING"]);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{t("pending")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -490,7 +502,8 @@ function UserListTableContent({
|
||||
|
||||
const permissionsForUser = {
|
||||
canEdit:
|
||||
((permissionsRaw.canEdit ?? false) || (permissions?.canEditAttributesForUser ?? false)) &&
|
||||
((permissionsRaw.canEdit ?? false) ||
|
||||
(permissions?.canEditAttributesForUser ?? false)) &&
|
||||
user.accepted &&
|
||||
!isSelf,
|
||||
canRemove: (permissionsRaw.canRemove ?? false) && !isSelf,
|
||||
@@ -501,7 +514,8 @@ function UserListTableContent({
|
||||
!!org?.canAdminImpersonate &&
|
||||
(permissionsRaw.canImpersonate ?? false),
|
||||
canLeave: user.accepted && isSelf,
|
||||
canResendInvitation: (permissionsRaw.canResendInvitation ?? false) && !user.accepted,
|
||||
canResendInvitation:
|
||||
(permissionsRaw.canResendInvitation ?? false) && !user.accepted,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -559,7 +573,9 @@ function UserListTableContent({
|
||||
}))
|
||||
);
|
||||
default: {
|
||||
const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId);
|
||||
const attribute = facetedTeamValues.attributes.find(
|
||||
(attr) => attr.id === columnId
|
||||
);
|
||||
if (attribute) {
|
||||
return convertFacetedValuesToMap(
|
||||
attribute?.options.map(({ value }) => ({
|
||||
@@ -619,12 +635,19 @@ function UserListTableContent({
|
||||
}
|
||||
|
||||
const ATTRIBUTE_IDS = attributes?.map((attr) => attr.id) ?? [];
|
||||
const csvRaw = generateCsvRawForMembersTable(headers, allRows, ATTRIBUTE_IDS, domain);
|
||||
const csvRaw = generateCsvRawForMembersTable(
|
||||
headers,
|
||||
allRows,
|
||||
ATTRIBUTE_IDS,
|
||||
domain
|
||||
);
|
||||
if (!csvRaw) {
|
||||
throw new Error("Generating CSV file failed.");
|
||||
}
|
||||
|
||||
const filename = `${org.name}_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
const filename = `${org.name}_${
|
||||
new Date().toISOString().split("T")[0]
|
||||
}.csv`;
|
||||
downloadAsCsv(csvRaw, filename);
|
||||
} catch (error) {
|
||||
showToast(`Error: ${error}`, "error");
|
||||
@@ -654,7 +677,8 @@ function UserListTableContent({
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</>
|
||||
}>
|
||||
}
|
||||
>
|
||||
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
|
||||
<DataTableSelectionBar.Root className="bottom-[7.3rem]! md:bottom-32!">
|
||||
<DynamicLink table={table} domain={domain} />
|
||||
@@ -667,17 +691,23 @@ function UserListTableContent({
|
||||
</p>
|
||||
{!isPlatformUser && (
|
||||
<>
|
||||
{permissions?.canChangeMemberRole && <TeamListBulkAction table={table} />}
|
||||
{permissions?.canChangeMemberRole && (
|
||||
<TeamListBulkAction table={table} />
|
||||
)}
|
||||
{numberOfSelectedRows >= 2 && (
|
||||
<DataTableSelectionBar.Button
|
||||
color="secondary"
|
||||
onClick={() => setDynamicLinkVisible(!dynamicLinkVisible)}
|
||||
icon="handshake">
|
||||
icon="handshake"
|
||||
>
|
||||
{t("group_meeting")}
|
||||
</DataTableSelectionBar.Button>
|
||||
)}
|
||||
{(permissions?.canEditAttributesForUser ?? adminOrOwner) && (
|
||||
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
|
||||
<MassAssignAttributesBulkAction
|
||||
table={table}
|
||||
filters={columnFilters}
|
||||
/>
|
||||
)}
|
||||
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
|
||||
<EventTypesList table={table} orgTeams={teams} />
|
||||
@@ -686,7 +716,9 @@ function UserListTableContent({
|
||||
)}
|
||||
{(permissions?.canRemove ?? adminOrOwner) && (
|
||||
<DeleteBulkUsers
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
users={table
|
||||
.getSelectedRowModel()
|
||||
.flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -694,10 +726,18 @@ function UserListTableContent({
|
||||
)}
|
||||
</DataTableWrapper>
|
||||
|
||||
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
|
||||
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
|
||||
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
|
||||
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
|
||||
{state.deleteMember.showModal && (
|
||||
<DeleteMemberModal state={state} dispatch={dispatch} />
|
||||
)}
|
||||
{state.inviteMember.showModal && (
|
||||
<InviteMemberModal dispatch={dispatch} />
|
||||
)}
|
||||
{state.impersonateMember.showModal && (
|
||||
<ImpersonationMemberModal dispatch={dispatch} state={state} />
|
||||
)}
|
||||
{state.changeMemberRole.showModal && (
|
||||
<ChangeUserRoleModal dispatch={dispatch} state={state} />
|
||||
)}
|
||||
{state.editSheet.showModal && (
|
||||
<EditUserSheet
|
||||
dispatch={dispatch}
|
||||
@@ -717,7 +757,8 @@ function UserListTableContent({
|
||||
StartIcon="file-down"
|
||||
loading={isDownloading}
|
||||
onClick={() => handleDownload()}
|
||||
data-testid="export-members-button">
|
||||
data-testid="export-members-button"
|
||||
>
|
||||
{t("download")}
|
||||
</DataTableToolbar.CTA>
|
||||
{(permissions?.canInvite ?? adminOrOwner) && (
|
||||
@@ -734,7 +775,8 @@ function UserListTableContent({
|
||||
});
|
||||
posthog.capture("add_organization_member_clicked");
|
||||
}}
|
||||
data-testid="new-organization-member-button">
|
||||
data-testid="new-organization-member-button"
|
||||
>
|
||||
{t("add")}
|
||||
</DataTableToolbar.CTA>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user