Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dashboard/e2e/hardware-listing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ test.describe('Hardware Listing Page Tests', () => {
await expect(page).toHaveTitle(/KernelCI/);
await expect(page).toHaveURL(/\/hardware/);
await expect(page.locator(HARDWARE_LISTING_SELECTORS.table)).toBeVisible();
await expect(
page.getByRole('button', { name: 'CLI command' }),
).toBeInViewport();
});

test('selecting a tree auto-fills branch and revision', async ({ page }) => {
Expand Down
3 changes: 3 additions & 0 deletions dashboard/e2e/issue-listing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ test.describe('Issue Listing Page Tests', () => {
await expect(
page.locator(ISSUE_LISTING_SELECTORS.originColumnHeader),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'CLI command' }),
).toBeInViewport();
});

test('date range inputs are visible with default values', async ({
Expand Down
11 changes: 11 additions & 0 deletions dashboard/e2e/tree-compare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,15 @@ test('loads revisions and comparison data from the API', async ({ page }) => {
).toBeVisible();
await expect(page.getByText('defconfig+allmodconfig')).toBeVisible();
await expect(page.getByText('Regression')).toBeVisible();

await expect(
page.getByRole('button', { name: 'CLI command' }),
).toBeInViewport();
await page.getByRole('button', { name: 'CLI command' }).click();
const command = page.getByLabel('Tree comparison');
await expect(command).toContainText('--giturl');
await expect(command).not.toContainText('--git-url');
await expect(command).toContainText('--origin maestro');
await expect(command).toContainText('--branch master');
await expect(command).toContainText(`${HASH_A} ${HASH_B}`);
});
3 changes: 3 additions & 0 deletions dashboard/e2e/tree-listing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ test.describe('Tree Listing Page Tests', () => {
await expect(
page.locator(TREE_LISTING_SELECTORS.branchColumnHeader),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'CLI command' }),
).toBeInViewport();
});

