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
2 changes: 2 additions & 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 All @@ -88,6 +89,7 @@
"lucide-react": "^1.12.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"typescript": "^6.0.2",
"vitest": "^4.1.5",
"yaml": "^2.8.3"
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('[email protected]')).toBe('MK')
expect(computeUserInitials('[email protected]')).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