Compare commits

...

3 Commits

Author SHA1 Message Date
Steven Masley 7126aca51a add badge 2025-11-13 15:31:49 -06:00
Steven Masley 658631abd8 Add mocked examples 2025-11-13 15:25:00 -06:00
Steven Masley f9c15b84f0 feat(site): add template warnings page with dismiss/restore
Add a new Warnings tab to the template page that displays diagnostics
for the active template version. The page provides tips and suggestions
on how to better format Terraform code.

Features:
- Table-based list view with organized columns
- Dismiss/restore functionality - minimizes warnings and moves to bottom
- Smart sorting - active warnings at top, dismissed at bottom
- Three severity levels with color-coded pills and icons
- Visual feedback with opacity changes
- Empty state when no warnings exist
- Backend integration ready (placeholder hook included)

The dismissed state is tracked both client-side (temporary) and supports
an optional 'dismissed' attribute from the backend for persistence.
2025-11-12 14:00:24 -06:00
4 changed files with 467 additions and 0 deletions
@@ -2,6 +2,7 @@ import { API } from "api/api";
import { checkAuthorization } from "api/queries/authCheck";
import type { AuthorizationRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Badge } from "components/Badge/Badge";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
@@ -50,10 +51,20 @@ const fetchTemplate = async (organizationId: string, templateName: string) => {
}),
]);
// TODO: Replace with actual API call once backend is implemented
// const warnings = await API.getTemplateVersionWarnings(activeVersion.id);
const warnings: Array<{ dismissed?: boolean }> = [
{ dismissed: false },
{ dismissed: false },
{ dismissed: false },
];
const unreadWarningsCount = warnings.filter(w => !w.dismissed).length;
return {
template,
activeVersion,
permissions,
unreadWarningsCount,
};
};
@@ -157,6 +168,16 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
Insights
</TabLink>
)}
<TabLink to="warnings" value="warnings">
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
Warnings
{data.unreadWarningsCount > 0 && (
<Badge variant="destructive" size="xs">
{data.unreadWarningsCount}
</Badge>
)}
</span>
</TabLink>
</TabsList>
</Margins>
</Tabs>
@@ -0,0 +1,113 @@
# Template Warnings Page
This page displays warnings and errors for the active template version, providing tips and suggestions on how to better format Terraform code.
## Features
- **Table-based list view** with organized columns for easy scanning
- **Minimize/Restore warnings** - click the X button to minimize warnings (moves to bottom) or ↻ to restore
- **Smart sorting** - Active warnings at top, dismissed warnings at bottom
- **Three severity levels**: Error, Warning, and Info with color-coded pills and icons
- **Empty state** when no warnings exist
- **Optional error codes** displayed in monospace badges
- **Visual feedback** - Dismissed warnings show reduced opacity and simplified view
- **Clean, professional design** following Coder's design system patterns
## Backend Integration
The page currently uses a placeholder `useTemplateWarnings` hook that returns an empty array. You'll need to:
1. **Create an API endpoint** in the Go backend that returns warnings for a template version
2. **Add the API method** to `site/src/api/api.ts` (e.g., `getTemplateVersionWarnings`)
3. **Replace the hook** with an actual React Query hook that fetches data
### Expected Warning Structure
```typescript
interface Warning {
id: string; // Unique identifier for dismissal tracking
severity: "error" | "warning" | "info";
title: string;
message: string;
code?: string; // Optional error code (e.g., "TF001")
dismissed?: boolean; // Optional - marks warning as dismissed from backend
}
```
**Note:** The component tracks dismissed state both from:
1. Backend-provided `dismissed` attribute (persisted across sessions)
2. Client-side state (temporary, resets on page reload)
### Example Implementation
```typescript
// In site/src/api/api.ts
export const getTemplateVersionWarnings = async (
versionId: string,
): Promise<Warning[]> => {
const response = await axios.get(
`/api/v2/templateversions/${versionId}/warnings`,
);
return response.data;
};
// In TemplateWarningsPage.tsx - replace the placeholder hook
const useTemplateWarnings = (templateVersionId: string): Warning[] => {
const { data } = useQuery({
queryKey: ['templateWarnings', templateVersionId],
queryFn: () => API.getTemplateVersionWarnings(templateVersionId),
});
return data ?? [];
};
```
## UI Components
The page uses Coder's standard table components:
- `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableCell`, `TableHead` from `components/Table/Table`
- `Pill` component for severity badges
- `EmptyState` component for empty states
- Icons from `lucide-react` (CircleAlertIcon, TriangleAlertIcon, InfoIcon, XIcon, RotateCcwIcon)
- Theme-aware colors and consistent typography
### Table Columns
1. **Icon** (40px) - Visual severity indicator (dimmed when dismissed)
2. **Severity** (120px) - Colored pill with severity level (hidden when dismissed)
3. **Issue** (flexible) - Warning title and detailed message (simplified when dismissed)
4. **Code** (100px) - Optional error code in monospace (hidden when dismissed)
5. **Actions** (40px) - Toggle button (X to dismiss, ↻ to restore)
### Dismissed State
When a warning is dismissed:
- **Opacity reduced** to 50%
- **Moved to bottom** of the list
- **Severity pill hidden**
- **Message hidden** (only title shown in italic)
- **Code hidden**
- **Icon dimmed**
- **Button changes** to restore icon (↻)
## Navigation
The Warnings tab is visible to all users and appears after Insights in the template tabs:
- Docs
- Source Code (if user can update template)
- Resources
- Versions
- Embed
- Insights (if user has permissions)
- **Warnings** (always visible)
## Future Enhancements
Potential improvements for the backend implementation:
1. **Persist dismissed state**: Save dismissed warnings to backend so they persist across sessions
2. **File locations**: Add file name and line number to each warning
3. **Quick fixes**: Provide automated fix suggestions
4. **Filtering**: Allow filtering by severity level (show/hide dismissed)
5. **Categorization**: Group warnings by type (security, best practices, etc.)
6. **Historical data**: Show warning trends across versions
7. **Bulk actions**: Dismiss all warnings of a certain type
@@ -0,0 +1,328 @@
import { useTheme } from "@emotion/react";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Pill } from "components/Pill/Pill";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import {
CircleAlertIcon,
InfoIcon,
RotateCcwIcon,
TriangleAlertIcon,
XIcon,
} from "lucide-react";
import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout";
import { type FC, useMemo, useState } from "react";
import { pageTitle } from "utils/page";
interface Warning {
id: string;
severity: "error" | "warning" | "info";
title: string;
message: string;
code?: string;
dismissed?: boolean;
}
// Placeholder for backend data - the user will implement the actual API call
const useTemplateWarnings = (templateVersionId: string): Warning[] => {
// TODO: Replace this with actual API call once backend is implemented
// Example: const { data } = useQuery(['templateWarnings', templateVersionId], () => API.getTemplateVersionWarnings(templateVersionId));
// Mock data for UI demonstration
return [
{
id: "1",
severity: "error",
title: "Terraform module 'coder-server' is not pinned to a version",
message:
"The 'coder-server' module should be pinned to a specific version to ensure stability and reproducibility. " +
"Please update the module source to include a version constraint.",
code: "ERR_UNPINNED_MODULE_VERSION",
},
{
id: "2",
severity: "error",
title: "Missing terraform lock file",
message:
"The terraform lock file (terraform.lock.hcl) is missing. " +
"Please generate the lock file to ensure consistent provider versions.",
code: "ERR_MISSING_LOCK_FILE",
},
{
id: "3",
severity: "warning",
title: "Unused coder parameter 'region' detected",
message: "The parameter 'region' is defined but not used in the template. ",
code: "WRN_UNUSED_PARAMETER",
},
];
};
const TemplateWarningsPage: FC = () => {
const { template, activeVersion } = useTemplateLayoutContext();
const theme = useTheme();
const warnings = useTemplateWarnings(activeVersion.id);
const [dismissedWarnings, setDismissedWarnings] = useState<Set<string>>(
new Set(),
);
// Sort warnings: non-dismissed first, dismissed at the end
const sortedWarnings = useMemo(() => {
return [...warnings].sort((a, b) => {
const aDismissed = dismissedWarnings.has(a.id) || a.dismissed;
const bDismissed = dismissedWarnings.has(b.id) || b.dismissed;
if (aDismissed === bDismissed) return 0;
return aDismissed ? 1 : -1;
});
}, [warnings, dismissedWarnings]);
const handleToggleDismiss = (warningId: string) => {
setDismissedWarnings((prev) => {
const next = new Set(prev);
if (next.has(warningId)) {
next.delete(warningId);
} else {
next.add(warningId);
}
return next;
});
};
const isWarningDismissed = (warning: Warning) => {
return dismissedWarnings.has(warning.id) || warning.dismissed;
};
const getSeverityIcon = (severity: string, dismissed: boolean) => {
const iconSize = 16;
const opacity = dismissed ? 0.4 : 1;
switch (severity) {
case "error":
return (
<CircleAlertIcon
size={iconSize}
css={{ color: theme.palette.error.main, opacity }}
/>
);
case "warning":
return (
<TriangleAlertIcon
size={iconSize}
css={{ color: theme.palette.warning.main, opacity }}
/>
);
case "info":
default:
return (
<InfoIcon
size={iconSize}
css={{ color: theme.palette.info.main, opacity }}
/>
);
}
};
const getSeverityPillType = (
severity: string,
): "error" | "warning" | "info" => {
switch (severity) {
case "error":
return "error";
case "warning":
return "warning";
case "info":
default:
return "info";
}
};
return (
<>
<title>{pageTitle(template.name, "Warnings")}</title>
<div
css={{
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
overflow: "hidden",
}}
>
{/* Header */}
<div
css={{
padding: "16px 24px",
borderBottom: `1px solid ${theme.palette.divider}`,
}}
>
<div
css={{
color: theme.palette.text.secondary,
fontWeight: 600,
fontSize: 14,
}}
>
Warnings & Errors
</div>
<div
css={{
color: theme.palette.text.secondary,
fontSize: 12,
marginTop: 4,
}}
>
Showing diagnostics for the active template version:{" "}
<span css={{ fontWeight: 600 }}>{activeVersion.name}</span>
</div>
</div>
{/* Content */}
{sortedWarnings.length === 0 ? (
<div css={{ padding: "64px 24px" }}>
<EmptyState
message="No warnings or errors"
description="This template version looks good!"
/>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead css={{ width: 40 }} />
<TableHead css={{ width: 120 }}>Severity</TableHead>
<TableHead>Issue</TableHead>
<TableHead css={{ width: 100 }}>Code</TableHead>
<TableHead css={{ width: 40 }} />
</TableRow>
</TableHeader>
<TableBody>
{sortedWarnings.map((warning) => {
const isDismissed = isWarningDismissed(warning);
return (
<TableRow
key={warning.id}
css={{
opacity: isDismissed ? 0.5 : 1,
transition: "opacity 0.2s ease",
}}
>
<TableCell>
<div css={{ display: "flex", alignItems: "center" }}>
{getSeverityIcon(
warning.severity,
isDismissed || false,
)}
</div>
</TableCell>
<TableCell>
{!isDismissed && (
<Pill type={getSeverityPillType(warning.severity)}>
{warning.severity.charAt(0).toUpperCase() +
warning.severity.slice(1)}
</Pill>
)}
</TableCell>
<TableCell>
{isDismissed ? (
<div
css={{
fontSize: 13,
color: theme.palette.text.secondary,
fontStyle: "italic",
}}
>
{warning.title}
</div>
) : (
<div>
<div
css={{
fontWeight: 600,
fontSize: 14,
color: theme.palette.text.primary,
marginBottom: 4,
}}
>
{warning.title}
</div>
<div
css={{
fontSize: 13,
color: theme.palette.text.secondary,
lineHeight: 1.5,
}}
>
{warning.message}
</div>
</div>
)}
</TableCell>
<TableCell>
{!isDismissed && warning.code && (
<span
css={{
fontSize: 12,
color: theme.palette.text.secondary,
fontFamily: "monospace",
backgroundColor: theme.palette.background.default,
padding: "2px 8px",
borderRadius: 4,
border: `1px solid ${theme.palette.divider}`,
}}
>
{warning.code}
</span>
)}
</TableCell>
<TableCell>
<button
type="button"
onClick={() => handleToggleDismiss(warning.id)}
css={{
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
color: theme.palette.text.secondary,
transition: "all 0.15s ease",
"&:hover": {
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.primary,
},
}}
aria-label={
isDismissed ? "Restore warning" : "Dismiss warning"
}
title={isDismissed ? "Restore" : "Dismiss"}
>
{isDismissed ? (
<RotateCcwIcon size={16} />
) : (
<XIcon size={16} />
)}
</button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</>
);
};
export default TemplateWarningsPage;
+5
View File
@@ -287,6 +287,10 @@ const TemplateInsightsPage = lazy(
() =>
import("./pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage"),
);
const TemplateWarningsPage = lazy(
() =>
import("./pages/TemplatePage/TemplateWarningsPage/TemplateWarningsPage"),
);
const PremiumPage = lazy(
() => import("./pages/DeploymentSettingsPage/PremiumPage/PremiumPage"),
);
@@ -361,6 +365,7 @@ const templateRouter = () => {
<Route path="versions" element={<TemplateVersionsPage />} />
<Route path="embed" element={<TemplateEmbedExperimentRouter />} />
<Route path="insights" element={<TemplateInsightsPage />} />
<Route path="warnings" element={<TemplateWarningsPage />} />
</Route>
<Route path="workspace" element={<CreateWorkspaceExperimentRouter />} />