Skip to content
Open
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/app/components/DragAndDropFile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const DragAndDropFile = ({
errorMessage,
loading,
tooltipComponent,
required,
}: {
accept: string;
title?: string;
Expand All @@ -26,13 +27,15 @@ const DragAndDropFile = ({
errorMessage?: string;
tooltipComponent?: React.ReactNode;
loading?: boolean;
required?: boolean;
}) => {
return (
<div className="flex w-full flex-col gap-4">
{title ? (
<div className="flex flex-row gap-2">
<Label className="text-base text-grey-900" htmlFor={title}>
{title}
{required && <span className="text-red-500"> *</span>}
</Label>

{tooltipComponent ? (
Expand Down
1 change: 1 addition & 0 deletions src/app/create/[id]/createBlueprintSteps/EmailDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ const EmailDetails = ({
<div className="">
<Input
title="Max Email Body Length"
required
disabled={store.ignoreBodyHashCheck}
placeholder="4032"
error={!!validationErrors.emailBodyMaxLength}
Expand Down
4 changes: 2 additions & 2 deletions src/app/create/[id]/createBlueprintSteps/ExtractFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ const ExtractFields = ({
<div className="mb-4 w-full overflow-hidden rounded-lg">
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-1">
<Label className="font-medium text-grey-900">Quick header extraction</Label>
<Label className="font-medium text-grey-900">Quick header extraction <span className="text-red-500">*</span></Label>
<p className="text-base font-medium text-grey-700">
We auto-write the regexes for all the toggled fields
</p>
Expand Down Expand Up @@ -934,7 +934,7 @@ const ExtractFields = ({
<div key={index} className="mb-2 flex flex-col gap-3 px-1">
<div className="flex items-center justify-between py-3 pb-1">
<div className="flex items-center gap-2">
<Label className="font-medium">Extracted data #{index + 1}</Label>
<Label className="font-medium">Extracted data #{index + 1}{index === 0 ? <><span className="text-red-500"> *</span></> : ''}</Label>
</div>
<Button
size="sm"
Expand Down
6 changes: 6 additions & 0 deletions src/app/create/[id]/createBlueprintSteps/PatternDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ const PatternDetails = ({
<div className="flex flex-col gap-6">
<Input
title="Pattern Name"
required
// disabled={id !== 'new'}
placeholder="Name of the Blueprint"
value={store.title}
Expand Down Expand Up @@ -203,6 +204,7 @@ const PatternDetails = ({
<Input title="Slug" disabled value={store.slug} loading={isCheckExistingBlueprintLoading} />
<Textarea
title="Description"
required
placeholder="Prove that you own a particular GitHub account"
value={store.description}
rows={3}
Expand All @@ -227,6 +229,7 @@ const PatternDetails = ({
</div>
}
title="Upload test .eml"
required
id="drag-and-drop-emails"
data-testid="drag-and-drop-emails"
helpText="Our AI will autofill fields based on contents inside your mail. Don't worry you can edit them later"
Expand Down Expand Up @@ -265,6 +268,7 @@ const PatternDetails = ({
</div>
<Input
title="Email Query"
required
disabled={store.clientStatus === Status.Done && store.serverStatus === Status.Done}
value={store.emailQuery}
onChange={(e) => setField('emailQuery', e.target.value)}
Expand All @@ -289,6 +293,7 @@ const PatternDetails = ({
/>
<Input
title="Sender domain"
required
loading={isVerifyDKIMLoading}
placeholder="twitter.com"
helpText="This is the domain used for DKIM verification, which may not exactly match the senders domain (you can check via the d= field in the DKIM-Signature header). Note to only include the part after the @ symbol"
Expand Down Expand Up @@ -333,6 +338,7 @@ const PatternDetails = ({
/>
<Input
title="Max Email Header Length"
required
placeholder="1024"
type="number"
min={0}
Expand Down
128 changes: 100 additions & 28 deletions src/app/create/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { debounce } from '@/app/utils';
import ModalGenerator from '@/components/ModalGenerator';
import { useEmlStore } from '@/lib/stores/useEmlStore';
import Loader from '@/components/ui/loader';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';

type Step = '0' | '1' | '2';

Expand Down Expand Up @@ -169,7 +170,9 @@ const CreateBlueprint = ({ params }: { params: Promise<{ id: string }> }) => {
} catch (err) {
toast.error('Failed to submit blueprint');
console.error('Failed to submit blueprint: ', err);
setErrors(['Unknown error while submitting blueprint']);
// Don't set errors state — save failure is already shown via toast and is not a
// validation issue; the Next tooltip should only list form/validation reasons.
throw err; // Rethrow so callers (e.g. onClickNext) know save failed and don't advance step
} finally {
setIsSaveDraftLoading(false);
}
Expand Down Expand Up @@ -387,34 +390,73 @@ const CreateBlueprint = ({ params }: { params: Promise<{ id: string }> }) => {
}
}, [JSON.stringify(store.decomposedRegexes), savedEmls[id]]);

const isNextButtonDisabled = () => {
if ((!savedEmls[id] || isFileInvalid) && !skipEmlUpload) {
return true;
const getNextButtonDisabledReasons = (): string[] => {
const reasons: string[] = [];
const validationErrors = store.validationErrors || {};

// EML file requirement (when not skipping)
if (!skipEmlUpload) {
if (!savedEmls[id]) {
reasons.push('Upload a sample .eml file or enable "Skip EML upload"');
} else if (isFileInvalid) {
reasons.push('The uploaded file is invalid. Upload a valid .eml or enable "Skip EML upload"');
}
}

if (step === '0') {
return !store.circuitName || !store.title || !store.description || store.title?.includes(' ');
if (!store.circuitName) reasons.push('Pattern name is required');
if (!store.title) {
reasons.push('Title is required');
} else if (store.title?.includes(' ')) {
reasons.push('Title must not contain spaces');
}
if (!store.description) reasons.push('Description is required');
if (!store.emailQuery) reasons.push('Email Query is required');
else if (validationErrors.emailQuery) reasons.push(`Email Query: ${validationErrors.emailQuery}`);
if (!store.senderDomain) reasons.push('Sender domain is required');
else if (validationErrors.senderDomain) reasons.push(`Sender domain: ${validationErrors.senderDomain}`);
if (validationErrors.emailHeaderMaxLength) {
reasons.push(`Max Email Header Length: ${validationErrors.emailHeaderMaxLength}`);
}
if (isDKIMMissing && store.senderDomain) {
reasons.push('DKIM key not found for the sender domain');
}
}

if (step === '1') {
return !store.decomposedRegexes.length && !skipEmlUpload;
if (!store.decomposedRegexes.length && !skipEmlUpload) {
reasons.push('Add at least one extract field');
reasons.push('Or enable "Skip EML upload" to continue');
}
if (errors.length) {
reasons.push(...errors);
}
if (isDKIMMissing && store.senderDomain) {
reasons.push('DKIM key not found for the sender domain');
}
}

if (step === '2') {
// Check canCompile state from ExtractFields component
return !canCompile && !skipEmlUpload;
if (!canCompile && !skipEmlUpload) {
reasons.push('Complete extract field configuration to continue');
reasons.push('Or enable "Skip EML upload"');
}
}

return !!errors.length || isDKIMMissing;
return reasons;
};

const isNextButtonDisabled = () => getNextButtonDisabledReasons().length > 0;

const onClickNext = async () => {
try {
setIsNextButtonClicked(true);
const newId = await handleSaveDraft();
// Only advance step if save succeeded (handleSaveDraft throws on failure)
setStep((parseInt(step) + 1).toString() as Step, newId);
} catch (err) {
console.error('failed to save draft and move to next step: ', err);
// Don't advance step — user stays on current step
} finally {
setIsNextButtonClicked(false);
}
Expand Down Expand Up @@ -607,25 +649,55 @@ const CreateBlueprint = ({ params }: { params: Promise<{ id: string }> }) => {
Save as Draft
</Button>
{parseInt(step) < 2 ? (
<Button
onClick={onClickNext}
loading={isNextButtonClicked}
endIcon={
<Image
src="/assets/ArrowRight.svg"
alt="arrow right"
width={16}
height={16}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
}
disabled={isNextButtonDisabled()}
>
Next
</Button>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<span className="relative inline-block cursor-pointer [&:has(button:disabled)]:cursor-not-allowed">
{/* Invisible overlay when disabled so tooltip trigger receives hover (disabled buttons block pointer events in some browsers) */}
{isNextButtonDisabled() && (
<span className="pointer-events-auto absolute inset-0 z-10" aria-hidden />
)}
<Button
onClick={onClickNext}
loading={isNextButtonClicked}
endIcon={
<Image
src="/assets/ArrowRight.svg"
alt="arrow right"
width={16}
height={16}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
}
disabled={isNextButtonDisabled()}
>
Next
</Button>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={8}
className="max-w-sm border border-grey-500 bg-grey-800 px-3 py-2.5 text-left text-sm text-white shadow-lg"
>
{isNextButtonDisabled() ? (
<div className="flex flex-col gap-1.5 py-0.5">
<p className="font-medium">Complete the following to continue:</p>
<ul className="list-inside list-disc space-y-0.5 text-sm">
{getNextButtonDisabledReasons().map((reason, i) => (
<li key={i}>{reason}</li>
))}
</ul>
</div>
) : (
'Go to next step'
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
onClick={handleCompile}
Expand Down
3 changes: 3 additions & 0 deletions src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
startIcon?: React.ReactNode;
tooltipComponent?: React.ReactNode;
loading?: boolean;
required?: boolean;
}

const inputVariants = cva(
Expand Down Expand Up @@ -45,6 +46,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
size,
tooltipComponent,
loading,
required,
...props
},
ref
Expand All @@ -55,6 +57,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<div className="flex flex-row gap-2">
<Label className="text-base text-grey-900" htmlFor={props.title}>
{props.title}
{required && <span className="text-red-500"> *</span>}
</Label>

{tooltipComponent ? (
Expand Down
4 changes: 3 additions & 1 deletion src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextArea
title?: string;
errorMessage?: string;
helpText?: string;
required?: boolean;
}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, errorMessage, helpText, ...props }, ref) => {
({ className, errorMessage, helpText, required, ...props }, ref) => {
return (
<div className="flex flex-col gap-2">
{props.title ? (
<Label className="text-base text-grey-900" htmlFor={props.title}>
{props.title}
{required && <span className="text-red-500"> *</span>}
</Label>
) : null}
<textarea
Expand Down