Skip to content
Merged
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
30 changes: 17 additions & 13 deletions services/platform/app/features/chat/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface ChatInputProps extends Omit<
uploadFiles: (files: File[]) => Promise<void>;
removeAttachment: (fileId: Id<'_storage'>) => void;
clearAttachments: () => FileAttachment[];
fileUploadDisabled?: boolean;
isIndexing?: boolean;
indexingStatuses?: Map<
Id<'_storage'>,
Expand All @@ -65,6 +66,7 @@ export function ChatInput({
uploadFiles,
removeAttachment,
clearAttachments,
fileUploadDisabled = false,
isIndexing = false,
indexingStatuses,
...restProps
Expand Down Expand Up @@ -124,7 +126,7 @@ export function ChatInput({
};

const handlePaste = (e: React.ClipboardEvent) => {
if (inputDisabled) return;
if (inputDisabled || fileUploadDisabled) return;
const items = e.clipboardData?.items;
if (!items) return;

Expand Down Expand Up @@ -165,7 +167,7 @@ export function ChatInput({
className="relative flex h-full min-h-0 flex-1 flex-col"
onFilesSelected={uploadFiles}
clickable={false}
disabled={inputDisabled}
disabled={inputDisabled || fileUploadDisabled}
>
<FileUpload.Overlay className="mx-2 rounded-t-3xl" />
<input
Expand Down Expand Up @@ -351,17 +353,19 @@ export function ChatInput({

<HStack justify="between" align="center" className="pb-3">
<HStack gap={3} align="center">
<Tooltip content={tDialogs('attach')} side="top">
<Button
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={inputDisabled}
aria-label={tDialogs('attach')}
>
<Paperclip className="size-4" />
</Button>
</Tooltip>
{!fileUploadDisabled && (
<Tooltip content={tDialogs('attach')} side="top">
<Button
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={inputDisabled}
aria-label={tDialogs('attach')}
>
<Paperclip className="size-4" />
</Button>
</Tooltip>
)}
<ArenaModeToggle disabled={isLoading} />
{isArenaMode ? (
<ArenaModelSelector organizationId={organizationId} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { api } from '@/convex/_generated/api';
import { useT } from '@/lib/i18n/client';
import { cn } from '@/lib/utils/cn';

import { useMyFeatureFlags } from '../../settings/governance/hooks/queries';
import { useBranchContext } from '../context/branch-context';
import { useChatLayout } from '../context/chat-layout-context';
import { useEditAndBranch, useForkOwnThread } from '../hooks/mutations';
Expand Down Expand Up @@ -144,6 +145,9 @@ export function ChatInterface({
const { isIndexing, statusMap: indexingStatuses } =
useFileIndexingStatus(attachments);

const { data: featureFlags } = useMyFeatureFlags(organizationId);
const fileUploadDisabled = featureFlags?.fileUpload === false;

usePersistedAttachments({
userId: user?.userId,
threadId,
Expand Down Expand Up @@ -695,6 +699,7 @@ export function ChatInterface({
uploadFiles={uploadFiles}
removeAttachment={removeAttachment}
clearAttachments={clearAttachments}
fileUploadDisabled={fileUploadDisabled}
isIndexing={isIndexing}
indexingStatuses={indexingStatuses}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/react';

import { FeatureFlagsEditor } from './feature-flags-editor';

const meta: Meta<typeof FeatureFlagsEditor> = {
title: 'Settings/Governance/FeatureFlagsEditor',
component: FeatureFlagsEditor,
tags: ['autodocs'],
parameters: {
layout: 'padded',
},
args: {
organizationId: 'org_test',
},
};

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

export const Default: Story = {};

export const WithOrganization: Story = {
args: {
organizationId: 'org_demo',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

import { checkAccessibility } from '@/test/utils/a11y';
import { render, screen } from '@/test/utils/render';

vi.mock('@/app/hooks/use-organization-id', () => ({
useOrganizationId: () => 'org_test',
}));

vi.mock('@/app/hooks/use-ability', () => ({
useAbility: () => ({
can: () => true,
cannot: () => false,
}),
}));

vi.mock('@/app/hooks/use-toast', () => ({
useToast: () => ({
toast: vi.fn(),
}),
}));

vi.mock('../hooks/queries', () => ({
useGovernancePolicy: vi.fn().mockReturnValue({
data: null,
isLoading: false,
}),
}));

vi.mock('../hooks/mutations', () => ({
useUpsertGovernancePolicy: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));

vi.mock('@/app/features/settings/organization/hooks/queries', () => ({
useMembers: () => ({
members: [
{ userId: 'user_1', displayName: 'Alice', email: 'alice@test.com' },
{ userId: 'user_2', displayName: 'Bob', email: 'bob@test.com' },
],
}),
}));

vi.mock('@/app/features/settings/teams/hooks/queries', () => ({
useOrgTeams: () => ({
teams: [
{ id: 'team_1', name: 'Engineering' },
{ id: 'team_2', name: 'Marketing' },
],
}),
}));

const { useGovernancePolicy } = await import('../hooks/queries');
const mockedUseGovernancePolicy = vi.mocked(useGovernancePolicy);

const { FeatureFlagsEditor } = await import('./feature-flags-editor');

describe('FeatureFlagsEditor', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUseGovernancePolicy.mockReturnValue({
data: null,
isLoading: false,
} as never);
Comment on lines +63 to +66

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.

🧹 Nitpick | 🔵 Trivial

Prefer typed mock fixtures over as never.

These casts erase the useGovernancePolicy contract, so the tests will still compile if the hook return shape drifts. A small typed factory or satisfies keeps the mocks honest without losing inference. Based on learnings, "In TypeScript test fixtures ... prefer the 'satisfies' operator over 'as' type assertions for config objects and test data."

Also applies to: 78-93, 116-119, 135-150

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx`
around lines 63 - 66, Replace the unsafe "as never" casts on
mockedUseGovernancePolicy fixtures with a typed mock that preserves the hook's
return shape: create a small typed factory or use TypeScript's "satisfies" on
the mock objects so they conform to the actual useGovernancePolicy return type
(e.g., the shape used by mockedUseGovernancePolicy in
feature-flags-editor.test.tsx) and update all instances (lines matching the
other fixtures at 78-93, 116-119, 135-150) to use that factory or "satisfies"
expression so the tests will error if the hook contract drifts.

});

it('renders empty state when no rules exist', () => {
render(<FeatureFlagsEditor organizationId="org_1" />);

expect(
screen.getByText(/no feature control rules configured/i),
).toBeInTheDocument();
});

it('renders rules table when rules exist', () => {
mockedUseGovernancePolicy.mockReturnValue({
data: {
config: {
enabled: true,
rules: [
{
scope: 'default',
webSearch: true,
codeExecution: false,
fileUpload: true,
},
],
},
},
isLoading: false,
} as never);

render(<FeatureFlagsEditor organizationId="org_1" />);

expect(screen.getByText('default')).toBeInTheDocument();
expect(screen.getByText('\u2718')).toBeInTheDocument();
});

it('renders add rule button', () => {
render(<FeatureFlagsEditor organizationId="org_1" />);

expect(
screen.getByRole('button', { name: /add rule/i }),
).toBeInTheDocument();
});

it('renders enabled toggle', () => {
render(<FeatureFlagsEditor organizationId="org_1" />);

expect(screen.getByRole('switch')).toBeInTheDocument();
});

it('renders loading skeleton while loading', () => {
mockedUseGovernancePolicy.mockReturnValue({
data: null,
isLoading: true,
} as never);

const { container } = render(<FeatureFlagsEditor organizationId="org_1" />);

expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument();
});

describe('accessibility', () => {
it('passes axe audit with empty state', async () => {
const { container } = render(
<FeatureFlagsEditor organizationId="org_1" />,
);
await checkAccessibility(container);
});

it('passes axe audit with rules table', async () => {
mockedUseGovernancePolicy.mockReturnValue({
data: {
config: {
enabled: true,
rules: [
{
scope: 'default',
webSearch: true,
codeExecution: true,
fileUpload: true,
},
],
},
},
isLoading: false,
} as never);

const { container } = render(
<FeatureFlagsEditor organizationId="org_1" />,
);
await checkAccessibility(container);
});
});
});
Loading
Loading