diff --git a/app/profile/compare/comparePage.js b/app/profile/compare/comparePage.js index 89d41b10a..1f6ada1b8 100644 --- a/app/profile/compare/comparePage.js +++ b/app/profile/compare/comparePage.js @@ -4,13 +4,14 @@ import { useSearchParams } from 'next/navigation' import { Suspense, useEffect, useState } from 'react' +import ErrorDisplay from '../../../components/ErrorDisplay' +import LoadingSpinner from '../../../components/LoadingSpinner' import api from '../../../lib/api-client' -import { prettyId } from '../../../lib/utils' +import { getNoteAuthorIds, getNoteAuthors, prettyId } from '../../../lib/utils' +import CommonLayout from '../../CommonLayout' import Compare from './Compare' + import styles from './Compare.module.scss' -import ErrorDisplay from '../../../components/ErrorDisplay' -import CommonLayout from '../../CommonLayout' -import LoadingSpinner from '../../../components/LoadingSpinner' const addMetadata = (profile, fieldName) => { const localProfile = { ...profile } @@ -111,14 +112,10 @@ function Page() { publication.apiVersion === 1 ? publication.content.title : publication.content.title.value, - authors: - publication.apiVersion === 1 - ? publication.content.authors - : publication.content.authors.value, - authorids: (publication.apiVersion === 1 - ? publication.content.authorids - : publication.content.authorids.value - ).filter((id) => id), + authors: getNoteAuthors(publication, publication.apiVersion !== 1), + authorids: getNoteAuthorIds(publication, publication.apiVersion !== 1).filter( + (id) => id + ), })) } return [] diff --git a/app/profile/edit/page.js b/app/profile/edit/page.js index a51580234..3e2c2c8c3 100644 --- a/app/profile/edit/page.js +++ b/app/profile/edit/page.js @@ -1,15 +1,17 @@ 'use client' +import { useRouter } from 'next/navigation' /* globals promptMessage,promptError: false */ import { useEffect, useState } from 'react' -import { useRouter } from 'next/navigation' +import LoadingSpinner from '../../../components/LoadingSpinner' import ProfileEditor from '../../../components/profile/ProfileEditor' +import useUser from '../../../hooks/useUser' import api from '../../../lib/api-client' import { formatProfileData } from '../../../lib/profiles' -import useUser from '../../../hooks/useUser' +import { getNoteAuthorIds, prettyId } from '../../../lib/utils' import LimitedStateAlert from './LimitedStateAlert' + import styles from './Edit.module.scss' -import LoadingSpinner from '../../../components/LoadingSpinner' export default function Page() { const [profile, setProfile] = useState(null) @@ -23,10 +25,10 @@ export default function Page() { let authorIds let invitation if (note.invitations) { - authorIds = note.content.authorids?.value + authorIds = getNoteAuthorIds(note, true) invitation = note.invitations[0] } else { - authorIds = note.content.authorids + authorIds = getNoteAuthorIds(note, false) // oxlint-disable-next-line prefer-destructuring invitation = note.invitation } @@ -38,6 +40,7 @@ export default function Page() { 'OpenReview.net/Archive/-/Direct_Upload_Revision', 'DBLP.org/-/Record': 'DBLP.org/-/Author_Coreference', [`${process.env.SUPER_USER}/Public_Article/ORCID.org/-/Record`]: `${process.env.SUPER_USER}/Public_Article/-/Author_Removal`, + [`${process.env.SUPER_USER}/Public_Article/DBLP.org/-/Record`]: `${process.env.SUPER_USER}/Public_Article/-/Author_Removal`, } if (!authorIds) { throw new Error(`Note ${noteId} is missing author ids`) @@ -73,6 +76,10 @@ export default function Page() { content: { author_index: { value: matchedIdx[0] }, author_id: { value: '' }, + ...(invitationMap[invitation] === + `${process.env.SUPER_USER}/Public_Article/-/Author_Removal` && { + author_name: { value: prettyId(profileId) }, + }), }, } : { diff --git a/components/DblpPublicationTable.js b/components/DblpPublicationTable.js index 317b7717e..e088e191a 100644 --- a/components/DblpPublicationTable.js +++ b/components/DblpPublicationTable.js @@ -17,28 +17,39 @@ export default function DblpPublicationTable({ orPublicationsImportedByOtherProfile, maxNumberofPublicationsToImport, }) { + const { user } = useUser(true) const [profileIdsRequested, setProfileIdsRequested] = useState([]) - const pubsCouldNotImport = [] // either existing or associated with other profile - const pubsCouldImport = [] - dblpPublications.forEach((dblpPub) => { - const titleMatch = (orPub) => - orPub.title === dblpPub.formattedTitle && - orPub.authorCount === dblpPub.authorCount && - orPub.venue === dblpPub.venue - const existing = orPublications.find(titleMatch) - const existingWithOtherProfile = orPublicationsImportedByOtherProfile.find(titleMatch) - if (existing || existingWithOtherProfile || dblpPub.authorIndex === -1) { - pubsCouldNotImport.push(dblpPub.key) - } else { - pubsCouldImport.push(dblpPub.key) + + const categorizedDblpPublications = dblpPublications.map((dblpPub) => { + const externalIdOrtitleMatch = (orPub) => + orPub.externalId === dblpPub.externalId || + (orPub.title === dblpPub.formattedTitle && + orPub.authorCount === dblpPub.authorCount && + orPub.venue === dblpPub.venue) + const existing = orPublications.find(externalIdOrtitleMatch) + const existingWithOtherProfile = + orPublicationsImportedByOtherProfile.find(externalIdOrtitleMatch) + + return { + ...dblpPub, + existing, + existingWithOtherProfile, + couldNotImport: existing || existingWithOtherProfile || dblpPub.authorIndex === -1, } }) - const allExistInOpenReview = dblpPublications.length === pubsCouldNotImport.length - const allChecked = - dblpPublications.length - pubsCouldNotImport.length === selectedPublications.length - const dblpPublicationsGroupedByYear = groupBy(dblpPublications, (p) => p.year) + const pubsCouldNotImport = categorizedDblpPublications.flatMap((p) => + p.couldNotImport ? [p.key] : [] + ) + const pubsCouldImport = categorizedDblpPublications + .map((p) => p.key) + .filter((key) => !pubsCouldNotImport.includes(key)) + const allExistInOpenReview = categorizedDblpPublications.length === pubsCouldNotImport.length + const allChecked = + categorizedDblpPublications.length - pubsCouldNotImport.length === + selectedPublications.length + const dblpPublicationsGroupedByYear = groupBy(categorizedDblpPublications, (p) => p.year) const toggleSelectAll = (checked) => { if (!checked) { setSelectedPublications([]) @@ -156,13 +167,9 @@ export default function DblpPublicationTable({ ), body: publicationsOfYear.map((publication) => { - const titleMatch = (orPub) => - orPub.title === publication.formattedTitle && - orPub.authorCount === publication.authorCount && - orPub.venue === publication.venue - const existingPublication = orPublications.find(titleMatch) + const existingPublication = publication.existing const existingPublicationOfOtherProfile = - orPublicationsImportedByOtherProfile.find(titleMatch) + publication.existingWithOtherProfile const category = existingPublication ? 'existing-publication' : existingPublicationOfOtherProfile @@ -186,6 +193,7 @@ export default function DblpPublicationTable({ source={publication.source} profileIdsRequested={profileIdsRequested} setProfileIdsRequested={setProfileIdsRequested} + user={user} /> ) }), @@ -217,8 +225,8 @@ const DblpPublicationRow = ({ source, profileIdsRequested, setProfileIdsRequested, + user, }) => { - const { user } = useUser(true) const [error, setError] = useState(null) const [profileMergeStatus, setProfileMergeStatus] = useState(null) const profileMergeInvitationId = `${process.env.SUPER_USER}/Support/-/Profile_Merge` diff --git a/components/EditorComponents/ContentFieldEditor.js b/components/EditorComponents/ContentFieldEditor.js index b623fe259..5c37fc2b3 100644 --- a/components/EditorComponents/ContentFieldEditor.js +++ b/components/EditorComponents/ContentFieldEditor.js @@ -1,5 +1,5 @@ import { useContext, useState } from 'react' -import { get, set } from 'lodash' +import { get, set, unset } from 'lodash' import EditorComponentContext from '../EditorComponentContext' import { Tab, TabList, TabPanel, TabPanels, Tabs } from '../Tabs' import CodeEditorWidget from './CodeEditorWidget' @@ -150,6 +150,10 @@ const JsonEditor = ({ existingFields, onFieldChange }) => { // { value: 'group', description: 'Group ID (Profile Search)' }, // { value: 'profile', description: 'Profile ID (Profile Search)' }, { value: 'group[]', description: 'Group ID Array (Profile Search)' }, + { + value: 'author{}', + description: 'Author Object (Profile Search with Institution)', + }, // { value: 'profile[]', description: 'Profile ID Array (Profile Search)' }, // { value: 'note', description: 'Note ID (Not implemented)' }, // { value: 'note[]', description: 'Note ID Array (Not implemented)' }, @@ -187,7 +191,7 @@ const JsonEditor = ({ existingFields, onFieldChange }) => { }, }, shouldBeShown: (formData) => - !['const', 'file', 'date', 'boolean', 'json'].includes(formData.dataType), + !['const', 'file', 'date', 'boolean', 'json', 'author{}'].includes(formData.dataType), getValue: function (existingValue) { return get(existingValue, this.valuePath) }, @@ -432,12 +436,63 @@ const JsonEditor = ({ existingFields, onFieldChange }) => { }, valuePath: 'value.param.markdown', }, + institution: { + order: 9, + description: 'whether to include institution info', + value: { + param: { + input: 'checkbox', + type: 'boolean', + enum: [{ value: true, description: 'Include institution info' }], + optional: true, + }, + }, + shouldBeShown: (formData) => formData.dataType === 'author{}', + getValue: function (existingValue) { + const hasInstitution = get(existingValue, this.valuePath) + return !!hasInstitution + }, + valuePath: 'value.param.properties.institutions', + }, } const onFormChange = (updatedForm) => { const updatedField = Object.entries(fieldSpecificationsOfJsonField).reduce( (prev, [key, config]) => { - if (config.valuePath) { + if (key === 'dataType' && updatedForm[key] === 'author{}') { + set(prev, config.valuePath, updatedForm[key]) + set(prev, 'value.param.properties', { + fullname: { param: { type: 'string' } }, + username: { param: { type: 'string' } }, + }) + } else if (key === 'institution') { + const includeInstitution = updatedForm[key] + // eslint-disable-next-line no-unused-expressions + includeInstitution + ? set(prev, 'value.param.properties.institutions', { + param: { + type: 'object{}', + properties: { + name: { + param: { + type: 'string', + }, + }, + domain: { + param: { + type: 'string', + }, + }, + country: { + param: { + type: 'string', + }, + }, + }, + }, + }) + : unset(prev, 'value.param.properties.institutions', undefined) + } else if (config.valuePath) { set(prev, config.valuePath, updatedForm[key]) } else { set(prev, key, updatedForm[key]) diff --git a/components/EditorComponents/ProfileSearchWithInstitutionWidget.js b/components/EditorComponents/ProfileSearchWithInstitutionWidget.js new file mode 100644 index 000000000..39abb1c5a --- /dev/null +++ b/components/EditorComponents/ProfileSearchWithInstitutionWidget.js @@ -0,0 +1,618 @@ +/* globals promptError, $: false */ + +import { useCallback, useContext, useEffect, useRef, useState } from 'react' +import { + DndContext, + PointerSensor, + pointerWithin, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { throttle, maxBy } from 'lodash' +import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable' +import useUser from '../../hooks/useUser' +import EditorComponentContext from '../EditorComponentContext' +import styles from '../../styles/components/ProfileSearchWidget.module.scss' +import api from '../../lib/api-client' +import { getProfileName, inflect, isValidEmail } from '../../lib/utils' +import Icon from '../Icon' +import IconButton from '../IconButton' +import MultiSelectorDropdown from '../MultiSelectorDropdown' +import LoadingSpinner from '../LoadingSpinner' +import PaginationLinks from '../PaginationLinks' + +const mapDisplayAuthorsToEditorValue = (displayAuthors, hasInstitutionProperty) => + displayAuthors.map((p) => ({ + username: p.username, + fullname: p.fullname, + ...(hasInstitutionProperty && { + institutions: p.selectedInstitutions.map((institution) => ({ + name: institution.name, + domain: institution.domain, + country: institution.country, + })), + }), + })) + +const getTitle = (profile) => { + if (!profile.content) return null + const latestHistory = + profile.content.history?.find((p) => !p.end) || maxBy(profile.content.history, 'end') + + const title = latestHistory + ? `${latestHistory.position ? `${latestHistory.position}` : ''}${ + latestHistory.institution?.name ? ` at ${latestHistory.institution?.name}` : '' + }${latestHistory.institution?.domain ? ` (${latestHistory.institution?.domain})` : ''}` + : '' + return title +} + +const getCurrentInstitutionOptionsFromProfile = (profile) => { + const institutionOptions = [] + const currentYear = new Date().getFullYear() + profile?.content?.history?.forEach((history) => { + const { institution } = history + if (institutionOptions.find((p) => p.value === institution.domain)) return + if (history.end && history.end < currentYear) return + institutionOptions.push({ + label: `${institution.name} (${institution.domain})`, + value: institution.domain, + name: institution.name, + domain: institution.domain, + country: institution.country, + }) + }) + return institutionOptions +} + +const checkIsInAuthorList = (selectedAuthors, profileToCheck) => { + const profileIds = profileToCheck.content?.names?.map((p) => p.username) ?? [] + return selectedAuthors?.find((p) => profileIds.includes(p.username)) +} + +const Author = ({ + fieldName, + username, + profile, + showArrowButton, + showDragSort, + displayAuthors, + setDisplayAuthors, + allowAddRemove, + allowInstitutionChange, + hasInstitutionProperty, + onChange, + clearError, + hasError, +}) => { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: username, + }) + + const style = { + transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : 'none', + transition, + } + + const increaseAuthorIndex = () => { + const authorIndex = displayAuthors.findIndex((p) => p.username === username) + const updatedValue = [...displayAuthors] + updatedValue.splice(authorIndex, 1) + updatedValue.splice(authorIndex + 1, 0, displayAuthors[authorIndex]) + + setDisplayAuthors(updatedValue) + onChange({ + fieldName, + value: mapDisplayAuthorsToEditorValue(updatedValue, hasInstitutionProperty), + }) + clearError?.() + } + + const handleInstitutionChange = (newValues) => { + const updatedAuthors = displayAuthors.map((p) => { + if (p.username !== username) return p + const selectedInstitutions = p.institutionOptions.filter((option) => + newValues.includes(option.value) + ) + return { + ...p, + selectedInstitutions, + } + }) + setDisplayAuthors(updatedAuthors) + onChange({ + fieldName, + value: mapDisplayAuthorsToEditorValue(updatedAuthors, true), + }) + clearError?.() + } + + if (!profile) return null + + return ( +
+
+ {showDragSort && ( +
+ +
+ )} +
+ + {profile.fullname} + +
+
+ {allowAddRemove && ( + { + const updatedAuthors = displayAuthors.filter((p) => p.username !== username) + + setDisplayAuthors(updatedAuthors) + onChange({ + fieldName, + value: mapDisplayAuthorsToEditorValue( + updatedAuthors, + hasInstitutionProperty + ), + }) + clearError?.() + }} + extraClasses="remove-button" + /> + )} + {showArrowButton && } +
+
+ + {hasInstitutionProperty && ( +
+ p.domain)} + setSelectedValues={handleInstitutionChange} + displayTextFn={(selectedValues) => { + if (!profile.institutionOptions.length) return 'No Active Institution' + return selectedValues.length === 0 + ? `${allowInstitutionChange ? 'Add' : 'No'} Institution` + : `${inflect(selectedValues.length, 'Institution', 'Institutions', true)} added` + }} + optionsDisabled={!allowInstitutionChange} + /> +
+ )} +
+ ) +} + +const ProfileSearchResultRow = ({ + profile, + setProfileSearchResults, + setSearchTerm, + displayAuthors, + setDisplayAuthors, + field, + onChange, + clearError, + hasInstitutionProperty, +}) => { + const fieldName = Object.keys(field ?? {})[0] + + if (!profile) return null + const preferredId = profile.content.names?.find((p) => p.preferred)?.username ?? profile.id + + return ( +
+
+
+ + {preferredId.split(/([^~_0-9]+|[~_0-9]+)/g).map((segment, index) => { + if (/[^~_0-9]+/.test(segment)) { + return ( + + {segment} + + ) + } + return ( + + {segment} + + ) + })} + + {profile?.active ? ( + + ) : ( + + )} +
+
{getTitle(profile)}
+
+
+ {profile.content?.emailsConfirmed?.map((email, index) => ( + {email} + ))} +
+
+ { + const institutionOptions = getCurrentInstitutionOptionsFromProfile(profile) + const updatedAuthors = displayAuthors.concat({ + username: preferredId, + fullname: getProfileName(profile), + institutionOptions, + selectedInstitutions: institutionOptions.length ? [institutionOptions[0]] : [], + }) + setDisplayAuthors(updatedAuthors) + onChange({ + fieldName, + value: mapDisplayAuthorsToEditorValue(updatedAuthors, hasInstitutionProperty), + }) + + clearError?.() + setProfileSearchResults(null) + setSearchTerm('') + }} + /> +
+
+ ) +} + +const ProfileSearchFormAndResults = ({ + displayAuthors, + setDisplayAuthors, + error, + field, + onChange, + clearError, + hasInstitutionProperty, +}) => { + const [searchTerm, setSearchTerm] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [pageNumber, setPageNumber] = useState(null) + const [totalCount, setTotalCount] = useState(0) + const [profileSearchResults, setProfileSearchResults] = useState(null) + const { accessToken } = useUser() + const pageSize = 20 + + // eslint-disable-next-line no-shadow + const searchProfiles = async (searchTerm, pageNumber, showLoadingSpinner = true) => { + const cleanSearchTerm = searchTerm.trim() + let paramKey = 'fullname' + let paramValue = cleanSearchTerm.toLowerCase() + if (isValidEmail(cleanSearchTerm)) { + paramKey = 'email' + } else if (cleanSearchTerm.startsWith('~')) { + paramKey = 'id' + paramValue = cleanSearchTerm + } + + if (showLoadingSpinner) setIsLoading(true) + try { + const results = await api.get( + '/profiles/search', + { + [paramKey]: paramValue, + es: true, + limit: pageSize, + offset: pageSize * (pageNumber - 1), + }, + { accessToken } + ) + setTotalCount(results.count > 200 ? 200 : results.count) + setProfileSearchResults(results.profiles.filter((p) => p.content.emails?.length)) + } catch (apiError) { + promptError(apiError.message) + } + if (showLoadingSpinner) setIsLoading(false) + } + + const displayResults = () => { + if (!profileSearchResults) return null + if (!profileSearchResults.length) + return ( +
+
+ No results found for your search query. +
+
+ ) + + return ( +
+ <> + {profileSearchResults.map((profile, index) => ( + + ))} + + +
+ ) + } + + useEffect(() => { + if (!searchTerm || !pageNumber) return + searchProfiles(searchTerm, pageNumber, false) + }, [pageNumber]) + + useEffect(() => { + if (!profileSearchResults?.length) return + $('[data-toggle="tooltip"]').tooltip() + }, [profileSearchResults]) + + if (isLoading) return + return ( + <> +
{ + e.preventDefault() + searchProfiles(searchTerm, 1) + setPageNumber(null) + }} + > + { + setSearchTerm(e.target.value) + setProfileSearchResults(null) + }} + /> + +
+ + {displayResults()} + + ) +} + +const ProfileSearchWithInstitutionWidget = () => { + const { user, accessToken, isRefreshing } = useUser() + const { + field, + onChange, + value, + error, + error: { index: errorIndex } = {}, + clearError, + } = useContext(EditorComponentContext) + + const reorderOnly = Array.isArray(field?.authors?.value) + const allowAddRemove = !reorderOnly && !field.authors?.value.param.elements // reorder with institution change + const allowInstitutionChange = !reorderOnly + + const hasInstitutionProperty = + field?.authors?.value?.param?.properties?.institutions || // add institution + field?.authors?.value?.param?.elements || // reorder with institution change + (reorderOnly && field.authors.value?.[0]?.institutions) // reorder only institution exists + + const sensors = useSensors(useSensor(PointerSensor), useSensor(TouchSensor)) + const [displayAuthors, setDisplayAuthors] = useState(null) + const displayAuthorsRef = useRef(displayAuthors) + + const handleDragOver = useCallback( + throttle((event) => { + const { active, over } = event + + if (over && active.id !== over.id) { + const currentAuthors = displayAuthorsRef.current + const oldIndex = currentAuthors.findIndex((p) => p.username === active.id) + const newIndex = currentAuthors.findIndex((p) => p.username === over.id) + const updatedValue = arrayMove(currentAuthors, oldIndex, newIndex) + setDisplayAuthors(updatedValue) + } + }, 250), + [] + ) + + const handleDragEnd = () => { + onChange({ + fieldName: 'authors', + value: mapDisplayAuthorsToEditorValue(displayAuthors, hasInstitutionProperty), + }) + clearError?.() + } + + const getProfiles = async (authorIds) => { + try { + const { profiles } = await api.post( + '/profiles/search', + { + ids: authorIds, + }, + { accessToken } + ) + return profiles + } catch (apiError) { + promptError(apiError.message) + return [] + } + } + + const setDisplayAuthorNewEditor = async () => { + const profileResults = await getProfiles([user.profile.id]) + const currentUserProfile = profileResults?.[0] + const institutionOptions = getCurrentInstitutionOptionsFromProfile(currentUserProfile) + const username = user.profile.id + const fullname = getProfileName(currentUserProfile) + const selectedInstitutions = institutionOptions.length ? [institutionOptions[0]] : [] + const authors = [ + { + username, + fullname, + selectedInstitutions, + institutionOptions, + }, + ] + setDisplayAuthors(authors) + onChange({ + fieldName: 'authors', + value: mapDisplayAuthorsToEditorValue(authors, hasInstitutionProperty), + }) + } + + const setDisplayAuthorExistingValue = async () => { + const authorIds = value.map((p) => p.username) + const profileResults = await getProfiles(authorIds) + if (!hasInstitutionProperty) { + setDisplayAuthors(value) + return + } + const authors = value.map((author) => { + const profile = profileResults.find((p) => + p.content.names.find((q) => q.username === author.username) + ) + const institutionOptionsFromProfile = getCurrentInstitutionOptionsFromProfile(profile) + const institutionOptionsFromValue = author.institutions.flatMap((institution) => { + if (institutionOptionsFromProfile.find((p) => p.domain === institution.domain)) + // institution selected before still exist in profile + return [] + return { + label: `${institution.name} (${institution.domain})`, + value: institution.domain, + name: institution.name, + domain: institution.domain, + country: institution.country, + } + }) + + return { + username: author.username, + fullname: author.fullname, + selectedInstitutions: author.institutions, + institutionOptions: [...institutionOptionsFromValue, ...institutionOptionsFromProfile], + } + }) + setDisplayAuthors(authors) + } + + useEffect(() => { + if (isRefreshing) return + if (!value) { + setDisplayAuthorNewEditor() + return + } + setDisplayAuthorExistingValue() + }, [isRefreshing]) + + useEffect(() => { + if (!value?.length) return + $('[data-toggle="tooltip"]').tooltip() + displayAuthorsRef.current = displayAuthors + }, [value, displayAuthors]) + + return ( +
+
+ + p.username) ?? []} + strategy={() => null} + > + {displayAuthors?.map((profile, index) => { + const showArrowButton = + displayAuthors.length !== 1 && index !== displayAuthors.length - 1 + const hasError = errorIndex === index + return ( + 5} + displayAuthors={displayAuthors} + setDisplayAuthors={setDisplayAuthors} + allowAddRemove={allowAddRemove} + allowInstitutionChange={allowInstitutionChange} + hasInstitutionProperty={hasInstitutionProperty} + onChange={onChange} + clearError={clearError} + hasError={hasError} + /> + ) + })} + + +
+ + {allowAddRemove && ( + + )} +
+ ) +} + +export default ProfileSearchWithInstitutionWidget diff --git a/components/MultiSelectorDropdown.js b/components/MultiSelectorDropdown.js index 543846a32..399989ea3 100644 --- a/components/MultiSelectorDropdown.js +++ b/components/MultiSelectorDropdown.js @@ -4,6 +4,7 @@ export default function MultiSelectorDropdown({ selectedValues, setSelectedValues, disabled, + optionsDisabled = false, extraClass = undefined, displayTextFn = undefined, }) { @@ -11,6 +12,7 @@ export default function MultiSelectorDropdown({ const numOptions = allValues.length const handleSelectAllChange = () => { + if (optionsDisabled) return if (selectedValues?.length === numOptions) { setSelectedValues([]) } else { @@ -19,6 +21,7 @@ export default function MultiSelectorDropdown({ } const handleSelectValueChange = (value) => { + if (optionsDisabled) return if (selectedValues?.includes(value)) { setSelectedValues(selectedValues?.filter((v) => v !== value)) } else { @@ -56,6 +59,7 @@ export default function MultiSelectorDropdown({ type="checkbox" checked={selectedValues?.length === numOptions} onChange={(e) => handleSelectAllChange(e.target.value)} + disabled={optionsDisabled} /> Select All @@ -68,6 +72,7 @@ export default function MultiSelectorDropdown({ type="checkbox" checked={selectedValues?.includes(option.value)} onChange={(e) => handleSelectValueChange(e.target.value)} + disabled={optionsDisabled} /> {option.label} diff --git a/components/Note.js b/components/Note.js index a26e6dc99..c19c9412a 100644 --- a/components/Note.js +++ b/components/Note.js @@ -3,7 +3,7 @@ import NoteAuthors, { NoteAuthorsV2 } from './NoteAuthors' import NoteReaders from './NoteReaders' import NoteContent, { NoteContentV2 } from './NoteContent' import Icon from './Icon' -import { prettyId, forumDate, inflect } from '../lib/utils' +import { prettyId, forumDate, inflect, getNoteAuthorIds, getNoteAuthors } from '../lib/utils' import Collapse from './Collapse' import ClientForumDate from './ClientForumDate' @@ -111,6 +111,8 @@ const Note = ({ note, invitation, options }) => { export const NoteV2 = ({ note, options }) => { const privatelyRevealed = options.showPrivateIcon && !note.readers.includes('everyone') const omitContentFields = ['pdf', 'html'].concat(options.omitFields ?? []) + const authorIds = getNoteAuthorIds(note, true) + const authors = getNoteAuthors(note, true) const renderNoteContent = () => { if (!options.showContents || (note.ddate && note.ddate <= Date.now())) return null @@ -170,8 +172,8 @@ export const NoteV2 = ({ note, options }) => { options.customAuthor(note) ) : ( diff --git a/components/NoteActivity.js b/components/NoteActivity.js index 777956516..384e96ab8 100644 --- a/components/NoteActivity.js +++ b/components/NoteActivity.js @@ -6,13 +6,22 @@ import { NoteAuthorsV2 } from './NoteAuthors' import { NoteContentV2 } from './NoteContent' import Collapse from './Collapse' import Icon from './Icon' -import { prettyId, prettyInvitationId, buildNoteTitle } from '../lib/utils' +import { + prettyId, + prettyInvitationId, + buildNoteTitle, + getNoteAuthorIds, + getNoteAuthors, +} from '../lib/utils' dayjs.extend(relativeTime) + export default function NoteActivity({ note, showGroup, showActionButtons }) { const { details } = note const { id, forum, content = {} } = note.note + const authorIds = getNoteAuthorIds(note.note, true) + const authors = getNoteAuthors(note.note, true) let actionDescription if (details.isDeleted) { @@ -81,11 +90,11 @@ export default function NoteActivity({ note, showGroup, showActionButtons }) { - {content.authors && content.authorids && ( + {authors && authorIds && (
diff --git a/components/NoteAuthors.js b/components/NoteAuthors.js index 21d6c9789..675b91d0d 100644 --- a/components/NoteAuthors.js +++ b/components/NoteAuthors.js @@ -1,11 +1,11 @@ +import isEqual from 'lodash/isEqual' /* globals $: false */ import uniqBy from 'lodash/uniqBy' -import isEqual from 'lodash/isEqual' import zip from 'lodash/zip' import Link from 'next/link' +import { prettyId } from '../lib/utils' import ExpandableList from './ExpandableList' import Icon from './Icon' -import { prettyId } from '../lib/utils' const maxAuthorsToShow = 20 @@ -96,7 +96,22 @@ const NoteAuthors = ({ authors, authorIds, signatures, original }) => { ) } -export const NoteAuthorsV2 = ({ authors, authorIds, signatures, noteReaders }) => { +export const NoteAuthorsV2 = ({ + authors: authorsProp, + authorIds: authorIdsProp, + signatures, + noteReaders, + showAuthorInstitutions, +}) => { + if (showAuthorInstitutions && !authorIdsProp?.value) { + return + } + + // forum note pass raw authors (for NoteAuthorsWithInstitutions) + // note list pass string array + const authors = authorsProp?.value ?? authorsProp + const authorIds = authorIdsProp?.value ?? authorIdsProp + let showPrivateLabel = false const sortedReaders = noteReaders ? [...noteReaders].sort() : [] if (Array.isArray(authorIds?.readers) && !isEqual(sortedReaders, authorIds.readers.sort())) { @@ -104,8 +119,8 @@ export const NoteAuthorsV2 = ({ authors, authorIds, signatures, noteReaders }) = } let authorsList - if (authors?.value?.length > 0) { - authorsList = zip(authors?.value, authorIds?.value || []) + if (authors?.length > 0) { + authorsList = zip(authors, authorIds || []) } else if (signatures?.length > 0) { authorsList = signatures.map((id) => [prettyId(id), id]) } else { @@ -180,4 +195,102 @@ export const NoteAuthorsV2 = ({ authors, authorIds, signatures, noteReaders }) = ) } +export const NoteAuthorsWithInstitutions = ({ authors, noteReaders }) => { + let showPrivateLabel = false + const sortedReaders = noteReaders ? [...noteReaders].sort() : [] + if (Array.isArray(authors?.readers) && !isEqual(sortedReaders, authors.readers.sort())) { + showPrivateLabel = !authors.readers.includes('everyone') + } + + if (!authors?.value) return null + const uniqueInstitutions = uniqBy( + authors.value + .map((p) => p.institutions) + .flat() + .filter((p) => p?.domain), + (p) => p.domain + ) + + const institutionIndexMap = new Map( + uniqueInstitutions.map((institution, index) => [institution.domain, index + 1]) + ) + + const authorsLinks = authors.value.map((author) => { + if (!author) return null + if (!author.username) return {author.fullname} + if (author.username.startsWith('https://dblp.org')) { + return ( + + {author.fullname} + + ) + } + + const institutionNumbers = (author.institutions || []).map((institution) => + institutionIndexMap.get(institution.domain) + ) + + return ( + + + {author.fullname} + + {institutionNumbers.length > 0 && {institutionNumbers.join(',')}} + + ) + }) + + return ( + <> + + {showPrivateLabel && ( + prettyId(p)) + .join(', ')}`} + /> + )} + + {uniqueInstitutions.length > 0 && ( +
+ {uniqueInstitutions.map((institution) => { + const institutionIndex = institutionIndexMap.get(institution.domain) + return ( +
+ {institutionIndex} {institution.name} ({institution.domain}) +
+ ) + })} +
+ )} + + ) +} + export default NoteAuthors diff --git a/components/NoteEditor.js b/components/NoteEditor.js index d48f85116..3b37e3583 100644 --- a/components/NoteEditor.js +++ b/components/NoteEditor.js @@ -352,7 +352,12 @@ const NoteEditor = ({ })) } - if (fieldName === 'authors' && Array.isArray(fieldDescription?.value)) return null + if ( + fieldName === 'authors' && + Array.isArray(fieldDescription?.value) && + fieldDescription.value.every((p) => typeof p !== 'object') // object author reorder should still show widget + ) + return null return (
@@ -567,7 +572,7 @@ const NoteEditor = ({ formData.authors = noteEditorData.authorids.map((p) => p.authorName) formData.authorids = noteEditorData.authorids.map((p) => p.authorId) } - } else { + } else if (!noteEditorData.authors) { formData.authors = { delete: true } formData.authorids = { delete: true } } @@ -603,12 +608,16 @@ const NoteEditor = ({ if (error.errors) { setErrors( error.errors.map((p) => { - const fieldName = getErrorFieldName(p.details.path) + const { fieldName, index } = getErrorFieldName(p.details.path) const fieldNameInError = fieldName === 'notePDateValue' ? 'Publication Date' : prettyField(fieldName) if (isNonDeletableError(p.details.invalidValue)) - return { fieldName, message: `${fieldNameInError} is not deletable` } - return { fieldName, message: p.message.replace(fieldName, fieldNameInError) } + return { fieldName, message: `${fieldNameInError} is not deletable`, index } + return { + fieldName, + message: p.message.replace(fieldName, fieldNameInError), + index, + } }) ) const hasOnlyMissingFieldsError = error.errors.every( @@ -620,7 +629,7 @@ const NoteEditor = ({ : 'Some info submitted are invalid.' ) } else if (error.details?.path) { - const fieldName = getErrorFieldName(error.details.path) + const { fieldName, index } = getErrorFieldName(error.details.path) const fieldNameInError = fieldName === 'notePDateValue' ? 'Publication Date' : prettyField(fieldName) const prettyErrorMessage = isNonDeletableError(error.details.invalidValue) @@ -630,6 +639,7 @@ const NoteEditor = ({ { fieldName, message: prettyErrorMessage, + index, }, ]) displayError(prettyErrorMessage) diff --git a/components/SourceGroupedNoteList.js b/components/SourceGroupedNoteList.js index eb8048828..07041bf43 100644 --- a/components/SourceGroupedNoteList.js +++ b/components/SourceGroupedNoteList.js @@ -1,16 +1,24 @@ import { useState } from 'react' -import Note, { NoteV2 } from './Note' -import { NoteAuthorsV2 } from './NoteAuthors' -import { buildNoteTitle, buildNoteUrl, prettyId } from '../lib/utils' +import { getImportSourceIcon } from '../lib/profiles' +import { + buildNoteTitle, + buildNoteUrl, + getNoteAuthorIds, + getNoteAuthors, + prettyId, +} from '../lib/utils' import ClientForumDate from './ClientForumDate' import Icon from './Icon' +import Note, { NoteV2 } from './Note' +import { NoteAuthorsV2 } from './NoteAuthors' import NoteReaders from './NoteReaders' -import { getImportSourceIcon } from '../lib/profiles' const MultiSourceNote = ({ notes, displayOptions }) => { const [noteToShow, setNoteToShow] = useState(notes[0]) const { id, forum, content, invitations, readers, signatures } = noteToShow const privatelyRevealed = !noteToShow?.readers?.includes('everyone') + const authorIds = getNoteAuthorIds(noteToShow, true) + const authors = getNoteAuthors(noteToShow, true) const sources = [ 'DBLP.org/-/Record', @@ -32,8 +40,8 @@ const MultiSourceNote = ({ notes, displayOptions }) => {
@@ -93,8 +101,8 @@ const SourceGroupedNoteList = ({ notes, displayOptions }) => { return prev } const title = curr.content.title.value - const authors = curr.content.authors.value.join(',') - const key = `${title}|${authors}` + const authorNames = getNoteAuthors(curr, true).join(',') + const key = `${title}|${authorNames}` if (!prev[key]) { prev[key] = [] diff --git a/components/browser/NoteEntity.js b/components/browser/NoteEntity.js index 586bd9e1e..dce13e65b 100644 --- a/components/browser/NoteEntity.js +++ b/components/browser/NoteEntity.js @@ -2,15 +2,8 @@ /* globals promptError: false */ import React, { useContext } from 'react' -import EdgeBrowserContext from './EdgeBrowserContext' -import EditEdgeDropdown from './EditEdgeDropdown' -import EditEdgeToggle from './EditEdgeToggle' -import NoteAuthors from './NoteAuthors' -import NoteContent from './NoteContent' -import ScoresList from './ScoresList' -import EditEdgeTwoDropdowns from './EditEdgeTwoDropdowns' +import useUser from '../../hooks/useUser' import api from '../../lib/api-client' - import { getInterpolatedValues, getSignatures, @@ -18,7 +11,13 @@ import { isInGroupInvite, isNotInGroupInvite, } from '../../lib/edge-utils' -import useUser from '../../hooks/useUser' +import EdgeBrowserContext from './EdgeBrowserContext' +import EditEdgeDropdown from './EditEdgeDropdown' +import EditEdgeToggle from './EditEdgeToggle' +import EditEdgeTwoDropdowns from './EditEdgeTwoDropdowns' +import NoteAuthors from './NoteAuthors' +import NoteContent from './NoteContent' +import ScoresList from './ScoresList' export default function NoteEntity(props) { const { editInvitations, traverseInvitation, availableSignaturesInvitationMap, version } = @@ -50,6 +49,12 @@ export default function NoteEntity(props) { if (editEdges?.length) extraClasses.push('is-editable') if (props.isSelected) extraClasses.push('is-selected') + let { authors, authorids } = content + + if (!authorids) { + authorids = authors.map((p) => p.username) + authors = authors.map((p) => p.fullname) + } // Event handlers const handleClick = (e) => { if (!props.canTraverse) return @@ -328,8 +333,8 @@ export default function NoteEntity(props) {
diff --git a/components/profile/ImportedPublicationsSection.js b/components/profile/ImportedPublicationsSection.js index 688247c07..d31996758 100644 --- a/components/profile/ImportedPublicationsSection.js +++ b/components/profile/ImportedPublicationsSection.js @@ -97,6 +97,16 @@ const ImportedPublicationsSection = ({ { sort: 'tmdate:desc' } ) .then((notes) => notes.map((note) => ({ ...note, apiVersion: 2 }))) + const v2NotesWithObjectAuthorSchema = await api + .getAll( + '/notes', + { + 'content.authors.username': profileId, + invitations: [`${process.env.SUPER_USER}/Public_Article/DBLP.org/-/Record`], + }, + { sort: 'tmdate:desc' } + ) + .then((notes) => notes.map((note) => ({ ...note, apiVersion: 2 }))) const v1Notes = await api .getAll( '/notes', @@ -108,7 +118,7 @@ const ImportedPublicationsSection = ({ ) .then((notes) => notes.map((note) => ({ ...note, apiVersion: 1 }))) - const allNotes = v2Notes.concat(v1Notes) + const allNotes = v2Notes.concat(v2NotesWithObjectAuthorSchema, v1Notes) setPublications(allNotes) setTotalCount(allNotes.length) } catch (error) { diff --git a/components/webfield/AreaChairConsole.js b/components/webfield/AreaChairConsole.js index 2fa081baf..797abd5ea 100644 --- a/components/webfield/AreaChairConsole.js +++ b/components/webfield/AreaChairConsole.js @@ -1125,6 +1125,13 @@ const AreaChairConsole = ({ appContext }) => { } }) const metaReview = allMetaReviews.find((p) => !p.isByOtherAC) + if (typeof note.content?.authors?.value === 'object' && !note.content?.authorids) { + // eslint-disable-next-line no-param-reassign + note.authorSearchValue = note.content.authors.value.map((p) => ({ + ...p, + type: 'authorObj', + })) + } const customStageReviews = customStageInvitations?.reduce((prev, curr) => { const customStageReview = note.details.replies.find((p) => diff --git a/components/webfield/AreaChairConsoleMenuBar.js b/components/webfield/AreaChairConsoleMenuBar.js index 6d8216333..62415bc59 100644 --- a/components/webfield/AreaChairConsoleMenuBar.js +++ b/components/webfield/AreaChairConsoleMenuBar.js @@ -35,7 +35,11 @@ const AreaChairConsoleMenuBar = ({ number: ['note.number'], id: ['note.id'], title: ['note.content.title.value'], - author: ['note.content.authors.value', 'note.content.authorids.value'], + author: [ + 'note.content.authors.value', + 'note.content.authorids.value', + 'note.authorSearchValue', + ], keywords: ['note.content.keywords.value'], venue: ['note.content.venue.value'], [formattedReviewerName]: ['reviewers'], diff --git a/components/webfield/AuthorConsole.js b/components/webfield/AuthorConsole.js index 6531b0a5b..6f5a49a70 100644 --- a/components/webfield/AuthorConsole.js +++ b/components/webfield/AuthorConsole.js @@ -14,6 +14,7 @@ import { prettyId, inflect, pluralizeString, + getNoteAuthorIds, } from '../../lib/utils' import ErrorDisplay from '../ErrorDisplay' import Table from '../Table' @@ -511,9 +512,8 @@ const AuthorConsole = ({ appContext }) => { const loadProfiles = async (notes, version) => { const authorIds = new Set() notes.forEach((note) => { - const ids = version === 2 ? note.content.authorids.value : note.content.authorids + const ids = getNoteAuthorIds(note, version === 2) if (!Array.isArray(ids)) return - ids.forEach((id) => { if (!id.includes('@')) { authorIds.add(id) diff --git a/components/webfield/EditorWidget.js b/components/webfield/EditorWidget.js index 5e9600375..8e78c74b4 100644 --- a/components/webfield/EditorWidget.js +++ b/components/webfield/EditorWidget.js @@ -50,6 +50,14 @@ const ProfileSearchWidget = dynamic(() => import('../EditorComponents/ProfileSea loading: () => , }) +const ProfileSearchWithInstitutionWidget = dynamic( + () => import('../EditorComponents/ProfileSearchWithInstitutionWidget'), + { + ssr: false, + loading: () => , + } +) + // #endregion const EditorWidget = () => { @@ -117,6 +125,8 @@ const EditorWidget = () => { case 'profile[]': case 'profile{}': return + case 'author{}': + return case 'note': case 'note[]': case 'edit': @@ -133,6 +143,12 @@ const EditorWidget = () => { if (fieldName === 'authorids' && Array.isArray(field.authorids?.value)) return + if ( + fieldName === 'authors' && + (Array.isArray(field.authors?.value) || // reorder only + field.authors?.value?.param?.elements) // reorder with institution change + ) + return if (!field[fieldName].value?.param) { if (!field[fieldName].value && field[fieldName].readers) { return null // TODO: an empty widget which shows only readers diff --git a/components/webfield/EthicsChairConsole/EthicsChairMenuBar.js b/components/webfield/EthicsChairConsole/EthicsChairMenuBar.js index 34151e1cd..ce1f8148b 100644 --- a/components/webfield/EthicsChairConsole/EthicsChairMenuBar.js +++ b/components/webfield/EthicsChairConsole/EthicsChairMenuBar.js @@ -20,7 +20,11 @@ const EthicsChairMenuBar = ({ tableRowsAll, tableRows, setPaperStatusTabData }) number: ['note.number'], id: ['note.id'], title: ['note.content.title.value'], - author: ['note.content.authors.value', 'note.content.authorids.value'], + author: [ + 'note.content.authors.value', + 'note.content.authorids.value', + 'note.authorSearchValue', + ], keywords: ['note.content.keywords.value'], reviewer: ['ethicsReviewers'], numReviewersAssigned: ['numReviewersAssigned'], diff --git a/components/webfield/EthicsChairConsole/EthicsChairPaperStatus.js b/components/webfield/EthicsChairConsole/EthicsChairPaperStatus.js index 10c669df0..99d96911e 100644 --- a/components/webfield/EthicsChairConsole/EthicsChairPaperStatus.js +++ b/components/webfield/EthicsChairConsole/EthicsChairPaperStatus.js @@ -1,19 +1,17 @@ -/* globals promptError: false */ -import { useContext, useEffect, useState } from 'react' import Link from 'next/link' +import { useContext, useEffect, useState } from 'react' import api from '../../../lib/api-client' -import WebFieldContext from '../../WebFieldContext' import { getIndentifierFromGroup, getNumberFromGroup, getProfileName, } from '../../../lib/utils' import LoadingSpinner from '../../LoadingSpinner' - -import Table from '../../Table' import PaginationLinks from '../../PaginationLinks' -import NoteSummary from '../NoteSummary' +import Table from '../../Table' +import WebFieldContext from '../../WebFieldContext' import { EthicsReviewStatus } from '../NoteReviewStatus' +import NoteSummary from '../NoteSummary' import EthicsChairMenuBar from './EthicsChairMenuBar' const EthicsSubmissionRow = ({ rowData }) => { @@ -198,7 +196,13 @@ const EthicsChairPaperStatus = () => { return p.invitations.includes(ethicsMetaReviewInvitationId) }) : null - + if (typeof note.content?.authors?.value === 'object' && !note.content?.authorids) { + // eslint-disable-next-line no-param-reassign + note.authorSearchValue = note.content.authors.value.map((p) => ({ + ...p, + type: 'authorObj', + })) + } return { note, ethicsReviews, diff --git a/components/webfield/NoteMetaReviewStatus.js b/components/webfield/NoteMetaReviewStatus.js index f150bd539..1c8abaf72 100644 --- a/components/webfield/NoteMetaReviewStatus.js +++ b/components/webfield/NoteMetaReviewStatus.js @@ -1,13 +1,13 @@ /* globals promptError,promptMessage: false */ +import copy from 'copy-to-clipboard' // modified from noteMetaReviewStatus.hbs handlebar template import React, { useContext, useEffect, useState } from 'react' -import copy from 'copy-to-clipboard' -import WebFieldContext from '../WebFieldContext' import useUser from '../../hooks/useUser' import api from '../../lib/api-client' -import { inflect, pluralizeString, prettyField } from '../../lib/utils' import { getNoteContentValues } from '../../lib/forum-utils' +import { inflect, pluralizeString, prettyField } from '../../lib/utils' +import WebFieldContext from '../WebFieldContext' import ProfileLink from './ProfileLink' const IEEECopyrightForm = ({ note, isV2Note }) => { @@ -15,13 +15,14 @@ const IEEECopyrightForm = ({ note, isV2Note }) => { useContext(WebFieldContext) const { user, isRefreshing } = useUser(true) const noteContent = isV2Note ? getNoteContentValues(note.content) : note.content + const noteAuthors = getNoteAuthors(note, isV2Note) if (showIEEECopyright && IEEEPublicationTitle && IEEEArtSourceCode && !isRefreshing) { return (
- + diff --git a/components/webfield/NoteSummary.js b/components/webfield/NoteSummary.js index 445c4e16c..b47f6999a 100644 --- a/components/webfield/NoteSummary.js +++ b/components/webfield/NoteSummary.js @@ -1,7 +1,12 @@ /* globals promptError: false */ -import isEqual from 'lodash/isEqual' import { useState } from 'react' -import { forumDate, getNotePdfUrl, isValidEmail } from '../../lib/utils' +import { + forumDate, + getNoteAuthorIds, + getNoteAuthors, + getNotePdfUrl, + isValidEmail, +} from '../../lib/utils' import Collapse from '../Collapse' import Icon from '../Icon' import NoteContent, { NoteContentV2 } from '../NoteContent' @@ -10,14 +15,6 @@ import ExpandableList from '../ExpandableList' import api from '../../lib/api-client' import ProfileLink from './ProfileLink' -const getAuthorsValue = (note, isV2Note) => { - if (isV2Note) return note.content?.authors?.value - const noteAuthors = note.content?.authors - const originalAuthors = note.details?.original?.content?.authors - if (originalAuthors && !isEqual(noteAuthors, originalAuthors)) return originalAuthors - return noteAuthors -} - const NoteSummary = ({ note, referrerUrl, @@ -30,8 +27,8 @@ const NoteSummary = ({ }) => { const titleValue = isV2Note ? note.content?.title?.value : note.content?.title const pdfValue = isV2Note ? note.content?.pdf?.value : note.content?.pdf - const authorsValue = getAuthorsValue(note, isV2Note) - const authorIdsValue = isV2Note ? note.content?.authorids?.value : note.content?.authorids + const authorsValue = getNoteAuthors(note, isV2Note) + const authorIdsValue = getNoteAuthorIds(note, isV2Note) const privatelyRevealed = !note.readers?.includes('everyone') const maxAuthors = 15 diff --git a/components/webfield/ProgramChairConsole.js b/components/webfield/ProgramChairConsole.js index 2e9e0a3f3..1000e3059 100644 --- a/components/webfield/ProgramChairConsole.js +++ b/components/webfield/ProgramChairConsole.js @@ -1475,6 +1475,13 @@ const ProgramChairConsole = ({ appContext, extraTabs = [] }) => { } note.replyCount = replies.length if (useCache) delete note.details?.replies + if (typeof note.content?.authors?.value === 'object' && !note.content?.authorids) { + // eslint-disable-next-line no-param-reassign + note.authorSearchValue = note.content.authors.value.map((p) => ({ + ...p, + type: 'authorObj', + })) + } }) // map reviewer recommendation to ac id to calculate recommendation progress correctly @@ -1797,15 +1804,19 @@ const ProgramChairConsole = ({ appContext, extraTabs = [] }) => { preferredName: profile ? profile.preferredName : reviewer.reviewerProfileId, } }), - authors: note.content?.authorids?.value?.map((authorId, index) => { - const preferredName = note.content.authors?.value?.[index] - return { - preferredId: authorId, - preferredName, - noteNumber: note.number, - anonymizedGroup: authorId, - } - }), + authors: note.content?.authorids?.value + ? note.content.authorids.value.map((authorId, index) => ({ + preferredId: authorId, + preferredName: note.content.authors?.value?.[index], + noteNumber: note.number, + anonymizedGroup: authorId, + })) + : note.content?.authors?.value?.map((author) => ({ + preferredId: author.username, + preferredName: author.fullname, + noteNumber: note.number, + anonymizedGroup: author.username, + })), reviewerProfiles: assignedReviewerProfiles, officialReviews, reviewProgressData: { diff --git a/components/webfield/ProgramChairConsole/PaperStatusMenuBar.js b/components/webfield/ProgramChairConsole/PaperStatusMenuBar.js index c92c05523..d8216baa7 100644 --- a/components/webfield/ProgramChairConsole/PaperStatusMenuBar.js +++ b/components/webfield/ProgramChairConsole/PaperStatusMenuBar.js @@ -49,7 +49,11 @@ const PaperStatusMenuBar = ({ number: ['note.number'], id: ['note.id'], title: ['note.content.title.value'], - author: ['note.content.authors.value', 'note.content.authorids.value'], + author: [ + 'note.content.authors.value', + 'note.content.authorids.value', + 'note.authorSearchValue', + ], keywords: ['note.content.keywords.value'], [formattedReviewerName]: ['reviewers'], ...(formattedSACName && { [formattedSACName]: ['metaReviewData.seniorAreaChairs'] }), diff --git a/components/webfield/ProgramChairConsole/RejectedWithdrawnPapers.js b/components/webfield/ProgramChairConsole/RejectedWithdrawnPapers.js index 9f52d55ea..943deb53f 100644 --- a/components/webfield/ProgramChairConsole/RejectedWithdrawnPapers.js +++ b/components/webfield/ProgramChairConsole/RejectedWithdrawnPapers.js @@ -53,13 +53,29 @@ const RejectedWithdrawnPapers = ({ consoleData, isSacConsole = false }) => { deskRejectedNotes .map((p) => ({ number: p.number, - note: p, + note: { + ...p, + ...(typeof p.content?.authors?.value === 'object' && { + authorSearchValue: p.content.authors.value.map((q) => ({ + ...q, + type: 'authorObj', + })), + }), + }, reason: 'Desk Rejected', })) .concat( withdrawnNotes.map((p) => ({ number: p.number, - note: p, + note: { + ...p, + ...(typeof p.content?.authors?.value === 'object' && { + authorSearchValue: p.content.authors.value.map((q) => ({ + ...q, + type: 'authorObj', + })), + }), + }, reason: 'Withdrawn', })) ) diff --git a/components/webfield/ProgramChairConsole/RejectedWithdrawnPapersMenuBar.js b/components/webfield/ProgramChairConsole/RejectedWithdrawnPapersMenuBar.js index 77a37eb67..63efecbd5 100644 --- a/components/webfield/ProgramChairConsole/RejectedWithdrawnPapersMenuBar.js +++ b/components/webfield/ProgramChairConsole/RejectedWithdrawnPapersMenuBar.js @@ -1,8 +1,8 @@ import { useContext } from 'react' +import { getNoteAuthors, pluralizeString } from '../../../lib/utils' +import WebFieldContext from '../../WebFieldContext' import BaseMenuBar from '../BaseMenuBar' import QuerySearchInfoModal from '../QuerySearchInfoModal' -import WebFieldContext from '../../WebFieldContext' -import { pluralizeString } from '../../../lib/utils' const DeskrejectedWithdrawnPapersMenuBar = ({ tableRowsAll, @@ -17,7 +17,11 @@ const DeskrejectedWithdrawnPapersMenuBar = ({ number: ['number'], id: ['note.id'], title: ['note.content.title', 'note.content.title.value'], - author: ['note.content.authors.value', 'note.content.authorids.value'], + author: [ + 'note.content.authors.value', + 'note.content.authorids.value', + 'note.authorSearchValue', + ], keywords: ['note.content.keywords', 'note.content.keywords.value'], reason: ['reason'], } @@ -33,7 +37,10 @@ const DeskrejectedWithdrawnPapersMenuBar = ({ }, { header: 'authors', - getValue: (p) => p.note?.content?.authors?.value?.join('|') ?? 'N/A', + getValue: (p) => { + const authors = getNoteAuthors(p.note, true) + return authors ? authors.join('|') : 'N/A' + }, }, { header: 'reason', getValue: (p) => p.reason }, ] diff --git a/lib/profiles.js b/lib/profiles.js index 1c6cefaef..7de489581 100644 --- a/lib/profiles.js +++ b/lib/profiles.js @@ -4,7 +4,13 @@ import dayjs from 'dayjs' import _ from 'lodash' import { nanoid } from 'nanoid' import api from './api-client' -import { deburrString, getNameString } from './utils' +import { + deburrString, + getNameString, + getNoteAuthorIds, + getNoteAuthors, + prettyId, +} from './utils' const superUserSignatures = [process.env.SUPER_USER, '~Super_User1'] let paperSearchResults = [] @@ -215,16 +221,15 @@ export function getCoAuthorsFromPublications(profile, notes) { if (!note.content.authors) return const isV2Note = note.version === 2 - const noteAuthorsValue = isV2Note ? note.content.authors?.value : note.content.authors + const noteAuthorsValue = getNoteAuthors(note, isV2Note) const noteAuthors = noteAuthorsValue?.map((a) => a.replace(/\*|\d$/g, '')) + const noteAuthorIds = getNoteAuthorIds(note, isV2Note) for (let i = 0; i < noteAuthors.length; i += 1) { const authorName = noteAuthors[i] allAuthors.add(authorName) - const authorId = isV2Note - ? note.content.authorids?.value && note.content.authorids?.value?.[i] - : note.content.authorids && note.content.authorids[i] + const authorId = noteAuthorIds?.[i] if (authorId) { if ( // ~>email>dblp @@ -314,7 +319,13 @@ function titleNameTransformation(title) { return formattedTitle } -async function searchPublicationTitle(title, authorIndex, authorNames, venue) { +async function searchPublicationTitle( + title, + authorIndex, + authorNames, + venue, + dblpPublicationExternalId +) { const paperDoesNotExist = { paperExistInOpenReview: false, authorNameInAuthorsList: false, @@ -325,17 +336,32 @@ async function searchPublicationTitle(title, authorIndex, authorNames, venue) { return paperDoesNotExist } - // need to check if title is exact match for (let index = 0; index < paperSearchResults.length; index += 1) { const note = paperSearchResults[index] + const existingNoteDblpExternalId = note.externalIds?.find((id) => id.startsWith('dblp:')) + if (existingNoteDblpExternalId && typeof note.content?.authors?.value?.[0] === 'object') { + // openreview paper is object author schema + if (existingNoteDblpExternalId === dblpPublicationExternalId) { + return { + authorIndex: note.content.authors.value.findIndex((author) => + authorNames + .map((authorName) => getNameString(authorName)) + .includes(author.fullname) + ), + paperId: note.id, + dblpPublicationExternalId, + } + } + continue + } + + // need to check if title is exact match const noteTitle = note.version === 2 ? note.content.title?.value : note.content.title if (noteTitle && titleNameTransformation(noteTitle) === title) { // even if titles match, need to check if authorids field already contains // author id at authorIndex and venue also match - const noteAuthorIds = - note.version === 2 ? note.content.authorids?.value : note.content.authorids - const noteAuthors = - note.version === 2 ? note.content.authors?.value : note.content.authors + const noteAuthorIds = getNoteAuthorIds(note, note.version === 2) + const noteAuthors = getNoteAuthors(note, note.version === 2) const noteVenue = note.version === 2 ? note.content.venue?.value : note.content.venue if ( noteAuthorIds && @@ -435,6 +461,7 @@ export async function getDblpPublicationsFromXmlUrl(xmlUrl, profileId, profileNa authorCount: authorPids.length, venue, year: year ?? 'Unknown', + externalId: `dblp:${publicationNode.getAttribute('key')}`, } }), possibleNames: [...possibleNames], @@ -470,7 +497,21 @@ export async function getAllPapersByGroupId(profileId) { venue: note.content.venue.value, })) ) - return v1Notes.concat(v2Notes) + const v2NotesWithObjectAuthorSchema = await api + .getAll('/notes', { + 'content.authors.username': profileId, + invitation: `${process.env.SUPER_USER}/Public_Article/DBLP.org/-/Record`, + }) + .then((notes) => + notes.map((note) => ({ + id: note.id, + title: titleNameTransformation(note.content.title?.value), + authorCount: note.content.authors?.value.length, + venue: note.content.venue.value, + externalId: note.externalIds.find((p) => p.startsWith('dblp:')), + })) + ) + return v1Notes.concat(v2Notes, v2NotesWithObjectAuthorSchema) } catch (error) { throw new Error('Fetching existing publications from OpenReview failed') } @@ -484,8 +525,33 @@ export async function postOrUpdatePaper(dblpPublication, profileId, names) { dblpPublication.formattedTitle, dblpPublication.authorIndex, names, - dblpPublication.venue + dblpPublication.venue, + dblpPublication.externalId ) + if (publicationTitleExistInOpenReview.dblpPublicationExternalId) { + const authorId = + names?.find((p) => dblpPublication.authorNames?.includes(getNameString(p)))?.username ?? + profileId + return api.post('/notes/edits', { + invitation: `${process.env.SUPER_USER}/Public_Article/-/Authorship_Claim`, + signatures: [profileId], + note: { + id: publicationTitleExistInOpenReview.paperId, + }, + content: { + author_index: { + value: publicationTitleExistInOpenReview.authorIndex, + }, + author_id: { + value: authorId, + }, + author_name: { + value: prettyId(authorId), + }, + }, + }) + } + if ( publicationTitleExistInOpenReview.paperExistInOpenReview && publicationTitleExistInOpenReview.authorNameInAuthorsList @@ -508,8 +574,13 @@ export async function postOrUpdatePaper(dblpPublication, profileId, names) { const authorids = Array(dblpPublication.authorNames.length).fill('') authorids[dblpPublication.authorIndex] = tileIdToUpdate + const authors = dblpPublication.authorNames.map((name, index) => ({ + fullname: name, + username: index === dblpPublication.authorIndex ? tileIdToUpdate : undefined, + })) + return api.post('/notes/edits', { - invitation: 'DBLP.org/-/Record', + invitation: `${process.env.SUPER_USER}/Public_Article/DBLP.org/-/Record`, signatures: [profileId], content: { xml: { @@ -520,9 +591,9 @@ export async function postOrUpdatePaper(dblpPublication, profileId, names) { content: { title: { value: dblpPublication.title }, venue: { value: dblpPublication.venue }, - authors: { value: dblpPublication.authorNames }, - authorids: { value: authorids }, + authors: { value: authors }, }, + externalId: dblpPublication.externalId, }, }) } @@ -535,6 +606,13 @@ export async function getAllPapersImportedByOtherProfiles( const dblpPublicationsGroupedByYear = _.groupBy(dblpPublications, (p) => p.year) const searchPapersOfYearPs = Object.entries(dblpPublicationsGroupedByYear).map( ([year, papers]) => [ + api.post('/notes/search', { + invitation: `${process.env.SUPER_USER}/Public_Article/DBLP.org/-/Record`, + content: { + title: { terms: papers.map((p) => `"${p.title}"`) }, + venue: year === 'Unknown' ? undefined : { terms: [year] }, + }, + }), api.post('/notes/search', { invitation: 'DBLP.org/-/Record', content: { @@ -573,7 +651,21 @@ export async function getAllPapersImportedByOtherProfiles( if ( note.content?.title?.value && titleNameTransformation(note.content.title?.value) === publication.title && - note.content?.authorids?.value.length === publication.authorCount && + note.content?.authors?.value?.length === publication.authorCount && + note.content?.venue?.value === publication.venue + ) { + const authorId = note.content.authors.value[publication.authorIndex].username + if ( + authorId && + authorId.startsWith('~') && + !profileNames.some((p) => p.username === authorId) + ) { + return { ...publication, existingProfileId: authorId, noteId: note.id } + } + } else if ( + note.content?.title?.value && + titleNameTransformation(note.content.title?.value) === publication.title && + note.content?.authorids?.value?.length === publication.authorCount && note.content?.venue?.value === publication.venue ) { const authorId = note.content.authorids?.value[publication.authorIndex] @@ -760,6 +852,9 @@ export async function postOrUpdateOrcidPaper(profileId, profileNames, publicatio }) const existingNoteInOpenReview = existingNotesInOpenReview?.[0] if (existingNoteInOpenReview) { + const authorId = + profileNames?.find((p) => publication.authorNames?.includes(getNameString(p))) + ?.username ?? profileId return api.post('/notes/edits', { invitation: `${process.env.SUPER_USER}/Public_Article/-/Authorship_Claim`, signatures: [profileId], @@ -771,19 +866,27 @@ export async function postOrUpdateOrcidPaper(profileId, profileNames, publicatio value: publication.authorIndex, }, author_id: { - value: - profileNames?.find((p) => publication.authorNames?.includes(getNameString(p))) - ?.username ?? profileId, + value: authorId, + }, + author_name: { + value: prettyId(authorId), }, }, }) } // new paper - const authorIds = Array(publication.authorNames.length).fill('') - authorIds[publication.authorIndex] = - profileNames?.find((p) => publication.authorNames?.includes(getNameString(p)))?.username ?? - profileId + const profileNameObj = profileNames?.find((p) => + publication.authorNames?.includes(getNameString(p)) + ) + const authors = publication.authorNames.map((publicationAuthorName, index) => { + const username = profileNameObj?.username ?? profileId + const fullname = profileNameObj?.fullname ?? prettyId(username) + return { + fullname: index === publication.authorIndex ? fullname : publicationAuthorName, + username: index === publication.authorIndex ? username : '', + } + }) return api.post('/notes/edits', { invitation: `${process.env.SUPER_USER}/Public_Article/ORCID.org/-/Record`, signatures: [profileId], @@ -795,8 +898,7 @@ export async function postOrUpdateOrcidPaper(profileId, profileNames, publicatio note: { content: { title: { value: publication.title }, - authors: { value: publication.authorNames }, - authorids: { value: authorIds }, + authors: { value: authors }, venue: { value: publication.venue }, }, externalId: publication.externalId, diff --git a/lib/utils.js b/lib/utils.js index 72e9edac5..70b927808 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1129,7 +1129,7 @@ export function getTitleObjects(docArray, searchTerm) { .map((docObj) => { const contentObj = docObj.content const title = docObj.version === 2 ? contentObj.title?.value : contentObj.title - const authors = docObj.version === 2 ? contentObj.authors?.value : contentObj.authors + const authors = getNoteAuthors(docObj, docObj.version === 2) return { value: isEmpty(title) ? '' : title, forum: docObj?.forum ?? '', @@ -1845,6 +1845,40 @@ export function getTagDispayText(tag, showProfileId = true) { } } +/** + * return authorids from a note, handling v1 note, v2 note array and object format + * + * @param {object} note - the note to get authorids from + * @param {boolean} isV2Note - whether the note is a v2 note + * @returns Array + */ +export function getNoteAuthorIds(note, isV2Note) { + if (!isV2Note) return note.content.authorids + if (!note?.content?.authorids?.value) { + // object author + return note?.content?.authors?.value?.map((p) => p.username) + } + return note?.content?.authorids?.value +} + +/** + * return author names from a note, handling v1 note, v2 note array and object format + * + * @param {object} note - the note to get author names from + * @param {boolean} isV2Note - whether the note is a v2 note + * @returns Array + */ +export function getNoteAuthors(note, isV2Note) { + if (isV2Note) { + if (note.content?.authorids?.value) return note.content?.authors?.value + return note.content?.authors?.value?.map((p) => p.fullname) + } + const noteAuthors = note.content?.authors + const originalAuthors = note.details?.original?.content?.authors + if (originalAuthors && !isEqual(noteAuthors, originalAuthors)) return originalAuthors + return noteAuthors +} + const allowedRedirectPaths = new Set([ '/activity', '/assignments', diff --git a/lib/webfield-utils.js b/lib/webfield-utils.js index 43f4ec878..90f8fe1a9 100644 --- a/lib/webfield-utils.js +++ b/lib/webfield-utils.js @@ -419,12 +419,23 @@ export const evaluateOperator = (operator, propertyValue, targetValue) => { ) return false if (typeof targetValue === 'number' && propertyValue === 'N/A') propertyValue = 0 - if (Array.isArray(propertyValue) && propertyValue.some((p) => p.type === 'profile')) { - propertyValue = [ - ...Object.values(propertyValue).map((p) => p.preferredName), - ...Object.values(propertyValue).map((p) => p.preferredId), - ] - if (operator !== '==') targetValue = targetValue.toString().toLowerCase() + if (Array.isArray(propertyValue)) { + if (propertyValue.some((p) => p.type === 'profile')) { + // eslint-disable-next-line no-param-reassign + propertyValue = [ + ...Object.values(propertyValue).map((p) => p.preferredName), + ...Object.values(propertyValue).map((p) => p.preferredId), + ] + // eslint-disable-next-line no-param-reassign + if (operator !== '==') targetValue = targetValue.toString().toLowerCase() + } else if (propertyValue.some((p) => p.type === 'authorObj')) { + // eslint-disable-next-line no-param-reassign + propertyValue = [ + ...Object.values(propertyValue).map((p) => p.username), + ...Object.values(propertyValue).map((p) => p.fullname), + ] // eslint-disable-next-line no-param-reassign + if (operator !== '==') targetValue = targetValue.toString().toLowerCase() + } } if ( !(typeof propertyValue === 'number' || typeof targetValue === 'number') && @@ -673,26 +684,32 @@ export function getEdgeValue(edge) { * @returns {string} */ export function getErrorFieldName(errorPath) { - if (errorPath === 'signatures') return 'editSignatureInputValues' - if (errorPath === 'note/signatures') return 'noteSignatureInputValues' - if (errorPath === 'readers') return 'editReaderValues' - if (errorPath === 'note/readers') return 'noteReaderValues' - if (errorPath === 'note/license') return 'noteLicenseValue' - if (errorPath === 'note/pdate') return 'notePDateValue' - if (errorPath === 'note/cdate') return 'noteCDateValue' - if (errorPath === 'note/mdate') return 'noteMDateValue' - - if (!errorPath.includes('/')) return errorPath + if (errorPath === 'signatures') return { fieldName: 'editSignatureInputValues' } + if (errorPath === 'note/signatures') return { fieldName: 'noteSignatureInputValues' } + if (errorPath === 'readers') return { fieldName: 'editReaderValues' } + if (errorPath === 'note/readers') return { fieldName: 'noteReaderValues' } + if (errorPath === 'note/license') return { fieldName: 'noteLicenseValue' } + if (errorPath === 'note/pdate') return { fieldName: 'notePDateValue' } + if (errorPath === 'note/cdate') return { fieldName: 'noteCDateValue' } + if (errorPath === 'note/mdate') return { fieldName: 'noteMDateValue' } + + if (!errorPath.includes('/')) return { fieldName: errorPath } const lastToken = errorPath.split('/').pop() if (lastToken === 'value') { const fieldName = errorPath.split('/').slice(-2, -1)[0] - return errorPath.startsWith('note') ? fieldName : `content.${fieldName}` + return errorPath.startsWith('note') ? { fieldName } : { fieldName: `content.${fieldName}` } } if (!Number.isNaN(Number(lastToken))) { const fieldName = errorPath.split('/').slice(-3, -2)[0] - return errorPath.startsWith('note') ? fieldName : `content.${fieldName}` + return errorPath.startsWith('note') ? { fieldName } : { fieldName: `content.${fieldName}` } + } + if (errorPath.startsWith('note/content')) { + const tokens = errorPath.split('/') + const fieldName = tokens[2] + const index = Number(tokens[4]) + return Number.isFinite(index) ? { fieldName, index } : { fieldName } } - return lastToken + return { fieldName: lastToken } } /** diff --git a/styles/components/ProfileSearchWidget.module.scss b/styles/components/ProfileSearchWidget.module.scss index fb86d8b41..4b2cc7dfb 100644 --- a/styles/components/ProfileSearchWidget.module.scss +++ b/styles/components/ProfileSearchWidget.module.scss @@ -20,6 +20,10 @@ border: 0.25rem solid transparent; background-clip: padding-box; + &.invalidValue { + border: 2px solid constants.$orRed; + } + .dragHandle { opacity: 0.3; color: constants.$mediumBlue; @@ -154,3 +158,43 @@ button.invalidValue { .spinnerSmall { height: 34px; } + +.profileSearchWithInstitution { + max-width: 100%; + @media #{constants.$large} { + max-width: 80%; + } + .selectedAuthor { + display: flex; + flex-direction: column; + align-items: stretch; + } + + .authorHeader { + display: flex; + align-items: center; + justify-content: space-between; + flex-grow: 1; + column-gap: 0.25rem; + } + .authorInstitution { + font-size: 0.875rem; + color: constants.$subtleGray; + + .authorInstitutionDropdown { + display: inline-block; + width: 100%; + padding-right: 0.25rem; + margin-top: 0.25rem; + margin-left: 0; + + button { + width: 100%; + border: none; + background-color: unset; + box-shadow: none; + padding-left: 0; + } + } + } +} diff --git a/styles/components/forum-note.scss b/styles/components/forum-note.scss index c99ceed4d..93aa47b74 100644 --- a/styles/components/forum-note.scss +++ b/styles/components/forum-note.scss @@ -54,6 +54,13 @@ padding-left: 0.25rem; } } + + .note-authors-institutions { + font-size: 0.85rem; + font-style: normal; + color: constants.$subtleGray; + margin-top: 0.25rem; + } } .forum-meta { diff --git a/unitTests/ContentFieldEditor.test.js b/unitTests/ContentFieldEditor.test.js index 712397345..683ae68e3 100644 --- a/unitTests/ContentFieldEditor.test.js +++ b/unitTests/ContentFieldEditor.test.js @@ -27,7 +27,7 @@ describe('ContentFieldEditor', () => { const providerProps = { value: { field: { - note_cotent: { + note_content: { value: { param: { type: 'content', @@ -52,7 +52,7 @@ describe('ContentFieldEditor', () => { const providerProps = { value: { field: { - note_cotent: { + note_content: { value: { param: { type: 'content', @@ -84,7 +84,7 @@ describe('ContentFieldEditor', () => { const providerProps = { value: { field: { - note_cotent: { + note_content: { value: { param: { type: 'content', @@ -119,7 +119,7 @@ describe('ContentFieldEditor', () => { const providerProps = { value: { field: { - note_cotent: { + note_content: { value: { param: { type: 'content', @@ -172,7 +172,7 @@ describe('ContentFieldEditor', () => { }) ) - expect(Object.keys(mockedFormProps.mock.calls[0][0].fields)).toHaveLength(21) // 21 fields defined for a field (removed hidden) + expect(Object.keys(mockedFormProps.mock.calls[0][0].fields)).toHaveLength(22) // 22 fields defined for a field (removed hidden) }) test('update invitation content editor when widget form is updated', async () => { @@ -180,7 +180,7 @@ describe('ContentFieldEditor', () => { const providerProps = { value: { field: { - note_cotent: { + note_content: { value: { param: { type: 'content', @@ -221,7 +221,7 @@ describe('ContentFieldEditor', () => { // update invitation content editor's note_content field with whole json expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ - fieldName: 'note_cotent', + fieldName: 'note_content', value: expect.objectContaining({ title: expect.objectContaining({ description: 'the title of the submission', @@ -237,4 +237,291 @@ describe('ContentFieldEditor', () => { }) ) }) + + test('update definition of object author', async () => { + const onChange = jest.fn() + const providerProps = { + value: { + field: { + note_content: { + value: { + param: { + type: 'content', + }, + }, + }, + }, + value: { + author: { + order: 1, + description: 'author of the submission', + value: { + param: { + type: 'string', + regex: '^.{1,250}$', + }, + }, + }, + }, + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await userEvent.click(screen.getByRole('tab', { name: 'Widgets' })) + await userEvent.click(screen.getByText('Add a field or Select a field to edit')) + await userEvent.click(screen.getByRole('option', { name: 'author' })) + + onFormChange({ + name: 'author', + description: 'author of the submission', + dataType: 'author{}', + order: 1, + }) + + // update invitation content editor's note_content field with whole json + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + fieldName: 'note_content', + value: expect.objectContaining({ + author: expect.objectContaining({ + description: 'author of the submission', + order: 1, + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + }, + }, + }, + }), + }), + }) + ) + }) + + test('add institutions to author properties when check institution checkbox', async () => { + const onChange = jest.fn() + const providerProps = { + value: { + field: { + note_content: { + value: { + param: { + type: 'content', + }, + }, + }, + }, + value: { + author: { + order: 1, + description: 'author of the submission', + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await userEvent.click(screen.getByRole('tab', { name: 'Widgets' })) + await userEvent.click(screen.getByText('Add a field or Select a field to edit')) + await userEvent.click(screen.getByRole('option', { name: 'author' })) + + onFormChange({ + name: 'author', + description: 'author of the submission', + dataType: 'author{}', + order: 1, + institution: true, + }) + + // update invitation content editor's note_content field with whole json + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + fieldName: 'note_content', + value: expect.objectContaining({ + author: expect.objectContaining({ + description: 'author of the submission', + order: 1, + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + institutions: { + param: { + type: 'object{}', + properties: { + name: { + param: { + type: 'string', + }, + }, + domain: { + param: { + type: 'string', + }, + }, + country: { + param: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }), + }), + }) + ) + }) + + test('remove institutions to author properties when uncheck institution checkbox', async () => { + const onChange = jest.fn() + const providerProps = { + value: { + field: { + note_content: { + value: { + param: { + type: 'content', + }, + }, + }, + }, + value: { + author: { + order: 1, + description: 'author of the submission', + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + institutions: { + param: { + type: 'object{}', + properties: { + name: { + param: { + type: 'string', + }, + }, + domain: { + param: { + type: 'string', + }, + }, + country: { + param: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await userEvent.click(screen.getByRole('tab', { name: 'Widgets' })) + await userEvent.click(screen.getByText('Add a field or Select a field to edit')) + await userEvent.click(screen.getByRole('option', { name: 'author' })) + + onFormChange({ + name: 'author', + description: 'author of the submission', + dataType: 'author{}', + order: 1, + institution: false, + }) + + // update invitation content editor's note_content field with whole json + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + fieldName: 'note_content', + value: expect.objectContaining({ + author: expect.objectContaining({ + description: 'author of the submission', + order: 1, + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + }, + }, + }, + }), + }), + }) + ) + }) }) diff --git a/unitTests/ProfileSearchWithInstitutionWidget.test.js b/unitTests/ProfileSearchWithInstitutionWidget.test.js new file mode 100644 index 000000000..d718bc91c --- /dev/null +++ b/unitTests/ProfileSearchWithInstitutionWidget.test.js @@ -0,0 +1,1480 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ProfileSearchWithInstitutionWidget from '../components/EditorComponents/ProfileSearchWithInstitutionWidget' +import { renderWithEditorComponentContext, reRenderWithEditorComponentContext } from './util' +import '@testing-library/jest-dom' + +import api from '../lib/api-client' + +jest.mock('nanoid', () => ({ nanoid: () => 'some id' })) +jest.mock('../hooks/useUser', () => () => ({ + user: { profile: { id: '~test_id1' } }, + accessToken: 'test token', +})) + +global.$ = jest.fn(() => ({ + tooltip: jest.fn(), +})) +global.promptError = jest.fn() + +describe('ProfileSearchWithInstitutionWidget', () => { + test('add current user to author list (no current institution)', async () => { + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: 1999, + end: 1999, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: 2000, + institution: { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + institutions: { + param: { + type: 'object{}', + properties: { + name: { param: { type: 'string' } }, + domain: { param: { type: 'string' } }, + country: { param: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [], + }, + ], + }) + ) + }) + }) + + test('add current user to author list (1 current institution)', async () => { + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: null, + end: null, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + institutions: { + param: { + type: 'object{}', + properties: { + name: { param: { type: 'string' } }, + domain: { param: { type: 'string' } }, + country: { param: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + fullname: 'Test First Test Last', + institutions: [{ country: 'TC', domain: 'test.edu', name: 'Test Institution' }], + username: '~test_id1', + }, + ], + }) + ) + }) + }) + + test('add current user to author list (multiple current institution)', async () => { + const currentYear = new Date().getFullYear() + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: currentYear + 1, + institution: { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + }, + { + start: 1999, + end: currentYear + 1, + institution: { + name: 'Yet Another Test Institution', + domain: 'yet.another.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + institutions: { + param: { + type: 'object{}', + properties: { + name: { param: { type: 'string' } }, + domain: { param: { type: 'string' } }, + country: { param: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [{ country: 'TC', domain: 'test.edu', name: 'Test Institution' }], // only the first institution is added + }, + ], + }) + ) + }) + }) + + test('add current user to author list (multiple current institution but invitation does not request institution)', async () => { + const currentYear = new Date().getFullYear() + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: currentYear + 1, + institution: { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + }, + { + start: 1999, + end: currentYear + 1, + institution: { + name: 'Yet Another Test Institution', + domain: 'yet.another.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + institutions: undefined, // institution is not requested + }, + }, + }, + }, + }, + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + }, + ], + }) + ) + }) + }) + + test('show added author (no current institution)', async () => { + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: 1999, + end: 1999, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: 2000, + institution: { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + institutions: { + param: { + type: 'object{}', + properties: { + name: { param: { type: 'string' } }, + domain: { param: { type: 'string' } }, + country: { param: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [], + }, + ], + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + // show name + expect(screen.getByText('Test First Test Last')).toBeInTheDocument() + // show tilde id as tooltip + expect(screen.getByText('Test First Test Last')).toHaveAttribute( + 'data-original-title', + '~test_id1' + ) + // show link to profile page + expect(screen.getByText('Test First Test Last')).toHaveAttribute( + 'href', + '/profile?id=~test_id1' + ) + // show remove button + expect(screen.getByRole('button', { name: 'remove' })).toBeInTheDocument() + // dropdown show no institution + expect(screen.getByRole('button', { name: 'No Active Institution' })).toBeInTheDocument() + }) + }) + + test('show added author (1 current institution)', async () => { + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: null, + end: null, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + institutions: { + param: { + type: 'object{}', + properties: { + name: { param: { type: 'string' } }, + domain: { param: { type: 'string' } }, + country: { param: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + ], + }, + ], + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + // show name + expect(screen.getByText('Test First Test Last')).toBeInTheDocument() + // show tilde id as tooltip + expect(screen.getByText('Test First Test Last')).toHaveAttribute( + 'data-original-title', + '~test_id1' + ) + // show link to profile page + expect(screen.getByText('Test First Test Last')).toHaveAttribute( + 'href', + '/profile?id=~test_id1' + ) + // show remove button + expect(screen.getByRole('button', { name: 'remove' })).toBeInTheDocument() + // dropdown show the 1 institution added + expect(screen.getByRole('button', { name: '1 Institution added' })).toBeInTheDocument() + // expand dropdown to show the institution checked + userEvent.click(screen.getByRole('button', { name: '1 Institution added' })) + expect( + screen.getByRole('checkbox', { name: 'Test Institution (test.edu)' }) + ).toBeChecked() + }) + }) + + test('show added author (multiple current institution, selected 1)', async () => { + const currentYear = new Date().getFullYear() + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: currentYear + 1, + institution: { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + }, + { + start: 1999, + end: currentYear - 1, + institution: { + name: 'Non Current Institution', + domain: 'not.current.test.edu', + country: 'TC', + }, + }, + { + start: 1999, + end: currentYear + 1, + institution: { + name: 'Yet Another Test Institution', + domain: 'yet.another.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const clearError = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + institutions: { + param: { + type: 'object{}', + properties: { + name: { param: { type: 'string' } }, + domain: { param: { type: 'string' } }, + country: { param: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + ], + }, + ], + onChange, + clearError, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + // show name + expect(screen.getByText('Test First Test Last')).toBeInTheDocument() + // show tilde id as tooltip + expect(screen.getByText('Test First Test Last')).toHaveAttribute( + 'data-original-title', + '~test_id1' + ) + // show link to profile page + expect(screen.getByText('Test First Test Last')).toHaveAttribute( + 'href', + '/profile?id=~test_id1' + ) + // show remove button + expect(screen.getByRole('button', { name: 'remove' })).toBeInTheDocument() + // dropdown show the 1 institution added + expect(screen.getByRole('button', { name: '1 Institution added' })).toBeInTheDocument() + }) + // expand dropdown to show all options + await userEvent.click(screen.getByRole('button', { name: '1 Institution added' })) + await waitFor(() => { + expect(screen.getAllByRole('checkbox').length).toBe(4) // select all + 3 current options + expect( + screen.getByRole('checkbox', { name: 'Test Institution (test.edu)' }) + ).toBeChecked() + expect( + screen.getByRole('checkbox', { name: 'Another Test Institution (another.test.edu)' }) + ).not.toBeChecked() + expect( + screen.getByRole('checkbox', { + name: 'Yet Another Test Institution (yet.another.test.edu)', + }) + ).not.toBeChecked() + }) + + // check another institution + await userEvent.click( + screen.getByRole('checkbox', { name: 'Another Test Institution (another.test.edu)' }) + ) + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + ], + }, + ], + }) + ) + expect(clearError).toHaveBeenCalled() // clear error when change institution + }) + + // check all + await userEvent.click(screen.getByRole('checkbox', { name: 'Select All' })) + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + { + name: 'Yet Another Test Institution', + domain: 'yet.another.test.edu', + country: 'TC', + }, + ], + }, + ], + }) + ) + }) + + // uncheck selected institution + await userEvent.click( + screen.getByRole('checkbox', { name: 'Test Institution (test.edu)' }) + ) + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + { + name: 'Yet Another Test Institution', + domain: 'yet.another.test.edu', + country: 'TC', + }, + ], + }, + ], + }) + ) + }) + }) + + test('show added author (multiple current institution but invitation does not request institution)', async () => { + const currentYear = new Date().getFullYear() + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: currentYear + 1, + institution: { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + }, + { + start: 1999, + end: currentYear - 1, + institution: { + name: 'Non Current Institution', + domain: 'not.current.test.edu', + country: 'TC', + }, + }, + { + start: 1999, + end: currentYear + 1, + institution: { + name: 'Yet Another Test Institution', + domain: 'yet.another.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: { + param: { + type: 'author{}', + properties: { + fullname: { + param: { + type: 'string', + }, + }, + username: { + param: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + }, + ], + onChange, + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + // show name + expect(screen.getByText('Test First Test Last')).toBeInTheDocument() + // show tilde id as tooltip + expect(screen.getByText('Test First Test Last')).toHaveAttribute( + 'data-original-title', + '~test_id1' + ) + // show link to profile page + expect(screen.getByText('Test First Test Last')).toHaveAttribute( + 'href', + '/profile?id=~test_id1' + ) + // show remove button + expect(screen.getByRole('button', { name: 'remove' })).toBeInTheDocument() + // no invitation info + expect( + screen.queryByRole('button', { name: '1 Institution added' }) + ).not.toBeInTheDocument() + }) + }) + + test('allow reorder only (no institution)', async () => { + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: 2000, + institution: { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + { + id: '~test_id2', + content: { + names: [{ fullname: 'Another First Another Last', username: '~test_id2' }], + preferredEmail: 'another@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Another User Institution', + domain: 'another.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: null, + institution: { + name: 'Another User Another Institution', + domain: 'another.user.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const clearError = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + }, + { + username: '~test_id2', + fullname: 'Another First Another Last', + }, + ], + }, + }, + onChange, + clearError, + value: [ + // value from existing note + { + username: '~test_id1', + fullname: 'Test First Test Last', + }, + { + username: '~test_id2', + fullname: 'Another First Another Last', + }, + ], + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + // show name and reorder button only + expect(screen.getByText('Test First Test Last')).toBeInTheDocument() + expect(screen.getByText('Another First Another Last')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'arrow-right' })).toBeInTheDocument() + + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'remove' })).not.toBeInTheDocument() + expect(screen.queryByText('1 Institution added')).not.toBeInTheDocument() + }) + + // update order + await userEvent.click(screen.getByRole('button', { name: 'arrow-right' })) + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id2', + fullname: 'Another First Another Last', + }, + { + username: '~test_id1', + fullname: 'Test First Test Last', + }, + ], + }) + ) + expect(clearError).toHaveBeenCalled() // clear error when reorder + }) + }) + + test('allow reorder only (with institution)', async () => { + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: 2000, + institution: { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + { + id: '~test_id2', + content: { + names: [{ fullname: 'Another First Another Last', username: '~test_id2' }], + preferredEmail: 'another@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Another User Institution', + domain: 'another.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: null, + institution: { + name: 'Another User Another Institution', + domain: 'another.user.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const clearError = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: [ + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { name: 'Non existing Institution', domain: 'non.existing', country: 'TC' }, + ], + }, + { + username: '~test_id2', + fullname: 'Another First Another Last', + institutions: [ + { + name: 'Another User Institution', + domain: 'another.edu', + country: 'TC', + }, + { + name: 'Another User Another Institution', + domain: 'another.user.test.edu', + country: 'TC', + }, + ], + }, + ], + }, + }, + onChange, + clearError, + value: [ + // value from existing note + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { name: 'Non existing Institution', domain: 'non.existing', country: 'TC' }, + ], + }, + { + username: '~test_id2', + fullname: 'Another First Another Last', + institutions: [ + { + name: 'Another User Institution', + domain: 'another.edu', + country: 'TC', + }, + { + name: 'Another User Another Institution', + domain: 'another.user.test.edu', + country: 'TC', + }, + ], + }, + ], + }, + } + + renderWithEditorComponentContext(, providerProps) + + await waitFor(() => { + // show name and reorder button + expect(screen.getByText('Test First Test Last')).toBeInTheDocument() + expect(screen.getByText('Another First Another Last')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'arrow-right' })).toBeInTheDocument() + + expect(screen.queryByRole('button', { name: 'remove' })).not.toBeInTheDocument() + }) + + // institution is shown is disabled + await waitFor(() => { + expect(screen.getByRole('button', { name: '1 Institution added' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '2 Institutions added' })).toBeInTheDocument() + screen.getAllByRole('checkbox').forEach((checkbox) => { + expect(checkbox).toBeDisabled() + }) + }) + + //update order + await userEvent.click(screen.getByRole('button', { name: 'arrow-right' })) + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id2', + fullname: 'Another First Another Last', + institutions: [ + { + name: 'Another User Institution', + domain: 'another.edu', + country: 'TC', + }, + { + name: 'Another User Another Institution', + domain: 'another.user.test.edu', + country: 'TC', + }, + ], + }, + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { name: 'Non existing Institution', domain: 'non.existing', country: 'TC' }, + ], + }, + ], + }) + ) + expect(clearError).toHaveBeenCalled() + }) + }) + + test('allow reorder with institution change', async () => { + const apiPost = jest.fn(() => + Promise.resolve({ + profiles: [ + { + id: '~test_id1', + content: { + names: [{ fullname: 'Test First Test Last', username: '~test_id1' }], + preferredEmail: 'test@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Test Institution', + domain: 'test.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: 2000, + institution: { + name: 'Another Test Institution', + domain: 'another.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + { + id: '~test_id2', + content: { + names: [{ fullname: 'Another First Another Last', username: '~test_id2' }], + preferredEmail: 'another@email.com', + history: [ + { + start: 1999, + end: null, + institution: { + name: 'Another User Institution', + domain: 'another.edu', + country: 'TC', + }, + }, + { + start: 2000, + end: null, + institution: { + name: 'Another User Another Institution', + domain: 'another.user.test.edu', + country: 'TC', + }, + }, + ], + }, + }, + ], + }) + ) + api.post = apiPost + const onChange = jest.fn() + const clearError = jest.fn() + const providerProps = { + value: { + field: { + authors: { + value: { + param: { + type: 'object{}', + elements: [ + { + param: { + type: 'object', + properties: { + fullname: 'Test First Test Last', + username: '~test_id1', + institutions: { + param: { + type: 'object{}', + properties: { + name: { param: { type: 'string' } }, + domain: { param: { type: 'string' } }, + country: { param: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + { + param: { + type: 'object', + properties: { + fullname: 'Another First Another Last', + username: '~test_id2', + institutions: { + param: { + type: 'object{}', + properties: { + name: { param: { type: 'string' } }, + domain: { param: { type: 'string' } }, + country: { param: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, + }, + onChange, + clearError, + value: [ + // value from existing note + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { name: 'Non existing Institution', domain: 'non.existing', country: 'TC' }, + ], + }, + { + username: '~test_id2', + fullname: 'Another First Another Last', + institutions: [ + { + name: 'Another User Institution', + domain: 'another.edu', + country: 'TC', + }, + { + name: 'Another User Another Institution', + domain: 'another.user.test.edu', + country: 'TC', + }, + ], + }, + ], + }, + } + + renderWithEditorComponentContext(, providerProps) + + // no remove button + await waitFor(() => { + expect(screen.getByText('Test First Test Last')).toBeInTheDocument() + expect(screen.getByText('Another First Another Last')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '1 Institution added' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '2 Institutions added' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'arrow-right' })).toBeInTheDocument() + + expect(screen.queryByRole('button', { name: 'remove' })).not.toBeInTheDocument() + }) + + // institution is enabled + await waitFor(() => { + screen.getAllByRole('checkbox').forEach((checkbox) => { + expect(checkbox).not.toBeDisabled() + }) + }) + + // update order + await userEvent.click(screen.getByRole('button', { name: 'arrow-right' })) + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id2', + fullname: 'Another First Another Last', + institutions: [ + { + name: 'Another User Institution', + domain: 'another.edu', + country: 'TC', + }, + { + name: 'Another User Another Institution', + domain: 'another.user.test.edu', + country: 'TC', + }, + ], + }, + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { name: 'Non existing Institution', domain: 'non.existing', country: 'TC' }, + ], + }, + ], + }) + ) + expect(clearError).toHaveBeenCalled() + }) + + // uncheck an institution from ~test_id2 + await userEvent.click( + screen.getByRole('checkbox', { name: 'Another User Institution (another.edu)' }) + ) + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + value: [ + { + username: '~test_id2', + fullname: 'Another First Another Last', + institutions: [ + { + name: 'Another User Another Institution', + domain: 'another.user.test.edu', + country: 'TC', + }, + ], + }, + { + username: '~test_id1', + fullname: 'Test First Test Last', + institutions: [ + { name: 'Non existing Institution', domain: 'non.existing', country: 'TC' }, + ], + }, + ], + }) + ) + }) + }) +}) diff --git a/unitTests/webfield-utils.test.js b/unitTests/webfield-utils.test.js index fa9a0be8a..19e421bd8 100644 --- a/unitTests/webfield-utils.test.js +++ b/unitTests/webfield-utils.test.js @@ -15,45 +15,49 @@ const uniqueIdentifier = 'id' describe('webfield-utils', () => { test('return field name in getErrorFieldName', () => { let errorPath = 'note/content/pdf' - let resultExpected = 'pdf' + let resultExpected = { fieldName: 'pdf' } - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) errorPath = 'note/content/title/value' - resultExpected = 'title' - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + resultExpected = { fieldName: 'title' } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) errorPath = 'note/content/authorids/value/0' - resultExpected = 'authorids' - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + resultExpected = { fieldName: 'authorids' } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) errorPath = 'signatures' // edit signatures - resultExpected = 'editSignatureInputValues' - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + resultExpected = { fieldName: 'editSignatureInputValues' } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) errorPath = 'note/signatures' // note signatures - resultExpected = 'noteSignatureInputValues' - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + resultExpected = { fieldName: 'noteSignatureInputValues' } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) errorPath = 'readers' // edit readers - resultExpected = 'editReaderValues' - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + resultExpected = { fieldName: 'editReaderValues' } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) errorPath = 'note/readers' // note readers - resultExpected = 'noteReaderValues' - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + resultExpected = { fieldName: 'noteReaderValues' } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) errorPath = 'note/license' // note license - resultExpected = 'noteLicenseValue' - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + resultExpected = { fieldName: 'noteLicenseValue' } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) errorPath = 'content/author_id/value' // edit content - resultExpected = 'content.author_id' - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + resultExpected = { fieldName: 'content.author_id' } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) errorPath = 'content/author_index/value/0' - resultExpected = 'content.author_index' - expect(getErrorFieldName(errorPath)).toBe(resultExpected) + resultExpected = { fieldName: 'content.author_index' } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) + + errorPath = 'note/content/authors/value/4/institutions' // object authors + resultExpected = { fieldName: 'authors', index: 4 } + expect(getErrorFieldName(errorPath)).toEqual(resultExpected) }) test('return whether the error invalidValue is {delete:true} in isNonDeletableError', () => { @@ -755,6 +759,111 @@ describe('filterCollections', () => { ) expect(result.filteredRows.map((p) => p.id)).toEqual([2]) }) + + test('filter object authors', () => { + const note1Authors = [ + { + fullname: 'Name One', + username: '~Id1', + institutions: [{ domain: 'institution.one', name: 'Institution One', country: 'TC' }], + }, + { + fullname: 'Name Two', + username: '~Id2', + institutions: [{ domain: 'institution.two', name: 'Institution Two', country: 'TC' }], + }, + ] + const note2Authors = [ + { + fullname: 'Name Three', + username: '~Id3', + institutions: [ + { domain: 'institution.three', name: 'Institution Three', country: 'TC' }, + ], + }, + { + fullname: 'Name Four', + username: '~Id4', + institutions: [ + { domain: 'institution.four', name: 'Institution Four', country: 'TC' }, + ], + }, + ] + const collections = [ + { + id: 1, + note: { + content: { + authors: { value: note1Authors }, + }, + authorSearchValue: note1Authors.map((p) => ({ + ...p, + type: 'authorObj', + })), + }, + }, + { + id: 2, + note: { + content: { + authors: { value: note2Authors }, + }, + authorSearchValue: note2Authors.map((p) => ({ + ...p, + type: 'authorObj', + })), + }, + }, + ] + + // id match + let filterString = 'author=~Id' + const propertiesAllowed = { + author: ['note.authorSearchValue'], + } + + let result = filterCollections( + collections, + filterString, + filterOperators, + propertiesAllowed, + uniqueIdentifier + ) + expect(result.filteredRows.map((p) => p.id)).toEqual([1, 2]) + + // id exact match + filterString = 'author=~Id3' + result = filterCollections( + collections, + filterString, + filterOperators, + propertiesAllowed, + uniqueIdentifier + ) + expect(result.filteredRows.map((p) => p.id)).toEqual([2]) + + // name match + filterString = 'author=Name' + result = filterCollections( + collections, + filterString, + filterOperators, + propertiesAllowed, + uniqueIdentifier + ) + expect(result.filteredRows.map((p) => p.id)).toEqual([1, 2]) + + // name exact match + filterString = 'author==Name Three' + result = filterCollections( + collections, + filterString, + filterOperators, + propertiesAllowed, + uniqueIdentifier + ) + expect(result.filteredRows.map((p) => p.id)).toEqual([2]) + }) }) describe('convertToString', () => {