diff --git a/app/components/TimeAgo.tsx b/app/components/TimeAgo.tsx index 289f027ca..24e9eb947 100644 --- a/app/components/TimeAgo.tsx +++ b/app/components/TimeAgo.tsx @@ -11,7 +11,7 @@ import RelativeTime from 'dayjs/plugin/relativeTime' dayjs.locale(locale) dayjs.extend(RelativeTime) -const dateTimeFormat = new Intl.DateTimeFormat(locale.name, { +export const dateTimeFormat = new Intl.DateTimeFormat(locale.name, { dateStyle: 'full', timeStyle: 'long', timeZone: 'utc', diff --git a/app/routes/circulars._archive._index/route.tsx b/app/routes/circulars._archive._index/route.tsx index a3c04c0c8..4870fe677 100644 --- a/app/routes/circulars._archive._index/route.tsx +++ b/app/routes/circulars._archive._index/route.tsx @@ -35,6 +35,7 @@ import { createChangeRequest, get, getChangeRequests, + moderatorGroup, put, putVersion, search, @@ -97,9 +98,22 @@ export async function action({ request }: ActionFunctionArgs) { if (circularId === undefined) throw new Response('circularId is required', { status: 400 }) if (!user?.name || !user.email) throw new Response(null, { status: 403 }) - + let submitter, createdOnDate, createdOnTime, createdOn + if (user.groups.includes(moderatorGroup)) { + submitter = getFormDataString(data, 'submitter') + createdOnDate = getFormDataString(data, 'createdOnDate') + createdOnTime = getFormDataString(data, 'createdOnTime') + createdOn = Date.parse(`${createdOnDate} ${createdOnTime} UTC`) + } + if (!submitter || !createdOnDate || !createdOnTime || !createdOn) + throw new Response(null, { status: 400 }) await createChangeRequest( - { circularId: parseFloat(circularId), ...props }, + { + circularId: parseFloat(circularId), + ...props, + submitter, + createdOn, + }, user ) await postZendeskRequest({ diff --git a/app/routes/circulars.correction.$circularId.tsx b/app/routes/circulars.correction.$circularId.tsx index 243ab77a5..3893477b2 100644 --- a/app/routes/circulars.correction.$circularId.tsx +++ b/app/routes/circulars.correction.$circularId.tsx @@ -29,13 +29,19 @@ export async function loader({ const user = await getUser(request) if (!user?.groups.includes(group)) throw new Response(null, { status: 403 }) const circular = await get(parseFloat(circularId)) + const defaultDateTime = new Date(circular.createdOn ?? 0) + .toISOString() + .split('T') + return { formattedContributor: user ? formatAuthor(user) : '', defaultBody: circular.body, defaultSubject: circular.subject, defaultFormat: circular.format, circularId: circular.circularId, - submitter: circular.submitter, + defaultSubmitter: circular.submitter, + defaultCreatedOnDate: defaultDateTime[0], + defaultCreatedOnTime: defaultDateTime[1].substring(0, 5), searchString: '', } } diff --git a/app/routes/circulars.edit.$circularId/CircularEditForm.tsx b/app/routes/circulars.edit.$circularId/CircularEditForm.tsx index fb0f635a2..632c6a16a 100644 --- a/app/routes/circulars.edit.$circularId/CircularEditForm.tsx +++ b/app/routes/circulars.edit.$circularId/CircularEditForm.tsx @@ -9,11 +9,14 @@ import { Form, Link, useNavigation } from '@remix-run/react' import { Button, ButtonGroup, + DatePicker, + Grid, Icon, InputGroup, InputPrefix, Table, TextInput, + TimePicker, } from '@trussworks/react-uswds' import classnames from 'classnames' import { type ReactNode, useContext, useState } from 'react' @@ -24,12 +27,17 @@ import { MarkdownBody } from '../circulars.$circularId.($version)/Body' import { type CircularFormat, bodyIsValid, + dateIsValid, subjectIsValid, + submitterIsValid, } from '../circulars/circulars.lib' import { RichEditor } from './RichEditor' import { CircularsKeywords } from '~/components/CircularsKeywords' import CollapsableInfo from '~/components/CollapsableInfo' import Spinner from '~/components/Spinner' +import { useModStatus } from '~/root' + +import styles from './CircularsEditForm.module.css' function SyntaxExample({ label, @@ -103,20 +111,24 @@ export function SyntaxReference() { export function CircularEditForm({ formattedContributor, circularId, - submitter, + defaultSubmitter, defaultFormat, defaultBody, defaultSubject, searchString, + defaultCreatedOnDate, + defaultCreatedOnTime, intent, }: { formattedContributor: string circularId?: number - submitter?: string + defaultSubmitter?: string defaultFormat?: CircularFormat defaultBody: string defaultSubject: string searchString: string + defaultCreatedOnDate?: string + defaultCreatedOnTime?: string intent: 'correction' | 'edit' | 'new' }) { let formSearchString = '?index' @@ -130,9 +142,15 @@ export function CircularEditForm({ const [body, setBody] = useState(defaultBody) const [subject, setSubject] = useState(defaultSubject) const [format, setFormat] = useState(defaultFormat) + const [date, setDate] = useState(defaultCreatedOnDate) + const [time, setTime] = useState(defaultCreatedOnTime ?? '12:00') + const dateValid = circularId ? dateIsValid(date, time) : true + + const [submitter, setSubmitter] = useState(defaultSubmitter) + const submitterValid = circularId ? submitterIsValid(submitter) : true const bodyValid = bodyIsValid(body) const sending = Boolean(useNavigation().formData) - const valid = subjectValid && bodyValid + const valid = subjectValid && bodyValid && dateValid && submitterValid let headerText, saveButtonText switch (intent) { @@ -150,22 +168,40 @@ export function CircularEditForm({ break } const bodyPlaceholder = useBodyPlaceholder() - const changesHaveBeenMade = body.trim() !== defaultBody.trim() || subject.trim() !== defaultSubject.trim() || - format !== defaultFormat + format !== defaultFormat || + submitter?.trim() !== defaultSubmitter || + date !== defaultCreatedOnDate || + time !== defaultCreatedOnTime + + const userIsModerator = useModStatus() + return (

{headerText} GCN Circular

- {circularId !== undefined && ( + {circularId !== undefined && userIsModerator && ( <> - + From - {submitter} + setSubmitter(event.target.value)} + required + /> )} @@ -183,6 +219,71 @@ export function CircularEditForm({ + {circularId !== undefined && ( + + + + Date + { + setDate(value ?? '') + }} + name="createdOnDate" + id="createdOnDate" + dateFormat="YYYY-MM-DD" + /> + + + + + {/* FIXME: The TimePicker component does not by itself + contribute useful form data because only the element has + a name, and the field does not. So the form data is only + populated correctly if the user selects an option from the + dropdown, but not if they type a valid value into the combo box. + + See https://github.com/trussworks/react-uswds/issues/2806 */} + + Time + {/* FIXME: Currently only 12 hour formats are supported. We should + switch to 24 hours as it is more common/useful for the community. + + See https://github.com/trussworks/react-uswds/issues/2947 */} + { + setTime(value ?? '') + }} + step={1} + label="" + /> + + + + )} () - return ( <>

Circular {circular.circularId}

Original Author

- {circular.submitter} +

Requestor

{correction.requestor} +

Created On

+

Subject

diff --git a/app/routes/circulars/circulars.lib.ts b/app/routes/circulars/circulars.lib.ts index 060b2ddf0..b332dd8be 100644 --- a/app/routes/circulars/circulars.lib.ts +++ b/app/routes/circulars/circulars.lib.ts @@ -37,6 +37,8 @@ export interface CircularChangeRequest extends CircularMetadata { requestorSub: string requestorEmail: string format: CircularFormat + submitter: string + createdOn: number } export interface CircularChangeRequestKeys { @@ -130,6 +132,15 @@ export function formatIsValid(format: string): format is CircularFormat { return (circularFormats as any as string[]).includes(format) } +/** For updated dates, check that the date is valid */ +export function dateIsValid(date?: string, time?: string) { + return !Number.isNaN(Date.parse(`${date}T${time}:00.000Z`)) +} + +export function submitterIsValid(submitter?: string) { + return Boolean(submitter) +} + export function emailIsAutoReply(subject: string) { const lowercaseSubject = subject.toLowerCase() return emailAutoReplyChecklist.some((x) => lowercaseSubject.includes(x)) diff --git a/app/routes/circulars/circulars.server.ts b/app/routes/circulars/circulars.server.ts index 08864b594..9f2c2ad41 100644 --- a/app/routes/circulars/circulars.server.ts +++ b/app/routes/circulars/circulars.server.ts @@ -368,16 +368,11 @@ export async function getVersions(circularId: number): Promise { export async function createChangeRequest( item: Omit< Circular, - | 'sub' - | 'createdOn' - | 'submitter' - | 'submittedHow' - | 'bibcode' - | 'editedBy' - | 'version' - | 'editedOn' + 'sub' | 'submittedHow' | 'bibcode' | 'editedBy' | 'version' | 'editedOn' >, - user?: User + user?: User, + submitter?: string, + createdOn?: number ) { validateCircular(item) if (!user) @@ -386,11 +381,16 @@ export async function createChangeRequest( }) const requestor = formatAuthor(user) const db = await tables() + const circular = (await db.circulars.get({ + circularId: item.circularId, + })) as Circular await db.circulars_change_requests.put({ ...item, requestorSub: user.sub, requestorEmail: user.email, requestor, + createdOn: item.createdOn ?? circular.createdOn, + submitter: item.submitter ?? circular.submitter, }) await sendEmail({ @@ -518,6 +518,8 @@ export async function approveChangeRequest( editedBy: `${formatAuthor(user)} on behalf of ${changeRequest.requestor}`, editedOn: Date.now(), format: changeRequest.format, + submitter: changeRequest.submitter, + createdOn: changeRequest.createdOn, }) await deleteChangeRequestRaw(circularId, requestorSub)