Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/30758-idp-username-hosts-column
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Hosts page's User email column now shows a host's IdP username (or first Chrome profile email if no IdP username) instead of a generic "N users" label, with a `+N` count and tooltip listing any additional emails.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ interface ITooltipTruncatedTextCellProps {
prefix?: React.ReactNode;
/** Content does not get truncated */
suffix?: React.ReactNode;
/** When `true`, the truncated text grows to fill the available width so
* `suffix` always sits flush against the right edge of the cell, instead
* of hugging the end of the (variable-length) text. */
justifySuffixEnd?: boolean;
/** When `true`, the tooltip is also shown whenever `suffix` is present,
* even if the text itself isn't visually truncated. Useful when `suffix`
* (a "+N" count) itself implies there's more info in the tooltip. */
showTooltipWithSuffix?: boolean;
}

const baseClass = "tooltip-truncated-cell";
Expand All @@ -30,9 +38,12 @@ const TooltipTruncatedTextCell = ({
className,
prefix,
suffix,
justifySuffixEnd = false,
showTooltipWithSuffix = false,
}: ITooltipTruncatedTextCellProps): JSX.Element => {
const classNames = classnames(baseClass, className, {
"tooltip-break-on-word": tooltipBreakOnWord,
"justify-suffix-end": justifySuffixEnd,
});

// Tooltip visibility logic: Enable only when text is truncated
Expand All @@ -45,7 +56,9 @@ const TooltipTruncatedTextCell = ({
const offsetWidth = ref.current.offsetWidth;
setTooltipDisabled(scrollWidth <= offsetWidth);
}
}, [ref]);
// `ref` itself never changes identity, so re-measure whenever a prop
// that can affect the rendered text's width changes, not just on mount.
}, [value, prefix, suffix, justifySuffixEnd]);
// End

