diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73410da..f19c9fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: 18 - cache: 'npm' + cache: "npm" + cache-dependency-path: dashboard/package-lock.json - name: Install dependencies working-directory: dashboard run: npm ci diff --git a/contract/contracts/hello-world/src/tests/autoshare_test.rs b/contract/contracts/hello-world/src/tests/autoshare_test.rs index 4b5225c..c503b34 100644 --- a/contract/contracts/hello-world/src/tests/autoshare_test.rs +++ b/contract/contracts/hello-world/src/tests/autoshare_test.rs @@ -1486,10 +1486,10 @@ fn test_create_fails_name_too_long() { let creator = test_env.users.get(0).unwrap().clone(); let token = test_env.mock_tokens.get(0).unwrap().clone(); let id = BytesN::from_array(&test_env.env, &[1u8; 32]); - + // Create a very long name (over 100 characters) let long_name = String::from_str(&test_env.env, "a".repeat(101).as_str()); - + crate::test_utils::mint_tokens(&test_env.env, &token, &creator, 10000000); client.create(&id, &long_name, &creator, &10u32, &token); } @@ -1504,7 +1504,7 @@ fn test_create_fails_invalid_usage_count_zero() { let token = test_env.mock_tokens.get(0).unwrap().clone(); let id = BytesN::from_array(&test_env.env, &[1u8; 32]); let name = String::from_str(&test_env.env, "Test Group"); - + crate::test_utils::mint_tokens(&test_env.env, &token, &creator, 10000000); client.create(&id, &name, &creator, &0u32, &token); } @@ -1518,14 +1518,14 @@ fn test_create_fails_unsupported_token() { let creator = test_env.users.get(0).unwrap().clone(); let id = BytesN::from_array(&test_env.env, &[1u8; 32]); let name = String::from_str(&test_env.env, "Test Group"); - + // Create an unsupported token (not added to supported tokens) - let unsupported_token = test_utils::deploy_mock_token( - &test_env.env, - &String::from_str(&test_env.env, "Unsupported"), - &String::from_str(&test_env.env, "UNSUP") + let unsupported_token = crate::test_utils::deploy_mock_token( + &test_env.env, + &String::from_str(&test_env.env, "Unsupported"), + &String::from_str(&test_env.env, "UNSUP"), ); - + crate::test_utils::mint_tokens(&test_env.env, &unsupported_token, &creator, 10000000); client.create(&id, &name, &creator, &10u32, &unsupported_token); } @@ -1539,10 +1539,17 @@ fn test_topup_fails_invalid_usage_count_zero() { let creator = test_env.users.get(0).unwrap().clone(); let token = test_env.mock_tokens.get(0).unwrap().clone(); let id = BytesN::from_array(&test_env.env, &[1u8; 32]); - + let members = Vec::new(&test_env.env); - create_test_group(&test_env.env, &test_env.autoshare_contract, &creator, &members, 10, &token); - + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 10, + &token, + ); + client.topup_subscription(&id, &0u32, &token, &creator); } @@ -1555,17 +1562,24 @@ fn test_topup_fails_unsupported_token() { let creator = test_env.users.get(0).unwrap().clone(); let supported_token = test_env.mock_tokens.get(0).unwrap().clone(); let id = BytesN::from_array(&test_env.env, &[1u8; 32]); - + let members = Vec::new(&test_env.env); - create_test_group(&test_env.env, &test_env.autoshare_contract, &creator, &members, 10, &supported_token); - + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 10, + &supported_token, + ); + // Create an unsupported token - let unsupported_token = test_utils::deploy_mock_token( - &test_env.env, - &String::from_str(&test_env.env, "Unsupported"), - &String::from_str(&test_env.env, "UNSUP") + let unsupported_token = crate::test_utils::deploy_mock_token( + &test_env.env, + &String::from_str(&test_env.env, "Unsupported"), + &String::from_str(&test_env.env, "UNSUP"), ); - + crate::test_utils::mint_tokens(&test_env.env, &unsupported_token, &creator, 10000000); client.topup_subscription(&id, &10u32, &unsupported_token, &creator); } @@ -1579,13 +1593,20 @@ fn test_reduce_usage_fails_no_usages_remaining() { let creator = test_env.users.get(0).unwrap().clone(); let token = test_env.mock_tokens.get(0).unwrap().clone(); let id = BytesN::from_array(&test_env.env, &[1u8; 32]); - + let members = Vec::new(&test_env.env); - create_test_group(&test_env.env, &test_env.autoshare_contract, &creator, &members, 1, &token); - + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 1, + &token, + ); + // Reduce once (should work) client.reduce_usage(&id); - + // Reduce again (should panic) client.reduce_usage(&id); } @@ -1600,7 +1621,7 @@ fn test_set_usage_fee_fails_zero() { let admin = Address::generate(&env); client.initialize_admin(&admin); - + client.set_usage_fee(&0u32, &admin); } @@ -1614,7 +1635,7 @@ fn test_add_supported_token_fails_already_exists() { let admin = Address::generate(&env); client.initialize_admin(&admin); - + let token_id = env.register(MockToken, ()); let token_client = MockTokenClient::new(&env, &token_id); token_client.initialize( @@ -1623,7 +1644,7 @@ fn test_add_supported_token_fails_already_exists() { &String::from_str(&env, "Test Token"), &String::from_str(&env, "TST"), ); - + // Add once client.add_supported_token(&token_id, &admin); // Add again (should panic) @@ -1640,7 +1661,7 @@ fn test_remove_supported_token_fails_not_found() { let admin = Address::generate(&env); client.initialize_admin(&admin); - + let token_id = env.register(MockToken, ()); let token_client = MockTokenClient::new(&env, &token_id); token_client.initialize( @@ -1649,7 +1670,7 @@ fn test_remove_supported_token_fails_not_found() { &String::from_str(&env, "Test Token"), &String::from_str(&env, "TST"), ); - + // Try to remove a token that was never added client.remove_supported_token(&token_id, &admin); } @@ -1663,11 +1684,18 @@ fn test_update_members_fails_too_many() { let creator = test_env.users.get(0).unwrap().clone(); let token = test_env.mock_tokens.get(0).unwrap().clone(); let id = BytesN::from_array(&test_env.env, &[1u8; 32]); - + // Create group first let initial_members = Vec::new(&test_env.env); - create_test_group(&test_env.env, &test_env.autoshare_contract, &creator, &initial_members, 10, &token); - + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &initial_members, + 10, + &token, + ); + // Create 51 members (MAX_MEMBERS is 50) let mut too_many_members = Vec::new(&test_env.env); for _ in 0..51 { @@ -1676,7 +1704,7 @@ fn test_update_members_fails_too_many() { percentage: 1, // This will sum to 51, but first check is TooManyMembers }); } - + client.update_members(&id, &creator, &too_many_members); } diff --git a/dashboard/src/components/EventFiltersBar.tsx b/dashboard/src/components/EventFiltersBar.tsx index 222a48d..a598cf6 100644 --- a/dashboard/src/components/EventFiltersBar.tsx +++ b/dashboard/src/components/EventFiltersBar.tsx @@ -1,6 +1,7 @@ import { memo } from 'react'; import { useEventStore } from '../store/eventStore'; import { useEventCount, useEventFilters, useFilterOptions } from '../hooks/useEventSelectors'; +import { SearchAutocomplete } from './SearchAutocomplete'; export const EventFiltersBar = memo(function EventFiltersBar() { const filters = useEventFilters(); @@ -14,12 +15,9 @@ export const EventFiltersBar = memo(function EventFiltersBar() {
- setSearch(event.target.value)} + setSearch(value)} />
diff --git a/dashboard/src/components/SearchAutocomplete.tsx b/dashboard/src/components/SearchAutocomplete.tsx new file mode 100644 index 0000000..593de00 --- /dev/null +++ b/dashboard/src/components/SearchAutocomplete.tsx @@ -0,0 +1,188 @@ +import React, { useState, useEffect, useRef, memo } from 'react'; +import { useDebounce } from '../hooks/useDebounce'; +import { useEventStore } from '../store/eventStore'; +import { filterEvents } from '../utils/eventData'; +import type { BlockchainEvent } from '../types/event'; + +// Highlight matches +function HighlightMatch({ text, query }: { text: string; query: string }) { + if (!query) return <>{text}; + + const regex = new RegExp(`(${query})`, 'gi'); + const parts = text.split(regex); + + return ( + <> + {parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + + {part} + + ) : ( + {part} + ) + )} + + ); +} + +export const SearchAutocomplete = memo(function SearchAutocomplete({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const [localValue, setLocalValue] = useState(value); + const debouncedSearchTerm = useDebounce(localValue, 300); + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + // Sync internal state with external value changes (if any external changes) + useEffect(() => { + setLocalValue(value); + }, [value]); + + useEffect(() => { + // Close dropdown if search is empty + if (!debouncedSearchTerm.trim()) { + setSuggestions([]); + setIsOpen(false); + return; + } + + let isMounted = true; + const fetchSuggestions = async () => { + setIsLoading(true); + setIsOpen(true); + + // Simulate async fetching + await new Promise((resolve) => setTimeout(resolve, 300)); + + if (isMounted) { + const allEvents = useEventStore.getState().events; + // Get up to 5 matching suggestions based on query, disregarding other filters generally, + // or passing "all" for them. + const matches = filterEvents(allEvents, debouncedSearchTerm, 'all', 'all').slice(0, 5); + setSuggestions(matches); + setIsLoading(false); + } + }; + + fetchSuggestions(); + + return () => { + isMounted = false; + }; + }, [debouncedSearchTerm]); + + // Handle outside click to close suggestions + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setLocalValue(val); + onChange(val); // Immediately pass up the value to update store + if (!val.trim()) { + setIsOpen(false); + } + }; + + const handleSelect = (event: BlockchainEvent) => { + // Determine what to use for search field. We can use eventName or eventId + const targetValue = event.eventName || event.eventId; + setLocalValue(targetValue); + onChange(targetValue); + setIsOpen(false); + }; + + return ( +
+ { + if (debouncedSearchTerm.trim()) { + setIsOpen(true); + } + }} + autoComplete="off" + style={{ width: '100%' }} + /> + + {isOpen && ( +
+ {isLoading ? ( +
+ Loading suggestions... +
+ ) : suggestions.length > 0 ? ( +
    + {suggestions.map((event) => ( +
  • handleSelect(event)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + borderBottom: '1px solid rgba(255,255,255,0.06)', + fontSize: '0.85rem' + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.06)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > +
    + + + + +
    +
    + +
    +
  • + ))} +
+ ) : ( +
+ No suggestions found +
+ )} +
+ )} +
+ ); +}); diff --git a/dashboard/src/hooks/useDebounce.ts b/dashboard/src/hooks/useDebounce.ts new file mode 100644 index 0000000..614feab --- /dev/null +++ b/dashboard/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +}