diff --git a/src/components/BMDashboard/Projects/ProjectDetails/MemberSearchInput.jsx b/src/components/BMDashboard/Projects/ProjectDetails/MemberSearchInput.jsx new file mode 100644 index 00000000000..d1d6ee69e02 --- /dev/null +++ b/src/components/BMDashboard/Projects/ProjectDetails/MemberSearchInput.jsx @@ -0,0 +1,134 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { highlightParts, normalizeSearch } from './memberSearch'; +import styles from './ProjectLog.module.css'; + +export function HighlightMatch({ value, query }) { + return highlightParts(value, query).map(part => + part.match ? ( + + {part.text} + + ) : ( + part.text + ), + ); +} + +HighlightMatch.propTypes = { + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + query: PropTypes.string, +}; + +export default function MemberSearchInput({ column, value, options, onChange, onSelect }) { + const id = useId(); + const listRef = useRef(null); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const query = normalizeSearch(value); + const matches = useMemo( + () => + query ? options.filter(option => normalizeSearch(option).includes(query)).slice(0, 8) : [], + [options, query], + ); + const expanded = Boolean(column.suggest && open && matches.length); + const active = expanded && activeIndex >= 0 && activeIndex < matches.length; + + useEffect(() => { + listRef.current?.children[activeIndex]?.scrollIntoView?.({ block: 'nearest' }); + }, [activeIndex]); + + const select = option => { + onSelect(option); + setOpen(false); + setActiveIndex(-1); + }; + + const handleKeyDown = event => { + if (event.nativeEvent.isComposing) return; + if (event.key === 'Escape') { + setOpen(false); + setActiveIndex(-1); + } else if (column.suggest && matches.length && ['ArrowDown', 'ArrowUp'].includes(event.key)) { + event.preventDefault(); + setOpen(true); + const direction = event.key === 'ArrowDown' ? 1 : -1; + setActiveIndex(previous => { + if (!expanded || previous < 0) return direction === 1 ? 0 : matches.length - 1; + return (previous + direction + matches.length) % matches.length; + }); + } else if (event.key === 'Enter' && active) { + event.preventDefault(); + select(matches[activeIndex]); + } + }; + + return ( +
+ + { + onChange(event.target.value); + setOpen(true); + setActiveIndex(-1); + }} + onFocus={() => setOpen(true)} + onBlur={() => { + setOpen(false); + setActiveIndex(-1); + }} + onKeyDown={handleKeyDown} + /> + {expanded && ( + + )} +
+ ); +} + +MemberSearchInput.propTypes = { + column: PropTypes.shape({ + key: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + suggest: PropTypes.bool, + }).isRequired, + value: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.string).isRequired, + onChange: PropTypes.func.isRequired, + onSelect: PropTypes.func.isRequired, +}; diff --git a/src/components/BMDashboard/Projects/ProjectDetails/ProjectDetails.jsx b/src/components/BMDashboard/Projects/ProjectDetails/ProjectDetails.jsx index ca706c27fb2..029d192932d 100644 --- a/src/components/BMDashboard/Projects/ProjectDetails/ProjectDetails.jsx +++ b/src/components/BMDashboard/Projects/ProjectDetails/ProjectDetails.jsx @@ -160,7 +160,7 @@ function ProjectDetails() { padding: '10px', }} > - + diff --git a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx index fc870581b77..bd9592755ff 100644 --- a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx +++ b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx @@ -1,5 +1,9 @@ +import { memo, useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; import { Card, Table } from 'reactstrap'; -import styles from './ProjectDetails.module.css'; +import MemberSearchInput, { HighlightMatch } from './MemberSearchInput'; +import { EMPTY_FILTERS, MEMBER_COLUMNS, filterMembers, getMemberOptions } from './memberSearch'; +import styles from './ProjectLog.module.css'; const DummyData = [ { id: '12', @@ -33,24 +37,102 @@ const DummyData = [ }, ]; -function ProjectLog() { - const tableRows = DummyData.map(person => ( - - {person.id} - {person.firstName} - {person.lastName} - {person.role} - {person.team} - {person.currentTask} - {person.totalHrs} - {person.todaysHrs} - - )); +// Keep the potentially large table body unchanged while the user types. +const MemberRows = memo(function MemberRows({ members, filters }) { + return ( + + {members.length ? ( + members.map(person => ( + + {MEMBER_COLUMNS.map(({ key }) => { + const content = ; + return key === 'id' ? ( + + {content} + + ) : ( + {content} + ); + })} + + )) + ) : ( + + No members match these filters. + + )} + + ); +}); + +MemberRows.propTypes = { + members: PropTypes.array.isRequired, + filters: PropTypes.object.isRequired, +}; + +function ProjectLog({ members = DummyData, darkMode = false }) { + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS); + const options = useMemo( + () => + Object.fromEntries( + MEMBER_COLUMNS.map(column => [ + column.key, + column.suggest ? getMemberOptions(members, column.key) : [], + ]), + ), + [members], + ); + const filtered = useMemo(() => filterMembers(members, appliedFilters), [members, appliedFilters]); + + useEffect(() => { + if (filters === appliedFilters) return undefined; + const timer = setTimeout(() => setAppliedFilters(filters), 350); + return () => clearTimeout(timer); + }, [filters, appliedFilters]); + + const clearFilters = () => { + setFilters(EMPTY_FILTERS); + setAppliedFilters(EMPTY_FILTERS); + }; return ( - +

Members working on site today

- +
+ + Showing {filtered.length} of {members.length} members + + +
+
+ Filter members + {MEMBER_COLUMNS.map(column => ( + setFilters(previous => ({ ...previous, [column.key]: value }))} + onSelect={value => { + const next = { ...filters, [column.key]: value }; + setFilters(next); + setAppliedFilters(next); + }} + /> + ))} +
+
+ @@ -63,10 +145,12 @@ function ProjectLog() { - {tableRows} +
+ Members working on site today, filtered by the fields above +
IDToday's Hrs
); } +ProjectLog.propTypes = { members: PropTypes.arrayOf(PropTypes.object), darkMode: PropTypes.bool }; + export default ProjectLog; diff --git a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css new file mode 100644 index 00000000000..8dc55c9d43e --- /dev/null +++ b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css @@ -0,0 +1,150 @@ +.projectLog { + --panel: #fff; + --text: #172b4d; + --border: #768399; + --active: #e5efff; + --highlight: #fff0a8; + + padding: 16px; + min-width: 0; + background: var(--panel); + color: var(--text); +} + +.dark { + --panel: #243447; + --text: #fff; + --border: #94a3b8; + --active: #365a7b; + --highlight: #225e7a; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 12px 0; +} + +.toolbar output { + margin: 0; +} + +.clearButton { + padding: 8px 12px; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; +} + +.clearButton:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); + gap: 12px; + margin-bottom: 16px; + min-width: 0; + padding: 0; + border: 0; +} + +.filtersLegend { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.filter { + position: relative; + min-width: 0; + text-align: left; +} + +.filter label { + display: block; + font-weight: 600; + margin-bottom: 4px; +} + +.searchInput { + width: 100%; + min-width: 0; + padding: 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--panel); + color: var(--text); +} + +.searchInput::placeholder { + color: var(--text); + opacity: 0.75; +} + +.searchInput:focus-visible, +.clearButton:focus-visible { + outline: 3px solid #598bde; + outline-offset: 2px; +} + +.suggestions { + position: absolute; + top: 100%; + left: 0; + z-index: 20; + width: 100%; + max-height: 280px; + overflow-y: auto; + padding: 0; + margin: 4px 0 0; + list-style: none; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--panel); + color: var(--text); + box-shadow: 0 4px 8px rgb(0 0 0 / 20%); +} + +.option { + padding: 8px; + overflow-wrap: anywhere; + cursor: pointer; +} + +.option:hover, +.option[aria-selected='true'] { + background: var(--active); +} + +.match { + padding: 0; + background: var(--highlight); + color: inherit; + border-radius: 2px; +} + +.membersTable { + min-width: 780px; + color: var(--text); +} + +.membersTable th, +.membersTable td { + color: var(--text); + background: var(--panel); +} + +.caption { + color: var(--text); +} diff --git a/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx b/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx new file mode 100644 index 00000000000..9fd85f80cea --- /dev/null +++ b/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx @@ -0,0 +1,264 @@ +import React from 'react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ProjectLog from '../ProjectLog'; +import { EMPTY_FILTERS, filterMembers, getMemberOptions, highlightParts } from '../memberSearch'; + +const members = [ + { + id: '1', + firstName: 'Cailin', + lastName: 'Colby', + role: 'Carpenter', + team: 'Team A', + currentTask: 'Framing', + totalHrs: 120, + todaysHrs: 0, + }, + { + id: '2', + firstName: 'Carl', + lastName: 'Smith', + role: 'Volunteer', + team: 'Team B', + currentTask: 'Painting', + totalHrs: 30, + todaysHrs: 2.5, + }, + { + id: '3', + firstName: 'Casey', + lastName: 'Colby', + role: 'Carpenter', + team: 'Team B', + currentTask: 'Framing', + totalHrs: 10, + todaysHrs: 1, + }, +]; +const change = (label, value) => + fireEvent.change(screen.getByLabelText(label), { target: { value } }); +const advance = (ms = 350) => act(() => vi.advanceTimersByTime(ms)); +const table = () => screen.getByRole('table'); +const rowNames = () => + within(table()) + .getAllByRole('row') + .slice(1) + .map(row => row.textContent); + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('on-site member filtering', () => { + it('uses a native output and labelled fieldset for accessible search feedback', () => { + render(); + expect(screen.getByRole('status').tagName).toBe('OUTPUT'); + expect(screen.getByRole('status')).toHaveTextContent('Showing 3 of 3 members'); + expect(screen.getByRole('group', { name: 'Filter members' }).tagName).toBe('FIELDSET'); + }); + + it('preserves all existing columns and starts with clear disabled', () => { + render(); + expect(within(table()).getAllByRole('columnheader')).toHaveLength(8); + expect(rowNames()).toHaveLength(3); + expect(screen.getByRole('button', { name: 'Clear All Filters' })).toBeDisabled(); + }); + + it('applies case-insensitive partial matching only after 350ms', () => { + render(); + change('First Name', 'AIL'); + advance(349); + expect(rowNames()).toHaveLength(3); + advance(1); + expect(rowNames()).toHaveLength(1); + expect(rowNames()[0]).toContain('Cailin'); + expect(within(table()).getByText('ail', { selector: 'mark' })).toBeInTheDocument(); + }); + + it('restarts the delay for each keystroke and never applies an older query', () => { + render(); + change('First Name', 'Cail'); + advance(300); + change('First Name', 'Carl'); + advance(50); + expect(rowNames()).toHaveLength(3); + advance(300); + expect(rowNames()).toHaveLength(1); + expect(rowNames()[0]).toContain('Carl'); + }); + + it('combines columns with AND and trims surrounding whitespace', () => { + render(); + change('Role', ' carpenter '); + change('Team', 'team b'); + advance(); + expect(rowNames()).toHaveLength(1); + expect(rowNames()[0]).toContain('Casey'); + }); + + it('clears every field immediately and cancels pending work', () => { + render(); + change('Role', 'Carpenter'); + advance(); + change('First Name', 'Nobody'); + fireEvent.click(screen.getByRole('button', { name: 'Clear All Filters' })); + expect(rowNames()).toHaveLength(3); + expect(screen.getByLabelText('Role')).toHaveValue(''); + expect(screen.getByLabelText('First Name')).toHaveValue(''); + advance(700); + expect(rowNames()).toHaveLength(3); + expect(within(table()).queryByText('Carpenter', { selector: 'mark' })).toBeNull(); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('supports task and numeric columns, including zero', () => { + render(); + change('Current Task', 'fram'); + change("Today's Hrs", '0'); + advance(); + expect(rowNames()).toHaveLength(1); + expect(rowNames()[0]).toContain('Cailin'); + expect(within(table()).getByText('0', { selector: 'mark' })).toBeInTheDocument(); + }); + + it('shows no-match and empty-data states without throwing', () => { + const { rerender } = render(); + change('ID', 'unknown'); + advance(); + expect(within(table()).getByText('No members match these filters.')).toBeInTheDocument(); + rerender(); + expect(screen.getByRole('status')).toHaveTextContent('Showing 0 of 0 members'); + }); + + it('recomputes filtered rows and suggestions when members are refreshed', () => { + const { rerender } = render(); + change('First Name', 'Cam'); + advance(); + rerender( + , + ); + expect(rowNames()).toHaveLength(1); + expect(screen.getByRole('option', { name: 'Cameron' })).toBeInTheDocument(); + }); + + it('cancels the debounce on unmount', () => { + const { unmount } = render(); + change('First Name', 'Ca'); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('uses the existing sample rows when no member source is supplied', () => { + render(); + expect(rowNames()).toHaveLength(3); + expect(within(table()).getByText('Dora')).toBeInTheDocument(); + }); +}); + +describe('typeahead', () => { + it.each(['First Name', 'Last Name', 'Role', 'Team'])( + 'provides a labelled combobox for %s', + label => { + render(); + expect(screen.getByRole('combobox', { name: label })).toHaveAttribute( + 'aria-autocomplete', + 'list', + ); + }, + ); + + it('offers unique values from the matching column and selects by mouse', () => { + render(); + change('Role', 'car'); + expect(screen.getAllByRole('option')).toHaveLength(1); + fireEvent.mouseDown(screen.getByRole('option', { name: 'Carpenter' })); + fireEvent.click(screen.getByRole('option', { name: 'Carpenter' })); + expect(screen.getByLabelText('Role')).toHaveValue('Carpenter'); + expect(screen.queryByRole('listbox')).toBeNull(); + expect(rowNames()).toHaveLength(2); + }); + + it('supports arrow keys and Enter while retaining input focus semantics', () => { + render(); + const input = screen.getByLabelText('First Name'); + fireEvent.focus(input); + change('First Name', 'ca'); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + const selected = screen + .getAllByRole('option') + .find(option => option.getAttribute('aria-selected') === 'true'); + expect(input).toHaveAttribute('aria-activedescendant', selected.id); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(input).toHaveValue('Casey'); + expect(rowNames()).toHaveLength(1); + expect(input).toHaveAttribute('aria-expanded', 'false'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + }); + + it('Escape and blur dismiss suggestions without changing the query', () => { + render(); + const input = screen.getByLabelText('First Name'); + change('First Name', 'ca'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'Escape' }); + expect(screen.queryByRole('listbox')).toBeNull(); + expect(input).toHaveValue('ca'); + fireEvent.focus(input); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + fireEvent.blur(input); + expect(screen.queryByRole('listbox')).toBeNull(); + expect(input).toHaveValue('ca'); + }); + + it('does not accept suggestions while composing text', () => { + render(); + const input = screen.getByLabelText('First Name'); + change('First Name', 'ca'); + fireEvent.keyDown(input, { key: 'ArrowDown', isComposing: true }); + expect(input).not.toHaveAttribute('aria-activedescendant'); + }); + + it('limits the dropdown to eight suggestions', () => { + render( + ({ + ...members[0], + id: `${i}`, + firstName: `Name ${i}`, + }))} + />, + ); + change('First Name', 'Name'); + expect(screen.getAllByRole('option')).toHaveLength(8); + }); +}); + +describe('safe search values', () => { + it('treats punctuation and markup as literal text, never executable HTML', () => { + const name = '[Ann].*'; + render(); + change('First Name', '[ann].*'); + advance(); + expect(rowNames()).toHaveLength(1); + expect(within(table()).getByText('[Ann].*', { selector: 'mark' })).toBeInTheDocument(); + expect(within(table()).queryByRole('img', { hidden: true })).toBeNull(); + }); + + it('handles absent data and deduplicates names without changing source records', () => { + const data = [{ firstName: null }, { firstName: ' Cailin ' }, { firstName: 'cailin' }]; + expect(getMemberOptions(data, 'firstName')).toEqual(['Cailin']); + expect(filterMembers(data, { ...EMPTY_FILTERS, firstName: 'CAI' })).toHaveLength(2); + expect(data[1].firstName).toBe(' Cailin '); + }); + + it('highlights all literal matches and preserves original case', () => { + expect( + highlightParts('Anna ANNA', 'ann') + .filter(part => part.match) + .map(part => part.text), + ).toEqual(['Ann', 'ANN']); + expect(highlightParts(null, '')).toEqual([{ text: '', match: false, key: 0 }]); + }); +}); diff --git a/src/components/BMDashboard/Projects/ProjectDetails/memberSearch.js b/src/components/BMDashboard/Projects/ProjectDetails/memberSearch.js new file mode 100644 index 00000000000..b68daaa1f5e --- /dev/null +++ b/src/components/BMDashboard/Projects/ProjectDetails/memberSearch.js @@ -0,0 +1,53 @@ +export const MEMBER_COLUMNS = [ + { key: 'id', label: 'ID' }, + { key: 'firstName', label: 'First Name', suggest: true }, + { key: 'lastName', label: 'Last Name', suggest: true }, + { key: 'role', label: 'Role', suggest: true }, + { key: 'team', label: 'Team', suggest: true }, + { key: 'currentTask', label: 'Current Task' }, + { key: 'totalHrs', label: 'Total Hrs' }, + { key: 'todaysHrs', label: "Today's Hrs" }, +]; + +export const EMPTY_FILTERS = Object.fromEntries(MEMBER_COLUMNS.map(({ key }) => [key, ''])); +export const normalizeSearch = value => + String(value ?? '') + .trim() + .toLowerCase(); + +export function filterMembers(members, filters) { + const active = MEMBER_COLUMNS.map(({ key }) => [key, normalizeSearch(filters[key])]).filter( + ([, query]) => query, + ); + return members.filter(member => + active.every(([key, query]) => normalizeSearch(member[key]).includes(query)), + ); +} + +export function getMemberOptions(members, key) { + const unique = new Map(); + members.forEach(member => { + const text = String(member[key] ?? '').trim(); + const normalized = normalizeSearch(text); + if (normalized && !unique.has(normalized)) unique.set(normalized, text); + }); + return [...unique.values()].sort((a, b) => a.localeCompare(b)); +} + +// Literal string matching: punctuation must never become a regular expression or HTML. +export function highlightParts(value, query) { + const text = String(value ?? ''); + const search = normalizeSearch(query); + if (!search) return [{ text, match: false, key: 0 }]; + const parts = []; + let offset = 0; + let index = text.toLowerCase().indexOf(search, offset); + while (index !== -1) { + if (index > offset) parts.push({ text: text.slice(offset, index), match: false, key: offset }); + parts.push({ text: text.slice(index, index + search.length), match: true, key: index }); + offset = index + search.length; + index = text.toLowerCase().indexOf(search, offset); + } + if (offset < text.length) parts.push({ text: text.slice(offset), match: false, key: offset }); + return parts; +}