Skip to content

Commit

Permalink
Add Markdown editing and rendering support for Circulars
Browse files Browse the repository at this point in the history
This is currently hidden behind a feature flag until we have
documentation and announcements for it.
  • Loading branch information
lpsinger committed Feb 14, 2024
1 parent 4a808a3 commit 99a1b23
Show file tree
Hide file tree
Showing 19 changed files with 817 additions and 127 deletions.
10 changes: 9 additions & 1 deletion app/routes/_gcn.circulars.$circularId.($version)/Body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { rehypeAstro } from '@nasa-gcn/remark-rehype-astro'
import classNames from 'classnames'
import type { Root } from 'mdast'
import { Fragment, createElement } from 'react'
import rehypeClassNames from 'rehype-class-names'
import rehypeReact from 'rehype-react'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { type Plugin, unified } from 'unified'
Expand Down Expand Up @@ -50,9 +52,15 @@ export function MarkdownBody({
}) {
const { result } = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeAstro)
.use(rehypeAutolinkLiteral)
.use(rehypeClassNames, {
ol: 'usa-list',
p: 'usa-paragraph',
table: 'usa-table',
ul: 'usa-list',
})
.use(rehypeReact, {
Fragment,
createElement,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
}

& pre,
&code {
& code {
margin: 0;
}
}
10 changes: 7 additions & 3 deletions app/routes/_gcn.circulars.$circularId.($version)/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import invariant from 'tiny-invariant'

import type { loader as parentLoader } from '../_gcn.circulars.$circularId/route'
import { get } from '../_gcn.circulars/circulars.server'
import { PlainTextBody } from './Body'
import { MarkdownBody, PlainTextBody } from './Body'
import { FrontMatter } from './FrontMatter'
import DetailsDropdownButton from '~/components/DetailsDropdownButton'
import DetailsDropdownContent from '~/components/DetailsDropdownContent'
Expand Down Expand Up @@ -60,9 +60,13 @@ export const headers: HeadersFunction = ({ loaderHeaders }) =>
pickHeaders(loaderHeaders, ['Link'])

export default function () {
const { circularId, body, bibcode, version, ...frontMatter } =
const { circularId, body, bibcode, version, format, ...frontMatter } =
useLoaderData<typeof loader>()
const searchString = useSearchString()
const Body =
useFeature('CIRCULARS_MARKDOWN') && format === 'text/markdown'
? MarkdownBody
: PlainTextBody

const result = useRouteLoaderData<typeof parentLoader>(
'routes/_gcn.circulars.$circularId'
Expand Down Expand Up @@ -133,7 +137,7 @@ export default function () {
</ButtonGroup>
<h1 className="margin-bottom-0">GCN Circular {circularId}</h1>
<FrontMatter {...frontMatter} />
<PlainTextBody className="margin-y-2">{body}</PlainTextBody>
<Body className="margin-y-2">{body}</Body>
</>
)
}
Expand Down
21 changes: 18 additions & 3 deletions app/routes/_gcn.circulars._archive._index/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import clamp from 'lodash/clamp'
import { useId, useState } from 'react'

import { getUser } from '../_gcn._auth/user.server'
import {
type CircularFormat,
circularFormats,
} from '../_gcn.circulars/circulars.lib'
import {
circularRedirect,
get,
Expand All @@ -38,6 +42,7 @@ import CircularsHeader from './CircularsHeader'
import CircularsIndex from './CircularsIndex'
import { DateSelector } from './DateSelectorMenu'
import Hint from '~/components/Hint'
import { feature } from '~/lib/env.server'
import { getFormDataString } from '~/lib/utils'

import searchImg from 'nasawds/src/img/usa-icons-bg/search--white.svg'
Expand Down Expand Up @@ -67,23 +72,33 @@ export async function action({ request }: ActionFunctionArgs) {
const data = await request.formData()
const body = getFormDataString(data, 'body')
const subject = getFormDataString(data, 'subject')
let format
if (feature('CIRCULARS_MARKDOWN')) {
format = getFormDataString(data, 'format') as CircularFormat | undefined
if (format && !circularFormats.includes(format)) {
throw new Response('Invalid format', { status: 400 })
}
} else {
format = undefined
}
if (!body || !subject)
throw new Response('Body and subject are required', { status: 400 })
const user = await getUser(request)
const circularId = getFormDataString(data, 'circularId')

let result
const props = { body, subject, ...(format ? { format } : {}) }
if (circularId) {
await putVersion(
{
body,
circularId: parseFloat(circularId),
subject,
...props,
},
user
)
result = await get(parseFloat(circularId))
} else {
result = await put({ subject, body, submittedHow: 'web' }, user)
result = await put({ submittedHow: 'web', ...props }, user)
}
return result
}
Expand Down
91 changes: 58 additions & 33 deletions app/routes/_gcn.circulars.edit.$circularId/CircularEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,27 @@ import {
Textarea,
} from '@trussworks/react-uswds'
import classnames from 'classnames'
import type { ReactNode } from 'react'
import { useContext, useState } from 'react'
import { type ReactNode, useContext, useState } from 'react'
import { dedent } from 'ts-dedent'

import { AstroDataContext } from '../_gcn.circulars.$circularId.($version)/AstroDataContext'
import {
MarkdownBody,
PlainTextBody,
} from '../_gcn.circulars.$circularId.($version)/Body'
import { bodyIsValid, subjectIsValid } from '../_gcn.circulars/circulars.lib'
import {
type CircularFormat,
bodyIsValid,
subjectIsValid,
} from '../_gcn.circulars/circulars.lib'
import { RichEditor } from './RichEditor'
import {
SegmentedRadioButton,
SegmentedRadioButtonGroup,
} from './SegmentedRadioButton'
import { CircularsKeywords } from '~/components/CircularsKeywords'
import Spinner from '~/components/Spinner'
import { useFeature } from '~/root'

function SyntaxExample({
label,
Expand Down Expand Up @@ -107,13 +112,15 @@ export function CircularEditForm({
formattedContributor,
circularId,
submitter,
defaultFormat,
defaultBody,
defaultSubject,
searchString,
}: {
formattedContributor: string
circularId?: number
submitter?: string
defaultFormat?: CircularFormat
defaultBody: string
defaultSubject: string
searchString: string
Expand All @@ -133,6 +140,8 @@ export function CircularEditForm({
const [showPreview, setShowPreview] = useState(false)
const sending = Boolean(useNavigation().formData)
const valid = subjectValid && bodyValid
const bodyPlaceholder = useBodyPlaceholder()

return (
<AstroDataContext.Provider value={{ rel: 'noopener', target: '_blank' }}>
<h1>{circularId ? 'Edit' : 'New'} GCN Circular</h1>
Expand Down Expand Up @@ -205,36 +214,52 @@ export function CircularEditForm({
<label hidden htmlFor="body">
Body
</label>
<SegmentedRadioButtonGroup>
<SegmentedRadioButton
defaultChecked
onClick={() => setShowPreview(false)}
>
Edit
</SegmentedRadioButton>
<SegmentedRadioButton onClick={() => setShowPreview(true)}>
Preview
</SegmentedRadioButton>
</SegmentedRadioButtonGroup>
<Textarea
hidden={showPreview}
name="body"
id="body"
aria-describedby="bodyDescription"
placeholder={useBodyPlaceholder()}
defaultValue={defaultBody}
required={true}
className={classnames('maxw-full', {
'usa-input--success': bodyValid,
})}
onChange={({ target: { value } }) => {
setBody(value)
}}
/>
{showPreview && (
<PlainTextBody className="border padding-1 margin-top-1">
{body}
</PlainTextBody>
{useFeature('CIRCULARS_MARKDOWN') ? (
<RichEditor
aria-describedby="bodyDescription"
placeholder={bodyPlaceholder}
defaultValue={defaultBody}
defaultMarkdown={defaultFormat === 'text/markdown'}
required={true}
className={bodyValid ? 'usa-input--success' : undefined}
onChange={({ target: { value } }) => {
setBody(value)
}}
/>
) : (
<>
<SegmentedRadioButtonGroup>
<SegmentedRadioButton
defaultChecked
onClick={() => setShowPreview(false)}
>
Edit
</SegmentedRadioButton>
<SegmentedRadioButton onClick={() => setShowPreview(true)}>
Preview
</SegmentedRadioButton>
</SegmentedRadioButtonGroup>
<Textarea
hidden={showPreview}
name="body"
id="body"
aria-describedby="bodyDescription"
placeholder={bodyPlaceholder}
defaultValue={defaultBody}
required={true}
className={classnames('maxw-full', {
'usa-input--success': bodyValid,
})}
onChange={({ target: { value } }) => {
setBody(value)
}}
/>
{showPreview && (
<PlainTextBody className="border padding-1 margin-top-1">
{body}
</PlainTextBody>
)}
</>
)}
<div className="text-base margin-bottom-1" id="bodyDescription">
<small>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*!
* Copyright © 2023 United States Government as represented by the
* Administrator of the National Aeronautics and Space Administration.
* All Rights Reserved.
*
* SPDX-License-Identifier: Apache-2.0
*/

.icon {
display: inline-block;
color: inherit;
background-color: currentcolor;
mask-position: center;
mask-repeat: no-repeat;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*!
* Copyright © 2023 United States Government as represented by the
* Administrator of the National Aeronautics and Space Administration.
* All Rights Reserved.
*
* SPDX-License-Identifier: Apache-2.0
*/
import classNames from 'classnames'

import styles from './GitLabIcon.module.css'

export function GitLabIcon({
src,
className,
style,
...props
}: { src: string } & JSX.IntrinsicElements['span']) {
return (
<span
style={{
maskImage: `url(${src})`,
...style,
}}
className={classNames(styles.icon, className)}
{...props}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*!
* Copyright © 2023 United States Government as represented by the
* Administrator of the National Aeronautics and Space Administration.
* All Rights Reserved.
*
* SPDX-License-Identifier: Apache-2.0
*/

.tabs {
:global(.usa-button-group__item) {
:global(.usa-button) {
box-shadow: 0px 0px 0px 0px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border: 2px solid;
padding-bottom: 14px;
width: auto;

&:not(.checked) {
margin-top: 2px;
}

&.checked {
padding-top: 14px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-color: transparent;
}
}

&:last-child :global(.usa-button) {
margin-left: -1px;
}
}
}
52 changes: 52 additions & 0 deletions app/routes/_gcn.circulars.edit.$circularId/RichEditor/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*!
* Copyright © 2023 United States Government as represented by the
* Administrator of the National Aeronautics and Space Administration.
* All Rights Reserved.
*
* SPDX-License-Identifier: Apache-2.0
*/
import { Button, ButtonGroup } from '@trussworks/react-uswds'
import classNames from 'classnames'
import { type ReactElement } from 'react'

import { ExclusiveOptionGroup, useExclusiveOption } from './exclusiveGroup'

import styles from './Tabs.module.css'

export function Tab({
defaultChecked,
className,
onClick,
...props
}: { defaultSelected?: boolean } & Omit<
Parameters<typeof Button>[0],
'outline' | 'type'
>) {
const [checked, setChecked] = useExclusiveOption(defaultChecked)
return (
<Button
outline
type="button"
className={classNames(className, { [styles.checked]: checked })}
onClick={(e) => {
setChecked()
onClick?.(e)
}}
{...props}
/>
)
}

export function TabBar({
children,
}: {
children: Iterable<ReactElement<typeof Tab>>
}) {
return (
<ExclusiveOptionGroup>
<ButtonGroup type="segmented" className={styles.tabs}>
{children}
</ButtonGroup>
</ExclusiveOptionGroup>
)
}
Loading

0 comments on commit 99a1b23

Please sign in to comment.