diff --git a/crates/cli/src/commands/order/list.rs b/crates/cli/src/commands/order/list.rs index 74bd62ed1..4048c780e 100644 --- a/crates/cli/src/commands/order/list.rs +++ b/crates/cli/src/commands/order/list.rs @@ -1,6 +1,6 @@ use crate::{ execute::Execute, - subgraph::{CliPaginationArgs, CliSubgraphArgs}, + subgraph::{CliFilterArgs, CliPaginationArgs, CliSubgraphArgs}, }; use anyhow::Result; use clap::Args; @@ -19,6 +19,9 @@ pub struct CliOrderListArgs { #[clap(flatten)] pub subgraph_args: CliSubgraphArgs, + + #[clap(flatten)] + pub filter_args: CliFilterArgs, } impl Execute for CliOrderListArgs { @@ -42,7 +45,10 @@ impl Execute for CliOrderListArgs { subgraph_args .to_subgraph_client() .await? - .orders_list(self.pagination_args.clone().into()) + .orders_list( + self.filter_args.clone().into(), + self.pagination_args.clone().into(), + ) .await? .into_iter() .map(|o| o.try_into()) @@ -112,6 +118,9 @@ mod tests { page_size: 25, page: 1, }, + filter_args: CliFilterArgs { + owners: vec!["addr1".to_string()], + }, }; // should succeed @@ -135,6 +144,9 @@ mod tests { page_size: 25, page: 1, }, + filter_args: CliFilterArgs { + owners: vec!["addr1".to_string()], + }, }; // should succeed @@ -158,6 +170,9 @@ mod tests { page_size: 25, page: 1, }, + filter_args: CliFilterArgs { + owners: vec!["addr1".to_string()], + }, }; // should error diff --git a/crates/cli/src/subgraph.rs b/crates/cli/src/subgraph.rs index 8bc08042d..10a6456bf 100644 --- a/crates/cli/src/subgraph.rs +++ b/crates/cli/src/subgraph.rs @@ -1,6 +1,9 @@ use clap::Args; use rain_orderbook_common::subgraph::SubgraphArgs; -use rain_orderbook_subgraph_client::PaginationArgs; +use rain_orderbook_subgraph_client::{ + types::common::{Bytes, OrdersListFilterArgs}, + PaginationArgs, +}; #[derive(Args, Clone)] pub struct CliSubgraphArgs { @@ -57,3 +60,21 @@ impl From for PaginationArgs { } } } + +#[derive(Args, Clone)] +pub struct CliFilterArgs { + #[arg( + long, + help = "Filter orders by owner addresses (comma-separated)", + value_delimiter = ',' + )] + pub owners: Vec, +} + +impl From for OrdersListFilterArgs { + fn from(val: CliFilterArgs) -> Self { + Self { + owners: val.owners.into_iter().map(Bytes).collect(), + } + } +} diff --git a/crates/subgraph/src/orderbook_client.rs b/crates/subgraph/src/orderbook_client.rs index dcf1e3be2..deb3c9a39 100644 --- a/crates/subgraph/src/orderbook_client.rs +++ b/crates/subgraph/src/orderbook_client.rs @@ -69,16 +69,26 @@ impl OrderbookSubgraphClient { /// Fetch all orders, paginated pub async fn orders_list( &self, + filter_args: OrdersListFilterArgs, pagination_args: PaginationArgs, ) -> Result, OrderbookSubgraphClientError> { let pagination_variables = Self::parse_pagination_args(pagination_args); + + let variables = OrdersListQueryVariables { + first: pagination_variables.first, + skip: pagination_variables.skip, + filters: if filter_args.owners.is_empty() { + None + } else { + Some(OrdersListQueryFilters { + owner_in: filter_args.owners, + }) + }, + }; + let data = self - .query::(PaginationQueryVariables { - first: pagination_variables.first, - skip: pagination_variables.skip, - }) + .query::(variables) .await?; - Ok(data.orders) } @@ -89,10 +99,13 @@ impl OrderbookSubgraphClient { loop { let page_data = self - .orders_list(PaginationArgs { - page, - page_size: ALL_PAGES_QUERY_PAGE_SIZE, - }) + .orders_list( + OrdersListFilterArgs { owners: vec![] }, + PaginationArgs { + page, + page_size: ALL_PAGES_QUERY_PAGE_SIZE, + }, + ) .await?; if page_data.is_empty() { break; diff --git a/crates/subgraph/src/types/common.rs b/crates/subgraph/src/types/common.rs index f3252ca4f..1c31df27d 100644 --- a/crates/subgraph/src/types/common.rs +++ b/crates/subgraph/src/types/common.rs @@ -1,5 +1,5 @@ use crate::schema; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use typeshare::typeshare; #[derive(cynic::QueryVariables, Debug, Clone)] @@ -8,6 +8,11 @@ pub struct IdQueryVariables<'a> { pub id: &'a cynic::Id, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrdersListFilterArgs { + pub owners: Vec, +} + #[derive(cynic::QueryVariables, Debug, Clone)] #[typeshare] pub struct PaginationQueryVariables { @@ -15,6 +20,23 @@ pub struct PaginationQueryVariables { pub skip: Option, } +#[derive(cynic::InputObject, Debug, Clone)] +#[cynic(graphql_type = "Order_filter")] +#[typeshare] +pub struct OrdersListQueryFilters { + #[cynic(rename = "owner_in")] + pub owner_in: Vec, +} + +#[derive(cynic::QueryVariables, Debug, Clone)] +#[typeshare] +pub struct OrdersListQueryVariables { + pub first: Option, + pub skip: Option, + #[cynic(rename = "filters")] + pub filters: Option, +} + #[derive(cynic::QueryVariables, Debug, Clone)] #[typeshare] pub struct PaginationWithIdQueryVariables { diff --git a/crates/subgraph/src/types/order.rs b/crates/subgraph/src/types/order.rs index 577be31e5..233c566a9 100644 --- a/crates/subgraph/src/types/order.rs +++ b/crates/subgraph/src/types/order.rs @@ -27,10 +27,10 @@ pub struct BatchOrderDetailQuery { } #[derive(cynic::QueryFragment, Debug)] -#[cynic(graphql_type = "Query", variables = "PaginationQueryVariables")] +#[cynic(graphql_type = "Query", variables = "OrdersListQueryVariables")] #[typeshare] pub struct OrdersListQuery { - #[arguments(orderBy: "timestampAdded", orderDirection: "desc", skip: $skip, first: $first)] + #[arguments(orderBy: "timestampAdded", orderDirection: "desc", skip: $skip, first: $first, where: $filters)] pub orders: Vec, } diff --git a/crates/subgraph/tests/orders_test.rs b/crates/subgraph/tests/orders_test.rs index 6230a2532..81517428f 100644 --- a/crates/subgraph/tests/orders_test.rs +++ b/crates/subgraph/tests/orders_test.rs @@ -6,9 +6,10 @@ use rain_orderbook_subgraph_client::types::order::OrdersListQuery; fn orders_query_gql_output() { use cynic::QueryBuilder; - let request_body = OrdersListQuery::build(PaginationQueryVariables { + let request_body = OrdersListQuery::build(OrdersListQueryVariables { skip: Some(0), first: Some(10), + filters: None, }); assert_snapshot!(request_body.query); diff --git a/crates/subgraph/tests/snapshots/orders_test__orders_query_gql_output.snap b/crates/subgraph/tests/snapshots/orders_test__orders_query_gql_output.snap index 010ec7c28..80caa4527 100644 --- a/crates/subgraph/tests/snapshots/orders_test__orders_query_gql_output.snap +++ b/crates/subgraph/tests/snapshots/orders_test__orders_query_gql_output.snap @@ -2,8 +2,8 @@ source: crates/subgraph/tests/orders_test.rs expression: request_body.query --- -query OrdersListQuery($first: Int, $skip: Int) { - orders(orderBy: timestampAdded, orderDirection: desc, skip: $skip, first: $first) { +query OrdersListQuery($first: Int, $skip: Int, $filters: Order_filter) { + orders(orderBy: timestampAdded, orderDirection: desc, skip: $skip, first: $first, where: $filters) { id orderBytes orderHash diff --git a/tauri-app/src-tauri/src/commands/order.rs b/tauri-app/src-tauri/src/commands/order.rs index 7bf11177d..0a121b535 100644 --- a/tauri-app/src-tauri/src/commands/order.rs +++ b/tauri-app/src-tauri/src/commands/order.rs @@ -15,12 +15,13 @@ use tauri::AppHandle; #[tauri::command] pub async fn orders_list( subgraph_args: SubgraphArgs, + filter_args: OrdersListFilterArgs, pagination_args: PaginationArgs, ) -> CommandResult> { let orders = subgraph_args .to_subgraph_client() .await? - .orders_list(pagination_args) + .orders_list(filter_args, pagination_args) .await?; Ok(orders) } diff --git a/tauri-app/src/lib/components/ListViewOrderbookSelector.svelte b/tauri-app/src/lib/components/ListViewOrderbookSelector.svelte index e3852e2a9..19569ce03 100644 --- a/tauri-app/src/lib/components/ListViewOrderbookSelector.svelte +++ b/tauri-app/src/lib/components/ListViewOrderbookSelector.svelte @@ -2,6 +2,7 @@ import { isEmpty } from 'lodash'; import DropdownActiveNetwork from './DropdownActiveNetwork.svelte'; import DropdownActiveOrderbook from './DropdownActiveOrderbook.svelte'; + import DropdownOrderListWatchlist from './dropdown/DropdownOrderListWatchlist.svelte'; import { settings } from '$lib/stores/settings'; import { Alert } from 'flowbite-svelte'; @@ -12,6 +13,7 @@ >No networks added to settings {:else} + {/if} diff --git a/tauri-app/src/lib/components/dropdown/DropdownCheckbox.svelte b/tauri-app/src/lib/components/dropdown/DropdownCheckbox.svelte new file mode 100644 index 000000000..c39e2be93 --- /dev/null +++ b/tauri-app/src/lib/components/dropdown/DropdownCheckbox.svelte @@ -0,0 +1,85 @@ + + + +
+ + + + {#if isEmpty(options)} +
{emptyMessage}
+ {:else if Object.keys(options).length > 1} + +
{allLabel}
+
+ {/if} + + {#each Object.entries(options) as [key, optionValue]} + toggleItem(key)} + checked={key in value} + > +
+
{key}
+
{optionValue}
+
+
+ {/each} +
+
diff --git a/tauri-app/src/lib/components/dropdown/DropdownOrderListWatchlist.svelte b/tauri-app/src/lib/components/dropdown/DropdownOrderListWatchlist.svelte new file mode 100644 index 000000000..db9b7317f --- /dev/null +++ b/tauri-app/src/lib/components/dropdown/DropdownOrderListWatchlist.svelte @@ -0,0 +1,14 @@ + + + diff --git a/tauri-app/src/lib/components/dropdown/DropdownOrderListWatchlist.test.ts b/tauri-app/src/lib/components/dropdown/DropdownOrderListWatchlist.test.ts new file mode 100644 index 000000000..53d71c4d0 --- /dev/null +++ b/tauri-app/src/lib/components/dropdown/DropdownOrderListWatchlist.test.ts @@ -0,0 +1,79 @@ +import { render, fireEvent, screen, waitFor } from '@testing-library/svelte'; +import { get, writable } from 'svelte/store'; +import DropdownOrderListWatchlist from './DropdownOrderListWatchlist.svelte'; +import { activeWatchlistItems } from '$lib/stores/settings'; +import { expect, test, vi } from 'vitest'; + +vi.mock('$lib/stores/settings', async (importOriginal) => { + const { mockSettingsStore } = await import('$lib/mocks/settings'); + return { + ...((await importOriginal()) as object), + settings: mockSettingsStore, + watchlist: writable({ + address1: 'Label 1', + address2: 'Label 2', + address3: 'Label 3', + }), + activeWatchlist: writable({}), + activeWatchlistItems: writable({}), + }; +}); + +test('renders correctly', () => { + render(DropdownOrderListWatchlist); + expect(screen.getByText('Watchlist')).toBeInTheDocument(); +}); + +test('displays the correct number of options', async () => { + render(DropdownOrderListWatchlist); + + await fireEvent.click(screen.getByTestId('dropdown-checkbox-button')); + + await waitFor(() => { + const options = screen.getAllByTestId('dropdown-checkbox-option'); + expect(options).toHaveLength(4); + }); +}); + +test('updates active watchlist when an option is selected', async () => { + render(DropdownOrderListWatchlist); + + await fireEvent.click(screen.getByTestId('dropdown-checkbox-button')); + await fireEvent.click(screen.getByText('Label 1')); + + await waitFor(() => { + expect(get(activeWatchlistItems)).toEqual({ address1: 'Label 1' }); + }); +}); + +test('selects all items when "All addresses" is clicked', async () => { + render(DropdownOrderListWatchlist); + + await fireEvent.click(screen.getByTestId('dropdown-checkbox-button')); + await fireEvent.click(screen.getByText('All addresses')); + + await waitFor(() => { + expect(get(activeWatchlistItems)).toEqual({ + address1: 'Label 1', + address2: 'Label 2', + address3: 'Label 3', + }); + }); +}); + +test('displays "No watchlist added" when watchlist is empty', async () => { + vi.doUnmock('$lib/stores/settings'); + vi.resetModules(); + + const { default: DropdownOrderListWatchlist } = await import( + './DropdownOrderListWatchlist.svelte' + ); + + render(DropdownOrderListWatchlist); + + await fireEvent.click(screen.getByTestId('dropdown-checkbox-button')); + + await waitFor(() => { + expect(screen.getByText('No watchlist added')).toBeInTheDocument(); + }); +}); diff --git a/tauri-app/src/lib/components/tables/OrdersListTable.svelte b/tauri-app/src/lib/components/tables/OrdersListTable.svelte index d85da54ef..8f2e7e5cf 100644 --- a/tauri-app/src/lib/components/tables/OrdersListTable.svelte +++ b/tauri-app/src/lib/components/tables/OrdersListTable.svelte @@ -21,11 +21,13 @@ import { subgraphUrl } from '$lib/stores/settings'; import { formatTimestampSecondsAsLocal } from '$lib/utils/time'; import { handleOrderRemoveModal } from '$lib/services/modal'; + import { activeWatchlist } from '$lib/stores/settings'; + import { get } from 'svelte/store'; $: query = createInfiniteQuery({ - queryKey: [QKEY_ORDERS], + queryKey: [QKEY_ORDERS, $activeWatchlist], queryFn: ({ pageParam }) => { - return ordersList($subgraphUrl, pageParam); + return ordersList($subgraphUrl, Object.values(get(activeWatchlist)), pageParam); }, initialPageParam: 0, getNextPageParam(lastPage, _allPages, lastPageParam) { diff --git a/tauri-app/src/lib/queries/ordersList.ts b/tauri-app/src/lib/queries/ordersList.ts index 5e011da82..dc86c1aae 100644 --- a/tauri-app/src/lib/queries/ordersList.ts +++ b/tauri-app/src/lib/queries/ordersList.ts @@ -15,6 +15,7 @@ export type OrdersListArgs = { export const ordersList = async ( url: string | undefined, + owners: string[] = [], pageParam: number, pageSize: number = DEFAULT_PAGE_SIZE, ) => { @@ -23,6 +24,9 @@ export const ordersList = async ( } return await invoke('orders_list', { subgraphArgs: { url }, + filterArgs: { + owners, + }, paginationArgs: { page: pageParam + 1, page_size: pageSize }, } as OrdersListArgs); }; @@ -50,10 +54,10 @@ if (import.meta.vitest) { }); // check for a result with no URL - expect(await ordersList(undefined, 0)).toEqual([]); + expect(await ordersList(undefined, [], 0)).toEqual([]); // check for a result with a URL - expect(await ordersList('http://localhost:8000', 0)).toEqual([ + expect(await ordersList('http://localhost:8000', [], 0)).toEqual([ { id: '1', order_bytes: '0x123', diff --git a/tauri-app/src/lib/stores/settings.ts b/tauri-app/src/lib/stores/settings.ts index 5d9b8ace8..51d34dfc9 100644 --- a/tauri-app/src/lib/stores/settings.ts +++ b/tauri-app/src/lib/stores/settings.ts @@ -104,7 +104,30 @@ export const hasRequiredSettings = derived( ); // watchlist -export const watchlist = derived(settings, ($settings) => $settings?.watchlist ?? []); +export const watchlist = derived(settings, ($settings) => $settings?.watchlist ?? {}); + +export const activeWatchlistItems = cachedWritableStore>( + 'settings.activeWatchlistItems', + {}, + JSON.stringify, + (s) => { + try { + return JSON.parse(s); + } catch { + return {}; + } + }, +); + +export const activeWatchlist = derived( + [watchlist, activeWatchlistItems], + ([$watchlist, $activeWatchlistItems]) => + Object.keys($activeWatchlistItems).length === 0 + ? {} + : Object.fromEntries( + Object.entries($watchlist).filter(([key]) => key in $activeWatchlistItems), + ), +); // When networks / orderbooks settings updated, reset active network / orderbook settings.subscribe(async () => {