mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat(app): refactor Sources components and add custom Mantine UI variants (#1561)
Co-authored-by: Drew Davis <drew.davis@clickhouse.com>
This commit is contained in:
parent
ae12ca1670
commit
5dded38f87
21 changed files with 1116 additions and 210 deletions
11
.changeset/sources-refactor-ui-variants.md
Normal file
11
.changeset/sources-refactor-ui-variants.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
"@hyperdx/app": minor
|
||||
---
|
||||
|
||||
Refactor Sources components and add custom Mantine UI variants
|
||||
|
||||
- Move SourceForm to Sources/ subfolder with reusable SourcesList component
|
||||
- Add primary, secondary, and danger button/action icon variants
|
||||
- Improve Storybook with font switching and component stories
|
||||
- Update ErrorBoundary styling with danger variant
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -61,6 +61,9 @@ e2e/cypress/results
|
|||
**/playwright/.cache/
|
||||
**/.auth/
|
||||
|
||||
# storybook
|
||||
**/storybook-static/
|
||||
|
||||
# scripts
|
||||
scripts/*.csv
|
||||
**/venv
|
||||
|
|
|
|||
|
|
@ -23,6 +23,22 @@
|
|||
- Define TypeScript interfaces for props
|
||||
- Use proper keys for lists, memoization for expensive computations
|
||||
|
||||
## Mantine UI Components
|
||||
|
||||
The project uses Mantine UI with **custom variants** defined in `packages/app/src/theme/mantineTheme.ts`:
|
||||
|
||||
### Custom Button Variants
|
||||
- `variant="primary"` - Light green button for primary actions
|
||||
- `variant="secondary"` - Default styled button for secondary actions
|
||||
- `variant="danger"` - Light red button for destructive actions
|
||||
|
||||
### Custom ActionIcon Variants
|
||||
- `variant="primary"` - Light green action icon
|
||||
- `variant="secondary"` - Default styled action icon
|
||||
- `variant="danger"` - Light red action icon for destructive actions
|
||||
|
||||
These are valid variants - do not replace them with standard Mantine variants like `variant="light" color="red"`.
|
||||
|
||||
## Refactoring
|
||||
|
||||
- Edit files directly - don't create `component-v2.tsx` copies
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"app:dev": "concurrently -k -n 'API,APP,ALERTS-TASK,COMMON-UTILS' -c 'green.bold,blue.bold,yellow.bold,magenta' 'nx run @hyperdx/api:dev' 'nx run @hyperdx/app:dev' 'nx run @hyperdx/api:dev-task check-alerts' 'nx run @hyperdx/common-utils:dev'",
|
||||
"app:dev:local": "concurrently -k -n 'APP,COMMON-UTILS' -c 'blue.bold,magenta' 'nx run @hyperdx/app:dev:local' 'nx run @hyperdx/common-utils:dev'",
|
||||
"app:lint": "nx run @hyperdx/app:ci:lint",
|
||||
"app:storybook": "nx run @hyperdx/app:storybook",
|
||||
"dev": "yarn build:common-utils && dotenvx run --convention=nextjs -- docker compose -f docker-compose.dev.yml up -d && yarn app:dev && docker compose -f docker-compose.dev.yml down",
|
||||
"dev:local": "IS_LOCAL_APP_MODE='DANGEROUSLY_is_local_app_mode💀' yarn dev",
|
||||
"dev:down": "docker compose -f docker-compose.dev.yml down",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ const config: StorybookConfig = {
|
|||
options: {},
|
||||
},
|
||||
staticDirs: ['./public'],
|
||||
webpackFinal: async config => {
|
||||
if (config.resolve) {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'next/router': require.resolve('next/router'),
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import { NextAdapter } from 'next-query-params';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import type { Preview } from '@storybook/nextjs';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { ibmPlexMono, inter, roboto, robotoMono } from '../src/fonts';
|
||||
import { meHandler } from '../src/mocks/handlers';
|
||||
import { ThemeWrapper } from '../src/ThemeWrapper';
|
||||
|
||||
|
|
@ -31,29 +32,75 @@ export const globalTypes = {
|
|||
defaultValue: 'light',
|
||||
toolbar: {
|
||||
icon: 'mirror',
|
||||
title: 'Theme',
|
||||
items: [
|
||||
{ value: 'light', title: 'Light' },
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
],
|
||||
},
|
||||
},
|
||||
font: {
|
||||
name: 'Font',
|
||||
description: 'App font family',
|
||||
defaultValue: 'inter',
|
||||
toolbar: {
|
||||
icon: 'typography',
|
||||
title: 'Font',
|
||||
items: [
|
||||
{ value: 'inter', title: 'Inter' },
|
||||
{ value: 'roboto', title: 'Roboto' },
|
||||
{ value: 'ibm-plex-mono', title: 'IBM Plex Mono' },
|
||||
{ value: 'roboto-mono', title: 'Roboto Mono' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const fontMap = {
|
||||
inter: inter,
|
||||
roboto: roboto,
|
||||
'ibm-plex-mono': ibmPlexMono,
|
||||
'roboto-mono': robotoMono,
|
||||
};
|
||||
|
||||
// Create a new QueryClient for each story to avoid cache pollution between stories
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [
|
||||
(Story, context) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryParamProvider adapter={NextAdapter}>
|
||||
<ThemeWrapper colorScheme={context.globals.theme || 'light'}>
|
||||
<Story />
|
||||
</ThemeWrapper>
|
||||
</QueryParamProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
(Story, context) => {
|
||||
// Create a fresh QueryClient for each story render
|
||||
const [queryClient] = React.useState(() => createQueryClient());
|
||||
|
||||
const selectedFont = context.globals.font || 'inter';
|
||||
const font = fontMap[selectedFont as keyof typeof fontMap] || inter;
|
||||
const fontFamily = font.style.fontFamily;
|
||||
|
||||
return (
|
||||
<div className={font.className}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryParamProvider adapter={NextAdapter}>
|
||||
<ThemeWrapper
|
||||
colorScheme={context.globals.theme || 'light'}
|
||||
fontFamily={fontFamily}
|
||||
>
|
||||
<Story />
|
||||
</ThemeWrapper>
|
||||
</QueryParamProvider>
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
],
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ import { InputControlled } from '@/components/InputControlled';
|
|||
import OnboardingModal from '@/components/OnboardingModal';
|
||||
import SearchPageActionBar from '@/components/SearchPageActionBar';
|
||||
import SearchTotalCountChart from '@/components/SearchTotalCountChart';
|
||||
import { TableSourceForm } from '@/components/SourceForm';
|
||||
import { TableSourceForm } from '@/components/Sources/SourceForm';
|
||||
import { SourceSelectControlled } from '@/components/SourceSelect';
|
||||
import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
|
||||
import { Tags } from '@/components/Tags';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { Fragment, useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
|
||||
import { DEFAULT_METADATA_MAX_ROWS_TO_READ } from '@hyperdx/common-utils/dist/core/metadata';
|
||||
import {
|
||||
SourceKind,
|
||||
TeamClickHouseSettings,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { TeamClickHouseSettings } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
|
|
@ -27,19 +24,15 @@ import {
|
|||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconClipboard,
|
||||
IconDatabase,
|
||||
IconHelpCircle,
|
||||
IconPencil,
|
||||
IconServer,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { ConnectionForm } from '@/components/ConnectionForm';
|
||||
import SelectControlled from '@/components/SelectControlled';
|
||||
import { TableSourceForm } from '@/components/SourceForm';
|
||||
import { SourcesList } from '@/components/Sources/SourcesList';
|
||||
import { IS_LOCAL_MODE } from '@/config';
|
||||
|
||||
import { PageHeader } from './components/PageHeader';
|
||||
|
|
@ -49,8 +42,6 @@ import api from './api';
|
|||
import { useConnections } from './connection';
|
||||
import { DEFAULT_QUERY_TIMEOUT, DEFAULT_SEARCH_ROW_LIMIT } from './defaults';
|
||||
import { withAppNav } from './layout';
|
||||
import { useSources } from './source';
|
||||
import { capitalizeFirstLetter } from './utils';
|
||||
|
||||
function ConnectionsSection() {
|
||||
const { data: connections } = useConnections();
|
||||
|
|
@ -64,7 +55,7 @@ function ConnectionsSection() {
|
|||
<Box id="connections">
|
||||
<Text size="md">Connections</Text>
|
||||
<Divider my="md" />
|
||||
<Card variant="muted">
|
||||
<Card>
|
||||
<Stack mb="md">
|
||||
{connections?.map(c => (
|
||||
<Box key={c.id}>
|
||||
|
|
@ -149,91 +140,15 @@ function ConnectionsSection() {
|
|||
}
|
||||
|
||||
function SourcesSection() {
|
||||
const { data: connections } = useConnections();
|
||||
const { data: sources } = useSources();
|
||||
|
||||
const [editedSourceId, setEditedSourceId] = useState<string | null>(null);
|
||||
const [isCreatingSource, setIsCreatingSource] = useState(false);
|
||||
|
||||
return (
|
||||
<Box id="sources">
|
||||
<Text size="md">Sources</Text>
|
||||
<Divider my="md" />
|
||||
<Card variant="muted">
|
||||
<Stack>
|
||||
{sources?.map(s => (
|
||||
<Fragment key={s.id}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<div>
|
||||
<Text>{s.name}</Text>
|
||||
<Text size="xxs" c="dimmed" mt="xs" component="div">
|
||||
<Group gap="xs">
|
||||
{capitalizeFirstLetter(s.kind)}
|
||||
<Group gap={2}>
|
||||
<IconServer size={14} />
|
||||
{connections?.find(c => c.id === s.connection)?.name}
|
||||
</Group>
|
||||
<Group gap={2}>
|
||||
{s.from && (
|
||||
<>
|
||||
<IconDatabase size={14} />
|
||||
{s.from.databaseName}
|
||||
{
|
||||
s.kind === SourceKind.Metric
|
||||
? ''
|
||||
: '.' /** Metrics dont have table names */
|
||||
}
|
||||
{s.from.tableName}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Text>
|
||||
</div>
|
||||
{editedSourceId !== s.id && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setEditedSourceId(s.id)}
|
||||
size="sm"
|
||||
>
|
||||
<IconChevronDown size={14} />
|
||||
</Button>
|
||||
)}
|
||||
{editedSourceId === s.id && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setEditedSourceId(null)}
|
||||
size="sm"
|
||||
>
|
||||
<IconChevronUp size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
{editedSourceId === s.id && (
|
||||
<TableSourceForm
|
||||
sourceId={s.id}
|
||||
onSave={() => setEditedSourceId(null)}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
</Fragment>
|
||||
))}
|
||||
{!IS_LOCAL_MODE && isCreatingSource && (
|
||||
<TableSourceForm
|
||||
isNew
|
||||
onCreate={() => {
|
||||
setIsCreatingSource(false);
|
||||
}}
|
||||
onCancel={() => setIsCreatingSource(false)}
|
||||
/>
|
||||
)}
|
||||
{!IS_LOCAL_MODE && !isCreatingSource && (
|
||||
<Button variant="default" onClick={() => setIsCreatingSource(true)}>
|
||||
Add Source
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
<SourcesList
|
||||
withBorder={false}
|
||||
variant="default"
|
||||
showEmptyState={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -242,7 +157,7 @@ function IntegrationsSection() {
|
|||
<Box id="integrations">
|
||||
<Text size="md">Integrations</Text>
|
||||
<Divider my="md" />
|
||||
<Card variant="muted">
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<WebhooksSection />
|
||||
</Stack>
|
||||
|
|
@ -290,7 +205,7 @@ function TeamNameSection() {
|
|||
<Box id="team_name">
|
||||
<Text size="md">Team Name</Text>
|
||||
<Divider my="md" />
|
||||
<Card variant="muted">
|
||||
<Card>
|
||||
{isEditingTeamName ? (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Group gap="xs">
|
||||
|
|
@ -552,7 +467,7 @@ function TeamQueryConfigSection() {
|
|||
<Box id="team_name">
|
||||
<Text size="md">ClickHouse Client Settings</Text>
|
||||
<Divider my="md" />
|
||||
<Card variant="muted">
|
||||
<Card>
|
||||
<Stack>
|
||||
<ClickhouseSettingForm
|
||||
settingKey="searchRowLimit"
|
||||
|
|
@ -670,7 +585,7 @@ function ApiKeysSection() {
|
|||
<Box id="api_keys">
|
||||
<Text size="md">API Keys</Text>
|
||||
<Divider my="md" />
|
||||
<Card variant="muted" mb="md">
|
||||
<Card mb="md">
|
||||
<Text mb="md">Ingestion API Key</Text>
|
||||
<Group gap="xs">
|
||||
{team?.apiKey && (
|
||||
|
|
@ -726,7 +641,7 @@ function ApiKeysSection() {
|
|||
</Modal>
|
||||
</Card>
|
||||
{!isLoadingMe && me != null && (
|
||||
<Card variant="muted">
|
||||
<Card>
|
||||
<Card.Section p="md">
|
||||
<Text mb="md">Personal API Access Key</Text>
|
||||
<APIKeyCopyButton value={me.accessKey} dataTestId="api-key" />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export default function ConfirmDeleteMenu({
|
|||
return (
|
||||
<Menu withArrow>
|
||||
<Menu.Target>
|
||||
<Button variant="outline" size="xs">
|
||||
<Button variant="danger" size="xs">
|
||||
Delete
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
|
|
|
|||
|
|
@ -1,42 +1,105 @@
|
|||
import type { Meta } from '@storybook/nextjs';
|
||||
import { Box, Text } from '@mantine/core';
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs';
|
||||
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'ErrorBoundary',
|
||||
// Component that throws an error for testing
|
||||
const BuggyComponent = ({ shouldThrow = true }: { shouldThrow?: boolean }) => {
|
||||
if (shouldThrow) {
|
||||
throw new Error('This is a test error from BuggyComponent!');
|
||||
}
|
||||
return (
|
||||
<Box p="md">
|
||||
<Text>This component rendered successfully!</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ErrorBoundary> = {
|
||||
title: 'Components/ErrorBoundary',
|
||||
component: ErrorBoundary,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
argTypes: {
|
||||
message: {
|
||||
control: 'text',
|
||||
description: 'Custom error message title',
|
||||
},
|
||||
showErrorMessage: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show the actual error message',
|
||||
},
|
||||
allowReset: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show a reset/retry button',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const BadComponent = () => {
|
||||
throw new Error('Error message');
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<ErrorBoundary>
|
||||
<BadComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export const WithRetry = () => (
|
||||
<ErrorBoundary onRetry={() => {}}>
|
||||
<BadComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export const WithMessage = () => (
|
||||
<ErrorBoundary
|
||||
onRetry={() => {}}
|
||||
message="An error occurred while rendering the event details. Contact support
|
||||
for more help."
|
||||
>
|
||||
<BadComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export const WithErrorMessage = () => (
|
||||
<ErrorBoundary onRetry={() => {}} message="Don't panic" showErrorMessage>
|
||||
<BadComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Error caught with default message */
|
||||
export const ErrorCaught: Story = {
|
||||
name: 'Error Caught (Default)',
|
||||
render: () => (
|
||||
<ErrorBoundary>
|
||||
<BuggyComponent shouldThrow />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
};
|
||||
|
||||
/** Error caught with custom message */
|
||||
export const CustomMessage: Story = {
|
||||
name: 'Error Caught (Custom Message)',
|
||||
render: () => (
|
||||
<ErrorBoundary message="Oops! Something broke.">
|
||||
<BuggyComponent shouldThrow />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
};
|
||||
|
||||
/** Error caught showing the error details */
|
||||
export const ShowErrorMessage: Story = {
|
||||
name: 'Show Error Message',
|
||||
render: () => (
|
||||
<ErrorBoundary showErrorMessage>
|
||||
<BuggyComponent shouldThrow />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
};
|
||||
|
||||
/** Error caught with retry button */
|
||||
export const WithRetryButton: Story = {
|
||||
name: 'With Retry Button',
|
||||
render: () => (
|
||||
<ErrorBoundary allowReset showErrorMessage>
|
||||
<BuggyComponent shouldThrow />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
};
|
||||
|
||||
/** Error caught with custom retry handler */
|
||||
export const WithCustomRetry: Story = {
|
||||
name: 'With Custom Retry Handler',
|
||||
render: () => (
|
||||
<ErrorBoundary
|
||||
showErrorMessage
|
||||
onRetry={() => alert('Custom retry handler called!')}
|
||||
>
|
||||
<BuggyComponent shouldThrow />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
};
|
||||
|
||||
/** No error - normal render */
|
||||
export const NoError: Story = {
|
||||
name: 'No Error (Normal Render)',
|
||||
render: () => (
|
||||
<ErrorBoundary>
|
||||
<BuggyComponent shouldThrow={false} />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
|
||||
import { Alert, Button, Stack, Text } from '@mantine/core';
|
||||
import { IconInfoCircleFilled } from '@tabler/icons-react';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
|
||||
type ErrorBoundaryProps = {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -31,8 +31,8 @@ export const ErrorBoundary = ({
|
|||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Alert
|
||||
p="xs"
|
||||
color="orange"
|
||||
icon={<IconInfoCircleFilled size={16} />}
|
||||
color="red"
|
||||
icon={<IconExclamationCircle size={16} />}
|
||||
title={message || 'Something went wrong'}
|
||||
>
|
||||
{(showErrorMessage || showRetry) && (
|
||||
|
|
@ -42,7 +42,7 @@ export const ErrorBoundary = ({
|
|||
<Button
|
||||
onClick={onRetry || resetErrorBoundary}
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
variant="danger"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
useUpdateSource,
|
||||
} from '@/source';
|
||||
|
||||
import { TableSourceForm } from './SourceForm';
|
||||
import { TableSourceForm } from './Sources/SourceForm';
|
||||
|
||||
async function addOtelDemoSources({
|
||||
connectionId,
|
||||
|
|
|
|||
137
packages/app/src/components/Sources/SourceForm.stories.tsx
Normal file
137
packages/app/src/components/Sources/SourceForm.stories.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { SourceKind } from '@hyperdx/common-utils/dist/types';
|
||||
import { Box, Card, Text } from '@mantine/core';
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs';
|
||||
|
||||
import { TableSourceForm } from './SourceForm';
|
||||
|
||||
// Mock data
|
||||
const mockConnections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'Local ClickHouse',
|
||||
host: 'localhost:8123',
|
||||
username: 'default',
|
||||
},
|
||||
];
|
||||
|
||||
const mockSources = [
|
||||
{
|
||||
id: 'source-logs',
|
||||
name: 'Logs',
|
||||
kind: SourceKind.Log,
|
||||
connection: 'conn-1',
|
||||
from: { databaseName: 'default', tableName: 'otel_logs' },
|
||||
timestampValueExpression: 'Timestamp',
|
||||
},
|
||||
{
|
||||
id: 'source-traces',
|
||||
name: 'Traces',
|
||||
kind: SourceKind.Trace,
|
||||
connection: 'conn-1',
|
||||
from: { databaseName: 'default', tableName: 'otel_traces' },
|
||||
timestampValueExpression: 'Timestamp',
|
||||
},
|
||||
];
|
||||
|
||||
const mockDatabases = ['default', 'system', 'logs'];
|
||||
|
||||
const mockTables = [
|
||||
'otel_logs',
|
||||
'otel_traces',
|
||||
'otel_metrics_gauge',
|
||||
'otel_metrics_sum',
|
||||
'otel_metrics_histogram',
|
||||
];
|
||||
|
||||
// MSW handlers for API mocking (using named handlers for easy per-story overrides)
|
||||
const defaultHandlers = {
|
||||
connections: http.get('*/api/connections', () => {
|
||||
return HttpResponse.json(mockConnections);
|
||||
}),
|
||||
sources: http.get('*/api/sources', () => {
|
||||
return HttpResponse.json(mockSources);
|
||||
}),
|
||||
sourceById: http.get('*/api/sources/:id', ({ params }) => {
|
||||
const source = mockSources.find(s => s.id === params.id);
|
||||
if (source) {
|
||||
return HttpResponse.json(source);
|
||||
}
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}),
|
||||
databases: http.get('*/api/clickhouse/databases', () => {
|
||||
return HttpResponse.json(mockDatabases);
|
||||
}),
|
||||
tables: http.get('*/api/clickhouse/tables', () => {
|
||||
return HttpResponse.json(mockTables);
|
||||
}),
|
||||
createSource: http.post('*/api/sources', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ id: 'new-source-id', ...body });
|
||||
}),
|
||||
updateSource: http.put('*/api/sources/:id', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json(body);
|
||||
}),
|
||||
deleteSource: http.delete('*/api/sources/:id', () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof TableSourceForm> = {
|
||||
title: 'Components/Sources/SourceForm',
|
||||
component: TableSourceForm,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
msw: {
|
||||
handlers: defaultHandlers,
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<Card withBorder p="md" style={{ maxWidth: 900 }}>
|
||||
<Story />
|
||||
</Card>
|
||||
),
|
||||
],
|
||||
argTypes: {
|
||||
isNew: {
|
||||
control: 'boolean',
|
||||
description: 'Whether this is a new source (create mode)',
|
||||
},
|
||||
defaultName: {
|
||||
control: 'text',
|
||||
description: 'Default name for new sources',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Create new source form */
|
||||
export const CreateNew: Story = {
|
||||
name: 'Create New Source',
|
||||
args: {
|
||||
isNew: true,
|
||||
defaultName: 'My New Source',
|
||||
onCreate: () => {
|
||||
// Source created
|
||||
},
|
||||
onCancel: () => {
|
||||
// Cancelled
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Edit existing source */
|
||||
export const EditExisting: Story = {
|
||||
name: 'Edit Existing Source',
|
||||
args: {
|
||||
sourceId: 'source-logs',
|
||||
onSave: () => {
|
||||
// Saved
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -61,13 +61,13 @@ import {
|
|||
MV_GRANULARITY_OPTIONS,
|
||||
} from '@/utils/materializedViews';
|
||||
|
||||
import ConfirmDeleteMenu from './ConfirmDeleteMenu';
|
||||
import { ConnectionSelectControlled } from './ConnectionSelect';
|
||||
import { DatabaseSelectControlled } from './DatabaseSelect';
|
||||
import { DBTableSelectControlled } from './DBTableSelect';
|
||||
import { InputControlled } from './InputControlled';
|
||||
import SelectControlled from './SelectControlled';
|
||||
import { SQLInlineEditorControlled } from './SQLInlineEditor';
|
||||
import ConfirmDeleteMenu from '../ConfirmDeleteMenu';
|
||||
import { ConnectionSelectControlled } from '../ConnectionSelect';
|
||||
import { DatabaseSelectControlled } from '../DatabaseSelect';
|
||||
import { DBTableSelectControlled } from '../DBTableSelect';
|
||||
import { InputControlled } from '../InputControlled';
|
||||
import SelectControlled from '../SelectControlled';
|
||||
import { SQLInlineEditorControlled } from '../SQLInlineEditor';
|
||||
|
||||
const DEFAULT_DATABASE = 'default';
|
||||
|
||||
|
|
@ -1824,42 +1824,7 @@ export function TableSourceForm({
|
|||
}
|
||||
>
|
||||
<Stack gap="md" mb="md">
|
||||
<Flex justify="space-between" align="center" mb="lg">
|
||||
<Text>Source Settings</Text>
|
||||
<Group>
|
||||
{onCancel && (
|
||||
<Button variant="outline" onClick={onCancel} size="xs">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{isNew ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="green"
|
||||
onClick={_onCreate}
|
||||
size="xs"
|
||||
loading={createSource.isPending}
|
||||
>
|
||||
Save New Source
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<ConfirmDeleteMenu
|
||||
onDelete={() => deleteSource.mutate({ id: sourceId ?? '' })}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="green"
|
||||
onClick={_onSave}
|
||||
size="xs"
|
||||
loading={createSource.isPending}
|
||||
>
|
||||
Save Source
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Flex>
|
||||
<Text mb="lg">Source Settings</Text>
|
||||
<FormRow label={'Name'}>
|
||||
<InputControlled
|
||||
control={control}
|
||||
|
|
@ -1914,6 +1879,37 @@ export function TableSourceForm({
|
|||
)}
|
||||
</Stack>
|
||||
<TableModelForm control={control} setValue={setValue} kind={kind} />
|
||||
<Group justify="flex-end" mt="lg">
|
||||
{onCancel && (
|
||||
<Button variant="secondary" onClick={onCancel} size="xs">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{isNew ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={_onCreate}
|
||||
size="xs"
|
||||
loading={createSource.isPending}
|
||||
>
|
||||
Save New Source
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<ConfirmDeleteMenu
|
||||
onDelete={() => deleteSource.mutate({ id: sourceId ?? '' })}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={_onSave}
|
||||
size="xs"
|
||||
loading={createSource.isPending}
|
||||
>
|
||||
Save Source
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
packages/app/src/components/Sources/Sources.module.scss
Normal file
3
packages/app/src/components/Sources/Sources.module.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.sourcesCard {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
168
packages/app/src/components/Sources/SourcesList.stories.tsx
Normal file
168
packages/app/src/components/Sources/SourcesList.stories.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { SourceKind } from '@hyperdx/common-utils/dist/types';
|
||||
import { Box } from '@mantine/core';
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs';
|
||||
|
||||
import { SourcesList } from './SourcesList';
|
||||
|
||||
const mockConnections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'Local ClickHouse',
|
||||
host: 'localhost:8123',
|
||||
username: 'default',
|
||||
},
|
||||
];
|
||||
|
||||
const mockSources = [
|
||||
{
|
||||
id: 'source-logs',
|
||||
name: 'Logs',
|
||||
kind: SourceKind.Log,
|
||||
connection: 'conn-1',
|
||||
from: { databaseName: 'default', tableName: 'otel_logs' },
|
||||
timestampValueExpression: 'Timestamp',
|
||||
},
|
||||
{
|
||||
id: 'source-traces',
|
||||
name: 'Traces',
|
||||
kind: SourceKind.Trace,
|
||||
connection: 'conn-1',
|
||||
from: { databaseName: 'default', tableName: 'otel_traces' },
|
||||
timestampValueExpression: 'Timestamp',
|
||||
},
|
||||
];
|
||||
|
||||
// Default handlers that return mock data
|
||||
const defaultHandlers = {
|
||||
connections: http.get('*/api/connections', () => {
|
||||
return HttpResponse.json(mockConnections);
|
||||
}),
|
||||
sources: http.get('*/api/sources', () => {
|
||||
return HttpResponse.json(mockSources);
|
||||
}),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SourcesList> = {
|
||||
title: 'Components/Sources/SourcesList',
|
||||
component: SourcesList,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
msw: {
|
||||
handlers: defaultHandlers,
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<Box style={{ maxWidth: 800 }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['compact', 'default'],
|
||||
description: 'Visual variant for text/icon sizing',
|
||||
},
|
||||
withCard: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to wrap in a Card component',
|
||||
},
|
||||
withBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the card has a border',
|
||||
},
|
||||
showEmptyState: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show empty state UI',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/* Default with mock data - compact variant (for GettingStarted) */
|
||||
export const Default: Story = {
|
||||
name: 'Compact Variant (GettingStarted)',
|
||||
args: {
|
||||
variant: 'compact',
|
||||
withCard: true,
|
||||
withBorder: true,
|
||||
},
|
||||
};
|
||||
|
||||
/* Default variant (for TeamPage) */
|
||||
export const DefaultVariant: Story = {
|
||||
name: 'Default Variant (TeamPage)',
|
||||
args: {
|
||||
variant: 'default',
|
||||
withCard: true,
|
||||
withBorder: false,
|
||||
showEmptyState: false,
|
||||
},
|
||||
};
|
||||
|
||||
/* Loading State - simulates slow API */
|
||||
export const Loading: Story = {
|
||||
name: 'Loading State',
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: {
|
||||
connections: http.get('*/api/connections', async () => {
|
||||
await delay('infinite');
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
sources: http.get('*/api/sources', async () => {
|
||||
await delay('infinite');
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/* Error State - simulates API failure */
|
||||
export const Error: Story = {
|
||||
name: 'Error State',
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: {
|
||||
connections: http.get('*/api/connections', () => {
|
||||
return HttpResponse.json(
|
||||
{ message: 'Failed to connect to database' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}),
|
||||
sources: http.get('*/api/sources', () => {
|
||||
return HttpResponse.json(
|
||||
{ message: 'Failed to fetch sources' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/* Empty State - no sources configured */
|
||||
export const Empty: Story = {
|
||||
name: 'Empty (No Sources)',
|
||||
args: {
|
||||
showEmptyState: true,
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: {
|
||||
connections: http.get('*/api/connections', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
sources: http.get('*/api/sources', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
242
packages/app/src/components/Sources/SourcesList.tsx
Normal file
242
packages/app/src/components/Sources/SourcesList.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import React, { useState } from 'react';
|
||||
import { SourceKind } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconServer,
|
||||
IconStack,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { IS_LOCAL_MODE } from '@/config';
|
||||
import { useConnections } from '@/connection';
|
||||
import { useSources } from '@/source';
|
||||
import { capitalizeFirstLetter } from '@/utils';
|
||||
|
||||
import { TableSourceForm } from './SourceForm';
|
||||
|
||||
import styles from './Sources.module.scss';
|
||||
|
||||
export interface SourcesListProps {
|
||||
/** Callback when add source button is clicked */
|
||||
onAddSource?: () => void;
|
||||
/** Whether to wrap content in a Card component (default: true) */
|
||||
withCard?: boolean;
|
||||
/** Whether the card has a border (default: true) */
|
||||
withBorder?: boolean;
|
||||
/** Custom className for the card */
|
||||
cardClassName?: string;
|
||||
/** Visual variant: 'compact' for smaller text, 'default' for standard sizing */
|
||||
variant?: 'compact' | 'default';
|
||||
/** Whether to show empty state UI (default: true) */
|
||||
showEmptyState?: boolean;
|
||||
}
|
||||
|
||||
export function SourcesList({
|
||||
onAddSource,
|
||||
withCard = true,
|
||||
withBorder = true,
|
||||
cardClassName,
|
||||
variant = 'compact',
|
||||
showEmptyState = true,
|
||||
}: SourcesListProps) {
|
||||
const {
|
||||
data: connections,
|
||||
isLoading: isLoadingConnections,
|
||||
error: connectionsError,
|
||||
refetch: refetchConnections,
|
||||
} = useConnections();
|
||||
const {
|
||||
data: sources,
|
||||
isLoading: isLoadingSources,
|
||||
error: sourcesError,
|
||||
refetch: refetchSources,
|
||||
} = useSources();
|
||||
|
||||
const [editedSourceId, setEditedSourceId] = useState<string | null>(null);
|
||||
const [isCreatingSource, setIsCreatingSource] = useState(false);
|
||||
|
||||
const isLoading = isLoadingConnections || isLoadingSources;
|
||||
const error = connectionsError || sourcesError;
|
||||
|
||||
const handleRetry = () => {
|
||||
refetchConnections();
|
||||
refetchSources();
|
||||
};
|
||||
|
||||
// Sizing based on variant
|
||||
const textSize = variant === 'compact' ? 'sm' : 'md';
|
||||
const subtextSize = variant === 'compact' ? 'xs' : 'sm';
|
||||
const iconSize = variant === 'compact' ? 11 : 14;
|
||||
const buttonSize = variant === 'compact' ? 'xs' : 'sm';
|
||||
|
||||
const Wrapper = withCard ? Card : React.Fragment;
|
||||
const wrapperProps = withCard
|
||||
? {
|
||||
withBorder,
|
||||
p: 'md',
|
||||
radius: 'sm',
|
||||
className: cardClassName ?? styles.sourcesCard,
|
||||
}
|
||||
: {};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Wrapper {...wrapperProps}>
|
||||
<Flex justify="center" align="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed" ml="sm">
|
||||
Loading sources...
|
||||
</Text>
|
||||
</Flex>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Wrapper {...wrapperProps}>
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
title="Failed to load sources"
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm" mb="sm">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: 'An error occurred while loading data sources.'}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={handleRetry}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Alert>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !sources || sources.length === 0;
|
||||
|
||||
return (
|
||||
<Wrapper {...wrapperProps}>
|
||||
<Stack gap="md">
|
||||
{isEmpty && !isCreatingSource && showEmptyState && (
|
||||
<Flex direction="column" align="center" py="xl" gap="sm">
|
||||
<IconStack size={32} color="var(--color-text-muted)" />
|
||||
<Title size="sm" ta="center" c="var(--color-text-muted)">
|
||||
No data sources configured yet.
|
||||
</Title>
|
||||
<Text size="xs" ta="center" c="var(--color-text-muted)">
|
||||
Add a source to start querying your data.
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{sources?.map((s, index) => (
|
||||
<React.Fragment key={s.id}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size={textSize} fw={500}>
|
||||
{s.name}
|
||||
</Text>
|
||||
<Text size={subtextSize} c="dimmed" mt={4}>
|
||||
<Group gap="xs">
|
||||
{capitalizeFirstLetter(s.kind)}
|
||||
<Group gap={4}>
|
||||
<IconServer size={iconSize} />
|
||||
{connections?.find(c => c.id === s.connection)?.name}
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
{s.from && (
|
||||
<>
|
||||
<IconStack size={iconSize} />
|
||||
{s.from.databaseName}
|
||||
{s.kind === SourceKind.Metric ? '' : '.'}
|
||||
{s.from.tableName}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Text>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
size={buttonSize}
|
||||
onClick={() =>
|
||||
setEditedSourceId(editedSourceId === s.id ? null : s.id)
|
||||
}
|
||||
>
|
||||
{editedSourceId === s.id ? (
|
||||
<IconChevronUp size={iconSize + 2} />
|
||||
) : (
|
||||
<IconChevronDown size={iconSize + 2} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
{editedSourceId === s.id && (
|
||||
<Box mt="xs">
|
||||
<TableSourceForm
|
||||
sourceId={s.id}
|
||||
onSave={() => setEditedSourceId(null)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{index < (sources?.length ?? 0) - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{isCreatingSource && (
|
||||
<>
|
||||
{sources && sources.length > 0 && <Divider />}
|
||||
<TableSourceForm
|
||||
isNew
|
||||
onCreate={() => setIsCreatingSource(false)}
|
||||
onCancel={() => setIsCreatingSource(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!IS_LOCAL_MODE && !isCreatingSource && (
|
||||
<Flex
|
||||
justify="flex-end"
|
||||
pt={sources && sources.length > 0 ? 'md' : 0}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={buttonSize}
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => {
|
||||
setIsCreatingSource(true);
|
||||
onAddSource?.();
|
||||
}}
|
||||
>
|
||||
Add source
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
9
packages/app/src/components/Sources/index.ts
Normal file
9
packages/app/src/components/Sources/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
LogTableModelForm,
|
||||
MetricTableModelForm,
|
||||
SessionTableModelForm,
|
||||
TableSourceForm,
|
||||
TraceTableModelForm,
|
||||
} from './SourceForm';
|
||||
export type { SourcesListProps } from './SourcesList';
|
||||
export { SourcesList } from './SourcesList';
|
||||
128
packages/app/src/stories/ActionIcon.stories.tsx
Normal file
128
packages/app/src/stories/ActionIcon.stories.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { ActionIcon, Group, Stack, Text } from '@mantine/core';
|
||||
import type { Meta } from '@storybook/nextjs';
|
||||
import {
|
||||
IconCheck,
|
||||
IconEdit,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/ActionIcon',
|
||||
component: ActionIcon,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const CustomVariants = () => (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Primary
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon variant="primary" size="sm">
|
||||
<IconCheck size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="primary" size="md">
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="primary" size="lg">
|
||||
<IconCheck size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="primary" disabled>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Secondary
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon variant="secondary" size="sm">
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="secondary" size="md">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="secondary" size="lg">
|
||||
<IconEdit size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="secondary" disabled>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Danger
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon variant="danger" size="sm">
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="danger" size="md">
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="danger" size="lg">
|
||||
<IconTrash size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="danger" disabled>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600}>
|
||||
ActionIcon Sizes
|
||||
</Text>
|
||||
<Group align="center">
|
||||
<ActionIcon variant="primary" size="xs">
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="primary" size="sm">
|
||||
<IconPlus size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="primary" size="md">
|
||||
<IconPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const CommonUseCases = () => (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600}>
|
||||
Common Use Cases
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon variant="primary" aria-label="Confirm">
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="danger" aria-label="Cancel">
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="secondary" aria-label="Settings">
|
||||
<IconSettings size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="secondary" aria-label="Edit">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="danger" aria-label="Delete">
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
|
|
@ -1,25 +1,92 @@
|
|||
import { Button } from '@mantine/core';
|
||||
import { Button, Group, Stack, Text } from '@mantine/core';
|
||||
import type { Meta } from '@storybook/nextjs';
|
||||
import { IconStarFilled } from '@tabler/icons-react';
|
||||
|
||||
// Just a test story, can be deleted
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Button',
|
||||
title: 'Components/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconStarFilled size={14} />}
|
||||
size="compact-sm"
|
||||
>
|
||||
Assign exception to Warren
|
||||
</Button>
|
||||
export default meta;
|
||||
|
||||
export const CustomVariants = () => (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Primary
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="primary" leftSection={<IconCheck size={16} />}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="primary" rightSection={<IconArrowRight size={16} />}>
|
||||
Continue
|
||||
</Button>
|
||||
<Button variant="primary" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Secondary
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="secondary" leftSection={<IconPlus size={16} />}>
|
||||
Add Item
|
||||
</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Danger
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="danger" leftSection={<IconTrash size={16} />}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="danger" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default meta;
|
||||
export const Sizes = () => (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600}>
|
||||
Button Sizes
|
||||
</Text>
|
||||
<Group align="center">
|
||||
<Button variant="primary" size="xxs">
|
||||
XXS
|
||||
</Button>
|
||||
<Button variant="primary" size="xs">
|
||||
XS
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
SM
|
||||
</Button>
|
||||
<Button variant="primary" size="md">
|
||||
MD
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
rem,
|
||||
Select,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
|
||||
export const makeTheme = ({
|
||||
|
|
@ -78,6 +79,13 @@ export const makeTheme = ({
|
|||
fontFamily,
|
||||
},
|
||||
components: {
|
||||
Tooltip: Tooltip.extend({
|
||||
styles: () => ({
|
||||
tooltip: {
|
||||
fontFamily: 'var(--mantine-font-family)',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
Modal: {
|
||||
styles: {
|
||||
header: {
|
||||
|
|
@ -221,6 +229,49 @@ export const makeTheme = ({
|
|||
|
||||
return { root: {} };
|
||||
},
|
||||
styles: (_theme, props) => {
|
||||
// Primary variant - light green style
|
||||
if (props.variant === 'primary') {
|
||||
return {
|
||||
root: {
|
||||
backgroundColor: 'var(--mantine-color-green-light)',
|
||||
color: 'var(--mantine-color-green-light-color)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-green-light-hover)',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Secondary variant - similar to default
|
||||
if (props.variant === 'secondary') {
|
||||
return {
|
||||
root: {
|
||||
backgroundColor: 'var(--color-bg-body)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-bg-hover)',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Danger variant - light red style
|
||||
if (props.variant === 'danger') {
|
||||
return {
|
||||
root: {
|
||||
backgroundColor: 'var(--mantine-color-red-light)',
|
||||
color: 'var(--mantine-color-red-light-color)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-red-light-hover)',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
}),
|
||||
SegmentedControl: {
|
||||
styles: {
|
||||
|
|
@ -237,7 +288,7 @@ export const makeTheme = ({
|
|||
variant: 'subtle',
|
||||
color: 'gray',
|
||||
},
|
||||
styles: (theme, props) => {
|
||||
styles: (_theme, props) => {
|
||||
// Subtle variant stays transparent
|
||||
if (props.variant === 'subtle') {
|
||||
return {
|
||||
|
|
@ -271,6 +322,46 @@ export const makeTheme = ({
|
|||
};
|
||||
}
|
||||
|
||||
// Primary variant - light green style
|
||||
if (props.variant === 'primary') {
|
||||
return {
|
||||
root: {
|
||||
backgroundColor: 'var(--mantine-color-green-light)',
|
||||
color: 'var(--mantine-color-green-light-color)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-green-light-hover)',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Secondary variant - similar to default
|
||||
if (props.variant === 'secondary') {
|
||||
return {
|
||||
root: {
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-bg-hover)',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Danger variant - light red style
|
||||
if (props.variant === 'danger') {
|
||||
return {
|
||||
root: {
|
||||
backgroundColor: 'var(--mantine-color-red-light)',
|
||||
color: 'var(--mantine-color-red-light-color)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-red-light-hover)',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue