diff --git a/package.json b/package.json index 6dde27fbff..8ce85f0ac5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,11 @@ "@okta/okta-auth-js": "^7.8.0", "@okta/okta-react": "^6.9.0", "@okta/okta-signin-widget": "^7.23.0", + "@tiptap/extension-mention": "^2.9.1", + "@tiptap/pm": "^2.9.1", + "@tiptap/react": "^2.9.1", + "@tiptap/starter-kit": "^2.9.1", + "@tiptap/suggestion": "^2.9.1", "@toast-ui/react-editor": "^3.2.3", "@trussworks/react-uswds": "^3.2.0", "@types/apollo-upload-client": "^17.0.2", @@ -75,6 +80,7 @@ "redux-devtools-extension": "^2.13.9", "redux-saga": "^1.3.0", "redux-saga-routines": "^3.2.2", + "tippy.js": "^6.3.7", "typescript": "^4.9.5", "uuid": "^8.3.2", "wildcard-mock-link": "^2.0.3", @@ -179,7 +185,8 @@ "node-fetch": "2.6.1", "graphql": "15.8.0", "@graphql-typed-document-node/core": "3.2.0", - "@apollo/federation": "0.38.1" + "@apollo/federation": "0.38.1", + "prosemirror-model": "1.23.0" }, "comments": { "on_resolutions": { diff --git a/src/components/MentionTextArea/MentionList.tsx b/src/components/MentionTextArea/MentionList.tsx new file mode 100644 index 0000000000..4570d4b57b --- /dev/null +++ b/src/components/MentionTextArea/MentionList.tsx @@ -0,0 +1,111 @@ +/* MentionList renders the TipTap suggestion dropdown in addition to defining +defining keyboard events */ + +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import Spinner from 'components/Spinner'; + +import './index.scss'; + +export const SuggestionLoading = () => { + return ( +
+ +
+ ); +}; + +// Handler dropdown scroll event on keypress +const scrollIntoView = () => { + const selectedElm = document.querySelector('.is-selected'); + selectedElm?.scrollIntoView({ block: 'nearest' }); +}; + +const MentionList = forwardRef((props: any, ref) => { + const { t } = useTranslation('discussionsMisc'); + + const [selectedIndex, setSelectedIndex] = useState(0); + + // Sets the selected mention within the editor props + const selectItem = (index: any) => { + const item = props.items[index]; + + if (item) { + props.command({ + id: item.username, + label: item.displayName, + 'tag-type': item.tagType + }); + } + }; + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items?.length - 1) % props.items?.length + ); + scrollIntoView(); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items?.length); + scrollIntoView(); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + useEffect(() => setSelectedIndex(0), [props.items]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: any }) => { + if (event.key === 'ArrowUp' || (event.shiftKey && event.key === 'Tab')) { + upHandler(); + return true; + } + + if ( + event.key === 'ArrowDown' || + (!event.shiftKey && event.key === 'Tab') + ) { + downHandler(); + return true; + } + + if (event.key === 'Enter') { + enterHandler(); + return true; + } + + return false; + } + })); + + return ( +
+ {props.items?.length ? ( + props.items?.map((item: any, index: any) => ( + + )) + ) : ( +
{t('noResults')}
+ )} +
+ ); +}); + +export default MentionList; diff --git a/src/components/MentionTextArea/index.scss b/src/components/MentionTextArea/index.scss new file mode 100644 index 0000000000..a3ee045c73 --- /dev/null +++ b/src/components/MentionTextArea/index.scss @@ -0,0 +1,104 @@ +@use 'uswds-core' as *; + +/* Basic editor styles */ +.tiptap { + border: 1px solid black; + padding: .5rem; + + p { + margin: 0px; + } + + &__readonly { + margin-bottom: 1rem; + + .ProseMirror { + outline: none; + border: none; + padding: 0; + } + + &.notification__content { + p { + quotes: "“" "”"; + + &:first-child::before { + content: open-quote; + } + + &:last-child::after { + content: close-quote; + } + + span.react-renderer.node-mention { + & ~ .ProseMirror-trailingBreak { + display: none; + } + } + } + } + } + + &__editable { + .ProseMirror { + min-height: 155px; + font-size: 16px; + line-height: 22px; + } + } +} + +[data-tippy-root] { + width: 99.7%; + margin-left: .1rem !important; +} + +.tippy-box { + max-width: none !important; +} + +.mention { + color: #005EA2; + border: none; + background-color: transparent; + padding: 0; +} + +.text-base-darker { + .mention { + color: color($theme-color-base-darker); + } +} + +.text-base-darkest { + .mention { + color: color($theme-color-base-darkest); + } +} + +.items { + position: relative; + background: #FFF; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1); + border: 1px solid color($theme-color-base-lighter); + padding: 0; + max-height: 300px; + overflow: auto; +} + +.item { + display: block; + border: none; + margin: 0; + width: 100%; + text-align: left; + background: transparent; + padding-top: 0.65rem; + padding-bottom: 0.65rem; + border-bottom: 1px solid color($theme-color-base-lighter); + min-width: 475px; + + &.is-selected { + background-color: #d9e8f6; + } +} diff --git a/src/components/MentionTextArea/index.tsx b/src/components/MentionTextArea/index.tsx new file mode 100644 index 0000000000..3ccd15bdb4 --- /dev/null +++ b/src/components/MentionTextArea/index.tsx @@ -0,0 +1,172 @@ +import React, { useState } from 'react'; +// import { useTranslation } from 'react-i18next'; +import Mention from '@tiptap/extension-mention'; +import { + EditorContent, + NodeViewWrapper, + ReactNodeViewRenderer, + useEditor +} from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import classNames from 'classnames'; + +// import { sortBy } from 'lodash'; +import Alert from 'components/shared/Alert'; + +import suggestion from './suggestion'; +import { getMentions } from './util'; + +import './index.scss'; + +/* The rendered Mention after selected from MentionList +This component can be any react jsx component, but must be wrapped in +Attrs of selected mention are accessed through node prop */ +const MentionComponent = ({ node }: { node: any }) => { + const { label } = node.attrs; + + // Label may return null if the text was truncated by + // In this case don't render the mention, and shift the line up by the height of the non-rendered label + if (!label) { + return
; + } + + return ( + + {`@${label}`} + + ); +}; + +/* Extended TipTap Mention class with additional attributes +Additionally sets a addNodeView to render custo JSX as mention */ +const CustomMention = Mention.extend({ + atom: true, + selectable: true, + addAttributes() { + return { + ...this.parent?.(), + 'data-id-db': { + default: '' + }, + 'tag-type': { + default: '' + } + }; + }, + addNodeView() { + return ReactNodeViewRenderer(MentionComponent); + } +}); + +const MentionTextArea = ({ + id, + setFieldValue, + editable, + disabled, + initialContent, + className +}: { + id: string; + setFieldValue?: ( + field: string, + value: any, + shouldValidate?: boolean | undefined + ) => void; + editable?: boolean; + disabled?: boolean; + initialContent?: any; + className?: string; +}) => { + // const { t } = useTranslation(''); + + const [tagAlert, setTagAlert] = useState(false); + + const fetchUsers = ({ query }: { query: string }) => { + return [ + { username: 'a', displayName: 'Admin lead', tagType: 'other' }, + { + username: 'b', + displayName: 'Governance Admin Team', + tagType: 'other' + }, + { + username: 'c', + displayName: 'Governance Review Board (GRB)', + tagType: 'other' + }, + { + username: 'OSYC', + displayName: 'Grant Eliezer', + tagType: 'user' + }, + { + username: 'MKCK', + displayName: 'Forest Brown', + tagType: 'user' + }, + { + username: 'PJEA', + displayName: 'Janae Stokes', + tagType: 'user' + } + ]; + }; + + const editor = useEditor( + { + editable: editable && !disabled, + editorProps: { + attributes: { + id + } + }, + extensions: [ + StarterKit, + CustomMention.configure({ + HTMLAttributes: { + class: 'mention' + }, + suggestion: { + ...suggestion, + items: fetchUsers + } + }) + ], + onUpdate: ({ editor: input }: any) => { + // Uses the form setter prop (Formik) for mutation input + if (setFieldValue) { + setFieldValue('content', input?.getHTML()); + } + }, + // Sets a alert of a mention is selected, and users/teams will be emailed + onSelectionUpdate: ({ editor: input }: any) => { + setTagAlert(!!getMentions(input?.getJSON()).length); + }, + content: initialContent + }, + [initialContent, disabled] + ); + + return ( + <> + + + {tagAlert && editable && ( + + {/* t() */} + When you save your discussion, the selected team(s) and individual(s) + will be notified via email. + + )} + + ); +}; + +export default MentionTextArea; diff --git a/src/components/MentionTextArea/suggestion.ts b/src/components/MentionTextArea/suggestion.ts new file mode 100644 index 0000000000..bd74109404 --- /dev/null +++ b/src/components/MentionTextArea/suggestion.ts @@ -0,0 +1,129 @@ +import { ReactRenderer } from '@tiptap/react'; +import tippy from 'tippy.js'; + +import MentionList, { SuggestionLoading } from './MentionList'; + +/* Returns the current textarea/RTE editor dimension to append the Mentionslist dropdown +MentionList should have the same width as this parent clientRect */ +const getClientRect = (props: any) => { + const editorID = props.editor.options.editorProps.attributes.id; + const elem = document.getElementById(editorID); + const rect = elem?.getBoundingClientRect(); + const mentionRect = props.clientRect(); + + return () => + new DOMRect( + rect?.left, + mentionRect.y, + mentionRect.width, + mentionRect.height + ); +}; + +const suggestion = { + allowSpaces: true, + render: () => { + let reactRenderer: any; + let spinner: any; + let popup: any; + + return { + // If we had async initial data - load a spinning symbol until onStart gets called + // We have hardcoded in memory data for current implementation, doesn't currently get called + onBeforeStart: (props: any) => { + const editorID = props.editor.options.editorProps.attributes.id; + + if (!props.clientRect) { + return; + } + + reactRenderer = new ReactRenderer(SuggestionLoading, { + props, + editor: props.editor + }); + + spinner = tippy('body', { + getReferenceClientRect: getClientRect(props), + appendTo: () => document.getElementById(editorID) || document.body, + content: reactRenderer.element, + showOnCreate: true, + interactive: false, + trigger: 'manual', + placement: 'bottom-start' + }); + }, + + // Render any available suggestions when mention trigger is first called - @ + onStart: (props: any) => { + const editorID = props.editor.options.editorProps.attributes.id; + + if (!props.clientRect) { + return; + } + + spinner[0].hide(); + + reactRenderer = new ReactRenderer(MentionList, { + props, + editor: props.editor + }); + + popup = tippy('body', { + getReferenceClientRect: getClientRect(props), + appendTo: () => document.getElementById(editorID) || document.body, + content: reactRenderer.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start' + }); + }, + + // When async data/suggestions return, hide the spinner and show the updated list + onUpdate(props: any) { + reactRenderer.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: getClientRect(props) + }); + spinner[0].setProps({ + getReferenceClientRect: getClientRect(props) + }); + + spinner[0].hide(); + + popup[0].show(); + }, + + // If a valid character key, render the spinner until onUpdate gets called to rerender updated list + onKeyDown(props: any) { + if (props.event.key === 'Escape') { + popup[0].hide(); + spinner[0].hide(); + + return true; + } + + if (props.event.key.length === 1 || props.event.key === 'Backspace') { + popup[0].hide(); + + spinner[0].show(); + } + + return reactRenderer.ref?.onKeyDown(props); + }, + + onExit() { + popup[0].destroy(); + spinner[0].destroy(); + reactRenderer.destroy(); + } + }; + } +}; + +export default suggestion; diff --git a/src/components/MentionTextArea/util.tsx b/src/components/MentionTextArea/util.tsx new file mode 100644 index 0000000000..3fb38d603f --- /dev/null +++ b/src/components/MentionTextArea/util.tsx @@ -0,0 +1,15 @@ +// Possible Util to extract only mentions from content +// eslint-disable-next-line import/prefer-default-export +export const getMentions = (data: any) => { + const mentions: any = []; + + data?.content?.forEach((para: any) => { + para?.content?.forEach((content: any) => { + if (content?.type === 'mention') { + mentions.push(content?.attrs); + } + }); + }); + + return mentions; +}; diff --git a/src/components/Modal/index.scss b/src/components/Modal/index.scss index 1cd8bcc3a9..29065c585f 100644 --- a/src/components/Modal/index.scss +++ b/src/components/Modal/index.scss @@ -2,33 +2,19 @@ @use 'viewports' as *; .easi-modal { - &__overlay { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-color: rgba(0, 0, 0, 0.6); - z-index: 400; - } - &__has-title svg { color: color('primary'); } &__content { - position: absolute; width: 100%; height: 100%; max-height: 90vh; overflow-y: auto; - background-color: color('white'); top: 50%; left: 50%; transform: translate(-50%, -50%); - z-index: 1; font-size: 1.375em; - line-height: 1.6em; @media screen and (min-width: $tablet) { width: 668px; diff --git a/src/components/Sidepanel/__snapshots__/index.test.tsx.snap b/src/components/Sidepanel/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..58a6312b2b --- /dev/null +++ b/src/components/Sidepanel/__snapshots__/index.test.tsx.snap @@ -0,0 +1,72 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Sidepanel > matches snapshot 1`] = ` + +
+
+
+
+ +
+
+ +`; diff --git a/src/components/Sidepanel/index.scss b/src/components/Sidepanel/index.scss new file mode 100644 index 0000000000..db25ab91fe --- /dev/null +++ b/src/components/Sidepanel/index.scss @@ -0,0 +1,31 @@ +@use 'uswds-core' as *; +@use 'viewports' as *; + +.easi-sidepanel { + &__content { + width: 100%; + height: auto; + min-height: 100%; + right: 0; + + @media screen and (min-width: $desktop) { + width: 50%; + } + } + + &__x-button-container { + width: 100%; + box-shadow: 0px .25rem .5rem rgba(0, 0, 0, 0.1); + padding: 1rem; + } + + &__x-button { + background: none; + border: 0; + line-height: 0; + + &:hover { + cursor: pointer; + } + } +} diff --git a/src/components/Sidepanel/index.test.tsx b/src/components/Sidepanel/index.test.tsx new file mode 100644 index 0000000000..824f698fe5 --- /dev/null +++ b/src/components/Sidepanel/index.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import Modal from 'react-modal'; +import { render, waitFor } from '@testing-library/react'; + +import Sidepanel from '.'; + +describe('Sidepanel', () => { + beforeAll(() => { + Modal.setAppElement(document.body); + }); + + it('renders without errors', async () => { + const { getByText, getByTestId } = render( + {}} + isOpen + modalHeading="modalHeading" + testid="testid" + > +
children
+
, + { container: document.body } + ); + + expect(getByTestId('testid')).toBeInTheDocument(); + expect(getByText('modalHeading')).toBeInTheDocument(); + }); + + it('matches snapshot', async () => { + const { asFragment, getByText, getByTestId } = render( + {}} + isOpen + modalHeading="modalHeading" + testid="testid" + > +
children
+
, + { container: document.body } + ); + + await waitFor(() => { + expect(getByTestId('testid')).toBeInTheDocument(); + expect(getByText('modalHeading')).toBeInTheDocument(); + }); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/components/Sidepanel/index.tsx b/src/components/Sidepanel/index.tsx new file mode 100644 index 0000000000..cbc4834b11 --- /dev/null +++ b/src/components/Sidepanel/index.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import ReactModal from 'react-modal'; +import { Icon } from '@trussworks/react-uswds'; +import classNames from 'classnames'; +import noScroll from 'no-scroll'; + +import './index.scss'; + +type SidepanelProps = { + ariaLabel: string; + children: React.ReactNode | React.ReactNodeArray; + classname?: string; + closeModal: () => void; + isOpen: boolean; + modalHeading: string; + openModal?: () => void; + testid: string; +}; + +const Sidepanel = ({ + ariaLabel, + children, + classname, + closeModal, + isOpen, + modalHeading, + openModal, + testid +}: SidepanelProps) => { + const handleOpenModal = () => { + noScroll.on(); + if (openModal) { + openModal(); + } + }; + + return ( + +
+
+ +

{modalHeading}

+
+ + {children} +
+
+ ); +}; + +export default Sidepanel; diff --git a/src/stylesheets/custom.scss b/src/stylesheets/custom.scss index 3a74b76e2d..cdc4f36ecf 100644 --- a/src/stylesheets/custom.scss +++ b/src/stylesheets/custom.scss @@ -263,3 +263,23 @@ .bg-green-5 { background-color: $green-5; } + +// Modal and Sidepanel shared styles +.easi-modal, .easi-sidepanel { + &__overlay { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.6); + z-index: 400; + } + + &__content { + position: absolute; + background-color: #fff; + z-index: 1; + line-height: 1.6em; + } +} diff --git a/src/views/DiscussionBoard/DiscussionModalWrapper.tsx b/src/views/DiscussionBoard/DiscussionModalWrapper.tsx new file mode 100644 index 0000000000..2273b81fe0 --- /dev/null +++ b/src/views/DiscussionBoard/DiscussionModalWrapper.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Grid, GridContainer } from '@trussworks/react-uswds'; + +import Sidepanel from 'components/Sidepanel'; + +type DiscussionModalWrapperProps = { + isOpen: boolean; + closeModal: () => void; + children: React.ReactNode; +}; + +const DiscussionModalWrapper = ({ + isOpen, + closeModal, + children +}: DiscussionModalWrapperProps) => { + const { t } = useTranslation('discussions'); + + return ( + + + {children} + + + ); +}; + +export default DiscussionModalWrapper; diff --git a/src/views/DiscussionBoard/index.scss b/src/views/DiscussionBoard/index.scss new file mode 100644 index 0000000000..a46b6df997 --- /dev/null +++ b/src/views/DiscussionBoard/index.scss @@ -0,0 +1,37 @@ +@use 'uswds-core' as *; +@use 'viewports' as *; + +.easi-discussions { + &__body { + padding: 4rem 2rem 2rem; + } + + &__connected { + border-left: .25rem solid color('base-lightest'); + margin-left: .9rem; + padding-left: 1.4rem; + margin-top: 0.5rem; + } + + &__not-connected { + padding-left: 2.6rem; + } + + &__single-discussion:last-of-type { + margin-bottom: -1rem; + margin-top: -.5rem; + } +} + +.no-button > .usa-accordion__heading > .usa-accordion__button { + background-size: 0rem !important; +} + +.discussion-accordion > .usa-accordion__content { + padding: 0rem 1rem; + + &:empty { + padding-top: 0; + padding-bottom: 0; + } +} diff --git a/src/views/DiscussionBoard/index.tsx b/src/views/DiscussionBoard/index.tsx new file mode 100644 index 0000000000..65e68ae9c6 --- /dev/null +++ b/src/views/DiscussionBoard/index.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Button, ButtonGroup } from '@trussworks/react-uswds'; + +import MentionTextArea from '../../components/MentionTextArea'; + +import DiscussionModalWrapper from './DiscussionModalWrapper'; + +type DiscussionBoardProps = { + isOpen: boolean; + closeModal: () => void; + id: string; +}; + +function DiscussionBoard({ isOpen, closeModal, id }: DiscussionBoardProps) { + return ( + + {/* Question */} +

+ Start a discussion +

+

+ Have a question or comment that you want to discuss internally with the + Governance Admin Team or other Governance Review Board (GRB) members + involved in this request? Start a discussion and you’ll be notified when + they reply. +

+
+ +
+ + + + +
+ ); +} + +export default DiscussionBoard; diff --git a/src/views/GovernanceReviewTeam/GRBReview/index.tsx b/src/views/GovernanceReviewTeam/GRBReview/index.tsx index 5574121ba2..3a327e28e8 100644 --- a/src/views/GovernanceReviewTeam/GRBReview/index.tsx +++ b/src/views/GovernanceReviewTeam/GRBReview/index.tsx @@ -35,6 +35,7 @@ import { GRBReviewFormAction } from 'types/grbReview'; import { formatDateLocal } from 'utils/date'; import DocumentsTable from 'views/SystemIntake/Documents/DocumentsTable'; +import DiscussionBoard from '../../DiscussionBoard'; import ITGovAdminContext from '../ITGovAdminContext'; import GRBReviewerForm from './GRBReviewerForm'; @@ -130,6 +131,8 @@ const GRBReview = ({ [history, isForm, id, mutate, showMessage, t] ); + const [isDiscussionOpen, setIsDiscussionOpen] = useState(false); + return ( <> { @@ -363,6 +366,17 @@ const GRBReview = ({ +
+ +
+ setIsDiscussionOpen(false)} + id="grb-discussion" + /> +