Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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 packages/k8s-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"lucide-react": ">=0.400.0",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
"tailwind-merge": ">=2",
Comment thread
cursor[bot] marked this conversation as resolved.
"yaml": ">=2.0.0"
},
"devDependencies": {
Expand Down
46 changes: 46 additions & 0 deletions packages/k8s-ui/src/components/ui/Tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { renderToString } from 'react-dom/server'
import { Tooltip } from './Tooltip'

// Pins the wrapper-className merge contract: a caller passing
// `wrapperClassName="block"` MUST override the default `inline-flex`
// on the trigger span. Without `twMerge`, plain `clsx` concatenation
// emits both `inline-flex` and `block`, and stylesheet ordering picks
// the wrong one — breaks ChartBrowser's truncation flow because the
// wrapper stays `inline-flex` and the child `truncate` never engages.
describe('Tooltip wrapper className', () => {
it('lets the caller override the default display utility via twMerge', () => {
const html = renderToString(
<Tooltip content="hi" wrapperClassName="block">
<span>child</span>
</Tooltip>,
)
// Caller wins on the display group — twMerge drops the conflicting
// default. Assert each class independently; emit order is a
// twMerge implementation detail and varies across versions.
expect(html).toContain('block')
expect(html).toContain('max-w-full')
expect(html).not.toContain('inline-flex')
})

it('keeps the default display utility when no caller override is supplied', () => {
const html = renderToString(
<Tooltip content="hi">
<span>child</span>
</Tooltip>,
)
expect(html).toContain('inline-flex max-w-full')
})

it('merges arbitrary non-conflicting utilities from the caller alongside the defaults', () => {
const html = renderToString(
<Tooltip content="hi" wrapperClassName="min-w-0 flex-1">
<span>child</span>
</Tooltip>,
)
expect(html).toContain('inline-flex')
expect(html).toContain('max-w-full')
expect(html).toContain('min-w-0')
expect(html).toContain('flex-1')
})
})
10 changes: 9 additions & 1 deletion packages/k8s-ui/src/components/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ReactNode, useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { computeTooltipPosition } from './tooltip-position'

// Module-level singleton coordinator: only one Tooltip can be visible
Expand Down Expand Up @@ -199,7 +200,14 @@ export function Tooltip({
<>
<span
ref={triggerRef}
className={clsx('inline-flex max-w-full', wrapperClassName)}
// twMerge so a caller-supplied display utility (e.g.
// `wrapperClassName="block"` from ChartBrowser, where the
// wrapper needs to fill its `flex-1 min-w-0` parent so the
// child's `truncate` triggers) actually overrides our default
// `inline-flex`. Plain clsx concatenation can't override
// utilities that share a Tailwind property group, since the
// generated stylesheet ordering — not className order — wins.
className={twMerge(clsx('inline-flex max-w-full', wrapperClassName))}
style={wrapperStyle}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
Expand Down
1 change: 1 addition & 0 deletions packages/k8s-ui/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './k8s-errors'
export * from './parse-go-time'
export * from './view-transition'
export * from './validators'
export * from './user-initials'
95 changes: 95 additions & 0 deletions packages/k8s-ui/src/utils/user-initials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { computeUserInitials } from './user-initials'

// Pin the contract that the avatar circle never tries to render a
// non-letter glyph: previous implementations either produced
// silhouettes for separator-free usernames OR leaked separator
// characters into the result (e.g. ".U" for ".user").

describe('computeUserInitials', () => {
it('uses segment initials when separators are present', () => {
expect(computeUserInitials('mary.kohli')).toBe('MK')
expect(computeUserInitials('mary_kohli')).toBe('MK')
expect(computeUserInitials('mary-kohli')).toBe('MK')
})

it('caps segment initials at 2 even with many separators', () => {
expect(computeUserInitials('a.b.c.d')).toBe('AB')
})

it('falls back to leading letters when no separators', () => {
expect(computeUserInitials('mkohli')).toBe('MK')
expect(computeUserInitials('alice')).toBe('AL')
})

it('returns a single letter for single-character usernames', () => {
expect(computeUserInitials('a')).toBe('A')
})

it('strips the @-domain before computing', () => {
expect(computeUserInitials('mary.kohli@example.com')).toBe('MK')
expect(computeUserInitials('mkohli@example.com')).toBe('MK')
})

it('uppercases the result', () => {
expect(computeUserInitials('alice')).toBe('AL')
expect(computeUserInitials('ALICE')).toBe('AL')
expect(computeUserInitials('aLiCe')).toBe('AL')
})

it('returns empty string for null/undefined/empty inputs', () => {
expect(computeUserInitials(null)).toBe('')
expect(computeUserInitials(undefined)).toBe('')
expect(computeUserInitials('')).toBe('')
})

it('handles consecutive separators without producing empty segments', () => {
expect(computeUserInitials('mary..kohli')).toBe('MK')
expect(computeUserInitials('mary__kohli')).toBe('MK')
})

it('handles email-only usernames with @ as the first character', () => {
expect(computeUserInitials('@example.com')).toBe('')
})

it('does not include separator characters in the fallback', () => {
// Leading/trailing separators must not leak into the avatar
// circle as ".U", "-A", "_O" — non-letters get filtered before
// the slice, not after.
expect(computeUserInitials('.user')).toBe('US')
expect(computeUserInitials('-admin')).toBe('AD')
expect(computeUserInitials('_ops')).toBe('OP')
})

it('takes leading letters of the segment, not the whole localPart, for trailing/leading separator inputs', () => {
// Single-segment inputs must fall back to leading letters of
// the SEGMENT, not the raw localPart — otherwise `'mary.'`
// returns `'MA'` from the wrong slice and inconsistency with
// `'mary.kohli'` shows up only on partial inputs.
expect(computeUserInitials('mary.')).toBe('MA')
expect(computeUserInitials('.mary')).toBe('MA')
})

it('returns empty for inputs with no letters', () => {
// The avatar circle has no glyph to render for separator-only,
// digit-only, or punctuation-only inputs — return `''` so the
// caller can fall back to a silhouette.
expect(computeUserInitials('..')).toBe('')
expect(computeUserInitials('123')).toBe('')
expect(computeUserInitials('_')).toBe('')
expect(computeUserInitials('---')).toBe('')
})

it('drops leading whitespace before computing initials', () => {
// Leading whitespace must not leak into the avatar circle as
// a pair of blank glyphs — the truthy `' '` would defeat the
// caller's `{initials || <silhouette>}` fallback.
expect(computeUserInitials(' alice')).toBe('AL')
expect(computeUserInitials('\talice')).toBe('AL')
})

it('skips digits and punctuation interleaved with letters', () => {
expect(computeUserInitials('m1k')).toBe('MK')
expect(computeUserInitials('a$b$c')).toBe('AB')
})
})
41 changes: 41 additions & 0 deletions packages/k8s-ui/src/utils/user-initials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Computes a 1- or 2-character avatar label for a username.
*
* Rules (in order):
* 1. Strip the @-domain from the local-part — domains never carry
* useful identity for an in-app avatar.
* 2. Drop everything that isn't a letter (separators like `.`,
* `_`, `-`, digits, punctuation). The avatar circle can only
* render a meaningful glyph for letters; rendering `.U` or
* `12` looks broken.
* 3. If the cleaned local-part contains separator-bounded
* segments (`.`, `_`, `-`), use the first letter of each
* segment (max 2). e.g. `"mary.kohli"` → `"MK"`.
* 4. Otherwise use the first 1-2 letters of the cleaned
* local-part. e.g. `"mkohli"` → `"MK"`.
* 5. Always uppercase.
* 6. Returns `''` when no letters survive — the caller falls back
* to a silhouette / `?` icon.
*/
export function computeUserInitials(username: string | null | undefined): string {
if (!username) return ''
const localPart = username.split('@')[0]
if (!localPart) return ''
// Split on the canonical separators first so segment-based
// initials still work, then drop any non-letter characters per
// segment so leading punctuation can't leak into the result.
const segments = localPart
.split(/[._-]/)
.map(s => s.replace(/[^a-zA-Z]/g, ''))
.filter(Boolean)
if (segments.length === 0) return ''
if (segments.length >= 2) {
return segments
.slice(0, 2)
.map(s => s[0].toUpperCase())
.join('')
}
// Single segment (no usable separators) — surface up to two
// leading letters so e.g. `mkohli` produces `MK` instead of `M`.
return segments[0].slice(0, 2).toUpperCase()
}
8 changes: 2 additions & 6 deletions web/src/components/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'
import { User, LogOut } from 'lucide-react'
import { useAuthMe } from '../api/client'
import { useQueryClient } from '@tanstack/react-query'
import { computeUserInitials } from '@skyhook-io/k8s-ui/utils/user-initials'

export function UserMenu() {
const { data: authMe } = useAuthMe()
Expand Down Expand Up @@ -40,12 +41,7 @@ export function UserMenu() {
return null
}

const initials = authMe.username
.split('@')[0]
.split(/[._-]/)
.slice(0, 2)
.map(s => s[0]?.toUpperCase() || '')
.join('')
const initials = computeUserInitials(authMe.username)

return (
<div ref={menuRef} className="relative">
Expand Down
8 changes: 6 additions & 2 deletions web/src/components/helm/ChartBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,9 @@ function LocalChartCard({ chart, onSelect }: LocalChartCardProps) {
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
<Tooltip content={chart.name} wrapperClassName="min-w-0 flex-1">
<h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
</Tooltip>
{chart.deprecated && (
<span className={clsx('px-1 py-0.5 text-[10px] rounded', SEVERITY_BADGE.warning)}>
deprecated
Expand Down Expand Up @@ -489,7 +491,9 @@ function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) {

{/* Name and org */}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
<Tooltip content={chart.name} wrapperClassName="block">
Comment thread
cursor[bot] marked this conversation as resolved.
<h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
</Tooltip>
<div className="flex items-center gap-2 mt-0.5 text-xs text-theme-text-tertiary">
<span className="flex items-center gap-1">
<Building2 className="w-3 h-3" />
Expand Down