Skip to content

Commit aada5a9

Browse files
committed
fix: display long list members with overflow badge
1 parent 4fb68c4 commit aada5a9

5 files changed

Lines changed: 211 additions & 12 deletions

File tree

client/src/features/searchV2/components/MemberListRow.tsx

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,25 @@ export default function MemberListRow({ members }: MemberListRowProps) {
3737
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
3838
const measuredWidths = useRef<number[]>([]);
3939
const [visibleCount, setVisibleCount] = useState(members.length);
40+
// When the first member does not fit at full width, constrain it so the name can truncate.
41+
const [truncateMaxWidth, setTruncateMaxWidth] = useState<number | null>(null);
4042

4143
const measureItemWidths = useCallback(() => {
4244
for (let i = 0; i < members.length; i++) {
4345
const el = itemRefs.current[i];
44-
if (el) {
45-
measuredWidths.current[i] = el.offsetWidth;
46-
}
46+
if (!el) continue;
47+
// Sum children so a truncated item still reports its natural width
48+
// (the item's own scrollWidth collapses to maxWidth when truncating).
49+
const avatar = el.children[0] as HTMLElement | undefined;
50+
const name = el.children[1] as HTMLElement | undefined;
51+
const innerGap =
52+
avatar && name
53+
? parseFloat(
54+
getComputedStyle(el).columnGap || getComputedStyle(el).gap,
55+
) || 0
56+
: 0;
57+
measuredWidths.current[i] =
58+
(avatar?.offsetWidth ?? 0) + innerGap + (name?.scrollWidth ?? 0);
4759
}
4860
}, [members.length]);
4961

@@ -78,20 +90,33 @@ export default function MemberListRow({ members }: MemberListRowProps) {
7890
count++;
7991
}
8092

81-
setVisibleCount(Math.max(1, count));
93+
if (count === 0 && total > 0) {
94+
// Always show at least one member; cap width so text-truncate
95+
const reservedForBadge =
96+
total > 1 ? OVERFLOW_BADGE_WIDTH + MEMBER_GAP : 0;
97+
setVisibleCount(1);
98+
setTruncateMaxWidth(Math.max(0, containerWidth - reservedForBadge));
99+
return;
100+
}
101+
102+
setVisibleCount(count);
103+
setTruncateMaxWidth(null);
82104
}, [members.length]);
83105

