fix: resolve workbench final review findings

This commit is contained in:
2026-07-19 19:59:38 +08:00
parent 6341a6bd8b
commit ba4ca16375
13 changed files with 357 additions and 44 deletions

View File

@@ -6,25 +6,44 @@
let account = '';
let password = '';
let error = '';
let connectionStatus = 'Not connected. Enter a server address to continue.';
function normalizeServer(value: string) {
function normalizeServer(value: string): { apiBase?: string; error?: string } {
const trimmed = value.trim();
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
if (!trimmed) {
return { error: 'Enter a server address.' };
}
const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
try {
const parsed = new URL(candidate);
if (!parsed.hostname || !['http:', 'https:'].includes(parsed.protocol)) {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
return { apiBase: candidate };
} catch {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
return `http://${trimmed}`;
}
function submitLogin() {
error = '';
const normalized = normalizeServer(server);
if (normalized.error) {
error = normalized.error;
connectionStatus = 'Check the server address before signing in.';
return;
}
if (!account.trim() || !password.trim()) {
error = 'Enter an account and password.';
return;
}
const normalized = normalizeServer(server);
server = normalized;
onLogin({ apiBase: normalized, account: account.trim() });
server = normalized.apiBase ?? server;
connectionStatus = 'Connection settings are ready.';
onLogin({ apiBase: server, account: account.trim() });
}
</script>
@@ -56,6 +75,8 @@
<p class="form-error" aria-live="polite">{error}</p>
{/if}
<p class="connection-status" role="status">{connectionStatus}</p>
<button type="submit">Log in</button>
<p class="login-note">The server address is remembered on this device.</p>
</form>

View File

@@ -11,6 +11,7 @@ describe('ServerLogin', () => {
expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
});
it('normalizes server address and emits login details', async () => {
@@ -46,4 +47,20 @@ describe('ServerLogin', () => {
expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
});
it.each([
['', 'Enter a server address.'],
['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
])('rejects an invalid server value: %s', async (server, error) => {
const onLogin = vi.fn();
render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByText(error)).toBeInTheDocument();
expect(onLogin).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { fireEvent, render, screen, within } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import ChannelContent from './ChannelContent.svelte';
import { getProjectWorkspace } from './mockData';
@@ -7,15 +7,25 @@ import { getProjectWorkspace } from './mockData';
describe('ChannelContent', () => {
const workspace = getProjectWorkspace(1);
it('renders different templates for system and custom channels', () => {
for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) {
it('renders a distinctive heading or control for every channel type', () => {
const expectedContent = {
overview: { role: 'heading', name: 'Overview' },
inbox: { role: 'heading', name: 'Inbox' },
tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
ai_sessions: { role: 'heading', name: 'AI Sessions' },
notes_sources: { role: 'heading', name: 'Notes & Sources' },
cron: { role: 'heading', name: 'Cron Plans' },
custom_link: { role: 'link', name: 'Open external channel' },
} as const;
for (const type of Object.keys(expectedContent) as Array<keyof typeof expectedContent>) {
const channel = workspace.channels.find((item) => item.type === type);
if (!channel) throw new Error(`missing ${type}`);
render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
unmount();
}
expect(screen.getByText('Open external channel')).toBeInTheDocument();
});
it('sends selected task details to inspector', async () => {
@@ -36,4 +46,19 @@ describe('ChannelContent', () => {
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
expect(inspectedTitle).toBe('Confirm homepage information architecture');
});
it('toggles a task completion state accessibly', async () => {
const channel = workspace.channels.find((item) => item.type === 'tasks');
if (!channel) throw new Error('missing task channel');
render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
expect(completion).not.toBeChecked();
await fireEvent.click(completion);
expect(completion).toBeChecked();
expect(within(completion.closest('article') as HTMLElement).getByText('Completed')).toBeInTheDocument();
});
});

View File

@@ -7,6 +7,18 @@
export let tags: string[];
export let recentSessions: AISessionItem[];
export let onSelectChannel: (channelID: string) => void;
const channelIcons: Record<WorkbenchChannel['icon'], string> = {
home: '\u2302',
mail: '\u2709',
list: '\u2630',
sparkles: '\u2726',
file: '\u25A4',
clock: '\u25F7',
link: '\u21AA',
};
$: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder);
</script>
<aside class="channel-sidebar" aria-label="Project channels">
@@ -15,23 +27,24 @@
<p>Current project</p>
<h2>{project.name}</h2>
</div>
<button aria-label="Project settings">&#9881;</button>
<button aria-label="Project settings" disabled>&#9881;</button>
</header>
<section class="tag-row" aria-label="Project tags">
<ul class="tag-row" aria-label="Project tags">
{#each tags as tag}
<button type="button">#{tag}</button>
<li>#{tag}</li>
{/each}
</section>
</ul>
<section class="channel-group">
{#each channels as channel}
{#each sortedChannels as channel}
<button
class:active={channel.id === selectedChannelID}
aria-pressed={channel.id === selectedChannelID}
aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
on:click={() => onSelectChannel(channel.id)}
>
<span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span>
<span>{channel.title}</span>
{#if channel.count !== undefined}
<small>{channel.count}</small>
@@ -42,11 +55,13 @@
<section class="recent-sessions" aria-label="Recent sessions">
<h3>Recent sessions</h3>
{#each recentSessions as session}
<button type="button">
<span>{session.title}</span>
<small>{session.updatedAt}</small>
</button>
{/each}
<ul>
{#each recentSessions as session}
<li>
<span>{session.title}</span>
<small>{session.updatedAt}</small>
</li>
{/each}
</ul>
</section>
</aside>

View File

@@ -7,7 +7,7 @@
</script>
<nav class="project-rail" aria-label="Project list">
<button class="rail-logo" aria-label="Dashboard">SA</button>
<button class="rail-logo" aria-label="Dashboard" disabled>SA</button>
{#each projects as project}
<button
class:active={project.id === selectedProjectID}
@@ -22,5 +22,5 @@
{/if}
</button>
{/each}
<button class="add-project" aria-label="Create project">+</button>
<button class="add-project" aria-label="Create project" disabled>+</button>
</nav>

View File

@@ -16,10 +16,12 @@
$: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0];
function selectProject(projectID: number) {
selectedItem = null;
selectedProjectID = projectID;
}
function selectChannel(channelID: string) {
selectedItem = null;
selectedChannelID = channelID;
}

View File

@@ -51,4 +51,22 @@ describe('ProjectWorkbench', () => {
expect(within(inspector).getByText('Owner')).toBeInTheDocument();
expect(within(inspector).getByText('David')).toBeInTheDocument();
});
it('clears the inspector when the project or channel changes', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
const inspector = screen.getByLabelText('Object inspector');
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
});
});

View File

@@ -9,8 +9,8 @@
<input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" />
</label>
<div class="topbar-actions">
<button aria-label="Back">&#8592;</button>
<button aria-label="Forward">&#8594;</button>
<button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button>
<button aria-label="Back" disabled>&#8592;</button>
<button aria-label="Forward" disabled>&#8594;</button>
<button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button>
</div>
</header>

View File

@@ -2,15 +2,24 @@
import type { WorkbenchChannel } from '../types';
export let channel: WorkbenchChannel;
let copyStatus = '';
function copyUrl() {
void navigator.clipboard?.writeText(channel.url ?? '');
async function copyUrl() {
try {
await navigator.clipboard?.writeText(channel.url ?? '');
copyStatus = 'URL copied.';
} catch {
copyStatus = 'Copy unavailable in this browser.';
}
}
</script>
<section class="channel-page custom-link-channel">
<header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header>
<p>{channel.url}</p>
<a href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
<button type="button" on:click={copyUrl}>Copy URL</button>
<a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
<button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button>
{#if copyStatus}
<p class="copy-feedback" aria-live="polite">{copyStatus}</p>
{/if}
</section>

View File

@@ -4,18 +4,41 @@
export let tasks: WorkTask[];
export let onInspect: (item: InspectorItem) => void;
let taskStates: WorkTask[] = [];
let sourceTasks: WorkTask[] | undefined;
$: if (tasks !== sourceTasks) {
sourceTasks = tasks;
taskStates = tasks.map((task) => ({ ...task }));
}
function inspectTask(task: WorkTask) {
onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] });
}
function toggleTask(taskID: string) {
taskStates = taskStates.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task);
}
</script>
<section class="channel-page tasks-channel">
<header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header>
<div class="task-list">
{#each tasks as task}
{#each taskStates as task}
<article class:completed={task.completed} class="task-card">
<span class="task-check" aria-hidden="true">{task.completed ? 'Done' : 'Open'}</span>
<div><h2>{task.title}</h2><p>{task.summary}</p><small>{task.owner} · {task.due} · #{task.tag}</small></div>
<input
class="task-check"
type="checkbox"
aria-label={`Mark ${task.title} complete`}
checked={task.completed}
on:change={() => toggleTask(task.id)}
/>
<div>
<h2>{task.title}</h2>
<p>{task.summary}</p>
<small>{task.owner} / {task.due} / #{task.tag}</small>
<span class="task-status">{task.completed ? 'Completed' : 'Open'}</span>
</div>
<button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
</article>
{/each}