diff --git a/lib/config.ts b/lib/config.ts index 90da3b08b..24426d5fb 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,3 +1,3 @@ export const HOST = 'https://www.json-schema.org'; -export const DRAFT_ORDER = ['2020-12', '2019-09', 7, 6, 5, 4, 3, 2, 1]; +export const DRAFT_ORDER = ['2020-12', '2019-09', 7, 6, 5, 4, 3, 2, 1] as const; diff --git a/package.json b/package.json index e3c1be243..a5b0ea891 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,14 @@ }, "dependencies": { "@docsearch/react": "3.5.2", + "@types/jsonpath": "^0.2.4", "axios": "1.6.0", "classnames": "^2.3.1", "feed": "^4.2.2", + "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "js-yaml": "^4.1.0", + "jsonpath": "^1.1.1", "markdown-to-jsx": "^7.1.6", "moment": "2.29.4", "next": "14.1.1", diff --git a/pages/tools/JSONSchemaTool.ts b/pages/tools/JSONSchemaTool.ts new file mode 100644 index 000000000..faeef29ef --- /dev/null +++ b/pages/tools/JSONSchemaTool.ts @@ -0,0 +1,44 @@ +export interface JSONSchemaTool { + name: string; + description?: string; + toolingTypes: string[]; + languages?: string[]; + environments?: string[]; + dependsOnValidators?: string[]; + creators?: Person[]; + maintainers?: Person[]; + license?: string; + source: string; + homepage?: string; + documentation?: object; + supportedDialects?: { + draft?: (number | string)[]; + additional?: { + name: string; + homepage?: string; + source: string; + }[]; + }; + bowtie?: { + identifier: string; + }; + toolingListingNotes?: string; + compliance?: { + config?: { + docs?: string; + instructions?: string; + }; + }; + landscape?: { + logo?: string; + optOut?: boolean; + }; + lastUpdated?: string; +} + +export interface Person { + name?: string; + email?: string; + username: string; + platform: 'github' | 'gitlab' | 'bitbucket' | string; +} diff --git a/pages/tools/components/GroupBySelector.tsx b/pages/tools/components/GroupBySelector.tsx new file mode 100644 index 000000000..132e7d2e2 --- /dev/null +++ b/pages/tools/components/GroupBySelector.tsx @@ -0,0 +1,44 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import { Preferences } from '../hooks/usePreferences'; + +export default function GroupBySelector({ + preferences, + setPreferences, +}: { + preferences: Preferences; + setPreferences: Dispatch>; +}) { + const groupBy = preferences.groupBy; + const setGroupPreference = (event: React.MouseEvent) => { + setPreferences((prev) => ({ + ...prev, + groupBy: (event.target as HTMLButtonElement).value as typeof groupBy, + sortBy: 'name', + sortOrder: 'ascending', + })); + }; + + const groups: Record = { + None: 'none', + 'Tooling Types': 'toolingTypes', + Languages: 'languages', + }; + + return ( +
+ GROUP BY: + {Object.keys(groups).map((group) => { + return ( + + ); + })} +
+ ); +} diff --git a/pages/tools/components/SearchBar.tsx b/pages/tools/components/SearchBar.tsx new file mode 100644 index 000000000..622e9e277 --- /dev/null +++ b/pages/tools/components/SearchBar.tsx @@ -0,0 +1,31 @@ +import React, { useEffect, useState } from 'react'; +import type { Preferences } from '../hooks/usePreferences'; + +const SearchBar = ({ preferences }: { preferences: Preferences }) => { + const [query, setQuery] = useState(preferences.query); + + const changeHandler = (e: React.ChangeEvent) => { + setQuery(e.target.value); + }; + + useEffect(() => { + setQuery(preferences.query); + }, [preferences.query]); + + return ( +
+
+ +
+
+ ); +}; + +export default SearchBar; diff --git a/pages/tools/components/Sidebar.tsx b/pages/tools/components/Sidebar.tsx new file mode 100644 index 000000000..5e531baf9 --- /dev/null +++ b/pages/tools/components/Sidebar.tsx @@ -0,0 +1,147 @@ +import React, { + Dispatch, + SetStateAction, + useEffect, + useRef, + useState, +} from 'react'; +import DropdownMenu from './ui/DropdownMenu'; +import SearchBar from './SearchBar'; +import Checkbox from './ui/Checkbox'; +import { type UniqueValuesPerField } from '../lib/getUniqueValuesPerField'; +import toTitleCase from '../lib/toTitleCase'; +import { useTheme } from 'next-themes'; +import { Preferences } from '../hooks/usePreferences'; + +export default function Sidebar({ + uniqueValuesPerField, + preferences, + setPreferences, + resetPreferences, + setIsSidebarOpen, +}: { + uniqueValuesPerField: UniqueValuesPerField; + preferences: Preferences; + setPreferences: Dispatch>; + resetPreferences: () => void; + setIsSidebarOpen: Dispatch>; +}) { + const filterFormRef = useRef(null); + const [filterIcon, setFilterIcon] = useState(''); + const { theme } = useTheme(); + + useEffect(() => { + if (theme === 'dark') { + setFilterIcon('/icons/filter-dark.svg'); + } else { + setFilterIcon('/icons/filter.svg'); + } + }, [theme]); + + const submitHandler = (e: React.FormEvent) => { + e.preventDefault(); + if (!filterFormRef.current) return; + const formData = new FormData(filterFormRef.current); + + setPreferences((prev) => { + const updatedPreferences: Preferences = { + query: (formData.get('query') as Preferences['query']) || '', + groupBy: prev.groupBy || 'toolingTypes', + sortBy: prev.sortBy || 'name', + sortOrder: prev.sortOrder || 'ascending', + 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 Preferences['drafts'], + toolingTypes: formData + .getAll('toolingTypes') + .map((value) => value as string), + }; + return updatedPreferences; + }); + setIsSidebarOpen((prev) => (prev ? false : prev)); + }; + + const resetHandler = () => { + if (!filterFormRef.current) return; + filterFormRef.current.reset(); + resetPreferences(); + setIsSidebarOpen((prev) => (prev ? false : prev)); + }; + + return ( +
+
+ + + {uniqueValuesPerField.languages?.map((uniqueValue) => ( + + ))} + + + {uniqueValuesPerField.drafts?.map((uniqueValue) => ( + + ))} + + + {uniqueValuesPerField.toolingTypes?.map((uniqueValue) => ( + + ))} + + + {uniqueValuesPerField.licenses?.map((uniqueValue) => ( + + ))} + +
+ + +
+ +
+ ); +} diff --git a/pages/tools/components/ToolingTable/TableCell.tsx b/pages/tools/components/ToolingTable/TableCell.tsx new file mode 100644 index 000000000..07ba9f509 --- /dev/null +++ b/pages/tools/components/ToolingTable/TableCell.tsx @@ -0,0 +1,19 @@ +import React, { ReactNode } from 'react'; + +const TableCell = ({ + className, + children, +}: { + className: string; + children: ReactNode | ReactNode[]; +}) => { + return ( + + {children} + + ); +}; + +export default TableCell; diff --git a/pages/tools/components/ToolingTable/TableColumnHeader.tsx b/pages/tools/components/ToolingTable/TableColumnHeader.tsx new file mode 100644 index 000000000..e92cb4935 --- /dev/null +++ b/pages/tools/components/ToolingTable/TableColumnHeader.tsx @@ -0,0 +1,17 @@ +import React, { ReactNode } from 'react'; + +const TableColumnHeader = ({ + className, + children, +}: { + className: string; + children: ReactNode | ReactNode[]; +}) => { + return ( + + {children} + + ); +}; + +export default TableColumnHeader; diff --git a/pages/tools/components/ToolingTable/TableSortableColumnHeader.tsx b/pages/tools/components/ToolingTable/TableSortableColumnHeader.tsx new file mode 100644 index 000000000..08ff9c56a --- /dev/null +++ b/pages/tools/components/ToolingTable/TableSortableColumnHeader.tsx @@ -0,0 +1,79 @@ +import React, { + Dispatch, + ReactNode, + SetStateAction, + useEffect, + useState, +} from 'react'; +import TableColumnHeader from './TableColumnHeader'; +import { Preferences } from '../../hooks/usePreferences'; + +const TableSortableColumnHeader = ({ + className, + sortBy, + preferences, + setPreferences, + children, +}: { + className: string; + sortBy: Preferences['sortBy']; + preferences: Preferences; + setPreferences: Dispatch>; + children: ReactNode; +}) => { + const [isSortedBy, setIsSortedBy] = useState(preferences.sortBy === sortBy); + + useEffect(() => { + setIsSortedBy(preferences.sortBy === sortBy); + }, [preferences.sortBy]); + + const sortByColumn = (e: React.MouseEvent) => { + e.preventDefault(); + setPreferences((prevPreferences) => { + const newSortOrder = + prevPreferences.sortBy === sortBy + ? prevPreferences.sortOrder === 'descending' + ? 'ascending' + : 'descending' + : 'ascending'; + return { + ...prevPreferences, + sortBy, + sortOrder: newSortOrder, + }; + }); + + setIsSortedBy(true); + }; + + const rotateClass = preferences.sortOrder === 'ascending' ? 'rotate-180' : ''; + + return ( + + + + ); +}; + +export default TableSortableColumnHeader; diff --git a/pages/tools/components/ToolingTable/ToolingDetailModal.tsx b/pages/tools/components/ToolingTable/ToolingDetailModal.tsx new file mode 100644 index 000000000..9d8f54992 --- /dev/null +++ b/pages/tools/components/ToolingTable/ToolingDetailModal.tsx @@ -0,0 +1,283 @@ +import React, { useEffect } from 'react'; + +import Badge from '../ui/Badge'; +import type { JSONSchemaTool } from '../../JSONSchemaTool'; +import toTitleCase from '../../lib/toTitleCase'; + +export default function ToolingDetailModal({ + tool, + onClose, +}: { + tool: JSONSchemaTool; + onClose: () => void; +}) { + useEffect(() => { + document.body.classList.add('no-scroll'); + return () => { + document.body.classList.remove('no-scroll'); + }; + }, []); + + return ( +
+
+
+
+ +
+
+

{tool.name}

+ {tool.description && ( +

+ {tool.description} +

+ )} +
+
+
+ {tool.source && ( +
+

Source

+ + {tool.source} + +
+ )} + {tool.homepage && ( +
+

Homepage

+ + {tool.homepage} + +
+ )} + {tool.license && ( +
+

License

+

{tool.license}

+
+ )} + {tool.compliance && ( +
+

Compliance

+ {tool.compliance.config && ( +
+ {tool.compliance.config.docs && ( + + )} + {tool.compliance.config.instructions && ( +
+

Instructions:

+

{tool.compliance.config.instructions}

+
+ )} +
+ )} +
+ )} + {tool.toolingListingNotes && ( +
+

Tooling Listing Notes

+

{tool.toolingListingNotes}

+
+ )} + {tool.creators && ( +
+

Creators

+
    + {tool.creators.map((creator, index) => ( +
  • + {creator.name ? creator.name : 'N.A.'} + + ( + {creator.username && creator.platform && ( + + @{creator.username} + + )} + ) + +
  • + ))} +
+
+ )} + {tool.maintainers && ( +
+

Maintainers

+
    + {tool.maintainers.map((maintainer, index) => ( +
  • + {maintainer.name ? maintainer.name : 'N.A.'} + + ( + {maintainer.username && maintainer.platform && ( + + @{maintainer.username} + + )} + ) + +
  • + ))} +
+
+ )} +
+
+ {tool.supportedDialects && ( +
+

Supported Dialects

+ {tool.supportedDialects.draft && ( +
+

Draft:

+
    + {tool.supportedDialects.draft.map((draft) => ( + {draft} + ))} +
+
+ )} + {tool.supportedDialects.additional && ( +
+

Additional:

+
    + {tool.supportedDialects.additional.map( + (additional, index) => ( +
  • + {additional.name} ( + + Source + + ) +
  • + ), + )} +
+
+ )} +
+ )} + {tool.toolingTypes && ( +
+

Tooling Types

+
    + {tool.toolingTypes.map((type, index) => ( +
  • {toTitleCase(type, '-')}
  • + ))} +
+
+ )} + {tool.languages && ( +
+

Languages

+
    + {tool.languages.map((language, index) => ( +
  • {language}
  • + ))} +
+
+ )} + {tool.environments && ( +
+

Environments

+
    + {tool.environments.map((environment, index) => ( +
  • {environment}
  • + ))} +
+
+ )} + {tool.bowtie && ( +
+

Bowtie Identifier

+

{tool.bowtie.identifier}

+
+ )} + {tool.dependsOnValidators && ( +
+

Depends On Validators

+
    + {tool.dependsOnValidators.map((validator, index) => ( +
  • + + {validator} + +
  • + ))} +
+
+ )} + {tool.landscape && ( +
+

Landscape

+ {tool.landscape.logo && ( +
+

Logo:

+

{tool.landscape.logo}

+
+ )} + {tool.landscape.optOut !== undefined && ( +
+

Opt-Out:

+

{tool.landscape.optOut ? 'Yes' : 'No'}

+
+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/pages/tools/components/ToolingTable/ToolingTable.tsx b/pages/tools/components/ToolingTable/ToolingTable.tsx new file mode 100644 index 000000000..9479cc026 --- /dev/null +++ b/pages/tools/components/ToolingTable/ToolingTable.tsx @@ -0,0 +1,201 @@ +import React, { Dispatch, SetStateAction, useState } from 'react'; + +import { Headline2 } from '~/components/Headlines'; + +import type { GroupedTools, Preferences } from '../../hooks/usePreferences'; +import type { JSONSchemaTool } from '../../JSONSchemaTool'; +import toTitleCase from '../../lib/toTitleCase'; +import Badge from '../ui/Badge'; +import TableColumnHeader from './TableColumnHeader'; +import TableSortableColumnHeader from './TableSortableColumnHeader'; +import TableCell from './TableCell'; +import ToolingDetailModal from './ToolingDetailModal'; + +const ToolingTable = ({ + groupedTools, + preferences, + setPreferences, +}: { + groupedTools: GroupedTools; + preferences: Preferences; + setPreferences: Dispatch>; +}) => { + const [selectedTool, setSelectedTool] = useState(null); + + const groups = Object.keys(groupedTools); + + const openModal = (tool: JSONSchemaTool) => { + setSelectedTool(tool); + }; + + const closeModal = () => { + setSelectedTool(null); + }; + + const outlinkIcon = ( + + + + ); + + const notAvailableIcon = ( + + + + ); + + const columnWidths = { + allPresent: { + name: 'w-[25%]', + toolingType: 'w-[20%]', + languages: 'w-[20%]', + drafts: 'w-[15%]', + license: 'w-[10%]', + bowtie: 'w-[10%]', + }, + oneAbsent: { + name: 'w-[30%]', + toolingType: 'w-[25%]', + languages: 'w-[25%]', + drafts: 'w-[20%]', + license: 'w-[15%]', + bowtie: 'w-[10%]', + }, + }; + + const currentWidths = + preferences.groupBy === 'toolingTypes' + ? columnWidths.oneAbsent + : preferences.groupBy === 'languages' + ? columnWidths.oneAbsent + : columnWidths.allPresent; + + return ( + <> + {groups.map((group) => ( +
+ {group !== 'none' && ( +
+ + {toTitleCase(group, '-')} + +
+ )} +
+ + + + + Name + + {preferences.groupBy !== 'toolingTypes' && ( + + Tooling Type + + )} + {preferences.groupBy !== 'languages' && ( + + Languages + + )} + + Drafts + + + License + + + Bowtie + + + + + {groupedTools[group].map((tool: JSONSchemaTool, index) => ( + openModal(tool)} + > + + {tool.name} + + {preferences.groupBy !== 'toolingTypes' && ( + + {tool.toolingTypes + ?.map((type) => toTitleCase(type, '-')) + .join(', ')} + + )} + {preferences.groupBy !== 'languages' && ( + + {tool.languages?.join(', ')} + + )} + + {tool.supportedDialects?.draft?.map((draft) => { + return {draft}; + })} + + + {tool.license} + + + {tool.bowtie?.identifier ? ( + event.stopPropagation()} + > + {outlinkIcon} + + ) : ( + {notAvailableIcon} + )} + + + ))} + +
+
+
+ ))} + {selectedTool && ( + + )} + + ); +}; + +export default ToolingTable; diff --git a/pages/tools/components/ui/Badge.tsx b/pages/tools/components/ui/Badge.tsx new file mode 100644 index 000000000..2085773c3 --- /dev/null +++ b/pages/tools/components/ui/Badge.tsx @@ -0,0 +1,11 @@ +import React, { ReactNode } from 'react'; + +const Badge = ({ children }: { children: ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +export default Badge; diff --git a/pages/tools/components/ui/Checkbox.tsx b/pages/tools/components/ui/Checkbox.tsx new file mode 100644 index 000000000..57973402b --- /dev/null +++ b/pages/tools/components/ui/Checkbox.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function Checkbox({ + label, + value, + name, +}: { + label: string; + value: string; + name: string; +}) { + return ( + + ); +} diff --git a/pages/tools/components/ui/DropdownMenu.tsx b/pages/tools/components/ui/DropdownMenu.tsx new file mode 100644 index 000000000..5e4a3dd35 --- /dev/null +++ b/pages/tools/components/ui/DropdownMenu.tsx @@ -0,0 +1,67 @@ +import classnames from 'classnames'; +import { useRouter } from 'next/router'; +import React, { ReactNode, useEffect, useState } from 'react'; + +export default function DropdownMenu({ + children, + label, + iconSrc, + iconAlt, +}: { + children: ReactNode; + label: string; + iconSrc: string; + iconAlt: string; +}) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const router = useRouter(); + + useEffect(() => { + setIsDropdownOpen(false); + }, [router]); + + return ( +
+
{ + setIsDropdownOpen((prev) => !prev); + }} + > + {iconAlt} +
+ {label} +
+ + + +
+ +
+ {children} +
+
+ ); +} diff --git a/pages/tools/components/ui/Radio.tsx b/pages/tools/components/ui/Radio.tsx new file mode 100644 index 000000000..911500ff7 --- /dev/null +++ b/pages/tools/components/ui/Radio.tsx @@ -0,0 +1,32 @@ +import React, { ChangeEventHandler } from 'react'; + +export default function Radio({ + label, + value, + selectedValue, + onChange, +}: { + label: string; + value: string; + selectedValue: string; + onChange: ChangeEventHandler; +}) { + return ( + + ); +} diff --git a/pages/tools/hooks/usePreferences.tsx b/pages/tools/hooks/usePreferences.tsx new file mode 100644 index 000000000..18db78e0c --- /dev/null +++ b/pages/tools/hooks/usePreferences.tsx @@ -0,0 +1,241 @@ +import { useMemo, useState, useEffect, useCallback } from 'react'; +import { NextRouter, useRouter } from 'next/router'; +import Fuse from 'fuse.js'; + +import { DRAFT_ORDER } from '~/lib/config'; +import getQueryParamValues from '../lib/getQueryParamValues'; +import { type JSONSchemaTool } from '../JSONSchemaTool'; + +export interface Preferences { + query: string; + groupBy: 'none' | 'toolingTypes' | 'languages'; + sortBy: 'name' | 'license'; + sortOrder: 'ascending' | 'descending'; + licenses: string[]; + languages: string[]; + drafts: `${(typeof DRAFT_ORDER)[number]}`[]; + toolingTypes: string[]; +} + +export interface GroupedTools { + [group: string]: JSONSchemaTool[]; +} + +const getInitialPreferences = (searchParams: URLSearchParams): Preferences => ({ + query: (searchParams.get('query') as Preferences['query']) || '', + groupBy: + (searchParams.get('groupBy') as Preferences['groupBy']) || 'toolingTypes', + sortBy: (searchParams.get('sortBy') as Preferences['sortBy']) || 'name', + sortOrder: + (searchParams.get('sortOrder') as Preferences['sortOrder']) || 'ascending', + languages: getQueryParamValues(searchParams.getAll('languages')), + licenses: getQueryParamValues(searchParams.getAll('license')), + drafts: getQueryParamValues( + searchParams.getAll('drafts'), + ) as Preferences['drafts'], + toolingTypes: getQueryParamValues(searchParams.getAll('toolingTypes')), +}); + +const updateURLParams = (preferences: Preferences, router: NextRouter) => { + const params = new URLSearchParams(); + Object.entries(preferences).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((val) => params.append(key, val)); + } else { + params.set(key, value); + } + }); + router.replace({ query: params.toString() }, undefined, { shallow: true }); +}; + +const filterTools = ( + tools: JSONSchemaTool[], + preferences: Preferences, +): JSONSchemaTool[] => { + const lowerCaseArray = (arr: string[]) => + arr.map((item) => item.toLowerCase()); + const lowerCasePreferences = { + languages: lowerCaseArray(preferences.languages), + licenses: lowerCaseArray(preferences.licenses), + toolingTypes: lowerCaseArray(preferences.toolingTypes), + drafts: preferences.drafts.map(String), + }; + + return tools.filter((tool) => { + const matchesLanguage = + lowerCasePreferences.languages.length === 0 || + (tool.languages || []).some((lang) => + lowerCasePreferences.languages.includes(lang.toLowerCase()), + ); + + const matchesLicense = + lowerCasePreferences.licenses.length === 0 || + (tool.license && + lowerCasePreferences.licenses.includes(tool.license.toLowerCase())); + + const matchesToolingType = + lowerCasePreferences.toolingTypes.length === 0 || + (tool.toolingTypes || []).some((type) => + lowerCasePreferences.toolingTypes.includes(type.toLowerCase()), + ); + + const matchesDraft = + lowerCasePreferences.drafts.length === 0 || + (tool.supportedDialects?.draft || []).some((draft) => + lowerCasePreferences.drafts.includes(String(draft)), + ); + + return ( + matchesLanguage && matchesLicense && matchesToolingType && matchesDraft + ); + }); +}; + +const sortTools = ( + tools: JSONSchemaTool[], + preferences: Preferences, +): JSONSchemaTool[] => { + const compare = (a: JSONSchemaTool, b: JSONSchemaTool) => { + const aValue = + preferences.sortBy === 'name' + ? a.name.toLowerCase() + : (a.license || '').toLowerCase(); + const bValue = + preferences.sortBy === 'name' + ? b.name.toLowerCase() + : (b.license || '').toLowerCase(); + return preferences.sortOrder === 'ascending' + ? aValue.localeCompare(bValue) + : bValue.localeCompare(aValue); + }; + return [...tools].sort(compare); +}; + +const toolingTypesOrder = [ + 'validator', + 'hyper-schema', + 'benchmarks', + 'documentation', + 'LDO-utility', + 'code-to-schema', + 'data-to-schema', + 'model-to-schema', + 'schema-to-types', + 'schema-to-code', + 'schema-to-web-UI', + 'schema-to-data', + 'util-general-processing', + 'util-schema-to-schema', + 'util-draft-migration', + 'util-format-conversion', + 'util-testing', + 'editor', + 'editor-plugins', + 'schema-repository', + 'linter', + 'linter-plugins', +]; + +const groupTools = ( + tools: JSONSchemaTool[], + groupBy: Preferences['groupBy'], +): [GroupedTools, numberOfTools: number] => { + const groupedTools: GroupedTools = {}; + let numberOfTools = 0; + + if (groupBy === 'languages' || groupBy === 'toolingTypes') { + tools.forEach((tool) => { + const groups = tool[groupBy] || []; + if (groups.length > 0) { + groups.forEach((group) => { + if (!groupedTools[group]) groupedTools[group] = []; + groupedTools[group].push(tool); + }); + numberOfTools++; + } + }); + } else { + groupedTools['none'] = tools; + return [groupedTools, tools.length]; + } + + const sortedGroupedTools = Object.keys(groupedTools) + .sort((a, b) => { + if (groupBy === 'toolingTypes') { + const indexA = toolingTypesOrder.indexOf(a); + const indexB = toolingTypesOrder.indexOf(b); + if (indexA === -1 || indexB === -1) { + return a.toLowerCase().localeCompare(b.toLowerCase()); + } + return indexA - indexB; + } + return a.toLowerCase().localeCompare(b.toLowerCase()); + }) + .reduce((acc, key) => { + acc[key] = groupedTools[key]; + return acc; + }, {} as GroupedTools); + + return [sortedGroupedTools, numberOfTools]; +}; + +export default function usePreferences(tools: JSONSchemaTool[]) { + const router = useRouter(); + const { asPath } = router; + const searchParams = new URLSearchParams(asPath.split('?')[1]); + const initialPreferences = getInitialPreferences(searchParams); + + const [preferences, setPreferences] = + useState(initialPreferences); + + useEffect(() => { + updateURLParams(preferences, router); + }, [preferences]); + + const resetPreferences = useCallback(() => { + setPreferences((prev) => ({ + query: '', + groupBy: prev.groupBy, + sortBy: 'name', + sortOrder: 'ascending', + languages: [], + licenses: [], + drafts: [], + toolingTypes: [], + })); + window.scrollTo(0, 0); + }, []); + + const fuse = useMemo( + () => + new Fuse(tools, { keys: ['name'], includeScore: true, threshold: 0.3 }), + [tools], + ); + const hits = useMemo( + () => + preferences.query.trim() === '' + ? tools + : fuse.search(preferences.query).map((result) => result.item), + [fuse, preferences.query, tools], + ); + const filteredHits = useMemo( + () => filterTools(hits, preferences), + [hits, preferences], + ); + const sortedHits = useMemo( + () => sortTools(filteredHits, preferences), + [filteredHits, preferences.sortBy, preferences.sortOrder], + ); + const [groupedTools, numberOfTools] = useMemo( + () => groupTools(sortedHits, preferences.groupBy), + [sortedHits, preferences.groupBy], + ); + + return { + preferredTools: groupedTools, + numberOfTools, + preferences, + setPreferences, + resetPreferences, + }; +} diff --git a/pages/tools/index.page.tsx b/pages/tools/index.page.tsx new file mode 100644 index 000000000..81dd12cfe --- /dev/null +++ b/pages/tools/index.page.tsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react'; +import fs from 'fs'; +import Head from 'next/head'; +import yaml from 'js-yaml'; + +import { SectionContext } from '~/context'; +import { getLayout } from '~/components/SiteLayout'; +import { DRAFT_ORDER } from '~/lib/config'; +import { Headline1 } from '~/components/Headlines'; + +import Sidebar from './components/Sidebar'; +import ToolingTable from './components/ToolingTable/ToolingTable'; +import GroupBySelector from './components/GroupBySelector'; +import usePreferences from './hooks/usePreferences'; + +import { type JSONSchemaTool } from './JSONSchemaTool'; +import getUniqueValuesPerField, { + UniqueValuesPerField, +} from './lib/getUniqueValuesPerField'; +import Link from 'next/link'; + +export async function getStaticProps() { + const toolingData = yaml.load( + fs.readFileSync('data/tooling-data.yaml', 'utf-8'), + ) as JSONSchemaTool[]; + + const uniqueValuesPerField = { + languages: getUniqueValuesPerField(toolingData, '$..languages[*]'), + drafts: getUniqueValuesPerField( + toolingData, + '$..supportedDialects.draft[*]', + [1, 2, 3], + ), + toolingTypes: getUniqueValuesPerField(toolingData, '$..toolingTypes[*]'), + licenses: getUniqueValuesPerField(toolingData, '$..license'), + }; + + uniqueValuesPerField.drafts?.sort((a, b) => { + const aIndex = DRAFT_ORDER.map(String).indexOf(a); + const bIndex = DRAFT_ORDER.map(String).indexOf(b); + + if (aIndex === -1 && bIndex === -1) { + return 0; + } else if (aIndex === -1) { + return 1; + } else if (bIndex === -1) { + return -1; + } + + return aIndex - bIndex; + }); + + return { + props: { + toolingData, + uniqueValuesPerField, + }, + }; +} + +export default function ToolingPage({ + toolingData, + uniqueValuesPerField, +}: { + toolingData: JSONSchemaTool[]; + uniqueValuesPerField: UniqueValuesPerField; +}) { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + const { + preferredTools, + numberOfTools, + preferences, + setPreferences, + resetPreferences, + } = usePreferences(toolingData); + + return ( + + + JSON Schema - Tools + +
+
{ + setIsSidebarOpen((prev) => !prev); + }} + > +

