Add stack list, new stack, edit stack

This commit is contained in:
idastambuk
2025-12-08 12:27:41 +01:00
parent 7e991886e0
commit 41f9162472
14 changed files with 979 additions and 0 deletions
+4
View File
@@ -150,6 +150,10 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index)
r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/add-new-connection", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks/new", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks/edit/*", authorize(datasources.ConfigurationPageAccess), hs.Index)
// Plugin details pages
r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
@@ -182,6 +182,8 @@ export function getNavTitle(navId: string | undefined) {
return t('nav.connections.title', 'Connections');
case 'connections-add-new-connection':
return t('nav.add-new-connections.title', 'Add new connection');
case 'connections-stacks':
return t('nav.stacks.title', 'Stacks');
case 'standalone-plugin-page-/connections/collector':
return t('nav.collector.title', 'Collector');
case 'connections-datasources':
@@ -10,10 +10,13 @@ import { CacheFeatureHighlightPage } from './pages/CacheFeatureHighlightPage';
import ConnectionsHomePage from './pages/ConnectionsHomePage';
import { DataSourceDashboardsPage } from './pages/DataSourceDashboardsPage';
import { DataSourceDetailsPage } from './pages/DataSourceDetailsPage';
import { DataSourceStacksPage } from './pages/DataSourceStacksPage';
import { DataSourcesListPage } from './pages/DataSourcesListPage';
import { EditDataSourcePage } from './pages/EditDataSourcePage';
import { EditStackPage } from './pages/EditStackPage';
import { InsightsFeatureHighlightPage } from './pages/InsightsFeatureHighlightPage';
import { NewDataSourcePage } from './pages/NewDataSourcePage';
import { NewStackPage } from './pages/NewStackPage';
import { PermissionsFeatureHighlightPage } from './pages/PermissionsFeatureHighlightPage';
function RedirectToAddNewConnection() {
@@ -41,6 +44,9 @@ export default function Connections() {
{/* The route paths need to be relative to the parent path (ROUTES.Base), so we need to remove that part */}
<Route caseSensitive path={ROUTES.DataSources.replace(ROUTES.Base, '')} element={<DataSourcesListPage />} />
<Route caseSensitive path={ROUTES.DataSourcesNew.replace(ROUTES.Base, '')} element={<NewDataSourcePage />} />
<Route caseSensitive path={ROUTES.Stacks.replace(ROUTES.Base, '')} element={<DataSourceStacksPage />} />
<Route caseSensitive path={ROUTES.StacksNew.replace(ROUTES.Base, '')} element={<NewStackPage />} />
<Route caseSensitive path={ROUTES.StacksEdit.replace(ROUTES.Base, '')} element={<EditStackPage />} />
<Route
caseSensitive
path={ROUTES.DataSourcesDetails.replace(ROUTES.Base, '')}
@@ -75,5 +75,11 @@ export function getOssCardData(): CardData[] {
url: '/connections/datasources',
icon: 'database',
},
{
text: 'Stacks',
subTitle: 'Manage your data source stacks',
url: '/connections/stacks',
icon: 'layers',
},
];
}
@@ -9,6 +9,10 @@ export const ROUTES = {
DataSourcesNew: `/${ROUTE_BASE_ID}/datasources/new`,
DataSourcesEdit: `/${ROUTE_BASE_ID}/datasources/edit/:uid`,
DataSourcesDashboards: `/${ROUTE_BASE_ID}/datasources/edit/:uid/dashboards`,
// Stacks
Stacks: `/${ROUTE_BASE_ID}/stacks`,
StacksNew: `/${ROUTE_BASE_ID}/stacks/new`,
StacksEdit: `/${ROUTE_BASE_ID}/stacks/edit/:uid`,
// Add new connection
AddNewConnection: `/${ROUTE_BASE_ID}/add-new-connection`,
@@ -0,0 +1,252 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import {
Card,
EmptyState,
FilterInput,
IconButton,
LinkButton,
Spinner,
Stack,
TagList,
useStyles2,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { Resource, ResourceList, GroupVersionResource } from 'app/features/apiserver/types';
// Define the DataSourceStack spec type based on the backend Go types
export interface DataSourceStackTemplateItem {
group: string;
name: string;
}
export interface DataSourceStackModeItem {
dataSourceRef: string;
}
export interface DataSourceStackModeSpec {
name: string;
uid: string;
definition: Record<string, DataSourceStackModeItem>;
}
export interface DataSourceStackSpec {
template: Record<string, DataSourceStackTemplateItem>;
modes: DataSourceStackModeSpec[];
}
// GroupVersionResource for datasourcestacks
const datasourceStacksGVR: GroupVersionResource = {
group: 'collections.grafana.app',
version: 'v1alpha1',
resource: 'datasourcestacks',
};
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
export function DataSourceStacksPage() {
const [stacks, setStacks] = useState<Array<Resource<DataSourceStackSpec>>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const styles = useStyles2(getStyles);
const fetchStacks = useCallback(async () => {
try {
setLoading(true);
const response: ResourceList<DataSourceStackSpec> = await datasourceStacksClient.list();
setStacks(response.items);
} catch (err) {
console.error('Failed to fetch datasource stacks:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stacks');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStacks();
}, [fetchStacks]);
const onDeleteStack = (stackName: string) => async () => {
await datasourceStacksClient.delete(stackName, false);
fetchStacks();
};
// Filter stacks based on search query
const filteredStacks = useMemo(() => {
if (!searchQuery) {
return stacks;
}
const query = searchQuery.toLowerCase();
return stacks.filter((stack) => {
const nameMatch = stack.metadata.name?.toLowerCase().includes(query);
const templateMatch = Object.values(stack.spec.template).some(
(template) => template.name.toLowerCase().includes(query) || template.group.toLowerCase().includes(query)
);
return nameMatch || templateMatch;
});
}, [stacks, searchQuery]);
const actions =
stacks.length > 0 ? (
<LinkButton variant="primary" icon="plus" href="/connections/stacks/new">
<Trans i18nKey="connections.stacks-list-view.add-stack">Add stack</Trans>
</LinkButton>
) : undefined;
const pageNav = {
text: t('connections.stacks-list-view.title', 'Data source stacks'),
subTitle: t(
'connections.stacks-list-view.subtitle',
'Manage your data source stacks to group environments like dev, staging, and production'
),
};
return (
<Page navId="connections-datasources" pageNav={pageNav} actions={actions}>
<Page.Contents>
<DataSourceStacksListContent
stacks={filteredStacks}
loading={loading}
error={error}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
onDeleteStack={onDeleteStack}
styles={styles}
/>
</Page.Contents>
</Page>
);
}
interface DataSourceStacksListContentProps {
stacks: Array<Resource<DataSourceStackSpec>>;
loading: boolean;
error: string | null;
searchQuery: string;
setSearchQuery: (query: string) => void;
styles: ReturnType<typeof getStyles>;
onDeleteStack: (stackName: string) => () => Promise<void>;
}
function DataSourceStacksListContent({
stacks,
loading,
error,
searchQuery,
setSearchQuery,
styles,
onDeleteStack,
}: DataSourceStacksListContentProps) {
if (loading) {
return <Spinner />;
}
if (error) {
return (
<EmptyState
variant="not-found"
message={t('connections.stacks-list-view.error', 'Failed to load data source stacks')}
>
<div>{error}</div>
</EmptyState>
);
}
if (stacks.length === 0 && !searchQuery) {
return (
<EmptyState
message={t(
'connections.stacks-list-view.empty.no-rules-created',
"You haven't created any data source stacks yet"
)}
variant="call-to-action"
>
<div>
<Trans i18nKey="connections.stacks-list-view.empty.description">
Use data source stacks to group environments like dev, stg, and prod. Reference the stack in your query, and
Grafana automatically selects the right data source for that environment.
</Trans>
</div>
<LinkButton variant="primary" icon="plus" size="lg" href="/connections/stacks/new">
<Trans i18nKey="connections.stacks-list-view.empty.new-stack">New stack</Trans>
</LinkButton>
</EmptyState>
);
}
return (
<Stack direction="column" gap={2}>
<div className={styles.searchContainer}>
<FilterInput
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('connections.stacks-list-view.search-placeholder', 'Search by name or type')}
/>
</div>
{stacks.length === 0 && searchQuery ? (
<EmptyState
variant="not-found"
message={t('connections.stacks-list-view.no-results', 'No data source stacks found')}
/>
) : (
<ul className={styles.list}>
{stacks.map((stack) => (
<li key={stack.metadata.name}>
<Card noMargin href={`/connections/stacks/edit/${stack.metadata.name}`}>
<Card.Heading>{stack.metadata.name}</Card.Heading>
<Card.Tags>
<Stack direction="row" gap={2} alignItems="center">
<TagList tags={getDatasourceList(stack.spec)} />
<IconButton
name="trash-alt"
variant="destructive"
aria-label={t('connections.stacks-list-view.delete-stack', 'Delete stack')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeleteStack(stack.metadata.name)();
}}
/>
</Stack>
</Card.Tags>
</Card>
</li>
))}
</ul>
)}
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
searchContainer: css({
marginBottom: theme.spacing(2),
maxWidth: '500px',
}),
list: css({
listStyle: 'none',
display: 'grid',
gap: theme.spacing(1),
}),
});
const getDatasourceList = (stack: DataSourceStackSpec): string[] => {
return Array.from(
// remove duplicates
new Set(
Object.values(stack.template).map((template) => {
const match = template.group.match(/^grafana-(.+)-datasource$/);
if (match && match[1]) {
return match[1].charAt(0).toUpperCase() + match[1].slice(1);
}
return template.name.charAt(0).toUpperCase() + template.name.slice(1);
})
)
);
};
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { EmptyState, Spinner } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { Resource, GroupVersionResource } from 'app/features/apiserver/types';
import {
StackForm,
transformStackSpecToFormValues,
} from 'app/features/datasources/components/new-stack-form/StackForm';
import { StackFormValues } from 'app/features/datasources/components/new-stack-form/types';
import { DataSourceStackSpec } from './DataSourceStacksPage';
const datasourceStacksGVR: GroupVersionResource = {
group: 'collections.grafana.app',
version: 'v1alpha1',
resource: 'datasourcestacks',
};
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
export function EditStackPage() {
const { uid } = useParams<{ uid: string }>();
const [stack, setStack] = useState<Resource<DataSourceStackSpec> | null>(null);
const [formValues, setFormValues] = useState<StackFormValues | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStack = async () => {
if (!uid) {
setError('No stack UID provided');
setLoading(false);
return;
}
try {
setLoading(true);
const response = await datasourceStacksClient.get(uid);
setStack(response);
const values = transformStackSpecToFormValues(response.metadata.name || '', response.spec);
setFormValues(values);
} catch (err) {
console.error('Failed to fetch datasource stack:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stack');
} finally {
setLoading(false);
}
};
fetchStack();
}, [uid]);
const pageNav = {
text: stack?.metadata.name
? t('connections.edit-stack-page.title-with-name', 'Edit {{name}}', { name: stack.metadata.name })
: t('connections.edit-stack-page.title', 'Edit Data Source Stack'),
subTitle: t('connections.edit-stack-page.subtitle', 'Modify your data source stack configuration'),
};
return (
<Page navId="connections-datasources" pageNav={pageNav}>
<Page.Contents>
<EditStackContent loading={loading} error={error} formValues={formValues} />
</Page.Contents>
</Page>
);
}
interface EditStackContentProps {
loading: boolean;
error: string | null;
formValues: StackFormValues | null;
}
function EditStackContent({ loading, error, formValues }: EditStackContentProps) {
if (loading) {
return <Spinner />;
}
if (error) {
return (
<EmptyState
variant="not-found"
message={t('connections.edit-stack-page.error', 'Failed to load data source stack')}
>
<div>{error}</div>
</EmptyState>
);
}
if (!formValues) {
return (
<EmptyState
variant="not-found"
message={t('connections.edit-stack-page.not-found', 'Data source stack not found')}
/>
);
}
return <StackForm existing={formValues} />;
}
@@ -0,0 +1,19 @@
import { Page } from 'app/core/components/Page/Page';
import { StackForm } from 'app/features/datasources/components/new-stack-form/StackForm';
export function NewStackPage() {
return (
<Page
navId={'connections-datasources'}
pageNav={{
text: 'New Data Source Stack',
subTitle: 'Add a new data source stack',
active: true,
}}
>
<Page.Contents>
<StackForm />
</Page.Contents>
</Page>
);
}
@@ -0,0 +1,178 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { FormProvider, SubmitErrorHandler, useForm } from 'react-hook-form';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Button, Stack, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { DataSourceStackSpec } from 'app/features/connections/pages/DataSourceStacksPage';
import { StackModes } from './StackModes';
import { StackName } from './StackName';
import { StackTemplate } from './StackTemplate';
import { StackFormValues } from './types';
type Props = {
existing?: StackFormValues;
};
const defaultValues: StackFormValues = {
name: '',
templates: [],
modes: [],
};
export const StackForm = ({ existing }: Props) => {
const styles = useStyles2(getStyles);
const notifyApp = useAppNotification();
const initialValues: StackFormValues = useMemo(() => {
if (existing) {
return existing;
}
return defaultValues;
}, [existing]);
const formAPI = useForm<StackFormValues>({
mode: 'onSubmit',
defaultValues: initialValues,
shouldFocusError: true,
});
const {
handleSubmit,
formState: { isSubmitting },
} = formAPI;
const submit = async (values: StackFormValues): Promise<void> => {
const payload = prepareCreateStackPayload(values);
console.log('Form submitted with payload:', payload);
// TODO: Call API to save the stack using payload
notifyApp.success('Stack saved successfully!');
};
const onInvalid: SubmitErrorHandler<StackFormValues> = () => {
notifyApp.error('There are errors in the form. Please correct them and try again!');
};
return (
<FormProvider {...formAPI}>
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
<div className={styles.contentOuter}>
<Stack direction="column" gap={3}>
{/* Step 1 - name */}
<StackName />
{/* Step 2 - Templates */}
<StackTemplate />
{/* Step 3 - Modes */}
<StackModes />
{/* Actions */}
<Stack direction="row" alignItems="center">
<Button
variant="primary"
type="button"
onClick={handleSubmit((values) => submit(values), onInvalid)}
disabled={isSubmitting}
icon={isSubmitting ? 'spinner' : undefined}
>
<Trans i18nKey="datasources.stack-form.save">Save</Trans>
</Button>
</Stack>
</Stack>
</div>
</form>
</FormProvider>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
form: css({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
}),
contentOuter: css({
background: theme.colors.background.primary,
overflow: 'hidden',
maxWidth: theme.breakpoints.values.xl,
flex: 1,
}),
});
export const prepareCreateStackPayload = (formValues: StackFormValues): DataSourceStackSpec => {
// creates a mapping from template name to UUID
const templateNameToUuid: Record<string, string> = {};
formValues.templates.forEach((template) => {
templateNameToUuid[template.name] = uuidv4();
});
// builds the template record with UUIDs as keys
const template: DataSourceStackSpec['template'] = {};
formValues.templates.forEach((t) => {
const uuid = templateNameToUuid[t.name];
template[uuid] = {
group: t.type,
name: t.name,
};
});
// uses template ids to build modes
const modes: DataSourceStackSpec['modes'] = formValues.modes.map((mode) => {
const definition: Record<string, { dataSourceRef: string }> = {};
Object.entries(mode.datasources).forEach(([templateName, dataSourceUid]) => {
const uuid = templateNameToUuid[templateName];
if (uuid) {
definition[uuid] = { dataSourceRef: dataSourceUid };
}
});
return {
name: mode.name,
uid: uuidv4(),
definition,
};
});
return { template, modes };
};
//used when loading an existing stack for editing.
export const transformStackSpecToFormValues = (stackName: string, spec: DataSourceStackSpec): StackFormValues => {
const uuidToTemplateName: Record<string, string> = {};
Object.entries(spec.template).forEach(([uuid, templateItem]) => {
uuidToTemplateName[uuid] = templateItem.name;
});
const templates = Object.values(spec.template).map((templateItem) => ({
name: templateItem.name,
type: templateItem.group,
}));
const modes = spec.modes.map((mode) => {
const datasources: Record<string, string> = {};
Object.entries(mode.definition).forEach(([uuid, modeItem]) => {
const templateName = uuidToTemplateName[uuid];
if (templateName) {
datasources[templateName] = modeItem.dataSourceRef;
}
});
return {
name: mode.name,
datasources,
};
});
return {
name: stackName,
templates,
modes,
};
};
@@ -0,0 +1,63 @@
import { css, cx } from '@emotion/css';
import * as React from 'react';
import { ReactElement } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { FieldSet, Stack, Text, useStyles2 } from '@grafana/ui';
export interface StackFormSectionProps {
title: string;
stepNo: number;
description?: string | ReactElement;
fullWidth?: boolean;
}
export const StackFormSection = ({
title,
stepNo,
children,
fullWidth = false,
description,
}: React.PropsWithChildren<StackFormSectionProps>) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.parent}>
<FieldSet
className={cx(fullWidth && styles.fullWidth)}
label={
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="h3">
{stepNo}. {title}
</Text>
</Stack>
}
>
<Stack direction="column">
{description && <div className={styles.description}>{description}</div>}
{children}
</Stack>
</FieldSet>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
parent: css({
display: 'flex',
flexDirection: 'row',
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.lg,
padding: `${theme.spacing(2)} ${theme.spacing(3)}`,
}),
description: css({
marginTop: `-${theme.spacing(2)}`,
}),
fullWidth: css({
width: '100%',
}),
reverse: css({
flexDirection: 'row-reverse',
gap: theme.spacing(1),
}),
});
@@ -0,0 +1,145 @@
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Button, Field, IconButton, Input, Stack, Text } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { StackFormSection } from './StackFormSection';
import { ModeSection, StackFormValues } from './types';
const createEmptyMode = (): ModeSection => ({
name: '',
datasources: {},
});
export const StackModes = () => {
const {
control,
register,
watch,
formState: { errors },
} = useFormContext<StackFormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: 'modes',
});
const templates = watch('templates');
const hasTemplates = templates && templates.length > 0;
return (
<StackFormSection
stepNo={3}
title={t('datasources.stack-modes.title', 'Add modes')}
description={
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="datasources.stack-modes.description">
Define modes (e.g., dev, staging, prod) and select the actual datasources for each template entry.
</Trans>
</Text>
}
>
<Stack direction="column" gap={3}>
{!hasTemplates && (
<Text color="secondary" italic>
<Trans i18nKey="datasources.stack-modes.no-templates">
Add template sections first to define modes.
</Trans>
</Text>
)}
{hasTemplates &&
fields.map((field, index) => (
<ModeSectionRow
key={field.id}
index={index}
register={register}
control={control}
errors={errors}
templates={templates}
onRemove={() => remove(index)}
/>
))}
{hasTemplates && (
<Button type="button" variant="secondary" icon="plus" onClick={() => append(createEmptyMode())}>
<Trans i18nKey="datasources.stack-modes.add-mode">Add mode</Trans>
</Button>
)}
</Stack>
</StackFormSection>
);
};
interface ModeSectionRowProps {
index: number;
register: ReturnType<typeof useFormContext<StackFormValues>>['register'];
control: ReturnType<typeof useFormContext<StackFormValues>>['control'];
errors: ReturnType<typeof useFormContext<StackFormValues>>['formState']['errors'];
templates: StackFormValues['templates'];
onRemove: () => void;
}
const ModeSectionRow = ({ index, register, control, errors, templates, onRemove }: ModeSectionRowProps) => {
return (
<Stack direction="column" gap={2}>
<Stack direction="row" gap={2} alignItems="center">
<Field
noMargin
label={t('datasources.stack-modes.mode-name-label', 'Mode name')}
error={errors?.modes?.[index]?.name?.message}
invalid={!!errors?.modes?.[index]?.name?.message}
>
<Input
id={`modes.${index}.name`}
width={30}
{...register(`modes.${index}.name`, {
required: {
value: true,
message: t('datasources.stack-modes.mode-name-required', 'Mode name is required'),
},
})}
placeholder={t('datasources.stack-modes.mode-name-placeholder', 'e.g. production')}
/>
</Field>
<IconButton
name="trash-alt"
variant="destructive"
tooltip={t('datasources.stack-modes.remove-mode', 'Remove mode')}
onClick={onRemove}
aria-label={t('datasources.stack-modes.remove-mode', 'Remove mode')}
/>
</Stack>
<Stack direction="row" gap={2} wrap="wrap">
{templates.map((template) => (
<Field
noMargin
key={template.name}
label={template.name || t('datasources.stack-modes.unnamed-template', 'Unnamed template')}
>
<Controller
name={`modes.${index}.datasources.${template.name}`}
control={control}
render={({ field: { ref, onChange, value, ...field } }) => (
<DataSourcePicker
{...field}
current={value}
onChange={(ds) => onChange(ds.uid)}
noDefault={true}
pluginId={template.type}
placeholder={t('datasources.stack-modes.select-datasource', 'Select datasource')}
width={30}
/>
)}
/>
</Field>
))}
</Stack>
</Stack>
);
};
@@ -0,0 +1,49 @@
import { useFormContext } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { Field, Input, Stack, Text } from '@grafana/ui';
import { StackFormSection } from './StackFormSection';
import { StackFormValues } from './types';
export const StackName = () => {
const {
register,
formState: { errors },
} = useFormContext<StackFormValues>();
return (
<StackFormSection
stepNo={1}
title={t('datasources.stack-name.title', 'Enter stack name')}
description={
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="datasources.stack-name.description">Enter a name to identify your stack.</Trans>
</Text>
}
>
<Stack direction="column">
<Field
label={t('datasources.stack-name.label', 'Name')}
error={errors?.name?.message}
invalid={!!errors.name?.message}
>
<Input
data-testid={selectors.components.AlertRules.ruleNameField}
id="name"
width={38}
{...register('name', {
required: {
value: true,
message: t('datasources.stack-name.required', 'Must enter a name'),
},
})}
aria-label={t('datasources.stack-name.aria-label', 'name')}
placeholder="example: LGTM"
/>
</Field>
</Stack>
</StackFormSection>
);
};
@@ -0,0 +1,129 @@
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Button, Combobox, Field, IconButton, Input, Stack, Text } from '@grafana/ui';
import { getOptionDataSourceTypes } from 'app/features/dashboard-scene/settings/variables/utils';
import { StackFormSection } from './StackFormSection';
import { StackFormValues, TemplateSection } from './types';
const emptyTemplateSection: TemplateSection = {
name: '',
type: '',
};
export const StackTemplate = () => {
const {
control,
register,
formState: { errors },
} = useFormContext<StackFormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: 'templates',
});
return (
<StackFormSection
stepNo={2}
title={t('datasources.stack-template.title', 'Add template sections')}
description={
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="datasources.stack-template.description">
Add which datasource types comprise your stack and add names to reference them in the query editor.
</Trans>
</Text>
}
>
<Stack direction="column" gap={2}>
{fields.map((field, index) => (
<TemplateSectionRow
key={field.id}
index={index}
register={register}
control={control}
errors={errors}
onRemove={() => remove(index)}
/>
))}
<Button type="button" variant="secondary" icon="plus" onClick={() => append(emptyTemplateSection)}>
<Trans i18nKey="datasources.stack-template.add-section">Add datasource</Trans>
</Button>
</Stack>
</StackFormSection>
);
};
interface TemplateSectionRowProps {
index: number;
register: ReturnType<typeof useFormContext<StackFormValues>>['register'];
control: ReturnType<typeof useFormContext<StackFormValues>>['control'];
errors: ReturnType<typeof useFormContext<StackFormValues>>['formState']['errors'];
onRemove: () => void;
}
const TemplateSectionRow = ({ index, register, control, errors, onRemove }: TemplateSectionRowProps) => {
const dataSourceOptions = getOptionDataSourceTypes();
return (
<Stack direction="row" gap={2} alignItems="flex-start">
<Field
noMargin
label={t('datasources.stack-template.name-label', 'Name')}
error={errors?.templates?.[index]?.name?.message}
invalid={!!errors?.templates?.[index]?.name?.message}
>
<Input
id={`templates.${index}.name`}
width={30}
{...register(`templates.${index}.name`, {
required: {
value: true,
message: t('datasources.stack-template.name-required', 'Name is required'),
},
})}
placeholder={t('datasources.stack-template.name-placeholder', 'e.g. logs-datasource')}
/>
</Field>
<Field
noMargin
label={t('datasources.stack-template.type-label', 'Data source type')}
error={errors?.templates?.[index]?.type?.message}
invalid={!!errors?.templates?.[index]?.type?.message}
>
<Controller
name={`templates.${index}.type`}
control={control}
rules={{
required: {
value: true,
message: t('datasources.stack-template.type-required', 'Type is required'),
},
}}
render={({ field: { ref, onChange, ...field } }) => (
<Combobox
id={`templates.${index}.type`}
width={30}
options={dataSourceOptions}
onChange={(option) => onChange(option?.value || '')}
placeholder={t('datasources.stack-template.type-placeholder', 'Select type')}
{...field}
/>
)}
/>
</Field>
<IconButton
name="trash-alt"
variant="destructive"
tooltip={t('datasources.stack-template.remove-section', 'Remove section')}
onClick={onRemove}
aria-label={t('datasources.stack-template.remove-section', 'Remove section')}
style={{ marginTop: '28px' }}
/>
</Stack>
);
};
@@ -0,0 +1,16 @@
export interface TemplateSection {
name: string;
type: string;
}
export interface ModeSection {
name: string;
/** template name to selected datasource UID */
datasources: Record<string, string>;
}
export interface StackFormValues {
name: string;
templates: TemplateSection[];
modes: ModeSection[];
}