Skip to content

Commit

Permalink
Merge pull request #348 from dkackman/tx-ux
Browse files Browse the repository at this point in the history
Search debounce
  • Loading branch information
Rigidity authored Mar 7, 2025
2 parents 68c61fc + 64c9572 commit 76c074a
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 148 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ If you add or change queries run this command to generate new sqlx SQL files:
cargo sqlx prepare --workspace
```

Schema changes, including new indices, go into the `migrations` folder as SQL DDL scripts.
Schema changes, including new indices, go into the `migrations` folder as SQL DDL scripts.

### Testing

Expand Down
48 changes: 33 additions & 15 deletions crates/sage-database/src/coin_states.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,23 +432,37 @@ async fn get_block_heights(
let mut query = sqlx::QueryBuilder::new(
"
WITH filtered_coins AS (
SELECT cs.coin_id, cs.kind,
cats.ticker,
cats.name,
created_height as height
SELECT cs.coin_id,
cs.kind,
cats.ticker,
cats.name as cat_name,
dids.name as did_name,
nfts.name as nft_name,
cs.created_height as height
FROM coin_states cs
LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id
LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id
WHERE created_height IS NOT NULL
LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id
LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id
LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id
LEFT JOIN dids ON did_coins.coin_id = dids.coin_id
LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id
LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id
WHERE cs.created_height IS NOT NULL
UNION ALL
SELECT cs.coin_id, cs.kind,
cats.ticker,
cats.name,
spent_height as height
SELECT cs.coin_id,
cs.kind,
cats.ticker,
cats.name as cat_name,
dids.name as did_name,
nfts.name as nft_name,
cs.spent_height as height
FROM coin_states cs
LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id
LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id
WHERE spent_height IS NOT NULL
LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id
LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id
LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id
LEFT JOIN dids ON did_coins.coin_id = dids.coin_id
LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id
LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id
WHERE cs.spent_height IS NOT NULL
),
filtered_heights AS (
SELECT DISTINCT height
Expand Down Expand Up @@ -476,7 +490,11 @@ async fn get_block_heights(
query
.push("ticker LIKE ")
.push_bind(format!("%{}%", value))
.push(" OR name LIKE ")
.push(" OR cat_name LIKE ")
.push_bind(format!("%{}%", value))
.push(" OR did_name LIKE ")
.push_bind(format!("%{}%", value))
.push(" OR nft_name LIKE ")
.push_bind(format!("%{}%", value))
.push(")");
}
Expand Down
181 changes: 69 additions & 112 deletions crates/sage-database/src/primitives/nfts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -835,137 +835,94 @@ async fn nfts_by_metadata_hash(
.collect()
}

fn escape_fts_query(query: &str) -> String {
// First escape backslashes by doubling them
// Then escape quotes by doubling them
// Finally wrap in quotes to treat as literal string
let escaped = query.replace('\\', "\\\\").replace('"', "\"\"");
format!("\"{escaped}\"")
}

async fn search_nfts(
conn: impl SqliteExecutor<'_>,
params: NftSearchParams,
limit: u32,
offset: u32,
) -> Result<(Vec<NftRow>, u32)> {
let mut conditions = vec!["is_owned = 1"];

// Group filtering (Collection/DID)
match params.group {
Some(NftGroup::Collection(_)) => conditions.push("collection_id = ?"),
Some(NftGroup::NoCollection) => conditions.push("collection_id IS NULL"),
Some(NftGroup::MinterDid(_)) => conditions.push("minter_did = ?"),
Some(NftGroup::NoMinterDid) => conditions.push("minter_did IS NULL"),
Some(NftGroup::OwnerDid(_)) => conditions.push("owner_did = ?"),
Some(NftGroup::NoOwnerDid) => conditions.push("owner_did IS NULL"),
None => {}
}
let mut query = sqlx::QueryBuilder::new(
"SELECT launcher_id,
coin_id,
collection_id,
minter_did,
owner_did,
visible,
sensitive_content,
name,
is_owned,
created_height,
metadata_hash,
is_named,
is_pending,
COUNT(*) OVER() as total_count
FROM nfts
WHERE 1=1
AND is_owned = 1
",
);

// Visibility condition
// Add visibility condition if not including hidden NFTs
if !params.include_hidden {
conditions.push("visible = 1");
query.push(" AND visible = 1");
}

// Build base conditions
let where_clause = conditions.join(" AND ");

// Common parts
let order_by = format!(
r"ORDER BY {visible_order}
is_pending DESC,
{sort_order},
launcher_id ASC
LIMIT ? OFFSET ?",
visible_order = if params.include_hidden {
"visible DESC,"
} else {
""
},
sort_order = match params.sort_mode {
NftSortMode::Recent => "created_height DESC",
NftSortMode::Name => "is_named DESC, name ASC",
// Add group filtering (Collection/DID)
if let Some(group) = &params.group {
match group {
NftGroup::Collection(id) => {
query.push(" AND collection_id = ");
query.push_bind(id.as_ref());
}
NftGroup::NoCollection => {
query.push(" AND collection_id IS NULL");
}
NftGroup::MinterDid(id) => {
query.push(" AND minter_did = ");
query.push_bind(id.as_ref());
}
NftGroup::NoMinterDid => {
query.push(" AND minter_did IS NULL");
}
NftGroup::OwnerDid(id) => {
query.push(" AND owner_did = ");
query.push_bind(id.as_ref());
}
NftGroup::NoOwnerDid => {
query.push(" AND owner_did IS NULL");
}
}
);
}

// Choose index based on sort mode and group type
let index = match (params.sort_mode, &params.group) {
// Collection grouping
(NftSortMode::Name, Some(NftGroup::Collection(_) | NftGroup::NoCollection)) => {
"nft_col_name"
}
(NftSortMode::Recent, Some(NftGroup::Collection(_) | NftGroup::NoCollection)) => {
"nft_col_recent"
}
// Add name search if present
if let Some(name_search) = &params.name {
query.push(" AND name LIKE ");
query.push_bind(format!("%{}%", name_search));
}

// Minter DID grouping
(NftSortMode::Name, Some(NftGroup::MinterDid(_) | NftGroup::NoMinterDid)) => {
"nft_minter_did_name"
}
(NftSortMode::Recent, Some(NftGroup::MinterDid(_) | NftGroup::NoMinterDid)) => {
"nft_minter_did_recent"
}
// Add ORDER BY clause based on sort_mode
query.push(" ORDER BY ");

// Add visible DESC to sort order if including hidden NFTs
if params.include_hidden {
query.push("visible DESC, ");
}

// Owner DID grouping
(NftSortMode::Name, Some(NftGroup::OwnerDid(_) | NftGroup::NoOwnerDid)) => {
"nft_owner_did_name"
match params.sort_mode {
NftSortMode::Recent => {
query.push("is_pending DESC, created_height DESC, launcher_id ASC");
}
(NftSortMode::Recent, Some(NftGroup::OwnerDid(_) | NftGroup::NoOwnerDid)) => {
"nft_owner_did_recent"
NftSortMode::Name => {
query.push("is_pending DESC, is_named DESC, name ASC, launcher_id ASC");
}

// Global sorting
(NftSortMode::Name, None) => "nft_name",
(NftSortMode::Recent, None) => "nft_recent",
};

// Construct query based on whether we're doing a name search
let query = if params.name.is_some() {
format!(
r"
WITH matched_names AS (
SELECT launcher_id
FROM nft_name_fts
WHERE name MATCH ? || '*'
ORDER BY rank
)
SELECT nfts.*, COUNT(*) OVER() as total_count
FROM nfts INDEXED BY {index}
INNER JOIN matched_names ON nfts.launcher_id = matched_names.launcher_id
WHERE {where_clause}
{order_by}
"
)
} else {
format!(
r"
SELECT *, COUNT(*) OVER() as total_count
FROM nfts INDEXED BY {index}
WHERE {where_clause}
{order_by}
"
)
};

// Execute query with bindings
let mut query = sqlx::query_as::<_, NftSearchRow>(&query);

// Bind name search if present
if let Some(name_search) = params.name {
query = query.bind(escape_fts_query(&name_search));
}

// Bind group parameters if present
//if let Some(NftGroup::Collection(id) | NftGroup::MinterDid(id)) = &params.group {
if let Some(NftGroup::Collection(id) | NftGroup::MinterDid(id) | NftGroup::OwnerDid(id)) =
&params.group
{
query = query.bind(id.as_ref());
}
query.push(" LIMIT ? OFFSET ?");

let query = query.build_query_as::<NftSearchRow>();

// Limit and offset
query = query.bind(limit);
query = query.bind(offset);
// Bind limit and offset
let query = query.bind(limit).bind(offset);

let rows = query.fetch_all(conn).await?;
let total_count = rows
Expand Down
40 changes: 34 additions & 6 deletions src/components/NftOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ import {
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { Input } from './ui/input';
import { Pagination } from './Pagination';
import { CardSizeToggle } from './CardSizeToggle';
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useDebounce } from '@/hooks/useDebounce';

export interface NftOptionsProps {
isCollection?: boolean;
Expand Down Expand Up @@ -69,6 +69,34 @@ export function NftOptions({
const navigate = useNavigate();
const isFilteredView = Boolean(collection_id || owner_did || minter_did);
const allowSearch = group === NftGroupMode.None || isFilteredView;
const [searchValue, setSearchValue] = useState(query ?? '');
const debouncedSearch = useDebounce(searchValue, 400);
const prevSearchRef = useRef(query);

useEffect(() => {
setSearchValue(query ?? '');
}, [query]);

useEffect(() => {
if (debouncedSearch !== query) {
const shouldResetPage = prevSearchRef.current !== debouncedSearch;
prevSearchRef.current = debouncedSearch;

setParams({
query: debouncedSearch || null,
...(shouldResetPage && { page: 1 }),
});
}
}, [debouncedSearch, query, setParams]);

const handleInputChange = useCallback((value: string) => {
setSearchValue(value);
}, []);

const handleClearSearch = useCallback(() => {
setSearchValue('');
}, []);

const handleBack = () => {
if (collection_id) {
setParams({ group: NftGroupMode.Collection, page: 1 });
Expand Down Expand Up @@ -107,24 +135,24 @@ export function NftOptions({
aria-hidden='true'
/>
<Input
value={query ?? ''}
value={searchValue}
aria-label={t`Search NFTs...`}
title={t`Search NFTs...`}
placeholder={t`Search NFTs...`}
onChange={(e) => setParams({ query: e.target.value, page: 1 })}
onChange={(e) => handleInputChange(e.target.value)}
className='w-full pl-8 pr-8'
disabled={!allowSearch}
aria-disabled={!allowSearch}
/>
</div>
{query && (
{searchValue && (
<Button
variant='ghost'
size='icon'
title={t`Clear search`}
aria-label={t`Clear search`}
className='absolute right-0 top-0 h-full px-2 hover:bg-transparent'
onClick={() => setParams({ query: '', page: 1 })}
onClick={handleClearSearch}
disabled={!allowSearch}
>
<XIcon className='h-4 w-4' aria-hidden='true' />
Expand Down
Loading

0 comments on commit 76c074a

Please sign in to comment.