mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-03 06:51:00 +02:00
Squashed 'ai-context/workbench-ui/' content from commit 32e36a5c
git-subtree-dir: ai-context/workbench-ui git-subtree-split: 32e36a5c2131e429a7081cfaf67dabad3193cda3
This commit is contained in:
commit
a8390532f7
310 changed files with 56430 additions and 0 deletions
39
src/components/common/AltCard.tsx
Normal file
39
src/components/common/AltCard.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React from "react";
|
||||
import { Box, Flex, Heading, Text } from "@chakra-ui/react";
|
||||
|
||||
interface AltCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const AltCard: React.FC<AltCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<Box borderRadius="lg" boxShadow="sm" p={5} border="1px" height="100%">
|
||||
<Flex alignItems="center" mb={description ? 2 : 4}>
|
||||
{icon && (
|
||||
<Box mr={3} color="accent.solid">
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Heading as="h3" size="md" fontWeight="semibold">
|
||||
{title}
|
||||
</Heading>
|
||||
</Flex>
|
||||
{description && (
|
||||
<Text mb={4} fontSize="sm">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AltCard;
|
||||
40
src/components/common/BasicTable.tsx
Normal file
40
src/components/common/BasicTable.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Table } from "@chakra-ui/react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
|
||||
const BasicTable = ({ table }) => {
|
||||
return (
|
||||
<>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.ColumnHeader key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicTable;
|
||||
34
src/components/common/Card.tsx
Normal file
34
src/components/common/Card.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
import { Box, Flex, Heading, Text } from "@chakra-ui/react";
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({ title, description, icon, children }) => {
|
||||
return (
|
||||
<Box borderRadius="lg" boxShadow="sm" p={5} border="1px" height="100%">
|
||||
<Flex alignItems="center" mb={description ? 2 : 4}>
|
||||
{icon && (
|
||||
<Box mr={3} color="primary.solid">
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Heading as="h3" size="md" fontWeight="semibold">
|
||||
{title}
|
||||
</Heading>
|
||||
</Flex>
|
||||
{description && (
|
||||
<Text mb={4} fontSize="sm">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
28
src/components/common/CenterSpinner.tsx
Normal file
28
src/components/common/CenterSpinner.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
|
||||
import { Box, Spinner } from "@chakra-ui/react";
|
||||
|
||||
import { useProgressStateStore } from "@trustgraph/react-state";
|
||||
|
||||
const CenterSpinner: React.FC = () => {
|
||||
const activity = useProgressStateStore((state) => state.activity);
|
||||
|
||||
if (activity.size < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="calc(50% - 3rem)"
|
||||
left="calc(50% - 3rem)"
|
||||
zIndex="999"
|
||||
margin="0"
|
||||
padding="0"
|
||||
>
|
||||
<Spinner size="xl" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CenterSpinner;
|
||||
118
src/components/common/ChipInputField.tsx
Normal file
118
src/components/common/ChipInputField.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { Input, Tag, Wrap, Field } from "@chakra-ui/react";
|
||||
|
||||
// Represents a label added to the list. Highlighted with a close button for
|
||||
// removal.
|
||||
const Chip = ({ label, onCloseClick }) => (
|
||||
<Tag.Root
|
||||
key={label}
|
||||
borderRadius="full"
|
||||
variant="solid"
|
||||
colorScheme="green"
|
||||
>
|
||||
<Tag.Label>{label}</Tag.Label>
|
||||
<Tag.EndElement>
|
||||
<Tag.CloseTrigger
|
||||
onClick={() => {
|
||||
onCloseClick(label);
|
||||
}}
|
||||
/>
|
||||
</Tag.EndElement>
|
||||
</Tag.Root>
|
||||
);
|
||||
|
||||
// A horizontal stack of chips. Like a Pringles can on its side.
|
||||
const ChipList = ({ items = [], onCloseClick }) => (
|
||||
<Wrap spacing={1} mb={3}>
|
||||
{items.map((item) => (
|
||||
<Chip label={item} key={item} onCloseClick={onCloseClick} />
|
||||
))}
|
||||
</Wrap>
|
||||
);
|
||||
|
||||
// Form field wrapper.
|
||||
const ChipInput = ({ ...rest }) => <Input {...rest} />;
|
||||
|
||||
// Field wrapping chip list and input
|
||||
const ChipInputField: React.FC<{
|
||||
values: string[];
|
||||
onValuesChange: (v: string[]) => void;
|
||||
label: string;
|
||||
}> = ({ values, onValuesChange, label }) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
// Checks whether we've added this item already.
|
||||
const itemChipExists = (item) => values.includes(item);
|
||||
|
||||
// Add an item to the list, if it's valid and isn't already there.
|
||||
const addItems = (itemsToAdd) => {
|
||||
const validatedItems = itemsToAdd
|
||||
.map((e) => e.trim())
|
||||
.filter((item) => !itemChipExists(item));
|
||||
|
||||
const newItems = [...values, ...validatedItems];
|
||||
|
||||
onValuesChange(newItems);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
// Remove an item from the list.
|
||||
const removeItem = (item) => {
|
||||
const index = values.findIndex((e) => e === item);
|
||||
if (index !== -1) {
|
||||
const newItems = [...values];
|
||||
newItems.splice(index, 1);
|
||||
onValuesChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
// Save input field contents in state when changed.
|
||||
const handleChange = (e) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
// Validate and add the item if we press tab, enter or comma.
|
||||
const handleKeyDown = (e) => {
|
||||
if (["Enter", "Tab", ","].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
addItems([inputValue]);
|
||||
}
|
||||
};
|
||||
|
||||
// Split and add items when pasting.
|
||||
const handlePaste = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const pastedData = e.clipboardData.getData("text");
|
||||
const pastedItems = pastedData.split(",");
|
||||
addItems(pastedItems);
|
||||
};
|
||||
|
||||
const handleCloseClick = (item) => {
|
||||
removeItem(item);
|
||||
};
|
||||
|
||||
const required = false;
|
||||
|
||||
return (
|
||||
<Field.Root mb={4} required={required}>
|
||||
<Field.Label>
|
||||
{label} {required && <Field.RequiredIndicator />}
|
||||
</Field.Label>
|
||||
|
||||
<ChipList items={values} onCloseClick={handleCloseClick} />
|
||||
|
||||
<ChipInput
|
||||
placeholder="enter items"
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
value={inputValue}
|
||||
variant="subtle"
|
||||
/>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChipInputField;
|
||||
40
src/components/common/ClickableTable.tsx
Normal file
40
src/components/common/ClickableTable.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Table } from "@chakra-ui/react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
|
||||
const ClickableTable = ({ table, onClick, ...tableProps }) => {
|
||||
return (
|
||||
<>
|
||||
<Table.Root interactive {...tableProps}>
|
||||
<Table.Header>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.ColumnHeader key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row key={row.id} onClick={() => onClick(row)}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClickableTable;
|
||||
139
src/components/common/ConfirmDialog.tsx
Normal file
139
src/components/common/ConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import React from "react";
|
||||
import { Box, VStack, HStack, Text, Button } from "@chakra-ui/react";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "danger" | "warning" | "info";
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
variant = "warning",
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getVariantColors = () => {
|
||||
switch (variant) {
|
||||
case "danger":
|
||||
return {
|
||||
icon: "red.500",
|
||||
confirmButton: "red",
|
||||
bg: "red.50",
|
||||
border: "red.200",
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
icon: "orange.500",
|
||||
confirmButton: "orange",
|
||||
bg: "orange.50",
|
||||
border: "orange.200",
|
||||
};
|
||||
case "info":
|
||||
return {
|
||||
icon: "blue.500",
|
||||
confirmButton: "blue",
|
||||
bg: "blue.50",
|
||||
border: "blue.200",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: "orange.500",
|
||||
confirmButton: "orange",
|
||||
bg: "orange.50",
|
||||
border: "orange.200",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const colors = getVariantColors();
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bg="blackAlpha.600"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
zIndex="modal"
|
||||
>
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="lg"
|
||||
boxShadow="xl"
|
||||
w="500px"
|
||||
maxW="90vw"
|
||||
maxH="90vh"
|
||||
overflow="auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box p={6} borderBottomWidth="1px">
|
||||
<HStack justify="space-between" align="center">
|
||||
<HStack>
|
||||
<AlertTriangle size={20} color={colors.icon} />
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box p={6}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box
|
||||
p={4}
|
||||
bg={colors.bg}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={colors.border}
|
||||
>
|
||||
<Text fontSize="sm" color="gray.700" whiteSpace="pre-line">
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box p={6} borderTopWidth="1px" bg="gray.50">
|
||||
<HStack justify="flex-end" spacing={3}>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
colorPalette={colors.confirmButton}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
117
src/components/common/ConnectionStatus.tsx
Normal file
117
src/components/common/ConnectionStatus.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import React from "react";
|
||||
import { Box, HStack, Text, Tooltip } from "@chakra-ui/react";
|
||||
import { Info, Clock, Wifi, WifiOff, Shield, ShieldOff } from "lucide-react";
|
||||
import { useConnectionState } from "@trustgraph/react-provider";
|
||||
import type { ConnectionState } from "@trustgraph/client";
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
showDetails?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const getStatusDisplay = (state: ConnectionState) => {
|
||||
switch (state.status) {
|
||||
case "connecting":
|
||||
return {
|
||||
icon: Clock,
|
||||
color: "yellow.500",
|
||||
text: "Connecting...",
|
||||
tooltip: "Establishing connection to server",
|
||||
};
|
||||
|
||||
case "connected":
|
||||
return {
|
||||
icon: Wifi,
|
||||
color: "green.500",
|
||||
text: "Connected",
|
||||
tooltip: "Connected to server",
|
||||
};
|
||||
|
||||
case "authenticated":
|
||||
return {
|
||||
icon: Shield,
|
||||
color: "green.500",
|
||||
text: "Authenticated",
|
||||
tooltip: "Connected with API key authentication",
|
||||
};
|
||||
|
||||
case "unauthenticated":
|
||||
return {
|
||||
icon: ShieldOff,
|
||||
color: "blue.500",
|
||||
text: "Unauthenticated",
|
||||
tooltip: "Connected but no API key provided (limited functionality)",
|
||||
};
|
||||
|
||||
case "reconnecting":
|
||||
return {
|
||||
icon: Clock,
|
||||
color: "orange.500",
|
||||
text: `Reconnecting... (${state.reconnectAttempt}/${state.maxAttempts})`,
|
||||
tooltip: `Attempting to reconnect. Try ${state.reconnectAttempt} of ${state.maxAttempts}`,
|
||||
};
|
||||
|
||||
case "failed":
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "red.500",
|
||||
text: "Connection Failed",
|
||||
tooltip:
|
||||
state.lastError || "Connection failed after maximum retry attempts",
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
icon: Info,
|
||||
color: "gray.500",
|
||||
text: "Unknown",
|
||||
tooltip: "Unknown connection state",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({
|
||||
showDetails = false,
|
||||
size = "md",
|
||||
}) => {
|
||||
const connectionState = useConnectionState();
|
||||
|
||||
if (!connectionState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
icon: StatusIcon,
|
||||
color,
|
||||
text,
|
||||
tooltip,
|
||||
} = getStatusDisplay(connectionState);
|
||||
|
||||
const iconSize = size === "sm" ? 16 : size === "lg" ? 24 : 20;
|
||||
const fontSize = size === "sm" ? "xs" : size === "lg" ? "md" : "sm";
|
||||
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<HStack spacing={2}>
|
||||
<Box color={color}>
|
||||
<StatusIcon size={iconSize} />
|
||||
</Box>
|
||||
<Text fontSize={fontSize} color="fg.default">
|
||||
{showDetails ? text : connectionState.status}
|
||||
</Text>
|
||||
{showDetails && connectionState.hasApiKey && (
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
(API Key)
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Positioner>
|
||||
<Tooltip.Content>{tooltip}</Tooltip.Content>
|
||||
</Tooltip.Positioner>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionStatus;
|
||||
39
src/components/common/EntityList.tsx
Normal file
39
src/components/common/EntityList.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useNavigate } from "react-router";
|
||||
|
||||
import { HStack, Tag } from "@chakra-ui/react";
|
||||
|
||||
import { Entity } from "@trustgraph/react-state";
|
||||
import { useWorkbenchStateStore } from "@trustgraph/react-state";
|
||||
|
||||
const EntityList = () => {
|
||||
const entities = useWorkbenchStateStore((state) => state.entities);
|
||||
const setSelected = useWorkbenchStateStore((state) => state.setSelected);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSelect = (x: Entity) => {
|
||||
setSelected(x);
|
||||
navigate("/entity");
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack mt={8}>
|
||||
{entities.slice(0, 8).map((entity, ix) => (
|
||||
<Tag.Root
|
||||
asChild
|
||||
size="sm"
|
||||
key={ix}
|
||||
color="primary.solid"
|
||||
bgColor="bg"
|
||||
variant="surface"
|
||||
>
|
||||
<button onClick={() => onSelect(entity)}>
|
||||
<Tag.Label>{entity.label}</Tag.Label>
|
||||
</button>
|
||||
</Tag.Root>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntityList;
|
||||
17
src/components/common/ExternalDocs.tsx
Normal file
17
src/components/common/ExternalDocs.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { Link } from "@chakra-ui/react";
|
||||
|
||||
const ExternalDocs: React.FC<
|
||||
PropsWithChildren<{
|
||||
href: string;
|
||||
}>
|
||||
> = ({ href, children }) => {
|
||||
return (
|
||||
<Link href={href} target="_blank" colorPalette="accent">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalDocs;
|
||||
255
src/components/common/FlowSelector.tsx
Normal file
255
src/components/common/FlowSelector.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { Text, Box, Stack, HStack, Popover, Portal } from "@chakra-ui/react";
|
||||
|
||||
import { Database, Workflow } from "lucide-react";
|
||||
|
||||
import { useSessionStore } from "@trustgraph/react-state";
|
||||
import { useFlows } from "@trustgraph/react-state";
|
||||
import { useSettings } from "@trustgraph/react-state";
|
||||
import { useCollections } from "@trustgraph/react-state";
|
||||
|
||||
const FlowSelector = () => {
|
||||
const flowState = useFlows();
|
||||
const flows = flowState.flows ? flowState.flows : [];
|
||||
|
||||
const collectionsState = useCollections();
|
||||
const collections = collectionsState.collections || [];
|
||||
|
||||
const flowId = useSessionStore((state) => state.flowId);
|
||||
|
||||
const setFlowId = useSessionStore((state) => state.setFlowId);
|
||||
const setFlow = useSessionStore((state) => state.setFlow);
|
||||
|
||||
const { settings, updateSetting } = useSettings();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover.Root
|
||||
open={open}
|
||||
onOpenChange={(e) => setOpen(e.open)}
|
||||
size="xl"
|
||||
positioning={{ placement: "bottom-end" }}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<Stack
|
||||
p={3}
|
||||
gap={2}
|
||||
borderWidth="1px"
|
||||
borderRadius="8px"
|
||||
borderColor="border.inverted/20"
|
||||
color="fg.muted"
|
||||
backgroundColor="primary.bg"
|
||||
_hover={{
|
||||
backgroundColor: "bg.emphasized",
|
||||
borderColor: "border.inverted",
|
||||
color: "fg",
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
cursor="pointer"
|
||||
>
|
||||
<HStack gap={2} align="center">
|
||||
<Database size={14} />
|
||||
<Text fontSize="xs" fontWeight="medium">
|
||||
{settings.collection}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} align="center">
|
||||
<Workflow size={14} />
|
||||
<Text fontSize="xs" fontWeight="medium">
|
||||
{flowId || "<none>"}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Popover.Trigger>
|
||||
<Portal>
|
||||
<Popover.Positioner>
|
||||
<Popover.Content>
|
||||
<Popover.Arrow />
|
||||
<Popover.Body>
|
||||
<Stack gap={4} p={4}>
|
||||
{/* Collection Selection */}
|
||||
<Stack gap={3}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
color="fg.muted"
|
||||
mb={2}
|
||||
>
|
||||
Select Collection
|
||||
</Text>
|
||||
<Stack gap="1">
|
||||
{collections.map((collection) => {
|
||||
const isSelected =
|
||||
settings.collection === collection.collection;
|
||||
return (
|
||||
<Box
|
||||
key={collection.collection}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={
|
||||
isSelected ? "primary.500" : "border.subtle"
|
||||
}
|
||||
backgroundColor={
|
||||
isSelected ? "primary.50" : "transparent"
|
||||
}
|
||||
_hover={{
|
||||
borderColor: "primary.300",
|
||||
backgroundColor: isSelected
|
||||
? "primary.100"
|
||||
: "bg.subtle",
|
||||
}}
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
updateSetting("collection", collection.collection);
|
||||
}}
|
||||
>
|
||||
<HStack gap={3} align="start">
|
||||
<Box
|
||||
w={4}
|
||||
h={4}
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor={
|
||||
isSelected
|
||||
? "colorPalette.500"
|
||||
: "border.emphasized"
|
||||
}
|
||||
backgroundColor={
|
||||
isSelected ? "colorPalette.500" : "transparent"
|
||||
}
|
||||
mt={0.5}
|
||||
flexShrink={0}
|
||||
position="relative"
|
||||
>
|
||||
{isSelected && (
|
||||
<Box
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
backgroundColor="bg"
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex="1">
|
||||
<Text fontWeight="semibold" fontSize="sm" mb={1}>
|
||||
{collection.name}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
lineHeight="1.4"
|
||||
>
|
||||
{collection.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Flow Selection */}
|
||||
<Box borderTopWidth="1px" borderColor="border.subtle" pt={4}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
color="fg.muted"
|
||||
mb={3}
|
||||
>
|
||||
Select Flow
|
||||
</Text>
|
||||
<Stack gap="1">
|
||||
{flows.map((flow) => {
|
||||
const isSelected = flowId === flow.id;
|
||||
return (
|
||||
<Box
|
||||
key={flow.id}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={
|
||||
isSelected ? "primary.500" : "border.subtle"
|
||||
}
|
||||
backgroundColor={
|
||||
isSelected ? "primary.50" : "transparent"
|
||||
}
|
||||
_hover={{
|
||||
borderColor: "primary.300",
|
||||
backgroundColor: isSelected
|
||||
? "primary.100"
|
||||
: "bg.subtle",
|
||||
}}
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
setFlowId(flow.id);
|
||||
setFlow(flow);
|
||||
}}
|
||||
>
|
||||
<HStack gap={3} align="start">
|
||||
<Box
|
||||
w={4}
|
||||
h={4}
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor={
|
||||
isSelected
|
||||
? "colorPalette.500"
|
||||
: "border.emphasized"
|
||||
}
|
||||
backgroundColor={
|
||||
isSelected ? "colorPalette.500" : "transparent"
|
||||
}
|
||||
mt={0.5}
|
||||
flexShrink={0}
|
||||
position="relative"
|
||||
>
|
||||
{isSelected && (
|
||||
<Box
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
backgroundColor="bg"
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex="1">
|
||||
<Text fontWeight="semibold" fontSize="sm" mb={1}>
|
||||
{flow.id}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
lineHeight="1.4"
|
||||
>
|
||||
{flow.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Popover.Body>
|
||||
</Popover.Content>
|
||||
</Popover.Positioner>
|
||||
</Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowSelector;
|
||||
45
src/components/common/NumberField.tsx
Normal file
45
src/components/common/NumberField.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React from "react";
|
||||
import { Field, NumberInput } from "@chakra-ui/react";
|
||||
|
||||
interface NumberFieldProps {
|
||||
label: string;
|
||||
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
value: number;
|
||||
onValueChange: (x: number) => void;
|
||||
}
|
||||
|
||||
const NumberField: React.FC<NumberFieldProps> = ({
|
||||
label,
|
||||
minValue,
|
||||
maxValue,
|
||||
value,
|
||||
onValueChange,
|
||||
}) => {
|
||||
return (
|
||||
<Field.Root mb={4}>
|
||||
<Field.Label fontWeight="medium">{label}</Field.Label>
|
||||
<NumberInput.Root
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
value={value.toString()}
|
||||
onValueChange={(e) => {
|
||||
const numValue =
|
||||
e.value === "" || e.value == null ? 0 : Number(e.value);
|
||||
if (!isNaN(numValue)) {
|
||||
onValueChange(numValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NumberInput.Input />
|
||||
<NumberInput.Control>
|
||||
<NumberInput.IncrementTrigger />
|
||||
<NumberInput.DecrementTrigger />
|
||||
</NumberInput.Control>
|
||||
</NumberInput.Root>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumberField;
|
||||
42
src/components/common/OptionWithImage.tsx
Normal file
42
src/components/common/OptionWithImage.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Stack,
|
||||
Image,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Center,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
const OptionWithImage: React.FC<{
|
||||
image: string;
|
||||
title: string;
|
||||
description?: string | React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
}> = ({ description, title, image, badge }) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Flex alignItems="center">
|
||||
<Box mr={4} minWidth="5rem" width="5rem">
|
||||
<Center>
|
||||
<Image rounded="md" src={image} alt={title} />
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
<Flex alignItems="center">
|
||||
<Heading as="h1" size="md" color="fg" fontWeight="bold" mr={2}>
|
||||
{title}
|
||||
</Heading>
|
||||
{badge && badge}
|
||||
</Flex>
|
||||
<Text mt={1} textStyle="xs" color="fg.muted">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionWithImage;
|
||||
64
src/components/common/PageHeader.tsx
Normal file
64
src/components/common/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from "react";
|
||||
|
||||
import { Flex, Text, Box, HStack, VStack, Heading } from "@chakra-ui/react";
|
||||
|
||||
import ColorModeToggle from "../color-mode-toggle";
|
||||
import FlowSelector from "./FlowSelector";
|
||||
import ConnectionStatus from "./ConnectionStatus";
|
||||
import UserDisplay from "./UserDisplay";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}) => {
|
||||
return (
|
||||
<Flex
|
||||
mb={8}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
px={1}
|
||||
py={1}
|
||||
>
|
||||
<Flex alignItems="center">
|
||||
{icon && (
|
||||
<Box mr={4} color="{colors.primary.fg}" fontSize="xl">
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Heading
|
||||
as="h1"
|
||||
size="xl"
|
||||
color="{colors.primary.fg}"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text mt={1} fontSize="md" color="{colors.primary.emphasized}">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box>
|
||||
<HStack gap={6} align="center">
|
||||
<VStack gap={1} align="end">
|
||||
<ConnectionStatus showDetails={true} size="sm" />
|
||||
<UserDisplay />
|
||||
</VStack>
|
||||
<FlowSelector />
|
||||
<ColorModeToggle />
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
41
src/components/common/Progress.tsx
Normal file
41
src/components/common/Progress.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from "react";
|
||||
|
||||
import { Box, Text } from "@chakra-ui/react";
|
||||
|
||||
import { useProgressStateStore } from "@trustgraph/react-state";
|
||||
|
||||
const Progress: React.FC = () => {
|
||||
const activity = useProgressStateStore((state) => state.activity);
|
||||
|
||||
return (
|
||||
<>
|
||||
{activity.size > 0 && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="1rem"
|
||||
left="1rem"
|
||||
zIndex="999"
|
||||
pt="0.8rem"
|
||||
pb="0.8rem"
|
||||
pl="1.2rem"
|
||||
pr="1.2rem"
|
||||
backgroundColor="bg.emphasized/40"
|
||||
borderWidth="1px"
|
||||
borderColor="border.inverted/40"
|
||||
borderRadius="5px"
|
||||
width="25rem"
|
||||
>
|
||||
{Array.from(activity)
|
||||
.slice(0, 4)
|
||||
.map((a, ix) => (
|
||||
<Box key={ix} color="fg/40" truncate>
|
||||
<Text textStyle="sm">{a}...</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Progress;
|
||||
33
src/components/common/ProgressSubmitButton.tsx
Normal file
33
src/components/common/ProgressSubmitButton.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from "react";
|
||||
|
||||
import { SendHorizontal } from "lucide-react";
|
||||
|
||||
import { Box, Button } from "@chakra-ui/react";
|
||||
|
||||
interface ProgressSubmitButtonProps {
|
||||
disabled: boolean;
|
||||
working: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ProgressSubmitButton: React.FC<ProgressSubmitButtonProps> = ({
|
||||
disabled,
|
||||
working,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={disabled}
|
||||
loading={working}
|
||||
color="primary"
|
||||
onClick={() => onClick()}
|
||||
>
|
||||
Send <SendHorizontal />
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressSubmitButton;
|
||||
11
src/components/common/RecommendedBadge.tsx
Normal file
11
src/components/common/RecommendedBadge.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Badge } from "@chakra-ui/react";
|
||||
|
||||
const RecommendedBadge = () => {
|
||||
return (
|
||||
<Badge colorPalette="green" size="sm">
|
||||
recommended
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecommendedBadge;
|
||||
92
src/components/common/SelectField.tsx
Normal file
92
src/components/common/SelectField.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* CRITICAL: DO NOT MODIFY THIS COMPONENT WITHOUT DESIGN AUTHORITY APPROVAL
|
||||
*
|
||||
* This SelectField component is used throughout the application by 15+ components
|
||||
* across multiple domains (ontologies, flows, documents, etc.). Any changes to
|
||||
* this component's interface or behavior will have extensive downstream impact.
|
||||
*
|
||||
* Changes to this component in September 2025 broke multiple features and required
|
||||
* systematic updates across the entire application. Always prefer adapter patterns
|
||||
* or feature-specific solutions over modifying this shared infrastructure.
|
||||
*
|
||||
* Required API contract:
|
||||
* - value: string[] (arrays only)
|
||||
* - onValueChange: (values: string[]) => void
|
||||
* - items must include description fields with SelectOptionText/SelectOption
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
Field,
|
||||
Select,
|
||||
Portal,
|
||||
Stack,
|
||||
createListCollection,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
export interface SelectFieldValue {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | React.ReactElement;
|
||||
}
|
||||
|
||||
interface SelectFieldProps {
|
||||
label: string;
|
||||
|
||||
items: SelectFieldValue[];
|
||||
|
||||
value: string[];
|
||||
onValueChange: (x: string[]) => void;
|
||||
|
||||
contentRef?;
|
||||
}
|
||||
|
||||
const SelectField: React.FC<SelectFieldProps> = ({
|
||||
label,
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
contentRef,
|
||||
}) => {
|
||||
// Only create new collection when items actually change
|
||||
const collection = useMemo(
|
||||
() => createListCollection({ items: items }),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<Field.Root mb={4}>
|
||||
<Field.Label fontWeight="medium">{label}</Field.Label>
|
||||
|
||||
<Select.Root
|
||||
collection={collection}
|
||||
value={value}
|
||||
onValueChange={(e) => onValueChange(e.value)}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText placeholder={label} />
|
||||
</Select.Trigger>
|
||||
<Select.IndicatorGroup>
|
||||
<Select.Indicator />
|
||||
</Select.IndicatorGroup>
|
||||
</Select.Control>
|
||||
<Portal container={contentRef}>
|
||||
<Select.Positioner>
|
||||
<Select.Content>
|
||||
{items.map((v) => (
|
||||
<Select.Item item={v.value} key={v.value}>
|
||||
<Stack>{v.description && v.description}</Stack>
|
||||
<Select.ItemIndicator />
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Positioner>
|
||||
</Portal>
|
||||
</Select.Root>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectField;
|
||||
34
src/components/common/SelectOption.tsx
Normal file
34
src/components/common/SelectOption.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* CRITICAL: DO NOT MODIFY THIS COMPONENT WITHOUT DESIGN AUTHORITY APPROVAL
|
||||
*
|
||||
* This SelectOption component is used by SelectField throughout the application.
|
||||
* Changes to this component's interface or styling will affect all dropdown
|
||||
* options across multiple domains. Any modifications require extensive testing
|
||||
* and approval from the application design authority.
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { Box, Flex, Heading, Text } from "@chakra-ui/react";
|
||||
|
||||
const SelectOption: React.FC<
|
||||
PropsWithChildren<{
|
||||
title: string;
|
||||
badge?: React.ReactNode;
|
||||
}>
|
||||
> = ({ title, badge, children }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Flex alignItems="center">
|
||||
<Heading as="h1" size="sm" color="fg" fontWeight="bold">
|
||||
{title}
|
||||
</Heading>
|
||||
{badge && badge}
|
||||
</Flex>
|
||||
<Text mt={1} textStyle="xs" color="fg.muted">
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectOption;
|
||||
23
src/components/common/SelectOptionText.tsx
Normal file
23
src/components/common/SelectOptionText.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* CRITICAL: DO NOT MODIFY THIS COMPONENT WITHOUT DESIGN AUTHORITY APPROVAL
|
||||
*
|
||||
* This SelectOptionText component is used by SelectField throughout the application.
|
||||
* Changes to this component's interface or styling will affect all dropdown
|
||||
* options across multiple domains. Any modifications require extensive testing
|
||||
* and approval from the application design authority.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Text } from "@chakra-ui/react";
|
||||
|
||||
const SelectOptionText: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
return (
|
||||
<Text mt={1} textStyle="xs" color="fg.muted">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectOptionText;
|
||||
40
src/components/common/SelectableTable.tsx
Normal file
40
src/components/common/SelectableTable.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Table } from "@chakra-ui/react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
|
||||
const SelectableTable = ({ table }) => {
|
||||
return (
|
||||
<>
|
||||
<Table.Root interactive>
|
||||
<Table.Header>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.ColumnHeader key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row key={row.id} onClick={row.getToggleSelectedHandler()}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectableTable;
|
||||
45
src/components/common/SimplePage.tsx
Normal file
45
src/components/common/SimplePage.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { Box, Container, Flex, Heading, Stack } from "@chakra-ui/react";
|
||||
|
||||
import UnauthedHeader from "./UnauthedHeader";
|
||||
|
||||
const SimplePage: React.FC<
|
||||
PropsWithChildren<{
|
||||
title: string;
|
||||
}>
|
||||
> = ({ title, children }) => {
|
||||
return (
|
||||
<>
|
||||
<UnauthedHeader />
|
||||
<Flex minH="100vh" align="center" justify="center" bg="primary.900">
|
||||
<Container maxW="md" py={12}>
|
||||
<Box
|
||||
bg="primary.800"
|
||||
p={8}
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
borderWidth="1px"
|
||||
borderColor="primary.muted"
|
||||
>
|
||||
<Stack spacing={6}>
|
||||
<Heading
|
||||
as="h1"
|
||||
fontSize="2xl"
|
||||
textAlign="center"
|
||||
color="primary.400"
|
||||
mb={2}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimplePage;
|
||||
45
src/components/common/Slider.tsx
Normal file
45
src/components/common/Slider.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React from "react";
|
||||
|
||||
import { Field, Slider as ChakraSlider } from "@chakra-ui/react";
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
value: number;
|
||||
step: number;
|
||||
onValueChange: (x: number) => void;
|
||||
}
|
||||
|
||||
const Slider: React.FC<SliderProps> = ({
|
||||
label,
|
||||
minValue,
|
||||
maxValue,
|
||||
value,
|
||||
onValueChange,
|
||||
step,
|
||||
}) => {
|
||||
return (
|
||||
<Field.Root mb={4}>
|
||||
<Field.Label fontWeight="medium">{label}</Field.Label>
|
||||
<ChakraSlider.Root
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={(e) => onValueChange(e.value[0])}
|
||||
width="100%"
|
||||
>
|
||||
<ChakraSlider.ValueText />
|
||||
<ChakraSlider.Control>
|
||||
<ChakraSlider.Track bg="{colors.primary.muted}">
|
||||
<ChakraSlider.Range bg="{colors.primary.solid}" />
|
||||
</ChakraSlider.Track>
|
||||
<ChakraSlider.Thumbs rounded={11} />
|
||||
</ChakraSlider.Control>
|
||||
</ChakraSlider.Root>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Slider;
|
||||
35
src/components/common/StatusBadge.tsx
Normal file
35
src/components/common/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React from "react";
|
||||
import { Badge, BadgeProps } from "@chakra-ui/react";
|
||||
type StatusType = "success" | "warning" | "error" | "info" | "default";
|
||||
interface StatusBadgeProps extends Omit<BadgeProps, "colorScheme"> {
|
||||
status: StatusType;
|
||||
label: string;
|
||||
}
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||
status,
|
||||
label,
|
||||
...rest
|
||||
}) => {
|
||||
const colorSchemes: Record<StatusType, string> = {
|
||||
success: "#65c97a",
|
||||
warning: "orange",
|
||||
error: "red",
|
||||
info: "#5285ed",
|
||||
default: "gray",
|
||||
};
|
||||
return (
|
||||
<Badge
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
bg={colorSchemes[status]}
|
||||
color={status === "default" ? "gray.800" : "white"}
|
||||
fontWeight="medium"
|
||||
fontSize="xs"
|
||||
{...rest}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
export default StatusBadge;
|
||||
36
src/components/common/TableStates.tsx
Normal file
36
src/components/common/TableStates.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import { Box, Text, Center } from "@chakra-ui/react";
|
||||
|
||||
interface ErrorStateProps {
|
||||
error: Error | unknown;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const ErrorState: React.FC<ErrorStateProps> = ({
|
||||
error,
|
||||
title = "Error loading data",
|
||||
}) => (
|
||||
<Box
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderColor="red.500"
|
||||
borderRadius="md"
|
||||
bg="red.50"
|
||||
>
|
||||
<Text color="red.700">
|
||||
{title}: {error?.toString()}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
interface EmptyStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
message = "No data found.",
|
||||
}) => (
|
||||
<Center h="200px">
|
||||
<Text color="fg.muted">{message}</Text>
|
||||
</Center>
|
||||
);
|
||||
55
src/components/common/TableWithStates.tsx
Normal file
55
src/components/common/TableWithStates.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from "react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { ErrorState, EmptyState } from "./TableStates";
|
||||
import BasicTable from "./BasicTable";
|
||||
import ClickableTable from "./ClickableTable";
|
||||
|
||||
interface TableWithStatesProps<T> {
|
||||
table: Table<T>;
|
||||
data: T[];
|
||||
error?: Error | unknown;
|
||||
onClick?: (row: T) => void;
|
||||
emptyMessage?: string;
|
||||
errorTitle?: string;
|
||||
bordered?: boolean;
|
||||
}
|
||||
|
||||
const TableWithStates = <T,>({
|
||||
table,
|
||||
data,
|
||||
error,
|
||||
onClick,
|
||||
emptyMessage = "No data found.",
|
||||
errorTitle = "Error loading data",
|
||||
bordered = true,
|
||||
}: TableWithStatesProps<T>) => {
|
||||
// Handle error state
|
||||
if (error) {
|
||||
return <ErrorState error={error} title={errorTitle} />;
|
||||
}
|
||||
|
||||
// Handle empty state
|
||||
if (data.length === 0) {
|
||||
return <EmptyState message={emptyMessage} />;
|
||||
}
|
||||
|
||||
// Render table with optional border wrapper
|
||||
const TableComponent = onClick ? (
|
||||
<ClickableTable table={table} onClick={(row) => onClick(row.original)} />
|
||||
) : (
|
||||
<BasicTable table={table} />
|
||||
);
|
||||
|
||||
if (bordered) {
|
||||
return (
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="lg">
|
||||
{TableComponent}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return TableComponent;
|
||||
};
|
||||
|
||||
export default TableWithStates;
|
||||
40
src/components/common/TextAreaField.tsx
Normal file
40
src/components/common/TextAreaField.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from "react";
|
||||
|
||||
import { Field, Textarea } from "@chakra-ui/react";
|
||||
|
||||
interface TextFieldProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onValueChange: (x: string) => void;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TextAreaField: React.FC<TextFieldProps> = ({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onValueChange,
|
||||
required,
|
||||
disabled,
|
||||
}) => {
|
||||
return (
|
||||
<Field.Root mb={4} required={required}>
|
||||
<Field.Label>
|
||||
{label} {required && <Field.RequiredIndicator />}
|
||||
</Field.Label>
|
||||
<Textarea
|
||||
placeholder={placeholder}
|
||||
variant="subtle"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
maxH="30lh"
|
||||
h="10lh"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextAreaField;
|
||||
44
src/components/common/TextField.tsx
Normal file
44
src/components/common/TextField.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
|
||||
import { Field, Input } from "@chakra-ui/react";
|
||||
|
||||
interface TextFieldProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onValueChange: (x: string) => void;
|
||||
required?: boolean;
|
||||
helperText?: string;
|
||||
disabled?: boolean;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const TextField: React.FC<TextFieldProps> = ({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onValueChange,
|
||||
required,
|
||||
helperText,
|
||||
disabled,
|
||||
type = "text",
|
||||
}) => {
|
||||
return (
|
||||
<Field.Root mb={4} required={required}>
|
||||
<Field.Label>
|
||||
{label} {required && <Field.RequiredIndicator />}
|
||||
</Field.Label>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
variant="subtle"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{helperText && <Field.HelperText>{helperText}</Field.HelperText>}
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextField;
|
||||
25
src/components/common/UnauthedHeader.tsx
Normal file
25
src/components/common/UnauthedHeader.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Box, Flex } from "@chakra-ui/react";
|
||||
|
||||
import ColorModeToggle from "../color-mode-toggle";
|
||||
|
||||
const UnauthedHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
mb={2}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
<Flex mb={2} alignItems="center"></Flex>
|
||||
<Box>
|
||||
<ColorModeToggle />
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnauthedHeader;
|
||||
19
src/components/common/UserDisplay.tsx
Normal file
19
src/components/common/UserDisplay.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from "react";
|
||||
import { HStack, Text } from "@chakra-ui/react";
|
||||
import { User } from "lucide-react";
|
||||
import { useSettings } from "@trustgraph/react-state";
|
||||
|
||||
const UserDisplay: React.FC = () => {
|
||||
const { settings } = useSettings();
|
||||
|
||||
return (
|
||||
<HStack gap={2} align="center">
|
||||
<User size={14} color="currentColor" />
|
||||
<Text fontSize="xs" fontWeight="medium" color="fg.muted">
|
||||
{settings.user}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDisplay;
|
||||
408
src/components/common/__tests__/BasicTable.test.tsx
Normal file
408
src/components/common/__tests__/BasicTable.test.tsx
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import BasicTable from "../BasicTable";
|
||||
|
||||
// Mock Chakra UI components
|
||||
vi.mock("@chakra-ui/react", () => ({
|
||||
Table: {
|
||||
Root: ({ children }: React.PropsWithChildren) => (
|
||||
<table data-testid="table-root">{children}</table>
|
||||
),
|
||||
Header: ({ children }: React.PropsWithChildren) => (
|
||||
<thead data-testid="table-header">{children}</thead>
|
||||
),
|
||||
Body: ({ children }: React.PropsWithChildren) => (
|
||||
<tbody data-testid="table-body">{children}</tbody>
|
||||
),
|
||||
Row: ({ children }: React.PropsWithChildren) => (
|
||||
<tr data-testid="table-row">{children}</tr>
|
||||
),
|
||||
ColumnHeader: ({ children }: React.PropsWithChildren) => (
|
||||
<th data-testid="table-column-header">{children}</th>
|
||||
),
|
||||
Cell: ({ children }: React.PropsWithChildren) => (
|
||||
<td data-testid="table-cell">{children}</td>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock TanStack React Table
|
||||
vi.mock("@tanstack/react-table", () => ({
|
||||
flexRender: vi.fn((content) => content),
|
||||
}));
|
||||
|
||||
describe("BasicTable", () => {
|
||||
const mockTable = {
|
||||
getHeaderGroups: vi.fn(),
|
||||
getRowModel: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render table structure", () => {
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByTestId("table-root")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render header groups", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Column 1",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "header-2",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Column 2",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Column 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Column 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render placeholder headers", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: true,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Placeholder Column",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "header-2",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Visible Column",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.queryByText("Placeholder Column")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Visible Column")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render table rows", () => {
|
||||
const mockRows = [
|
||||
{
|
||||
id: "row-1",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Cell 1",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "cell-2",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Cell 2",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: mockRows });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Cell 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cell 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render multiple rows", () => {
|
||||
const mockRows = [
|
||||
{
|
||||
id: "row-1",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-1-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Row 1 Cell 1",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-2-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Row 2 Cell 1",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: mockRows });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Row 1 Cell 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Row 2 Cell 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render complete table with headers and rows", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Name",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "header-2",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Age",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockRows = [
|
||||
{
|
||||
id: "row-1",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-1-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "John",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "cell-1-2",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "25",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-2-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Jane",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "cell-2-2",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "30",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: mockRows });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
// Check headers
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Age")).toBeInTheDocument();
|
||||
|
||||
// Check rows
|
||||
expect(screen.getByText("John")).toBeInTheDocument();
|
||||
expect(screen.getByText("25")).toBeInTheDocument();
|
||||
expect(screen.getByText("Jane")).toBeInTheDocument();
|
||||
expect(screen.getByText("30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty table", () => {
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByTestId("table-root")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle table with headers but no rows", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Empty Table Header",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Empty Table Header")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle table with rows but no headers", () => {
|
||||
const mockRows = [
|
||||
{
|
||||
id: "row-1",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Orphan Cell",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: mockRows });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Orphan Cell")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle multiple header groups", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Group 1 Header",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "header-group-2",
|
||||
headers: [
|
||||
{
|
||||
id: "header-2",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Group 2 Header",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Group 1 Header")).toBeInTheDocument();
|
||||
expect(screen.getByText("Group 2 Header")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
327
src/components/common/__tests__/Card.test.tsx
Normal file
327
src/components/common/__tests__/Card.test.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Card from "../Card";
|
||||
|
||||
// Helper function to filter out Chakra UI props
|
||||
const filterChakraProps = (props: Record<string, unknown>) => {
|
||||
const chakraProps = [
|
||||
"alignItems",
|
||||
"justifyContent",
|
||||
"direction",
|
||||
"gap",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pb",
|
||||
"pl",
|
||||
"pr",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mb",
|
||||
"ml",
|
||||
"mr",
|
||||
"w",
|
||||
"h",
|
||||
"maxW",
|
||||
"maxH",
|
||||
"minW",
|
||||
"minH",
|
||||
"bg",
|
||||
"color",
|
||||
"borderRadius",
|
||||
"borderWidth",
|
||||
"borderColor",
|
||||
"borderStyle",
|
||||
"boxShadow",
|
||||
"display",
|
||||
"position",
|
||||
"top",
|
||||
"right",
|
||||
"bottom",
|
||||
"left",
|
||||
"zIndex",
|
||||
"overflow",
|
||||
"textAlign",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
"textDecoration",
|
||||
"opacity",
|
||||
"visibility",
|
||||
"cursor",
|
||||
"pointerEvents",
|
||||
"userSelect",
|
||||
"resize",
|
||||
"outline",
|
||||
"transform",
|
||||
"transformOrigin",
|
||||
"transition",
|
||||
"animation",
|
||||
"colorPalette",
|
||||
"variant",
|
||||
"size",
|
||||
"loading",
|
||||
"disabled",
|
||||
"checked",
|
||||
"selected",
|
||||
"active",
|
||||
"focus",
|
||||
"hover",
|
||||
"flexDirection",
|
||||
"flexWrap",
|
||||
"flex",
|
||||
"flexGrow",
|
||||
"flexShrink",
|
||||
"flexBasis",
|
||||
"alignSelf",
|
||||
"justifySelf",
|
||||
"order",
|
||||
"gridColumn",
|
||||
"gridRow",
|
||||
"gridArea",
|
||||
"gridTemplateColumns",
|
||||
"gridTemplateRows",
|
||||
"gridGap",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"placeItems",
|
||||
"placeContent",
|
||||
"placeSelf",
|
||||
"area",
|
||||
"colSpan",
|
||||
"rowSpan",
|
||||
"start",
|
||||
"end",
|
||||
];
|
||||
const filtered = { ...props };
|
||||
chakraProps.forEach((prop) => delete filtered[prop]);
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Mock Chakra UI components
|
||||
vi.mock("@chakra-ui/react", () => ({
|
||||
Box: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="box" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Flex: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="flex" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Heading: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<h3 data-testid="heading" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
Text: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<p data-testid="text" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Card", () => {
|
||||
it("should render with title", () => {
|
||||
render(<Card title="Test Title" />);
|
||||
|
||||
expect(screen.getByTestId("heading")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with description when provided", () => {
|
||||
render(<Card title="Test Title" description="Test description" />);
|
||||
|
||||
expect(screen.getByTestId("text")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render description when not provided", () => {
|
||||
render(<Card title="Test Title" />);
|
||||
|
||||
expect(screen.queryByTestId("text")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render icon when provided", () => {
|
||||
const TestIcon = () => <span data-testid="test-icon">🎯</span>;
|
||||
render(<Card title="Test Title" icon={<TestIcon />} />);
|
||||
|
||||
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("🎯")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render icon when not provided", () => {
|
||||
render(<Card title="Test Title" />);
|
||||
|
||||
expect(screen.queryByTestId("test-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render children when provided", () => {
|
||||
render(
|
||||
<Card title="Test Title">
|
||||
<div data-testid="child-content">Child content</div>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render children when not provided", () => {
|
||||
render(<Card title="Test Title" />);
|
||||
|
||||
expect(screen.queryByTestId("child-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with all props together", () => {
|
||||
const TestIcon = () => <span data-testid="test-icon">⭐</span>;
|
||||
render(
|
||||
<Card
|
||||
title="Complete Card"
|
||||
description="This is a complete card with all props"
|
||||
icon={<TestIcon />}
|
||||
>
|
||||
<div data-testid="child-content">Children content</div>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Complete Card")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("This is a complete card with all props"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty title", () => {
|
||||
render(<Card title="" />);
|
||||
|
||||
expect(screen.getByTestId("heading")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("heading")).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("should handle empty description", () => {
|
||||
render(<Card title="Test Title" description="" />);
|
||||
|
||||
// Empty description should not render the text element
|
||||
expect(screen.queryByTestId("text")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle long title", () => {
|
||||
const longTitle = "a".repeat(100);
|
||||
render(<Card title={longTitle} />);
|
||||
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle long description", () => {
|
||||
const longDescription = "b".repeat(500);
|
||||
render(<Card title="Test Title" description={longDescription} />);
|
||||
|
||||
expect(screen.getByText(longDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle special characters in title", () => {
|
||||
const specialTitle = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/~`";
|
||||
render(<Card title={specialTitle} />);
|
||||
|
||||
expect(screen.getByText(specialTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle special characters in description", () => {
|
||||
const specialDescription = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/~`";
|
||||
render(<Card title="Test Title" description={specialDescription} />);
|
||||
|
||||
expect(screen.getByText(specialDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle multiline title", () => {
|
||||
const multilineTitle = "Line 1\nLine 2\nLine 3";
|
||||
render(<Card title={multilineTitle} />);
|
||||
|
||||
// Check that content is rendered (newlines may be normalized)
|
||||
expect(screen.getByTestId("heading")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("heading").textContent).toContain("Line 1");
|
||||
expect(screen.getByTestId("heading").textContent).toContain("Line 2");
|
||||
expect(screen.getByTestId("heading").textContent).toContain("Line 3");
|
||||
});
|
||||
|
||||
it("should handle multiline description", () => {
|
||||
const multilineDescription = "Line 1\nLine 2\nLine 3";
|
||||
render(<Card title="Test Title" description={multilineDescription} />);
|
||||
|
||||
// Check that content is rendered (newlines may be normalized)
|
||||
expect(screen.getByTestId("text")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("text").textContent).toContain("Line 1");
|
||||
expect(screen.getByTestId("text").textContent).toContain("Line 2");
|
||||
expect(screen.getByTestId("text").textContent).toContain("Line 3");
|
||||
});
|
||||
|
||||
it("should handle complex icon component", () => {
|
||||
const ComplexIcon = () => (
|
||||
<div data-testid="complex-icon">
|
||||
<span>🎯</span>
|
||||
<span>Complex</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
render(<Card title="Test Title" icon={<ComplexIcon />} />);
|
||||
|
||||
expect(screen.getByTestId("complex-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("🎯")).toBeInTheDocument();
|
||||
expect(screen.getByText("Complex")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle complex children", () => {
|
||||
render(
|
||||
<Card title="Test Title">
|
||||
<div data-testid="complex-child">
|
||||
<button>Click me</button>
|
||||
<input placeholder="Enter text" />
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("complex-child")).toBeInTheDocument();
|
||||
expect(screen.getByText("Click me")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null icon", () => {
|
||||
render(<Card title="Test Title" icon={null} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("test-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle undefined icon", () => {
|
||||
render(<Card title="Test Title" icon={undefined} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("test-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
461
src/components/common/__tests__/ProgressSubmitButton.test.tsx
Normal file
461
src/components/common/__tests__/ProgressSubmitButton.test.tsx
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ProgressSubmitButton from "../ProgressSubmitButton";
|
||||
|
||||
// Helper function to filter out Chakra UI props
|
||||
const filterChakraProps = (props: Record<string, unknown>) => {
|
||||
const chakraProps = [
|
||||
"alignItems",
|
||||
"justifyContent",
|
||||
"direction",
|
||||
"gap",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pb",
|
||||
"pl",
|
||||
"pr",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mb",
|
||||
"ml",
|
||||
"mr",
|
||||
"w",
|
||||
"h",
|
||||
"maxW",
|
||||
"maxH",
|
||||
"minW",
|
||||
"minH",
|
||||
"bg",
|
||||
"color",
|
||||
"borderRadius",
|
||||
"borderWidth",
|
||||
"borderColor",
|
||||
"borderStyle",
|
||||
"boxShadow",
|
||||
"display",
|
||||
"position",
|
||||
"top",
|
||||
"right",
|
||||
"bottom",
|
||||
"left",
|
||||
"zIndex",
|
||||
"overflow",
|
||||
"textAlign",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
"textDecoration",
|
||||
"opacity",
|
||||
"visibility",
|
||||
"cursor",
|
||||
"pointerEvents",
|
||||
"userSelect",
|
||||
"resize",
|
||||
"outline",
|
||||
"transform",
|
||||
"transformOrigin",
|
||||
"transition",
|
||||
"animation",
|
||||
"colorPalette",
|
||||
"variant",
|
||||
"size",
|
||||
"loading",
|
||||
"disabled",
|
||||
"checked",
|
||||
"selected",
|
||||
"active",
|
||||
"focus",
|
||||
"hover",
|
||||
"flexDirection",
|
||||
"flexWrap",
|
||||
"flex",
|
||||
"flexGrow",
|
||||
"flexShrink",
|
||||
"flexBasis",
|
||||
"alignSelf",
|
||||
"justifySelf",
|
||||
"order",
|
||||
"gridColumn",
|
||||
"gridRow",
|
||||
"gridArea",
|
||||
"gridTemplateColumns",
|
||||
"gridTemplateRows",
|
||||
"gridGap",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"placeItems",
|
||||
"placeContent",
|
||||
"placeSelf",
|
||||
"area",
|
||||
"colSpan",
|
||||
"rowSpan",
|
||||
"start",
|
||||
"end",
|
||||
];
|
||||
const filtered = { ...props };
|
||||
chakraProps.forEach((prop) => delete filtered[prop]);
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Mock Chakra UI components
|
||||
vi.mock("@chakra-ui/react", () => ({
|
||||
Box: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="box" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
...props
|
||||
}: React.PropsWithChildren<
|
||||
{
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
} & Record<string, unknown>
|
||||
>) => (
|
||||
<button
|
||||
data-testid="progress-button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-loading={loading}
|
||||
{...filterChakraProps(props)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icon
|
||||
vi.mock("lucide-react", () => ({
|
||||
SendHorizontal: () => <div data-testid="send-icon">Send</div>,
|
||||
}));
|
||||
|
||||
describe("ProgressSubmitButton", () => {
|
||||
const mockOnClick = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render button with correct content", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("progress-button")).toHaveTextContent("Send");
|
||||
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render within Box wrapper", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("box")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("box")).toContainElement(
|
||||
screen.getByTestId("progress-button"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onClick when button is clicked", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("progress-button"));
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should be disabled when disabled prop is true", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not be disabled when disabled prop is false", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should have loading state when working prop is true", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).toHaveAttribute(
|
||||
"data-loading",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not have loading state when working prop is false", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).toHaveAttribute(
|
||||
"data-loading",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render button with correct structure", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("should handle both disabled and working states", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
|
||||
it("should not call onClick when disabled", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("progress-button"));
|
||||
expect(mockOnClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle multiple clicks when enabled", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should maintain consistent state when props change", () => {
|
||||
const { rerender } = render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
let button = screen.getByTestId("progress-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "false");
|
||||
|
||||
// Change to working state
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
button = screen.getByTestId("progress-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "true");
|
||||
|
||||
// Change to disabled state
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "false");
|
||||
});
|
||||
|
||||
it("should handle rapid state changes", () => {
|
||||
const { rerender } = render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Rapidly change states
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "false");
|
||||
});
|
||||
|
||||
it("should handle onClick function changes", () => {
|
||||
const newOnClick = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("progress-button"));
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change onClick function
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={newOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("progress-button"));
|
||||
expect(newOnClick).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1); // Should not be called again
|
||||
});
|
||||
|
||||
it("should render icon alongside text", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
expect(button).toHaveTextContent("Send");
|
||||
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle edge case combinations", () => {
|
||||
// Test disabled=true, working=false
|
||||
const { rerender } = render(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
let button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "false");
|
||||
|
||||
// Test disabled=false, working=true
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
button = screen.getByTestId("progress-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "true");
|
||||
|
||||
// Test disabled=true, working=true
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
});
|
||||
329
src/components/common/__tests__/TextField.test.tsx
Normal file
329
src/components/common/__tests__/TextField.test.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import TextField from "../TextField";
|
||||
|
||||
// Helper function to filter out Chakra UI props
|
||||
const filterChakraProps = (props: Record<string, unknown>) => {
|
||||
const chakraProps = [
|
||||
"alignItems",
|
||||
"justifyContent",
|
||||
"direction",
|
||||
"gap",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pb",
|
||||
"pl",
|
||||
"pr",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mb",
|
||||
"ml",
|
||||
"mr",
|
||||
"w",
|
||||
"h",
|
||||
"maxW",
|
||||
"maxH",
|
||||
"minW",
|
||||
"minH",
|
||||
"bg",
|
||||
"color",
|
||||
"borderRadius",
|
||||
"borderWidth",
|
||||
"borderColor",
|
||||
"borderStyle",
|
||||
"boxShadow",
|
||||
"display",
|
||||
"position",
|
||||
"top",
|
||||
"right",
|
||||
"bottom",
|
||||
"left",
|
||||
"zIndex",
|
||||
"overflow",
|
||||
"textAlign",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
"textDecoration",
|
||||
"opacity",
|
||||
"visibility",
|
||||
"cursor",
|
||||
"pointerEvents",
|
||||
"userSelect",
|
||||
"resize",
|
||||
"outline",
|
||||
"transform",
|
||||
"transformOrigin",
|
||||
"transition",
|
||||
"animation",
|
||||
"colorPalette",
|
||||
"variant",
|
||||
"size",
|
||||
"loading",
|
||||
"disabled",
|
||||
"checked",
|
||||
"selected",
|
||||
"active",
|
||||
"focus",
|
||||
"hover",
|
||||
"flexDirection",
|
||||
"flexWrap",
|
||||
"flex",
|
||||
"flexGrow",
|
||||
"flexShrink",
|
||||
"flexBasis",
|
||||
"alignSelf",
|
||||
"justifySelf",
|
||||
"order",
|
||||
"gridColumn",
|
||||
"gridRow",
|
||||
"gridArea",
|
||||
"gridTemplateColumns",
|
||||
"gridTemplateRows",
|
||||
"gridGap",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"placeItems",
|
||||
"placeContent",
|
||||
"placeSelf",
|
||||
"area",
|
||||
"colSpan",
|
||||
"rowSpan",
|
||||
"start",
|
||||
"end",
|
||||
"required",
|
||||
];
|
||||
const filtered = { ...props };
|
||||
chakraProps.forEach((prop) => delete filtered[prop]);
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Mock Chakra UI components
|
||||
vi.mock("@chakra-ui/react", () => ({
|
||||
Field: {
|
||||
Root: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="field-root" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Label: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<label data-testid="field-label" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
RequiredIndicator: ({ ...props }: Record<string, unknown>) => (
|
||||
<span data-testid="required-indicator" {...filterChakraProps(props)}>
|
||||
*
|
||||
</span>
|
||||
),
|
||||
HelperText: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="helper-text" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
Input: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
...props
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
placeholder?: string;
|
||||
} & Record<string, unknown>) => (
|
||||
<input
|
||||
data-testid="text-input"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
{...filterChakraProps(props)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("TextField", () => {
|
||||
const defaultProps = {
|
||||
label: "Test Label",
|
||||
value: "",
|
||||
onValueChange: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render with label and input", () => {
|
||||
render(<TextField {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("field-label")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("text-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the current value", () => {
|
||||
render(<TextField {...defaultProps} value="test value" />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue("test value");
|
||||
});
|
||||
|
||||
it("should call onValueChange when input value changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnValueChange = vi.fn();
|
||||
|
||||
render(<TextField {...defaultProps} onValueChange={mockOnValueChange} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
await user.type(input, "new value");
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("n");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("e");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("w");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith(" ");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("v");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("a");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("l");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("u");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("e");
|
||||
});
|
||||
|
||||
it("should handle onChange event correctly", () => {
|
||||
const mockOnValueChange = vi.fn();
|
||||
|
||||
render(<TextField {...defaultProps} onValueChange={mockOnValueChange} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
fireEvent.change(input, { target: { value: "test input" } });
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("test input");
|
||||
});
|
||||
|
||||
it("should display placeholder when provided", () => {
|
||||
render(<TextField {...defaultProps} placeholder="Enter text here" />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveAttribute("placeholder", "Enter text here");
|
||||
});
|
||||
|
||||
it("should show required indicator when required is true", () => {
|
||||
render(<TextField {...defaultProps} required />);
|
||||
|
||||
expect(screen.getByTestId("required-indicator")).toBeInTheDocument();
|
||||
expect(screen.getByText("*")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show required indicator when required is false", () => {
|
||||
render(<TextField {...defaultProps} required={false} />);
|
||||
|
||||
expect(screen.queryByTestId("required-indicator")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show required indicator when required is undefined", () => {
|
||||
render(<TextField {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId("required-indicator")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display helper text when provided", () => {
|
||||
render(<TextField {...defaultProps} helperText="This is helper text" />);
|
||||
|
||||
expect(screen.getByTestId("helper-text")).toBeInTheDocument();
|
||||
expect(screen.getByText("This is helper text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display helper text when not provided", () => {
|
||||
render(<TextField {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId("helper-text")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty string value", () => {
|
||||
render(<TextField {...defaultProps} value="" />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should handle special characters in value", () => {
|
||||
const specialValue = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/~`";
|
||||
render(<TextField {...defaultProps} value={specialValue} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue(specialValue);
|
||||
});
|
||||
|
||||
it("should handle multiline text in value", () => {
|
||||
const multilineValue = "Line 1\nLine 2\nLine 3";
|
||||
render(<TextField {...defaultProps} value={multilineValue} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
// Check that the value contains the expected content
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.getAttribute("value")).toContain("Line 1");
|
||||
expect(input.getAttribute("value")).toContain("Line 2");
|
||||
expect(input.getAttribute("value")).toContain("Line 3");
|
||||
});
|
||||
|
||||
it("should render field root structure", () => {
|
||||
render(<TextField {...defaultProps} required />);
|
||||
|
||||
const fieldRoot = screen.getByTestId("field-root");
|
||||
expect(fieldRoot).toBeInTheDocument();
|
||||
expect(fieldRoot).toContainElement(screen.getByTestId("field-label"));
|
||||
expect(fieldRoot).toContainElement(screen.getByTestId("text-input"));
|
||||
});
|
||||
|
||||
it("should handle long text values", () => {
|
||||
const longValue = "a".repeat(1000);
|
||||
render(<TextField {...defaultProps} value={longValue} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue(longValue);
|
||||
});
|
||||
|
||||
it("should handle rapid input changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnValueChange = vi.fn();
|
||||
|
||||
render(<TextField {...defaultProps} onValueChange={mockOnValueChange} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
await user.type(input, "abc", { delay: 1 });
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenCalledTimes(3);
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, "a");
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, "b");
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(3, "c");
|
||||
});
|
||||
|
||||
it("should clear input when value changes to empty string", () => {
|
||||
const { rerender } = render(
|
||||
<TextField {...defaultProps} value="initial value" />,
|
||||
);
|
||||
|
||||
let input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue("initial value");
|
||||
|
||||
rerender(<TextField {...defaultProps} value="" />);
|
||||
|
||||
input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue