Skip to content

Commit e00f7ca

Browse files
authored
feat(adapter-stellar): add comprehensive history filtering (changeType, txId, timestamp, ledger) (#268)
* feat(adapter-stellar): add server-side changeType filtering to getHistory Adds support for filtering history events by change type (GRANTED, REVOKED, TRANSFERRED) at the server level via GraphQL. This enables proper filtering across paginated results, fixing the limitation where client-side filtering only worked on the current page. Changes: - Add HistoryChangeType to types package - Add changeType parameter to HistoryQueryOptions and IndexerHistoryOptions - Update buildHistoryQuery() to include type filter in GraphQL query - Add integration tests for changeType filtering - Fix unit tests with missing pageInfo in mock responses * fix: address PR review - add TRANSFERRED support to HistoryEntry - Add 'TRANSFERRED' to HistoryEntry.changeType union type - Update transformIndexerEntries to map OWNERSHIP_TRANSFER_COMPLETED to 'TRANSFERRED' - Add integration test for TRANSFERRED changeType filtering - Update unit test expectation for ownership transfer mapping - Refactor: use HistoryChangeType type alias consistently (DRY) * feat(adapter-stellar): add txId, timestamp, and ledger filters to getHistory Extends the history query API with additional server-side filtering options: - txId: filter by exact transaction ID match - timestampFrom/timestampTo: filter by timestamp range - ledger: filter by exact block/ledger number Key implementation details: - Uses BigFloat for blockHeight GraphQL variable type - Uses Datetime for timestamp GraphQL variable type - Timestamps should be passed without timezone suffix (e.g., '2025-12-05T10:34:00') All filters can be combined with existing roleId, account, and changeType filters. * refactor(adapter-stellar): remove duplicate IndexerHistoryOptions interface - Use HistoryQueryOptions from types package instead of local duplicate - Fix comment to correctly state BigFloat for blockHeight filtering * feat(ui): add date range picker components - Add Calendar component using react-day-picker with shadcn styling - Add Popover component using Radix UI primitives - Add DateRangePicker component combining calendar and popover - Add react-day-picker and date-fns dependencies * fix: address PR review comments - Update timestamp format docs to use generic 'YYYY-MM-DDTHH:mm:ss' format - Add assertions to verify timestamps are within bounds in tests
1 parent 28959e0 commit e00f7ca

File tree

9 files changed

+474
-992
lines changed

9 files changed

+474
-992
lines changed

packages/adapter-stellar/src/access-control/indexer-client.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import type {
99
HistoryChangeType,
1010
HistoryEntry,
11+
HistoryQueryOptions,
1112
IndexerEndpointConfig,
1213
PageInfo,
1314
PaginatedHistoryResult,
@@ -81,19 +82,6 @@ export interface GrantInfo {
8182
ledger: number;
8283
}
8384

84-
/**
85-
* Options for querying history with pagination
86-
*/
87-
export interface IndexerHistoryOptions {
88-
roleId?: string;
89-
account?: string;
90-
/** Filter by change type (grant, revoke, or ownership transfer) */
91-
changeType?: HistoryChangeType;
92-
limit?: number;
93-
/** Cursor for fetching the next page */
94-
cursor?: string;
95-
}
96-
9785
/**
9886
* Stellar Indexer Client
9987
* Handles GraphQL queries to the configured indexer for historical access control data
@@ -170,7 +158,7 @@ export class StellarIndexerClient {
170158
*/
171159
async queryHistory(
172160
contractAddress: string,
173-
options?: IndexerHistoryOptions
161+
options?: HistoryQueryOptions
174162
): Promise<PaginatedHistoryResult> {
175163
const isAvailable = await this.checkAvailability();
176164
if (!isAvailable) {
@@ -539,21 +527,38 @@ export class StellarIndexerClient {
539527
/**
540528
* Build GraphQL query for history with SubQuery filtering and pagination
541529
*/
542-
private buildHistoryQuery(_contractAddress: string, options?: IndexerHistoryOptions): string {
530+
private buildHistoryQuery(_contractAddress: string, options?: HistoryQueryOptions): string {
543531
const roleFilter = options?.roleId ? ', role: { equalTo: $role }' : '';
544532
const accountFilter = options?.account ? ', account: { equalTo: $account }' : '';
545533
// Type filter uses inline enum value (consistent with buildLatestGrantsQuery pattern)
546534
const typeFilter = options?.changeType
547535
? `, type: { equalTo: ${this.mapChangeTypeToGraphQLEnum(options.changeType)} }`
548536
: '';
537+
const txFilter = options?.txId ? ', txHash: { equalTo: $txHash }' : '';
538+
// Build combined timestamp filter to avoid duplicate keys
539+
const timestampConditions: string[] = [];
540+
if (options?.timestampFrom) {
541+
timestampConditions.push('greaterThanOrEqualTo: $timestampFrom');
542+
}
543+
if (options?.timestampTo) {
544+
timestampConditions.push('lessThanOrEqualTo: $timestampTo');
545+
}
546+
const timestampFilter =
547+
timestampConditions.length > 0 ? `, timestamp: { ${timestampConditions.join(', ')} }` : '';
548+
const ledgerFilter = options?.ledger ? ', blockHeight: { equalTo: $blockHeight }' : '';
549549
const limitClause = options?.limit ? ', first: $limit' : '';
550550
const cursorClause = options?.cursor ? ', after: $cursor' : '';
551551

552552
// Build variable declarations
553+
// Note: SubQuery uses Datetime for timestamp filters and BigFloat for blockHeight filtering
553554
const varDeclarations = [
554555
'$contract: String!',
555556
options?.roleId ? '$role: String' : '',
556557
options?.account ? '$account: String' : '',
558+
options?.txId ? '$txHash: String' : '',
559+
options?.timestampFrom ? '$timestampFrom: Datetime' : '',
560+
options?.timestampTo ? '$timestampTo: Datetime' : '',
561+
options?.ledger ? '$blockHeight: BigFloat' : '',
557562
options?.limit ? '$limit: Int' : '',
558563
options?.cursor ? '$cursor: Cursor' : '',
559564
]
@@ -564,7 +569,7 @@ export class StellarIndexerClient {
564569
query GetHistory(${varDeclarations}) {
565570
accessControlEvents(
566571
filter: {
567-
contract: { equalTo: $contract }${roleFilter}${accountFilter}${typeFilter}
572+
contract: { equalTo: $contract }${roleFilter}${accountFilter}${typeFilter}${txFilter}${timestampFilter}${ledgerFilter}
568573
}
569574
orderBy: TIMESTAMP_DESC${limitClause}${cursorClause}
570575
) {
@@ -591,7 +596,7 @@ export class StellarIndexerClient {
591596
*/
592597
private buildQueryVariables(
593598
contractAddress: string,
594-
options?: IndexerHistoryOptions
599+
options?: HistoryQueryOptions
595600
): Record<string, unknown> {
596601
const variables: Record<string, unknown> = {
597602
contract: contractAddress,
@@ -603,6 +608,19 @@ export class StellarIndexerClient {
603608
if (options?.account) {
604609
variables.account = options.account;
605610
}
611+
if (options?.txId) {
612+
variables.txHash = options.txId;
613+
}
614+
if (options?.timestampFrom) {
615+
variables.timestampFrom = options.timestampFrom;
616+
}
617+
if (options?.timestampTo) {
618+
variables.timestampTo = options.timestampTo;
619+
}
620+
if (options?.ledger) {
621+
// GraphQL expects blockHeight as string
622+
variables.blockHeight = String(options.ledger);
623+
}
606624
if (options?.limit) {
607625
variables.limit = options.limit;
608626
}

packages/adapter-stellar/test/access-control/indexer-integration.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,126 @@ describe('StellarIndexerClient - Integration Test with Real Indexer', () => {
672672
` ✅ Combined filter: ${combinedResult.items.length} GRANTED events for account '${targetAccount.slice(0, 10)}...'`
673673
);
674674
}, 15000);
675+
676+
it('should filter history by txId (server-side)', async () => {
677+
if (!indexerAvailable) {
678+
return; // Skip test if indexer is not available
679+
}
680+
681+
// First get all history to find a valid txId
682+
const allResult = await client.queryHistory(TEST_CONTRACT, { limit: 10 });
683+
684+
if (allResult.items.length === 0) {
685+
console.log(' ⏭️ No events found, skipping txId filter test');
686+
return;
687+
}
688+
689+
const targetTxId = allResult.items[0].txId;
690+
691+
// Query with txId filter
692+
const filteredResult = await client.queryHistory(TEST_CONTRACT, {
693+
txId: targetTxId,
694+
});
695+
696+
expect(filteredResult.items.length).toBeGreaterThan(0);
697+
698+
// Verify ALL returned entries have the matching txId
699+
for (const entry of filteredResult.items) {
700+
expect(entry.txId).toBe(targetTxId);
701+
}
702+
703+
console.log(
704+
` ✅ Filtered ${filteredResult.items.length} event(s) by txId '${targetTxId.slice(0, 16)}...'`
705+
);
706+
}, 15000);
707+
708+
it('should filter history by ledger/blockHeight (server-side)', async () => {
709+
if (!indexerAvailable) {
710+
return; // Skip test if indexer is not available
711+
}
712+
713+
// First get all history to find a valid ledger number
714+
const allResult = await client.queryHistory(TEST_CONTRACT, { limit: 10 });
715+
716+
if (allResult.items.length === 0) {
717+
console.log(' ⏭️ No events found, skipping ledger filter test');
718+
return;
719+
}
720+
721+
const targetLedger = allResult.items[0].ledger!;
722+
723+
// Query with ledger filter
724+
const filteredResult = await client.queryHistory(TEST_CONTRACT, {
725+
ledger: targetLedger,
726+
});
727+
728+
expect(filteredResult.items.length).toBeGreaterThan(0);
729+
730+
// Verify ALL returned entries have the matching ledger
731+
for (const entry of filteredResult.items) {
732+
expect(entry.ledger).toBe(targetLedger);
733+
}
734+
735+
console.log(
736+
` ✅ Filtered ${filteredResult.items.length} event(s) at ledger ${targetLedger}`
737+
);
738+
}, 15000);
739+
740+
it('should filter history by timestamp range (server-side)', async () => {
741+
if (!indexerAvailable) {
742+
return; // Skip test if indexer is not available
743+
}
744+
745+
// Use known timestamp range - indexer stores times without timezone
746+
// Events are around 2025-12-05T10:34:xx (indexer time, no Z suffix)
747+
const timestampFrom = '2025-12-05T10:34:00';
748+
const timestampTo = '2025-12-05T10:35:00';
749+
750+
const filteredResult = await client.queryHistory(TEST_CONTRACT, {
751+
timestampFrom,
752+
timestampTo,
753+
limit: 20,
754+
});
755+
756+
expect(filteredResult.items.length).toBeGreaterThan(0);
757+
758+
// Verify all returned events have timestamps within the specified range
759+
for (const item of filteredResult.items) {
760+
if (item.timestamp) {
761+
expect(item.timestamp >= timestampFrom).toBe(true);
762+
expect(item.timestamp <= timestampTo).toBe(true);
763+
}
764+
}
765+
766+
console.log(
767+
` ✅ Filtered ${filteredResult.items.length} event(s) in timestamp range (${timestampFrom} to ${timestampTo})`
768+
);
769+
}, 15000);
770+
771+
it('should filter with timestampFrom only (events after date)', async () => {
772+
if (!indexerAvailable) {
773+
return; // Skip test if indexer is not available
774+
}
775+
776+
// Use known timestamp - indexer stores times without timezone
777+
const timestampFrom = '2025-12-05T10:34:20';
778+
779+
const filteredResult = await client.queryHistory(TEST_CONTRACT, {
780+
timestampFrom,
781+
limit: 20,
782+
});
783+
784+
expect(filteredResult.items.length).toBeGreaterThan(0);
785+
786+
// Verify all returned events have timestamps on or after timestampFrom
787+
for (const item of filteredResult.items) {
788+
if (item.timestamp) {
789+
expect(item.timestamp >= timestampFrom).toBe(true);
790+
}
791+
}
792+
793+
console.log(` ✅ Filtered ${filteredResult.items.length} event(s) from ${timestampFrom}`);
794+
}, 15000);
675795
});
676796

677797
describe('History Query - Known Event Verification', () => {

packages/types/src/adapters/access-control.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ export interface HistoryQueryOptions {
143143
account?: string;
144144
/** Filter by change type (grant, revoke, or ownership transfer) */
145145
changeType?: HistoryChangeType;
146+
/** Filter by transaction ID (exact match) */
147+
txId?: string;
148+
/** Filter by timestamp - return events on or after this time (format: 'YYYY-MM-DDTHH:mm:ss', no timezone) */
149+
timestampFrom?: string;
150+
/** Filter by timestamp - return events on or before this time (format: 'YYYY-MM-DDTHH:mm:ss', no timezone) */
151+
timestampTo?: string;
152+
/** Filter by ledger/block number (exact match) */
153+
ledger?: number;
146154
/** Maximum number of items to return (page size) */
147155
limit?: number;
148156
/** Cursor for fetching the next page */

packages/ui/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@radix-ui/react-dialog": "^1.1.14",
5858
"@radix-ui/react-dropdown-menu": "^2.1.15",
5959
"@radix-ui/react-label": "^2.1.7",
60+
"@radix-ui/react-popover": "^1.1.15",
6061
"@radix-ui/react-progress": "^1.1.7",
6162
"@radix-ui/react-radio-group": "^1.3.7",
6263
"@radix-ui/react-select": "^2.2.5",
@@ -68,9 +69,11 @@
6869
"@web3icons/react": "^4.0.26",
6970
"class-variance-authority": "^0.7.1",
7071
"clsx": "^2.1.1",
72+
"date-fns": "^4.1.0",
7173
"lodash": "^4.17.21",
7274
"lucide-react": "^0.510.0",
7375
"next-themes": "^0.4.6",
76+
"react-day-picker": "^9.12.0",
7477
"react-hook-form": "^7.62.0",
7578
"sonner": "^2.0.7",
7679
"tailwind-merge": "^3.3.1",
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use client';
2+
3+
import { ChevronLeft, ChevronRight } from 'lucide-react';
4+
import * as React from 'react';
5+
import { DayPicker } from 'react-day-picker';
6+
7+
import { cn } from '@openzeppelin/ui-builder-utils';
8+
9+
import { buttonVariants } from '../../utils/button-variants';
10+
11+
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
12+
13+
/**
14+
* Calendar component built on react-day-picker with shadcn/ui styling.
15+
* Supports single date, multiple dates, and date range selection modes.
16+
*/
17+
function Calendar({
18+
className,
19+
classNames,
20+
showOutsideDays = true,
21+
...props
22+
}: CalendarProps): React.ReactElement {
23+
return (
24+
<DayPicker
25+
showOutsideDays={showOutsideDays}
26+
className={cn('p-3', className)}
27+
classNames={{
28+
months: 'flex flex-col sm:flex-row gap-2 px-5',
29+
month: 'flex flex-col gap-4',
30+
month_caption: 'flex justify-center pt-1 relative items-center w-full',
31+
caption_label: 'text-sm font-medium',
32+
nav: 'flex items-center gap-1',
33+
button_previous: cn(
34+
buttonVariants({ variant: 'outline' }),
35+
'absolute left-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
36+
),
37+
button_next: cn(
38+
buttonVariants({ variant: 'outline' }),
39+
'absolute right-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
40+
),
41+
month_grid: 'w-full border-collapse space-y-1',
42+
weekdays: 'flex',
43+
weekday: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
44+
week: 'flex w-full mt-2',
45+
day: cn(
46+
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
47+
props.mode === 'range'
48+
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
49+
: '[&:has([aria-selected])]:rounded-md'
50+
),
51+
day_button: cn(
52+
buttonVariants({ variant: 'ghost' }),
53+
'h-9 w-9 p-0 font-normal aria-selected:opacity-100 rounded-none hover:rounded-none'
54+
),
55+
range_start: 'day-range-start rounded-l-md',
56+
range_end: 'day-range-end rounded-r-md',
57+
selected:
58+
'bg-zinc-200 dark:bg-zinc-700 text-foreground hover:bg-zinc-200 dark:hover:bg-zinc-700 hover:text-foreground focus:bg-zinc-200 dark:focus:bg-zinc-700 focus:text-foreground',
59+
today: 'bg-accent text-accent-foreground',
60+
outside:
61+
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
62+
disabled: 'text-muted-foreground opacity-50',
63+
range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
64+
hidden: 'invisible',
65+
...classNames,
66+
}}
67+
components={{
68+
Chevron: ({ orientation }) => {
69+
const Icon = orientation === 'left' ? ChevronLeft : ChevronRight;
70+
return <Icon className="h-4 w-4" />;
71+
},
72+
}}
73+
{...props}
74+
/>
75+
);
76+
}
77+
Calendar.displayName = 'Calendar';
78+
79+
export { Calendar };

0 commit comments

Comments
 (0)