diff --git a/src/cryptometrics/components/button/Button.js b/src/cryptometrics/components/button/Button.js index c2208b1175c5ac12f5937fb8de6c2f57595e8314..3e625e13c4d67a8db13f309c9eb7267d1679a8c9 100644 --- a/src/cryptometrics/components/button/Button.js +++ b/src/cryptometrics/components/button/Button.js @@ -13,11 +13,12 @@ import classNames from "classnames"; * </Button> * ) */ -function Button({ children, className, onClick }) { +function Button({ children, className, onClick, type }) { return ( <button className={classNames("py-2 px-5 rounded-xl", className)} onClick={onClick} + type={type ? type : "button"} > {children} </button> diff --git a/src/cryptometrics/components/dropdown/FilterDropdown.js b/src/cryptometrics/components/dropdown/FilterDropdown.js index 3781d9fd7700a54d32b75feb36059fc301beb4b1..8ea571776a30cd38609a191aca62367ad0db10a5 100644 --- a/src/cryptometrics/components/dropdown/FilterDropdown.js +++ b/src/cryptometrics/components/dropdown/FilterDropdown.js @@ -5,19 +5,19 @@ import Button from "../button/Button"; import { RadioInputForm } from "../radio/RadioForm"; import { CSSTransition } from "react-transition-group"; -export function FilterDropdown({ filterOptions, setOpen, addFilter }) { +export function FilterDropdown({ + filterOptions, + setOpen, + addFilter, + dropdownRef, +}) { const [selectedFilter, setSelectedFilter] = useState(null); - const [radioValue, setRadioValue] = useState("is"); const [inputValue, setInputValue] = useState(""); useEffect(() => { if (selectedFilter) { - setRadioValue( - filterOptions[selectedFilter].options[ - Object.keys(filterOptions[selectedFilter].options)[0] - ].name - ); + setRadioValue(Object.keys(filterOptions[selectedFilter].options)[0]); } }, [filterOptions, selectedFilter]); @@ -35,7 +35,7 @@ export function FilterDropdown({ filterOptions, setOpen, addFilter }) { const onFilterAdd = () => { addFilter({ - subject: filterOptions[selectedFilter].name, + subject: selectedFilter, condition: radioValue, value: inputValue, }); @@ -43,24 +43,28 @@ export function FilterDropdown({ filterOptions, setOpen, addFilter }) { }; return ( - <div className="absolute flex flex-row z-50"> + <div className="absolute flex flex-row z-50" ref={dropdownRef}> <div className={classNames( "dark:bg-dark-600 w-56 h-max max-h-72 rounded-xl mt-2 transition-all duration-100 p-1 overflow-y-scroll shadow-lg shadow-dark-600" )} > - {Object.keys(filterOptions).map((key) => { - return ( - <FilterDropdownItem - key={"primary_option_" + key} - selected={selectedFilter === key} - id={key} - onClick={onSelectedFilterChange} - > - {filterOptions[key].name} - </FilterDropdownItem> - ); - })} + {Object.keys(filterOptions).length > 0 ? ( + Object.keys(filterOptions).map((key) => { + return ( + <FilterDropdownItem + key={"primary_option_" + key} + selected={selectedFilter === key} + id={key} + onClick={onSelectedFilterChange} + > + {filterOptions[key].name} + </FilterDropdownItem> + ); + }) + ) : ( + <FilterDropdownItem disabled>No options available</FilterDropdownItem> + )} </div> <CSSTransition in={selectedFilter} @@ -79,6 +83,9 @@ export function FilterDropdown({ filterOptions, setOpen, addFilter }) { inputValue={inputValue} onInputChange={onInputChange} onFilterAdd={onFilterAdd} + inputLeftSymbol={filterOptions[selectedFilter]?.symbol_left} + inputRightSymbol={filterOptions[selectedFilter]?.symbol_right} + inputType={filterOptions[selectedFilter]?.input_type} /> </span> ) : ( @@ -98,6 +105,9 @@ export function SecondaryFilterDropdown({ inputValue, onInputChange, onFilterAdd, + inputLeftSymbol, + inputRightSymbol, + inputType, }) { return ( <div @@ -115,6 +125,10 @@ export function SecondaryFilterDropdown({ onRadioChange={onRadioChange} inputValue={inputValue} onInputChange={onInputChange} + inputLeftSymbol={inputLeftSymbol} + inputRightSymbol={inputRightSymbol} + inputType={inputType} + onSubmit={onFilterAdd} /> <div className="flex flex-row space-x-2 mb-2"> <Button @@ -126,6 +140,7 @@ export function SecondaryFilterDropdown({ <Button className="bg-indigo-600 text-white font-semibold w-full" onClick={onFilterAdd} + type="submit" > Filter </Button> @@ -135,17 +150,24 @@ export function SecondaryFilterDropdown({ } // selected: boolean -function FilterDropdownItem({ children, selected, onClick, id }) { +function FilterDropdownItem({ + children, + selected, + onClick, + id, + disabled = false, +}) { return ( <div className={classNames( - "py-3 px-4 rounded-xl dark:text-neutral-100 hover:bg-dark-800 border-1 transition-all duration-100 cursor-pointer", + "py-3 px-4 rounded-xl dark:text-neutral-100 border-1 transition-all duration-100 cursor-pointer", { "border-transparent": !selected, "bg-dark-800 border-indigo-600 font-semibold": selected, + "hover:bg-dark-800": !disabled, } )} - onClick={() => onClick(id)} + onClick={disabled || !onClick ? undefined : () => onClick(id)} > <div className="flex flex-row items-center"> {children} diff --git a/src/cryptometrics/components/filters/Filter.js b/src/cryptometrics/components/filters/Filter.js index bde9750921c687448ba62bf71d7c22313a20d1e0..3e31d270523afccf8f40934803647e5c9035be14 100644 --- a/src/cryptometrics/components/filters/Filter.js +++ b/src/cryptometrics/components/filters/Filter.js @@ -4,6 +4,8 @@ export function Filter({ subject, condition, value, + symbolLeft, + symbolRight, buttonIcon, onButtonClick, }) { @@ -14,7 +16,11 @@ export function Filter({ <span className="font-light dark:text-neutral-300"> {condition} </span> - <span>{value}</span> + <span> + {symbolLeft} + {value} + {symbolRight} + </span> </div> {/* Button */} diff --git a/src/cryptometrics/components/inputs/Input.js b/src/cryptometrics/components/inputs/Input.js index 39d33c8322470575ebed1dc6f2a2090a355d30cc..bd4361b423c134552b7acd19f432cd9108abdf10 100644 --- a/src/cryptometrics/components/inputs/Input.js +++ b/src/cryptometrics/components/inputs/Input.js @@ -1,14 +1,46 @@ +import classNames from "classnames"; import React from "react"; -function Input({ placeholder, type, onChange, initialValue }) { +function Input({ + placeholder, + type, + onChange, + initialValue, + symbolLeft, + symbolRight, +}) { return ( - <input - className="w-full h-12 text-base focus:ring-indigo-500 focus:border-indigo-500 border-1 border-transparent font-semibold outline-none dark:text-white px-3 pr-3 rounded-2xl bg-dark-800" - placeholder={placeholder} - type={type ? type : "text"} - onChange={onChange} - defaultValue={initialValue || ""} - ></input> + <span className="relative flex w-full flex-wrap items-stretch mb-3"> + {symbolLeft && ( + <span className="absolute left-3 w-5 text-center items-center justify-center h-full z-10 py-3"> + <p className="dark:text-white align-top font-extralight"> + {symbolLeft} + </p> + </span> + )} + <input + className={classNames( + "relative w-full h-12 text-base focus:ring-indigo-500 focus:border-indigo-500 border-1 border-transparent font-semibold outline-none dark:text-white rounded-2xl bg-dark-800", + { + "pr-3": !symbolRight, + "pr-8": symbolRight, + "pl-3": !symbolLeft, + "pl-8": symbolLeft, + } + )} + placeholder={placeholder} + type={type ? type : "text"} + onChange={onChange} + defaultValue={initialValue || ""} + ></input> + {symbolRight && ( + <span className="absolute right-3 w-5 text-center items-center justify-center h-full z-10 py-3"> + <p className="dark:text-white align-top font-extralight"> + {symbolRight} + </p> + </span> + )} + </span> ); } diff --git a/src/cryptometrics/components/radio/RadioForm.js b/src/cryptometrics/components/radio/RadioForm.js index 294d3db4da610f311ee98b243a0314a3aed0df72..79727e5ad53c360e3f9df0b151542865f044e87f 100644 --- a/src/cryptometrics/components/radio/RadioForm.js +++ b/src/cryptometrics/components/radio/RadioForm.js @@ -3,31 +3,38 @@ import React from "react"; import Input from "../inputs/Input"; export function RadioInputForm({ + inputLeftSymbol, + inputRightSymbol, + inputType, options, radioValue, onRadioChange, inputValue, onInputChange, + onSubmit, }) { return ( - <div> + <form onSubmit={onSubmit}> <div className="flex flex-col py-2 space-y-2"> {Object.keys(options).map((key) => { return ( - <div key={"radio_option_" + options[key].id}> + <div key={"radio_option_" + key}> <Radio selected={radioValue} - radioValue={options[key].name} - id={options[key].id} + radioValue={key} + radioLabel={options[key].name} + id={key} onChange={onRadioChange} /> <div className="my-1"> - {options[key].name === radioValue && ( + {key === radioValue && ( <Input - type="text" placeholder="Enter a value here..." initialValue={inputValue} onChange={onInputChange} + symbolLeft={inputLeftSymbol} + symbolRight={inputRightSymbol} + type={inputType} /> )} </div> @@ -35,11 +42,11 @@ export function RadioInputForm({ ); })} </div> - </div> + </form> ); } -export function Radio({ selected, radioValue, onChange }) { +export function Radio({ selected, radioValue, radioLabel, onChange }) { return ( <label className="inline-flex items-center w-full"> <input @@ -55,7 +62,7 @@ export function Radio({ selected, radioValue, onChange }) { "dark:text-indigo-500": selected === radioValue, })} > - {radioValue} + {radioLabel} </span> </label> ); diff --git a/src/cryptometrics/components/tabs/Tab.js b/src/cryptometrics/components/tabs/Tab.js index 5e80f279d873075e65a213a4d12c7670ae38448c..7463462c67b3fe5d4955db101a81a377d5bb33a1 100644 --- a/src/cryptometrics/components/tabs/Tab.js +++ b/src/cryptometrics/components/tabs/Tab.js @@ -1,11 +1,8 @@ import classNames from "classnames"; -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; export function Tabs({ children }) { const [tab, setTab] = useState("card-view"); - useEffect(() => { - console.log(tab); - }, [tab]); return ( <div> diff --git a/src/cryptometrics/constants/constants.js b/src/cryptometrics/constants/constants.js index 5bfc28934fe30fdb11da9d2bf880233e2b6fb3a8..a49aa34432187852e4b246e7ae68f0e25abadaba 100644 --- a/src/cryptometrics/constants/constants.js +++ b/src/cryptometrics/constants/constants.js @@ -1,3 +1,11 @@ +import { + less_than_number, + greater_than_number, + equals_string, + equals_integer, + contains_string, +} from "../utils"; + export const cryptoChartOptions = ( colors = [], dark = false, @@ -178,53 +186,89 @@ export const cryptocurrencies = [ ]; export const filterOptions = { - price: { - id: "price", + current_price: { + id: "current_price", name: "Price", + input_type: "number", + symbol_left: "$", options: { equals: { id: "equals", - name: "is", + name: "is (approximately)", + function: equals_integer, }, less_than: { id: "less_than", name: "is less than", + function: less_than_number, }, greater_than: { id: "greater_than", name: "is greater than", + function: greater_than_number, }, }, }, name: { id: "name", name: "Name", + input_type: "text", options: { equals: { id: "equals", name: "is", + function: equals_string, }, less_than: { id: "contains", name: "contains", + function: contains_string, }, }, }, - price_change_percentage: { - id: "price_change_percentage", + price_change_percentage_24h: { + id: "price_change_percentage_24h", name: "Price Change in %", + symbol_right: "%", + input_type: "number", options: { equals: { id: "equals", - name: "is", + name: "is (approximately)", + function: equals_integer, + }, + less_than: { + id: "less_than", + name: "is less than", + function: less_than_number, + }, + greater_than: { + id: "greater_than", + name: "is greater than", + function: greater_than_number, + }, + }, + }, + price_change_24h: { + id: "price_change_24h", + name: "Price Change in $", + input_type: "number", + symbol_left: "$", + options: { + equals: { + id: "equals", + name: "is (approximately)", + function: equals_integer, }, less_than: { id: "less_than", name: "is less than", + function: less_than_number, }, greater_than: { id: "greater_than", name: "is greater than", + function: greater_than_number, }, }, }, diff --git a/src/cryptometrics/hooks/index.js b/src/cryptometrics/hooks/index.js new file mode 100644 index 0000000000000000000000000000000000000000..678b5771db58d2c91036a64425ed479804687c5d --- /dev/null +++ b/src/cryptometrics/hooks/index.js @@ -0,0 +1,2 @@ +export * from "./useFilters"; +export * from "./useOnClickOutside"; diff --git a/src/cryptometrics/hooks/useFilters.js b/src/cryptometrics/hooks/useFilters.js index 145c8d4fe04a63d7d26ef39aa3da2d0dcedbf04a..d5fde2b6ec994d22f27eb04ca71f2faeff9eb9bd 100644 --- a/src/cryptometrics/hooks/useFilters.js +++ b/src/cryptometrics/hooks/useFilters.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import { useState } from "react"; /** * Format of each filter should be: @@ -8,7 +8,7 @@ import React, { useState } from "react"; * value: "" * } */ -export default function useFilters(initialArray) { +export function useFilters(initialArray) { const [filters, setFilters] = useState(initialArray); const checkIfFilterExists = (filter) => { diff --git a/src/cryptometrics/hooks/useOnClickOutside.js b/src/cryptometrics/hooks/useOnClickOutside.js new file mode 100644 index 0000000000000000000000000000000000000000..95b795f15da722a981c642567a8238a33e0e9e56 --- /dev/null +++ b/src/cryptometrics/hooks/useOnClickOutside.js @@ -0,0 +1,18 @@ +import { useEffect } from "react"; + +export function useOnClickOutside(ref, handler) { + useEffect(() => { + const listener = (event) => { + if (ref.current && ref.current?.contains(event.target)) { + return; + } + handler(event); + }; + document.addEventListener("mousedown", listener); + document.addEventListener("touchstart", listener); + return () => { + document.removeEventListener("mousedown", listener); + document.removeEventListener("touchstart", listener); + }; + }, [ref, handler]); +} diff --git a/src/cryptometrics/pages/index.js b/src/cryptometrics/pages/index.js index ba9aa70b56370ef5d4d1d74d6f1bf6cf56320564..5612e722447acdfaec40ff782eebdeb5d30685f2 100644 --- a/src/cryptometrics/pages/index.js +++ b/src/cryptometrics/pages/index.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useRef, useCallback } from "react"; import Head from "next/head"; import Container from "../components/content/Container"; import Main from "../components/content/Main"; @@ -17,7 +17,7 @@ import { TableIcon, XIcon, } from "@heroicons/react/outline"; -import useFilters from "../hooks/useFilters"; +import { useFilters, useOnClickOutside } from "../hooks"; import { Filter, FilterButton, Filters } from "../components/filters/Filter"; import { FilterDropdown } from "../components/dropdown/FilterDropdown"; import { filterOptions } from "../constants"; @@ -29,13 +29,29 @@ import Link from "next/link"; export default function Home() { const [filters, addFilter, removeFilter] = useFilters([]); + const filterDropdownRef = useRef(null); const [dropdownOpen, setDropdownOpen] = useState(false); + const callbackDropdownClose = useCallback(() => setDropdownOpen(false), []); + useOnClickOutside(filterDropdownRef, callbackDropdownClose); const [searchText, setSearchText] = useState(""); const listOfCoins = useCryptoList("usd", 21, false); - const filteredCoins = listOfCoins.data?.filter((coin) => { + + // Filter coins based on search + let filteredCoins = listOfCoins.data?.filter((coin) => { return coin.name.toLowerCase().includes(searchText.toLowerCase()); }); + // Apply filters + for (let i = 0; i < filters.length; i++) { + const { subject, condition, value } = filters[i]; + filteredCoins = filteredCoins.filter((coin) => { + return filterOptions[subject]?.options[condition]?.function( + coin[subject], + value + ); + }); + } + return ( <div> <Head> @@ -60,9 +76,14 @@ export default function Home() { return ( <Filter key={"filter_" + idx} - subject={filter.subject} - condition={filter.condition} + subject={filterOptions[filter.subject]?.name} + condition={ + filterOptions[filter.subject]?.options[filter.condition] + ?.name + } value={filter.value} + symbolLeft={filterOptions[filter.subject]?.symbol_left} + symbolRight={filterOptions[filter.subject]?.symbol_right} buttonIcon={<XIcon className="w-5 h-5" />} onButtonClick={() => removeFilter(filter)} /> @@ -75,12 +96,22 @@ export default function Home() { setDropdownOpen(!dropdownOpen) && addFilter({}) } /> - {dropdownOpen && ( <FilterDropdown + dropdownRef={filterDropdownRef} setOpen={setDropdownOpen} addFilter={addFilter} - filterOptions={filterOptions} + filterOptions={Object.keys(filterOptions) + .filter( + (option) => + filters.filter( + (filter) => filter.subject === option + ).length === 0 + ) + .reduce((obj, key) => { + obj[key] = filterOptions[key]; + return obj; + }, {})} /> )} </div> diff --git a/src/cryptometrics/utils/index.js b/src/cryptometrics/utils/index.js new file mode 100644 index 0000000000000000000000000000000000000000..178cd64f81d76edb65bec9b366e7a8c36f2866d1 --- /dev/null +++ b/src/cryptometrics/utils/index.js @@ -0,0 +1 @@ +export * from "./utils"; diff --git a/src/cryptometrics/utils/utils.js b/src/cryptometrics/utils/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..599f8e73c4762fd94bff67e5e154bdf54e350c53 --- /dev/null +++ b/src/cryptometrics/utils/utils.js @@ -0,0 +1,19 @@ +export function less_than_number(a, b) { + return Number(a) < Number(b); +} + +export function equals_integer(a, b) { + return parseInt(a) === parseInt(b); +} + +export function greater_than_number(a, b) { + return Number(a) > Number(b); +} + +export function equals_string(a, b) { + return a.toLowerCase() === b.toLowerCase(); +} + +export function contains_string(a, b) { + return a.toLowerCase().includes(b.toLowerCase()); +}