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
23 changes: 23 additions & 0 deletions apps/e2e/src/generated/test-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3038,6 +3038,22 @@ export const TestIds = {
/** ul */
columnChangeWarnings: 'column-change-warnings',
},
ColumnReferences: {
/** section · Where is named — in */
columnChangeReferences: 'column-change-references',
/** article */
columnRefs: (toLowerCase: string | number, name: string | number) => `column-refs-${toLowerCase}-${name}`,
/** span */
columnRefsAfter: (toLowerCase: string | number, name: string | number, line: string | number) => `column-refs-${toLowerCase}-${name}-after-${line}`,
/** pre */
columnRefsDefinition: (toLowerCase: string | number, name: string | number) => `column-refs-${toLowerCase}-${name}-definition`,
/** ul */
columnRefsLines: (toLowerCase: string | number, name: string | number) => `column-refs-${toLowerCase}-${name}-lines`,
/** li */
columnRefsMore: (toLowerCase: string | number, name: string | number) => `column-refs-${toLowerCase}-${name}-more`,
/** button */
columnRefsToggle: (toLowerCase: string | number, name: string | number) => `column-refs-${toLowerCase}-${name}-toggle`,
},
DatabaseAccessModal: {
/** div */
dbAccessAllForm: 'db-access-all-form',
Expand Down Expand Up @@ -4177,6 +4193,7 @@ export type TestId =
| `column-change-part-${string}-copy`
| 'column-change-password'
| 'column-change-progress'
| 'column-change-references'
| 'column-change-refused'
| `column-change-run-${string}`
| 'column-change-run-all'
Expand All @@ -4188,6 +4205,12 @@ export type TestId =
| 'column-change-table'
| 'column-change-unlock'
| 'column-change-warnings'
| `column-refs-${string}-${string}`
| `column-refs-${string}-${string}-after-${string}`
| `column-refs-${string}-${string}-definition`
| `column-refs-${string}-${string}-lines`
| `column-refs-${string}-${string}-more`
| `column-refs-${string}-${string}-toggle`
| 'command-palette'
| 'command-palette-btn'
| 'command-palette-input'
Expand Down
19 changes: 19 additions & 0 deletions apps/e2e/src/helpers/features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* Features the UI currently offers, for specs that would otherwise fail on a
* feature the app deliberately hides.
*/

/**
* Whether Workflow is on the activity rail — mirrors `NAV_FLAGS.workflow` in
* `apps/web/src/frontend/app/features/featureRegistry.ts`, held back for 0.3.1.
*
* A view is reached only through the rail or the command palette; there is no
* URL for one. With the flag off the Workflow view cannot be opened at all, so
* its specs have nothing to drive. Turn this back to `true` with the flag and
* they run again unchanged.
*/
export const WORKFLOW_ON_RAIL = false;
4 changes: 3 additions & 1 deletion apps/e2e/src/tests/workflow-designer-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { buildDriver, clickWhen, quitDriver, waitFor } from '../helpers/driver.j
import { byTestId } from '../helpers/test-ids.js';
import { AppPage } from '../pages/AppPage.js';
import { WorkflowPage } from '../pages/WorkflowPage.js';
import { WORKFLOW_ON_RAIL } from '../helpers/features.js';

const ENGINE = 'http://127.0.0.1:8081/api';
const ID = 'e2e-designer-carry';
Expand Down Expand Up @@ -80,7 +81,8 @@ afterAll(async () => {
}
});