{numberOfTools} Tools

+ + + + +
+ +
+
+
+

+ {numberOfTools} +

+
+ Tools +
+
+ +
+ +
+ JSON Schema Tooling +

+ Toolings below are written in different languages, and support + part, or all, of at least one recent version of the specification. +

+

+ Listing does not signify a recommendation or endorsement of any + kind. +

+
+
+ + + +

+ Raise an issue and we'll add your tool to the data we use to + build this website +

+
+ +
+ + + +

+ Bowtie is a meta-validator for JSON Schema, coordinating and + reporting results from other validators. +

+
+
+ + +
+
+
+
+ ); +} + +ToolingPage.getLayout = getLayout; diff --git a/pages/tools/lib/getQueryParamValues.ts b/pages/tools/lib/getQueryParamValues.ts new file mode 100644 index 000000000..1d020ade5 --- /dev/null +++ b/pages/tools/lib/getQueryParamValues.ts @@ -0,0 +1,11 @@ +export default function getQueryParamValues( + param: string | string[] | undefined, +): string[] { + if (!param) return []; + + if (typeof param === 'string') { + return [decodeURIComponent(param)]; + } else { + return param.map((p) => decodeURIComponent(p)); + } +} diff --git a/pages/tools/lib/getUniqueValuesPerField.ts b/pages/tools/lib/getUniqueValuesPerField.ts new file mode 100644 index 000000000..1114ebbc8 --- /dev/null +++ b/pages/tools/lib/getUniqueValuesPerField.ts @@ -0,0 +1,17 @@ +import jsonpath from 'jsonpath'; +import { type JSONSchemaTool } from '../JSONSchemaTool'; + +export type Fields = 'languages' | 'drafts' | 'toolingTypes' | 'licenses'; + +export type UniqueValuesPerField = Partial>; + +const getUniqueValuesPerField = ( + data: JSONSchemaTool[], + path: string, + exclude: Array = [], +) => { + const values = Array.from(new Set(jsonpath.query(data, path))); + return values.filter((value) => !exclude.includes(value)); +}; + +export default getUniqueValuesPerField; diff --git a/pages/tools/lib/toTitleCase.ts b/pages/tools/lib/toTitleCase.ts new file mode 100644 index 000000000..364d10fc1 --- /dev/null +++ b/pages/tools/lib/toTitleCase.ts @@ -0,0 +1,12 @@ +export default function toTitleCase( + text: string, + delimiter: string = ' ', + separator: string = ' ', +) { + return text + .split(delimiter) + .map(function (word: string) { + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(separator); +} diff --git a/public/icons/filter-dark.svg b/public/icons/filter-dark.svg new file mode 100644 index 000000000..bc4023f8a --- /dev/null +++ b/public/icons/filter-dark.svg @@ -0,0 +1 @@ + diff --git a/public/icons/filter.svg b/public/icons/filter.svg index 1d92e8eb2..dedf164f7 100644 --- a/public/icons/filter.svg +++ b/public/icons/filter.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/public/img/tools/adding_your_tool.png b/public/img/tools/adding_your_tool.png new file mode 100644 index 000000000..fd1bd6857 Binary files /dev/null and b/public/img/tools/adding_your_tool.png differ diff --git a/public/img/tools/try_bowtie.png b/public/img/tools/try_bowtie.png new file mode 100644 index 000000000..371cb88a5 Binary files /dev/null and b/public/img/tools/try_bowtie.png differ diff --git a/styles/globals.css b/styles/globals.css index 6dbf621c1..73e87442b 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -229,3 +229,6 @@ border-radius: 4px; */ } } +.no-scroll { + overflow: hidden; +}