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
2 changes: 1 addition & 1 deletion components/elements/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Card } from '@components/layout';
import classNames from 'classnames';
import { useSearchState } from 'context/SearchProvider';
import { useSearchState } from 'context/ToolsProvider';
import { FC, useEffect, useState } from 'react';
import styles from './Dropdown.module.css';

Expand Down
37 changes: 10 additions & 27 deletions components/elements/TagList/TagList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { FC, useState } from 'react';
import Image from 'next/image';
import cn from 'classnames';
import styles from './TagList.module.css';
import { TagTypes, useSearchState } from 'context/SearchProvider';
import { objectToQueryString } from 'utils/query';
import { useRouterPush } from 'hooks';
import { useTools } from 'context/ToolsProvider';
import classNames from 'classnames';
import { tagIconPath } from 'utils/icons';

// Tag type for filtering
type TagTypes = 'languages' | 'others';

const NUM_TAGS = 8;

export interface TagListProps {
Expand All @@ -17,8 +18,7 @@ export interface TagListProps {
}

const TagList: FC<TagListProps> = ({ languageTags, otherTags, className }) => {
const routerPush = useRouterPush();
const { search, setSearch } = useSearchState();
const { toggleFilter, isSelected } = useTools();
const [showAllTags, setShowAllTags] = useState(false);

const toggleShowAllTags = () => {
Expand All @@ -31,28 +31,11 @@ const TagList: FC<TagListProps> = ({ languageTags, otherTags, className }) => {
) => {
const target = event?.target as HTMLElement;
const tag = target.innerText;
toggleFilter(tagType, tag);
};

let currValue = search[tagType] || [];
if (!Array.isArray(currValue)) {
currValue = [currValue];
}
if (currValue.length) {
const index = currValue.indexOf(tag);
if (index > -1) {
currValue.splice(index, 1);
} else {
currValue.push(tag);
}
} else {
currValue.push(tag);
}

const newState = { ...search, [tagType]: currValue };
setSearch(newState);
// Update query params in router
routerPush(`/tools?${objectToQueryString(newState)}`, undefined, {
shallow: true,
});
const isTagSelected = (tag: string, type: TagTypes): boolean => {
return isSelected(type, tag);
};

const combinedTags = [
Expand All @@ -75,7 +58,7 @@ const TagList: FC<TagListProps> = ({ languageTags, otherTags, className }) => {
{tagsToRender.map(({ tag, type }, index) => (
<li
className={classNames(styles.tag, {
[`${styles.highlight}`]: search[type]?.includes(tag),
[`${styles.highlight}`]: isTagSelected(tag, type),
})}
key={`${tag}-${index}`}>
<a onClick={(e) => handleClick(e, type)}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
.clearButton {
width: 140px;
}

.resultsCount {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
margin-right: auto;
}

.loadMoreContainer {
display: flex;
justify-content: center;
padding: 2rem 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import styles from './MobileFilter.module.css';
import Select, { MultiValue } from 'react-select';
import { Button, PanelHeader } from '@components/elements';
import classNames from 'classnames';
import { SearchState, SearchFilter } from 'context/SearchProvider';
import { SearchState, SearchFilter } from 'context/ToolsProvider';

interface Option {
value: string;
Expand Down
33 changes: 18 additions & 15 deletions components/tools/listPage/MobileFilters/MobileFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { FC, useState } from 'react';
import classNames from 'classnames';
import { Button, PanelHeader } from '@components/elements';
import styles from './MobileFilters.module.css';
import { useLanguagesQuery } from '@components/tools/queries';
import { useSearchState } from 'context/SearchProvider';
import { useTools, type SearchState } from 'context/ToolsProvider';
import MobileFilter from './MobileFilter/MobileFilter';

import {
Expand All @@ -13,20 +12,22 @@ import {
LICENSE_OPTIONS,
PRICING_OPTIONS,
} from '@appdata/filters';
import { useOthersQuery } from '@components/tools/queries/others';
import { LanguageFilterOption } from '../ToolsSidebar/FilterCard/LanguageFilterCard';
import { FilterOption } from '../ToolsSidebar/FilterCard/FilterCard';

const MobileFilters: FC = () => {
const { search, setSearch } = useSearchState();
export interface MobileFiltersProps {
languages?: LanguageFilterOption[];
others?: FilterOption[];
}

const [modelOpen, setModelOpen] = useState(false);
const [state, setState] = useState(search);

const { data: otherResult } = useOthersQuery();
const { data: languageResult } = useLanguagesQuery();
const MobileFilters: FC<MobileFiltersProps> = ({
languages = [],
others = [],
}) => {
const { search, setSearch } = useTools();

const others = otherResult?.data || [];
// Filter duplicates on tag
const languages = languageResult?.data || [];
const [modelOpen, setModelOpen] = useState(false);
const [state, setState] = useState<SearchState>(search);

const submit = () => {
setSearch(state);
Expand All @@ -39,6 +40,8 @@ const MobileFilters: FC = () => {
};

const openModal = () => {
// Sync state with current search when opening
setState(search);
// Add modal-open class to body
document.body.classList.add('modal-open');
setModelOpen(true);
Expand Down Expand Up @@ -87,7 +90,7 @@ const MobileFilters: FC = () => {
<MobileFilter
id="languages"
label="Languages"
options={languages || []}
options={languages}
placeholder="Choose Language"
state={state}
setState={setState}
Expand Down Expand Up @@ -132,7 +135,7 @@ const MobileFilters: FC = () => {
<MobileFilter
id="others"
label="Other Tags"
options={others || []}
options={others}
placeholder="Other Tags"
state={state}
setState={setState}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { FC, useState, useEffect } from 'react';
import { Button, Dropdown, PanelHeader } from '@components/elements';
import { Panel } from '@components/layout';
import { MobileFilters, ToolCard, ToolsSidebar } from '@components/tools';
import { useTools, type SortOption } from 'context/ToolsProvider';
import { type ArticlePreview } from 'utils/types';
import { FilterOption } from '../ToolsSidebar/FilterCard/FilterCard';
import { LanguageFilterOption } from '../ToolsSidebar/FilterCard/LanguageFilterCard';
import styles from '../ListPageComponent/ListPageComponent.module.css';

const DEFAULT_PAGE_SIZE = 50;

export interface StaticListComponentProps {
languages: LanguageFilterOption[];
others: FilterOption[];
articles: ArticlePreview[];
}

const StaticListComponent: FC<StaticListComponentProps> = ({
languages,
others,
articles,
}) => {
const heading = `Static Analysis Tools`;
const { tools, search, clearFilters, setSorting, totalCount } = useTools();

// Simple client-side pagination with "Load More"
const [displayCount, setDisplayCount] = useState(DEFAULT_PAGE_SIZE);

// Reset display count when filters change
const searchKey = JSON.stringify(search);
useEffect(() => {
setDisplayCount(DEFAULT_PAGE_SIZE);
}, [searchKey]);

const displayedTools = tools.slice(0, displayCount);
const hasMore = displayCount < totalCount;

const loadMore = () => {
setDisplayCount((prev) => prev + DEFAULT_PAGE_SIZE);
};

const changeSort = (e: React.ChangeEvent<HTMLSelectElement>) => {
const sorting = e.target.value as SortOption;
setSorting(sorting);
};

const resetSearch = () => {
clearFilters();
};

// Show clear filter button if there are any filters applied
const shouldShowClearFilterButton =
Object.keys(search).filter((key) => key !== 'sorting').length > 0;

return (
<>
<ToolsSidebar
languages={languages}
others={others}
articles={articles}
/>
<Panel>
<PanelHeader level={3} text={heading}>
<span className={styles.resultsCount}>
{totalCount} tools
</span>
{shouldShowClearFilterButton ? (
<Button
className={styles.clearButton}
onClick={resetSearch}
theme="primary">
Clear All Filters
</Button>
) : null}
<Dropdown
className="mobileHidden"
changeSort={changeSort}
/>
<MobileFilters languages={languages} others={others} />
</PanelHeader>
{displayedTools.map((tool, index) => (
<ToolCard key={`tool-${tool.id}-${index}`} tool={tool} />
))}
{hasMore && (
<div className={styles.loadMoreContainer}>
<Button onClick={loadMore} theme="secondary">
Load More ({totalCount - displayCount} remaining)
</Button>
</div>
)}
</Panel>
</>
);
};

export default StaticListComponent;
1 change: 1 addition & 0 deletions components/tools/listPage/StaticListPageComponent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as StaticListPageComponent } from './StaticListPageComponent';
61 changes: 36 additions & 25 deletions components/tools/listPage/ToolsSidebar/FilterCard/FilterCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { Card } from '@components/layout';
import { Heading } from '@components/typography';

import styles from './FilterCard.module.css';
import { SearchFilter, useSearchState } from 'context/SearchProvider';
import { isChecked, isSelectedFilter, sortByChecked } from './utils';
import { changeQuery } from 'utils/query';
import { useTools, type SearchFilter } from 'context/ToolsProvider';
import classNames from 'classnames';

export interface FilterOption {
Expand All @@ -25,8 +23,6 @@ export interface FilterCardProps {
className?: string;
}

// TODO: Add Toggle Deprecated (default off)
// TODO: Add click functionality and debounce
const FilterCard: FC<FilterCardProps> = ({
showAllCheckbox = true,
heading,
Expand All @@ -35,10 +31,11 @@ const FilterCard: FC<FilterCardProps> = ({
limit = 10,
className,
}) => {
const { search, setSearch } = useSearchState();
const { search, toggleFilter, updateFilter, isSelected } = useTools();

const shouldShowToggle = options.length > limit;
const [listLimit, setLimit] = useState(limit);

const toggleAll = () => {
if (listLimit === 999) {
setLimit(limit);
Expand All @@ -49,15 +46,33 @@ const FilterCard: FC<FilterCardProps> = ({

const resetFilter = () => {
const searchFilter = filter as SearchFilter;
if (search[searchFilter]) {
delete search[searchFilter];
}
setSearch({ ...search });
updateFilter(searchFilter, []);
};

const handleCheckboxChange = (value: string) => {
const searchFilter = filter as SearchFilter;
toggleFilter(searchFilter, value);
};

const isChecked = (value: string): boolean => {
const searchFilter = filter as SearchFilter;
return isSelected(searchFilter, value);
};

const isFilterActive = (): boolean => {
const searchFilter = filter as SearchFilter;
const values = search[searchFilter];
return values !== undefined && values.length > 0;
};

if (options.length > limit) {
options.sort(sortByChecked(filter, search));
}
// Sort options: checked items first
const sortedOptions = [...options].sort((a, b) => {
const aChecked = isChecked(a.value);
const bChecked = isChecked(b.value);
if (aChecked && !bChecked) return -1;
if (!aChecked && bChecked) return 1;
return 0;
});

return (
<Card className={classNames(className)}>
Expand All @@ -70,36 +85,32 @@ const FilterCard: FC<FilterCardProps> = ({
<li>
<Input
type="checkbox"
id="checkbox_all"
id={`checkbox_all_${filter}`}
data-filter={filter}
checked={!isSelectedFilter(filter, search)}
checked={!isFilterActive()}
onChange={resetFilter}
/>
<label
className={styles.checkboxLabel}
htmlFor="checkbox_all"
htmlFor={`checkbox_all_${filter}`}
onClick={resetFilter}>
All
</label>
</li>
)}
{options.slice(0, listLimit).map((option, index) => (
{sortedOptions.slice(0, listLimit).map((option, index) => (
<li key={index}>
<Input
type="checkbox"
id={`checkbox_${option.value}`}
id={`checkbox_${filter}_${option.value}`}
value={option.value}
data-filter={filter}
checked={isChecked(filter, option.value, search)}
onChange={changeQuery(
option.value,
search,
setSearch,
)}
checked={isChecked(option.value)}
onChange={() => handleCheckboxChange(option.value)}
/>
<label
className={styles.checkboxLabel}
htmlFor={`checkbox_${option.value}`}>
htmlFor={`checkbox_${filter}_${option.value}`}>
{option.name}
</label>

Expand Down
Loading