describe('workflow designer round trip', () => {
// Workflow is off the rail for 0.3.1, so the view cannot be opened; see WORKFLOW_ON_RAIL.
describe.skipIf(!WORKFLOW_ON_RAIL)('workflow designer round trip', () => {
it('keeps every field it does not edit through a load and a save', async (ctx) => {
if (!engineUp) ctx.skip();
await new AppPage(driver).open();
Expand Down
4 changes: 3 additions & 1 deletion apps/e2e/src/tests/workflow-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Page } from 'playwright';
import { buildDriver, quitDriver, BASE_URL } from '../helpers/driver.js';
import { AppPage } from '../pages/AppPage.js';
import { WorkflowPage } from '../pages/WorkflowPage.js';
import { WORKFLOW_ON_RAIL } from '../helpers/features.js';

let driver: Page;

Expand All @@ -19,7 +20,8 @@ afterAll(async () => {
await quitDriver(driver);
});

describe('Workflow workspace', () => {
// Workflow is off the rail for 0.3.1, so the view cannot be opened; see WORKFLOW_ON_RAIL.
describe.skipIf(!WORKFLOW_ON_RAIL)('Workflow workspace', () => {
it('opens from the activity rail and shows owner panes', async () => {
const app = new AppPage(driver);
await app.open();
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/frontend/app/features/featureRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('the rail', () => {
['sqlEditor', 'Editor', 'view-sql-editor-btn'],
['utilities', 'Utils', 'view-utilities-btn'],
['access', 'Access', 'view-access-btn'],
['workflow', 'Workflow', 'view-workflow-btn'],
// Workflow is held back for 0.3.1 (NAV_FLAGS.workflow === false).
['snapshots', 'Snapshots', 'sync-pane-history-btn'],
]);
});
Expand All @@ -86,7 +86,7 @@ describe('the rail', () => {
'sync',
'sqlEditor',
'access',
'workflow',
// no 'workflow': held back for 0.3.1 (NAV_FLAGS.workflow === false).
'snapshots',
]);
});
Expand Down Expand Up @@ -137,6 +137,14 @@ describe('redirects', () => {
});

it('never redirects from views that check access themselves', () => {
for (const view of ['home', 'access', 'workflow', 'settings'] as const) expect(redirectFor(view, can())).toBeNull();
for (const view of ['home', 'access', 'settings'] as const) expect(redirectFor(view, can())).toBeNull();
});

it('sends anyone home from a view a feature flag has taken away', () => {
// Workflow is off the rail and out of the palette for 0.3.1. A session
// whose stored `activeView` still says `workflow` must not be the one way
// back into it.
expect(redirectFor('workflow', can())).toBe('home');
expect(redirectFor('workflow', can('workflow.access', 'workflow.design'))).toBe('home');
});
});
32 changes: 28 additions & 4 deletions apps/web/src/frontend/app/features/featureRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/
import type { ComponentType, ElementType } from 'react';
import { Camera, GitCompareArrows, ShieldCheck, Terminal, Workflow, Wrench } from 'lucide-react';
import { COMMUNITY_NAV, filterNav, type Permission } from '@foxschema/shared';
import { COMMUNITY_NAV, filterNav, type NavFeatureFlag, type Permission } from '@foxschema/shared';
import { HomeView } from '@/app/shell/HomeView';
import { CompareToolbarBelow, CompareToolbarEnd, CompareToolbarStart } from '@/features/compare/toolbar';
import { HistoryToolbar } from '@/features/lokee-weave/toolbar';
Expand Down Expand Up @@ -114,8 +114,32 @@ export const WORKSPACE_VIEWS: Record<ActiveView, WorkspaceViewDefinition> = {
settings: { load: () => import('@/app/settings/SettingsView') },
};

/** Workflow is on in this edition; flags gate nav items, not routes. */
const NAV_FLAGS = { workflow: true } as const;
/**
* Flags gate the nav items a view hangs off, and with them the view.
*
* Workflow is held back for 0.3.1: it is a large feature and is not ready to
* be offered yet, so it is off the rail and out of the command palette. A view
* is reached only through one of those, so with the flag off there is no way
* in — and `redirectFor` sends anyone whose last session left them on the view
* back to Home, so a stored `activeView` is not a way in either. The view's
* code and `/api/workflow` are untouched and the engine still answers, so
* turning this back to `true` is the whole of putting it back.
*/
const NAV_FLAGS: Partial<Record<NavFeatureFlag, boolean>> = { workflow: false };

/**
* The nav ids a flag currently hides. Read once: NAV_FLAGS is a build-time
* constant, and `filterNav`'s rule is the one followed here — a flag nobody
* has turned on hides its item.
*/
const FLAG_HIDDEN_NAV_IDS = new Set(
COMMUNITY_NAV.filter((item) => item.featureFlag && !NAV_FLAGS[item.featureFlag]).map((item) => item.id)
);

/** Whether a flag has taken the view's way in away. */
function hiddenByFlag(def: WorkspaceViewDefinition): boolean {
return !!def.rail && 'navId' in def.rail && FLAG_HIDDEN_NAV_IDS.has(def.rail.navId);
}

export interface RailItem {
view: ActiveView;
Expand All @@ -141,7 +165,7 @@ export function railItems(can: Can): RailItem[] {
/** Where to send someone who is on `view` but may not be: null when they may stay. */
export function redirectFor(view: ActiveView, can: Can): ActiveView | null {
const def = WORKSPACE_VIEWS[view];
if (!def.allowed || def.allowed(can)) return null;
if (!hiddenByFlag(def) && (!def.allowed || def.allowed(can))) return null;
return def.fallback?.(can) ?? 'home';
}

Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/frontend/app/shell/ActivityRail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@ describe('ActivityRail', () => {
expect(screen.getByTestId('view-sql-editor-btn')).toBeTruthy();
expect(screen.getByTestId('view-utilities-btn')).toBeTruthy();
expect(screen.getByTestId('view-access-btn')).toBeTruthy();
expect(screen.getByTestId('view-workflow-btn')).toBeTruthy();
// Workflow is held back for 0.3.1 (NAV_FLAGS.workflow === false).
expect(screen.queryByTestId('view-workflow-btn')).toBeNull();
expect(screen.getByTestId('sync-pane-history-btn')).toBeTruthy();
expect(screen.getByTestId('credentials-btn')).toBeTruthy();
expect(screen.getByTestId('history-btn')).toBeTruthy();
expect(screen.getByTestId('workspace-rail-btn')).toBeTruthy();
expect(screen.getByTestId('profile-menu-trigger')).toBeTruthy();
fireEvent.click(screen.getByTestId('view-workflow-btn'));
expect(useUiStore.getState().activeView).toBe('workflow');
fireEvent.click(screen.getByTestId('view-access-btn'));
expect(useUiStore.getState().activeView).toBe('access');
fireEvent.click(screen.getByTestId('home-open-btn'));
expect(useUiStore.getState().activeView).toBe('home');
fireEvent.click(screen.getByTestId('view-settings-btn'));
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/frontend/app/shell/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ describe('CommandPalette', () => {
const ids = screen
.getAllByTestId(/^command-palette-item-ws-/)
.map((el) => el.getAttribute('data-testid')!.replace('command-palette-item-', ''));
expect(ids).toEqual(['ws-home', 'ws-sync', 'ws-sqlEditor', 'ws-utilities', 'ws-access', 'ws-workflow', 'ws-snapshots', 'ws-settings']);
// No 'ws-workflow': held back for 0.3.1 (NAV_FLAGS.workflow === false).
expect(ids).toEqual(['ws-home', 'ws-sync', 'ws-sqlEditor', 'ws-utilities', 'ws-access', 'ws-snapshots', 'ws-settings']);
expect(screen.getByTestId('command-palette-item-ws-sqlEditor').textContent).toContain('SQL Editor');
expect(screen.getByTestId('command-palette-item-ws-utilities').textContent).toContain('Utilities');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { splitSqlStatements } from '@/shared/lib/sql-splitter';
import { needsSessionPassword } from '@/shared/lib/provider-settings';
import { sectionLabelCls } from '@/shared/components/surfaces';
import { ColumnReferences } from './ColumnReferences';
import type { TableSchema } from '@/shared/lib/types';

interface Props {
Expand Down Expand Up @@ -502,6 +503,8 @@ export const ColumnChangePanel: React.FC<Props> = ({ lockedConnectionId = '' })
</ul>
</section>

<ColumnReferences dependents={plan.impact.dependents} column={columnName} />

{conversionWarning && (
<div
className="space-y-2 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-[12px] text-amber-100"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { ColumnDependent } from '@/shared/lib/column-change';
import { ColumnReferences } from './ColumnReferences';

const view: ColumnDependent = {
name: 'v_open_orders',
objectType: 'VIEW',
definition: "CREATE VIEW v_open_orders AS\nSELECT id, status FROM orders\nWHERE status <> 'done'",
references: [
{
line: 2,
text: 'SELECT id, status FROM orders',
matches: [{ start: 11, end: 17 }],
renamed: 'SELECT id, order_status FROM orders',
},
{
line: 3,
text: "WHERE status <> 'done'",
matches: [{ start: 6, end: 12 }],
renamed: "WHERE order_status <> 'done'",
},
],
};

const proc: ColumnDependent = {
name: 'bump_order_status',
objectType: 'PROCEDURE',
definition: 'UPDATE orders SET status = status',
references: [
{
line: 1,
text: 'UPDATE orders SET status = status',
matches: [
{ start: 18, end: 24 },
{ start: 27, end: 33 },
],
},
],
};

describe('ColumnReferences', () => {
it('renders nothing when no object depends on the column', () => {
const { container } = render(<ColumnReferences dependents={[]} column="status" />);
expect(container.firstChild).toBeNull();
});

it('counts the lines and objects it found', () => {
render(<ColumnReferences dependents={[view, proc]} column="status" />);
const heading = screen.getByTestId('column-change-references').textContent ?? '';
expect(heading).toContain('3 lines');
expect(heading).toContain('2 objects');
});

it('shows each referencing line with its line number', () => {
render(<ColumnReferences dependents={[view]} column="status" />);
const lines = screen.getByTestId('column-refs-view-v_open_orders-lines').textContent ?? '';
expect(lines).toContain('2');
expect(lines).toContain('SELECT id, status FROM orders');
expect(lines).toContain('3');
expect(lines).toContain("WHERE status <> 'done'");
});

it('highlights every occurrence on a line, from the plan’s offsets', () => {
render(<ColumnReferences dependents={[proc]} column="status" />);
const marks = screen
.getByTestId('column-refs-procedure-bump_order_status')
.querySelectorAll('mark');
// `SET status = status` — both, not just the first.
expect([...marks].map((m) => m.textContent)).toEqual(['status', 'status']);
});

it('shows the line as it will have to read when renaming', () => {
render(<ColumnReferences dependents={[view]} column="status" />);
expect(screen.getByTestId('column-refs-view-v_open_orders-after-2').textContent).toBe(
'SELECT id, order_status FROM orders'
);
// And says plainly that Fox will not do it for you.
expect(screen.getByTestId('column-refs-view-v_open_orders').textContent).toContain(
'Fox does not rewrite this view'
);
});

it('offers no after-line and no rewrite note for a type change', () => {
render(<ColumnReferences dependents={[proc]} column="status" />);
const el = screen.getByTestId('column-refs-procedure-bump_order_status');
expect(el.querySelector('[data-testid$="-after-1"]')).toBeNull();
expect(el.textContent).not.toContain('does not rewrite');
});

it('keeps the whole definition behind a toggle', () => {
render(<ColumnReferences dependents={[view]} column="status" />);
expect(screen.queryByTestId('column-refs-view-v_open_orders-definition')).toBeNull();
fireEvent.click(screen.getByTestId('column-refs-view-v_open_orders-toggle'));
expect(screen.getByTestId('column-refs-view-v_open_orders-definition').textContent).toBe(view.definition);
});

it('stops listing lines at fifty and says how many are left', () => {
const many: ColumnDependent = {
name: 'v_wide',
objectType: 'VIEW',
definition: 'x',
references: Array.from({ length: 140 }, (_, i) => ({
line: i + 1,
text: 'status,',
matches: [{ start: 0, end: 6 }],
})),
};
render(<ColumnReferences dependents={[many]} column="status" />);
expect(screen.getByTestId('column-refs-view-v_wide-lines').querySelectorAll('mark')).toHaveLength(50);
expect(screen.getByTestId('column-refs-view-v_wide-more').textContent).toContain('90 more lines');
// The count above still reports every line that names the column.
expect(screen.getByTestId('column-change-references').textContent).toContain('140 lines');
});

it('labels each kind of object', () => {
render(
<ColumnReferences
dependents={[
view,
proc,
{ ...proc, name: 'f', objectType: 'FUNCTION' },
{ ...proc, name: 't', objectType: 'TRIGGER' },
]}
column="status"
/>
);
const text = screen.getByTestId('column-change-references').textContent ?? '';
for (const label of ['View', 'Procedure', 'Function', 'Trigger']) expect(text).toContain(label);
});
});
Loading
Loading