mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:10:29 +00:00
feat(components): add new FilePicker component [R8S-1050] (#2754)
This commit is contained in:
@@ -7,8 +7,12 @@ import { Icon } from './Icon';
|
||||
export function CollapseExpandButton({
|
||||
onClick,
|
||||
isExpanded,
|
||||
quarterRotation = false,
|
||||
...props
|
||||
}: { isExpanded: boolean } & ComponentProps<'button'>) {
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
quarterRotation?: boolean;
|
||||
} & ComponentProps<'button'>) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -31,8 +35,11 @@ export function CollapseExpandButton({
|
||||
icon={ChevronDown}
|
||||
size="md"
|
||||
className={clsx('transition ease-in-out', {
|
||||
'rotate-180': isExpanded,
|
||||
'rotate-0': !isExpanded,
|
||||
'rotate-180': isExpanded && !quarterRotation,
|
||||
'-rotate-90': !isExpanded && quarterRotation,
|
||||
'rotate-0':
|
||||
(!isExpanded && !quarterRotation) ||
|
||||
(isExpanded && quarterRotation),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Meta } from '@storybook/react-webpack5';
|
||||
import { useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
|
||||
export default {
|
||||
component: CommandPalette,
|
||||
title: 'Components/CommandPalette',
|
||||
} as Meta;
|
||||
|
||||
const samplePaths = [
|
||||
'src/index.ts',
|
||||
'src/app.ts',
|
||||
'src/components/Button.tsx',
|
||||
'src/components/Input.tsx',
|
||||
'src/utils/string.ts',
|
||||
'src/utils/number.ts',
|
||||
'docker-compose.yml',
|
||||
'docker-compose.dev.yml',
|
||||
'.github/workflows/ci.yml',
|
||||
'package.json',
|
||||
'tsconfig.json',
|
||||
'README.md',
|
||||
];
|
||||
|
||||
export function Default() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<div className="relative w-[480px] rounded-md border border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<div className="flex items-center px-3 py-2">
|
||||
<CommandPalette
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onCompletion={() => {}}
|
||||
allFilePaths={samplePaths}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownOnType() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<div className="relative w-[480px] rounded-md border border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<div className="flex items-center px-3 py-2">
|
||||
<CommandPalette
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onCompletion={() => {}}
|
||||
allFilePaths={samplePaths}
|
||||
openDropdownOnFocus={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithPortal() {
|
||||
const [value, setValue] = useState('');
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const portalTargetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[480px] overflow-hidden rounded-lg border border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7"
|
||||
onFocusCapture={() => setIsDropdownVisible(true)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDropdownVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<CommandPalette
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onCompletion={() => {}}
|
||||
allFilePaths={samplePaths}
|
||||
renderDropdown={(paths) => {
|
||||
if (!portalTargetRef.current) return null;
|
||||
return createPortal(
|
||||
paths.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
|
||||
No matching files
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{paths.map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
className="flex h-8 items-center px-3 hover:bg-gray-3 th-highcontrast:hover:bg-gray-iron-10 th-dark:hover:bg-gray-iron-10"
|
||||
>
|
||||
<span className="truncate font-mono text-[13px] text-gray-11 th-highcontrast:text-white th-dark:text-white">
|
||||
/{path}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
portalTargetRef.current
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={portalTargetRef}
|
||||
className="min-h-[120px] overflow-y-auto bg-white th-highcontrast:bg-black th-dark:bg-gray-iron-10"
|
||||
>
|
||||
{!isDropdownVisible && (
|
||||
<p className="px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
|
||||
Portal target — type above to filter files; matches render here
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
|
||||
const allFilePaths = ['foo.yml', 'bar.yml', 'baz.ts'];
|
||||
|
||||
function renderSearchBar({
|
||||
value = '',
|
||||
onChange = vi.fn(),
|
||||
addExpression = vi.fn(),
|
||||
filePaths = allFilePaths,
|
||||
renderDropdown,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
addExpression?: (pattern: string) => void;
|
||||
filePaths?: string[];
|
||||
renderDropdown?: (paths: string[]) => ReactNode;
|
||||
} = {}) {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CommandPalette
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onCompletion={addExpression}
|
||||
allFilePaths={filePaths}
|
||||
renderDropdown={renderDropdown}
|
||||
/>
|
||||
);
|
||||
return { user, onChange, addExpression };
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
it('shows a live match count when the filter is active', async () => {
|
||||
// *.yml matches foo.yml and bar.yml → 2 matches
|
||||
const { user } = renderSearchBar({ value: '*.yml' });
|
||||
const inputElement = screen.getByTestId('command-palette-search-input');
|
||||
await user.click(inputElement);
|
||||
expect(screen.getByText(/2 matches/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows singular "match" for exactly one result', async () => {
|
||||
// *.ts matches only baz.ts → 1 match
|
||||
const { user } = renderSearchBar({ value: '*.ts' });
|
||||
const inputElement = screen.getByTestId('command-palette-search-input');
|
||||
await user.click(inputElement);
|
||||
expect(screen.getByText(/1 match/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('calls addExpression with the glob pattern unchanged when Enter is pressed', async () => {
|
||||
const addExpression = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
const { user } = renderSearchBar({
|
||||
value: '*.yml',
|
||||
addExpression,
|
||||
onChange,
|
||||
});
|
||||
const inputElement = screen.getByTestId('command-palette-search-input');
|
||||
await user.click(inputElement);
|
||||
await user.keyboard('{Enter}');
|
||||
expect(addExpression).toHaveBeenCalledWith('*.yml');
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('wraps plain text in wildcards when added as an expression', async () => {
|
||||
const addExpression = vi.fn();
|
||||
const { user } = renderSearchBar({ value: 'foo', addExpression });
|
||||
await user.click(screen.getByRole('textbox'));
|
||||
await user.keyboard('{Enter}');
|
||||
expect(addExpression).toHaveBeenCalledWith('*foo*');
|
||||
});
|
||||
|
||||
it('does not call addExpression when Enter is pressed with an empty filter', async () => {
|
||||
const addExpression = vi.fn();
|
||||
const { user } = renderSearchBar({ value: '', addExpression });
|
||||
await user.click(screen.getByRole('textbox'));
|
||||
await user.keyboard('{Enter}');
|
||||
expect(addExpression).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the filter when Escape is pressed', async () => {
|
||||
const onChange = vi.fn();
|
||||
const { user } = renderSearchBar({ value: 'some text', onChange });
|
||||
await user.click(screen.getByRole('textbox'));
|
||||
await user.keyboard('{Escape}');
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('clears the filter when the clear (X) button is clicked', async () => {
|
||||
const onChange = vi.fn();
|
||||
const { user } = renderSearchBar({ value: 'test', onChange });
|
||||
await user.click(screen.getByRole('button', { name: /clear search/i }));
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('calls addExpression when the Add expression button is clicked', async () => {
|
||||
const addExpression = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
const { user } = renderSearchBar({
|
||||
value: 'src/**',
|
||||
addExpression,
|
||||
onChange,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /add expression/i }));
|
||||
expect(addExpression).toHaveBeenCalledWith('src/**');
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('calls onChange when typing in the input', async () => {
|
||||
const onChange = vi.fn();
|
||||
const { user } = renderSearchBar({ onChange });
|
||||
await user.type(screen.getByRole('textbox'), 'a');
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows matching file paths in the dropdown when the filter is active', async () => {
|
||||
const { user } = renderSearchBar({ value: '*.yml' });
|
||||
const inputElement = screen.getByTestId('command-palette-search-input');
|
||||
await user.click(inputElement);
|
||||
expect(screen.getByText('/foo.yml')).toBeVisible();
|
||||
expect(screen.getByText('/bar.yml')).toBeVisible();
|
||||
expect(screen.queryByText('/baz.ts')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the dropdown when the filter is empty', () => {
|
||||
renderSearchBar({ value: '' });
|
||||
expect(screen.queryByText('/foo.yml')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls renderDropdown with matching paths instead of rendering the default dropdown', async () => {
|
||||
const renderDropdown = vi.fn(() => null);
|
||||
const { user } = renderSearchBar({ value: '*.yml', renderDropdown });
|
||||
const inputElement = screen.getByTestId('command-palette-search-input');
|
||||
await user.click(inputElement);
|
||||
expect(renderDropdown).toHaveBeenCalledWith(['foo.yml', 'bar.yml']);
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not call renderDropdown when the filter is empty', () => {
|
||||
const renderDropdown = vi.fn(() => null);
|
||||
renderSearchBar({ value: '', renderDropdown });
|
||||
expect(renderDropdown).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { type ReactNode, forwardRef, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { pluralize } from '@/react/common/string-utils';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { filterToPattern } from '../form-components/FilePicker/utils';
|
||||
|
||||
import { globToRegex } from './utils';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onCompletion: (pattern: string) => void;
|
||||
allFilePaths: string[];
|
||||
renderDropdown?: (paths: string[]) => ReactNode;
|
||||
openDropdownOnFocus?: boolean;
|
||||
}
|
||||
|
||||
export const CommandPalette = forwardRef<HTMLInputElement, Props>(
|
||||
function CommandPalette(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onCompletion,
|
||||
allFilePaths,
|
||||
renderDropdown,
|
||||
openDropdownOnFocus = true,
|
||||
},
|
||||
forwardedRef
|
||||
) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
function setRefs(node: HTMLInputElement | null) {
|
||||
inputRef.current = node;
|
||||
if (typeof forwardedRef === 'function') {
|
||||
forwardedRef(node);
|
||||
} else if (forwardedRef) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
forwardedRef.current = node;
|
||||
}
|
||||
}
|
||||
|
||||
const filterTrimmed = value.trim();
|
||||
const filterActive = filterTrimmed.length > 0;
|
||||
|
||||
const matchingPaths = useMemo(() => {
|
||||
if (openDropdownOnFocus && !isFocused) return [];
|
||||
if (!filterActive) return allFilePaths;
|
||||
const re = globToRegex(filterToPattern(filterTrimmed));
|
||||
return allFilePaths.filter((p) => re.test(p));
|
||||
}, [
|
||||
openDropdownOnFocus,
|
||||
isFocused,
|
||||
filterActive,
|
||||
allFilePaths,
|
||||
filterTrimmed,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex min-h-[30px] flex-1 items-center gap-2">
|
||||
<Search
|
||||
size={14}
|
||||
className="shrink-0 text-gray-7 th-highcontrast:text-white th-dark:text-gray-5"
|
||||
/>
|
||||
<input
|
||||
ref={setRefs}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filterActive) handleAddExpression();
|
||||
if (e.key === 'Escape') {
|
||||
setIsFocused(false);
|
||||
inputRef.current?.blur();
|
||||
onChange('');
|
||||
}
|
||||
}}
|
||||
placeholder="Filter or add expression, e.g. *.yml, src/**/*.ts"
|
||||
className="flex-1 border-0 bg-transparent text-sm text-gray-11 outline-none placeholder:text-gray-6 th-highcontrast:text-white th-dark:text-white"
|
||||
data-cy="command-palette-search-input"
|
||||
/>
|
||||
{filterActive && (
|
||||
<>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => handleAddExpression()}
|
||||
className="shrink-0 border-0 bg-transparent text-gray-7 hover:text-gray-11 th-highcontrast:text-white th-dark:text-gray-5"
|
||||
aria-label="Add expression"
|
||||
data-cy="command-palette-search-add-expression"
|
||||
>
|
||||
Add expression
|
||||
</Button>
|
||||
<span className="shrink-0 text-xs text-gray-7 th-highcontrast:text-gray-5 th-dark:text-gray-5">
|
||||
{matchingPaths.length}{' '}
|
||||
{pluralize(matchingPaths.length, 'match', 'matches')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="shrink-0 border-0 bg-transparent text-gray-7 hover:text-gray-11 th-highcontrast:text-white th-dark:text-gray-5"
|
||||
aria-label="Clear search"
|
||||
data-cy="command-palette-search-clear-expression"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
{(isFocused || !openDropdownOnFocus) &&
|
||||
(openDropdownOnFocus || filterActive) &&
|
||||
(renderDropdown
|
||||
? renderDropdown(matchingPaths)
|
||||
: matchingPaths.length > 0 && (
|
||||
<ul
|
||||
style={{ top: 'calc(100% + 8px)' }}
|
||||
className="absolute -left-2 -right-2 z-10 max-h-48 overflow-y-auto rounded-b-md border border-solid border-gray-4 bg-white px-0 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-gray-7 th-dark:bg-gray-iron-10"
|
||||
>
|
||||
{matchingPaths.map((path) => (
|
||||
<li
|
||||
key={path}
|
||||
className="list-none px-3 py-1.5 font-mono text-xs text-gray-11 th-highcontrast:text-white th-dark:text-white"
|
||||
>
|
||||
/{path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleAddExpression() {
|
||||
onCompletion(filterToPattern(filterTrimmed));
|
||||
setIsFocused(false);
|
||||
inputRef.current?.blur();
|
||||
onChange('');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { globToRegex } from './utils';
|
||||
|
||||
describe('globToRegex', () => {
|
||||
it('matches a file by extension using *', () => {
|
||||
const re = globToRegex('*.yml');
|
||||
expect(re.test('foo.yml')).toBe(true);
|
||||
expect(re.test('foo.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('wraps pattern in "*" when not explicitly used', () => {
|
||||
const re = globToRegex('yml');
|
||||
expect(re.test('foo.yml')).toBe(true);
|
||||
expect(re.test('foo.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches nested paths with **', () => {
|
||||
const re = globToRegex('src/**');
|
||||
expect(re.test('src/foo.ts')).toBe(true);
|
||||
expect(re.test('src/nested/bar.ts')).toBe(true);
|
||||
expect(re.test('lib/foo.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const re = globToRegex('*.YML');
|
||||
expect(re.test('foo.yml')).toBe(true);
|
||||
expect(re.test('FOO.YML')).toBe(true);
|
||||
});
|
||||
|
||||
it('escapes dots so they match only literal dots', () => {
|
||||
const re = globToRegex('file.ts');
|
||||
expect(re.test('file.ts')).toBe(true);
|
||||
expect(re.test('fileXts')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches exact paths with no wildcards', () => {
|
||||
const re = globToRegex('src/foo.ts');
|
||||
expect(re.test('src/foo.ts')).toBe(true);
|
||||
expect(re.test('src/bar.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches any path with a bare wildcard', () => {
|
||||
const re = globToRegex('*');
|
||||
expect(re.test('anything')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches exactly one character with ?', () => {
|
||||
const re = globToRegex('te?t');
|
||||
expect(re.test('testy')).toBe(true);
|
||||
expect(re.test('text')).toBe(true);
|
||||
expect(re.test('tesst')).toBe(false);
|
||||
expect(re.test('tet')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Converts a glob pattern to a case-insensitive RegExp.
|
||||
*
|
||||
* Patterns without `*` are wrapped in `*...*` for substring matching
|
||||
* `*` matches any sequence of characters
|
||||
* `?` matches exactly one character
|
||||
*/
|
||||
export function globToRegex(pattern: string): RegExp {
|
||||
let glob = pattern;
|
||||
if (!pattern.includes('*')) {
|
||||
glob = `*${pattern}*`;
|
||||
}
|
||||
const escaped = glob
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*+/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
return new RegExp(`^${escaped}$`, 'i');
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Button } from '@@/buttons/Button';
|
||||
import { FileNode } from '@@/form-components/FilePicker/types';
|
||||
|
||||
import { FilePicker } from './FilePicker';
|
||||
import { FilePickerSkeleton } from './FilePickerSkeleton';
|
||||
|
||||
const meta: Meta<typeof FilePicker> = {
|
||||
title: 'Components/Forms/File Picker',
|
||||
component: FilePicker,
|
||||
tags: [],
|
||||
decorators: [
|
||||
(Story, { viewMode }) => (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg, #f9fafb)',
|
||||
padding: 'var(--space-16)',
|
||||
minHeight: viewMode === 'story' ? '100vh' : undefined,
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'`FilePicker` lets users select individual files or build wildcard expressions (e.g. `*.yml`, `src/**/*.ts`) from a repository tree. Selected items surface as **File** or **Expression** chips. A blue dot marks every tree row that is selected or matched by an expression. Typing in the filter bar shows a live preview of matching files and offers a one-click "Add expression (N matches)" button.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FilePicker>;
|
||||
|
||||
// ─── Demo data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const REPO_NODES: FileNode[] = [
|
||||
{
|
||||
name: '.github',
|
||||
children: [
|
||||
{
|
||||
name: 'workflows',
|
||||
children: [
|
||||
{ name: 'ci.yml' },
|
||||
{ name: 'deploy.yml' },
|
||||
{ name: 'release.yml' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'config',
|
||||
children: [
|
||||
{ name: 'app.yml' },
|
||||
{ name: 'database.yml' },
|
||||
{ name: 'cache.yml' },
|
||||
{ name: 'logging.yml' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'deploy',
|
||||
children: [
|
||||
{ name: 'k8s.yaml' },
|
||||
{ name: 'helm-values.yaml' },
|
||||
{ name: 'terraform.tfvars' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'docs',
|
||||
children: [
|
||||
{ name: 'README.md' },
|
||||
{ name: 'architecture.md' },
|
||||
{ name: 'CONTRIBUTING.md' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'scripts',
|
||||
children: [
|
||||
{ name: 'build.sh' },
|
||||
{ name: 'deploy.sh' },
|
||||
{ name: 'seed.sh' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'src',
|
||||
children: [
|
||||
{
|
||||
name: 'api',
|
||||
children: [
|
||||
{ name: 'routes.ts' },
|
||||
{ name: 'handlers.ts' },
|
||||
{ name: 'middleware.ts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'components',
|
||||
children: [
|
||||
{ name: 'Button.tsx' },
|
||||
{ name: 'Modal.tsx' },
|
||||
{ name: 'Table.tsx' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'utils',
|
||||
children: [{ name: 'helpers.ts' }, { name: 'types.ts' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tests',
|
||||
children: [
|
||||
{ name: 'setup.ts' },
|
||||
{ name: 'integration.ts' },
|
||||
{ name: 'e2e.ts' },
|
||||
],
|
||||
},
|
||||
{ name: '.gitignore' },
|
||||
{ name: 'docker-compose.yml' },
|
||||
{ name: 'docker-compose.prod.yml' },
|
||||
{ name: 'docker-compose.staging.yml' },
|
||||
{ name: 'Dockerfile' },
|
||||
{ name: 'package.json' },
|
||||
{ name: 'tsconfig.json' },
|
||||
];
|
||||
|
||||
// ─── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Skeleton: StoryObj<{ loading: boolean }> = {
|
||||
render: () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [loading, setLoading] = useState(true);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [value, setValue] = useState<string[]>([]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setLoading(false), 2000);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{loading ? (
|
||||
<FilePickerSkeleton />
|
||||
) : (
|
||||
<FilePicker
|
||||
files={REPO_NODES}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
exampleExpressions={[
|
||||
'*.yml',
|
||||
'*.yaml',
|
||||
'*.ts',
|
||||
'src/**',
|
||||
'deploy/**',
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows a skeleton placeholder while file tree data is loading, then transitions to the real `FilePicker` after 2 seconds. Reload the story to replay the transition.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Default ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
files: REPO_NODES,
|
||||
exampleExpressions: ['*.yml', '*.yaml', '*.ts', 'src/**', 'deploy/**'],
|
||||
},
|
||||
render: (args) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [value, setValue] = useState<string[]>([]);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<FilePicker {...args} value={value} onChange={setValue} />
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
data-cy="file-picker-add-paths"
|
||||
onClick={() => alert(`Files:\n${value.join('\n') || '(none)'}`)}
|
||||
>
|
||||
Add Paths
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Default state — header shows total file count, suggested pattern chips for one-click expression adding, and an empty tree with all folders collapsed. Click a pattern chip (e.g. `*.yml`) to add it as an expression immediately.',
|
||||
},
|
||||
source: {
|
||||
code: `<FilePicker
|
||||
files={REPO_NODES}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
exampleExpressions={['*.yml', '*.yaml', '*.ts', 'src/**', 'deploy/**']}
|
||||
/>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { FilePicker } from './FilePicker';
|
||||
import { FileNode } from './types';
|
||||
|
||||
const flatFiles: FileNode[] = [
|
||||
{ name: 'foo.yml' },
|
||||
{ name: 'bar.ts' },
|
||||
{ name: 'baz.json' },
|
||||
];
|
||||
|
||||
function renderFilePicker({
|
||||
files = flatFiles,
|
||||
initialValue = [],
|
||||
examplePatterns = ['*.yml', 'src/**'],
|
||||
}: {
|
||||
files?: FileNode[];
|
||||
initialValue?: string[];
|
||||
examplePatterns?: string[];
|
||||
} = {}) {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
function Wrapper() {
|
||||
const [value, setValue] = useState<string[]>(initialValue);
|
||||
return (
|
||||
<FilePicker
|
||||
files={files}
|
||||
value={value}
|
||||
onChange={(paths) => {
|
||||
setValue(paths);
|
||||
onChange(paths);
|
||||
}}
|
||||
exampleExpressions={examplePatterns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Wrapper />);
|
||||
return { user, onChange };
|
||||
}
|
||||
|
||||
describe('FilePicker', () => {
|
||||
it('calls onChange with slash-prefixed sorted paths when files are selected', async () => {
|
||||
const { user, onChange } = renderFilePicker({
|
||||
files: [{ name: 'foo.yml' }, { name: 'bar.ts' }],
|
||||
});
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await user.click(checkboxes[0]); // foo.yml
|
||||
await user.click(checkboxes[1]); // bar.ts
|
||||
expect(onChange).toHaveBeenLastCalledWith(['/bar.ts', '/foo.yml']);
|
||||
});
|
||||
|
||||
it('sets the search filter when an example pattern button is clicked', async () => {
|
||||
const { user } = renderFilePicker({ examplePatterns: ['*.yml'] });
|
||||
await user.click(screen.getByRole('button', { name: '*.yml' }));
|
||||
expect(screen.getByRole('textbox')).toHaveValue('*.yml');
|
||||
});
|
||||
|
||||
it('counts expression-matched files as selected', async () => {
|
||||
const { user } = renderFilePicker({
|
||||
files: [{ name: 'foo.yml' }, { name: 'bar.ts' }],
|
||||
});
|
||||
const filterInput = screen.getByRole('textbox');
|
||||
await user.click(filterInput);
|
||||
await user.type(filterInput, '*.yml');
|
||||
await user.keyboard('{Enter}');
|
||||
// *.yml matches foo.yml only → 1 selected
|
||||
expect(screen.getByText('1 selected')).toBeVisible();
|
||||
});
|
||||
|
||||
it('selecting a directory checkbox selects all its descendant files', async () => {
|
||||
const { user, onChange } = renderFilePicker({
|
||||
files: [{ name: 'src', children: [{ name: 'a.ts' }, { name: 'b.ts' }] }],
|
||||
});
|
||||
// Only the directory checkbox is visible (not expanded)
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
expect(onChange).toHaveBeenCalledWith(['/src/a.ts', '/src/b.ts']);
|
||||
});
|
||||
|
||||
it('deselects all directory files when a fully-checked directory is clicked again', async () => {
|
||||
const { user, onChange } = renderFilePicker({
|
||||
files: [{ name: 'src', children: [{ name: 'a.ts' }] }],
|
||||
});
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
await user.click(checkbox); // select all
|
||||
await user.click(checkbox); // deselect all
|
||||
expect(onChange).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('clears all selected files when Clear is clicked', async () => {
|
||||
const { user } = renderFilePicker({
|
||||
files: [{ name: 'foo.yml' }],
|
||||
});
|
||||
const filterInput = screen.getByRole('textbox');
|
||||
await user.click(filterInput);
|
||||
await user.type(filterInput, '*.yml');
|
||||
await user.keyboard('{Enter}');
|
||||
expect(screen.getByText('1 selected')).toBeVisible();
|
||||
await user.click(screen.getByRole('button', { name: /clear/i }));
|
||||
expect(screen.getByText('0 selected')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { pluralize } from '@/react/common/string-utils';
|
||||
|
||||
import { Button } from '@@/buttons/Button';
|
||||
import { Directory, File, FileNode } from '@@/form-components/FilePicker/types';
|
||||
import { CommandPalette } from '@@/CommandPalette/CommandPalette';
|
||||
import { globToRegex } from '@@/CommandPalette/utils';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { isDirectory, getAllFilePaths, getFolderState } from './utils';
|
||||
import { TreeNode } from './TreeNode';
|
||||
import { SelectedPanel } from './SelectedPanel';
|
||||
|
||||
interface Props {
|
||||
files: FileNode[];
|
||||
/* array of file paths that have been selected */
|
||||
value: string[];
|
||||
onChange: (filePaths: string[]) => void;
|
||||
exampleExpressions?: string[];
|
||||
}
|
||||
|
||||
export function FilePicker({
|
||||
files,
|
||||
value,
|
||||
onChange,
|
||||
exampleExpressions = ['*.yml', 'src/**', '**/dist/*'],
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set([]));
|
||||
const selected = useMemo(
|
||||
() => new Set(value.map((p) => (p.startsWith('/') ? p.slice(1) : p))),
|
||||
[value]
|
||||
);
|
||||
const [filter, setFilter] = useState('');
|
||||
const treeAreaRef = useRef<HTMLDivElement>(null);
|
||||
const commandPaletteRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const allFilePaths = useMemo(
|
||||
() => files.flatMap((file) => getAllFilePaths(file, file.name)),
|
||||
[files]
|
||||
);
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<div className="flex flex-col border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<p>
|
||||
Browse files, select individually, or use wildcard expressions.
|
||||
Contains {allFilePaths.length}{' '}
|
||||
{pluralize(allFilePaths.length, 'file')}. Try patterns like:
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
{exampleExpressions.map((pattern, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={`example-${index}`}
|
||||
color="none"
|
||||
className="!th-highcontrast:bg-gray-9 !th-highcontrast:text-gray-3 !th-dark:bg-gray-9 !th-dark:text-gray-3 !bg-gray-3 !p-2 font-mono !text-gray-9"
|
||||
onClick={() => {
|
||||
setFilter(pattern);
|
||||
commandPaletteRef.current?.focus();
|
||||
}}
|
||||
data-cy={`example-${index}-button`}
|
||||
>
|
||||
{pattern}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<CommandPalette
|
||||
ref={commandPaletteRef}
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
onCompletion={addExpression}
|
||||
allFilePaths={allFilePaths}
|
||||
openDropdownOnFocus={false}
|
||||
renderDropdown={renderDropdown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={treeAreaRef} className="flex h-80 overflow-hidden">
|
||||
{allFilePaths.length === 0 && (
|
||||
<p className="w-full px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
|
||||
No files available
|
||||
</p>
|
||||
)}
|
||||
{!filter.trim() && (
|
||||
<>
|
||||
<div className="min-w-0 flex-1 overflow-y-auto">
|
||||
{files.map((file) => (
|
||||
<TreeNode
|
||||
key={file.name}
|
||||
item={file}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
selected={selected}
|
||||
nodePath={file.name}
|
||||
onExpandDirectory={toggleDirectory}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex w-80 shrink-0 flex-col border-b-0 border-l border-r-0 border-t-0 border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<SelectedPanel selected={selected} removeFile={removeFile} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-b-0 border-l-0 border-r-0 border-t border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<span className="text-xs text-gray-6 th-highcontrast:text-white th-dark:text-gray-5">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
<Button
|
||||
color="default"
|
||||
size="small"
|
||||
onClick={() => onChange([])}
|
||||
data-cy="file-picker-clear"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
|
||||
function renderDropdown(paths: string[]): ReactNode {
|
||||
if (!treeAreaRef.current) return null;
|
||||
return createPortal(
|
||||
paths.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
|
||||
No matching files
|
||||
</p>
|
||||
) : (
|
||||
<ul className="px-1">
|
||||
{paths.map((path) => (
|
||||
<li key={path} className="flex h-8 list-none items-center px-3">
|
||||
<span className="truncate font-mono text-[13px] text-gray-11 th-highcontrast:text-white th-dark:text-white">
|
||||
/{path}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
treeAreaRef.current
|
||||
);
|
||||
}
|
||||
|
||||
function handleChange(next: Set<string>) {
|
||||
onChange(
|
||||
Array.from(next)
|
||||
.map((f) => `/${f}`)
|
||||
.sort()
|
||||
);
|
||||
}
|
||||
|
||||
function toggleDirectory(path: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelect(path: string, item: Directory | File) {
|
||||
if (!isDirectory(item)) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
handleChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePaths = getAllFilePaths(item, path);
|
||||
const state = getFolderState(item, selected, path);
|
||||
const next = new Set(selected);
|
||||
if (state === 'checked') {
|
||||
filePaths.forEach((p) => next.delete(p));
|
||||
} else {
|
||||
filePaths.forEach((p) => next.add(p));
|
||||
}
|
||||
handleChange(next);
|
||||
}
|
||||
|
||||
function removeFile(path: string) {
|
||||
const next = new Set(selected);
|
||||
next.delete(path);
|
||||
handleChange(next);
|
||||
}
|
||||
|
||||
function addExpression(pattern: string) {
|
||||
const re = globToRegex(pattern);
|
||||
const matchedPaths = allFilePaths.filter((p) => re.test(p));
|
||||
handleChange(new Set([...selected, ...matchedPaths]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export function FilePickerSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse overflow-hidden rounded-lg border border-solid border-gray-4 bg-white th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-gray-7 th-dark:bg-gray-iron-10">
|
||||
<div className="flex flex-col gap-2 border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<div className="h-3 w-3/4 rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||
<div className="flex gap-1">
|
||||
<div className="h-7 w-14 rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||
<div className="h-7 w-12 rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||
<div className="h-7 w-16 rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<div className="h-8 w-full rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-80 overflow-hidden">
|
||||
<div className="min-w-0 flex-1 overflow-y-auto px-1 py-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-8 items-center gap-2 px-3"
|
||||
style={{ paddingLeft: `${(i % 3) * 12 + 12}px` }}
|
||||
>
|
||||
<div className="h-4 w-4 shrink-0 rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||
<div
|
||||
className="h-3 rounded bg-gray-3 th-dark:bg-gray-8"
|
||||
style={{ width: `${40 + ((i * 37) % 80)}px` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex w-80 shrink-0 flex-col border-b-0 border-l border-r-0 border-t-0 border-solid border-gray-4 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<div className="border-b border-solid border-gray-4 px-3 py-2 th-dark:border-gray-7">
|
||||
<div className="h-3 w-20 rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="h-3 w-32 rounded bg-gray-2 th-dark:bg-gray-9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-b-0 border-l-0 border-r-0 border-t border-solid border-gray-4 px-3 py-2 th-highcontrast:border-white th-dark:border-gray-7">
|
||||
<div className="h-3 w-16 rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||
<div className="h-7 w-16 rounded bg-gray-3 th-dark:bg-gray-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { SelectedPanel } from './SelectedPanel';
|
||||
|
||||
function renderSelectedPanel({
|
||||
selected = new Set<string>(),
|
||||
removeFile = vi.fn(),
|
||||
}: {
|
||||
selected?: Set<string>;
|
||||
removeFile?: (path: string) => void;
|
||||
} = {}) {
|
||||
const user = userEvent.setup();
|
||||
render(<SelectedPanel selected={selected} removeFile={removeFile} />);
|
||||
return { user, removeFile };
|
||||
}
|
||||
|
||||
describe('SelectedPanel', () => {
|
||||
it('calls removeFile with the correct path when the remove button is clicked', async () => {
|
||||
const removeFile = vi.fn();
|
||||
const { user } = renderSelectedPanel({
|
||||
selected: new Set(['src/foo.ts']),
|
||||
removeFile,
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /remove file \/src\/foo\.ts/i })
|
||||
);
|
||||
expect(removeFile).toHaveBeenCalledWith('src/foo.ts');
|
||||
});
|
||||
|
||||
it('shows "No files selected" when nothing is selected', () => {
|
||||
renderSelectedPanel();
|
||||
expect(screen.getByText(/no files selected/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders all selected file paths', () => {
|
||||
renderSelectedPanel({
|
||||
selected: new Set(['lib/index.ts', 'src/foo.ts']),
|
||||
});
|
||||
expect(screen.getByTitle('/lib/index.ts')).toBeVisible();
|
||||
expect(screen.getByTitle('/src/foo.ts')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { pluralize } from '@/react/common/string-utils';
|
||||
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
interface Props {
|
||||
selected: Set<string>;
|
||||
removeFile: (path: string) => void;
|
||||
}
|
||||
|
||||
export function SelectedPanel({ selected, removeFile }: Props) {
|
||||
const selectedPaths = Array.from(selected).sort();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 px-3 py-2.5 text-sm font-semibold text-gray-11 th-highcontrast:border-white th-highcontrast:text-white th-dark:border-gray-7 th-dark:text-white">
|
||||
{selected.size} Selected {pluralize(selected.size, 'File')}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedPaths.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-xs text-gray-6 th-highcontrast:text-gray-5 th-dark:text-gray-5">
|
||||
No files selected
|
||||
</p>
|
||||
) : (
|
||||
selectedPaths.map((path) => (
|
||||
<FileRow key={path} path={path} onRemove={() => removeFile(path)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileRowProps {
|
||||
path: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function FileRow({ path, onRemove }: FileRowProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-l-0 border-r-0 border-t-0 border-solid border-gray-4 bg-blue-1 px-3 py-2.5 th-highcontrast:border-white th-highcontrast:bg-gray-iron-10 th-dark:border-gray-7 th-dark:bg-blue-11">
|
||||
<Badge type="infoSecondary" shape="rect" size="sm" className="uppercase">
|
||||
File
|
||||
</Badge>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-xs text-gray-11 th-highcontrast:text-white th-dark:text-white"
|
||||
title={`/${path}`}
|
||||
>
|
||||
/{path}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="flex shrink-0 items-center justify-center rounded border-0 bg-transparent p-0.5 text-gray-6 hover:text-gray-9 th-highcontrast:text-white th-dark:text-gray-6"
|
||||
aria-label={`Remove file /${path}`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useRef } from 'react';
|
||||
import { slugify } from 'markdown-to-jsx';
|
||||
|
||||
import { CollapseExpandButton } from '@@/CollapseExpandButton';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
||||
import { isDirectory, getFolderState, isDirectoryWithChildren } from './utils';
|
||||
import { TreeNodeIcon } from './TreeNodeIcon';
|
||||
import { FileNode } from './types';
|
||||
|
||||
const INDENT_PX = 16;
|
||||
const DOUBLE_CLICK_DELAY_MS = 250;
|
||||
|
||||
interface Props {
|
||||
item: FileNode;
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
selected: Set<string>;
|
||||
nodePath: string;
|
||||
onExpandDirectory: (path: string) => void;
|
||||
onToggleSelect: (path: string, item: FileNode) => void;
|
||||
}
|
||||
|
||||
export function TreeNode({
|
||||
item,
|
||||
depth,
|
||||
expanded,
|
||||
selected,
|
||||
nodePath,
|
||||
onExpandDirectory,
|
||||
onToggleSelect,
|
||||
}: Props) {
|
||||
const isDir = isDirectory(item);
|
||||
const hasSubDir = isDirectoryWithChildren(item);
|
||||
const isOpen = isDir && expanded.has(nodePath);
|
||||
const folderState = isDir ? getFolderState(item, selected, nodePath) : null;
|
||||
const isChecked = isDir ? folderState === 'checked' : selected.has(nodePath);
|
||||
const isIndeterminate = folderState === 'indeterminate';
|
||||
|
||||
const checkboxId = slugify(`${nodePath}/${item.name}`);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function handleRowClick() {
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = null;
|
||||
if (isDir) {
|
||||
onExpandDirectory(nodePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickTimerRef.current = null;
|
||||
onToggleSelect(nodePath, item);
|
||||
}, DOUBLE_CLICK_DELAY_MS);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="relative flex h-8 cursor-pointer select-none items-center gap-1 pr-2"
|
||||
style={{ paddingLeft: depth * INDENT_PX + 12 }}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isDir) onExpandDirectory(nodePath);
|
||||
if (e.key === 'Space') onToggleSelect(nodePath, item);
|
||||
}}
|
||||
>
|
||||
{isDir ? (
|
||||
<CollapseExpandButton
|
||||
quarterRotation
|
||||
isExpanded={isOpen}
|
||||
onClick={() => onExpandDirectory(nodePath)}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-[22px] shrink-0" />
|
||||
)}
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
data-cy={checkboxId}
|
||||
checked={isChecked}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={() => onToggleSelect(nodePath, item)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-labelledby={`${checkboxId}-label`}
|
||||
/>
|
||||
<TreeNodeIcon node={item} />
|
||||
<span
|
||||
id={`${checkboxId}-label`}
|
||||
className="flex-1 truncate text-sm text-gray-11 th-highcontrast:text-white th-dark:text-white"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isOpen &&
|
||||
hasSubDir &&
|
||||
item.children.map((child, index) => (
|
||||
<TreeNode
|
||||
key={`${nodePath}/${child.name}/${index}`}
|
||||
item={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
selected={selected}
|
||||
nodePath={`${nodePath}/${child.name}`}
|
||||
onExpandDirectory={onExpandDirectory}
|
||||
onToggleSelect={onToggleSelect}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { File as FileIcon, Folder } from 'lucide-react';
|
||||
|
||||
import { isDirectory } from '@@/form-components/FilePicker/utils';
|
||||
|
||||
import { Directory, File } from './types';
|
||||
|
||||
interface Props {
|
||||
node: Directory | File;
|
||||
}
|
||||
|
||||
export function TreeNodeIcon({ node }: Props) {
|
||||
return isDirectory(node) ? (
|
||||
<Folder
|
||||
size={14}
|
||||
className="shrink-0 text-blue-7 th-highcontrast:text-white th-dark:text-blue-5"
|
||||
/>
|
||||
) : (
|
||||
<FileIcon
|
||||
size={14}
|
||||
className="shrink-0 text-gray-7 th-highcontrast:text-white th-dark:text-gray-5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type FileNode = Directory | File;
|
||||
|
||||
export interface File {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Directory {
|
||||
name: string;
|
||||
children?: FileNode[];
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Directory, File, FileNode } from './types';
|
||||
import {
|
||||
isDirectory,
|
||||
getAllFilePaths,
|
||||
getFolderState,
|
||||
filterToPattern,
|
||||
} from './utils';
|
||||
|
||||
function file(name: string): File {
|
||||
return { name };
|
||||
}
|
||||
|
||||
function dir(name: string, children: FileNode[] = []): Directory {
|
||||
return { name, children };
|
||||
}
|
||||
|
||||
describe('isDirectory', () => {
|
||||
it('returns true when the item has a children property', () => {
|
||||
expect(isDirectory(dir('src'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when the children property is present but undefined', () => {
|
||||
const dirWithUndefinedChildren: Directory = {
|
||||
name: 'empty',
|
||||
children: undefined,
|
||||
};
|
||||
expect(isDirectory(dirWithUndefinedChildren)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a file without children', () => {
|
||||
expect(isDirectory(file('foo.ts'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllFilePaths', () => {
|
||||
it('returns the node path for a single file', () => {
|
||||
expect(getAllFilePaths(file('foo.ts'), 'foo.ts')).toEqual(['foo.ts']);
|
||||
});
|
||||
|
||||
it('returns all child file paths for a flat directory', () => {
|
||||
const node = dir('src', [file('a.ts'), file('b.ts')]);
|
||||
expect(getAllFilePaths(node, 'src')).toEqual(['src/a.ts', 'src/b.ts']);
|
||||
});
|
||||
|
||||
it('returns an empty array for an empty directory', () => {
|
||||
expect(getAllFilePaths(dir('empty'), 'empty')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns deeply nested file paths', () => {
|
||||
const node = dir('root', [dir('sub', [file('deep.ts')])]);
|
||||
expect(getAllFilePaths(node, 'root')).toEqual(['root/sub/deep.ts']);
|
||||
});
|
||||
|
||||
it('returns all paths across multiple nested levels', () => {
|
||||
const node = dir('a', [
|
||||
file('top.ts'),
|
||||
dir('b', [file('nested.ts'), dir('c', [file('deep.ts')])]),
|
||||
]);
|
||||
expect(getAllFilePaths(node, 'a').sort()).toEqual([
|
||||
'a/b/c/deep.ts',
|
||||
'a/b/nested.ts',
|
||||
'a/top.ts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFolderState', () => {
|
||||
const tree = dir('src', [file('a.ts'), file('b.ts')]);
|
||||
|
||||
it('returns unchecked when no files are selected', () => {
|
||||
expect(getFolderState(tree, new Set(), 'src')).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('returns checked when all files in the directory are selected', () => {
|
||||
expect(getFolderState(tree, new Set(['src/a.ts', 'src/b.ts']), 'src')).toBe(
|
||||
'checked'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns indeterminate when some but not all files are selected', () => {
|
||||
expect(getFolderState(tree, new Set(['src/a.ts']), 'src')).toBe(
|
||||
'indeterminate'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns unchecked for an empty directory', () => {
|
||||
expect(getFolderState(dir('empty'), new Set(), 'empty')).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('ignores selected paths outside the directory', () => {
|
||||
expect(getFolderState(tree, new Set(['other/file.ts']), 'src')).toBe(
|
||||
'unchecked'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterToPattern', () => {
|
||||
it('wraps plain text with wildcards', () => {
|
||||
expect(filterToPattern('foo')).toBe('*foo*');
|
||||
});
|
||||
|
||||
it('leaves a glob pattern with * unchanged', () => {
|
||||
expect(filterToPattern('*.yml')).toBe('*.yml');
|
||||
});
|
||||
|
||||
it('leaves a glob pattern with ? unchanged', () => {
|
||||
expect(filterToPattern('file?.ts')).toBe('file?.ts');
|
||||
});
|
||||
|
||||
it('trims whitespace before wrapping plain text', () => {
|
||||
expect(filterToPattern(' foo ')).toBe('*foo*');
|
||||
});
|
||||
|
||||
it('trims whitespace before preserving a glob', () => {
|
||||
expect(filterToPattern(' *.yml ')).toBe('*.yml');
|
||||
});
|
||||
|
||||
it('handles empty string after trimming', () => {
|
||||
expect(filterToPattern(' ')).toBe('**');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Directory, FileNode } from './types';
|
||||
|
||||
export function isDirectory(item: FileNode): item is Directory {
|
||||
return 'children' in item;
|
||||
}
|
||||
|
||||
export function isDirectoryWithChildren(
|
||||
item: FileNode
|
||||
): item is Required<Directory> {
|
||||
return isDirectory(item) && 'children' in item;
|
||||
}
|
||||
|
||||
export function getAllFilePaths(item: FileNode, nodePath: string): string[] {
|
||||
if (!isDirectory(item)) {
|
||||
return [nodePath];
|
||||
}
|
||||
return (item.children ?? []).flatMap((child) =>
|
||||
getAllFilePaths(child, `${nodePath}/${child.name}`)
|
||||
);
|
||||
}
|
||||
|
||||
export function getFolderState(
|
||||
item: Directory,
|
||||
selected: Set<string>,
|
||||
nodePath: string
|
||||
): 'checked' | 'indeterminate' | 'unchecked' {
|
||||
const filePaths = getAllFilePaths(item, nodePath);
|
||||
if (filePaths.length === 0) return 'unchecked';
|
||||
const selectedCount = filePaths.filter((p) => selected.has(p)).length;
|
||||
if (selectedCount === filePaths.length) return 'checked';
|
||||
if (selectedCount > 0) return 'indeterminate';
|
||||
return 'unchecked';
|
||||
}
|
||||
|
||||
export function filterToPattern(text: string): string {
|
||||
const t = text.trim();
|
||||
if (/[*?]/.test(t)) return t;
|
||||
return `*${t}*`;
|
||||
}
|
||||
Reference in New Issue
Block a user