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
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { FloatingSearch } from '../floating-search';
import { floatingEmptyState } from '../shared-styles';
import { ItemRow } from './item-row';
import { floatingEmptyState, menuPanelInset } from '../shared-styles';
import { ItemRow, ListScrollContext } from './item-row';
import type { FilterItem, FilterSelection } from './types';

const ITEM_HEIGHT = 28; // h-7
Expand Down Expand Up @@ -123,10 +123,24 @@ export function FilterContent({

const showSearch = alwaysShowSearch || items.length >= SEARCH_VISIBILITY_THRESHOLD;

const allNames = useMemo(() => items.map(item => item.name).join('\n'), [items]);
const hasSubmenus = items.some(item => item.values.length > 0);

return (
// Modest min-width so the popover doesn't collapse to a single 1–2 char
// item, but still sizes naturally to fit the content of small lists.
<div role="group" className="min-w-[120px]">
<div role="group" className={`min-w-[120px] ${menuPanelInset}`}>
{/*
Only the rows in view are in the DOM, so left to itself the popup would size to whichever
happen to be rendered. This zero-height row carries every name, one per line, so the popup
is as wide as its widest item (up to the popup's max-width) from the start and stays put
through scrolling and search. Laid out like an ItemRow: checkbox, gap, name, chevron.
*/}
<div aria-hidden className="invisible flex h-0 gap-2.5 overflow-hidden px-2">
<span className="size-3.5 shrink-0" />
<span className="whitespace-pre">{allNames}</span>
{hasSubmenus ? <span className="ml-auto size-3.5 shrink-0" /> : null}
</div>
{showSearch && <FloatingSearch label={label} onSearch={setSearch} value={search} />}
{/* Note about unavailable items */}
{items.some(item => item.unavailable) && (
Expand Down Expand Up @@ -154,28 +168,30 @@ export function FilterContent({
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`,
}}
>
{virtualizer.getVirtualItems().map(virtualItem => {
const item = filteredItems[virtualItem.index];
const selected = isItemSelected(item, selectedItems);
const selection = getItemSelection(item, selectedItems);
const hasPartialValues =
selected && selection?.values !== null && (selection?.values?.length ?? 0) > 0;

return (
<div key={getKey(item)} style={{ height: virtualItem.size }}>
<ItemRow
item={item}
selected={selected}
indeterminate={hasPartialValues}
onToggle={toggleItem}
selection={selection}
onValuesChange={updateItemValues}
valuesLabel={valuesLabel}
unavailable={item.unavailable}
/>
</div>
);
})}
<ListScrollContext.Provider value={scrollRef}>
{virtualizer.getVirtualItems().map(virtualItem => {
const item = filteredItems[virtualItem.index];
const selected = isItemSelected(item, selectedItems);
const selection = getItemSelection(item, selectedItems);
const hasPartialValues =
selected && selection?.values !== null && (selection?.values?.length ?? 0) > 0;

return (
<div key={getKey(item)} style={{ height: virtualItem.size }}>
<ItemRow
item={item}
selected={selected}
indeterminate={hasPartialValues}
onToggle={toggleItem}
selection={selection}
onValuesChange={updateItemValues}
valuesLabel={valuesLabel}
unavailable={item.unavailable}
/>
</div>
);
})}
</ListScrollContext.Provider>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const CLIENTS: FilterItem[] = [
{ name: 'graphql-mesh', values: ['1.0.0', '1.1.0', '1.2.0'] },
{ name: 'cosmo-router', values: ['0.1.0', '0.2.0', '0.3.0'] },
{ name: 'stellate-edge', values: ['0.9.0', '0.10.0'] },
{ name: 'hive-schema-registry-worker', values: ['2.4.0', '2.3.0'] },
{ name: 'unknown', values: [] },
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@ export function FilterDropdown({
side="bottom"
align="start"
maxWidth="lg"
stableWidth
content={
<FilterContent
label={label}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { memo } from 'react';
import { createContext, memo, useContext, useEffect, useState, type RefObject } from 'react';
import { ChevronRight } from 'lucide-react';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Menu, MenuItem } from '../menu/menu';
import type { FilterItem, FilterSelection } from './types';
import { ValuesSubPanel } from './values-sub-panel';

/** The list's scroll container, so a row can close its values panel when the list scrolls. */
export const ListScrollContext = createContext<RefObject<HTMLDivElement> | null>(null);

interface ItemRowProps {
item: FilterItem;
selected: boolean;
Expand Down Expand Up @@ -37,6 +41,18 @@ export const ItemRow = memo(function ItemRow({
unavailable,
}: ItemRowProps) {
const hasValues = item.values.length > 0;
const scrollRef = useContext(ListScrollContext);
const [open, setOpen] = useState(false);

// Scrolling the list moves the pointer off the row that opened the panel.
useEffect(() => {
const list = scrollRef?.current;
if (!open || !list) return;

const close = () => setOpen(false);
list.addEventListener('scroll', close, { passive: true });
return () => list.removeEventListener('scroll', close);
}, [open, scrollRef]);

if (!hasValues) {
return (
Expand All @@ -50,10 +66,13 @@ export const ItemRow = memo(function ItemRow({
return (
<Menu
submenu
open={open}
onOpenChange={setOpen}
trigger={
<div onClick={() => onToggle(item)}>
<Checkbox checked={selected} indeterminate={indeterminate} size="sm" visual />
<ItemName name={item.name} unavailable={unavailable} />
<ChevronRight className="ml-auto size-3.5" />
</div>
}
openOnHover
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export function TextFilterChip({
side="bottom"
align="start"
maxWidth="lg"
stableWidth
content={
<FloatingSearch
label={label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { FloatingSearch } from '../floating-search';
import { MenuItem } from '../menu/menu';
import { floatingEmptyState, floatingScrollArea } from '../shared-styles';
import { floatingEmptyState, floatingScrollArea, menuPanelInset } from '../shared-styles';
import { SEARCH_VISIBILITY_THRESHOLD } from './filter-content';

type ValuesSubPanelProps = {
Expand Down Expand Up @@ -59,7 +59,7 @@ export function ValuesSubPanel({
const showSearch = values.length >= SEARCH_VISIBILITY_THRESHOLD;

return (
<div>
<div className={menuPanelInset}>
{showSearch && (
<FloatingSearch
label={`Search ${valuesLabel} for ${itemName}`}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { createPreview, type NavPath } from 'react-foundry';
import { FilterChips, FilterMenu } from './filter-menu';
import type { FilterDimension, FilterItem, FilterSelection } from './types';
Expand Down Expand Up @@ -181,6 +181,112 @@ export const AllDimensionKinds = createPreview(() => {
);
});

const INSIGHTS_OPERATIONS: FilterItem[] = [
'GetOrganizationMembersWithPermissions',
'ListSchemaVersionsForTargetOverview',
'CreateAccessTokenForOrganization',
'UpdateProjectRegistryModelSettings',
'GetSchemaCheckWithBreakingChanges',
'FetchUsageStatisticsForClientVersion',
'DeleteTargetConditionalBreakingChangeConfig',
'ListOrganizationInvitationsPaginated',
'CompareSchemaVersionsWithContracts',
'PublishSubgraphSchemaWithMetadata',
'GetLaboratoryPreflightScriptHistory',
'IntrospectSupergraphFromCdn',
'ListAlertChannelsForProject',
'GetBillingUsageAndLimitsForOrg',
'me',
'ping',
].map((name, i) => ({ id: `hash-${i}`, name, values: [] }));

const INSIGHTS_CLIENTS: FilterItem[] = [
{ name: 'unknown', values: [] },
{ name: 'Hive CLI', values: ['0.50.1', '0.50.0', '0.49.0', '0.48.3'] },
{ name: 'Hive Client', values: ['0.5.0', '0.23.1', '0.14.2', '0.10.0', '0.8.2'] },
{ name: 'hive-gateway', values: ['1.13.0', '1.12.0', '1.11.4'] },
{ name: 'hive-console-frontend', values: ['0.0.1'] },
{ name: 'hive-schema-registry-worker', values: ['2.4.0', '2.3.0'] },
{ name: 'graphql-yoga', values: ['5.10.0', '5.9.0'] },
{ name: 'hive-apollo-router-plugin', values: ['1.2.0', '1.1.0', '1.0.0'] },
{ name: 'octokit-graphql', values: ['8.1.1', '8.0.0'] },
{ name: 'graphql-mesh-serve-runtime', values: ['1.5.0'] },
{ name: 'apollo-client', values: ['3.11.0', '3.10.0'] },
{ name: 'urql', values: ['4.1.0', '4.0.0'] },
{ name: 'relay-runtime', values: ['17.0.0'] },
{ name: 'hive-cdn-edge-worker', values: ['0.9.0', '0.8.0'] },
{ name: 'graphql-codegen-hive-plugin', values: ['2.0.0'] },
{ name: 'k6-load-test', values: ['0.52.0'] },
];

/**
* The Insights page: operations and clients, both long enough to scroll and
* search, with names longer than a short list would size the popup to.
*/
export const InsightsDimensions = createPreview(() => {
const [operations, setOperations] = useState<FilterSelection[]>([]);
const [clients, setClients] = useState<FilterSelection[]>([]);

const dimensions: FilterDimension[] = [
{
key: 'operation',
label: 'Operation',
items: INSIGHTS_OPERATIONS,
selectedItems: operations,
onChange: setOperations,
},
{
key: 'client',
label: 'Client',
items: INSIGHTS_CLIENTS,
selectedItems: clients,
onChange: setClients,
valuesLabel: 'versions',
},
];

return (
<div className="flex flex-wrap items-center gap-2">
<FilterMenu dimensions={dimensions} />
<FilterChips dimensions={dimensions} />
</div>
);
});

/**
* Insights on a slow connection: the picker query resolves after the page is
* interactive, so the submenu can be open on an empty list when the items land.
* Open Filter → Client within the first few seconds to see it.
*/
export const InsightsDimensionsLoading = createPreview(() => {
const [loaded, setLoaded] = useState(false);
const [clients, setClients] = useState<FilterSelection[]>([]);

useEffect(() => {
const timer = setTimeout(() => setLoaded(true), 4000);
return () => clearTimeout(timer);
}, []);

const dimensions: FilterDimension[] = [
{
key: 'client',
label: 'Client',
items: loaded ? INSIGHTS_CLIENTS : [],
selectedItems: clients,
onChange: setClients,
valuesLabel: 'versions',
},
];

return (
<div className="flex flex-wrap items-center gap-2">
<FilterMenu dimensions={dimensions} />
<FilterChips dimensions={dimensions} />
<span className="text-neutral-8 text-xs">{loaded ? 'items loaded' : 'loading items…'}</span>
</div>
);
});

/** Empty text value renders no chip, and the toggle section still gets its divider. */
export const TextFilterEmpty = createPreview(() => {
const [field, setField] = useState('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export function FilterMenu({
kind: 'submenu',
label: d.label,
maxWidth: 'lg',
stableWidth: true,
content: isText(d) ? (
<FloatingSearch
label={d.label.toLowerCase()}
Expand Down
Loading
Loading