Skip to content

Commit 3dc9a20

Browse files
authored
Respect server AI enabled flag (#320)
* Respect server AI enabled flag * Hide existing AI surfaces when AI is disabled * Fix AI enabled hook mocks in sidebar tests
1 parent ccbb103 commit 3dc9a20

35 files changed

Lines changed: 621 additions & 134 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
import {
4+
AddPageSelectors,
5+
ChatSelectors,
6+
DatabaseGridSelectors,
7+
DropdownSelectors,
8+
EditorSelectors,
9+
FieldType,
10+
GridFieldSelectors,
11+
HeaderSelectors,
12+
PageSelectors,
13+
PropertyMenuSelectors,
14+
SidebarSelectors,
15+
SlashCommandSelectors,
16+
WorkspaceSelectors,
17+
} from '../../support/selectors';
18+
import { signInAndWaitForApp } from '../../support/auth-flow-helpers';
19+
import { createDocumentPageAndNavigate } from '../../support/page-utils';
20+
import { expandSpace } from '../../support/page/flows';
21+
import { mockServerInfo } from '../../support/server-info-helpers';
22+
import { generateRandomEmail, setupPageErrorHandling } from '../../support/test-config';
23+
import { loginAndCreateGrid, addNewProperty, editLastProperty, getLastFieldId } from '../../support/field-type-test-helpers';
24+
import { waitForGridReady } from '../../support/database-ui-helpers';
25+
import {
26+
AIMeetingSelectors,
27+
areTestUtilitiesAvailable,
28+
injectAIMeetingBlock,
29+
} from '../../support/ai-meeting-helpers';
30+
31+
async function openFirstPageAddMenu(page: Page) {
32+
await expandSpace(page);
33+
const firstPage = PageSelectors.items(page).first();
34+
35+
await expect(firstPage).toBeVisible({ timeout: 30000 });
36+
await firstPage.hover({ force: true });
37+
await page.waitForTimeout(500);
38+
39+
const inlineAddButton = firstPage.getByTestId('inline-add-page').first();
40+
41+
await expect(inlineAddButton).toBeVisible({ timeout: 10000 });
42+
await inlineAddButton.click({ force: true });
43+
await expect(DropdownSelectors.content(page)).toBeVisible({ timeout: 10000 });
44+
}
45+
46+
test.describe('Server info ai_enabled flag', () => {
47+
test.beforeEach(async ({ page }) => {
48+
setupPageErrorHandling(page);
49+
await page.setViewportSize({ width: 1280, height: 720 });
50+
});
51+
52+
test('hides AI chat creation and existing AI chat pages when ai_enabled is false', async ({
53+
page,
54+
request,
55+
}) => {
56+
const serverInfo = await mockServerInfo(page, { ai_enabled: true });
57+
const testEmail = generateRandomEmail();
58+
let aiChatViewId = '';
59+
60+
await test.step('Given a signed-in user with AI enabled', async () => {
61+
await signInAndWaitForApp(page, request, testEmail);
62+
await expect(SidebarSelectors.pageHeader(page)).toBeVisible({ timeout: 30000 });
63+
await expect(PageSelectors.names(page).first()).toBeAttached({ timeout: 30000 });
64+
});
65+
66+
await test.step('And an AI chat page exists', async () => {
67+
await openFirstPageAddMenu(page);
68+
await expect(AddPageSelectors.addAIChatButton(page)).toBeVisible({ timeout: 10000 });
69+
await AddPageSelectors.addAIChatButton(page).click();
70+
await expect(ChatSelectors.aiChatContainer(page)).toBeVisible({ timeout: 30000 });
71+
72+
aiChatViewId = new URL(page.url()).pathname.split('/').filter(Boolean).pop() || '';
73+
expect(aiChatViewId).not.toBe('');
74+
});
75+
76+
await test.step('When the server info response disables AI and the app reloads', async () => {
77+
serverInfo.setServerInfo({ ai_enabled: false });
78+
await page.reload({ waitUntil: 'domcontentloaded' });
79+
await expect(SidebarSelectors.pageHeader(page)).toBeVisible({ timeout: 30000 });
80+
});
81+
82+
await test.step('Then the existing AI chat page is hidden', async () => {
83+
await expect(ChatSelectors.aiChatContainer(page)).toHaveCount(0);
84+
await expect(page.getByTestId(`page-${aiChatViewId}`)).toHaveCount(0);
85+
});
86+
87+
await test.step('And the Add Page menu no longer offers AI Chat', async () => {
88+
await openFirstPageAddMenu(page);
89+
await expect(AddPageSelectors.addAIChatButton(page)).toHaveCount(0);
90+
await page.keyboard.press('Escape');
91+
});
92+
93+
await test.step('And AI chat page actions are not available', async () => {
94+
await expect(HeaderSelectors.moreActionsButton(page)).toHaveCount(0);
95+
await expect(page.getByText('Add messages to page')).toHaveCount(0);
96+
});
97+
98+
await test.step('And the workspace menu no longer offers AI Max', async () => {
99+
await WorkspaceSelectors.dropdownTrigger(page).click({ force: true });
100+
await expect(WorkspaceSelectors.dropdownContent(page)).toBeVisible({ timeout: 10000 });
101+
await expect(page.getByTestId('upgrade-ai-max-button')).toHaveCount(0);
102+
});
103+
});
104+
105+
test('hides document AI popup, slash menu, and AI meeting regenerate controls when ai_enabled is false', async ({
106+
page,
107+
request,
108+
}) => {
109+
await mockServerInfo(page, { ai_enabled: false });
110+
const testEmail = generateRandomEmail();
111+
112+
await test.step('Given a signed-in user editing a document with AI disabled', async () => {
113+
await signInAndWaitForApp(page, request, testEmail);
114+
await expect(page).toHaveURL(/\/app/, { timeout: 30000 });
115+
await createDocumentPageAndNavigate(page);
116+
await EditorSelectors.firstEditor(page).click({ force: true });
117+
await page.waitForTimeout(500);
118+
});
119+
120+
await test.step('When text is selected in the document', async () => {
121+
await page.keyboard.type('Text that should not show AI actions');
122+
await page.keyboard.press('Home');
123+
await page.keyboard.press('Shift+End');
124+
await expect(EditorSelectors.selectionToolbar(page)).toBeVisible({ timeout: 10000 });
125+
});
126+
127+
await test.step('Then the selected-text AI popup controls are hidden', async () => {
128+
await expect(EditorSelectors.boldButton(page)).toBeVisible();
129+
await expect(page.getByTestId('toolbar-improve-writing-button')).toHaveCount(0);
130+
await expect(page.getByTestId('toolbar-ask-ai-button')).toHaveCount(0);
131+
});
132+
133+
await test.step('When the slash menu is opened', async () => {
134+
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
135+
await page.keyboard.press('Backspace');
136+
await page.keyboard.type('/');
137+
await expect(SlashCommandSelectors.slashPanel(page)).toBeVisible({ timeout: 10000 });
138+
});
139+
140+
await test.step('Then AI slash commands are hidden', async () => {
141+
await expect(page.getByTestId('slash-menu-text')).toBeVisible();
142+
await expect(page.getByTestId('slash-menu-askAIAnything')).toHaveCount(0);
143+
await expect(page.getByTestId('slash-menu-continueWriting')).toHaveCount(0);
144+
await page.keyboard.press('Escape');
145+
});
146+
147+
await test.step('And AI meeting regenerate controls are hidden', async () => {
148+
const available = await areTestUtilitiesAvailable(page);
149+
150+
if (!available) {
151+
test.skip(true, 'Editor Yjs test utilities are not available in this build');
152+
return;
153+
}
154+
155+
await injectAIMeetingBlock(page, {
156+
title: 'AI disabled meeting',
157+
summary: 'Existing summary content.',
158+
notes: 'Notes for regeneration.',
159+
speakers: [{ id: 'alice', name: 'Alice', timestamp: 0, content: 'Transcript content.' }],
160+
});
161+
await expect(AIMeetingSelectors.block(page)).toBeVisible({ timeout: 15000 });
162+
await expect(AIMeetingSelectors.regenerateButton(page)).toHaveCount(0);
163+
});
164+
});
165+
166+
test('hides database AI field types and existing AI columns when ai_enabled is false', async ({
167+
page,
168+
request,
169+
}) => {
170+
const serverInfo = await mockServerInfo(page, { ai_enabled: true });
171+
const testEmail = generateRandomEmail();
172+
let aiSummaryFieldId = '';
173+
let aiTranslateFieldId = '';
174+
175+
await test.step('Given a grid database with existing AI Summary and AI Translate fields', async () => {
176+
await loginAndCreateGrid(page, request, testEmail);
177+
await DatabaseGridSelectors.firstCell(page).click({ force: true });
178+
await page.keyboard.type('content to summarize');
179+
await page.keyboard.press('Enter');
180+
await addNewProperty(page, FieldType.AISummaries);
181+
aiSummaryFieldId = await getLastFieldId(page);
182+
expect(aiSummaryFieldId).not.toBe('');
183+
184+
const lastFieldIdBeforeAdd = await getLastFieldId(page);
185+
186+
await PropertyMenuSelectors.newPropertyButton(page).first().scrollIntoViewIfNeeded();
187+
await page.evaluate(() => {
188+
const el = document.querySelector('[data-testid="grid-new-property-button"]');
189+
190+
if (el) (el as HTMLElement).click();
191+
});
192+
await expect.poll(async () => getLastFieldId(page), { timeout: 10000 }).not.toBe(lastFieldIdBeforeAdd);
193+
await page.keyboard.press('Escape');
194+
await editLastProperty(page, FieldType.AITranslations);
195+
aiTranslateFieldId = await getLastFieldId(page);
196+
expect(aiTranslateFieldId).not.toBe('');
197+
198+
const aiCell = DatabaseGridSelectors.dataRowCellsForField(page, aiSummaryFieldId).first();
199+
200+
await aiCell.scrollIntoViewIfNeeded();
201+
await aiCell.hover();
202+
await expect(page.locator(`[data-testid^="ai-generate-button-"][data-testid$="-${aiSummaryFieldId}"]`).first())
203+
.toBeVisible({ timeout: 10000 });
204+
});
205+
206+
await test.step('When the server info response disables AI and the grid reloads', async () => {
207+
serverInfo.setServerInfo({ ai_enabled: false });
208+
await page.reload({ waitUntil: 'domcontentloaded' });
209+
await waitForGridReady(page);
210+
});
211+
212+
await test.step('Then AI field types are hidden from the property type menu', async () => {
213+
await PropertyMenuSelectors.newPropertyButton(page).first().scrollIntoViewIfNeeded();
214+
await page.evaluate(() => {
215+
const el = document.querySelector('[data-testid="grid-new-property-button"]');
216+
217+
if (el) (el as HTMLElement).click();
218+
});
219+
await page.waitForTimeout(1200);
220+
221+
const trigger = PropertyMenuSelectors.propertyTypeTrigger(page).first();
222+
223+
await expect(trigger).toBeVisible({ timeout: 10000 });
224+
await trigger.hover();
225+
await page.waitForTimeout(600);
226+
227+
await expect(PropertyMenuSelectors.propertyTypeOption(page, FieldType.AISummaries)).toHaveCount(0);
228+
await expect(PropertyMenuSelectors.propertyTypeOption(page, FieldType.AITranslations)).toHaveCount(0);
229+
await page.keyboard.press('Escape');
230+
});
231+
232+
await test.step('And existing AI columns are hidden from the grid', async () => {
233+
await expect(GridFieldSelectors.fieldHeader(page, aiSummaryFieldId)).toHaveCount(0);
234+
await expect(GridFieldSelectors.fieldHeader(page, aiTranslateFieldId)).toHaveCount(0);
235+
await expect(DatabaseGridSelectors.dataRowCellsForField(page, aiSummaryFieldId)).toHaveCount(0);
236+
await expect(DatabaseGridSelectors.dataRowCellsForField(page, aiTranslateFieldId)).toHaveCount(0);
237+
await expect(page.locator(`[data-testid^="ai-generate-button-"][data-testid$="-${aiSummaryFieldId}"]`)).toHaveCount(0);
238+
await expect(page.locator(`[data-testid^="ai-generate-button-"][data-testid$="-${aiTranslateFieldId}"]`)).toHaveCount(0);
239+
});
240+
});
241+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Page } from '@playwright/test';
2+
3+
export interface MockServerInfo {
4+
enable_page_history: boolean;
5+
ai_enabled: boolean;
6+
}
7+
8+
export interface ServerInfoMockController {
9+
getServerInfo: () => MockServerInfo;
10+
setServerInfo: (updates: Partial<MockServerInfo>) => void;
11+
}
12+
13+
export async function mockServerInfo(
14+
page: Page,
15+
overrides: Partial<MockServerInfo> = {}
16+
): Promise<ServerInfoMockController> {
17+
let serverInfo: MockServerInfo = {
18+
enable_page_history: true,
19+
ai_enabled: true,
20+
...overrides,
21+
};
22+
23+
await page.route('**/api/server-info**', async (route) => {
24+
const url = new URL(route.request().url());
25+
26+
if (url.pathname !== '/api/server-info') {
27+
await route.continue();
28+
return;
29+
}
30+
31+
await route.fulfill({
32+
status: 200,
33+
contentType: 'application/json',
34+
body: JSON.stringify({
35+
code: 0,
36+
data: serverInfo,
37+
message: 'success',
38+
}),
39+
});
40+
});
41+
42+
return {
43+
getServerInfo: () => serverInfo,
44+
setServerInfo: (updates) => {
45+
serverInfo = {
46+
...serverInfo,
47+
...updates,
48+
};
49+
},
50+
};
51+
}

src/application/database-yjs/database.type.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export enum FieldType {
2626
Rollup = 16,
2727
}
2828

29+
export const AI_FIELD_TYPES = [FieldType.AISummaries, FieldType.AITranslations] as const;
30+
31+
export function isAIFieldType(fieldType: FieldType | undefined): boolean {
32+
return fieldType !== undefined && AI_FIELD_TYPES.includes(fieldType as (typeof AI_FIELD_TYPES)[number]);
33+
}
34+
2935
export enum CalculationType {
3036
Average = 0,
3137
Max = 1,

src/application/database-yjs/selector.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export interface Column {
6969
visibility: FieldVisibility;
7070
wrap?: boolean;
7171
isPrimary: boolean;
72+
fieldType?: FieldType;
7273
}
7374

7475
export interface Row {

src/application/services/js-services/http/auth-api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { APIError, APIResponse, executeAPIRequest, getAxios } from './core';
77

88
export interface ServerInfo {
99
enable_page_history: boolean;
10+
ai_enabled?: boolean;
1011
}
1112

1213
export async function signInWithUrl(url: string) {
@@ -109,7 +110,7 @@ export async function getServerInfo(): Promise<ServerInfo> {
109110
);
110111
} catch (error) {
111112
console.warn('Server info API returned error:', (error as APIError)?.message);
112-
return { enable_page_history: true };
113+
return { enable_page_history: true, ai_enabled: true };
113114
}
114115
}
115116

src/components/_shared/outline/OutlineItem.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React, { useCallback, useEffect, useMemo } from 'react';
22

3-
import { UIVariant, View } from '@/application/types';
3+
import { UIVariant, View, ViewLayout } from '@/application/types';
44
import { ReactComponent as PrivateIcon } from '@/assets/icons/lock.svg';
55
import OutlineIcon from '@/components/_shared/outline/OutlineIcon';
66
import OutlineItemContent from '@/components/_shared/outline/OutlineItemContent';
77
import { getOutlineExpands, setOutlineExpands } from '@/components/_shared/outline/utils';
8+
import { useAIEnabled } from '@/components/app/app.hooks';
89

910
function OutlineItem({
1011
view,
@@ -22,6 +23,7 @@ function OutlineItem({
2223
variant?: UIVariant;
2324
}) {
2425
const selected = selectedViewId === view.view_id;
26+
const aiEnabled = useAIEnabled();
2527
const [isExpanded, setIsExpanded] = React.useState(() => {
2628
return getOutlineExpands()[view.view_id] || false;
2729
});
@@ -74,7 +76,10 @@ function OutlineItem({
7476
[variant, width, selected, getIcon, navigateToView, level]
7577
);
7678

77-
const children = useMemo(() => view.children || [], [view.children]);
79+
const children = useMemo(() => {
80+
if (aiEnabled) return view.children || [];
81+
return (view.children || []).filter((item) => item.layout !== ViewLayout.AIChat);
82+
}, [aiEnabled, view.children]);
7883

7984
const renderChildren = useMemo(() => {
8085
return (
@@ -99,6 +104,8 @@ function OutlineItem({
99104
);
100105
}, [children, isExpanded, level, navigateToView, selectedViewId, width, variant]);
101106

107+
if (!aiEnabled && view.layout === ViewLayout.AIChat) return null;
108+
102109
return (
103110
<div className={'flex h-fit w-full flex-col'}>
104111
{renderItem(view)}

0 commit comments

Comments
 (0)