console: add auto clean form on events tab

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/5956
Co-authored-by: Daniele Cammareri <5709409+dancamma@users.noreply.github.com>
GitOrigin-RevId: febb6ae707c9ac9c1e77a25aa01c2d2283c0d7d9
This commit is contained in:
Varun Choudhary 2022-09-21 20:53:32 +05:30 committed by hasura-bot
parent e369f567c3
commit a7c0b02296
19 changed files with 381 additions and 38 deletions

View File

@ -1,8 +1,8 @@
/* eslint-disable no-underscore-dangle */
import React from 'react'; import React from 'react';
import { browserHistory } from 'react-router'; import { browserHistory } from 'react-router';
import { Tabs } from '@/new-components/Tabs'; import { Tabs } from '@/new-components/Tabs';
import { useConsoleConfig } from '@/hooks/useEnvVars';
import { import {
useQueryCollections, useQueryCollections,
QueryCollectionsOperations, QueryCollectionsOperations,
@ -11,6 +11,7 @@ import {
import { AllowListSidebar, AllowListPermissions } from '@/features/AllowLists'; import { AllowListSidebar, AllowListPermissions } from '@/features/AllowLists';
import PageContainer from '@/components/Common/Layout/PageContainer/PageContainer'; import PageContainer from '@/components/Common/Layout/PageContainer/PageContainer';
import { isProConsole } from '@/utils/proConsole';
interface AllowListDetailProps { interface AllowListDetailProps {
params: { params: {
@ -35,8 +36,6 @@ export const AllowListDetail: React.FC<AllowListDetailProps> = props => {
isRefetching, isRefetching,
} = useQueryCollections(); } = useQueryCollections();
const { type } = useConsoleConfig();
const queryCollection = queryCollections?.find( const queryCollection = queryCollections?.find(
({ name: collectionName }) => collectionName === name ({ name: collectionName }) => collectionName === name
); );
@ -86,7 +85,7 @@ export const AllowListDetail: React.FC<AllowListDetailProps> = props => {
/> />
</div> </div>
)} )}
{type !== 'oss' ? ( {isProConsole(window.__env) ? (
<Tabs <Tabs
value={section} value={section}
onValueChange={value => { onValueChange={value => {

View File

@ -54,6 +54,7 @@ import {
ETOperationColumn, ETOperationColumn,
EventTriggerOperation, EventTriggerOperation,
RetryConf, RetryConf,
EventTriggerAutoCleanup,
} from '../../types'; } from '../../types';
interface Props extends InjectedProps {} interface Props extends InjectedProps {}
@ -317,6 +318,10 @@ const Add: React.FC<Props> = props => {
setState.operations(o); setState.operations(o);
}; };
const handleAutoCleanupChange = (config: EventTriggerAutoCleanup) => {
setState.cleanupConfig(config);
};
const handleOperationsColumnsChange = (oc: ETOperationColumn[]) => { const handleOperationsColumnsChange = (oc: ETOperationColumn[]) => {
setState.operationColumns(oc); setState.operationColumns(oc);
}; };
@ -361,6 +366,7 @@ const Add: React.FC<Props> = props => {
handleRetryConfChange={handleRetryConfChange} handleRetryConfChange={handleRetryConfChange}
handleHeadersChange={handleHeadersChange} handleHeadersChange={handleHeadersChange}
handleToggleAllColumn={setState.toggleAllColumnChecked} handleToggleAllColumn={setState.toggleAllColumnChecked}
handleAutoCleanupChange={handleAutoCleanupChange}
/> />
<ConfigureTransformation <ConfigureTransformation
transformationType="event" transformationType="event"

View File

@ -1,7 +1,9 @@
/* eslint-disable no-underscore-dangle */
import React from 'react'; import React from 'react';
import { Collapsible } from '@/new-components/Collapsible';
import { isProConsole } from '@/utils/proConsole';
import { LocalEventTriggerState } from '../state'; import { LocalEventTriggerState } from '../state';
import Headers, { Header } from '../../../../Common/Headers/Headers'; import Headers, { Header } from '../../../../Common/Headers/Headers';
import CollapsibleToggle from '../../../../Common/CollapsibleToggle/CollapsibleToggle';
import RetryConfEditor from '../../Common/Components/RetryConfEditor'; import RetryConfEditor from '../../Common/Components/RetryConfEditor';
import * as tooltip from '../Common/Tooltips'; import * as tooltip from '../Common/Tooltips';
import { Operations } from '../Common/Operations'; import { Operations } from '../Common/Operations';
@ -12,11 +14,13 @@ import {
ETOperationColumn, ETOperationColumn,
EventTriggerOperation, EventTriggerOperation,
RetryConf, RetryConf,
EventTriggerAutoCleanup,
} from '../../types'; } from '../../types';
import ColumnList from '../Common/ColumnList'; import ColumnList from '../Common/ColumnList';
import FormLabel from './FormLabel'; import FormLabel from './FormLabel';
import DebouncedDropdownInput from '../Common/DropdownWrapper'; import DebouncedDropdownInput from '../Common/DropdownWrapper';
import { inputStyles, heading } from '../../constants'; import { inputStyles, heading } from '../../constants';
import { AutoCleanupForm } from '../Common/AutoCleanupForm';
type CreateETFormProps = { type CreateETFormProps = {
state: LocalEventTriggerState; state: LocalEventTriggerState;
@ -34,6 +38,7 @@ type CreateETFormProps = {
handleRetryConfChange: (r: RetryConf) => void; handleRetryConfChange: (r: RetryConf) => void;
handleHeadersChange: (h: Header[]) => void; handleHeadersChange: (h: Header[]) => void;
handleToggleAllColumn: () => void; handleToggleAllColumn: () => void;
handleAutoCleanupChange: (config: EventTriggerAutoCleanup) => void;
}; };
const CreateETForm: React.FC<CreateETFormProps> = props => { const CreateETForm: React.FC<CreateETFormProps> = props => {
@ -48,6 +53,7 @@ const CreateETForm: React.FC<CreateETFormProps> = props => {
operations, operations,
operationColumns, operationColumns,
isAllColumnChecked, isAllColumnChecked,
cleanupConfig,
}, },
databaseInfo, databaseInfo,
dataSourcesList, dataSourcesList,
@ -63,9 +69,11 @@ const CreateETForm: React.FC<CreateETFormProps> = props => {
handleRetryConfChange, handleRetryConfChange,
handleHeadersChange, handleHeadersChange,
handleToggleAllColumn, handleToggleAllColumn,
handleAutoCleanupChange,
} = props; } = props;
const supportedDrivers = getSupportedDrivers('events.triggers.add'); const supportedDrivers = getSupportedDrivers('events.triggers.add');
return ( return (
<> <>
<FormLabel <FormLabel
@ -190,9 +198,25 @@ const CreateETForm: React.FC<CreateETFormProps> = props => {
</small> </small>
</div> </div>
<hr className="my-md" /> <hr className="my-md" />
<CollapsibleToggle {isProConsole(window.__env) && (
title={<h4 className={heading}>Advanced Settings</h4>} <>
testId="advanced-settings" <div className="mb-md">
<div className="mb-md cursor-pointer">
<AutoCleanupForm
cleanupConfig={cleanupConfig}
onChange={handleAutoCleanupChange}
/>
</div>
</div>
<hr className="my-md" />
</>
)}
<Collapsible
triggerChildren={
<h2 className="text-lg font-semibold mb-xs flex items-center mb-0">
Advanced Settings
</h2>
}
> >
<div> <div>
<div> <div>
@ -232,7 +256,7 @@ const CreateETForm: React.FC<CreateETFormProps> = props => {
<Headers headers={headers} setHeaders={handleHeadersChange} /> <Headers headers={headers} setHeaders={handleHeadersChange} />
</div> </div>
</div> </div>
</CollapsibleToggle> </Collapsible>
<hr className="my-md" /> <hr className="my-md" />
</> </>
); );

View File

@ -0,0 +1,151 @@
import defaultState from '@/components/Services/Events/EventTriggers/state';
import { Collapsible } from '@/new-components/Collapsible';
import { DropdownButton } from '@/new-components/DropdownButton';
import { InputSection } from '@/new-components/InputSetionWithoutForm';
import { Switch } from '@/new-components/Switch';
import React from 'react';
import { EventTriggerAutoCleanup } from '../../types';
interface AutoCleanupFormProps {
cleanupConfig: EventTriggerAutoCleanup;
onChange: (cleanupConfig: EventTriggerAutoCleanup) => void;
}
const crons = [
{ value: '* * * * *', label: 'Every Minute' },
{ value: '*/10 * * * *', label: 'Every 10 Minutes' },
{ value: '0 0 * * *', label: 'Every Midnight' },
{ value: '0 0 1 * *', label: 'Every Month Start' },
{ value: '0 12 * * 5', label: 'Every Friday Noon' },
];
export const AutoCleanupForm = (props: AutoCleanupFormProps) => {
const { cleanupConfig, onChange } = props;
return (
<div className="w-1/2">
<Collapsible
triggerChildren={
<h2 className="text-lg font-semibold mb-xs flex items-center mb-0">
Auto-cleanup Event Logs
</h2>
}
>
<div className="flex items-center mb-sm">
<Switch
checked={!cleanupConfig.paused}
onCheckedChange={() => {
onChange({
...cleanupConfig,
paused: !cleanupConfig.paused,
});
}}
/>
<span className="ml-xs cursor-pointer">Enable event log cleanup</span>
</div>
<div className="flex items-center mb-sm">
<Switch
checked={cleanupConfig.clean_invocation_logs}
disabled={cleanupConfig.paused}
onCheckedChange={() => {
onChange({
...cleanupConfig,
clean_invocation_logs: !cleanupConfig.clean_invocation_logs,
});
}}
/>
<span className="ml-xs cursor-pointer">
Clean invocation logs with event logs
</span>
</div>
<InputSection
label="Clear logs older than (hours)"
tooltip="Clear event logs older than (in hours, default:168 hours or 7
days)"
placeholder={
defaultState.cleanupConfig.clear_older_than?.toString() || ''
}
value={cleanupConfig.clear_older_than?.toString() || ''}
onChange={value => {
onChange({
...cleanupConfig,
clear_older_than: value ? parseInt(value, 10) : undefined,
});
}}
/>
<InputSection
label="Cleanup Frequency"
tooltip="Cron expression at which the cleanup should be invoked."
placeholder={defaultState.cleanupConfig.timeout?.toString() || ''}
value={cleanupConfig.schedule?.toString() || ''}
onChange={value => {
onChange({
...cleanupConfig,
schedule: value,
});
}}
/>
<div className="my-sm">
<DropdownButton
items={[
crons.map(cron => (
<div
key={cron.value}
onClick={() => {
onChange({
...cleanupConfig,
schedule: cron.value,
});
}}
className="cursor-pointer mx-1 px-xs py-1 rounded hover:bg-gray-100"
>
<p className="mb-0 font-semibold whitespace-nowrap">
{cron.label}
</p>
<p className="mb-0">{cron.value}</p>
</div>
)),
]}
>
<span className="font-bold">Frequent Frequencies</span>
</DropdownButton>
</div>
<Collapsible
triggerChildren={
<h2 className="text-lg font-semibold mb-xs flex items-center mb-0">
Advanced Settings
</h2>
}
>
<InputSection
label="Timeout (seconds)"
tooltip="Timeout for the query (in seconds, default: 60)"
placeholder={defaultState.cleanupConfig.timeout?.toString() || ''}
value={cleanupConfig.timeout?.toString() || ''}
onChange={value => {
onChange({
...cleanupConfig,
timeout: value ? parseInt(value, 10) : undefined,
});
}}
/>
<InputSection
label="Batch Size"
tooltip="Number of event trigger logs to delete in a batch (default: 10,000)"
placeholder={
defaultState.cleanupConfig.batch_size?.toString() || ''
}
value={cleanupConfig.batch_size?.toString() || ''}
onChange={value => {
onChange({
...cleanupConfig,
batch_size: value ? parseInt(value, 10) : undefined,
});
}}
/>
</Collapsible>
</Collapsible>
</div>
);
};

View File

@ -1,3 +1,4 @@
/* eslint-disable no-underscore-dangle */
import React, { useEffect, useReducer } from 'react'; import React, { useEffect, useReducer } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { import {
@ -31,12 +32,14 @@ import {
import ConfigureTransformation from '@/components/Common/ConfigureTransformation/ConfigureTransformation'; import ConfigureTransformation from '@/components/Common/ConfigureTransformation/ConfigureTransformation';
import requestAction from '@/utils/requestAction'; import requestAction from '@/utils/requestAction';
import Endpoints from '@/Endpoints'; import Endpoints from '@/Endpoints';
import defaultState from '@/components/Services/Events/EventTriggers/state';
import { import {
getValidateTransformOptions, getValidateTransformOptions,
parseValidateApiData, parseValidateApiData,
getTransformState, getTransformState,
} from '@/components/Common/ConfigureTransformation/utils'; } from '@/components/Common/ConfigureTransformation/utils';
import { Button } from '@/new-components/Button'; import { Button } from '@/new-components/Button';
import { isProConsole } from '@/utils/proConsole';
import { getSourceDriver } from '../../../Data/utils'; import { getSourceDriver } from '../../../Data/utils';
import { mapDispatchToPropsEmpty } from '../../../../Common/utils/reactUtils'; import { mapDispatchToPropsEmpty } from '../../../../Common/utils/reactUtils';
import { getEventRequestSampleInput } from '../utils'; import { getEventRequestSampleInput } from '../utils';
@ -57,6 +60,7 @@ import {
getDataSources, getDataSources,
getEventTriggerByName, getEventTriggerByName,
} from '../../../../../metadata/selector'; } from '../../../../../metadata/selector';
import { AutoCleanupForm } from '../Common/AutoCleanupForm';
interface Props extends InjectedProps {} interface Props extends InjectedProps {}
@ -384,6 +388,16 @@ const Modify: React.FC<Props> = props => {
requestUrlTransformOnChange={requestUrlTransformOnChange} requestUrlTransformOnChange={requestUrlTransformOnChange}
requestPayloadTransformOnChange={requestPayloadTransformOnChange} requestPayloadTransformOnChange={requestPayloadTransformOnChange}
/> />
{isProConsole(window.__env) && (
<div className="mb-md">
<AutoCleanupForm
onChange={setState.cleanupConfig}
cleanupConfig={
state?.cleanupConfig || defaultState.cleanupConfig
}
/>
</div>
)}
{!readOnlyMode && ( {!readOnlyMode && (
<div className="mb-md"> <div className="mb-md">
<span className="mr-md"> <span className="mr-md">

View File

@ -6,6 +6,7 @@ import {
EventTrigger, EventTrigger,
RetryConf, RetryConf,
DatabaseInfo, DatabaseInfo,
EventTriggerAutoCleanup,
} from '../types'; } from '../types';
import { Header, defaultHeader } from '../../../Common/Headers/Headers'; import { Header, defaultHeader } from '../../../Common/Headers/Headers';
import { import {
@ -27,9 +28,10 @@ export type LocalEventTriggerState = {
headers: Header[]; headers: Header[];
source: string; source: string;
isAllColumnChecked: boolean; isAllColumnChecked: boolean;
cleanupConfig: EventTriggerAutoCleanup;
}; };
const defaultState: LocalEventTriggerState = { export const defaultState: LocalEventTriggerState = {
name: '', name: '',
table: { table: {
name: '', name: '',
@ -54,6 +56,14 @@ const defaultState: LocalEventTriggerState = {
headers: [defaultHeader], headers: [defaultHeader],
source: '', source: '',
isAllColumnChecked: true, isAllColumnChecked: true,
cleanupConfig: {
schedule: '0 0 * * *',
batch_size: 10000,
clear_older_than: 168,
timeout: 60,
clean_invocation_logs: false,
paused: true,
},
}; };
export const parseServerETDefinition = ( export const parseServerETDefinition = (
@ -89,6 +99,8 @@ export const parseServerETDefinition = (
retryConf: etConf.retry_conf, retryConf: etConf.retry_conf,
headers: parseServerHeaders(eventTrigger.configuration.headers), headers: parseServerHeaders(eventTrigger.configuration.headers),
isAllColumnChecked: etDef?.update?.columns === '*', isAllColumnChecked: etDef?.update?.columns === '*',
cleanupConfig:
eventTrigger.configuration?.cleanup_config ?? defaultState.cleanupConfig,
}; };
}; };
@ -175,6 +187,12 @@ export const useEventTrigger = (initState?: LocalEventTriggerState) => {
isAllColumnChecked: !s.isAllColumnChecked, isAllColumnChecked: !s.isAllColumnChecked,
})); }));
}, },
cleanupConfig: (cleanupConfig: EventTriggerAutoCleanup) => {
setState(s => ({
...s,
cleanupConfig,
}));
},
}, },
}; };
}; };

View File

@ -64,6 +64,7 @@ import _push from '../Data/push';
import { RequestTransformState } from '../../Common/ConfigureTransformation/stateDefaults'; import { RequestTransformState } from '../../Common/ConfigureTransformation/stateDefaults';
import { getRequestTransformObject } from '../../Common/ConfigureTransformation/utils'; import { getRequestTransformObject } from '../../Common/ConfigureTransformation/utils';
import { getSourceDriver } from '../Data/utils'; import { getSourceDriver } from '../Data/utils';
import defaultState from '../Events/EventTriggers/state';
export const addScheduledTrigger = export const addScheduledTrigger =
( (
@ -460,6 +461,10 @@ export const modifyEventTrigger =
break; break;
} }
default: default:
upQuery.args.cleanup_config = {
...defaultState.cleanupConfig,
...state.cleanupConfig,
};
break; break;
} }
const migration = new Migration(); const migration = new Migration();

View File

@ -82,6 +82,15 @@ export type ETOperationColumn = {
enabled: boolean; enabled: boolean;
}; };
export type EventTriggerAutoCleanup = {
schedule?: string;
batch_size?: number;
clear_older_than?: number;
timeout?: number;
clean_invocation_logs: boolean;
paused: boolean;
};
export type EventTriggerOperationDefinition = { export type EventTriggerOperationDefinition = {
columns: string[] | '*'; columns: string[] | '*';
}; };
@ -100,6 +109,7 @@ export type EventTrigger = {
retry_conf: RetryConf; retry_conf: RetryConf;
webhook: Nullable<string>; webhook: Nullable<string>;
webhook_from_env?: Nullable<string>; webhook_from_env?: Nullable<string>;
cleanup_config?: EventTriggerAutoCleanup;
}; };
request_transform?: RequestTransform; request_transform?: RequestTransform;
}; };

View File

@ -1,7 +1,7 @@
import { Button } from '@/new-components/Button'; /* eslint-disable no-underscore-dangle */
import { useConsoleConfig } from '@/hooks/useEnvVars';
import React from 'react'; import React from 'react';
import { Button } from '@/new-components/Button';
import { isProConsole } from '@/utils/proConsole';
import { FaFolderPlus } from 'react-icons/fa'; import { FaFolderPlus } from 'react-icons/fa';
import { QueryCollectionCreateDialog } from './QueryCollectionCreateDialog'; import { QueryCollectionCreateDialog } from './QueryCollectionCreateDialog';
import { AllowListStatus } from './AllowListStatus'; import { AllowListStatus } from './AllowListStatus';
@ -13,7 +13,6 @@ interface AllowListSidebarHeaderProps {
export const AllowListSidebarHeader = (props: AllowListSidebarHeaderProps) => { export const AllowListSidebarHeader = (props: AllowListSidebarHeaderProps) => {
const { onQueryCollectionCreate } = props; const { onQueryCollectionCreate } = props;
const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false);
const { type } = useConsoleConfig();
return ( return (
<div className="pb-4"> <div className="pb-4">
{isCreateModalOpen && ( {isCreateModalOpen && (
@ -31,7 +30,7 @@ export const AllowListSidebarHeader = (props: AllowListSidebarHeaderProps) => {
<AllowListStatus /> <AllowListStatus />
</div> </div>
</div> </div>
{type !== 'oss' && ( {isProConsole(window.__env) && (
<div className="mt-2 2xl:mt-0 2xl:ml-auto"> <div className="mt-2 2xl:mt-0 2xl:ml-auto">
<Button <Button
icon={<FaFolderPlus />} icon={<FaFolderPlus />}

View File

@ -1,3 +1,4 @@
import defaultState from '@/components/Services/Events/EventTriggers/state';
import { import {
ColumnConfig, ColumnConfig,
CustomRootFields, CustomRootFields,
@ -523,6 +524,10 @@ export const generateCreateEventTriggerQuery = (
enable_manual: state.operations.enable_manual, enable_manual: state.operations.enable_manual,
retry_conf: state.retryConf, retry_conf: state.retryConf,
headers: transformHeaders(state.headers), headers: transformHeaders(state.headers),
cleanup_config: {
...defaultState.cleanupConfig,
...state.cleanupConfig,
},
replace, replace,
request_transform: requestTransform, request_transform: requestTransform,
}, },

View File

@ -291,6 +291,7 @@ export const getEventTriggers = createSelector(getMetadata, metadata => {
retry_conf: trigger.retry_conf, retry_conf: trigger.retry_conf,
webhook: trigger.webhook || '', webhook: trigger.webhook || '',
webhook_from_env: trigger.webhook_from_env, webhook_from_env: trigger.webhook_from_env,
cleanup_config: trigger.cleanup_config,
}, },
request_transform: trigger.request_transform, request_transform: trigger.request_transform,
})) || []; })) || [];
@ -340,6 +341,7 @@ export const getEventTriggerByName = createSelector(
retry_conf: trigger.retry_conf, retry_conf: trigger.retry_conf,
webhook: trigger.webhook || '', webhook: trigger.webhook || '',
webhook_from_env: trigger.webhook_from_env, webhook_from_env: trigger.webhook_from_env,
cleanup_config: trigger.cleanup_config,
}, },
request_transform: trigger.request_transform, request_transform: trigger.request_transform,
}; };

View File

@ -422,6 +422,15 @@ export interface ComputedFieldDefinition {
* NOTE: The metadata type doesn't QUITE match the 'create' arguments here * NOTE: The metadata type doesn't QUITE match the 'create' arguments here
* https://hasura.io/docs/latest/graphql/core/api-reference/schema-metadata-api/event-triggers.html#create-event-trigger * https://hasura.io/docs/latest/graphql/core/api-reference/schema-metadata-api/event-triggers.html#create-event-trigger
*/ */
export interface EventTriggerAutoCleanup {
schedule: string;
batch_size: number;
clear_older_than: number;
timeout: number;
clean_invocation_logs: boolean;
paused: boolean;
}
export interface EventTrigger { export interface EventTrigger {
/** Name of the event trigger */ /** Name of the event trigger */
name: TriggerName; name: TriggerName;
@ -436,6 +445,8 @@ export interface EventTrigger {
headers?: ServerHeader[]; headers?: ServerHeader[];
/** Request transformation object */ /** Request transformation object */
request_transform?: RequestTransform; request_transform?: RequestTransform;
/** Auto-cleanup configuration object */
cleanup_config?: EventTriggerAutoCleanup;
} }
export interface EventTriggerDefinition { export interface EventTriggerDefinition {

View File

@ -0,0 +1,68 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { within, userEvent, screen } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { Button } from '@/new-components/Button';
import { DropdownButton } from './DropdownButton';
export default {
title: 'components/Dropdown Button 🧬',
parameters: {
docs: { source: { type: 'code' } },
chromatic: { disableSnapshot: true },
},
decorators: [
Story => (
<div className="p-4 flex gap-5 items-center max-w-screen">{Story()}</div>
),
],
component: DropdownButton,
argTypes: {
onClick: { action: true },
},
} as ComponentMeta<typeof DropdownButton>;
export const Default: ComponentStory<typeof Button> = ({ onClick }) => (
<DropdownButton
data-testid="dropdown-button"
items={[
[
<span onClick={onClick}>Action</span>,
<span onClick={onClick} className="text-red-600">
Destructive Action
</span>,
],
[<span onClick={onClick}>Another action</span>],
]}
>
The DropdownButton label
</DropdownButton>
);
export const ApiPlayground: ComponentStory<typeof Button> = args => (
<DropdownButton
items={[
['Action', <span className="text-red-600">Destructive Action</span>],
['Another action'],
]}
{...args}
>
The DropdownButton label
</DropdownButton>
);
Default.play = async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// click the trigger
userEvent.click(canvas.getByTestId('dropdown-button'));
// the menu is visible
expect(screen.getByText('Another action')).toBeVisible();
// click the item
userEvent.click(screen.getByText('Another action'));
// the menu is not visible
expect(screen.queryByText('Another action')).not.toBeInTheDocument();
// the action is called
expect(args.onClick).toHaveBeenCalled();
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import { FaChevronDown } from 'react-icons/fa';
import { Button } from '../Button';
import { DropdownMenu } from '../DropdownMenu';
interface DropdownButtonProps extends React.ComponentProps<typeof Button> {
items: React.ReactNode[][];
}
export const DropdownButton: React.FC<DropdownButtonProps> = ({
items,
...rest
}) => (
<DropdownMenu items={items}>
<Button
iconPosition="end"
icon={
<FaChevronDown className="transition-transform group-radix-state-open:rotate-180 w-3 h-3" />
}
{...rest}
/>
</DropdownMenu>
);

View File

@ -0,0 +1 @@
export * from './DropdownButton';

View File

@ -57,28 +57,6 @@ import { DropdownMenu } from '@/new-components/DropdownMenu';
</Story> </Story>
</Canvas> </Canvas>
<Canvas>
<Story name="Dropdown Button">
<div>
<DropdownMenu
items={[
['Action', <span className="text-red-600">Destructive Action</span>],
['Another action'],
]}
>
<Button
iconPosition="end"
icon={
<FaChevronUp className="transition-transform group-radix-state-open:rotate-180" />
}
>
Dropdown Button
</Button>
</DropdownMenu>
</div>
</Story>
</Canvas>
#### 🚦 Usage #### 🚦 Usage
- The dropdownMenu button is used for opening a contextual menu with list of actions - The dropdownMenu button is used for opening a contextual menu with list of actions

View File

@ -0,0 +1,27 @@
import React from 'react';
import { IconTooltip } from '../Tooltip';
export const InputSection = (props: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
tooltip: string;
}) => {
const { label, value, onChange, placeholder, tooltip } = props;
return (
<div className="mb-sm">
<label className="flex items-center mb-xs font-semibold text-muted">
{label}
<IconTooltip message={tooltip} />
</label>
<input
type="text"
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
className="block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-0 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
/>
</div>
);
};

View File

@ -0,0 +1 @@
export { InputSection } from './InputSection';

View File

@ -3,11 +3,12 @@ import * as RadixSwitch from '@radix-ui/react-switch';
import clsx from 'clsx'; import clsx from 'clsx';
export const Switch = (props: RadixSwitch.SwitchProps) => { export const Switch = (props: RadixSwitch.SwitchProps) => {
const { checked } = props; const { checked, disabled } = props;
return ( return (
<RadixSwitch.Root <RadixSwitch.Root
className={clsx( className={clsx(
checked ? 'bg-green-600' : 'bg-gray-200', checked ? 'bg-green-600' : 'bg-gray-200',
disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
'relative inline-flex shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500' 'relative inline-flex shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'
)} )}
{...props} {...props}