diff --git a/frontend/components/ClubCard.tsx b/frontend/components/ClubCard.tsx index 7734f35c9..d9c621364 100644 --- a/frontend/components/ClubCard.tsx +++ b/frontend/components/ClubCard.tsx @@ -29,7 +29,7 @@ const CardWrapper = styled.div` } ` -const Description = styled.p` +export const Description = styled.p` margin-top: 0.2rem; color: ${CLUBS_GREY_LIGHT}; width: 100%; diff --git a/frontend/components/ClubEditPage.tsx b/frontend/components/ClubEditPage.tsx index be79184dc..183955134 100644 --- a/frontend/components/ClubEditPage.tsx +++ b/frontend/components/ClubEditPage.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import React, { ReactElement, useEffect, useState } from 'react' import { toast, TypeOptions } from 'react-toastify' +import AdminNotesPage from '../components/ClubEditPage/AdminNotesPage' import ApplicationsPage from '../components/ClubEditPage/ApplicationsPage' import ClubEditCard from '../components/ClubEditPage/ClubEditCard' import ClubManagementCard from '../components/ClubEditPage/ClubManagementCard' @@ -25,6 +26,7 @@ import { School, StudentType, Tag, + UserInfo, VisitType, Year, } from '../types' @@ -73,6 +75,7 @@ type ClubFormProps = { tags: Tag[] studentTypes: StudentType[] tab?: string | null + userInfo: UserInfo } const ClubForm = ({ @@ -83,6 +86,7 @@ const ClubForm = ({ tags, studentTypes, clubId, + userInfo, tab, }: ClubFormProps): ReactElement => { const [club, setClub] = useState(null) @@ -369,6 +373,12 @@ const ClubForm = ({ ), disabled: !SHOW_ORG_MANAGEMENT, }, + { + name: 'note', + label: 'Notes', + content: , + disabled: !userInfo.is_superuser, + }, ] } diff --git a/frontend/components/ClubEditPage/AdminNotesCard.tsx b/frontend/components/ClubEditPage/AdminNotesCard.tsx new file mode 100644 index 000000000..e64f2762e --- /dev/null +++ b/frontend/components/ClubEditPage/AdminNotesCard.tsx @@ -0,0 +1,67 @@ +import React, { ReactElement } from 'react' +import styled from 'styled-components' + +import { CardTitle, Description } from '../ClubCard' +import { Card } from '../common' + +const NotesCard = styled(Card)` + border: 1.5px solid gray; + cursor: pointer; + margin: 16px 0px; +` +const OverflowWrapper = styled.div` + width: 100%; + display: table; + table-layout: fixed; +` +const OverflowDescription = styled(Description)` + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + word-break: break-all; + word-wrap: break-word; +` +const NoteInfo = styled.div` + display: flex; + justify-content: space-between; + font-size: 0.75rem; + opacity: 0.75; + user-select: none; +` + +function extractContentFromHtml(html) { + return new DOMParser().parseFromString(html, 'text/html').documentElement + .textContent +} + +export default function AdminNotesCard({ + note, + viewNote, + disabled, +}): ReactElement { + return ( + { + // pass + } + : () => viewNote(note) + } + disabled={disabled} + > + {note.title} + + + {extractContentFromHtml(note.content)} + + + + + {note.creator} +

{new Date(note.created_at).toLocaleDateString()}

+
+
+ ) +} diff --git a/frontend/components/ClubEditPage/AdminNotesPage.tsx b/frontend/components/ClubEditPage/AdminNotesPage.tsx new file mode 100644 index 000000000..4f3a91a7f --- /dev/null +++ b/frontend/components/ClubEditPage/AdminNotesPage.tsx @@ -0,0 +1,368 @@ +import { Field, Form, Formik } from 'formik' +import React, { ReactElement } from 'react' +import styled from 'styled-components' + +import { Club } from '~/types' +import { doApiRequest } from '~/utils' + +import { + ALLBIRDS_GRAY, + CLUBS_GREY, + FOCUS_GRAY, + MEDIUM_GRAY, + WHITE, +} from '../../constants/colors' +import { BORDER_RADIUS, MD, mediaMaxWidth } from '../../constants/measurements' +import { BODY_FONT } from '../../constants/styles' +import { Icon } from '../common' +import { FormStyle, RichTextField, TextField } from '../FormComponents' +import AdminNotesCard from './AdminNotesCard' +import BaseCard from './BaseCard' + +const NotesPage = styled.div` + width: 100%; + height: 100%; + display: flex; + justify-items: space-between; + + ${mediaMaxWidth(MD)} { + flex-direction: column; + } +` + +const NotesSearchBar = styled.div` + margin: 0px 5px; + flex: 0 0 27.5%; + display: flex; + flex-direction: column; +` +const EditNotes = styled.div` + display: flex; + flex-direction: column; + flex: 1 1 auto; + margin-left: 16px; +` +const SelectIcon = styled(Icon)` + cursor: pointer; + color: ${MEDIUM_GRAY}; + opacity: 0.75; + margin-right: 6px !important; +` + +const CollapseButton = styled.div` + flex: 0 0 1.5rem; + height: 70vh; + + ${mediaMaxWidth(MD)} { + position: absolute; + top: 50px; + left: 5px; + height: 0px !important; + } +` + +const FieldWithButton = styled.div` + display: flex; + position: relative; + + &:after { + content: attr(data-time); + position: absolute; + color: gray; + bottom: 0; + right: 0; + font-size: 0.625rem; + } + + ${mediaMaxWidth(MD)} { + flex-direction: column; + margin-bottom: 1rem; + align-items: center; + + &:after { + bottom: -1rem; + } + } +` + +const ControlButton = styled.button` + flex: 0 1 20%; + margin-left: 16px; +` + +const NotesContainer = styled.div` + height: 60vh; + flex: 1 1 auto; + overflow: auto; + position: relative; +` + +const AddNotesFAB = styled.button.attrs({ className: 'button is-primary' })` + position: sticky; + bottom: 0; + border-radius: 50%; + height: 40px; + width: 40px; + float: right; + margin: 8px; + padding: 8px; +` + +const SearchWrapper = styled.div` + position: relative; + margin-bottom: 1.2rem; +` + +const Input = styled.input` + border: 1px solid ${ALLBIRDS_GRAY}; + outline: none; + color: ${CLUBS_GREY}; + width: 100%; + font-size: 1em; + padding: 8px 10px; + background: ${WHITE}; + border-radius: ${BORDER_RADIUS}; + font-family: ${BODY_FONT}; + + &:hover, + &:active, + &:focus { + background: ${FOCUS_GRAY}; + } +` + +const SearchIcon = styled.span` + cursor: pointer; + opacity: 0.75; + padding-top: 6px; + position: absolute; + right: 4px; +` + +type AdminNotesCardProp = { + id: number + title: string + creator: string + content: string + created_at: string + club: string +} + +type AdminNotesPageProp = { + club: Club +} + +export default function AdminNotesPage({ + club, +}: AdminNotesPageProp): ReactElement { + const [isEdit, setIsEdit] = React.useState(true) + const [notes, setNotes] = React.useState([]) + const [currNote, setCurrNote] = React.useState( + null, + ) + const [searchValue, setSearchValue] = React.useState('') + const [hideSearchBar, setHideSearchBar] = React.useState(false) + + const fabRef = React.useRef() + const formikValueRef = React.useRef() + + const viewNote = (note: AdminNotesCardProp) => { + setCurrNote(note) + setIsEdit(false) + } + + const controlClick = () => { + const hasContent = 'content' in formikValueRef.current.values + if (isEdit && hasContent) { + onSave({ ...formikValueRef.current.values, club: club.code }) + } + setIsEdit(!isEdit) + } + + const deleteClick = () => { + if (currNote !== null) { + onDelete(currNote.id) + } + } + + const getNotes = () => { + doApiRequest(`/clubs/${club.code}/adminnotes/?format=json`, { + method: 'GET', + }) + .then((resp) => resp.json()) + .then((response) => setNotes(response)) + } + + const onDelete = (id: number): void => { + doApiRequest(`/clubs/${club.code}/adminnotes/${id}/?format=json`, { + method: 'DELETE', + }) + .then(() => setCurrNote(null)) + .then(() => getNotes()) + } + + const onSave = (object: any): void => { + const newNote = currNote == null + doApiRequest( + `/clubs/${club.code}/adminnotes/${ + newNote ? '' : object.id + '/' + }?format=json`, + { + method: newNote ? 'POST' : 'PUT', + body: (({ club, title, content }) => ({ club, title, content }))( + object, + ), + }, + ) + .then((resp) => resp.json()) + .then((response) => setCurrNote(response)) + .then(() => getNotes()) + } + + React.useEffect(() => { + getNotes() + }, []) + + return ( + + + + + + + + + setSearchValue(e.target.value)} + /> + + + {notes + .filter((note) => + (note.title + note.content) + .toUpperCase() + .includes(searchValue.toUpperCase()), + ) + .map((note, i) => { + return ( + + ) + })} + { + setCurrNote(null) + setIsEdit(!isEdit) + }} + > + + + + + + {hideSearchBar ? ( + setHideSearchBar(false)} + /> + ) : ( + setHideSearchBar(true)} + /> + )} + + + { + // pass + }} + > +
+ + + + + controlClick()} + > + {isEdit ? ( + <> + + Save Notes + + ) : ( + <> + + Edit Notes + + )} + + deleteClick()} + > + + Delete Notes + + + + + +
+
+
+
+
+ ) +} diff --git a/frontend/components/FormComponents.tsx b/frontend/components/FormComponents.tsx index b31f7abd2..77f1047ca 100644 --- a/frontend/components/FormComponents.tsx +++ b/frontend/components/FormComponents.tsx @@ -41,6 +41,7 @@ interface BasicFormField { helpText?: string placeholder?: string noLabel?: boolean + flexAuto?: boolean } /** @@ -107,7 +108,10 @@ function useFieldWrapper( const fieldBody = ( <> -
+

@@ -117,7 +121,10 @@ function useFieldWrapper( ) return ( -

+
{noLabel || (isHorizontal ? (
{fieldLabel}
@@ -179,7 +186,7 @@ export const RichTextField = useFieldWrapper( }, [props.value]) return ( -
+
]} customBlockRenderFunc={blockRendererFunction} /> @@ -302,6 +322,8 @@ export const CreatableMultipleSelectField = useFieldWrapper( }} isMulti creatable + isDisabled={props.disabled} + placeholder={placeholder} value={ initialValues != null ? deserialize != null @@ -500,6 +522,7 @@ export const TextField = useFieldWrapper( value, customHandleChange, readOnly, + flexAuto, ...other } = props diff --git a/frontend/components/common/Card.tsx b/frontend/components/common/Card.tsx index aec82c6fa..7df497a35 100644 --- a/frontend/components/common/Card.tsx +++ b/frontend/components/common/Card.tsx @@ -7,6 +7,7 @@ type CardProps = { hoverable?: boolean background?: string pinned?: boolean + disabled?: boolean } export const CARD_BORDER_RADIUS = '4px' @@ -20,6 +21,9 @@ export const Card = styled.div` ${({ bordered }) => bordered && `border: 1px solid ${BORDER};`} + ${({ disabled }) => + disabled && + `background : rgb(245, 245, 245); opacity: 0.5; cursor: not-allowed !important;`} ${({ hoverable }) => hoverable && `