diff --git a/CHANGELOG.md b/CHANGELOG.md index fe287cee2..e4be46508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features +- [#93](https://github.com/alleslabs/celatone-frontend/pull/93) Add filter code by instantiate permission in all codes page - [#141](https://github.com/alleslabs/celatone-frontend/pull/141) Add 404 not found page, catch network params error - [#134](https://github.com/alleslabs/celatone-frontend/pull/134) Fix un-align sub-page with sidebar - [#144](https://github.com/alleslabs/celatone-frontend/pull/144) Add `Assign me` for admin address on instantiate form diff --git a/src/lib/components/InputWithIcon.tsx b/src/lib/components/InputWithIcon.tsx index 8d049aa9f..5fce94bb9 100644 --- a/src/lib/components/InputWithIcon.tsx +++ b/src/lib/components/InputWithIcon.tsx @@ -24,8 +24,8 @@ const InputWithIcon = ({ onChange={onChange} size={size} /> - - + + ); diff --git a/src/lib/components/forms/FilterByPermission.tsx b/src/lib/components/forms/FilterByPermission.tsx new file mode 100644 index 000000000..77dc0c0b2 --- /dev/null +++ b/src/lib/components/forms/FilterByPermission.tsx @@ -0,0 +1,61 @@ +import { Grid } from "@chakra-ui/react"; +import type { IconType } from "react-icons"; +import { MdCheck, MdHowToVote, MdPerson } from "react-icons/md"; + +import type { PermissionFilterValue } from "lib/hooks"; + +import { SelectInput } from "./SelectInput"; + +interface PermissionOption { + label: string; + value: PermissionFilterValue; + disabled: boolean; + icon?: IconType; + iconColor: string; +} + +interface FilterByPermissionProps { + setPermissionValue: (newVal: PermissionFilterValue) => void; + initialSelected: string; +} + +const options: PermissionOption[] = [ + { + label: "All", + value: "all", + disabled: false, + icon: MdCheck, + iconColor: "gray.600", + }, + { + label: "Can Instantiate without proposal", + value: "without-proposal", + disabled: false, + icon: MdPerson, + iconColor: "primary.main", + }, + { + label: "Instantiate through proposal only", + value: "with-proposal", + disabled: false, + icon: MdHowToVote, + iconColor: "text.dark", + }, +]; + +export const FilterByPermission = ({ + setPermissionValue, + initialSelected, +}: FilterByPermissionProps) => { + return ( + + + formLabel="Filter by Instantiate Permission" + options={options} + onChange={setPermissionValue} + placeholder="Select" + initialSelected={initialSelected} + /> + + ); +}; diff --git a/src/lib/components/forms/SelectInput.tsx b/src/lib/components/forms/SelectInput.tsx index 938ffa51e..d3d44697f 100644 --- a/src/lib/components/forms/SelectInput.tsx +++ b/src/lib/components/forms/SelectInput.tsx @@ -6,22 +6,33 @@ import { Popover, PopoverTrigger, PopoverContent, - Box, useDisclosure, useOutsideClick, + Flex, + InputLeftElement, } from "@chakra-ui/react"; import type { MutableRefObject, ReactNode } from "react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { IconType } from "react-icons/lib"; import { MdArrowDropDown } from "react-icons/md"; -const ITEM_HEIGHT = 57; +import type { Option } from "lib/types"; -interface SelectInputProps { +const ITEM_HEIGHT = 56; + +interface SelectInputProps { formLabel?: string; - options: { label: string; value: string; disabled: boolean }[]; - onChange: (newVal: string) => void; + options: { + label: string; + value: T; + disabled: boolean; + icon?: IconType; + iconColor?: string; + }[]; + onChange: (newVal: T) => void; placeholder?: string; initialSelected: string; + hasDivider?: boolean; } interface SelectItemProps { @@ -32,40 +43,49 @@ interface SelectItemProps { const SelectItem = ({ children, onSelect, disabled }: SelectItemProps) => { return ( - {children} - + ); }; -export const SelectInput = ({ +export const SelectInput = ({ formLabel, options, onChange, placeholder = "", initialSelected, -}: SelectInputProps) => { + hasDivider = false, +}: SelectInputProps) => { const optionRef = useRef() as MutableRefObject; const { isOpen, onClose, onOpen } = useDisclosure(); - + const inputRef = useRef() as MutableRefObject; const [selected, setSelected] = useState( - () => options.find((asset) => asset.value === initialSelected)?.label ?? "" + () => options.find((item) => item.value === initialSelected)?.label ?? "" ); - + const [inputRefWidth, setInputRefWidth] = useState>(); useOutsideClick({ ref: optionRef, handler: () => isOpen && onClose(), }); + const selectedOption = options.find((item) => item.label === selected); + useEffect(() => { + if (inputRef.current) { + setInputRefWidth(inputRef.current.clientWidth); + } + }, [inputRef]); return ( @@ -92,6 +112,15 @@ export const SelectInput = ({ }} >
{formLabel}
+ {selectedOption?.icon && ( + + + + )} @@ -109,7 +140,7 @@ export const SelectInput = ({ ref={optionRef} border="unset" bg="gray.900" - w="200px" + w={inputRefWidth} maxH={`${ITEM_HEIGHT * 4}px`} overflow="scroll" borderRadius="4px" @@ -118,12 +149,12 @@ export const SelectInput = ({ }} sx={{ "> div:not(:last-of-type)": { - borderBottom: "1px solid", - borderBottomColor: "divider.main", + borderBottom: hasDivider && "1px solid", + borderBottomColor: hasDivider && "divider.main", }, }} > - {options.map(({ label, value, disabled }) => ( + {options.map(({ label, value, disabled, icon, iconColor }) => ( { @@ -133,6 +164,7 @@ export const SelectInput = ({ }} disabled={disabled} > + {icon && } {label} ))} diff --git a/src/lib/hooks/index.ts b/src/lib/hooks/index.ts index 8810afd42..bc3f6ae0e 100644 --- a/src/lib/hooks/index.ts +++ b/src/lib/hooks/index.ts @@ -5,3 +5,4 @@ export * from "./useLCDEndpoint"; export * from "./useUserKey"; export * from "./useDummyWallet"; export * from "./useAddress"; +export * from "./useCodeFilter"; diff --git a/src/lib/hooks/useCodeFilter.ts b/src/lib/hooks/useCodeFilter.ts new file mode 100644 index 000000000..1714c3f35 --- /dev/null +++ b/src/lib/hooks/useCodeFilter.ts @@ -0,0 +1,46 @@ +import { useWallet } from "@cosmos-kit/react"; +import { useCallback } from "react"; + +import type { CodeInfo, HumanAddr } from "lib/types"; +import { InstantiatePermission } from "lib/types"; + +export type PermissionFilterValue = + | "all" + | "without-proposal" + | "with-proposal"; + +export const usePermissionFilter = (filterValue?: PermissionFilterValue) => { + const { address } = useWallet(); + return useCallback( + ({ instantiatePermission, permissionAddresses }: CodeInfo) => { + const isAllowed = + permissionAddresses.includes(address as HumanAddr) || + instantiatePermission === InstantiatePermission.EVERYBODY; + + switch (filterValue) { + case "with-proposal": + return !isAllowed; + case "without-proposal": + return isAllowed; + case "all": + default: + return true; + } + }, + [address, filterValue] + ); +}; + +export const useSearchFilter = (keyword = "") => { + return useCallback( + (code: CodeInfo) => { + const computedKeyword = keyword.trim(); + if (!computedKeyword.length) return true; + return ( + code.id.toString().startsWith(computedKeyword) || + code.description?.toLowerCase().includes(computedKeyword.toLowerCase()) + ); + }, + [keyword] + ); +}; diff --git a/src/lib/pages/all-codes/data.ts b/src/lib/pages/all-codes/data.ts index b2f60dfe5..95e918430 100644 --- a/src/lib/pages/all-codes/data.ts +++ b/src/lib/pages/all-codes/data.ts @@ -1,6 +1,7 @@ import { useMemo } from "react"; -import { useCodeStore } from "lib/hooks"; +import type { PermissionFilterValue } from "lib/hooks"; +import { useCodeStore, usePermissionFilter, useSearchFilter } from "lib/hooks"; import { useCodeListQuery } from "lib/services/codeService"; import type { CodeInfo } from "lib/types"; @@ -9,9 +10,14 @@ interface AllCodesData { isLoading: boolean; } -export const useAllCodesData = (keyword?: string): AllCodesData => { +export const useAllCodesData = ( + keyword: string, + permissionValue: PermissionFilterValue +): AllCodesData => { const { getCodeLocalInfo, isCodeIdSaved } = useCodeStore(); const { data: rawAllCodes = [], isLoading } = useCodeListQuery(); + const permissionFilterFn = usePermissionFilter(permissionValue); + const searchFilterFn = useSearchFilter(keyword); const allCodes = rawAllCodes.map((code) => ({ ...code, @@ -20,18 +26,9 @@ export const useAllCodesData = (keyword?: string): AllCodesData => { })); return useMemo(() => { - const filterFn = (code: CodeInfo) => { - if (keyword === undefined) return true; - - const computedKeyword = keyword.trim(); - if (computedKeyword.length === 0) return true; - - return ( - code.id.toString().startsWith(computedKeyword) || - code.description?.toLowerCase().includes(computedKeyword.toLowerCase()) - ); + return { + allCodes: allCodes.filter(permissionFilterFn).filter(searchFilterFn), + isLoading, }; - - return { allCodes: allCodes.filter(filterFn), isLoading }; - }, [keyword, allCodes, isLoading]); + }, [allCodes, isLoading, permissionFilterFn, searchFilterFn]); }; diff --git a/src/lib/pages/all-codes/index.tsx b/src/lib/pages/all-codes/index.tsx index f4b55963b..c254d7c86 100644 --- a/src/lib/pages/all-codes/index.tsx +++ b/src/lib/pages/all-codes/index.tsx @@ -1,22 +1,30 @@ -import { Heading, Box } from "@chakra-ui/react"; +import { Heading, Box, Flex } from "@chakra-ui/react"; import { observer } from "mobx-react-lite"; import type { ChangeEvent } from "react"; -import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { FilterByPermission } from "lib/components/forms/FilterByPermission"; import InputWithIcon from "lib/components/InputWithIcon"; import { Loading } from "lib/components/Loading"; +import type { PermissionFilterValue } from "lib/hooks"; import CodesTable from "lib/pages/codes/components/CodesTable"; import { useAllCodesData } from "./data"; -const AllCodes = observer(() => { - const [keyword, setKeyword] = useState(""); - const { allCodes, isLoading } = useAllCodesData(keyword); +interface AllCodeState { + keyword: string; + permissionValue: PermissionFilterValue; +} - const handleFilterChange = (e: ChangeEvent) => { - const inputValue = e.target.value; - setKeyword(inputValue); - }; +const AllCodes = observer(() => { + const { watch, setValue } = useForm({ + defaultValues: { + permissionValue: "all", + keyword: "", + }, + }); + const { keyword, permissionValue } = watch(); + const { allCodes, isLoading } = useAllCodesData(keyword, permissionValue); return ( @@ -24,13 +32,23 @@ const AllCodes = observer(() => { All Codes - - + + ) => + setValue("keyword", e.target.value) + } + size="lg" + /> + { + if (newVal === permissionValue) return; + setValue("permissionValue", newVal); + }} + /> + {isLoading ? ( diff --git a/src/lib/pages/codes/data.ts b/src/lib/pages/codes/data.ts index ab0149722..22cfded43 100644 --- a/src/lib/pages/codes/data.ts +++ b/src/lib/pages/codes/data.ts @@ -1,7 +1,13 @@ import { useWallet } from "@cosmos-kit/react"; import { useMemo } from "react"; -import { useUserKey, useCodeStore } from "lib/hooks"; +import type { PermissionFilterValue } from "lib/hooks"; +import { + useUserKey, + useCodeStore, + usePermissionFilter, + useSearchFilter, +} from "lib/hooks"; import { useCodeListByIDsQuery, useCodeListByUserQuery, @@ -17,7 +23,10 @@ interface CodeListData { allCodesCount: number; } -export const useCodeListData = (keyword?: string): CodeListData => { +export const useCodeListData = ( + keyword?: string, + permissionValue?: PermissionFilterValue +): CodeListData => { const { address } = useWallet(); const { getCodeLocalInfo, lastSavedCodes, lastSavedCodeIds, isCodeIdSaved } = useCodeStore(); @@ -25,6 +34,8 @@ export const useCodeListData = (keyword?: string): CodeListData => { const { data: rawStoredCodes = [] } = useCodeListByUserQuery(address); const userKey = useUserKey(); + const permissionFilterFn = usePermissionFilter(permissionValue); + const searchFilterFn = useSearchFilter(keyword); const savedCodeIds = lastSavedCodeIds(userKey); const { data: querySavedCodeInfos = [] } = @@ -60,20 +71,11 @@ export const useCodeListData = (keyword?: string): CodeListData => { const storedCodesCount = storedCodes.length; const [filteredSavedCodes, filteredStoredCodes] = useMemo(() => { - const filterFn = (code: CodeInfo) => { - if (keyword === undefined) return true; - - const computedKeyword = keyword.trim(); - if (computedKeyword.length === 0) return true; - - return ( - code.id.toString().startsWith(computedKeyword) || - code.description?.toLowerCase().includes(computedKeyword.toLowerCase()) - ); - }; - - return [savedCodes.filter(filterFn), storedCodes.filter(filterFn)]; - }, [keyword, savedCodes, storedCodes]); + return [ + savedCodes.filter(permissionFilterFn).filter(searchFilterFn), + storedCodes.filter(permissionFilterFn).filter(searchFilterFn), + ]; + }, [savedCodes, storedCodes, permissionFilterFn, searchFilterFn]); return { savedCodes: filteredSavedCodes, diff --git a/src/lib/pages/codes/index.tsx b/src/lib/pages/codes/index.tsx index bcd96bfec..5f66a9bd0 100644 --- a/src/lib/pages/codes/index.tsx +++ b/src/lib/pages/codes/index.tsx @@ -5,34 +5,42 @@ import { TabPanels, TabPanel, Box, + Flex, } from "@chakra-ui/react"; import { observer } from "mobx-react-lite"; import type { ChangeEvent } from "react"; -import { useState } from "react"; +import { useForm } from "react-hook-form"; import { CustomTab } from "lib/components/CustomTab"; +import { FilterByPermission } from "lib/components/forms/FilterByPermission"; import InputWithIcon from "lib/components/InputWithIcon"; +import type { PermissionFilterValue } from "lib/hooks"; import CodesTable from "lib/pages/codes/components/CodesTable"; import SaveCodeButton from "./components/SaveCodeButton"; import UploadButton from "./components/UploadButton"; import { useCodeListData } from "./data"; +interface AllCodeState { + keyword: string; + permissionValue: PermissionFilterValue; +} + const Codes = observer(() => { - const [keyword, setKeyword] = useState(""); + const { watch, setValue } = useForm({ + defaultValues: { + permissionValue: "all", + keyword: "", + }, + }); + const { keyword, permissionValue } = watch(); const { storedCodesCount, storedCodes: stored, savedCodesCount, savedCodes: saved, allCodesCount, - } = useCodeListData(keyword); - - const handleFilterChange = (e: ChangeEvent) => { - const inputValue = e.target.value; - - setKeyword(inputValue); - }; + } = useCodeListData(keyword, permissionValue); return ( @@ -48,12 +56,23 @@ const Codes = observer(() => { My Stored Codes My Saved Codes - + + ) => + setValue("keyword", e.target.value) + } + size="lg" + /> + { + if (newVal === permissionValue) return; + setValue("permissionValue", newVal); + }} + /> +