Skip to content
Open
29 changes: 29 additions & 0 deletions e2e/smoke/machines-crud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,34 @@ test.describe("Machines CRUD", () => {
await expect(page.getByText("Bear Kick opto not working")).toBeVisible();
});

test("should display machine owner to all logged-in users", async ({
page,
}) => {
// Navigate to a machine detail page
await page.goto(`/m/${seededMachines.medievalMadness.initials}`);

// Machine Information card should be visible
await expect(
page.getByRole("heading", { name: "Machine Information" })
).toBeVisible();

// As a member (default login), owner should be displayed but read-only
const ownerDisplay = page.getByTestId("owner-display");
await expect(ownerDisplay).toBeVisible();

// Verify owner label is present
await expect(page.getByText("Machine Owner")).toBeVisible();

// Verify owner name is shown (Admin User owns all seeded machines)
await expect(page.getByText("Admin User")).toBeVisible();

// Verify the help text is shown
await expect(
page.getByText(
"The owner receives notifications for new issues on this machine."
)
).toBeVisible();
});
Comment on lines +112 to +139
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This E2E test appears unrelated to the PR's stated purpose of improving form validation and user feedback for the Report Issue form. The PR description mentions fixes for #607 and #608, which concern the report form, but this test validates machine owner display functionality. Consider moving this test to a separate PR focused on machine owner display features.

Copilot uses AI. Check for mistakes.