84106
useEffect(() => {
85107
const container = containerRef.current;
86108
if (!container) return;
87109

88-
measureItemWidths();
110+
const updateVisibleMembers = () => {
111+
measureItemWidths();
112+
recalculate();
113+
};
114+
89115
// TODO: fix react-hooks/set-state-in-effect
90-
// eslint-disable-next-line react-hooks/set-state-in-effect
91-
recalculate();
116+
updateVisibleMembers();
92117

93118
const observer = new ResizeObserver(() => {
94-
recalculate();
119+
updateVisibleMembers();
95120
});
96121
observer.observe(container);
97122

@@ -112,9 +137,12 @@ export default function MemberListRow({ members }: MemberListRowProps) {
112137
"overflow-hidden",
113138
"position-relative",
114139
)}
140+
data-cy="member-list-row"
115141
>
116142
{members.map((member, index) => {
117143
const isHidden = index >= visibleCount;
144+
const shouldTruncate =
145+
!isHidden && truncateMaxWidth != null && index === 0;
118146
return (
119147
<div
120148
key={member.id}
@@ -125,7 +153,7 @@ export default function MemberListRow({ members }: MemberListRowProps) {
125153
"align-items-center",
126154
"d-flex",
127155
"gap-1",
128-
isHidden ? "flex-shrink-0" : "overflow-hidden",
156+
shouldTruncate ? "overflow-hidden" : "flex-shrink-0",
129157
)}
130158
style={
131159
isHidden
@@ -134,11 +162,13 @@ export default function MemberListRow({ members }: MemberListRowProps) {
134162
position: "absolute",
135163
pointerEvents: "none",
136164
}
137-
: { minWidth: 0 }
165+
: shouldTruncate
166+
? { maxWidth: truncateMaxWidth, minWidth: 0 }
167+
: undefined
138168
}
139169
>
140170
<UserAvatar namespace={member.namespace ?? ""} />
141-
<span className="text-truncate">
171+
<span className={cx("text-truncate", "min-w-0")}>
142172
{member.first_name} {member.last_name}
143173
</span>
144174
</div>

client/src/features/searchV2/components/SearchResults.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ function SearchResultListItem({ item }: SearchResultListItemProps) {
123123
to={url}
124124
data-cy="search-list-item"
125125
>
126-
<Row className="g-2">
126+
<Row className={cx("g-2", "flex-nowrap")}>
127127
<Col xs="auto">
128128
<h5>
129129
<SearchResultListItemIcon item={item} />

client/src/features/usersV2/show/UserAvatar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export function OverflowBadge({ count, hiddenMembers }: OverflowBadgeProps) {
176176
"text-black",
177177
styles.avatar,
178178
)}
179+
data-cy="member-list-overflow"
179180
>
180181
+{count}
181182
</div>

tests/cypress/e2e/searchV2.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,30 @@ describe("Search V2", () => {
7171
cy.location("pathname").should("eq", "/search");
7272
cy.location("search").should("include", "type=User");
7373
});
74+
75+
it("shows an overflow badge for a long member list", () => {
76+
fixtures
77+
.searchV2ListProjects({ numberOfProjects: 1, numberOfUsers: 0 })
78+
.listProjectV2Members({
79+
fixture: "projectV2/list-projectV2-members-long.json",
80+
projectId: "*",
81+
});
82+
cy.viewport(1000, 660);
83+
cy.visit("/search");
84+
85+
cy.getDataCy("search-query-input").type("test{enter}");
86+
cy.wait("@listProjectV2Members");
87+
cy.getDataCy("search-list-item").should("have.length", 1);
88+
89+
cy.getDataCy("member-list-row")
90+
.should("be.visible")
91+
.within(() => {
92+
cy.contains("Member One").should("be.visible");
93+
cy.contains("Member Twenty").should("not.be.visible");
94+
cy.getDataCy("member-list-overflow")
95+
.should("be.visible")
96+
.invoke("text")
97+
.should("match", /^\+[1-9]\d*$/);
98+
});
99+
});
74100
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
[
2+
{
3+
"id": "member-01-uuid",
4+
"first_name": "Member",
5+
"last_name": "One",
6+
"namespace": "member1",
7+
"role": "owner"
8+
},
9+
{
10+
"id": "member-02-uuid",
11+
"first_name": "Member",
12+
"last_name": "Two",
13+
"namespace": "member2",
14+
"role": "editor"
15+
},
16+
{
17+
"id": "member-03-uuid",
18+
"first_name": "Member",
19+
"last_name": "Three",
20+
"namespace": "member3",
21+
"role": "viewer"
22+
},
23+
{
24+
"id": "member-04-uuid",
25+
"first_name": "Member",
26+
"last_name": "Four",
27+
"namespace": "member4",
28+
"role": "viewer"
29+
},
30+
{
31+
"id": "member-05-uuid",
32+
"first_name": "Member",
33+
"last_name": "Five",
34+
"namespace": "member5",
35+
"role": "viewer"
36+
},
37+
{
38+
"id": "member-06-uuid",
39+
"first_name": "Member",
40+
"last_name": "Six",
41+
"namespace": "member6",
42+
"role": "viewer"
43+
},
44+
{
45+
"id": "member-07-uuid",
46+
"first_name": "Member",
47+
"last_name": "Seven",
48+
"namespace": "member7",
49+
"role": "viewer"
50+
},
51+
{
52+
"id": "member-08-uuid",
53+
"first_name": "Member",
54+
"last_name": "Eight",
55+
"namespace": "member8",
56+
"role": "viewer"
57+
},
58+
{
59+
"id": "member-09-uuid",
60+
"first_name": "Member",
61+
"last_name": "Nine",
62+
"namespace": "member9",
63+
"role": "viewer"
64+
},
65+
{
66+
"id": "member-10-uuid",
67+
"first_name": "Member",
68+
"last_name": "Ten",
69+
"namespace": "member10",
70+
"role": "viewer"
71+
},
72+
{
73+
"id": "member-11-uuid",
74+
"first_name": "Member",
75+
"last_name": "Eleven",
76+
"namespace": "member11",
77+
"role": "viewer"
78+
},
79+
{
80+
"id": "member-12-uuid",
81+
"first_name": "Member",
82+
"last_name": "Twelve",
83+
"namespace": "member12",
84+
"role": "viewer"
85+
},
86+
{
87+
"id": "member-13-uuid",
88+
"first_name": "Member",
89+
"last_name": "Thirteen",
90+
"namespace": "member13",
91+
"role": "viewer"
92+
},
93+
{
94+
"id": "member-14-uuid",
95+
"first_name": "Member",
96+
"last_name": "Fourteen",
97+
"namespace": "member14",
98+
"role": "viewer"
99+
},
100+
{
101+
"id": "member-15-uuid",
102+
"first_name": "Member",
103+
"last_name": "Fifteen",
104+
"namespace": "member15",
105+
"role": "viewer"
106+
},
107+
{
108+
"id": "member-16-uuid",
109+
"first_name": "Member",
110+
"last_name": "Sixteen",
111+
"namespace": "member16",
112+
"role": "viewer"
113+
},
114+
{
115+
"id": "member-17-uuid",
116+
"first_name": "Member",
117+
"last_name": "Seventeen",
118+
"namespace": "member17",
119+
"role": "viewer"
120+
},
121+
{
122+
"id": "member-18-uuid",
123+
"first_name": "Member",
124+
"last_name": "Eighteen",
125+
"namespace": "member18",
126+
"role": "viewer"
127+
},
128+
{
129+
"id": "member-19-uuid",
130+
"first_name": "Member",
131+
"last_name": "Nineteen",
132+
"namespace": "member19",
133+
"role": "viewer"
134+
},
135+
{
136+
"id": "member-20-uuid",
137+
"first_name": "Member",
138+
"last_name": "Twenty",
139+
"namespace": "member20",
140+
"role": "viewer"
141+
}
142+
]

0 commit comments

Comments
 (0)