Compare commits
5 Commits
@nhost/das
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61a8c6930f | ||
|
|
8f169885f7 | ||
|
|
6104e72204 | ||
|
|
65ca5deb4c | ||
|
|
e42832a012 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.0.5",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
@@ -18,6 +18,7 @@ import PinnedMainNav from '@/components/layout/MainNav/PinnedMainNav';
|
||||
import { OrgStatus } from '@/features/orgs/components/OrgStatus';
|
||||
import { useIsHealthy } from '@/features/orgs/projects/common/hooks/useIsHealthy';
|
||||
import { useNotFoundRedirect } from '@/features/projects/common/hooks/useNotFoundRedirect';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
@@ -90,7 +91,7 @@ export default function AuthenticatedLayout({
|
||||
|
||||
<Container
|
||||
rootClassName="h-full"
|
||||
className="grid justify-center max-w-md grid-flow-row gap-2 my-12 text-center"
|
||||
className="my-12 grid max-w-md grid-flow-row justify-center gap-2 text-center"
|
||||
>
|
||||
<div className="mx-auto">
|
||||
<Image
|
||||
@@ -127,18 +128,23 @@ export default function AuthenticatedLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseLayout className="flex flex-col h-full" {...props}>
|
||||
<BaseLayout className="flex h-full flex-col" {...props}>
|
||||
<Header className="flex py-1" />
|
||||
|
||||
<div
|
||||
className="relative flex flex-row h-full overflow-x-hidden"
|
||||
className="relative flex h-full flex-row overflow-hidden"
|
||||
ref={setMainNavContainer}
|
||||
>
|
||||
{mainNavPinned && isMdOrLarger && <PinnedMainNav />}
|
||||
|
||||
<div className="relative flex flex-row w-full h-full bg-accent">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full w-full flex-row bg-accent',
|
||||
mainNavPinned && isMdOrLarger ? 'overflow-x-auto' : '',
|
||||
)}
|
||||
>
|
||||
{(!mainNavPinned || !isMdOrLarger) && (
|
||||
<div className="flex justify-center w-6 h-full">
|
||||
<div className="flex h-full w-6 justify-center">
|
||||
<MainNav container={mainNavContainer} />
|
||||
</div>
|
||||
)}
|
||||
@@ -148,7 +154,7 @@ export default function AuthenticatedLayout({
|
||||
className: 'flex flex-col items-center',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div className="flex h-full w-full flex-col overflow-auto">
|
||||
<OrgStatus />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function PinnedMainNav() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full border-r p-0 sm:max-w-[310px]">
|
||||
<div className="flex h-full w-full flex-shrink-0 flex-col border-r p-0 sm:max-w-[310px]">
|
||||
<div className="flex justify-end w-full h-12 p-1 border-b bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function SettingsLayout({ children }: SettingsLayoutProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex flex-col flex-auto w-full overflow-x-hidden overflow-y-auto"
|
||||
className="flex flex-col flex-auto w-full h-full overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function ServiceForm({
|
||||
memory: 128,
|
||||
},
|
||||
replicas: 1,
|
||||
autoscaler: null,
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
@@ -98,7 +99,7 @@ export default function ServiceForm({
|
||||
return uuidv4();
|
||||
}, [serviceID]);
|
||||
|
||||
const privateRegistryImage = `registry.${project.region.name}.${project.region.domain}/${newServiceID}`;
|
||||
const privateRegistryImage = `registry.${project?.region.name}.${project?.region.domain}/${newServiceID}`;
|
||||
|
||||
let initialImageType: 'public' | 'private' | 'nhost' = 'public';
|
||||
|
||||
@@ -166,6 +167,11 @@ export default function ServiceForm({
|
||||
capacity: item.capacity,
|
||||
})),
|
||||
replicas: sanitizedValues.replicas,
|
||||
autoscaler: sanitizedValues.autoscaler
|
||||
? {
|
||||
maxReplicas: sanitizedValues.autoscaler?.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
environment: sanitizedValues.environment.map((item) => ({
|
||||
name: item.name,
|
||||
@@ -329,7 +335,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -353,7 +359,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -408,7 +414,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
|
||||
@@ -26,6 +26,12 @@ export const validationSchema = Yup.object({
|
||||
memory: Yup.number().min(MIN_SERVICES_MEM).max(MAX_SERVICES_MEM).required(),
|
||||
}),
|
||||
replicas: Yup.number().min(0).max(MAX_SERVICE_REPLICAS).required(),
|
||||
autoscaler: Yup.object()
|
||||
.shape({
|
||||
maxReplicas: Yup.number().min(0).max(MAX_SERVICE_REPLICAS),
|
||||
})
|
||||
.nullable()
|
||||
.default(undefined),
|
||||
ports: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
port: Yup.number().required(),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Option } from '@/components/ui/v2/Option';
|
||||
import { Select } from '@/components/ui/v2/Select';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import { PortTypes } from '@/features/services/components/ServiceForm/components/PortsFormSection/PortsFormSectionTypes';
|
||||
import { type ServiceFormValues } from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
@@ -19,7 +19,7 @@ import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
export default function PortsFormSection() {
|
||||
const form = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { project } = useProject();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -38,12 +38,13 @@ export default function PortsFormSection() {
|
||||
|
||||
const showURL = (index: number) =>
|
||||
formValues.subdomain &&
|
||||
formValues.ports[index]?.type === PortTypes.HTTP &&
|
||||
(formValues.ports[index]?.type === PortTypes.HTTP ||
|
||||
formValues.ports[index]?.type === PortTypes.GRPC) &&
|
||||
formValues.ports[index]?.publish;
|
||||
|
||||
return (
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="flex flex-row items-center justify-between">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Ports
|
||||
@@ -106,7 +107,7 @@ export default function PortsFormSection() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{['http', 'tcp', 'udp']?.map((portType) => (
|
||||
{['http', 'tcp', 'udp', 'grpc']?.map((portType) => (
|
||||
<Option key={portType} value={portType}>
|
||||
{portType}
|
||||
</Option>
|
||||
@@ -137,8 +138,8 @@ export default function PortsFormSection() {
|
||||
title="URL"
|
||||
value={getRunServicePortURL(
|
||||
formValues?.subdomain,
|
||||
currentProject?.region.name,
|
||||
currentProject?.region.domain,
|
||||
project?.region.name,
|
||||
project?.region.domain,
|
||||
formValues.ports[index],
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Slider } from '@/components/ui/v2/Slider';
|
||||
import { InfoOutlinedIcon } from '@/components/ui/v2/icons/InfoOutlinedIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { MAX_SERVICE_REPLICAS } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function ReplicasFormSection() {
|
||||
const { setValue } = useFormContext<ServiceFormValues>();
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
trigger: triggerValidation,
|
||||
} = useFormContext<ServiceFormValues>();
|
||||
const { replicas, autoscaler } = useWatch<ServiceFormValues>();
|
||||
const [autoscalerEnabled, setAutoscalerEnabled] = useState(!!autoscaler);
|
||||
|
||||
const { replicas } = useWatch<ServiceFormValues>();
|
||||
const toggleAutoscalerEnabled = async (enabled: boolean) => {
|
||||
setAutoscalerEnabled(enabled);
|
||||
|
||||
if (!enabled) {
|
||||
setValue('autoscaler', null);
|
||||
} else {
|
||||
setValue('autoscaler.maxReplicas', 10);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplicasChange = (value: string) => {
|
||||
const updatedReplicas = parseInt(value, 10);
|
||||
@@ -20,42 +36,95 @@ export default function ReplicasFormSection() {
|
||||
// TODO Trigger revalidate storage
|
||||
};
|
||||
|
||||
const handleMaxReplicasChange = (value: string) => {
|
||||
const updatedReplicas = parseInt(value, 10);
|
||||
|
||||
setValue('autoscaler.maxReplicas', updatedReplicas, { shouldDirty: true });
|
||||
|
||||
triggerValidation('autoscaler.maxReplicas');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Replicas ({replicas})
|
||||
</Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Number of replicas for the service. Multiple replicas can process
|
||||
requests/work in parallel. You can set replicas to 0 to pause the
|
||||
service. Refer to{' '}
|
||||
<Text className="text-white">
|
||||
Learn more about{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/resources"
|
||||
href="https://docs.nhost.io/platform/service-replicas"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</span>
|
||||
Service Replicas
|
||||
</a>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Slider
|
||||
value={replicas}
|
||||
onChange={(_event, value) => handleReplicasChange(value.toString())}
|
||||
min={0}
|
||||
max={MAX_SERVICE_REPLICAS}
|
||||
step={1}
|
||||
aria-label="Replicas"
|
||||
marks
|
||||
/>
|
||||
|
||||
<Box className="flex flex-col justify-between gap-4 lg:flex-row">
|
||||
<Box className="flex flex-col gap-4 lg:flex-row lg:gap-8">
|
||||
<Box className="flex flex-row items-center gap-2">
|
||||
<Text className="w-28 lg:w-auto">Replicas</Text>
|
||||
<Input
|
||||
{...register('replicas')}
|
||||
onChange={(event) => handleReplicasChange(event.target.value)}
|
||||
type="number"
|
||||
id="replicas"
|
||||
placeholder="Replicas"
|
||||
className="max-w-28"
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
onWheel={(e) => (e.target as HTMLInputElement).blur()}
|
||||
autoComplete="off"
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box className="flex flex-row items-center gap-2">
|
||||
<Text className="w-28 text-nowrap lg:w-auto">Max Replicas</Text>
|
||||
<Input
|
||||
value={autoscaler?.maxReplicas}
|
||||
onChange={(event) => handleMaxReplicasChange(event.target.value)}
|
||||
type="number"
|
||||
id="maxReplicas"
|
||||
placeholder="10"
|
||||
disabled={!autoscalerEnabled}
|
||||
className="max-w-28"
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
onWheel={(e) => (e.target as HTMLInputElement).blur()}
|
||||
autoComplete="off"
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className="flex flex-row items-center gap-3">
|
||||
<Switch
|
||||
checked={autoscalerEnabled}
|
||||
onChange={(e) => toggleAutoscalerEnabled(e.target.checked)}
|
||||
className="self-center"
|
||||
/>
|
||||
<Text>Autoscaler</Text>
|
||||
<Tooltip title="Enable autoscaler to automatically provision extra run service replicas when needed.">
|
||||
<InfoOutlinedIcon className="w-4 h-4 text-black" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function ServicesList({
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<CubeIcon className="w-5 h-5" />
|
||||
<Text>Edit {service.config?.name ?? 'unset'}</Text>
|
||||
</Box>
|
||||
),
|
||||
@@ -75,6 +75,7 @@ export default function ServicesList({
|
||||
memory: 128,
|
||||
},
|
||||
replicas: service.config?.resources?.replicas,
|
||||
autoscaler: service?.config?.resources?.autoscaler,
|
||||
storage: service.config?.resources?.storage,
|
||||
}}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
@@ -109,13 +110,13 @@ export default function ServicesList({
|
||||
onClick={() => viewService(service)}
|
||||
>
|
||||
<Box
|
||||
className="flex w-full flex-row justify-between"
|
||||
className="flex flex-row justify-between w-full"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<div className="flex flex-row items-center flex-1 space-x-4">
|
||||
<CubeIcon className="w-5 h-5" />
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{service.config?.name ?? 'unset'}
|
||||
@@ -131,7 +132,7 @@ export default function ServicesList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-row items-center space-x-2 md:flex">
|
||||
<div className="flex-row items-center hidden space-x-2 md:flex">
|
||||
<Text variant="subtitle1" className="font-mono text-xs">
|
||||
{service.id ?? service.serviceID}
|
||||
</Text>
|
||||
@@ -144,7 +145,7 @@ export default function ServicesList({
|
||||
}}
|
||||
aria-label="Service Id"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Box>
|
||||
@@ -174,7 +175,7 @@ export default function ServicesList({
|
||||
onClick={() => viewService(service)}
|
||||
className="z-50 grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<UserIcon className="w-4 h-4" />
|
||||
<Text className="font-medium">View Service</Text>
|
||||
</Dropdown.Item>
|
||||
<Divider component="li" />
|
||||
@@ -187,7 +188,7 @@ export default function ServicesList({
|
||||
}}
|
||||
disabled={!isPlatform}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
Delete Service
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import type { Column } from 'react-table';
|
||||
import { expect, test } from 'vitest';
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
interface MockDataDetails {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const mockColumns: Column<MockDataDetails>[] = [
|
||||
{ id: 'id', Header: 'ID', accessor: 'id' },
|
||||
{ id: 'name', Header: 'Name', accessor: 'name' },
|
||||
];
|
||||
|
||||
const mockData: MockDataDetails[] = [
|
||||
{ id: 1, name: 'foo' },
|
||||
{ id: 2, name: 'bar' },
|
||||
];
|
||||
|
||||
test('should render an empty state if columns are not available', () => {
|
||||
render(<DataGrid columns={[]} data={[]} />);
|
||||
|
||||
expect(screen.getByText(/columns not found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render columns and empty state message if data is unavailable', () => {
|
||||
render(<DataGrid columns={mockColumns} data={[]} />);
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('columnheader', { name: /id/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /name/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/no data is available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render custom empty state message if data is unavailable', () => {
|
||||
const customEmptyStateMessage = 'custom empty state message';
|
||||
|
||||
render(
|
||||
<DataGrid
|
||||
columns={mockColumns}
|
||||
data={[]}
|
||||
emptyStateMessage={customEmptyStateMessage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(customEmptyStateMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display a loading indicator', async () => {
|
||||
render(<DataGrid columns={mockColumns} data={[]} loading />);
|
||||
|
||||
// Activity indicator is not immediately displayed, so we need to wait
|
||||
expect(await screen.findByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render data if provided', () => {
|
||||
render(<DataGrid columns={mockColumns} data={mockData} />);
|
||||
|
||||
expect(screen.getAllByRole('row')).toHaveLength(2);
|
||||
expect(screen.getByRole('cell', { name: /1/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /foo/i })).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { UseDataGridOptions } from '@/components/dataGrid/DataGrid/useDataGrid';
|
||||
import { DataGridBody } from '@/components/dataGrid/DataGridBody';
|
||||
import { DataGridConfigProvider } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import { DataGridFrame } from '@/components/dataGrid/DataGridFrame';
|
||||
import type { DataGridHeaderProps } from '@/components/dataGrid/DataGridHeader';
|
||||
import { DataGridHeader } from '@/components/dataGrid/DataGridHeader';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { DataBrowserEmptyState } from '@/features/database/dataGrid/components/DataBrowserEmptyState';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useEffect, useRef } from 'react';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import type { Column, Row, SortingRule, TableOptions } from 'react-table';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import useDataGrid from './useDataGrid';
|
||||
|
||||
export interface DataGridProps<TColumnData extends object>
|
||||
extends Omit<UseDataGridOptions<TColumnData>, 'tableRef'> {
|
||||
/**
|
||||
* Available columns.
|
||||
*/
|
||||
columns: Column<TColumnData>[];
|
||||
/**
|
||||
* Data to be displayed in the table.
|
||||
*/
|
||||
data: any[];
|
||||
/**
|
||||
* Text to be displayed when no data is available in the data grid.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
emptyStateMessage?: string;
|
||||
/**
|
||||
* Additional configuration options for the `react-table` hook.
|
||||
*/
|
||||
options?: Omit<TableOptions<TColumnData>, 'columns' | 'data'>;
|
||||
/**
|
||||
* Additional data grid controls. This component will be part of the Data Grid
|
||||
* context, so it can use Data Grid configuration.
|
||||
*/
|
||||
controls?:
|
||||
| React.ReactNode
|
||||
| ((selectedFlatRows: Row<TColumnData>[]) => React.ReactNode);
|
||||
/**
|
||||
* Function to be called when columns are sorted in the table.
|
||||
*/
|
||||
onSort?: (args: SortingRule<TColumnData>[]) => void;
|
||||
/**
|
||||
* Function to be called when the user wants to insert a new row.
|
||||
*/
|
||||
onInsertRow?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the user wants to insert a new column.
|
||||
*/
|
||||
onInsertColumn?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the user wants to remove a column.
|
||||
*/
|
||||
onRemoveColumn?: (column: DataBrowserGridColumn<TColumnData>) => void;
|
||||
/**
|
||||
* Function to be called when the user wants to edit a column.
|
||||
*/
|
||||
onEditColumn?: (column: DataBrowserGridColumn<TColumnData>) => void;
|
||||
/**
|
||||
* Determines whether or not data is loading.
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Class name to be applied to the data grid.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Sort configuration.
|
||||
*/
|
||||
sortBy?: SortingRule<TColumnData>[];
|
||||
/**
|
||||
* Props to be passed to the `DataGridHeader` component.
|
||||
*/
|
||||
headerProps?: DataGridHeaderProps<TColumnData>;
|
||||
}
|
||||
|
||||
function DataGrid<TColumnData extends object>(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
allowSelection,
|
||||
allowSort,
|
||||
allowResize,
|
||||
emptyStateMessage,
|
||||
options = {},
|
||||
headerProps,
|
||||
controls,
|
||||
sortBy,
|
||||
onSort,
|
||||
onInsertRow,
|
||||
onInsertColumn,
|
||||
onEditColumn,
|
||||
onRemoveColumn,
|
||||
loading,
|
||||
className,
|
||||
}: DataGridProps<TColumnData>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const tableRef = useRef<HTMLDivElement>();
|
||||
const { toggleAllRowsSelected, setSortBy, ...dataGridProps } =
|
||||
useDataGrid<TColumnData>({
|
||||
columns: columns || [],
|
||||
data: data || [],
|
||||
allowSelection,
|
||||
allowSort,
|
||||
allowResize,
|
||||
...options,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortBy && setSortBy) {
|
||||
setSortBy([]);
|
||||
}
|
||||
}, [setSortBy, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSort && allowSort) {
|
||||
onSort(dataGridProps.state.sortBy);
|
||||
|
||||
if (toggleAllRowsSelected) {
|
||||
toggleAllRowsSelected(false);
|
||||
}
|
||||
}
|
||||
}, [allowSort, dataGridProps.state.sortBy, onSort, toggleAllRowsSelected]);
|
||||
|
||||
return (
|
||||
<DataGridConfigProvider
|
||||
toggleAllRowsSelected={toggleAllRowsSelected}
|
||||
setSortBy={setSortBy}
|
||||
tableRef={tableRef}
|
||||
{...dataGridProps}
|
||||
>
|
||||
<>
|
||||
{controls}
|
||||
|
||||
{columns.length === 0 && !loading && (
|
||||
<DataBrowserEmptyState
|
||||
title="Columns not found"
|
||||
description="Please create a column before adding data to the table."
|
||||
/>
|
||||
)}
|
||||
|
||||
{columns.length > 0 && (
|
||||
<Box
|
||||
ref={mergeRefs([ref, tableRef])}
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className={twMerge(
|
||||
'overflow-x-auto',
|
||||
!loading && 'h-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DataGridFrame>
|
||||
<DataGridHeader
|
||||
onInsertColumn={onInsertColumn}
|
||||
onEditColumn={onEditColumn}
|
||||
onRemoveColumn={onRemoveColumn}
|
||||
{...headerProps}
|
||||
/>
|
||||
|
||||
<DataGridBody
|
||||
emptyStateMessage={emptyStateMessage}
|
||||
loading={loading}
|
||||
onInsertRow={onInsertRow}
|
||||
allowInsertColumn={Boolean(onRemoveColumn)}
|
||||
/>
|
||||
</DataGridFrame>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading && <ActivityIndicator delay={1000} className="my-4" />}
|
||||
</>
|
||||
</DataGridConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(DataGrid) as <TColumnData extends object>(
|
||||
props: DataGridProps<TColumnData> & { ref?: ForwardedRef<HTMLDivElement> },
|
||||
) => ReturnType<typeof DataGrid>;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './DataGrid';
|
||||
export { default as DataGrid } from './DataGrid';
|
||||
export * from './useDataGrid';
|
||||
export { default as useDataGrid } from './useDataGrid';
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { PluginHook, TableInstance, TableOptions } from 'react-table';
|
||||
import {
|
||||
useBlockLayout,
|
||||
useResizeColumns,
|
||||
useRowSelect,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
|
||||
export interface UseDataGridBaseOptions {
|
||||
/**
|
||||
* Determines whether data grid columns are selectable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowSelection?: boolean;
|
||||
/**
|
||||
* Determines whether data grid columns are sortable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowSort?: boolean;
|
||||
/**
|
||||
* Determine whether data grid columns are resizable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowResize?: boolean;
|
||||
/**
|
||||
* Reference to the data grid root element.
|
||||
*/
|
||||
tableRef?: MutableRefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export type UseDataGridOptions<T extends object = {}> = TableOptions<T> &
|
||||
UseDataGridBaseOptions;
|
||||
export type UseDataGridReturn<T extends object = {}> = TableInstance<T> &
|
||||
UseDataGridBaseOptions;
|
||||
|
||||
export default function useDataGrid<T extends object>(
|
||||
{ allowSelection, allowSort, allowResize, ...options }: UseDataGridOptions<T>,
|
||||
...plugins: PluginHook<T>[]
|
||||
): UseDataGridReturn<T> {
|
||||
const defaultColumn = useMemo(
|
||||
() => ({
|
||||
width: 32,
|
||||
minWidth: 32,
|
||||
Cell: ({ value }: { value: any }) => (
|
||||
<span className="truncate">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : value}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const pluginHooks = [
|
||||
useBlockLayout,
|
||||
useResizeColumns,
|
||||
useSortBy,
|
||||
useRowSelect,
|
||||
];
|
||||
|
||||
const tableData = useTable<T>(
|
||||
{
|
||||
defaultColumn,
|
||||
...options,
|
||||
},
|
||||
...pluginHooks,
|
||||
...plugins,
|
||||
(hooks) =>
|
||||
allowSelection
|
||||
? hooks.visibleColumns.push((columns) => [
|
||||
{
|
||||
id: 'selection',
|
||||
Header: ({ rows, getToggleAllRowsSelectedProps }: any) => (
|
||||
<Checkbox
|
||||
disabled={rows.length === 0}
|
||||
{...getToggleAllRowsSelectedProps({ style: null })}
|
||||
style={{
|
||||
...getToggleAllRowsSelectedProps().style,
|
||||
cursor: rows.length === 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
Cell: ({ row }: any) => {
|
||||
const originalValue = row.original as any;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
{...row.getToggleRowSelectedProps()}
|
||||
// disable selection if row is just a upload preview
|
||||
checked={originalValue.uploading ? false : row.isSelected}
|
||||
disabled={originalValue.uploading}
|
||||
/>
|
||||
);
|
||||
},
|
||||
disableSortBy: true,
|
||||
disableResizing: true,
|
||||
},
|
||||
...columns,
|
||||
])
|
||||
: hooks.visibleColumns,
|
||||
);
|
||||
|
||||
return { ...tableData, allowSort, allowResize, allowSelection };
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
||||
import { DataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import type { DetailedHTMLProps, HTMLProps, KeyboardEvent } from 'react';
|
||||
import { Fragment, useMemo, useRef } from 'react';
|
||||
import type { Row } from 'react-table';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DataGridBodyProps<T extends object>
|
||||
extends Omit<
|
||||
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
||||
'children'
|
||||
>,
|
||||
Pick<DataGridProps<T>, 'onInsertRow' | 'emptyStateMessage' | 'loading'> {
|
||||
/**
|
||||
* Determines whether column insertion is allowed.
|
||||
*/
|
||||
allowInsertColumn?: boolean;
|
||||
}
|
||||
|
||||
interface InsertPlaceholderTableRowProps extends BoxProps {
|
||||
/**
|
||||
* Function to be called when the user wants to insert a new row.
|
||||
*/
|
||||
onInsertRow: VoidFunction;
|
||||
}
|
||||
|
||||
function InsertPlaceholderTableRow({
|
||||
onInsertRow,
|
||||
...props
|
||||
}: InsertPlaceholderTableRowProps) {
|
||||
return (
|
||||
<Box className="h-12 border-b-1 border-r-1" {...props}>
|
||||
<Button
|
||||
onClick={onInsertRow}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="h-full w-full justify-start rounded-none px-2 py-3 text-xs font-normal hover:shadow-none focus:shadow-none focus:outline-none"
|
||||
startIcon={
|
||||
<PlusIcon className="h-4 w-4" sx={{ color: 'text.secondary' }} />
|
||||
}
|
||||
>
|
||||
Insert New Row
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Get rid of Data Browser related code from here. This component should
|
||||
// be generic and not depend on Data Browser related data types and logic.
|
||||
export default function DataGridBody<T extends object>({
|
||||
emptyStateMessage = 'No data is available',
|
||||
loading,
|
||||
onInsertRow,
|
||||
allowInsertColumn,
|
||||
...props
|
||||
}: DataGridBodyProps<T>) {
|
||||
const { getTableBodyProps, totalColumnsWidth, rows, prepareRow, columns } =
|
||||
useDataGridConfig<T>();
|
||||
|
||||
const SELECTION_CELL_WIDTH = 32;
|
||||
const ADD_COLUMN_CELL_WIDTH = 100;
|
||||
const bodyRef = useRef<HTMLDivElement>();
|
||||
|
||||
const primaryAndUniqueKeys = useMemo(
|
||||
() =>
|
||||
columns
|
||||
.filter(
|
||||
(column: DataBrowserGridColumn<T>) =>
|
||||
column.isPrimary || column.isUnique,
|
||||
)
|
||||
.map((column) => column.id),
|
||||
[columns],
|
||||
);
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>, row: Row<T>) {
|
||||
const { id: rowId } = row;
|
||||
const cellId = document.activeElement.id;
|
||||
|
||||
const currentRow = bodyRef.current.children.namedItem(rowId);
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentRow.previousElementSibling) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cellInPreviousRow =
|
||||
currentRow.previousElementSibling.children.namedItem(cellId);
|
||||
|
||||
if (cellInPreviousRow instanceof HTMLElement) {
|
||||
cellInPreviousRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
cellInPreviousRow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentRow.nextElementSibling) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cellInNextRow =
|
||||
currentRow.nextElementSibling.children.namedItem(cellId);
|
||||
|
||||
if (cellInNextRow instanceof HTMLElement) {
|
||||
cellInNextRow.scrollIntoView({ block: 'nearest' });
|
||||
cellInNextRow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' || (event.shiftKey && event.key === 'Tab')) {
|
||||
let previousFocusableCellInRow: HTMLElement;
|
||||
let previousFocusableCellInRowFound = false;
|
||||
|
||||
currentRow.childNodes.forEach((node) => {
|
||||
if (node === currentRow.children.namedItem(cellId)) {
|
||||
previousFocusableCellInRowFound = true;
|
||||
}
|
||||
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.tabIndex > -1 &&
|
||||
!previousFocusableCellInRowFound
|
||||
) {
|
||||
previousFocusableCellInRow = node;
|
||||
}
|
||||
});
|
||||
|
||||
if (previousFocusableCellInRow) {
|
||||
event.preventDefault();
|
||||
|
||||
previousFocusableCellInRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
});
|
||||
previousFocusableCellInRow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'ArrowRight' ||
|
||||
(!event.shiftKey && event.key === 'Tab')
|
||||
) {
|
||||
let nextFocusableCellInRow: HTMLElement;
|
||||
let nextFocusableCellInRowFound = false;
|
||||
|
||||
currentRow.childNodes.forEach((node) => {
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.tabIndex > -1 &&
|
||||
parseInt(node.id, 10) > parseInt(cellId, 10) &&
|
||||
!nextFocusableCellInRowFound
|
||||
) {
|
||||
nextFocusableCellInRowFound = true;
|
||||
nextFocusableCellInRow = node;
|
||||
}
|
||||
});
|
||||
|
||||
if (nextFocusableCellInRow) {
|
||||
event.preventDefault();
|
||||
|
||||
nextFocusableCellInRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
});
|
||||
nextFocusableCellInRow.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getBackgroundCellColor = (
|
||||
row: Row<T>,
|
||||
column: DataBrowserGridColumn<T>,
|
||||
) => {
|
||||
// Grey out files not uploaded
|
||||
if (!row.values.isUploaded) {
|
||||
return 'grey.200';
|
||||
}
|
||||
|
||||
if (column.isDisabled) {
|
||||
return 'grey.100';
|
||||
}
|
||||
|
||||
return 'background.paper';
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
|
||||
{rows.length === 0 && !loading && (
|
||||
<div className="flex flex-nowrap pr-5">
|
||||
{onInsertRow ? (
|
||||
<InsertPlaceholderTableRow
|
||||
style={{
|
||||
width: allowInsertColumn
|
||||
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
|
||||
: totalColumnsWidth - SELECTION_CELL_WIDTH,
|
||||
}}
|
||||
onInsertRow={onInsertRow}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
className="inline-flex h-12 items-center border-b-1 border-r-1 px-2 py-1.5 text-xs"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
style={{
|
||||
width: allowInsertColumn
|
||||
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
|
||||
: totalColumnsWidth,
|
||||
}}
|
||||
>
|
||||
{emptyStateMessage}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.map((row, index) => {
|
||||
let rowKey = index.toString();
|
||||
|
||||
if (primaryAndUniqueKeys && primaryAndUniqueKeys.length > 0) {
|
||||
rowKey = primaryAndUniqueKeys
|
||||
.map((key) => row.values[key])
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
} else {
|
||||
rowKey = `${index}-${Object.keys(row.values)
|
||||
.map((key) => String(row.values[key]))
|
||||
.join('-')}`;
|
||||
}
|
||||
|
||||
prepareRow(row);
|
||||
|
||||
const rowProps = row.getRowProps({
|
||||
style: {
|
||||
width: allowInsertColumn
|
||||
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
|
||||
: totalColumnsWidth,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment key={rowKey.toString()}>
|
||||
<div
|
||||
{...rowProps}
|
||||
id={row.id}
|
||||
className="flex scroll-mt-10"
|
||||
role="row"
|
||||
onKeyDown={(event) => handleKeyDown(event, row)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
const column = cell.column as DataBrowserGridColumn<T>;
|
||||
const isCellDisabled =
|
||||
cell.value !== 0 &&
|
||||
!cell.value &&
|
||||
column.type !== 'boolean' &&
|
||||
column.id !== 'selection' &&
|
||||
column.isDisabled;
|
||||
|
||||
return (
|
||||
<DataGridCell
|
||||
{...cell.getCellProps({
|
||||
style: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})}
|
||||
cell={cell}
|
||||
sx={{
|
||||
backgroundColor: getBackgroundCellColor(row, column),
|
||||
color: isCellDisabled ? 'text.secondary' : 'text.primary',
|
||||
}}
|
||||
className={twMerge(
|
||||
'h-12 font-display text-xs motion-safe:transition-colors',
|
||||
'border-b-1 border-r-1',
|
||||
'scroll-ml-8 scroll-mt-[57px]',
|
||||
column.id === 'selection' &&
|
||||
'sticky left-0 z-20 justify-center px-0',
|
||||
)}
|
||||
isEditable={!column.isDisabled && column.isEditable}
|
||||
id={cellIndex.toString()}
|
||||
key={column.id}
|
||||
>
|
||||
{cell.render('Cell')}
|
||||
</DataGridCell>
|
||||
);
|
||||
})}
|
||||
|
||||
{allowInsertColumn && (
|
||||
<Box className="h-12 w-25 border-b-1 border-r-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onInsertRow && index === rows.length - 1 && (
|
||||
<InsertPlaceholderTableRow
|
||||
{...rowProps}
|
||||
key=""
|
||||
onInsertRow={onInsertRow}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridBody';
|
||||
export { default as DataGridBody } from './DataGridBody';
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type DataGridBooleanCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, boolean | null>;
|
||||
|
||||
export default function DataGridBooleanCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
cell: {
|
||||
column: { isNullable },
|
||||
},
|
||||
}: DataGridBooleanCellProps<TData>) {
|
||||
const {
|
||||
inputRef,
|
||||
isEditing,
|
||||
focusCell,
|
||||
editCell,
|
||||
cancelEditCell,
|
||||
isSelected,
|
||||
} = useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleMenuClick(
|
||||
event: MouseEvent<HTMLLIElement> | ReactKeyboardEvent<HTMLLIElement>,
|
||||
value: boolean | null,
|
||||
) {
|
||||
event.stopPropagation();
|
||||
await onSave(value);
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
async function handleMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// We need to restore the temporary value, because editing was cancelled
|
||||
if (event.key === 'Escape' && onTemporaryValueChange) {
|
||||
event.stopPropagation();
|
||||
|
||||
onTemporaryValueChange(optimisticValue);
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' && onSave) {
|
||||
await onSave(temporaryValue);
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTemporaryValueChange(value: boolean | null) {
|
||||
if (onTemporaryValueChange) {
|
||||
onTemporaryValueChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
return isSelected ? (
|
||||
<Dropdown.Root id="boolean-data-editor" className="h-full w-full">
|
||||
<Dropdown.Trigger
|
||||
id="boolean-trigger"
|
||||
className={twMerge(
|
||||
'h-full w-full border-none p-0 outline-none',
|
||||
isEditing && 'p-1.5',
|
||||
)}
|
||||
ref={inputRef}
|
||||
onClick={editCell}
|
||||
autoFocus={false}
|
||||
sx={{ '&:hover': { backgroundColor: 'transparent !important' } }}
|
||||
>
|
||||
<ReadOnlyToggle checked={optimisticValue} />
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
disablePortal
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
PaperProps={{ className: 'w-[200px]' }}
|
||||
TransitionProps={{ onExited: focusCell }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
selected={optimisticValue === true}
|
||||
onKeyUp={() => handleTemporaryValueChange(true)}
|
||||
onClick={(event) => handleMenuClick(event, true)}
|
||||
>
|
||||
<ReadOnlyToggle checked />
|
||||
</Dropdown.Item>
|
||||
|
||||
<Dropdown.Item
|
||||
selected={optimisticValue === false}
|
||||
onKeyUp={() => handleTemporaryValueChange(false)}
|
||||
onClick={(event) => handleMenuClick(event, false)}
|
||||
>
|
||||
<ReadOnlyToggle checked={false} />
|
||||
</Dropdown.Item>
|
||||
|
||||
{isNullable && (
|
||||
<Dropdown.Item
|
||||
selected={optimisticValue === null}
|
||||
onKeyUp={() => handleTemporaryValueChange(null)}
|
||||
onClick={(event) => handleMenuClick(event, null)}
|
||||
>
|
||||
<ReadOnlyToggle checked={null} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
) : (
|
||||
<ReadOnlyToggle checked={optimisticValue} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridBooleanCell';
|
||||
export { default as DataGridBooleanCell } from './DataGridBooleanCell';
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Tooltip, useTooltip } from '@/components/ui/v2/Tooltip';
|
||||
import type {
|
||||
ColumnType,
|
||||
DataBrowserGridCell,
|
||||
DataBrowserGridCellProps,
|
||||
} from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import type {
|
||||
FocusEvent,
|
||||
JSXElementConstructor,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
ReactPortal,
|
||||
} from 'react';
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import DataGridCellProvider from './DataGridCellProvider';
|
||||
import useDataGridCell from './useDataGridCell';
|
||||
|
||||
export interface CommonDataGridCellProps<TData extends object, TValue = any>
|
||||
extends DataBrowserGridCellProps<TData, TValue> {
|
||||
/**
|
||||
* Function that is called when the cell is saved.
|
||||
*/
|
||||
onSave?: (value: TValue, options?: { reset: boolean }) => Promise<void>;
|
||||
/**
|
||||
* Optimistic value for the cell.
|
||||
*/
|
||||
optimisticValue?: TValue;
|
||||
/**
|
||||
* Function to be called when the optimistic value should be changed.
|
||||
*/
|
||||
onOptimisticValueChange?: (value: TValue) => void;
|
||||
/**
|
||||
* Temporary value for the cell. This is used for storing the current input
|
||||
* value, that should be later saved as an optimistic value before saving the
|
||||
* data.
|
||||
*/
|
||||
temporaryValue?: TValue;
|
||||
/**
|
||||
* Function to be called when the temporary value should be changed.
|
||||
*/
|
||||
onTemporaryValueChange?: (value: TValue) => void;
|
||||
}
|
||||
|
||||
export interface DataGridCellProps<TData extends object, TValue = unknown>
|
||||
extends BoxProps {
|
||||
/**
|
||||
* Current cell's props.
|
||||
*/
|
||||
cell: DataBrowserGridCell<TData, TValue>;
|
||||
/**
|
||||
* Determines whether the cell is editable.
|
||||
*/
|
||||
isEditable?: boolean;
|
||||
/**
|
||||
* Determines the column's type.
|
||||
*/
|
||||
columnType?: ColumnType;
|
||||
}
|
||||
|
||||
function DataGridCellContent<TData extends object = {}, TValue = unknown>({
|
||||
isEditable,
|
||||
children,
|
||||
className,
|
||||
cell: {
|
||||
value: originalValue,
|
||||
column: { onCellEdit, id, isNullable, isPrimary, type },
|
||||
row,
|
||||
},
|
||||
...props
|
||||
}: DataGridCellProps<TData, TValue>) {
|
||||
const { openAlertDialog } = useDialog();
|
||||
|
||||
const {
|
||||
title: tooltipTitle,
|
||||
open: tooltipOpen,
|
||||
openTooltip,
|
||||
closeTooltip,
|
||||
resetTooltipTitle,
|
||||
} = useTooltip();
|
||||
|
||||
const [optimisticValue, setOptimisticValue] = useState<TValue>(originalValue);
|
||||
const [temporaryValue, setTemporaryValue] = useState<TValue>(originalValue);
|
||||
|
||||
useEffect(() => {
|
||||
setOptimisticValue(originalValue);
|
||||
setTemporaryValue(originalValue);
|
||||
}, [originalValue]);
|
||||
|
||||
const {
|
||||
cellRef,
|
||||
inputRef,
|
||||
focusCell,
|
||||
focusInput,
|
||||
blurInput,
|
||||
clickInput,
|
||||
isEditing,
|
||||
isSelected,
|
||||
selectCell,
|
||||
deselectCell,
|
||||
cancelEditCell,
|
||||
editCell,
|
||||
focusPrevCell,
|
||||
focusNextCell,
|
||||
} = useDataGridCell();
|
||||
|
||||
function activateInput() {
|
||||
if (isPrimary) {
|
||||
openTooltip("Primary keys can't be edited.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
editCell();
|
||||
|
||||
if (type === 'boolean') {
|
||||
clickInput();
|
||||
} else {
|
||||
focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick(event: MouseEvent<HTMLDivElement>) {
|
||||
if (!isEditable || isEditing || isPrimary) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.detail === 2 && type !== 'boolean') {
|
||||
editCell();
|
||||
await focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectCell();
|
||||
}
|
||||
|
||||
async function handleSave(
|
||||
value: TValue,
|
||||
options: { reset: boolean } = { reset: false },
|
||||
) {
|
||||
if (!onCellEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedValue =
|
||||
value !== null && typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value);
|
||||
|
||||
const normalizedOptimisticValue =
|
||||
optimisticValue !== null && typeof optimisticValue === 'object'
|
||||
? JSON.stringify(optimisticValue)
|
||||
: String(optimisticValue);
|
||||
|
||||
// We are making sure that optimistic value is not equal to the current
|
||||
// value. If it is, we are not going to save the value.
|
||||
if (
|
||||
normalizedValue.replace(/\n/gi, '\\n') ===
|
||||
normalizedOptimisticValue.replace(/\n/gi, '\\n') &&
|
||||
!options.reset
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In case of an error, we need to reset optimistic value
|
||||
const latestOptimisticValue = optimisticValue;
|
||||
|
||||
setOptimisticValue(value);
|
||||
|
||||
try {
|
||||
const data = await onCellEdit({
|
||||
row,
|
||||
columnsToUpdate: {
|
||||
[id]: {
|
||||
value: !options.reset ? value : undefined,
|
||||
reset: options.reset,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Syncing optimistic value with server-side value
|
||||
setTemporaryValue(data.original[id.toString()]);
|
||||
setOptimisticValue(data.original[id.toString()]);
|
||||
} catch (error) {
|
||||
triggerToast(`Error: ${error.message || 'Unknown error occurred.'}`);
|
||||
|
||||
// Resetting values
|
||||
setTemporaryValue(latestOptimisticValue);
|
||||
setOptimisticValue(latestOptimisticValue);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlur(event: FocusEvent<HTMLDivElement>) {
|
||||
// We are deselecting cell only if focus target is not a descendant of it.
|
||||
if (!isEditable || event.currentTarget.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSave(temporaryValue);
|
||||
closeTooltip();
|
||||
deselectCell();
|
||||
}
|
||||
|
||||
function resetCell() {
|
||||
if (isPrimary) {
|
||||
openTooltip('Primary keys are non-nullable.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNullable) {
|
||||
openTooltip(
|
||||
<span>
|
||||
<strong>{id}</strong>
|
||||
is non-nullable.
|
||||
</span>,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openAlertDialog({
|
||||
title: 'Set value to null',
|
||||
payload: (
|
||||
<p>
|
||||
Are you sure you want to set this cell to <strong>null</strong>?
|
||||
</p>
|
||||
),
|
||||
props: {
|
||||
primaryButtonText: 'Set to null',
|
||||
primaryButtonColor: 'error',
|
||||
onPrimaryAction: async () => {
|
||||
await handleSave(null, { reset: true });
|
||||
focusCell();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
closeTooltip();
|
||||
}
|
||||
|
||||
// Resetting temporary value and focusing cell on Escape when input field is
|
||||
// focused
|
||||
if (event.key === 'Escape' && event.target === inputRef.current) {
|
||||
setTemporaryValue(optimisticValue);
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
// Activating input field on Enter
|
||||
if (event.key === 'Enter' && event.target === cellRef.current) {
|
||||
activateInput();
|
||||
}
|
||||
|
||||
// Focusing next cell on Tab
|
||||
if (event.key === 'Tab' && !event.shiftKey) {
|
||||
event.stopPropagation();
|
||||
const nextCellAvailable = focusNextCell();
|
||||
|
||||
if (!nextCellAvailable) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await blurInput();
|
||||
await focusCell();
|
||||
}
|
||||
}
|
||||
|
||||
// Focusing previous cell on Shift-Tab
|
||||
if (event.key === 'Tab' && event.shiftKey) {
|
||||
event.stopPropagation();
|
||||
const prevCellAvailable = focusPrevCell();
|
||||
|
||||
if (!prevCellAvailable) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await blurInput();
|
||||
await focusCell();
|
||||
}
|
||||
}
|
||||
|
||||
// Initiating cell reset when cell is focused
|
||||
if (event.key === 'Backspace' && event.target === cellRef.current) {
|
||||
resetCell();
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<Box
|
||||
ref={cellRef}
|
||||
className={twMerge(
|
||||
'relative grid h-full w-full cursor-default grid-flow-col items-center gap-1',
|
||||
isEditable &&
|
||||
'focus-within:outline-none focus-within:ring-0 focus:ring-0',
|
||||
isSelected && 'shadow-outline',
|
||||
isEditing ? 'p-0.5 shadow-outline-dark' : 'px-2 py-1.5',
|
||||
className,
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={isEditable ? 0 : undefined}
|
||||
onClick={handleClick}
|
||||
role="textbox"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
{...props}
|
||||
>
|
||||
{Children.map(
|
||||
children,
|
||||
(
|
||||
child:
|
||||
| ReactNode
|
||||
| ReactPortal
|
||||
| ReactElement<unknown, string | JSXElementConstructor<any>>,
|
||||
) => {
|
||||
if (!isValidElement(child)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cloneElement(child, {
|
||||
...child.props,
|
||||
onSave: handleSave,
|
||||
optimisticValue,
|
||||
onOptimisticValueChange: setOptimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange: setTemporaryValue,
|
||||
});
|
||||
},
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isEditable) {
|
||||
return (
|
||||
<Tooltip
|
||||
disableHoverListener
|
||||
disableFocusListener
|
||||
open={tooltipOpen}
|
||||
title={tooltipTitle || ''}
|
||||
TransitionProps={{ onExited: resetTooltipTitle }}
|
||||
>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export default function DataGridCell<TData extends object, TValue = unknown>(
|
||||
props: DataGridCellProps<TData, TValue>,
|
||||
) {
|
||||
return (
|
||||
<DataGridCellProvider>
|
||||
<DataGridCellContent {...props} />
|
||||
</DataGridCellProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { MutableRefObject, PropsWithChildren } from 'react';
|
||||
import { createContext, useCallback, useMemo, useReducer, useRef } from 'react';
|
||||
|
||||
export interface DataGridCellContextProps<T extends HTMLElement> {
|
||||
/**
|
||||
* This `ref` should be attached to the cell element.
|
||||
*/
|
||||
cellRef: MutableRefObject<HTMLDivElement>;
|
||||
/**
|
||||
* This `ref` should be attached to the input element inside the data grid cell.
|
||||
*/
|
||||
inputRef: MutableRefObject<T>;
|
||||
/**
|
||||
* Determines whether or not the cell is currently being edited.
|
||||
*/
|
||||
isEditing: boolean;
|
||||
/**
|
||||
* Determines whether or not the cell is currently selected.
|
||||
*/
|
||||
isSelected: boolean;
|
||||
/**
|
||||
* Function to be called to start editing.
|
||||
*/
|
||||
editCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to cancel editing.
|
||||
*/
|
||||
cancelEditCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to select the cell, but not start editing.
|
||||
*/
|
||||
selectCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to deselect cell and cancel editing.
|
||||
*/
|
||||
deselectCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to focus cell.
|
||||
*/
|
||||
focusCell: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to blur cell.
|
||||
*/
|
||||
blurCell: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to programatically focus the input in the cell.
|
||||
*/
|
||||
focusInput: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to programatically blur the input in the cell.
|
||||
*/
|
||||
blurInput: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to programmatically click the input in the cell.
|
||||
*/
|
||||
clickInput: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to navigate to next cell if available.
|
||||
*
|
||||
* @returns `true` if there is a next cell to focus, `false` otherwise.
|
||||
*/
|
||||
focusNextCell: () => boolean;
|
||||
/**
|
||||
* Function to be called to navigate to previous cell if available.
|
||||
*
|
||||
* @returns `true` if there is a previous cell to focus, `false` otherwise.
|
||||
*/
|
||||
focusPrevCell: () => boolean;
|
||||
}
|
||||
|
||||
export const DataGridCellContext =
|
||||
createContext<DataGridCellContextProps<any>>(null);
|
||||
|
||||
interface EditAndSelectState {
|
||||
isEditing: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
type EditAndSelectAction =
|
||||
| { type: 'EDIT' }
|
||||
| { type: 'CANCEL_EDIT' }
|
||||
| { type: 'SELECT' }
|
||||
| { type: 'DESELECT' };
|
||||
|
||||
function editAndSelectCellReducer(
|
||||
state: EditAndSelectState,
|
||||
action: EditAndSelectAction,
|
||||
): EditAndSelectState {
|
||||
switch (action.type) {
|
||||
case 'EDIT':
|
||||
return { ...state, isEditing: true, isSelected: true };
|
||||
case 'CANCEL_EDIT':
|
||||
return { ...state, isEditing: false };
|
||||
case 'SELECT':
|
||||
return { ...state, isSelected: true };
|
||||
case 'DESELECT':
|
||||
return { ...state, isEditing: false, isSelected: false };
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataGridCellProvider<TInput extends HTMLElement>({
|
||||
children,
|
||||
}: PropsWithChildren<unknown>) {
|
||||
const cellRef = useRef<HTMLDivElement>();
|
||||
const inputRef = useRef<TInput>();
|
||||
const [{ isEditing, isSelected }, dispatch] = useReducer(
|
||||
editAndSelectCellReducer,
|
||||
{
|
||||
isEditing: false,
|
||||
isSelected: false,
|
||||
},
|
||||
);
|
||||
|
||||
function focusCell() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
cellRef.current?.focus();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deselectCell() {
|
||||
dispatch({ type: 'DESELECT' });
|
||||
}
|
||||
|
||||
const focusPrevCell = useCallback(() => {
|
||||
const prevCellAvailable =
|
||||
cellRef.current.previousElementSibling instanceof HTMLElement &&
|
||||
cellRef.current.previousElementSibling.tabIndex > -1;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (prevCellAvailable) {
|
||||
(cellRef.current.previousElementSibling as HTMLElement).focus();
|
||||
deselectCell();
|
||||
}
|
||||
});
|
||||
|
||||
return prevCellAvailable;
|
||||
}, []);
|
||||
|
||||
const focusNextCell = useCallback(() => {
|
||||
const nextCellAvailable =
|
||||
cellRef.current.nextElementSibling instanceof HTMLElement &&
|
||||
cellRef.current.nextElementSibling.tabIndex > -1;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (nextCellAvailable) {
|
||||
(cellRef.current.nextElementSibling as HTMLElement).focus();
|
||||
deselectCell();
|
||||
}
|
||||
});
|
||||
|
||||
return nextCellAvailable;
|
||||
}, []);
|
||||
|
||||
function blurCell() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
cellRef.current?.blur();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function blurInput() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.blur();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clickInput() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.click();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function editCell() {
|
||||
dispatch({ type: 'EDIT' });
|
||||
}
|
||||
|
||||
function cancelEditCell() {
|
||||
dispatch({ type: 'CANCEL_EDIT' });
|
||||
}
|
||||
|
||||
function selectCell() {
|
||||
dispatch({ type: 'SELECT' });
|
||||
}
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
focusCell,
|
||||
blurCell,
|
||||
focusInput,
|
||||
blurInput,
|
||||
clickInput,
|
||||
isEditing,
|
||||
isSelected,
|
||||
editCell,
|
||||
cancelEditCell,
|
||||
selectCell,
|
||||
deselectCell,
|
||||
cellRef,
|
||||
inputRef,
|
||||
focusPrevCell,
|
||||
focusNextCell,
|
||||
}),
|
||||
[focusNextCell, focusPrevCell, isEditing, isSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataGridCellContext.Provider value={value}>
|
||||
{children}
|
||||
</DataGridCellContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './DataGridCell';
|
||||
export { default as DataGridCell } from './DataGridCell';
|
||||
export * from './DataGridCellProvider';
|
||||
export { default as DataGridCellProvider } from './DataGridCellProvider';
|
||||
export { default as useDataGridCell } from './useDataGridCell';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react';
|
||||
import type { DataGridCellContextProps } from './DataGridCellProvider';
|
||||
import { DataGridCellContext } from './DataGridCellProvider';
|
||||
|
||||
export default function useDataGridCell<TInput extends HTMLElement>() {
|
||||
const context =
|
||||
useContext<DataGridCellContextProps<TInput>>(DataGridCellContext);
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
||||
import { createContext } from 'react';
|
||||
|
||||
const DataGridConfigContext = createContext<Partial<UseDataGridReturn>>(null);
|
||||
|
||||
export default DataGridConfigContext;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import DataGridConfigContext from './DataGridConfigContext';
|
||||
|
||||
export default function DataGridConfigProvider<T extends object = {}>({
|
||||
children,
|
||||
...value
|
||||
}: PropsWithChildren<UseDataGridReturn<T>>) {
|
||||
return (
|
||||
<DataGridConfigContext.Provider
|
||||
value={value as unknown as UseDataGridReturn<{}>}
|
||||
>
|
||||
{children}
|
||||
</DataGridConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as DataGridConfigContext } from './DataGridConfigContext';
|
||||
export { default as DataGridConfigProvider } from './DataGridConfigProvider';
|
||||
export { default as useDataGridConfig } from './useDataGridConfig';
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
||||
import { useContext } from 'react';
|
||||
import DataGridConfigContext from './DataGridConfigContext';
|
||||
|
||||
export default function useDataGridConfig<T extends object = {}>() {
|
||||
const context = useContext(DataGridConfigContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`useDataGridConfig must be used within a DataGridConfigContext`,
|
||||
);
|
||||
}
|
||||
|
||||
return context as unknown as UseDataGridReturn<T>;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import type { TextProps } from '@/components/ui/v2/Text';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getDateComponents } from '@/utils/getDateComponents';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DataGridDateCellProps<TData extends object>
|
||||
extends CommonDataGridCellProps<TData, string> {
|
||||
/**
|
||||
* Props to be passed to date display.
|
||||
*/
|
||||
dateProps?: TextProps;
|
||||
/**
|
||||
* Props to be passed to time display.
|
||||
*/
|
||||
timeProps?: TextProps;
|
||||
}
|
||||
|
||||
export default function DataGridDateCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
cell: {
|
||||
column: { specificType },
|
||||
},
|
||||
dateProps,
|
||||
timeProps,
|
||||
className,
|
||||
}: DataGridDateCellProps<TData>) {
|
||||
const { className: dateClassName, ...restDateProps } = dateProps || {};
|
||||
const { className: timeClassName, ...restTimeProps } = timeProps || {};
|
||||
|
||||
// Note: No date (year-month-day) is saved for time / timetz columns, so we
|
||||
// need to add it manually.
|
||||
const date =
|
||||
optimisticValue && specificType !== 'interval'
|
||||
? new Date(
|
||||
specificType === 'time' || specificType === 'timetz'
|
||||
? `1970-01-01 ${optimisticValue}`
|
||||
: optimisticValue,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const { year, month, day, hour, minute, second } = getDateComponents(date, {
|
||||
adjustTimezone: ['date', 'timetz', 'timestamptz'].includes(specificType),
|
||||
});
|
||||
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
await onSave(temporaryValue || '');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
if (event.target instanceof HTMLInputElement && onTemporaryValueChange) {
|
||||
onTemporaryValueChange(event.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={
|
||||
temporaryValue !== null && typeof temporaryValue !== 'undefined'
|
||||
? temporaryValue
|
||||
: ''
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
|
||||
sx={{
|
||||
[`&.${inputClasses.focused}`]: {
|
||||
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||
borderColor: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.secondary[100]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
inputWrapper: { className: 'h-full' },
|
||||
input: { className: 'h-full' },
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!optimisticValue) {
|
||||
return (
|
||||
<Text className="truncate text-xs" color="secondary">
|
||||
null
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (specificType === 'interval') {
|
||||
return <Text className="truncate text-xs">{optimisticValue}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={twMerge('grid grid-flow-row', className)}>
|
||||
{specificType !== 'time' && specificType !== 'timetz' && (
|
||||
<Text
|
||||
className={twMerge('truncate text-xs', dateClassName)}
|
||||
{...restDateProps}
|
||||
>
|
||||
{[year, month, day].filter(Boolean).join('-')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{specificType !== 'date' && (
|
||||
<Text
|
||||
className={twMerge('truncate text-xs', timeClassName)}
|
||||
color={
|
||||
specificType === 'time' || specificType === 'timetz'
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
}
|
||||
{...restTimeProps}
|
||||
>
|
||||
{[hour, minute, second].filter(Boolean).join(':')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridDateCell';
|
||||
export { default as DataGridDateCell } from './DataGridDateCell';
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import clsx from 'clsx';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
|
||||
export type DataGridFrameProps = DetailedHTMLProps<
|
||||
HTMLProps<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
|
||||
export default function DataGridFrame<T extends object>({
|
||||
style,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataGridFrameProps) {
|
||||
const { getTableProps } = useDataGridConfig<T>();
|
||||
const { style: reactTableStyle, ...restTableProps } = getTableProps();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...restTableProps}
|
||||
{...props}
|
||||
className={clsx('min-w-min', className)}
|
||||
style={{ ...reactTableStyle, minWidth: undefined, ...style }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridFrame';
|
||||
export { default as DataGridFrame } from './DataGridFrame';
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
||||
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { ArrowDownIcon } from '@/components/ui/v2/icons/ArrowDownIcon';
|
||||
import { ArrowUpIcon } from '@/components/ui/v2/icons/ArrowUpIcon';
|
||||
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface HeaderActionProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLElement>, HTMLElement> {}
|
||||
|
||||
export interface DataGridHeaderProps<T extends object>
|
||||
extends Omit<
|
||||
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
||||
'children'
|
||||
>,
|
||||
Pick<
|
||||
DataGridProps<T>,
|
||||
'onRemoveColumn' | 'onEditColumn' | 'onInsertColumn'
|
||||
> {
|
||||
/**
|
||||
* Props to be passed to component slots.
|
||||
*/
|
||||
componentsProps?: {
|
||||
/**
|
||||
* Props to be passed to the `Edit Column` header action item.
|
||||
*/
|
||||
editActionProps?: HeaderActionProps;
|
||||
/**
|
||||
* Props to be passed to the `Delete Column` header action item.
|
||||
*/
|
||||
deleteActionProps?: HeaderActionProps;
|
||||
/**
|
||||
* Props to be passed to the `Delete Column` header action item.
|
||||
*/
|
||||
insertActionProps?: HeaderActionProps;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Get rid of Data Browser related code from here. This component should
|
||||
// be generic and not depend on Data Browser related data types and logic.
|
||||
export default function DataGridHeader<T extends object>({
|
||||
className,
|
||||
onRemoveColumn,
|
||||
onEditColumn,
|
||||
onInsertColumn,
|
||||
componentsProps,
|
||||
...props
|
||||
}: DataGridHeaderProps<T>) {
|
||||
const { flatHeaders, allowSort, allowResize } = useDataGridConfig<T>();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'sticky top-0 z-30 inline-flex w-full items-center pr-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{flatHeaders.map((column: DataBrowserGridColumn<T>) => {
|
||||
const headerProps = column.getHeaderProps({
|
||||
style: { display: 'inline-grid' },
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown.Root
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
column.isDisabled
|
||||
? theme.palette.background.default
|
||||
: theme.palette.background.paper,
|
||||
color: 'text.primary',
|
||||
borderColor: 'grey.300',
|
||||
}}
|
||||
className={twMerge(
|
||||
'group relative inline-flex self-stretch overflow-hidden font-display text-xs font-bold focus:outline-none focus-visible:outline-none',
|
||||
'border-b-1 border-r-1',
|
||||
column.id === 'selection' && 'sticky left-0 max-w-2',
|
||||
)}
|
||||
style={{
|
||||
...headerProps.style,
|
||||
maxWidth:
|
||||
column.id === 'selection' ? 32 : headerProps.style?.maxWidth,
|
||||
width:
|
||||
column.id === 'selection' ? '100%' : headerProps.style?.width,
|
||||
zIndex:
|
||||
column.id === 'selection' ? 10 : headerProps.style?.zIndex,
|
||||
position: null,
|
||||
}}
|
||||
key={column.id}
|
||||
>
|
||||
{column.id === 'selection' ? (
|
||||
<span
|
||||
{...headerProps}
|
||||
className="relative grid w-full grid-flow-col items-center justify-between p-2"
|
||||
>
|
||||
{column.render('Header')}
|
||||
</span>
|
||||
) : (
|
||||
<Dropdown.Trigger
|
||||
className={twMerge(
|
||||
'focus:outline-none motion-safe:transition-colors',
|
||||
)}
|
||||
disabled={
|
||||
column.isDisabled || (column.disableSortBy && !onRemoveColumn)
|
||||
}
|
||||
hideChevron
|
||||
>
|
||||
<span
|
||||
{...headerProps}
|
||||
className="relative grid w-full grid-flow-col items-center justify-between p-2"
|
||||
>
|
||||
{column.render('Header')}
|
||||
|
||||
{allowSort && (
|
||||
<Box component="span" sx={{ color: 'text.primary' }}>
|
||||
{column.isSorted && !column.isSortedDesc && (
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
)}
|
||||
|
||||
{column.isSorted && column.isSortedDesc && (
|
||||
<ArrowDownIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{allowResize && !column.disableResizing && (
|
||||
<span
|
||||
{...column.getResizerProps({
|
||||
onClick: (event: Event) => event.stopPropagation(),
|
||||
})}
|
||||
className="absolute -right-0.5 bottom-0 top-0 z-10 h-full w-1.5 group-hover:bg-slate-900 group-hover:bg-opacity-20 group-active:bg-slate-900 group-active:bg-opacity-20 motion-safe:transition-colors"
|
||||
/>
|
||||
)}
|
||||
</Dropdown.Trigger>
|
||||
)}
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-52 mt-1' }}
|
||||
className="p-0"
|
||||
>
|
||||
{onEditColumn && (
|
||||
<Dropdown.Item
|
||||
onClick={() => onEditColumn(column)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
disabled={componentsProps?.editActionProps?.disabled}
|
||||
>
|
||||
<PencilIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Column</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{onEditColumn && <Divider component="li" sx={{ margin: 0 }} />}
|
||||
|
||||
{!column.disableSortBy && (
|
||||
<Dropdown.Item
|
||||
onClick={() => column.toggleSortBy(false)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<ArrowUpIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Sort Ascending</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{!column.disableSortBy && (
|
||||
<Dropdown.Item
|
||||
onClick={() => column.toggleSortBy(true)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<ArrowDownIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Sort Descending</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{onRemoveColumn && !column.isPrimary && (
|
||||
<Divider component="li" className="my-1" />
|
||||
)}
|
||||
|
||||
{onRemoveColumn && !column.isPrimary && (
|
||||
<Dropdown.Item
|
||||
onClick={() => onRemoveColumn(column)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
disabled={componentsProps?.deleteActionProps?.disabled}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" sx={{ color: 'error.main' }} />
|
||||
|
||||
<span>Delete Column</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
})}
|
||||
|
||||
{onInsertColumn && (
|
||||
<Box className="group relative inline-flex w-25 self-stretch overflow-hidden border-b-1 border-r-1 font-display text-xs font-bold focus:outline-none focus-visible:outline-none">
|
||||
<Button
|
||||
onClick={onInsertColumn}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="h-full w-full rounded-none text-xs hover:shadow-none focus:shadow-none focus:outline-none"
|
||||
aria-label="Insert New Column"
|
||||
disabled={componentsProps?.insertActionProps?.disabled}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" sx={{ color: 'text.disabled' }} />
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridHeader';
|
||||
export { default as DataGridHeader } from './DataGridHeader';
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
|
||||
export type DataGridNumericCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, number>;
|
||||
|
||||
export default function DataGridNumericCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
}: DataGridNumericCellProps<TData>) {
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
if (typeof temporaryValue === 'number') {
|
||||
await onSave(temporaryValue);
|
||||
} else {
|
||||
await onSave(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
if (onTemporaryValueChange) {
|
||||
if (event.target.value) {
|
||||
onTemporaryValueChange(parseInt(event.target.value, 10));
|
||||
} else {
|
||||
onTemporaryValueChange(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
ref={inputRef}
|
||||
value={
|
||||
temporaryValue !== null && typeof temporaryValue !== 'undefined'
|
||||
? temporaryValue
|
||||
: ''
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
|
||||
sx={{
|
||||
[`&.${inputClasses.focused}`]: {
|
||||
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||
borderColor: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.secondary[100]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
inputWrapper: { className: 'h-full' },
|
||||
input: { className: 'h-full' },
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (optimisticValue === null || typeof optimisticValue === 'undefined') {
|
||||
return (
|
||||
<Text className="truncate !text-xs" color="disabled">
|
||||
null
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text className="truncate !text-xs">{optimisticValue}</Text>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridNumericCell';
|
||||
export { default as DataGridNumericCell } from './DataGridNumericCell';
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import type { IconButtonProps } from '@/components/ui/v2/IconButton';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { ChevronLeftIcon } from '@/components/ui/v2/icons/ChevronLeftIcon';
|
||||
import { ChevronRightIcon } from '@/components/ui/v2/icons/ChevronRightIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface DataGridPaginationProps extends BoxProps {
|
||||
/**
|
||||
* Number of pages.
|
||||
*/
|
||||
totalPages: number;
|
||||
/**
|
||||
* Current page.
|
||||
*/
|
||||
currentPage: number;
|
||||
/**
|
||||
* Function to be called when navigating to the previous page.
|
||||
*/
|
||||
onOpenPrevPage: VoidFunction;
|
||||
/**
|
||||
* Function to be called when navigating to the next page.
|
||||
*/
|
||||
onOpenNextPage: VoidFunction;
|
||||
/**
|
||||
* Props to be passed to the next button component.
|
||||
*/
|
||||
nextButtonProps?: IconButtonProps;
|
||||
/**
|
||||
* Props to be passed to the previous button component.
|
||||
*/
|
||||
prevButtonProps?: IconButtonProps;
|
||||
}
|
||||
|
||||
export default function DataGridPagination({
|
||||
className,
|
||||
totalPages,
|
||||
currentPage,
|
||||
onOpenPrevPage,
|
||||
onOpenNextPage,
|
||||
nextButtonProps,
|
||||
prevButtonProps,
|
||||
...props
|
||||
}: DataGridPaginationProps) {
|
||||
return (
|
||||
<Box
|
||||
className={clsx(
|
||||
'grid grid-flow-col items-center justify-around rounded-md border-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={currentPage === 1}
|
||||
onClick={onOpenPrevPage}
|
||||
aria-label="Previous page"
|
||||
{...prevButtonProps}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
'mx-1 inline-block font-display font-medium',
|
||||
currentPage > 99 ? 'text-xs' : 'text-sm+',
|
||||
)}
|
||||
>
|
||||
{currentPage}
|
||||
<Text component="span" className="mx-1 inline-block" color="disabled">
|
||||
/
|
||||
</Text>
|
||||
{totalPages}
|
||||
</span>
|
||||
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={onOpenNextPage}
|
||||
aria-label="Next page"
|
||||
{...nextButtonProps}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridPagination';
|
||||
export { default as DataGridPagination } from './DataGridPagination';
|
||||
@@ -0,0 +1,410 @@
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { AudioPreviewIcon } from '@/components/ui/v2/icons/AudioPreviewIcon';
|
||||
import { FilePreviewIcon } from '@/components/ui/v2/icons/FilePreviewIcon';
|
||||
import { PDFPreviewIcon } from '@/components/ui/v2/icons/PDFPreviewIcon';
|
||||
import { VideoPreviewIcon } from '@/components/ui/v2/icons/VideoPreviewIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { useAppClient } from '@/features/orgs/projects/hooks/useAppClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import type { CellProps } from 'react-table';
|
||||
|
||||
export type PreviewProps = {
|
||||
fetchBlob: (
|
||||
init?: RequestInit,
|
||||
size?: { width?: number; height?: number },
|
||||
) => Promise<Blob | null>;
|
||||
mimeType?: string;
|
||||
alt?: string;
|
||||
blob?: Blob;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type DataGridPreviewCellProps<TData extends object> = CellProps<
|
||||
TData,
|
||||
PreviewProps
|
||||
> & {
|
||||
/**
|
||||
* Preview to use when the file is not an image or blob can't be fetched
|
||||
* properly.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
fallbackPreview?: ReactNode;
|
||||
};
|
||||
|
||||
function useBlob({
|
||||
fetchBlob,
|
||||
blob,
|
||||
mimeType,
|
||||
}: Pick<PreviewProps, 'fetchBlob' | 'blob' | 'mimeType'>) {
|
||||
const [objectUrl, setObjectUrl] = useState<string>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
// This side-effect fetches the blob of the file from the server and sets the
|
||||
// relevant `objectUrl` state. Abort controller is reponsible for cancelling
|
||||
// the fetch if the component is unmounted.
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function generateOptimizedObjectUrl() {
|
||||
// todo: it could be more declarative if this function was called with the
|
||||
// actual preview URL here, not pre-generated in useFiles
|
||||
const fetchedBlob = await fetchBlob(
|
||||
{ signal: abortController.signal },
|
||||
mimeType !== 'image/svg+xml' && { width: 80, height: 40 },
|
||||
);
|
||||
|
||||
if (fetchedBlob) {
|
||||
return URL.createObjectURL(fetchedBlob);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function generateObjectUrl() {
|
||||
setLoading(false);
|
||||
setError(undefined);
|
||||
|
||||
if (objectUrl || (mimeType && !mimeType?.startsWith('image'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (blob) {
|
||||
setObjectUrl(URL.createObjectURL(blob));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const generatedObjectUrl = await generateOptimizedObjectUrl();
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
setObjectUrl(generatedObjectUrl);
|
||||
}
|
||||
} catch (generateError) {
|
||||
if (!abortController.signal.aborted) {
|
||||
setError(generateError);
|
||||
}
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
generateObjectUrl();
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [blob, fetchBlob, objectUrl, mimeType]);
|
||||
|
||||
return { objectUrl, error, loading };
|
||||
}
|
||||
|
||||
const previewableImages = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const previewableVideos = [
|
||||
'video/mp4',
|
||||
'video/x-m4v',
|
||||
'video/3gpp',
|
||||
'video/3gpp2',
|
||||
];
|
||||
|
||||
const previewableFileTypes = [
|
||||
...previewableImages,
|
||||
...previewableVideos,
|
||||
'audio/',
|
||||
'application/json',
|
||||
];
|
||||
|
||||
function previewReducer(
|
||||
state: { loading: boolean; error?: Error; data?: string },
|
||||
action:
|
||||
| { type: 'PREVIEW_LOADING' }
|
||||
| { type: 'CLEAR_PREVIEW' }
|
||||
| { type: 'PREVIEW_FETCHED'; payload: string }
|
||||
| { type: 'PREVIEW_ERROR'; payload: Error },
|
||||
): { loading: boolean; error?: Error; data?: string } {
|
||||
switch (action.type) {
|
||||
case 'PREVIEW_LOADING':
|
||||
return { ...state, loading: true, error: undefined, data: undefined };
|
||||
case 'PREVIEW_FETCHED':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
data: action.payload,
|
||||
};
|
||||
case 'PREVIEW_ERROR':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: action.payload,
|
||||
data: undefined,
|
||||
};
|
||||
case 'CLEAR_PREVIEW':
|
||||
return { ...state, loading: false, error: undefined, data: undefined };
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataGridPreviewCell<TData extends object>({
|
||||
value: { fetchBlob, id, mimeType, alt, blob },
|
||||
fallbackPreview = null,
|
||||
}: DataGridPreviewCellProps<TData>) {
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const appClient = useAppClient();
|
||||
const { objectUrl, loading, error } = useBlob({ fetchBlob, blob, mimeType });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const [
|
||||
{ loading: previewLoading, error: previewError, data: previewUrl },
|
||||
dispatch,
|
||||
] = useReducer(previewReducer, {
|
||||
loading: false,
|
||||
error: undefined,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
const isPreviewable = previewableFileTypes.some(
|
||||
(type) => mimeType?.startsWith(type) || mimeType === type,
|
||||
);
|
||||
|
||||
const isVideo = mimeType?.startsWith('video');
|
||||
const isAudio = mimeType?.startsWith('audio');
|
||||
const isImage = mimeType?.startsWith('image');
|
||||
const isJson = mimeType === 'application/json';
|
||||
|
||||
async function handleOpenPreview() {
|
||||
if (!mimeType) {
|
||||
dispatch({
|
||||
type: 'PREVIEW_ERROR',
|
||||
payload: new Error('mimeType is not defined.'),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPreviewable) {
|
||||
setShowModal(true);
|
||||
dispatch({ type: 'PREVIEW_LOADING' });
|
||||
}
|
||||
|
||||
const { presignedUrl } = await appClient.storage
|
||||
.setAdminSecret(project?.config?.hasura.adminSecret)
|
||||
.getPresignedUrl({ fileId: id });
|
||||
|
||||
if (!presignedUrl) {
|
||||
dispatch({
|
||||
type: 'PREVIEW_ERROR',
|
||||
payload: new Error('Presigned URL could not be fetched.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPreviewable) {
|
||||
window.open(presignedUrl.url, '_blank', 'noopener noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'PREVIEW_FETCHED', payload: presignedUrl.url });
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator delay={500} className="mx-auto" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
className="grid w-full grid-flow-col items-center justify-center gap-1 text-center"
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<FilePreviewIcon error /> Error
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
wrapperClassName="items-center"
|
||||
showModal={showModal}
|
||||
close={() => setShowModal(false)}
|
||||
afterLeave={() => dispatch({ type: 'CLEAR_PREVIEW' })}
|
||||
className={clsx(
|
||||
previewableImages.includes(mimeType) || isVideo || isAudio
|
||||
? 'mx-12 flex h-screen items-center justify-center'
|
||||
: 'mt-4 inline-block h-near-screen w-full px-12',
|
||||
)}
|
||||
>
|
||||
<Box
|
||||
className={clsx(
|
||||
!isJson && 'bg-checker-pattern',
|
||||
'relative mx-auto flex overflow-hidden rounded-md',
|
||||
)}
|
||||
sx={{
|
||||
backgroundColor: isJson && 'background.default',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{!previewLoading && (
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="absolute right-2 top-2 z-50 p-2"
|
||||
sx={{
|
||||
[`&:hover, &:active, &:focus`]: {
|
||||
backgroundColor: (theme) => {
|
||||
if (isAudio || isVideo || isJson) {
|
||||
return 'common.black';
|
||||
}
|
||||
|
||||
return theme.palette.mode === 'dark'
|
||||
? 'grey.800'
|
||||
: 'grey.200';
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<XIcon
|
||||
className="h-5 w-5"
|
||||
sx={{
|
||||
color: (theme) => {
|
||||
if (isAudio || isVideo || isJson) {
|
||||
return 'common.white';
|
||||
}
|
||||
|
||||
return theme.palette.mode === 'dark'
|
||||
? 'grey.100'
|
||||
: 'grey.700';
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{previewLoading && !previewUrl && (
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
className="mx-auto"
|
||||
label="Loading preview..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewError && (
|
||||
<Box
|
||||
className="px-6 py-3.5 pr-12 text-start font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<p>Error: Preview can't be loaded.</p>
|
||||
|
||||
<p>{previewError.message}</p>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{previewUrl && isImage && (
|
||||
<picture className="h-auto max-h-near-screen min-h-38 min-w-38">
|
||||
<source srcSet={previewUrl} type={mimeType} />
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={alt}
|
||||
className="h-full w-full object-scale-down"
|
||||
/>
|
||||
</picture>
|
||||
)}
|
||||
|
||||
{previewUrl && isVideo && (
|
||||
<video
|
||||
autoPlay
|
||||
controls
|
||||
className="h-auto max-h-near-screen w-full bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
<source src={previewUrl} type={mimeType} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)}
|
||||
|
||||
{previewUrl && isAudio && (
|
||||
<audio autoPlay controls className="h-28 bg-black">
|
||||
<track kind="captions" />
|
||||
<source src={previewUrl} type={mimeType} />
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
)}
|
||||
|
||||
{!previewLoading &&
|
||||
previewUrl &&
|
||||
!previewableImages.includes(mimeType) &&
|
||||
!isVideo &&
|
||||
!isAudio && (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className="h-near-screen w-full"
|
||||
title="File preview"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
<div className="flex h-full w-full justify-center">
|
||||
{previewableImages.includes(mimeType) && objectUrl && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={alt}
|
||||
onClick={handleOpenPreview}
|
||||
className="mx-auto h-full"
|
||||
>
|
||||
<picture className="h-full w-20">
|
||||
<source srcSet={objectUrl} type={mimeType} />
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={alt}
|
||||
className="h-full w-full object-scale-down"
|
||||
/>
|
||||
</picture>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(!previewableImages.includes(mimeType) || !objectUrl) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenPreview}
|
||||
aria-label={alt}
|
||||
className="grid h-full w-full items-center justify-center self-center"
|
||||
>
|
||||
{isVideo && <VideoPreviewIcon className="h-5 w-5" />}
|
||||
|
||||
{isAudio && <AudioPreviewIcon className="h-5 w-5" />}
|
||||
|
||||
{mimeType === 'application/pdf' && (
|
||||
<PDFPreviewIcon className="h-5 w-5" />
|
||||
)}
|
||||
|
||||
{!isVideo &&
|
||||
!isAudio &&
|
||||
mimeType !== 'application/pdf' &&
|
||||
fallbackPreview}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridPreviewCell';
|
||||
export { default as DataGridPreviewCell } from './DataGridPreviewCell';
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { copy } from '@/utils/copy';
|
||||
import type { ChangeEvent, KeyboardEvent, Ref } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export type DataGridTextCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, string>;
|
||||
|
||||
export default function DataGridTextCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
cell: {
|
||||
column: { isCopiable, specificType },
|
||||
},
|
||||
}: DataGridTextCellProps<TData>) {
|
||||
const isMultiline =
|
||||
specificType === 'text' ||
|
||||
specificType === 'bpchar' ||
|
||||
specificType === 'varchar' ||
|
||||
specificType === 'json' ||
|
||||
specificType === 'jsonb';
|
||||
|
||||
const normalizedOptimisticValue =
|
||||
optimisticValue !== null && typeof optimisticValue === 'object'
|
||||
? optimisticValue
|
||||
: (String(optimisticValue) || '').replace(/(\\n)+/gi, ' ');
|
||||
|
||||
const normalizedTemporaryValue =
|
||||
temporaryValue !== null && typeof temporaryValue === 'object'
|
||||
? JSON.stringify(temporaryValue)
|
||||
: temporaryValue;
|
||||
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } = useDataGridCell<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && isMultiline) {
|
||||
const textArea = inputRef.current as HTMLTextAreaElement;
|
||||
|
||||
textArea.setSelectionRange(textArea.value.length, textArea.value.length);
|
||||
}
|
||||
}, [inputRef, isEditing, isMultiline]);
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
await onSave((normalizedTemporaryValue || '').replace(/\n/gi, `\\n`));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTextAreaKeyDown(
|
||||
event: KeyboardEvent<HTMLTextAreaElement>,
|
||||
) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// Saving content Enter / CTRL + Enter / CMD + Enter (macOS) - but not on
|
||||
// Shift + Enter
|
||||
if (
|
||||
(!event.shiftKey && event.key === 'Enter') ||
|
||||
(event.ctrlKey && event.key === 'Enter') ||
|
||||
(event.metaKey && event.key === 'Enter')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(
|
||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) {
|
||||
if (onTemporaryValueChange) {
|
||||
onTemporaryValueChange(event.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing && isMultiline) {
|
||||
return (
|
||||
<Input
|
||||
multiline
|
||||
ref={inputRef as Ref<HTMLInputElement>}
|
||||
value={(normalizedTemporaryValue || '').replace(/\\n/gi, `\n`)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleTextAreaKeyDown}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full min-h-38"
|
||||
rows={5}
|
||||
sx={{
|
||||
[`&.${inputClasses.focused}`]: {
|
||||
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||
borderColor: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.secondary[100]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef as Ref<HTMLInputElement>}
|
||||
value={(normalizedTemporaryValue || '').replace(/\\n/gi, `\n`)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
|
||||
sx={{
|
||||
[`&.${inputClasses.focused}`]: {
|
||||
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||
borderColor: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.secondary[100]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
inputWrapper: { className: 'h-full' },
|
||||
input: { className: 'h-full' },
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!optimisticValue) {
|
||||
return (
|
||||
<Text className="truncate !text-xs" color="secondary">
|
||||
{optimisticValue === '' ? 'empty' : 'null'}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCopiable) {
|
||||
return (
|
||||
<div className="grid grid-flow-col items-center justify-start gap-1">
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const copiableValue =
|
||||
typeof optimisticValue === 'object'
|
||||
? JSON.stringify(optimisticValue)
|
||||
: String(optimisticValue).replace(/\\n/gi, '\n');
|
||||
|
||||
copy(copiableValue, 'Value');
|
||||
}}
|
||||
className="-ml-px min-w-0 p-0"
|
||||
aria-label="Copy value"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.secondary'
|
||||
: 'text.disabled',
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Text className="truncate text-xs">
|
||||
{typeof normalizedOptimisticValue === 'object'
|
||||
? JSON.stringify(normalizedOptimisticValue)
|
||||
: normalizedOptimisticValue}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className="truncate text-xs">
|
||||
{typeof normalizedOptimisticValue === 'object'
|
||||
? JSON.stringify(normalizedOptimisticValue)
|
||||
: normalizedOptimisticValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridTextCell';
|
||||
export { default as DataGridTextCell } from './DataGridTextCell';
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
||||
import { DataGrid } from '@/components/dataGrid/DataGrid';
|
||||
import { DataGridBooleanCell } from '@/components/dataGrid/DataGridBooleanCell';
|
||||
import { DataGridDateCell } from '@/components/dataGrid/DataGridDateCell';
|
||||
import type { PreviewProps } from '@/components/dataGrid/DataGridPreviewCell';
|
||||
import { DataGridPreviewCell } from '@/components/dataGrid/DataGridPreviewCell';
|
||||
import { DataGridTextCell } from '@/components/dataGrid/DataGridTextCell';
|
||||
import type { DataGridProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||
import { DataGrid } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||
import { DataGridBooleanCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridBooleanCell';
|
||||
import { DataGridDateCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridDateCell';
|
||||
import type { PreviewProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGridPreviewCell';
|
||||
import { DataGridPreviewCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridPreviewCell';
|
||||
import { DataGridTextCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridTextCell';
|
||||
|
||||
import { FilePreviewIcon } from '@/components/ui/v2/icons/FilePreviewIcon';
|
||||
import { useAppClient } from '@/features/orgs/projects/hooks/useAppClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function useNotFoundRedirect() {
|
||||
router.pathname === '/' ||
|
||||
router.pathname === '/account' ||
|
||||
router.pathname === '/support/ticket' ||
|
||||
router.pathname === '/run-one-click-install' ||
|
||||
orgSlug ||
|
||||
(orgSlug && appSubdomain) ||
|
||||
// If we are on a valid workspace and project, we don't want to redirect to 404
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function SettingsAuthenticationPage() {
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
|
||||
className="grid max-w-5xl grid-flow-row bg-transparent gap-y-6"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<AuthServiceVersionSettings />
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function StoragePage() {
|
||||
: project.config?.hasura.adminSecret,
|
||||
}}
|
||||
>
|
||||
<div className="h-full pb-25 xs+:pb-[53px]">
|
||||
<div className="h-full max-w-full pb-25 xs+:pb-[56.5px]">
|
||||
<RetryableErrorBoundary>
|
||||
<FilesDataGrid />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
@@ -9,44 +8,58 @@ import { Input } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useWorkspaces } from '@/features/orgs/projects/hooks/useWorkspaces';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import {
|
||||
useGetAllWorkspacesAndProjectsQuery,
|
||||
type GetAllWorkspacesAndProjectsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Divider } from '@mui/material';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ChangeEvent, ReactElement } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type Workspace = Omit<
|
||||
GetAllWorkspacesAndProjectsQuery['workspaces'][0],
|
||||
'__typename'
|
||||
>;
|
||||
interface ProjectSelectorOption {
|
||||
type: 'workspace-project' | 'org-project';
|
||||
projectName: string;
|
||||
projectPathDescriptor: string;
|
||||
route: string;
|
||||
isFree: boolean;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export default function SelectWorkspaceAndProject() {
|
||||
const user = useUserData();
|
||||
const router = useRouter();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { orgs, loading: loadingOrgs } = useOrgs();
|
||||
const { workspaces, loading: loadingWorkspaces } = useWorkspaces();
|
||||
|
||||
const { data, loading } = useGetAllWorkspacesAndProjectsQuery({
|
||||
skip: !user,
|
||||
});
|
||||
const workspaceProjects: ProjectSelectorOption[] = workspaces.flatMap(
|
||||
(workspace) =>
|
||||
workspace.projects.map((project) => ({
|
||||
type: 'workspace-project',
|
||||
projectName: project.name,
|
||||
projectPathDescriptor: `${workspace.name}/${project.name}`,
|
||||
route: `${workspace.slug}/${project.slug}/services`,
|
||||
isFree: project.legacyPlan.isFree,
|
||||
plan: project.legacyPlan.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const workspaces: Workspace[] = data?.workspaces || [];
|
||||
|
||||
const projects = workspaces.flatMap((workspace) =>
|
||||
workspace.projects.map((project) => ({
|
||||
workspaceName: workspace.name,
|
||||
const orgProjects: ProjectSelectorOption[] = orgs.flatMap((org) =>
|
||||
org.apps.map((project) => ({
|
||||
type: 'org-project',
|
||||
projectName: project.name,
|
||||
value: `${workspace.slug}/${project.slug}`,
|
||||
isFree: project.legacyPlan.isFree,
|
||||
projectPathDescriptor: `${org.name}/${project.name}`,
|
||||
route: `/orgs/${org.slug}/projects/${project.subdomain}/run`,
|
||||
isFree: org.plan.isFree,
|
||||
plan: org.plan.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const projects = [...orgProjects, ...workspaceProjects];
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const handleFilterChange = useMemo(
|
||||
@@ -89,17 +102,12 @@ export default function SelectWorkspaceAndProject() {
|
||||
}
|
||||
}, [checkConfigFromQuery, router.query]);
|
||||
|
||||
const goToServices = async (project: {
|
||||
workspaceName: string;
|
||||
projectName: string;
|
||||
value: string;
|
||||
isFree: boolean;
|
||||
}) => {
|
||||
const goToServices = async (project: ProjectSelectorOption) => {
|
||||
if (!project) {
|
||||
openAlertDialog({
|
||||
title: 'Please select a workspace and a project',
|
||||
title: 'Please select a project',
|
||||
payload:
|
||||
'You must select a workspace and a project before proceeding to create the run service',
|
||||
'You must select a project before proceeding to create the run service',
|
||||
props: {
|
||||
primaryButtonText: 'Ok',
|
||||
hideSecondaryAction: true,
|
||||
@@ -111,8 +119,8 @@ export default function SelectWorkspaceAndProject() {
|
||||
|
||||
if (project.isFree) {
|
||||
openAlertDialog({
|
||||
title: 'The project must have a pro plan',
|
||||
payload: 'Creating run services is only availabel for pro projects',
|
||||
title: 'Cannot proceed',
|
||||
payload: 'Creating run services is only available on a Pro plan',
|
||||
props: {
|
||||
primaryButtonText: 'Ok',
|
||||
hideSecondaryAction: true,
|
||||
@@ -122,11 +130,7 @@ export default function SelectWorkspaceAndProject() {
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push({
|
||||
pathname: `/${project.value}/services`,
|
||||
// Keep the same query params that got us here
|
||||
query: router.query,
|
||||
});
|
||||
await router.push({ pathname: project.route, query: router.query });
|
||||
};
|
||||
|
||||
const projectsToDisplay = filter
|
||||
@@ -135,32 +139,27 @@ export default function SelectWorkspaceAndProject() {
|
||||
)
|
||||
: projects;
|
||||
|
||||
if (loading) {
|
||||
if (loadingWorkspaces || loadingOrgs) {
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading workspaces and projects..."
|
||||
/>
|
||||
<div className="flex justify-center w-full">
|
||||
<ActivityIndicator delay={500} label="Loading projects..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-auto grid max-w-[760px] grid-flow-row gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
New Run Service
|
||||
</Text>
|
||||
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||
<h1 className="text-2xl font-medium">New Run Service</h1>
|
||||
|
||||
<InfoCard
|
||||
title="Please select the workspace and the project where you want to create the service"
|
||||
title="Please select the project where you want to create the service"
|
||||
disableCopy
|
||||
value=""
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex w-full">
|
||||
<div className="flex w-full mb-2">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
onChange={handleFilterChange}
|
||||
@@ -170,15 +169,15 @@ export default function SelectWorkspaceAndProject() {
|
||||
</div>
|
||||
<RetryableErrorBoundary>
|
||||
{projectsToDisplay.length === 0 ? (
|
||||
<Box className="h-import py-2">
|
||||
<Box className="py-2 h-import">
|
||||
<Text variant="subtitle2">No results found.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<List className="h-import overflow-y-auto">
|
||||
<List className="flex flex-col gap-2 overflow-y-auto h-import">
|
||||
{projectsToDisplay.map((project, index) => (
|
||||
<Fragment key={project.value}>
|
||||
<Fragment key={project.projectPathDescriptor}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
className="flex flex-row items-center justify-center gap-4"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
@@ -189,19 +188,35 @@ export default function SelectWorkspaceAndProject() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListItem.Avatar>
|
||||
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</span>
|
||||
<ListItem.Avatar className="flex items-center justify-center h-full">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
className="w-10 h-10 rounded-md"
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
</ListItem.Avatar>
|
||||
<ListItem.Text
|
||||
primary={project.projectName}
|
||||
secondary={`${project.workspaceName} / ${project.projectName}`}
|
||||
primary={
|
||||
<div className="flex items-center">
|
||||
<span>{project.projectName}</span>
|
||||
<Badge
|
||||
variant={project.isFree ? 'outline' : 'default'}
|
||||
className={cn(
|
||||
'hover:none ml-2 h-5 px-[6px] text-[10px]',
|
||||
project.isFree && 'bg-muted',
|
||||
project.type === 'workspace-project' &&
|
||||
'bg-orange-200 text-foreground hover:bg-orange-200 dark:bg-orange-500',
|
||||
)}
|
||||
>
|
||||
{project.type === 'workspace-project'
|
||||
? 'Legacy'
|
||||
: project.plan}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
secondary={project.projectPathDescriptor}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
@@ -213,7 +228,7 @@ export default function SelectWorkspaceAndProject() {
|
||||
</RetryableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user