// Empty state test (requires creation) moved to integration/full suite
});
43 changes: 41 additions & 2 deletions src/app/(app)/m/[initials]/update-machine-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ interface UpdateMachineFormProps {
initials: string;
ownerId: string | null;
invitedOwnerId: string | null;
owner?: {
id: string;
name: string;
avatarUrl?: string | null;
email?: string;
} | null;
invitedOwner?: {
id: string;
name: string;
email?: string;
} | null;
Comment on lines +24 to +34
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change appears unrelated to the PR's stated purpose of improving form validation and user feedback for the Report Issue form. The PR description mentions fixes for #607 and #608, which concern the report form, but this change adds owner and invitedOwner object properties to the machine interface. Consider moving these machine owner display improvements to a separate PR focused on machine management features.

Suggested change
owner?: {
id: string;
name: string;
avatarUrl?: string | null;
email?: string;
} | null;
invitedOwner?: {
id: string;
name: string;
email?: string;
} | null;

Copilot uses AI. Check for mistakes.
};
allUsers: UnifiedUser[];
isAdmin: boolean;
Expand Down Expand Up @@ -97,12 +108,40 @@ export function UpdateMachineForm({
</p>
</div>

{/* Owner Select (Admin Only) */}
{isAdmin && (
{/* Machine Owner */}
{isAdmin ? (
<OwnerSelect
users={allUsers}
defaultValue={machine.ownerId ?? machine.invitedOwnerId ?? null}
/>
) : (
<div className="space-y-2" data-testid="owner-display">
<span className="text-sm font-semibold text-on-surface">
Machine Owner
</span>
<div className="rounded-md border border-outline bg-surface px-3 py-2">
{machine.owner || machine.invitedOwner ? (
<div className="flex items-center gap-2">
<span className="text-sm text-on-surface">
{machine.owner?.name ?? machine.invitedOwner?.name}
</span>
{/* Show invited badge only for truly invited owners (not yet accepted) */}
{machine.invitedOwner && !machine.owner && (
<span className="text-[10px] font-medium uppercase tracking-wider text-on-surface-variant/70">
(Invited)
</span>
)}
</div>
) : (
<span className="text-sm text-on-surface-variant">
No owner assigned
</span>
)}
</div>
<p className="text-xs text-on-surface-variant">
The owner receives notifications for new issues on this machine.
</p>
</div>
Comment on lines +111 to +144
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change appears unrelated to the PR's stated purpose of improving form validation and user feedback for the Report Issue form. The PR description mentions fixes for #607 and #608, which concern the report form, but this adds a read-only owner display section for non-admin users. Consider moving these machine owner display improvements to a separate PR focused on machine management features.

Suggested change
{/* Machine Owner */}
{isAdmin ? (
<OwnerSelect
users={allUsers}
defaultValue={machine.ownerId ?? machine.invitedOwnerId ?? null}
/>
) : (
<div className="space-y-2" data-testid="owner-display">
<span className="text-sm font-semibold text-on-surface">
Machine Owner
</span>
<div className="rounded-md border border-outline bg-surface px-3 py-2">
{machine.owner || machine.invitedOwner ? (
<div className="flex items-center gap-2">
<span className="text-sm text-on-surface">
{machine.owner?.name ?? machine.invitedOwner?.name}
</span>
{/* Show invited badge only for truly invited owners (not yet accepted) */}
{machine.invitedOwner && !machine.owner && (
<span className="text-[10px] font-medium uppercase tracking-wider text-on-surface-variant/70">
(Invited)
</span>
)}
</div>
) : (
<span className="text-sm text-on-surface-variant">
No owner assigned
</span>
)}
</div>
<p className="text-xs text-on-surface-variant">
The owner receives notifications for new issues on this machine.
</p>
</div>
{/* Machine Owner (admins only) */}
{isAdmin && (
<OwnerSelect
users={allUsers}
defaultValue={machine.ownerId ?? machine.invitedOwnerId ?? null}
/>

Copilot uses AI. Check for mistakes.
)}

{/* Actions */}
Expand Down
14 changes: 4 additions & 10 deletions src/app/report/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ import { eq } from "drizzle-orm";
import { createClient } from "~/lib/supabase/server";
import type { ActionState } from "./unified-report-form";

const redirectWithError = (message: string): never => {
const params = new URLSearchParams({ error: message });
redirect(`/report?${params.toString()}`);
};

/**
* Server Action: submit anonymous issue
*
Expand Down Expand Up @@ -51,15 +46,14 @@ export async function submitPublicIssueAction(

if (!success) {
const resetTime = formatResetTime(reset);
redirectWithError(
`Too many submissions. Please try again in ${resetTime}.`
);
return {
error: `Too many submissions. Please try again in ${resetTime}.`,
};
}

const parsedValue = parsePublicIssueForm(formData);
if (!parsedValue.success) {
redirectWithError(parsedValue.error);
return { error: parsedValue.error }; // Should be unreachable
return { error: parsedValue.error };
}

// After the early return, parsedValue is narrowed to ParsedPublicIssue
Expand Down
14 changes: 6 additions & 8 deletions src/app/report/unified-report-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,9 @@ export function UnifiedReportForm({
);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [severity, setSeverity] = useState<IssueSeverity>("minor");
const [priority, setPriority] = useState<IssuePriority>("medium");
const [consistency, setConsistency] =
useState<IssueConsistency>("intermittent");
const [severity, setSeverity] = useState<IssueSeverity | "">("");
const [priority, setPriority] = useState<IssuePriority | "">("");
const [consistency, setConsistency] = useState<IssueConsistency | "">("");

const [state, formAction, isPending] = useActionState(
submitPublicIssueAction,
Expand All @@ -96,9 +95,9 @@ export function UnifiedReportForm({
machineId: string;
title: string;
description: string;
severity: IssueSeverity;
priority: IssuePriority;
consistency: IssueConsistency;
severity: IssueSeverity | "";
priority: IssuePriority | "";
consistency: IssueConsistency | "";
}>;

// Only restore machineId if not provided via prop or URL already
Expand Down Expand Up @@ -385,7 +384,6 @@ export function UnifiedReportForm({
<Button
type="submit"
className="w-full bg-primary text-on-primary hover:bg-primary/90 mt-2 h-10 text-sm font-semibold"
disabled={!selectedMachineId}
loading={isPending}
>
Submit Issue Report
Expand Down
65 changes: 65 additions & 0 deletions src/app/report/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { parsePublicIssueForm } from "./validation";

describe("Public Issue Form Validation", () => {
it("should fail validation when machineId is missing", () => {
const formData = new FormData();
formData.set("title", "Test Issue");
formData.set("severity", "minor");
formData.set("consistency", "intermittent");

const result = parsePublicIssueForm(formData);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("machineId");
// Zod's default error for undefined/missing field when it expects a uuid string
expect(result.error).toContain(
"Invalid input: expected string, received undefined"
);
Comment on lines +16 to +19
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects a specific Zod error message format ("Invalid input: expected string, received undefined"). This is brittle as it couples the test to Zod's internal error message format. If Zod updates its error messages in a future version, this test would fail even though the validation logic is correct. Consider checking for the field name and validation failure only, without asserting on the exact error message text.

Suggested change
// Zod's default error for undefined/missing field when it expects a uuid string
expect(result.error).toContain(
"Invalid input: expected string, received undefined"
);

Copilot uses AI. Check for mistakes.
}
});

it("should fail validation when severity is missing", () => {
const formData = new FormData();
formData.set("machineId", "00000000-0000-0000-0000-000000000000");
formData.set("title", "Test Issue");
formData.set("consistency", "intermittent");

const result = parsePublicIssueForm(formData);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("severity");
expect(result.error).toContain("Select a severity");
}
});

it("should fail validation when title is empty", () => {
const formData = new FormData();
formData.set("machineId", "00000000-0000-0000-0000-000000000000");
formData.set("title", "");
formData.set("severity", "minor");
formData.set("consistency", "intermittent");

const result = parsePublicIssueForm(formData);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("title");
expect(result.error).toContain("Title is required");
}
});

it("should pass validation when all required fields are present", () => {
const formData = new FormData();
formData.set("machineId", "00000000-0000-0000-0000-000000000000");
formData.set("title", "Valid Title");
formData.set("severity", "minor");
formData.set("consistency", "intermittent");

const result = parsePublicIssueForm(formData);

expect(result.success).toBe(true);
});
});
Comment on lines +54 to +65
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for empty string submission. When users don't select a value (value is ""), the hidden input will submit an empty string to the server. While the Zod validation should catch this (enum doesn't accept empty string), there's no test case verifying this behavior. Add a test that sets severity or consistency to an empty string and verifies the validation fails appropriately.

Copilot uses AI. Check for mistakes.
33 changes: 20 additions & 13 deletions src/components/issues/fields/ConsistencySelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { CONSISTENCY_CONFIG } from "~/lib/issues/status";
import { type IssueConsistency } from "~/lib/types";

interface ConsistencySelectProps {
value: IssueConsistency;
value: IssueConsistency | "";
onValueChange: (value: IssueConsistency) => void;
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type mismatch between value and onValueChange. The value prop accepts IssueConsistency | "" but onValueChange only accepts IssueConsistency. This creates a type inconsistency where the component accepts an empty string as a valid value, but cannot properly handle that case in the callback. Consider updating onValueChange to accept (value: IssueConsistency | "") => void and handle the empty string case appropriately, or use a type assertion to narrow the value before passing to onValueChange.

Suggested change
onValueChange: (value: IssueConsistency) => void;
onValueChange: (value: IssueConsistency | "") => void;

Copilot uses AI. Check for mistakes.
disabled?: boolean;
name?: string;
placeholder?: string;
testId?: string;
}

Expand All @@ -30,6 +31,7 @@ export function ConsistencySelect({
onValueChange,
disabled = false,
name = "consistency",
placeholder = "Select consistency...",
testId = "issue-consistency-select",
}: ConsistencySelectProps): React.JSX.Element {
return (
Expand All @@ -41,20 +43,25 @@ export function ConsistencySelect({
>
<SelectTrigger
className="w-full border-outline-variant bg-surface text-on-surface"
aria-label={`Consistency: ${CONSISTENCY_CONFIG[value].label}`}
aria-label={
value
? `Consistency: ${CONSISTENCY_CONFIG[value].label}`
: "Select Consistency"
}
data-testid={testId}
>
<SelectValue>
{(() => {
const config = CONSISTENCY_CONFIG[value];
const Icon = config.icon;
return (
<div className="flex items-center gap-2">
<Icon className={`size-4 ${config.iconColor}`} />
<span>{config.label}</span>
</div>
);
})()}
<SelectValue placeholder={placeholder}>
{value &&
(() => {
const config = CONSISTENCY_CONFIG[value];
const Icon = config.icon;
return (
<div className="flex items-center gap-2">
<Icon className={`size-4 ${config.iconColor}`} />
<span>{config.label}</span>
</div>
);
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
Expand Down
33 changes: 20 additions & 13 deletions src/components/issues/fields/PrioritySelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { PRIORITY_CONFIG } from "~/lib/issues/status";
import { type IssuePriority } from "~/lib/types";

interface PrioritySelectProps {
value: IssuePriority;
value: IssuePriority | "";
onValueChange: (value: IssuePriority) => void;
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type mismatch between value and onValueChange. The value prop accepts IssuePriority | "" but onValueChange only accepts IssuePriority. This creates a type inconsistency where the component accepts an empty string as a valid value, but cannot properly handle that case in the callback. Consider updating onValueChange to accept (value: IssuePriority | "") => void and handle the empty string case appropriately, or use a type assertion to narrow the value before passing to onValueChange.

Suggested change
onValueChange: (value: IssuePriority) => void;
onValueChange: (value: IssuePriority | "") => void;

Copilot uses AI. Check for mistakes.
disabled?: boolean;
name?: string;
placeholder?: string;
testId?: string;
}

Expand All @@ -26,6 +27,7 @@ export function PrioritySelect({
onValueChange,
disabled = false,
name = "priority",
placeholder = "Select priority...",
testId = "issue-priority-select",
}: PrioritySelectProps): React.JSX.Element {
return (
Expand All @@ -37,20 +39,25 @@ export function PrioritySelect({
>
<SelectTrigger
className="w-full border-outline-variant bg-surface text-on-surface"
aria-label={`Priority: ${PRIORITY_CONFIG[value].label}`}
aria-label={
value
? `Priority: ${PRIORITY_CONFIG[value].label}`
: "Select Priority"
}
data-testid={testId}
>
<SelectValue>
{(() => {
const config = PRIORITY_CONFIG[value];
const Icon = config.icon;
return (
<div className="flex items-center gap-2">
<Icon className={`size-4 ${config.iconColor}`} />
<span>{config.label}</span>
</div>
);
})()}
<SelectValue placeholder={placeholder}>
{value &&
(() => {
const config = PRIORITY_CONFIG[value];
const Icon = config.icon;
return (
<div className="flex items-center gap-2">
<Icon className={`size-4 ${config.iconColor}`} />
<span>{config.label}</span>
</div>
);
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
Expand Down
Loading
Loading