diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx index 74d2ffc44..a1a859a93 100644 --- a/components/ui/checkbox.tsx +++ b/components/ui/checkbox.tsx @@ -25,7 +25,7 @@ function Checkbox({ > diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 000000000..35708a6c7 --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,33 @@ +/* eslint-disable linebreak-style */ +/* eslint-disable react/react-in-jsx-scope */ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; + +function Collapsible({ + ...props +}: React.ComponentProps) { + return ; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/components/ui/radio-group.tsx b/components/ui/radio-group.tsx new file mode 100644 index 000000000..b8ec9e4ff --- /dev/null +++ b/components/ui/radio-group.tsx @@ -0,0 +1,45 @@ +/* eslint-disable linebreak-style */ +/* eslint-disable react/prop-types */ +import * as React from 'react'; +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; +import { CircleIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/cypress/components/Button.cy.tsx b/cypress/components/Button.cy.tsx new file mode 100644 index 000000000..518d6cc4b --- /dev/null +++ b/cypress/components/Button.cy.tsx @@ -0,0 +1,99 @@ +/* eslint-disable linebreak-style */ +import React from 'react'; +import { Button } from '@/components/ui/button'; + +describe('Button Component', () => { + it('renders with default variant and size', () => { + cy.mount(); + + cy.get('button') + .should('have.class', 'bg-primary') + .and('have.class', 'h-9') + .and('have.class', 'px-4') + .and('have.class', 'py-2'); + }); + + it('renders with different variants', () => { + cy.mount( +
+ + + + + + +
, + ); + + cy.get('button').eq(0).should('have.class', 'bg-primary'); + cy.get('button').eq(1).should('have.class', 'bg-destructive'); + cy.get('button').eq(2).should('have.class', 'border'); + cy.get('button').eq(3).should('have.class', 'bg-secondary'); + cy.get('button').eq(4).should('have.class', 'hover:bg-accent'); + cy.get('button').eq(5).should('have.class', 'text-primary'); + }); + + it('renders with different sizes', () => { + cy.mount( +
+ + + + +
, + ); + + cy.get('button').eq(0).should('have.class', 'h-9'); + cy.get('button').eq(1).should('have.class', 'h-8'); + cy.get('button').eq(2).should('have.class', 'h-10'); + cy.get('button').eq(3).should('have.class', 'size-9'); + }); + + it('handles disabled state', () => { + cy.mount(); + + cy.get('button') + .should('be.disabled') + .and('have.class', 'disabled:opacity-50') + .and('have.class', 'disabled:pointer-events-none'); + }); + + it('renders with icon', () => { + cy.mount( + , + ); + + cy.get('button').should('have.class', 'has-[>svg]:px-3'); + cy.get('[data-testid="test-icon"]').should('exist'); + }); + + it('applies custom className', () => { + cy.mount(); + + cy.get('button').should('have.class', 'custom-class'); + }); + + it('handles click events', () => { + const onClickSpy = cy.spy().as('onClickSpy'); + + cy.mount(); + + cy.get('button').click(); + cy.get('@onClickSpy').should('have.been.calledOnce'); + }); + + it('renders as child component when asChild is true', () => { + cy.mount( + , + ); + + cy.get('a') + .should('have.attr', 'href', '#test') + .and('have.class', 'bg-primary'); + }); +}); diff --git a/cypress/components/Checkbox.cy.tsx b/cypress/components/Checkbox.cy.tsx index a301188eb..115dfdd24 100644 --- a/cypress/components/Checkbox.cy.tsx +++ b/cypress/components/Checkbox.cy.tsx @@ -66,6 +66,35 @@ describe('Checkbox Component', () => { .and('have.class', 'data-[state=checked]:border-blue-500') .and('have.class', 'data-[state=checked]:text-white'); }); + + it('has properly centered icon when checked', () => { + cy.mount( + , + ); + + // Check that the indicator container has proper centering classes + cy.get('[data-slot="checkbox-indicator"]') + .should('have.class', 'flex') + .and('have.class', 'items-center') + .and('have.class', 'justify-center') + .and('have.class', 'w-full') + .and('have.class', 'h-full'); + + // Check that the icon exists and has proper sizing + cy.get('[data-slot="checkbox-indicator"] svg') + .should('exist') + .and('have.class', 'size-3.5'); + + // Verify the checkbox has the correct dimensions for centering + cy.get('button[role="checkbox"]') + .should('have.class', 'size-4') + .and('have.class', 'rounded-[4px]'); + }); }); describe('Dark Mode', () => { diff --git a/cypress/components/DropdownMenu.cy.tsx b/cypress/components/DropdownMenu.cy.tsx new file mode 100644 index 000000000..d84a90aea --- /dev/null +++ b/cypress/components/DropdownMenu.cy.tsx @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React from 'react'; +import DropdownMenu from '@/pages/tools/components/ui/DropdownMenu'; +import mockNextRouter, { MockRouter } from '../plugins/mockNextRouterUtils'; + +describe('DropdownMenu Component', () => { + let mockRouter: MockRouter; + const mockIcon = ; + const mockChildren =
Test Content
; + + beforeEach(() => { + mockRouter = mockNextRouter(); + }); + + it('renders with basic props', () => { + cy.mount( + + {mockChildren} + , + ); + + cy.contains('Test Menu').should('be.visible'); + cy.get('[data-testid="test-icon"]').should('exist'); + }); + + it('shows content when clicked', () => { + cy.mount( + + {mockChildren} + , + ); + + cy.get('button').click(); + cy.get('[data-testid="test-content"]').should('be.visible'); + }); + + it('displays count badge when count is provided', () => { + cy.mount( + + {mockChildren} + , + ); + + cy.contains('5').should('be.visible'); + }); + + it('does not show count badge when count is 0', () => { + cy.mount( + + {mockChildren} + , + ); + + cy.contains('0').should('not.exist'); + }); + + it('rotates arrow icon when dropdown is toggled', () => { + cy.mount( + + {mockChildren} + , + ); + + // Initially arrow should point down + cy.get('#arrow') + .should('have.attr', 'style') + .and('include', 'rotate(0deg)'); + + // Click to open + cy.get('button').click(); + cy.get('#arrow') + .should('have.attr', 'style') + .and('include', 'rotate(180deg)'); + + // Click to close + cy.get('button').click(); + cy.get('#arrow') + .should('have.attr', 'style') + .and('include', 'rotate(0deg)'); + }); + + it('toggles content visibility multiple times', () => { + cy.mount( + + {mockChildren} + , + ); + + // First toggle + cy.get('button').click(); + cy.get('[data-testid="test-content"]').should('be.visible'); + + // Second toggle + cy.get('button').click(); + cy.get('[data-testid="test-content"]').should('not.exist'); + + // Third toggle + cy.get('button').click(); + cy.get('[data-testid="test-content"]').should('be.visible'); + }); +}); diff --git a/cypress/components/Radio.cy.tsx b/cypress/components/Radio.cy.tsx new file mode 100644 index 000000000..c53feec15 --- /dev/null +++ b/cypress/components/Radio.cy.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import Radio from '@/pages/tools/components/ui/Radio'; + +describe('Radio Component', () => { + it('renders with label and value', () => { + cy.mount( + {}} + />, + ); + + cy.get('[role="radiogroup"]').should('exist'); + cy.get('[role="radio"]').should('exist'); + cy.contains('Test Radio').should('be.visible'); + }); + + it('handles selection change', () => { + const onChangeSpy = cy.spy().as('onChangeSpy'); + + cy.mount( + , + ); + + cy.get('[role="radio"]').click(); + cy.get('@onChangeSpy').should('have.been.calledWith', 'test'); + }); + + it('shows correct selected state', () => { + cy.mount( + {}} + />, + ); + + cy.get('[role="radio"]').should('have.attr', 'data-state', 'checked'); + }); + + it('has correct styling classes', () => { + cy.mount( + {}} + />, + ); + + cy.get('label').should('have.class', 'flex'); + cy.get('label').should('have.class', 'items-center'); + cy.get('label').should('have.class', 'gap-3'); + cy.get('label').should('have.class', 'px-4'); + cy.get('label').should('have.class', 'py-2'); + }); + + it('maintains selection state when re-rendered', () => { + const onChangeSpy = cy.spy().as('onChangeSpy'); + + cy.mount( + , + ); + + // First click to select + cy.get('[role="radio"]').click(); + cy.get('@onChangeSpy').should('have.been.calledWith', 'test'); + + // Re-mount with the selected value + cy.mount( + , + ); + + // Verify the selection state is maintained + cy.get('[role="radio"]').should('have.attr', 'data-state', 'checked'); + }); +}); diff --git a/package.json b/package.json index ed3214aa7..bbec23239 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "dependencies": { "@docsearch/react": "3.9.0", "@radix-ui/react-checkbox": "^1.3.1", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-slot": "^1.2.2", "@types/jsonpath": "^0.2.4", "axios": "1.9.0", "babel-loader": "^9.2.1", @@ -59,7 +61,6 @@ "slugify": "^1.6.5", "tailwind-merge": "^3.3.0", "tw-animate-css": "^1.2.9", - "yarn": "1.22.22", "zero-fill": "^2.2.4", "zustand": "^5.0.0" }, diff --git a/pages/tools/components/GroupByMenu.tsx b/pages/tools/components/GroupByMenu.tsx index b0c7bfafa..3af4fe0f0 100644 --- a/pages/tools/components/GroupByMenu.tsx +++ b/pages/tools/components/GroupByMenu.tsx @@ -1,4 +1,5 @@ import React, { Dispatch, SetStateAction } from 'react'; +import { Button } from '~/components/ui/button'; import { Transform } from '../hooks/useToolsTransform'; interface GroupByMenuProps { @@ -39,14 +40,15 @@ const GroupByMenu = ({ transform, setTransform }: GroupByMenuProps) => { GROUP BY: {groupBy.map((group) => { return ( - + ); })} diff --git a/pages/tools/components/SearchBar.tsx b/pages/tools/components/SearchBar.tsx index a4f281d72..05b54cae2 100644 --- a/pages/tools/components/SearchBar.tsx +++ b/pages/tools/components/SearchBar.tsx @@ -2,11 +2,18 @@ import React, { useEffect, useState } from 'react'; import { Input } from '@/components/ui/input'; import type { Transform } from '../hooks/useToolsTransform'; -const SearchBar = ({ transform }: { transform: Transform }) => { +interface SearchBarProps { + transform: Transform; + onQueryChange?: (query: string) => void; +} + +const SearchBar = ({ transform, onQueryChange }: SearchBarProps) => { const [query, setQuery] = useState(transform.query); const changeHandler = (e: React.ChangeEvent) => { - setQuery(e.target.value); + const newQuery = e.target.value; + setQuery(newQuery); + onQueryChange?.(newQuery); }; useEffect(() => { diff --git a/pages/tools/components/Sidebar.tsx b/pages/tools/components/Sidebar.tsx index 0d1571761..e6673b813 100644 --- a/pages/tools/components/Sidebar.tsx +++ b/pages/tools/components/Sidebar.tsx @@ -1,9 +1,16 @@ -import React, { Dispatch, SetStateAction, useRef } from 'react'; +import React, { + Dispatch, + SetStateAction, + useRef, + useState, + useEffect, +} from 'react'; import LanguageIcon from '~/public/icons/language.svg'; import ToolingIcon from '~/public/icons/tooling.svg'; import EnvironmentIcon from '~/public/icons/environment.svg'; import DialectIcon from '~/public/icons/dialect.svg'; import LicenseIcon from '~/public/icons/license.svg'; +import { Button } from '~/components/ui/button'; import DropdownMenu from './ui/DropdownMenu'; import Checkbox from './ui/Checkbox'; import SearchBar from './SearchBar'; @@ -36,6 +43,13 @@ export default function Sidebar({ setIsSidebarOpen, }: SidebarProps) { const filterFormRef = useRef(null); + const [pendingSelections, setPendingSelections] = + useState(transform); + + // Sync pendingSelections with transform when transform changes + useEffect(() => { + setPendingSelections(transform); + }, [transform]); const filters = [ { label: 'Language', accessorKey: 'languages' }, @@ -45,39 +59,28 @@ export default function Sidebar({ { label: 'License', accessorKey: 'licenses' }, ]; + const handleCheckboxChange = ( + name: string, + value: string, + checked: boolean, + ) => { + setPendingSelections((prev) => { + const currentValues = prev[name as keyof Transform] as string[]; + const newValues = checked + ? [...currentValues, value] + : currentValues.filter((v) => v !== value); + + return { + ...prev, + [name]: newValues, + }; + }); + }; + const applyFilters = (e: React.FormEvent) => { e.preventDefault(); - if (!filterFormRef.current) return; - const formData = new FormData(filterFormRef.current); - setTransform((prev) => { - const newTransform = { - query: (formData.get('query') as Transform['query']) || '', - sortBy: prev.sortBy || 'name', - sortOrder: prev.sortOrder || 'ascending', - groupBy: prev.groupBy || 'toolingTypes', - languages: formData.getAll('languages').map((value) => value as string), - licenses: formData.getAll('licenses').map((value) => value as string), - drafts: formData - .getAll('drafts') - .map((value) => value) as Transform['drafts'], - toolingTypes: formData - .getAll('toolingTypes') - .map((value) => value as string), - environments: formData - .getAll('environments') - .map((value) => value as string), - showObsolete: - (formData.get('showObsolete') as string) === 'showObsolete' - ? 'true' - : 'false', - supportsBowtie: - (formData.get('supportsBowtie') as string) === 'supportsBowtie' - ? 'true' - : 'false', - } satisfies Transform; - postAnalytics({ eventType: 'query', eventPayload: newTransform }); - return newTransform; - }); + setTransform(pendingSelections); + postAnalytics({ eventType: 'query', eventPayload: pendingSelections }); setIsSidebarOpen((prev) => !prev); }; @@ -85,6 +88,23 @@ export default function Sidebar({ if (filterFormRef.current) { filterFormRef.current.reset(); } + + // Reset pending selections to initial empty state + const initialTransform: Transform = { + query: '', + sortBy: 'name', + sortOrder: 'ascending', + groupBy: 'toolingTypes', + languages: [], + licenses: [], + drafts: [], + toolingTypes: [], + environments: [], + showObsolete: 'false', + supportsBowtie: 'false', + }; + + setPendingSelections(initialTransform); resetTransform(); setIsSidebarOpen((prev) => !prev); }; @@ -92,9 +112,15 @@ export default function Sidebar({ return (
- + + setPendingSelections((prev) => ({ ...prev, query })) + } + /> {filters.map(({ label, accessorKey }) => { - const checkedValues = transform[accessorKey as keyof Transform] || []; + const checkedValues = + pendingSelections[accessorKey as keyof Transform] || []; const IconComponent = filterIcons[accessorKey as keyof typeof filterIcons]; return ( @@ -102,6 +128,7 @@ export default function Sidebar({ key={accessorKey} label={label} icon={} + count={checkedValues.length} > {filterCriteria[accessorKey as FilterCriteriaFields] ?.map(String) @@ -116,6 +143,9 @@ export default function Sidebar({ value={filterOption} name={accessorKey} checked={checkedValues.includes(filterOption)} + onChange={(checked) => + handleCheckboxChange(accessorKey, filterOption, checked) + } /> ))} @@ -125,28 +155,42 @@ export default function Sidebar({ label='Show obsolete' value='showObsolete' name='showObsolete' - checked={transform['showObsolete'] === 'true'} + checked={pendingSelections['showObsolete'] === 'true'} + onChange={(checked) => + setPendingSelections((prev) => ({ + ...prev, + showObsolete: checked ? 'true' : 'false', + })) + } /> + setPendingSelections((prev) => ({ + ...prev, + supportsBowtie: checked ? 'true' : 'false', + })) + } />
- - +
diff --git a/pages/tools/components/ToolingDetailModal.tsx b/pages/tools/components/ToolingDetailModal.tsx index 36438448e..9bdcb82b8 100644 --- a/pages/tools/components/ToolingDetailModal.tsx +++ b/pages/tools/components/ToolingDetailModal.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import CancelIcon from '~/public/icons/cancel.svg'; +import { Button } from '~/components/ui/button'; import Badge from './ui/Badge'; import type { JSONSchemaTool } from '../JSONSchemaTool'; @@ -47,12 +48,14 @@ export default function ToolingDetailModal({ style={{ overflowWrap: 'anywhere' }} >
- +
{tool.landscape?.logo && ( diff --git a/pages/tools/components/ToolingTable.tsx b/pages/tools/components/ToolingTable.tsx index ba68efb1b..95bdc6ec7 100644 --- a/pages/tools/components/ToolingTable.tsx +++ b/pages/tools/components/ToolingTable.tsx @@ -9,6 +9,7 @@ import React, { import { Headline2 } from '~/components/Headlines'; import InfoIcon from '~/public/icons/icons8-info.svg'; import OutLinkIcon from '~/public/icons/outlink.svg'; +import { Button } from '~/components/ui/button'; import toTitleCase from '../lib/toTitleCase'; import type { GroupedTools, Transform } from '../hooks/useToolsTransform'; @@ -400,7 +401,8 @@ const TableSortableColumnHeader = ({ return ( - + ); }; diff --git a/pages/tools/components/ui/Checkbox.tsx b/pages/tools/components/ui/Checkbox.tsx index 934a93f1b..4337e945a 100644 --- a/pages/tools/components/ui/Checkbox.tsx +++ b/pages/tools/components/ui/Checkbox.tsx @@ -7,12 +7,14 @@ export default function Checkbox({ name, checked, disabled, + onChange, }: { label: string; value: string; name: string; checked?: boolean; disabled?: boolean; + onChange?: (checked: boolean) => void; }) { return (