test('change table size', async ({ page }) => {
Expand Down
17 changes: 7 additions & 10 deletions dashboard/src/components/BuildDetails/BuildDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ import ButtonOpenLogSheet from '@/components/Button/ButtonOpenLogSheet';

import { processLogData } from '@/hooks/useLogData';

import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter';
import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton';
import { createResultDetailsCommand } from '@/components/Footer/kcidevCommand';

import { TreeDetailsLink } from '@/components/TreeDetailsLink/TreeDetailsLink';

Expand Down Expand Up @@ -303,14 +304,8 @@ const BuildDetails = ({

const kcidevComponent = useMemo(
() => (
<MemoizedKcidevFooter
commandGroup="details"
args={{
cmdName: 'build',
id: buildId,
'download-logs': true,
json: true,
}}
<MemoizedKcidevCommandButton
command={createResultDetailsCommand('build', buildId)}
/>
),
[buildId],
Expand Down Expand Up @@ -338,6 +333,9 @@ const BuildDetails = ({
<Sheet open={logOpen} onOpenChange={logOpenChange}>
<div className="flex flex-col gap-4 pb-10">
{breadcrumb}
<div className="flex flex-wrap justify-end gap-2">
{kcidevComponent}
</div>
<SectionGroup sections={generalSections} />
<BuildDetailsTestSection
buildId={buildId ?? ''}
Expand All @@ -354,7 +352,6 @@ const BuildDetails = ({
/>
)}
{filesSection && <SectionGroup sections={[filesSection]} />}
{kcidevComponent}
</div>
<LogOrJsonSheetContent
type={sheetType}
Expand Down
106 changes: 106 additions & 0 deletions dashboard/src/components/Footer/KcidevCommandButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { IntlProvider } from 'react-intl';

import type { JSX } from 'react';

import { LOCALES } from '@/locales/constants';
import { messages } from '@/locales/messages';

import { MemoizedKcidevCommandButton } from './KcidevCommandButton';

const completeCommand =
"kci-dev results trees --origin 'unsafe origin' --days 7";

const meta: Meta<typeof MemoizedKcidevCommandButton> = {
title: 'Components/Footer/KcidevCommandButton',
component: MemoizedKcidevCommandButton,
decorators: [
(story): JSX.Element => (
<IntlProvider messages={messages[LOCALES.EN_US]} locale={LOCALES.EN_US}>
{story()}
</IntlProvider>
),
],
args: {
command: {
id: 'trees',
label: 'Tree listing',
argv: [
'kci-dev',
'results',
'trees',
'--origin',
'unsafe origin',
'--days',
'7',
],
omittedFilters: ['tree search'],
reproduction: 'partial',
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Interaction: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const page = within(canvasElement.ownerDocument.body);
const writeText = fn<Clipboard['writeText']>().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const trigger = canvas.getByRole('button', { name: 'CLI command' });

await step(
'opens by keyboard and exposes the complete command',
async () => {
trigger.focus();
await userEvent.keyboard('{Enter}');
await expect(
page.getByRole('heading', { name: 'Run this query with kci-dev' }),
).toBeVisible();
await expect(
page.getByLabelText('Tree listing: Human-readable'),
).toHaveTextContent(completeCommand);
await expect(page.getByRole('note')).toHaveTextContent('tree search');
},
);

await step(
'reports copy success only after the clipboard resolves',
async () => {
await userEvent.click(
page.getByRole('button', { name: 'Copy command: Human-readable' }),
);
await expect(writeText).toHaveBeenCalledWith(completeCommand);
await expect(page.getByText('Copied')).toBeVisible();
},
);

await step('Escape closes the popover and returns focus', async () => {
await userEvent.keyboard('{Escape}');
await expect(trigger).toHaveFocus();
});

await step(
'keeps the command selectable and reports clipboard errors',
async () => {
writeText.mockRejectedValueOnce(new Error('Clipboard denied'));
await userEvent.keyboard('{Enter}');
await userEvent.click(
page.getByRole('button', { name: 'Copy command: Human-readable' }),
);
await expect(page.getByRole('alert')).toHaveTextContent(
'Select it above and copy it manually.',
);
await expect(
page.getByLabelText('Tree listing: Human-readable'),
).toHaveTextContent(completeCommand);
},
);
},
};
149 changes: 149 additions & 0 deletions dashboard/src/components/Footer/KcidevCommandButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { memo, useMemo, useState, type JSX } from 'react';
import { FormattedMessage } from 'react-intl';

import { TbTerminal2 } from 'react-icons/tb';

import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';

import {
serializeShellArgv,
type KcidevCommand,
type KcidevCommandVariant,
} from './kcidevCommand';

type CopyStatus = 'idle' | 'copied' | 'error';

const KcidevCommandButton = ({
command,
}: {
command?: KcidevCommand | readonly KcidevCommand[];
}): JSX.Element => {
const [copyStatus, setCopyStatus] = useState<CopyStatus>('idle');
const commands = useMemo(
() => (command ? (Array.isArray(command) ? command : [command]) : []),
[command],
);
const displayedCommands = useMemo(
() =>
commands.flatMap(value =>
(
value.variants ??
([
{ id: 'human' as const, label: 'Human-readable', argv: value.argv },
] satisfies KcidevCommandVariant[])
).map((variant: KcidevCommandVariant) => ({ command: value, variant })),
),
[commands],
);

const copyCommand = async (argv: readonly string[]): Promise<void> => {
setCopyStatus('idle');
try {
await navigator.clipboard.writeText(serializeShellArgv(argv));
setCopyStatus('copied');
} catch {
setCopyStatus('error');
}
};

if (commands.length === 0) {
return <></>;
}

return (
<div className="shrink-0 text-sm">
<Popover
onOpenChange={open => {
if (open) {
setCopyStatus('idle');
}
}}
>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
<TbTerminal2 aria-hidden="true" className="mr-2 size-5" />
<FormattedMessage id="footer.cliCommand" />
</Button>
</PopoverTrigger>
<PopoverContent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can present this information in a more concise way, especially by using icons in the buttons.
Also, the install kci-dev command is always present, so we could take it off the Popover.

Image

This is just a proposal, there might be better ways to present the popover.

className="w-[calc(100vw-2rem)] max-w-xl text-left"
collisionPadding={16}
>
<h2 className="mb-3 text-base font-semibold">
<FormattedMessage id="footer.commandTitle" />
</h2>
{displayedCommands.map(({ command: value, variant }) => (
<div
className="mb-3"
key={`${value.id}-${variant.id}-${variant.label}`}
>
<h3 className="mb-1 text-sm font-medium">
{commands.length > 1 && `${value.label}: `}
{variant.label}
</h3>
<pre
aria-label={`${value.label}: ${variant.label}`}
className="max-w-full cursor-text overflow-x-auto rounded-md bg-slate-100 p-3 text-sm select-text"
tabIndex={0}
>
<code>{serializeShellArgv(variant.argv)}</code>
</pre>
{value.omittedFilters.length > 0 && (
<div
className="mt-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-950"
role="note"
>
<FormattedMessage
id="footer.unsupportedFilters"
values={{ filters: value.omittedFilters.join(', ') }}
/>
</div>
)}
<Button
className="mt-2"
type="button"
onClick={() => copyCommand(variant.argv)}
>
<FormattedMessage id="footer.copyCommand" />
{`: ${variant.label}`}
</Button>
</div>
))}
<div className="flex flex-wrap items-center gap-3">
<a
className="text-dark-blue underline"
href="https://kci.dev"
rel="noreferrer"
target="_blank"
>
<FormattedMessage id="footer.installKcidev" />
</a>
<a
className="text-dark-blue underline"
href="https://kci.dev/results/"
rel="noreferrer"
target="_blank"
>
<FormattedMessage id="footer.commandDocumentation" />
</a>
</div>
<div aria-live="polite" className="mt-3 min-h-5 text-sm">
{copyStatus === 'copied' && <FormattedMessage id="footer.copied" />}
{copyStatus === 'error' && (
<span className="text-red" role="alert">
<FormattedMessage id="footer.copyError" />
</span>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
};

export const MemoizedKcidevCommandButton = memo(KcidevCommandButton);
Loading
Loading