moonstream/frontend/src/components/EntriesNavigation.js

621 wiersze
20 KiB
JavaScript

import React, {
useRef,
useEffect,
useContext,
useState,
useCallback,
} from "react";
import {
Flex,
Spinner,
Button,
Center,
Text,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuGroup,
IconButton,
Input,
Select,
Drawer,
DrawerBody,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
useDisclosure,
Tag,
TagLabel,
TagCloseButton,
Stack,
Spacer,
useBoolean,
} from "@chakra-ui/react";
import { useSubscriptions } from "../core/hooks";
import StreamEntry from "./StreamEntry";
import UIContext from "../core/providers/UIProvider/context";
import { FaFilter } from "react-icons/fa";
import useStream from "../core/hooks/useStream";
import { ImCancelCircle } from "react-icons/im";
import { IoStopCircleOutline, IoPlayCircleOutline } from "react-icons/io5";
const pageSize = 25;
const FILTER_TYPES = {
ADDRESS: 0,
GAS: 1,
GAS_PRICE: 2,
AMMOUNT: 3,
HASH: 4,
DISABLED: 99,
};
const DIRECTIONS = { SOURCE: "from", DESTINATION: "to" };
const CONDITION = {
EQUAL: 0,
CONTAINS: 1,
LESS: 2,
LESS_EQUAL: 3,
GREATER: 4,
GREATER_EQUAL: 5,
NOT_EQUAL: 6,
};
const EntriesNavigation = () => {
const ui = useContext(UIContext);
const [isStreamOn, setStreamState] = useBoolean(true);
const { isOpen, onOpen, onClose } = useDisclosure();
const { subscriptionsCache } = useSubscriptions();
const [newFilterState, setNewFilterState] = useState([
{
type: FILTER_TYPES.ADDRESS,
direction: DIRECTIONS.SOURCE,
condition: CONDITION.EQUAL,
value: null,
},
]);
const [filterState, setFilterState] = useState([]);
const loadMoreButtonRef = useRef(null);
const [streamBoundary, setStreamBoundary] = useState({
start_time: null,
end_time: null,
include_start: false,
include_end: true,
next_event_time: null,
previous_event_time: null,
});
const updateStreamBoundaryWith = (pageBoundary) => {
if (!pageBoundary) {
return streamBoundary;
}
let newBoundary = { ...streamBoundary };
// We do not check if there is no overlap between the streamBoundary and the pageBoundary - we assume
// that there *is* an overlap and even if there isn't the stream should gracefully respect the
// pageBoundary because that was the most recent request the user made.
// TODO(zomglings): If there is no overlap in boundaries, replace streamBoundary with pageBoundary.
// No overlap logic:
// if (<no overlap>) {
// setStreamBoundary(pageBoundary)
// return pageBoundary
// }
if (
!newBoundary.start_time ||
(pageBoundary.start_time &&
pageBoundary.start_time <= newBoundary.start_time)
) {
newBoundary.start_time = pageBoundary.start_time;
newBoundary.include_start =
newBoundary.include_start || pageBoundary.include_start;
}
newBoundary.include_start =
newBoundary.include_start || pageBoundary.include_start;
if (
!newBoundary.end_time ||
(pageBoundary.end_time && pageBoundary.end_time >= newBoundary.end_time)
) {
newBoundary.end_time = pageBoundary.end_time;
newBoundary.include_end =
newBoundary.include_end || pageBoundary.include_end;
}
newBoundary.include_end =
newBoundary.include_end || pageBoundary.include_end;
if (
!newBoundary.next_event_time ||
!pageBoundary.next_event_time ||
(pageBoundary.next_event_time &&
pageBoundary.next_event_time > newBoundary.next_event_time)
) {
newBoundary.next_event_time = pageBoundary.next_event_time;
}
if (
!newBoundary.previous_event_time ||
!pageBoundary.previous_event_time ||
(pageBoundary.previous_event_time &&
pageBoundary.previous_event_time < newBoundary.previous_event_time)
) {
newBoundary.previous_event_time = pageBoundary.previous_event_time;
}
setStreamBoundary(newBoundary);
return newBoundary;
};
const { EntriesPages, isLoading, refetch, isFetching, remove } = useStream({
refreshRate: 1500,
searchQuery: ui.searchTerm,
start_time: streamBoundary.start_time,
end_time: streamBoundary.end_time,
include_start: streamBoundary.include_start,
include_end: streamBoundary.include_end,
enabled: isStreamOn,
updateStreamBoundaryWith: updateStreamBoundaryWith,
streamBoundary: streamBoundary,
setStreamBoundary: setStreamBoundary,
isContent: false,
});
// const handleScroll = ({ currentTarget }) => {
// if (
// currentTarget.scrollTop + currentTarget.clientHeight >=
// 0.5 * currentTarget.scrollHeight
// ) {
// if (!isLoading && hasPreviousPage) {
// fetchPreviousPage();
// }
// }
// };
useEffect(() => {
if (!streamBoundary.start_time && !streamBoundary.end_time) {
refetch();
}
}, [streamBoundary]);
const setFilterProps = useCallback(
(filterIdx, props) => {
const newFilterProps = [...newFilterState];
newFilterProps[filterIdx] = { ...newFilterProps[filterIdx], ...props };
setNewFilterState(newFilterProps);
},
[newFilterState, setNewFilterState]
);
useEffect(() => {
if (
subscriptionsCache.data?.subscriptions[0]?.id &&
newFilterState[0].value === null
) {
setFilterProps(0, {
value: subscriptionsCache?.data?.subscriptions[0]?.address,
});
}
}, [subscriptionsCache, newFilterState, setFilterProps]);
const entriesPagesData = EntriesPages
? EntriesPages.data.map((page) => {
return page;
})
: [""];
const entries = entriesPagesData.flat();
const canCreate = false;
const canDelete = false;
const dropNewFilterArrayItem = (idx) => {
const oldArray = [...newFilterState];
const newArray = oldArray.filter(function (ele) {
return ele != oldArray[idx];
});
setNewFilterState(newArray);
};
const dropFilterArrayItem = (idx) => {
console.log("dropFilterArrayItem", idx, filterState);
const oldArray = [...filterState];
const newArray = oldArray.filter(function (ele) {
return ele != oldArray[idx];
});
setFilterState(newArray);
setNewFilterState(newArray);
ui.setSearchTerm(
newArray
.map((filter) => {
return filter.direction + ":" + filter.value;
})
.join("+")
);
};
const handleFilterSubmit = () => {
setFilterState(newFilterState);
ui.setSearchTerm(
newFilterState
.map((filter) => {
return filter.direction + ":" + filter.value;
})
.join("+")
);
onClose();
};
const handleAddressChange = (idx) => (e) => {
setFilterProps(idx, { value: e.target.value });
};
const handleConditionChange = (idx) => (e) => {
setFilterProps(idx, { condition: parseInt(e.target.value) });
};
const handleFilterStateCallback = (props) => {
console.log("handleFilterStateCallback", props);
const currentFilterState = [...filterState];
currentFilterState.push({ ...props });
ui.setSearchTerm(
currentFilterState
.map((filter) => {
return filter.direction + ":" + filter.value;
})
.join("+")
);
setFilterState(currentFilterState);
};
if (subscriptionsCache.isLoading) return "";
return (
<Flex
id="JournalNavigation"
height="100%"
maxH="100%"
overflow="hidden"
direction="column"
flexGrow={1}
>
{entries && !isLoading ? (
<>
<Drawer onClose={onClose} isOpen={isOpen} size="lg">
<DrawerOverlay />
<DrawerContent bgColor="gray.100">
<DrawerCloseButton />
<DrawerHeader>{`Filter results`}</DrawerHeader>
<DrawerBody>
<Text pt={2} fontWeight="600">
Source:
</Text>
{newFilterState.map((filter, idx) => {
if (filter.type === FILTER_TYPES.DISABLED) return "";
return (
<Flex
key={`subscription-filter-item-${idx}`}
direction="column"
>
<Flex
mt={4}
direction="row"
flexWrap="nowrap"
placeItems="center"
bgColor="gray.300"
borderRadius="md"
>
{filter.type === FILTER_TYPES.ADDRESS && (
<>
<Flex w="120px" placeContent="center">
{filter.direction === DIRECTIONS.SOURCE
? `From:`
: `To:`}
</Flex>
<Select
pr={2}
w="180px"
onChange={handleConditionChange(idx)}
>
<option value={CONDITION.EQUAL}>Is</option>
<option value={CONDITION.NOT_EQUAL}>
Is not
</option>
</Select>
{filter.direction === DIRECTIONS.SOURCE && (
<Select
variant="solid"
colorScheme="primary"
name="address"
onChange={handleAddressChange(idx)}
>
{!subscriptionsCache.isLoading &&
subscriptionsCache?.data?.subscriptions.map(
(subscription, idx) => {
return (
<option
value={subscription.address}
key={`subscription-filter-item-${idx}`}
>
{`${
subscription.label
} - ${subscription.address.slice(
0,
5
)}...${subscription.address.slice(
-3
)}`}
</option>
);
}
)}
</Select>
)}
{filter.direction === DIRECTIONS.DESTINATION && (
<Input
type="text"
onChange={(e) =>
setFilterProps(idx, {
value: e.target.value,
})
}
placeholder="Type in address"
/>
)}
</>
)}
<IconButton
placeItems="center"
colorScheme="primary"
variant="ghost"
onClick={() => dropNewFilterArrayItem(idx)}
icon={<ImCancelCircle />}
/>
</Flex>
</Flex>
);
})}
<Menu>
<MenuButton
as={Button}
mt={4}
colorScheme="secondary"
variant="solid"
>
Add filter row
</MenuButton>
<MenuList>
<MenuGroup title="source"></MenuGroup>
<MenuItem
onClick={() =>
setNewFilterState([
...newFilterState,
{
type: FILTER_TYPES.ADDRESS,
direction: DIRECTIONS.SOURCE,
condition: CONDITION.EQUAL,
value:
subscriptionsCache?.data?.subscriptions[0]
?.address,
},
])
}
>
Source
</MenuItem>
<MenuItem
onClick={() =>
setNewFilterState([
...newFilterState,
{
type: FILTER_TYPES.ADDRESS,
direction: DIRECTIONS.DESTINATION,
condition: CONDITION.EQUAL,
value:
subscriptionsCache?.data?.subscriptions[0]
?.address,
},
])
}
>
Destination
</MenuItem>
</MenuList>
</Menu>
</DrawerBody>
<DrawerFooter pb={16} placeContent="center">
<Button
colorScheme="suggested"
variant="solid"
// type="submit"
onClick={() => handleFilterSubmit()}
>
Apply selected filters
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Flex h="3rem" w="100%" bgColor="gray.100" alignItems="center">
<Flex maxW="90%">
<Flex direction="column">
<IconButton
size="sm"
onClick={() => setStreamState.toggle()}
icon={
isStreamOn ? (
<IoStopCircleOutline size="32px" />
) : (
<IoPlayCircleOutline size="32px" />
)
}
colorScheme={isStreamOn ? "unsafe" : "suggested"}
/>
</Flex>
{filterState.map((filter, idx) => {
if (filter.type === FILTER_TYPES.DISABLED) return "";
return (
<Tag
key={`filter-badge-display-${idx}`}
mx={1}
size="lg"
variant="solid"
colorScheme="secondary"
>
{filter?.type === FILTER_TYPES.ADDRESS && (
<TagLabel>
{filter.condition === CONDITION.NOT_EQUAL && "Not "}
{filter.direction === DIRECTIONS.SOURCE
? "From: "
: "To: "}
{subscriptionsCache?.data?.subscriptions.find(
(subscription) =>
subscription.address === filter.value
)?.label ?? filter.value}
</TagLabel>
)}
<TagCloseButton onClick={() => dropFilterArrayItem(idx)} />
</Tag>
);
})}
</Flex>
<Spacer />
<IconButton
mr={4}
onClick={onOpen}
colorScheme="primary"
variant="ghost"
icon={<FaFilter />}
/>
</Flex>
<Flex
className="ScrollableWrapper"
w="100%"
overflowY="hidden"
h="calc(100% - 3rem)"
>
<Flex
className="Scrollable"
id="StreamEntry"
overflowY="scroll"
direction="column"
w="100%"
//onScroll={(e) => handleScroll(e)}
>
<Stack direction="row" justifyContent="space-between">
{!isFetching ? (
<Button
onClick={() => {
remove();
setStreamBoundary({
start_time: null,
end_time: null,
include_start: false,
include_end: true,
next_event_time: null,
previous_event_time: null,
});
}}
variant="outline"
colorScheme="suggested"
>
Refresh to newest
</Button>
) : (
<Button
isLoading
loadingText="Loading"
variant="outline"
colorScheme="suggested"
></Button>
)}
{streamBoundary.next_event_time &&
streamBoundary.end_time != 0 &&
!isFetching ? (
<Button
onClick={() => {
updateStreamBoundaryWith({
end_time: streamBoundary.next_event_time + 5 * 60,
include_start: false,
include_end: true,
});
}}
variant="outline"
colorScheme="suggested"
>
Load latest transaction
</Button>
) : (
"" // some strange behaivior without else condition return 0 wich can see on frontend page
)}
</Stack>
{entries.map((entry, idx) => (
<StreamEntry
key={`entry-list-${idx}`}
entry={entry}
disableDelete={!canDelete}
disableCopy={!canCreate}
filterCallback={handleFilterStateCallback}
filterConstants={{ DIRECTIONS, CONDITION, FILTER_TYPES }}
/>
))}
{streamBoundary.previous_event_time || isFetching ? (
<Center>
<Button
onClick={() => {
remove();
updateStreamBoundaryWith({
start_time: streamBoundary.previous_event_time - 5 * 60,
include_start: false,
include_end: true,
});
}}
variant="outline"
colorScheme="suggested"
>
Go to previous transaction
</Button>
</Center>
) : (
""
)}
{streamBoundary.previous_event_time && isLoading && (
<Center>
<Spinner
hidden={!isFetchingMore}
ref={loadMoreButtonRef}
my={8}
size="lg"
color="primary.500"
thickness="4px"
speed="1.5s"
/>
</Center>
)}
</Flex>
</Flex>
</>
) : (
<Center>
<Spinner
mt="50%"
size="lg"
color="primary.500"
thickness="4px"
speed="1.5s"
/>
</Center>
)}
</Flex>
);
};
export default EntriesNavigation;