Skip to content
Merged
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
@@ -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 ? (
<mark className={styles.match} key={part.key}>
{part.text}
</mark>
) : (
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 (
<div className={styles.filter}>
<label htmlFor={id}>{column.label}</label>
<input
id={id}
type="text"
className={styles.searchInput}
value={value}
placeholder={`Search ${column.label.toLowerCase()}`}
autoComplete="off"
role={column.suggest ? 'combobox' : undefined}
aria-autocomplete={column.suggest ? 'list' : undefined}
aria-expanded={column.suggest ? expanded : undefined}
aria-controls={expanded ? `${id}-suggestions` : undefined}
aria-activedescendant={active ? `${id}-option-${activeIndex}` : undefined}
onChange={event => {
onChange(event.target.value);
setOpen(true);
setActiveIndex(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => {
setOpen(false);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
/>
{expanded && (
<ul
id={`${id}-suggestions`}
role="listbox"
aria-label={`${column.label} suggestions`}
className={styles.suggestions}
ref={listRef}
>
{matches.map((option, index) => (
<li
key={option}
id={`${id}-option-${index}`}
role="option"
aria-label={option}
aria-selected={index === activeIndex}
className={styles.option}
onMouseDown={event => event.preventDefault()}
onClick={() => select(option)}
onKeyDown={event => {
if (event.key === 'Enter') select(option);
}}
>
<HighlightMatch value={option} query={value} />
</li>
))}
</ul>
)}
</div>
);
}

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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function ProjectDetails() {
padding: '10px',
}}
>
<ProjectLog projectId={projectId} />
<ProjectLog projectId={projectId} darkMode={darkMode} />
</div>
</div>
</Col>
Expand Down
118 changes: 101 additions & 17 deletions src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -33,24 +37,102 @@ const DummyData = [
},
];

function ProjectLog() {
const tableRows = DummyData.map(person => (
<tr key={person.id}>
<th scope="row">{person.id}</th>
<td>{person.firstName}</td>
<td>{person.lastName}</td>
<td>{person.role}</td>
<td>{person.team}</td>
<td>{person.currentTask}</td>
<td>{person.totalHrs}</td>
<td>{person.todaysHrs}</td>
</tr>
));
// Keep the potentially large table body unchanged while the user types.
const MemberRows = memo(function MemberRows({ members, filters }) {
return (
<tbody>
{members.length ? (
members.map(person => (
<tr key={person.id}>
{MEMBER_COLUMNS.map(({ key }) => {
const content = <HighlightMatch value={person[key]} query={filters[key]} />;
return key === 'id' ? (
<th key={key} scope="row">
{content}
</th>
) : (
<td key={key}>{content}</td>
);
})}
</tr>
))
) : (
<tr>
<td colSpan={MEMBER_COLUMNS.length}>No members match these filters.</td>
</tr>
)}
</tbody>
);
});

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 (
<Card className={`${styles.projectLog}`}>
<Card className={`${styles.projectLog} ${darkMode ? styles.dark : ''}`}>
<h2>Members working on site today</h2>
<Table hover responsive striped>
<div className={styles.toolbar}>
<output>
Showing {filtered.length} of {members.length} members
</output>
<button
type="button"
className={styles.clearButton}
onClick={clearFilters}
disabled={!Object.values(filters).some(Boolean)}
>
Clear All Filters
</button>
</div>
<fieldset className={styles.filters}>
<legend className={styles.filtersLegend}>Filter members</legend>
{MEMBER_COLUMNS.map(column => (
<MemberSearchInput
key={column.key}
column={column}
value={filters[column.key]}
options={options[column.key]}
onChange={value => setFilters(previous => ({ ...previous, [column.key]: value }))}
onSelect={value => {
const next = { ...filters, [column.key]: value };
setFilters(next);
setAppliedFilters(next);
}}
/>
))}
</fieldset>
<Table hover responsive striped className={styles.membersTable}>
<caption className={styles.caption}>
Members working on site today, filtered by the fields above
</caption>
<thead>
<tr>
<th>ID</th>
Expand All @@ -63,10 +145,12 @@ function ProjectLog() {
<th>Today&apos;s Hrs</th>
</tr>
</thead>
<tbody>{tableRows}</tbody>
<MemberRows members={filtered} filters={appliedFilters} />
</Table>
</Card>
);
}

ProjectLog.propTypes = { members: PropTypes.arrayOf(PropTypes.object), darkMode: PropTypes.bool };

export default ProjectLog;
Loading
Loading