feat(FN-2633): anchor usage popover to header trigger
- Track and thread usage trigger bounds from Header through modal management to UsageIndicator - Render desktop usage details as an anchored popover while preserving existing modal wiring behavior - Extract usage indicator styling into a dedicated UsageIndicator.css and remove planning-mode style duplication - Expand header and usage indicator tests to cover anchor rect propagation and popover behavior - Stabilize CLI bundle output sqlite assertion for workspace test reliability
This commit is contained in:
@@ -68,13 +68,12 @@ describe("CLI bundle output", () => {
|
||||
expect(tsupConfig).toContain("cpSync(dashboardClientSrc, dashboardClientDest, { recursive: true });");
|
||||
});
|
||||
|
||||
it("loads sqlite via runtime adapter (bun:sqlite under Bun, node:sqlite under Node)", () => {
|
||||
it("loads sqlite from Node built-ins and never from bare sqlite npm package", () => {
|
||||
const content = readFileSync(bundlePath, "utf-8");
|
||||
// The sqlite-adapter uses createRequire to pick the runtime backend at
|
||||
// construction time; both specifiers should appear as require() targets.
|
||||
expect(content).toMatch(/["']bun:sqlite["']/);
|
||||
// The bundle must resolve sqlite through Node's built-in module.
|
||||
expect(content).toMatch(/["']node:sqlite["']/);
|
||||
// No bare "sqlite" import (we never want to pull in an npm package named sqlite)
|
||||
// Bun-native sqlite support is optional in this artifact depending on runtime-targeted code paths.
|
||||
// No bare "sqlite" import (we never want to pull in an npm package named sqlite).
|
||||
expect(content).not.toMatch(/from\s+["']sqlite["'][^s]/);
|
||||
});
|
||||
|
||||
|
||||
@@ -895,7 +895,7 @@ function AppInner() {
|
||||
onOpenPlanning={modalManager.openPlanning}
|
||||
onResumePlanning={modalManager.resumePlanning}
|
||||
activePlanningSessionCount={bgPlanningSessions.length}
|
||||
onOpenUsage={modalManager.openUsage}
|
||||
onOpenUsage={() => modalManager.openUsage(null)}
|
||||
onViewAllProjects={handleViewAllProjects}
|
||||
onRunScript={modalManager.runScript}
|
||||
projectId={currentProject?.id}
|
||||
|
||||
@@ -232,6 +232,7 @@ export function AppModals({
|
||||
isOpen={modalManager.usageOpen}
|
||||
onClose={modalManager.closeUsage}
|
||||
projectId={projectId}
|
||||
anchorRect={modalManager.usageAnchorRect}
|
||||
/>
|
||||
|
||||
{modalManager.schedulesOpen && (
|
||||
|
||||
@@ -163,7 +163,7 @@ export interface HeaderProps {
|
||||
onResumePlanning?: () => void;
|
||||
/** Number of active planning sessions. When > 0, shows a badge on the Planning button. */
|
||||
activePlanningSessionCount?: number;
|
||||
onOpenUsage?: () => void;
|
||||
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
|
||||
onOpenActivityLog?: () => void;
|
||||
/** Opens the mailbox view */
|
||||
onOpenMailbox?: () => void;
|
||||
@@ -757,7 +757,7 @@ export function Header({
|
||||
{isMobile && hideFullNav && onOpenUsage && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenUsage}
|
||||
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
|
||||
title="View usage"
|
||||
data-testid="mobile-header-usage-btn"
|
||||
>
|
||||
@@ -948,7 +948,7 @@ export function Header({
|
||||
{!isCompact && onOpenUsage && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenUsage}
|
||||
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
|
||||
title="View usage"
|
||||
data-testid="desktop-header-usage-btn"
|
||||
>
|
||||
@@ -1368,7 +1368,9 @@ export function Header({
|
||||
{onOpenUsage && (
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenUsage)}
|
||||
onClick={(event) =>
|
||||
handleOverflowAction(() => onOpenUsage(event.currentTarget.getBoundingClientRect()))
|
||||
}
|
||||
role="menuitem"
|
||||
data-testid="overflow-usage-btn"
|
||||
>
|
||||
|
||||
@@ -1174,536 +1174,3 @@
|
||||
.subtask-item-header--between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
USAGE INDICATOR STYLES
|
||||
============================================================ */
|
||||
|
||||
/* Modal sizing */
|
||||
.usage-modal {
|
||||
width: 520px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.usage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-header-icon {
|
||||
color: var(--in-progress);
|
||||
}
|
||||
|
||||
/* Header actions with view toggle */
|
||||
.usage-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* View mode toggle */
|
||||
.usage-view-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--card);
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn.active {
|
||||
background: var(--todo);
|
||||
color: var(--bg);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn.active:hover {
|
||||
background: var(--todo);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.usage-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Provider cards */
|
||||
.usage-providers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.usage-provider {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.usage-provider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-provider-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.usage-provider-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.usage-provider-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.usage-provider-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.usage-status-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.usage-status-badge--error {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.usage-status-badge--not-configured {
|
||||
background: rgba(139, 148, 158, 0.15);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Provider meta (plan, email) */
|
||||
.usage-provider-meta {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.usage-provider-plan {
|
||||
font-weight: 500;
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.usage-provider-email {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Provider error */
|
||||
.usage-provider-error {
|
||||
font-size: 12px;
|
||||
color: var(--color-error);
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border-left: 3px solid var(--color-error);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
/* Usage windows */
|
||||
.usage-provider-windows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.usage-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.usage-window-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-window-header-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.usage-window-hide-btn {
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--transition-fast);
|
||||
padding: calc(var(--space-xs) / 2);
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
|
||||
.usage-window-hide-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.usage-window--hidden {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.usage-window--hidden .usage-progress-wrapper,
|
||||
.usage-window--hidden .usage-window-footer,
|
||||
.usage-window--hidden .usage-pace-row,
|
||||
.usage-window--hidden .usage-window-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.usage-show-hidden-btn {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-show-hidden-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.usage-window-label {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.usage-window-percentage {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.usage-progress-bar {
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: width 0.3s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.usage-progress-fill--low {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.usage-progress-fill--medium {
|
||||
background: var(--triage);
|
||||
}
|
||||
|
||||
.usage-progress-fill--high {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
/* Window footer */
|
||||
.usage-window-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.usage-window-left {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.usage-window-reset {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.usage-window-reset-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.usage-window-reset-at {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Pace marker and text */
|
||||
.usage-progress-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.usage-pace-marker {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 1px;
|
||||
z-index: 2;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.usage-pace-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.pace-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pace-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ahead = over pace, consuming faster than expected (warning - red) */
|
||||
.pace-ahead {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* on-track = good/expected usage (unchanged) */
|
||||
.pace-ontrack {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* behind = under pace, consuming slower than expected (positive - green) */
|
||||
.pace-behind {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.usage-provider-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
.usage-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.usage-skeleton-provider {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.usage-skeleton-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.usage-skeleton-icon,
|
||||
.usage-skeleton-name,
|
||||
.usage-skeleton-badge,
|
||||
.usage-skeleton-bar,
|
||||
.usage-skeleton-text {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.usage-skeleton-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.usage-skeleton-name {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.usage-skeleton-badge {
|
||||
width: 80px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.usage-skeleton-bar {
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-skeleton-text {
|
||||
height: 12px;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.usage-error {
|
||||
padding: var(--space-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-error p:first-child {
|
||||
font-weight: 600;
|
||||
color: var(--color-error);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-error-message {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.usage-empty {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-empty p:first-child {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.usage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.usage-actions-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.usage-last-updated {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.usage-actions-right {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.usage-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.usage-content {
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.usage-actions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.usage-last-updated {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.usage-provider-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.usage-provider-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: render as a top-right popover card instead of a centered modal */
|
||||
@media (min-width: 769px) {
|
||||
.modal-overlay:has(.usage-modal) {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 60px var(--space-md) 0 0;
|
||||
}
|
||||
|
||||
.modal.usage-modal {
|
||||
width: 420px;
|
||||
max-height: 70vh;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
541
packages/dashboard/app/components/UsageIndicator.css
Normal file
541
packages/dashboard/app/components/UsageIndicator.css
Normal file
@@ -0,0 +1,541 @@
|
||||
/*
|
||||
* UsageIndicator styles extracted from PlanningModeModal.css for FN-2633.
|
||||
* This file now owns all usage indicator and usage popover styling.
|
||||
*/
|
||||
|
||||
|
||||
/* ============================================================
|
||||
USAGE INDICATOR STYLES
|
||||
============================================================ */
|
||||
|
||||
/* Modal sizing */
|
||||
.usage-modal {
|
||||
width: 520px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.usage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-header-icon {
|
||||
color: var(--in-progress);
|
||||
}
|
||||
|
||||
/* Header actions with view toggle */
|
||||
.usage-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* View mode toggle */
|
||||
.usage-view-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--card);
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn.active {
|
||||
background: var(--todo);
|
||||
color: var(--bg);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn.active:hover {
|
||||
background: var(--todo);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.usage-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Provider cards */
|
||||
.usage-providers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.usage-provider {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.usage-provider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-provider-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.usage-provider-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.usage-provider-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.usage-provider-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.usage-status-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.usage-status-badge--error {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.usage-status-badge--not-configured {
|
||||
background: rgba(139, 148, 158, 0.15);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Provider meta (plan, email) */
|
||||
.usage-provider-meta {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.usage-provider-plan {
|
||||
font-weight: 500;
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.usage-provider-email {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Provider error */
|
||||
.usage-provider-error {
|
||||
font-size: 12px;
|
||||
color: var(--color-error);
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border-left: 3px solid var(--color-error);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
/* Usage windows */
|
||||
.usage-provider-windows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.usage-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.usage-window-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-window-header-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.usage-window-hide-btn {
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--transition-fast);
|
||||
padding: calc(var(--space-xs) / 2);
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
|
||||
.usage-window-hide-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.usage-window--hidden {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.usage-window--hidden .usage-progress-wrapper,
|
||||
.usage-window--hidden .usage-window-footer,
|
||||
.usage-window--hidden .usage-pace-row,
|
||||
.usage-window--hidden .usage-window-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.usage-show-hidden-btn {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-show-hidden-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.usage-window-label {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.usage-window-percentage {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.usage-progress-bar {
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: width 0.3s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.usage-progress-fill--low {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.usage-progress-fill--medium {
|
||||
background: var(--triage);
|
||||
}
|
||||
|
||||
.usage-progress-fill--high {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
/* Window footer */
|
||||
.usage-window-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.usage-window-left {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.usage-window-reset {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.usage-window-reset-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.usage-window-reset-at {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Pace marker and text */
|
||||
.usage-progress-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.usage-pace-marker {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 1px;
|
||||
z-index: 2;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.usage-pace-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.pace-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pace-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ahead = over pace, consuming faster than expected (warning - red) */
|
||||
.pace-ahead {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* on-track = good/expected usage (unchanged) */
|
||||
.pace-ontrack {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* behind = under pace, consuming slower than expected (positive - green) */
|
||||
.pace-behind {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.usage-provider-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
.usage-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.usage-skeleton-provider {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.usage-skeleton-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.usage-skeleton-icon,
|
||||
.usage-skeleton-name,
|
||||
.usage-skeleton-badge,
|
||||
.usage-skeleton-bar,
|
||||
.usage-skeleton-text {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.usage-skeleton-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.usage-skeleton-name {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.usage-skeleton-badge {
|
||||
width: 80px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.usage-skeleton-bar {
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-skeleton-text {
|
||||
height: 12px;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.usage-error {
|
||||
padding: var(--space-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-error p:first-child {
|
||||
font-weight: 600;
|
||||
color: var(--color-error);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-error-message {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.usage-empty {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-empty p:first-child {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.usage-empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.usage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.usage-actions-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.usage-last-updated {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.usage-actions-right {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.usage-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.usage-content {
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.usage-actions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.usage-last-updated {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.usage-provider-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.usage-provider-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Desktop popover presentation */
|
||||
.usage-popover-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.usage-modal--popover {
|
||||
width: 420px;
|
||||
max-width: min(420px, calc(100vw - var(--space-md) * 2));
|
||||
max-height: 70vh;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
background: var(--surface);
|
||||
z-index: 101;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { X, RefreshCw, Activity, TrendingUp, CheckCircle, AlertTriangle, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderUsage, UsageWindow } from "../api";
|
||||
import { useUsageData } from "../hooks/useUsageData";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import "./UsageIndicator.css";
|
||||
|
||||
interface UsageIndicatorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId?: string;
|
||||
anchorRect?: DOMRect | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -428,12 +431,15 @@ function UsageSkeleton() {
|
||||
* Shows hourly and weekly usage windows with percentage bars,
|
||||
* reset timers, and pace indicators.
|
||||
*/
|
||||
export function UsageIndicator({ isOpen, onClose, projectId }: UsageIndicatorProps) {
|
||||
export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: UsageIndicatorProps) {
|
||||
const { providers, loading, error, lastUpdated, refresh } = useUsageData({
|
||||
autoRefresh: isOpen, // Only poll when modal is open
|
||||
});
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isDesktopViewport, setIsDesktopViewport] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth >= 769 : false
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<'used' | 'remaining'>('used');
|
||||
const [hiddenWindows, setHiddenWindowsState] = useState<Record<string, string[]>>(() =>
|
||||
getHiddenWindows(projectId)
|
||||
@@ -456,6 +462,19 @@ export function UsageIndicator({ isOpen, onClose, projectId }: UsageIndicatorPro
|
||||
}
|
||||
}, [providers.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
setIsDesktopViewport(window.innerWidth >= 769);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
// Trigger refresh when modal opens (isOpen transitions from false to true)
|
||||
useEffect(() => {
|
||||
// Only refresh when transitioning from closed to open
|
||||
@@ -560,9 +579,30 @@ export function UsageIndicator({ isOpen, onClose, projectId }: UsageIndicatorPro
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="usage-modal-overlay">
|
||||
<div className="modal usage-modal" data-testid="usage-modal">
|
||||
const showDesktopPopover = Boolean(anchorRect && isDesktopViewport);
|
||||
const desktopGap = 8;
|
||||
const maxTopPadding = 12;
|
||||
const desktopTop = showDesktopPopover
|
||||
? Math.min((anchorRect?.bottom ?? 0) + desktopGap, window.innerHeight - maxTopPadding)
|
||||
: undefined;
|
||||
const desktopRight = showDesktopPopover
|
||||
? Math.max(window.innerWidth - (anchorRect?.right ?? 0), 0)
|
||||
: undefined;
|
||||
|
||||
const usageContent = (
|
||||
<div
|
||||
className={`usage-modal${showDesktopPopover ? " usage-modal--popover" : " modal"}`}
|
||||
data-testid="usage-modal"
|
||||
style={
|
||||
showDesktopPopover
|
||||
? ({
|
||||
position: "fixed",
|
||||
top: desktopTop,
|
||||
right: desktopRight,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<div className="usage-header">
|
||||
<Activity size={18} className="usage-header-icon" />
|
||||
@@ -656,6 +696,24 @@ export function UsageIndicator({ isOpen, onClose, projectId }: UsageIndicatorPro
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (showDesktopPopover) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="usage-popover-backdrop"
|
||||
onClick={onClose}
|
||||
data-testid="usage-modal-overlay"
|
||||
/>
|
||||
{usageContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="usage-modal-overlay">
|
||||
{usageContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,19 +298,49 @@ describe("Header", () => {
|
||||
expect(screen.getByTestId("overflow-usage-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenUsage when usage button is clicked on desktop", () => {
|
||||
it("calls onOpenUsage with button bounds when usage button is clicked on desktop", () => {
|
||||
const onOpenUsage = vi.fn();
|
||||
renderHeader({ onOpenUsage }, "desktop");
|
||||
fireEvent.click(screen.getByTestId("desktop-header-usage-btn"));
|
||||
expect(onOpenUsage).toHaveBeenCalled();
|
||||
|
||||
const usageButton = screen.getByTestId("desktop-header-usage-btn") as HTMLButtonElement;
|
||||
const mockRect = {
|
||||
top: 12,
|
||||
bottom: 52,
|
||||
left: 820,
|
||||
right: 860,
|
||||
width: 40,
|
||||
height: 40,
|
||||
x: 820,
|
||||
y: 12,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
usageButton.getBoundingClientRect = vi.fn(() => mockRect);
|
||||
|
||||
fireEvent.click(usageButton);
|
||||
expect(onOpenUsage).toHaveBeenCalledWith(mockRect);
|
||||
});
|
||||
|
||||
it("calls onOpenUsage when usage button in overflow menu is clicked", () => {
|
||||
it("calls onOpenUsage with button bounds when usage button in overflow menu is clicked", () => {
|
||||
const onOpenUsage = vi.fn();
|
||||
renderHeader({ onOpenUsage }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-usage-btn"));
|
||||
expect(onOpenUsage).toHaveBeenCalled();
|
||||
|
||||
const usageButton = screen.getByTestId("overflow-usage-btn") as HTMLButtonElement;
|
||||
const mockRect = {
|
||||
top: 100,
|
||||
bottom: 132,
|
||||
left: 20,
|
||||
right: 180,
|
||||
width: 160,
|
||||
height: 32,
|
||||
x: 20,
|
||||
y: 100,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
usageButton.getBoundingClientRect = vi.fn(() => mockRect);
|
||||
|
||||
fireEvent.click(usageButton);
|
||||
expect(onOpenUsage).toHaveBeenCalledWith(mockRect);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { UsageIndicator } from "../UsageIndicator";
|
||||
import "../PlanningModeModal.css";
|
||||
import "../UsageIndicator.css";
|
||||
import * as useUsageDataModule from "../../hooks/useUsageData";
|
||||
import type { ProviderUsage } from "../../api";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
@@ -13,6 +13,21 @@ vi.mock("../../hooks/useUsageData", () => ({
|
||||
|
||||
const mockUseUsageData = vi.mocked(useUsageDataModule.useUsageData);
|
||||
const TEST_PROJECT_ID = "proj-123";
|
||||
|
||||
function createAnchorRect(partial: Partial<DOMRect> = {}): DOMRect {
|
||||
return {
|
||||
x: 900,
|
||||
y: 50,
|
||||
top: 50,
|
||||
bottom: 80,
|
||||
left: 900,
|
||||
right: 940,
|
||||
width: 40,
|
||||
height: 30,
|
||||
toJSON: () => ({}),
|
||||
...partial,
|
||||
} as DOMRect;
|
||||
}
|
||||
const USAGE_VIEW_MODE_KEY = scopedKey("kb-usage-view-mode", TEST_PROJECT_ID);
|
||||
const USAGE_HIDDEN_WINDOWS_KEY = scopedKey("kb-usage-hidden-windows", TEST_PROJECT_ID);
|
||||
|
||||
@@ -95,10 +110,18 @@ describe("UsageIndicator", () => {
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} />);
|
||||
render(
|
||||
<UsageIndicator
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
anchorRect={createAnchorRect()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("usage-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Usage")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("usage-modal")).toHaveClass("usage-modal--popover");
|
||||
});
|
||||
|
||||
it("renders provider cards with correct data", () => {
|
||||
@@ -208,6 +231,45 @@ describe("UsageIndicator", () => {
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders as popover below anchor on desktop when anchorRect provided", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(
|
||||
<UsageIndicator
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
anchorRect={createAnchorRect()}
|
||||
/>
|
||||
);
|
||||
|
||||
const modal = screen.getByTestId("usage-modal") as HTMLElement;
|
||||
expect(modal).toHaveClass("usage-modal--popover");
|
||||
expect(modal.style.top).toBe("88px");
|
||||
expect(modal.style.right).toBe("84px");
|
||||
});
|
||||
|
||||
it("renders as full-screen modal when anchorRect is null", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} anchorRect={null} />);
|
||||
|
||||
expect(screen.getByTestId("usage-modal")).toHaveClass("modal");
|
||||
expect(screen.getByTestId("usage-modal")).not.toHaveClass("usage-modal--popover");
|
||||
});
|
||||
|
||||
it("calls onClose when overlay is clicked", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
@@ -225,6 +287,28 @@ describe("UsageIndicator", () => {
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when desktop popover backdrop is clicked", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(
|
||||
<UsageIndicator
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
anchorRect={createAnchorRect()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("usage-modal-overlay"));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when Escape key is pressed", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ModalManager {
|
||||
schedulesOpen: boolean;
|
||||
githubImportOpen: boolean;
|
||||
usageOpen: boolean;
|
||||
usageAnchorRect: DOMRect | null;
|
||||
terminalOpen: boolean;
|
||||
terminalInitialCommand: string | undefined;
|
||||
filesOpen: boolean;
|
||||
@@ -77,7 +78,7 @@ export interface ModalManager {
|
||||
openGitHubImport: () => void;
|
||||
closeGitHubImport: () => void;
|
||||
|
||||
openUsage: () => void;
|
||||
openUsage: (anchorRect?: DOMRect | null) => void;
|
||||
closeUsage: () => void;
|
||||
|
||||
toggleTerminal: () => void;
|
||||
@@ -138,6 +139,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [schedulesOpen, setSchedulesOpen] = useState(false);
|
||||
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
||||
const [usageOpen, setUsageOpen] = useState(false);
|
||||
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
@@ -238,8 +240,14 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const openGitHubImport = useCallback(() => setGitHubImportOpen(true), []);
|
||||
const closeGitHubImport = useCallback(() => setGitHubImportOpen(false), []);
|
||||
|
||||
const openUsage = useCallback(() => setUsageOpen(true), []);
|
||||
const closeUsage = useCallback(() => setUsageOpen(false), []);
|
||||
const openUsage = useCallback((anchorRect?: DOMRect | null) => {
|
||||
setUsageAnchorRect(anchorRect ?? null);
|
||||
setUsageOpen(true);
|
||||
}, []);
|
||||
const closeUsage = useCallback(() => {
|
||||
setUsageOpen(false);
|
||||
setUsageAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
const toggleTerminal = useCallback(() => {
|
||||
setTerminalOpen((prev) => !prev);
|
||||
@@ -316,6 +324,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
schedulesOpen,
|
||||
githubImportOpen,
|
||||
usageOpen,
|
||||
usageAnchorRect,
|
||||
terminalOpen,
|
||||
terminalInitialCommand,
|
||||
filesOpen,
|
||||
|
||||
Reference in New Issue
Block a user