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
144 changes: 78 additions & 66 deletions web/components/posts/WebsiteLinksSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,8 @@ import { Plus, Trash2 } from 'lucide-react';
import type { Dispatch } from 'react';

import { Button, Input, Label } from '~/components/ui';
import type { WebsiteLinkErrors } from '~/containers/createPostValidation';

/**
* Parity with PG's `webLinkList`: up to 3 rows of `{url, title}`. Both fields
* are free-text; we don't validate URL shape client-side because PG does on
* write and the teacher may paste non-HTTP URLs (e.g. `tel:`). Rendered in
* both the Post and Post-with-Responses tiles — the outbound mapper forwards
* into `webLinkList` for both kinds.
*/
const MAX_WEBSITE_LINKS = 3;
const MAX_LINK_DESCRIPTION_LENGTH = 40;

Expand All @@ -33,9 +27,10 @@ export type WebsiteLinksAction =
interface WebsiteLinksSectionProps {
value: WebsiteLink[];
dispatch: Dispatch<WebsiteLinksAction>;
errors?: WebsiteLinkErrors[];
}

function WebsiteLinksSection({ value, dispatch }: WebsiteLinksSectionProps) {
function WebsiteLinksSection({ value, dispatch, errors = [] }: WebsiteLinksSectionProps) {
const canAdd = value.length < MAX_WEBSITE_LINKS;

return (
Expand All @@ -53,65 +48,82 @@ function WebsiteLinksSection({ value, dispatch }: WebsiteLinksSectionProps) {

{value.length > 0 && (
<div className="space-y-3">
{value.map((link, index) => (
// Row index is the stable identity here — list is at most 3 entries
// and removing a row shifts the tail, so key-by-index matches the
// reducer's index-based action payloads.
<div
// eslint-disable-next-line react/no-array-index-key
key={index}
className="grid gap-2 sm:grid-cols-[1fr_1fr_auto] sm:items-start"
>
<div className="space-y-1">
<Label htmlFor={`website-link-url-${index}`} className="sr-only">
URL for link {index + 1}
</Label>
<Input
id={`website-link-url-${index}`}
type="url"
inputMode="url"
placeholder="https://example.com"
value={link.url}
onChange={(e) =>
dispatch({
type: 'UPDATE_WEBSITE_LINK',
index,
field: 'url',
value: e.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`website-link-title-${index}`} className="sr-only">
Description for link {index + 1}
</Label>
<Input
id={`website-link-title-${index}`}
placeholder="Link description"
maxLength={MAX_LINK_DESCRIPTION_LENGTH}
value={link.title}
onChange={(e) =>
dispatch({
type: 'UPDATE_WEBSITE_LINK',
index,
field: 'title',
value: e.target.value,
})
}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove link ${index + 1}`}
onClick={() => dispatch({ type: 'REMOVE_WEBSITE_LINK', index })}
{value.map((link, index) => {
const rowErrors = errors[index];
return (
// Row index is the stable identity here — list is at most 3 entries
// and removing a row shifts the tail, so key-by-index matches the
// reducer's index-based action payloads.
<div
// eslint-disable-next-line react/no-array-index-key
key={index}
className="space-y-1"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<div className="grid gap-2 sm:grid-cols-[1fr_1fr_auto] sm:items-start">
<div className="space-y-1">
<Label htmlFor={`website-link-url-${index}`} className="sr-only">
URL for link {index + 1}
</Label>
<Input
id={`website-link-url-${index}`}
type="url"
inputMode="url"
placeholder="https://example.com"
aria-invalid={!!rowErrors?.url || undefined}
value={link.url}
onChange={(e) =>
dispatch({
type: 'UPDATE_WEBSITE_LINK',
index,
field: 'url',
value: e.target.value,
})
}
/>
{rowErrors?.url && (
<p role="alert" className="text-sm text-destructive">
{rowErrors.url}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor={`website-link-title-${index}`} className="sr-only">
Description for link {index + 1}
</Label>
<Input
id={`website-link-title-${index}`}
placeholder="Link description"
maxLength={MAX_LINK_DESCRIPTION_LENGTH}
aria-invalid={!!rowErrors?.title || undefined}
value={link.title}
onChange={(e) =>
dispatch({
type: 'UPDATE_WEBSITE_LINK',
index,
field: 'title',
value: e.target.value,
})
}
/>
{rowErrors?.title && (
<p role="alert" className="text-sm text-destructive">
{rowErrors.title}
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove link ${index + 1}`}
onClick={() => dispatch({ type: 'REMOVE_WEBSITE_LINK', index })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
)}

Expand Down
16 changes: 14 additions & 2 deletions web/containers/CreatePostView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ import {
type PostFormField,
} from '~/lib/validation-errors';

import { hasPendingUploads, isCreatePostFormValid } from './createPostValidation';
import {
getWebsiteLinksErrors,
hasPendingUploads,
isCreatePostFormValid,
} from './createPostValidation';

// ─── Route loader ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -846,6 +850,10 @@ function CreatePostViewInner({ editId }: { editId?: string }) {
// and these fields will be required by the wire contract; gate in advance so
// the form-state matches what Phase 2 expects.
const isFormValid = isCreatePostFormValid(state, selectedType);
const websiteLinkErrors = useMemo(
() => getWebsiteLinksErrors(state.websiteLinks),
[state.websiteLinks],
);
const uploadsPending = hasPendingUploads(state);
const recipientCount = state.selectedRecipients.reduce((sum, r) => sum + (r.count ?? 1), 0);
const isEditing = Boolean(editId);
Expand Down Expand Up @@ -1336,7 +1344,11 @@ function CreatePostViewInner({ editId }: { editId?: string }) {

{/* Website links — available on both kinds. */}
<div onFocus={() => setFocusSection('links')}>
<WebsiteLinksSection value={state.websiteLinks} dispatch={dispatch} />
<WebsiteLinksSection
value={state.websiteLinks}
dispatch={dispatch}
errors={websiteLinkErrors}
/>
</div>

{/* Attachments */}
Expand Down
74 changes: 73 additions & 1 deletion web/containers/CreatePostView.validation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { SelectedEntity } from '~/components/comms/entity-selector';
import type { ReminderConfig } from '~/data/mock-pg-announcements';

import { isCreatePostFormValid } from './createPostValidation';
import { getWebsiteLinkErrors, isCreatePostFormValid } from './createPostValidation';
import type { PostFormState } from './CreatePostView';

const recipient: SelectedEntity = {
Expand Down Expand Up @@ -89,3 +89,75 @@ describe('isCreatePostFormValid — post-with-response (form)', () => {
expect(isCreatePostFormValid({ ...formBase, reminder }, 'post-with-response')).toBe(true);
});
});

describe('getWebsiteLinkErrors', () => {
it('returns no errors when both fields are empty', () => {
expect(getWebsiteLinkErrors({ url: '', title: '' })).toEqual({});
});

it('returns no errors when both fields are filled with valid URL', () => {
expect(getWebsiteLinkErrors({ url: 'https://example.com', title: 'Example' })).toEqual({});
});

it('requires title when only URL is filled', () => {
const errors = getWebsiteLinkErrors({ url: 'https://example.com', title: '' });
expect(errors.title).toBe('Description is required.');
expect(errors.url).toBeUndefined();
});

it('requires URL when only title is filled', () => {
const errors = getWebsiteLinkErrors({ url: '', title: 'My link' });
expect(errors.url).toBe('URL is required.');
expect(errors.title).toBeUndefined();
});

it('rejects invalid URL format', () => {
const errors = getWebsiteLinkErrors({ url: 'not-a-url', title: 'My link' });
expect(errors.url).toBe('Please enter a valid URL.');
});

it('rejects non-http(s) protocols', () => {
const errors = getWebsiteLinkErrors({ url: 'ftp://files.example.com', title: 'FTP' });
expect(errors.url).toBe('Please enter a valid URL.');
});

it('accepts http URLs', () => {
expect(getWebsiteLinkErrors({ url: 'http://example.com', title: 'Example' })).toEqual({});
});

it('treats whitespace-only fields as empty', () => {
expect(getWebsiteLinkErrors({ url: ' ', title: ' ' })).toEqual({});
});
});

describe('isCreatePostFormValid — website links', () => {
it('fails when a link has URL but no description', () => {
const state = { ...validBase, websiteLinks: [{ url: 'https://example.com', title: '' }] };
expect(isCreatePostFormValid(state, 'post')).toBe(false);
});

it('fails when a link has description but no URL', () => {
const state = { ...validBase, websiteLinks: [{ url: '', title: 'My link' }] };
expect(isCreatePostFormValid(state, 'post')).toBe(false);
});

it('fails when URL is invalid', () => {
const state = { ...validBase, websiteLinks: [{ url: 'bad-url', title: 'My link' }] };
expect(isCreatePostFormValid(state, 'post')).toBe(false);
});

it('passes when all links have both fields with valid URLs', () => {
const state = {
...validBase,
websiteLinks: [
{ url: 'https://example.com', title: 'Example' },
{ url: 'https://school.edu.sg', title: 'School site' },
],
};
expect(isCreatePostFormValid(state, 'post')).toBe(true);
});

it('passes with empty website links array', () => {
expect(isCreatePostFormValid(validBase, 'post')).toBe(true);
});
});
48 changes: 48 additions & 0 deletions web/containers/createPostValidation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PostKind } from '~/components/posts/PostTypePicker';
import type { WebsiteLink } from '~/components/posts/WebsiteLinksSection';

import type { PostFormState } from './CreatePostView';

Expand Down Expand Up @@ -39,6 +40,10 @@ export function isCreatePostFormValid(
);
if (!allUploadsResolved) return false;

// Gate 4: website links — if either field in a row is filled, both are
// required, and the URL must look like a valid URL. Applies to all post types.
if (!areWebsiteLinksValid(state.websiteLinks)) return false;

if (selectedType !== 'post-with-response') return true;

// Due date must be today or later. Past dates make the reminder window empty
Expand All @@ -60,6 +65,49 @@ export function isCreatePostFormValid(
return true;
}

export interface WebsiteLinkErrors {
url?: string;
title?: string;
}

export function getWebsiteLinkErrors(link: WebsiteLink): WebsiteLinkErrors {
const hasUrl = link.url.trim().length > 0;
const hasTitle = link.title.trim().length > 0;

if (!hasUrl && !hasTitle) return {};

const errors: WebsiteLinkErrors = {};

if (!hasUrl) {
errors.url = 'URL is required.';
} else if (!isValidUrl(link.url.trim())) {
errors.url = 'Please enter a valid URL.';
}

if (!hasTitle) {
errors.title = 'Description is required.';
}

return errors;
}

export function getWebsiteLinksErrors(links: WebsiteLink[]): WebsiteLinkErrors[] {
return links.map(getWebsiteLinkErrors);
}

function areWebsiteLinksValid(links: WebsiteLink[]): boolean {
return links.every((l) => Object.keys(getWebsiteLinkErrors(l)).length === 0);
}

function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}

export function hasPendingUploads(state: PostFormState): boolean {
return [...state.attachments, ...state.photos].some(
(u) => u.status === 'uploading' || u.status === 'verifying',
Expand Down