const tooltipId = uniqueId();
Expand All @@ -54,6 +67,7 @@ const TooltipTruncatedTextCell = ({
? DEFAULT_EMPTY_CELL_VALUE
: value;
const isDefaultValue = value === DEFAULT_EMPTY_CELL_VALUE;
const showBecauseOfSuffix = showTooltipWithSuffix && Boolean(suffix);

Comment thread
spalmesano0 marked this conversation as resolved.
return (
<div className={classNames}>
Expand All @@ -62,7 +76,9 @@ const TooltipTruncatedTextCell = ({
className="data-table__tooltip-truncated-text-container"
data-tip
data-for={tooltipId}
data-tip-disable={isDefaultValue || tooltipDisabled}
data-tip-disable={
isDefaultValue || (tooltipDisabled && !showBecauseOfSuffix)
}
>
<span
ref={ref}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
min-width: 0; // Prevent flex child from growing beyond container
}

// Pins .data-table__suffix to the cell's right edge regardless of the
// truncated text's length, instead of letting it hug the text.
&.justify-suffix-end {
.data-table__tooltip-truncated-text-container {
flex: 1 1 0%;
}
}

.data-table__tooltip-truncated-text {
white-space: nowrap; /* Prevent wrapping */
overflow: hidden; /* Hide overflowing text */
Expand Down
155 changes: 155 additions & 0 deletions frontend/pages/hosts/ManageHostsPage/HostTableConfig.tests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React from "react";
import { render } from "@testing-library/react";

import {
generateAvailableTableHeaders,
getPrimaryDeviceUser,
} from "./HostTableConfig";

describe("getPrimaryDeviceUser", () => {
it("returns no primary email, no suffix, and no tooltip lines when there are no emails", () => {
expect(getPrimaryDeviceUser([])).toEqual({
primaryEmail: undefined,
suffixCount: 0,
tooltipLines: [],
});
});

it("returns the single email as primary with no suffix and no tooltip lines when there is exactly one", () => {
expect(
getPrimaryDeviceUser([{ email: "solo@acmecorp.com", source: "custom" }])
).toEqual({
primaryEmail: "solo@acmecorp.com",
suffixCount: 0,
tooltipLines: [],
});
});

it("prioritizes the IdP-sourced email over chrome/custom, regardless of array order", () => {
const { primaryEmail, suffixCount } = getPrimaryDeviceUser([
{ email: "custom1@acmecorp.com", source: "custom" },
{ email: "chrome@acmecorp.com", source: "google_chrome_profiles" },
{ email: "idp.user@acmecorp.com", source: "mdm_idp_accounts" },
{ email: "custom2@acmecorp.com", source: "custom" },
]);
expect(primaryEmail).toBe("idp.user@acmecorp.com");
expect(suffixCount).toBe(3);
});

it("falls back to the first chrome profile email when no IdP email is present", () => {
const { primaryEmail, suffixCount } = getPrimaryDeviceUser([
{ email: "custom1@acmecorp.com", source: "custom" },
{ email: "chrome@acmecorp.com", source: "google_chrome_profiles" },
]);
expect(primaryEmail).toBe("chrome@acmecorp.com");
expect(suffixCount).toBe(1);
});

it("falls back to the first available email when neither IdP nor chrome sources are present", () => {
const { primaryEmail, suffixCount } = getPrimaryDeviceUser([
{ email: "custom1@acmecorp.com", source: "custom" },
{ email: "custom2@acmecorp.com", source: "custom" },
]);
expect(primaryEmail).toBe("custom1@acmecorp.com");
expect(suffixCount).toBe(1);
});

it("lists the primary email first in tooltipLines, followed by the rest", () => {
const { tooltipLines } = getPrimaryDeviceUser([
{ email: "custom1@acmecorp.com", source: "custom" },
{ email: "idp.user@acmecorp.com", source: "mdm_idp_accounts" },
]);
expect(tooltipLines).toEqual([
"idp.user@acmecorp.com",
"custom1@acmecorp.com",
]);
});

it("caps tooltipLines at 5 entries, appending a '+N more' line for the remainder", () => {
const users = Array.from({ length: 7 }, (_, i) => ({
email: `user${i}@acmecorp.com`,
source: "custom",
}));
const { suffixCount, tooltipLines } = getPrimaryDeviceUser(users);
expect(suffixCount).toBe(6);
expect(tooltipLines).toEqual([
"user0@acmecorp.com",
"user1@acmecorp.com",
"user2@acmecorp.com",
"user3@acmecorp.com",
"user4@acmecorp.com",
"+2 more",
]);
});

it("does not append a '+N more' line when the total is exactly the cap", () => {
const users = Array.from({ length: 5 }, (_, i) => ({
email: `user${i}@acmecorp.com`,
source: "custom",
}));
const { tooltipLines } = getPrimaryDeviceUser(users);
expect(tooltipLines).toHaveLength(5);
expect(tooltipLines.some((line) => line.includes("more"))).toBe(false);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

describe("HostTableConfig - User email column", () => {
const getDeviceMappingColumn = () => {
const columns = generateAvailableTableHeaders({
isFreeTier: false,
isOnlyObserver: false,
});
const column = columns.find((c) => c.id === "device_mapping") as any;
if (!column) throw new Error("device_mapping column not found");
return column;
};

const renderCell = (value: Array<{ email: string; source: string }>) => {
const Cell = getDeviceMappingColumn().Cell as React.ElementType;
return render(<Cell cell={{ value }} />);
};

it("renders the default empty value with no suffix when there are no emails", () => {
const { container } = renderCell([]);
const textEl = container.querySelector(
".data-table__tooltip-truncated-text"
);
expect(textEl?.textContent).toBe("---");
expect(
container.querySelector(".data-table__suffix")
).not.toBeInTheDocument();
});

it("renders the primary email with a '+N' suffix and enables the tooltip when there are multiple emails", () => {
const { container } = renderCell([
{ email: "custom1@acmecorp.com", source: "custom" },
{ email: "idp.user@acmecorp.com", source: "mdm_idp_accounts" },
]);
const textEl = container.querySelector(
".data-table__tooltip-truncated-text"
);
const suffixEl = container.querySelector(".data-table__suffix");
const tooltipTrigger = container.querySelector(
".data-table__tooltip-truncated-text-container"
);

expect(textEl?.textContent).toBe("idp.user@acmecorp.com");
expect(suffixEl?.textContent).toBe("+1");
// Tooltip should be enabled here even though the primary email isn't
// long enough to be visually truncated, because a suffix is present.
expect(tooltipTrigger?.getAttribute("data-tip-disable")).toBe("false");
});

it("does not render a suffix or force the tooltip open for a single, untruncated email", () => {
const { container } = renderCell([
{ email: "solo@acmecorp.com", source: "custom" },
]);
expect(
container.querySelector(".data-table__suffix")
).not.toBeInTheDocument();
const tooltipTrigger = container.querySelector(
".data-table__tooltip-truncated-text-container"
);
expect(tooltipTrigger?.getAttribute("data-tip-disable")).toBe("true");
});
});
85 changes: 50 additions & 35 deletions frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,24 +58,41 @@ type ISelectionCellProps = CellProps<IHost>;
type IIssuesCellProps = CellProps<IHost, IHost["issues"]>;
type IDeviceUserCellProps = CellProps<IHost, IHost["device_mapping"]>;

const condenseDeviceUsers = (users: IDeviceUser[]): string[] => {
const MAX_USER_EMAILS_IN_TOOLTIP = 5;

interface IPrimaryDeviceUser {
primaryEmail?: string;
suffixCount: number;
tooltipLines: string[];
}

const getPrimaryDeviceUser = (users: IDeviceUser[]): IPrimaryDeviceUser => {
if (!users?.length) {
return [];
return { primaryEmail: undefined, suffixCount: 0, tooltipLines: [] };
}

const idpUser = users.find((u) => u.source === "mdm_idp_accounts");
const chromeUser = users.find((u) => u.source === "google_chrome_profiles");
const primary = idpUser ?? chromeUser ?? users[0];
const suffixCount = users.length - 1;

// No other emails to surface in a tooltip, so leave tooltipLines empty.
if (suffixCount === 0) {
return { primaryEmail: primary.email, suffixCount, tooltipLines: [] };
}
const condensed =
users.length === 4
? users
.slice(-4)

.map((u) => u.email)
.reverse()
: users
.slice(-3)
.map((u) => u.email)
.reverse() || [];
return users.length > 4
? condensed.concat(`+${users.length - 3} more`) // TODO: confirm limit
: condensed;
const orderedEmails = [
primary.email,
...users.filter((u) => u !== primary).map((u) => u.email),
];
const shown = orderedEmails.slice(0, MAX_USER_EMAILS_IN_TOOLTIP);
const remainder = orderedEmails.length - MAX_USER_EMAILS_IN_TOOLTIP;

return {
primaryEmail: primary.email,
suffixCount,
tooltipLines: remainder > 0 ? shown.concat(`+${remainder} more`) : shown,
};
};

const lastSeenTime = (
Expand Down Expand Up @@ -202,26 +219,23 @@ const allHostTableHeaders = (teamId?: number): IHostTableColumnConfig[] => [
id: "device_mapping",
Cell: (cellProps: IDeviceUserCellProps) => {
// TODO(android): is android supported?
const numUsers = cellProps.cell.value?.length || 0;
const users = condenseDeviceUsers(cellProps.cell.value || []);
if (users.length > 1) {
return (
<TooltipWrapper
tipContent={tooltipTextWithLineBreaks(users)}
underline={false}
showArrow
position="top"
tipOffset={10}
fixedPositionStrategy
>
<TextCell italic value={`${numUsers} users`} />
</TooltipWrapper>
);
}
if (users.length === 1) {
return <TextCell value={users[0]} />;
}
return <TextCell />;
const { primaryEmail, suffixCount, tooltipLines } = getPrimaryDeviceUser(
cellProps.cell.value || []
);
return (
<TooltipTruncatedTextCell
value={primaryEmail}
tooltip={
tooltipLines.length > 0
? tooltipTextWithLineBreaks(tooltipLines)
: undefined
}
suffix={suffixCount > 0 ? `+${suffixCount}` : undefined}
justifySuffixEnd
showTooltipWithSuffix
className="w250"
/>
);
},
},
// UUID
Expand Down Expand Up @@ -790,4 +804,5 @@ export {
defaultHiddenColumns,
generateAvailableTableHeaders,
generateVisibleTableColumns,
getPrimaryDeviceUser,
};
Loading