Skip to content

Commit e96c4a6

Browse files
committed
Redesign write tools UI
1 parent ad927a9 commit e96c4a6

10 files changed

Lines changed: 408 additions & 204 deletions

src/App.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ import { RunStatus } from "./components/RunStatus";
1010
import { SessionOverview } from "./components/SessionOverview";
1111
import { UploadsPanel, type ImportedUploadResult } from "./components/UploadsPanel";
1212
import { UserGroupSyncPanel } from "./components/UserGroupSyncPanel";
13+
import { WriteToolsCatalog, type WriteToolId } from "./components/WriteToolsCatalog";
1314
import { validateCredentialsForReport } from "./credentials/credentialRules";
1415
import { DEFAULT_REPORT_RUN_SCOPE } from "./domain/reportScope";
1516
import { reportRegistry } from "./domain/reportRegistry";
1617
import { createInitialSessionState, sessionReducer } from "./domain/sessionStore";
17-
import type { ReportId, RunPeriodRole, RunQueueItem } from "./domain/types";
18+
import type { ReportId, RunPeriodRole, RunQueueItem, SessionCredentials } from "./domain/types";
1819
import type { ReportRunResponseBody } from "./server/reportRunApi";
1920

2021
export function App() {
2122
const [state, dispatch] = useReducer(sessionReducer, undefined, createInitialSessionState);
2223
const [activePanel, setActivePanel] = useState<AppPanel>("report");
24+
const [selectedWriteToolId, setSelectedWriteToolId] = useState<WriteToolId>("user-group-sync");
2325
const [runQueue, setRunQueue] = useState<RunQueueItem[]>([]);
2426
const [reportScope, setReportScope] = useState(DEFAULT_REPORT_RUN_SCOPE);
2527

@@ -28,6 +30,11 @@ export function App() {
2830
setActivePanel("report");
2931
}
3032

33+
function selectWriteTool(toolId: WriteToolId) {
34+
setSelectedWriteToolId(toolId);
35+
setActivePanel("write-tools");
36+
}
37+
3138
async function queueSelectedReportRun(periodRole: RunPeriodRole = "current") {
3239
const report = reportRegistry.find((candidate) => candidate.id === state.selectedReportId)!;
3340
if (!state.credentials) {
@@ -158,15 +165,19 @@ export function App() {
158165
const selectedReportRecords = selectedReportOutput?.records ?? [];
159166
const datasets = Object.values(state.datasets);
160167
const datasetCount = datasets.length;
168+
const sidebar =
169+
activePanel === "write-tools" ? (
170+
<WriteToolsCatalog selectedToolId={selectedWriteToolId} onSelect={selectWriteTool} />
171+
) : (
172+
<ReportCatalog selectedReportId={state.selectedReportId} onSelect={selectReport} />
173+
);
161174

162175
return (
163176
<AppShell
164177
activePanel={activePanel}
165178
onPanelChange={setActivePanel}
166179
summary={{ credentialsSaved: state.credentials !== null, datasetCount }}
167-
sidebar={
168-
<ReportCatalog selectedReportId={state.selectedReportId} onSelect={selectReport} />
169-
}
180+
sidebar={sidebar}
170181
>
171182
<SessionOverview state={state} />
172183
<RunStatus queue={runQueue} />
@@ -184,9 +195,7 @@ export function App() {
184195
onRemoveDataset={(datasetId) => dispatch({ type: "dataset/remove", datasetId })}
185196
/>
186197
)}
187-
{activePanel === "write-tools" && (
188-
<UserGroupSyncPanel credentials={state.credentials} />
189-
)}
198+
{activePanel === "write-tools" && renderWriteToolPanel(selectedWriteToolId, state.credentials)}
190199
{activePanel === "report" && (
191200
<ReportWorkspace
192201
reportId={state.selectedReportId}
@@ -205,6 +214,16 @@ export function App() {
205214
);
206215
}
207216

217+
function renderWriteToolPanel(toolId: WriteToolId, credentials: SessionCredentials | null) {
218+
switch (toolId) {
219+
case "user-group-sync":
220+
return <UserGroupSyncPanel credentials={credentials} />;
221+
}
222+
223+
const unhandledToolId: never = toolId;
224+
return unhandledToolId;
225+
}
226+
208227
function getLiveRunErrorMessage(error: unknown, _reportTitle: string): string {
209228
if (error instanceof Error) {
210229
return error.message;

src/components/AppShell.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ describe("AppShell", () => {
5858

5959
await user.click(screen.getByRole("button", { name: "Write Tools" }));
6060

61+
expect(screen.getByRole("heading", { name: "Write Tools" })).toBeInTheDocument();
62+
expect(screen.getByRole("button", { name: "User Group Sync" })).toHaveAttribute("aria-pressed", "true");
63+
expect(screen.queryByRole("heading", { name: "Report Catalog" })).not.toBeInTheDocument();
64+
expect(screen.queryByRole("button", { name: "Tag Report" })).not.toBeInTheDocument();
6165
expect(screen.getByRole("heading", { name: "User Group Sync" })).toBeInTheDocument();
6266
expect(screen.getByLabelText("Upload user export CSV")).toBeInTheDocument();
6367
});

src/components/AppShell.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ReactNode } from "react";
2+
import { StackOverflowLogo } from "./StackOverflowLogo";
23

34
export type AppPanel = "report" | "credentials" | "uploads" | "datasets" | "write-tools";
45

@@ -32,13 +33,9 @@ export function AppShell({ activePanel, onPanelChange, sidebar, children, summar
3233
<div className="app-shell">
3334
<header className="app-topbar">
3435
<div className="app-brand-block">
35-
<div className="app-brand-mark" aria-hidden="true">
36-
<span />
37-
<span />
38-
<span />
39-
</div>
36+
<StackOverflowLogo className="app-brand-logo" />
4037
<div className="app-title">
41-
<p className="app-kicker">SO4T reports</p>
38+
<p className="app-kicker">Enterprise API tools</p>
4239
<h1 className="app-heading">Stack API Utilities</h1>
4340
</div>
4441
</div>

src/components/CredentialsPanel.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,12 @@ export function CredentialsPanel({ selectedReportId, credentials, onSave }: Cred
5858
return (
5959
<section className="workspace-panel" aria-labelledby="credentials-heading">
6060
<div className="workspace-header">
61-
<p className="fs-caption fc-light mb4">Browser session</p>
62-
<h2 className="fs-headline2 m0" id="credentials-heading">
63-
Session Credentials
64-
</h2>
61+
<div>
62+
<p className="workspace-kicker">Browser session</p>
63+
<h2 className="workspace-heading" id="credentials-heading">
64+
Session Credentials
65+
</h2>
66+
</div>
6567
</div>
6668
<p className="workspace-copy credential-session-copy">
6769
Credentials are kept in memory for this browser session only.

src/components/ReportWorkspace.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { PeriodScope, ReportId, ReportRunScope, RunPeriodRole } from "../do
44
import { DataTable } from "./DataTable";
55
import { ReportDashboard } from "./ReportDashboard";
66
import { ReportScopePanel } from "./ReportScopePanel";
7+
import { StackOverflowLogo } from "./StackOverflowLogo";
78

89
export interface ReportWorkspaceProps {
910
reportId: ReportId;
@@ -43,11 +44,7 @@ export function ReportWorkspace({
4344
{report.title}
4445
</h2>
4546
</div>
46-
<div className="workspace-stack-mark" aria-hidden="true">
47-
<span />
48-
<span />
49-
<span />
50-
</div>
47+
<StackOverflowLogo className="workspace-stack-mark" variant="glyph" />
5148
</div>
5249
<p className="workspace-copy">{report.description}</p>
5350
<div className="workspace-readiness" role="note">
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
interface StackOverflowLogoProps {
2+
className?: string;
3+
variant?: "full" | "glyph";
4+
}
5+
6+
const FULL_LOGO_PATH =
7+
"M18.52 9.69q-1.43 1.07-2.52 2.48l.03.01v.01l-.03-.02a14 14 0 0 0-1.77 2.99l-.02.05a13 13 0 0 0-.93 3.32v.02q-.13.92-.13 1.88c0 .55.07 1.04.13 1.57H0v-3.47h11.5l.07-.43L.5 15.16l.93-3.4 11.21 2.98.16-.34L2.72 8.66 4.5 5.6l10.22 5.83.25-.29L6.6 2.89 9.12.4zM28.9 5.78c3.3 0 5.98 2.15 5.98 5.24h-2.96c-.13-1.67-1.09-2.66-3.11-2.66-2.03 0-3.04.81-3.04 1.95 0 3.42 9.34.33 9.34 6.58 0 2.89-2.28 4.51-6.13 4.51-3.57 0-6.22-2.25-6.33-5.27h3c.14 1.7 1.59 2.71 3.48 2.71 1.78 0 3.07-.68 3.07-1.95 0-3.14-9.37-.28-9.37-6.58 0-2.45 2.33-4.53 6.08-4.53m20.15 2.58c1.75 0 2.96.7 3.75 1.95V8.7h2.76V21H52.8v-1.53c-.74 1.2-1.93 1.93-3.75 1.93-3.44 0-5.55-2.69-5.55-6.5 0-3.83 2.1-6.54 5.55-6.54m.43 2.48c-2.02 0-3.14 1.7-3.14 4.05 0 2.33 1.12 4 3.14 4 2.23 0 3.32-1.72 3.32-4s-1.1-4.05-3.32-4.05m13.78-2.48c3.37 0 5.73 2.48 5.75 5.2h-2.89c-.07-1.35-.88-2.7-2.86-2.7-2.4 0-3.44 1.75-3.44 4.03s1.04 3.98 3.44 3.98c2.06 0 2.87-1.45 2.94-3h2.86c-.05 3.2-2.3 5.53-5.8 5.53-3.8 0-6.27-2.69-6.27-6.5 0-3.83 2.48-6.54 6.27-6.54m28.26-2.58c4.91 0 7.6 3.57 7.6 7.8 0 4.22-2.71 7.82-7.6 7.82s-7.62-3.55-7.62-7.82c0-4.23 2.7-7.8 7.62-7.8m0 2.58c-3.22 0-4.68 2.68-4.68 5.22s1.44 5.24 4.68 5.24 4.6-2.71 4.6-5.24-1.38-5.22-4.6-5.22m25.93 0c3.34 0 6.2 2.7 5.6 7.44h-9c.23 1.8 1.3 3.1 3.4 3.1 1.44 0 2.35-.89 2.73-1.9h2.89a5.5 5.5 0 0 1-5.62 4.4c-3.83 0-6.3-2.69-6.3-6.5 0-3.83 2.47-6.54 6.3-6.54m0 2.48c-1.93 0-2.97 1.11-3.35 2.68h6.23c-.15-1.46-1.06-2.68-2.88-2.68m33.42-2.48c3.8 0 6.28 2.7 6.28 6.53 0 3.82-2.49 6.5-6.28 6.5s-6.3-2.68-6.3-6.5 2.47-6.53 6.3-6.53m0 2.48c-2.3 0-3.47 1.77-3.47 4.05 0 2.33 1.16 4 3.47 4 2.3 0 3.47-1.67 3.47-4s-1.17-4.05-3.47-4.05M40.5 8.71h2.1v2.36h-2.1v7.62h2.46V21h-5.22v-9.93H36V8.7h1.74V6h2.76zm32.69 4.94 3.97-4.94h3.27l-4 4.97 4.4 7.32h-3.08l-2.99-5.3-1.57 1.98V21H70.4V6h2.79zm32.15 4.91 2.79-9.85h2.96L107.24 21h-3.77L99.62 8.71h2.96zm25.88-9.85V11h-3.97v10h-2.76V8.71zm7.98-4.17v2.35h-1.78q-1.18.01-1.19 1.09v.73h2.71V11h-2.7v10h-2.77V11h-1.26V8.71h1.26v-.73c0-2.2 1.27-3.44 3.93-3.44zm3.97 0V21h-2.78V4.54zm18.97 13.42 2.05-9.25h3l2.06 9.27 1.82-9.27h2.94L171.05 21h-3.67l-1.67-7.5-1.67 7.5h-3.67L157.4 8.71h2.96z";
8+
9+
const GLYPH_PATH =
10+
"m22.18 16.32.05.02-.02.01za19 19 0 0 0-2.45 4.14l-.03.07q-.94 2.19-1.29 4.6v.03a18 18 0 0 0-.05 4.85H.01v-4.88h15.94l.1-.6L.69 20.48l1.28-4.7 15.55 4.1.22-.46-13.96-7.96 2.47-4.22 14.16 8.07.34-.4L9.15 3.47 12.65 0l11.78 11.64 1.25 1.23q-1.97 1.5-3.5 3.44";
11+
12+
export function StackOverflowLogo({ className, variant = "full" }: StackOverflowLogoProps) {
13+
const isFullLogo = variant === "full";
14+
15+
return (
16+
<svg
17+
aria-hidden="true"
18+
className={className}
19+
focusable="false"
20+
height={isFullLogo ? 22 : 31}
21+
viewBox={isFullLogo ? "0 0 175 22" : "0 0 26 31"}
22+
width={isFullLogo ? 175 : 26}
23+
>
24+
<path d={isFullLogo ? FULL_LOGO_PATH : GLYPH_PATH} fill="currentColor" />
25+
</svg>
26+
);
27+
}

src/components/UploadsPanel.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@ export function UploadsPanel({ onImported }: UploadsPanelProps) {
4343
return (
4444
<section className="workspace-panel" aria-labelledby="uploads-heading">
4545
<div className="workspace-header">
46-
<p className="fs-caption fc-light mb4">Local files</p>
47-
<h2 className="fs-headline2 m0" id="uploads-heading">
48-
Uploads
49-
</h2>
46+
<div>
47+
<p className="workspace-kicker">Local files</p>
48+
<h2 className="workspace-heading" id="uploads-heading">
49+
Uploads
50+
</h2>
51+
</div>
5052
</div>
5153
<p className="fs-body2 workspace-copy">
5254
Upload existing CSV or JSON outputs from current SO4T scripts. Files are parsed locally in

src/components/UserGroupSyncPanel.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,12 @@ export function UserGroupSyncPanel({ credentials }: UserGroupSyncPanelProps) {
199199
return (
200200
<section className="workspace-panel" aria-labelledby="user-group-sync-heading">
201201
<div className="workspace-header">
202-
<p className="fs-caption fc-light mb4">Enterprise write tool</p>
203-
<h2 className="fs-headline2 m0" id="user-group-sync-heading">
204-
User Group Sync
205-
</h2>
202+
<div>
203+
<p className="workspace-kicker">Enterprise write tool</p>
204+
<h2 className="workspace-heading" id="user-group-sync-heading">
205+
User Group Sync
206+
</h2>
207+
</div>
206208
</div>
207209

208210
<p className="workspace-copy">
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export type WriteToolId = "user-group-sync";
2+
3+
interface WriteToolDefinition {
4+
id: WriteToolId;
5+
title: string;
6+
scope: string;
7+
status: string;
8+
}
9+
10+
interface WriteToolsCatalogProps {
11+
selectedToolId: WriteToolId;
12+
onSelect: (toolId: WriteToolId) => void;
13+
}
14+
15+
export const writeTools: WriteToolDefinition[] = [
16+
{
17+
id: "user-group-sync",
18+
title: "User Group Sync",
19+
scope: "Enterprise",
20+
status: "Preview required",
21+
},
22+
];
23+
24+
export function WriteToolsCatalog({ selectedToolId, onSelect }: WriteToolsCatalogProps) {
25+
return (
26+
<section className="write-tools-catalog" aria-labelledby="write-tools-catalog-heading">
27+
<h2 className="fs-title mb12" id="write-tools-catalog-heading">
28+
Write Tools
29+
</h2>
30+
<div className="write-tool-list">
31+
{writeTools.map((tool) => (
32+
<button
33+
className={`write-tool-list-button${selectedToolId === tool.id ? " is-selected" : ""}`}
34+
type="button"
35+
aria-pressed={selectedToolId === tool.id}
36+
aria-label={tool.title}
37+
onClick={() => onSelect(tool.id)}
38+
key={tool.id}
39+
>
40+
<span className="write-tool-list-title">{tool.title}</span>
41+
<span className="write-tool-list-source">{tool.scope}</span>
42+
<span className="write-tool-list-meta">{tool.status}</span>
43+
</button>
44+
))}
45+
</div>
46+
</section>
47+
);
48+
}

0 commit comments

Comments
 (0)