Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/common/__tests__/pins.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import test from 'node:test'
import assert from 'node:assert/strict'

const { getRepoPinOwner, getRepoPinTarget, canUserPinFor } = await import('../pins.js')

const member = {username: 'tester', subscribed_orgs: [{id: 'CIEL'}]}

test('getRepoPinOwner maps the repo owner to a pin owner', () => {
assert.deepEqual(getRepoPinOwner({owner: 'CIEL', owner_type: 'Organization'}), {type: 'org', id: 'CIEL'})
assert.deepEqual(getRepoPinOwner({owner: 'tester', owner_type: 'User'}), {type: 'user', id: 'tester'})
assert.equal(getRepoPinOwner({}), false)
assert.equal(getRepoPinOwner(undefined), false)
})

test('getRepoPinTarget pins the repo, also from one of its versions', () => {
const url = '/orgs/CIEL/sources/CIEL/'
assert.deepEqual(getRepoPinTarget({url, type: 'Source', version: 'HEAD', uuid: '42'}), {url, type: 'Source', uuid: '42'})
assert.deepEqual(getRepoPinTarget({url, type: 'Source Version', version: 'v2026-09-24', uuid: '8358'}), {url, type: 'Source', uuid: undefined})
assert.deepEqual(getRepoPinTarget({url, type: 'Collection Version', version: 'v1', uuid: '9'}), {url, type: 'Collection', uuid: undefined})
})

test('org members can pin for their org only', () => {
assert.equal(canUserPinFor(member, {type: 'org', id: 'CIEL'}), true)
assert.equal(canUserPinFor(member, {type: 'org', id: 'WHO'}), false)
})

test('users can pin for themselves only', () => {
assert.equal(canUserPinFor(member, {type: 'user', id: 'tester'}), true)
assert.equal(canUserPinFor(member, {type: 'user', id: 'bob'}), false)
assert.equal(canUserPinFor({username: 'CIEL'}, {type: 'org', id: 'CIEL'}), false)
})

test('staff can pin for any owner', () => {
assert.equal(canUserPinFor({username: 'ops', is_staff: true}, {type: 'org', id: 'WHO'}), true)
assert.equal(canUserPinFor({username: 'root', is_superuser: true}, {type: 'user', id: 'bob'}), true)
})

test('nobody pins without a signed-in user or an owner', () => {
assert.equal(canUserPinFor(null, {type: 'org', id: 'CIEL'}), false)
assert.equal(canUserPinFor({is_staff: true}, {type: 'org', id: 'CIEL'}), false)
assert.equal(canUserPinFor(member, false), false)
})
26 changes: 26 additions & 0 deletions src/common/pins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Pins are what a user or an org highlights on its own overview page.
// An owner is {type: 'user'|'org', id: <username|mnemonic>}.

export const getRepoPinOwner = repo => {
if(!repo?.owner || !repo?.owner_type)
return false
return {type: repo.owner_type === 'Organization' ? 'org' : 'user', id: repo.owner}
}

// A pin highlights a repo, never one of its versions. A version's url is its repo's url, but its uuid is
// the version's own id, so a version page pins by url alone.
export const getRepoPinTarget = repo => {
const isVersion = Boolean(repo?.version && repo.version !== 'HEAD')
return {url: repo?.url, type: repo?.type?.replace(' Version', ''), uuid: isVersion ? undefined : repo?.uuid}
}

// The owning user, the org's members and staff can change an owner's pins.
export const canUserPinFor = (user, owner) => {
if(!owner?.id || !user?.username)
return false
if(user.is_staff || user.is_superuser)
return true
return owner.type === 'org' ?
(user.subscribed_orgs || []).some(org => org?.id === owner.id) :
user.username === owner.id
}
2 changes: 1 addition & 1 deletion src/common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ export const arrayToCSV = objArray => {
}

export const refreshCurrentUserCache = (callback, onFailure) => {
APIService.user().get(null, null, {includeSubscribedOrgs: true, includeAuthGroups: true, includePins: true, includeFollowing: true}).then(response => {
APIService.user().get(null, null, {includeSubscribedOrgs: true, includeAuthGroups: true, includeFollowing: true}).then(response => {
if(response?.status === 200) {
localStorage.setItem('user', JSON.stringify(response.data));
if(callback) callback(response);
Expand Down
34 changes: 0 additions & 34 deletions src/components/common/PinActionButton.jsx

This file was deleted.

5 changes: 1 addition & 4 deletions src/components/orgs/OrgHome.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const OrgHome = () => {
const [tab, setTab] = React.useState(findTab)
const [defaultFiltersOpen, onFiltersOpenChange] = useDefaultFiltersOpen()
const { setAlert } = React.useContext(OperationsContext);
const { pins: bookmarks, canPin, togglePin, deletePin } = usePins(params?.org ? {type: 'org', id: params.org} : false)
const { pins: bookmarks, canPin, deletePin } = usePins(params?.org ? {type: 'org', id: params.org} : false)

const TABS = [
{key: 'overview', label: t('common.overview')},
Expand Down Expand Up @@ -107,9 +107,6 @@ const OrgHome = () => {
<Search
resource={tab}
url={org.url + tab + '/'}
pins={bookmarks}
canPin={canPin}
onPinToggle={togglePin}
defaultFiltersOpen={defaultFiltersOpen}
onFiltersOpenChange={onFiltersOpenChange}
nested
Expand Down
2 changes: 0 additions & 2 deletions src/components/orgs/OrgTooltip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import Avatar from '@mui/material/Avatar'
import Skeleton from '@mui/material/Skeleton'
import has from 'lodash/has'
import HTMLTooltip from '../common/HTMLTooltip'
import FollowActionButton from '../common/FollowActionButton'
import RepoIcon from '../repos/RepoIcon';
import APIService from '../../services/APIService'

Expand Down Expand Up @@ -37,7 +36,6 @@ const TooltipTitle = ({ org }) => {
<OrganizationIcon sx={{width: '24px', height: '24px'}} />
</Avatar>
}
<FollowActionButton iconButton entity={entity} sx={{mr: 0, ml: 1.5}} size='small' />
</div>
<div style={{width: '100%', fontSize: '14px', marginTop: '6px'}}>
{
Expand Down
18 changes: 17 additions & 1 deletion src/components/repos/RepoHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ import ProcessingFlag from './ProcessingFlag';
import RepoChip from './RepoChip'
import OwnerChip from '../common/OwnerChip';
import { copyURL, currentUserHasAccess, toFullAPIURL } from '../../common/utils';
import { getRepoPinOwner, getRepoPinTarget } from '../../common/pins';
import usePins, { canActOnPins } from '../../hooks/usePins';
import RepoManagementList from './RepoManagementList';
import { createSimilarRepoHref } from './utils';
import FollowActionButton from '../common/FollowActionButton'
import { OperationsContext } from '../app/LayoutContext';

const RepoHeader = ({repo, owner, versions, versionsLoading, previewVersions, hasMoreVersions, onLoadMoreVersions, onVersionChange, repoHref, onCreateConceptClick, onCreateMappingClick, onVersionEditClick, onCreateVersionClick, onDeleteRepoClick, isVersion, onReleaseVersionClick, hideActions}) => {
const { t } = useTranslation()
const { setAlert } = React.useContext(OperationsContext)
const [menu, setMenu] = React.useState(false)
const [menuAnchorEl, setMenuAnchorEl] = React.useState(false)
const onMenuOpen = event => {
Expand All @@ -27,6 +31,16 @@ const RepoHeader = ({repo, owner, versions, versionsLoading, previewVersions, ha
setMenu(false)
}

// Pins highlight the repo on its owner's overview, for those who can pin for that owner.
const pinOwner = hideActions ? false : getRepoPinOwner(repo)
const { pins, canPin, findPin, togglePin } = usePins(canActOnPins(pinOwner) ? pinOwner : false)
const pinTarget = getRepoPinTarget(repo)
const pinned = Boolean(findPin(pinTarget))
const onPinToggle = () => togglePin(pinTarget).then(saved => {
if(saved)
setAlert({severity: 'success', message: t(pinned ? 'bookmarks.unpinned_from_owner_overview' : 'bookmarks.pinned_to_owner_overview', {owner: pinOwner.id, interpolation: {escapeValue: false}})})
})

const onManageOptionClick = option => {
onMenuClose()
if(option === 'addConcept')
Expand All @@ -43,6 +57,8 @@ const RepoHeader = ({repo, owner, versions, versionsLoading, previewVersions, ha
onReleaseVersionClick()
else if (option === 'copyURL')
copyURL(toFullAPIURL(repo.url))
else if (option === 'pin')
onPinToggle()
}

const hasAccess = currentUserHasAccess()
Expand Down Expand Up @@ -71,7 +87,7 @@ const RepoHeader = ({repo, owner, versions, versionsLoading, previewVersions, ha
<Button endIcon={<DownIcon fontSize='inherit' />} variant='text' sx={{textTransform: 'none', color: 'surface.contrastText'}} onClick={onMenuOpen} id='repo-actions'>
{t('common.actions')}
</Button>
<RepoManagementList isVersion={isVersion} hasAccess={hasAccess} createSimilarHref={createSimilarHref} anchorEl={menuAnchorEl} open={menu} onClose={onMenuClose} id='repo-actions' onClick={onManageOptionClick} repo={repo} />
<RepoManagementList isVersion={isVersion} hasAccess={hasAccess} createSimilarHref={createSimilarHref} anchorEl={menuAnchorEl} open={menu} onClose={onMenuClose} id='repo-actions' onClick={onManageOptionClick} repo={repo} pin={(canPin && pins !== false) ? {owner: pinOwner.id, pinned} : false} />
</React.Fragment>
}
</span>
Expand Down
12 changes: 11 additions & 1 deletion src/components/repos/RepoManagementList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import ReleaseIcon from '@mui/icons-material/NewReleases';
import RepeatIcon from '@mui/icons-material/Repeat';
import CopyIcon from '@mui/icons-material/ContentCopy';
import GAService from '../../services/GAService';
import PinIcon from '../common/PinIcon';

const RepoManagementList = ({ anchorEl, open, onClose, onClick, repo, id, isVersion, hasAccess, createSimilarHref }) => {
const RepoManagementList = ({ anchorEl, open, onClose, onClick, repo, id, isVersion, hasAccess, createSimilarHref, pin }) => {
const { t } = useTranslation()
const url = isVersion ? repo.version_url : repo.url
let editParams = isVersion ? {onClick: () => onClick('editVersion')} : {href: `${url}edit`}
Expand Down Expand Up @@ -83,6 +84,15 @@ const RepoManagementList = ({ anchorEl, open, onClose, onClick, repo, id, isVers
<ListItemText primary={t('repo.create_similar')} />
</ListItemButton>
}
{
Boolean(pin) &&
<ListItemButton id='pin' onClick={() => onClick('pin')} sx={{padding: '8px 12px'}}>
<ListItemIcon sx={{minWidth: 'auto', marginRight: '12px'}}>
<PinIcon pinned={pin.pinned} />
</ListItemIcon>
<ListItemText primary={t(pin.pinned ? 'bookmarks.unpin_from_owner_overview' : 'bookmarks.pin_to_owner_overview', {owner: pin.owner, interpolation: {escapeValue: false}})} />
</ListItemButton>
}
{
hasAccess &&
<Box>
Expand Down
2 changes: 0 additions & 2 deletions src/components/repos/RepoTooltip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import has from 'lodash/has'
import startCase from 'lodash/startCase'
import MUIBasicTooltip from '@mui/material/Tooltip'
import HTMLTooltip from '../common/HTMLTooltip'
import FollowActionButton from '../common/FollowActionButton'
import OwnerButton from '../common/OwnerButton'
import RepoVersionButton from './RepoVersionButton'
import { URIToOwnerParams, formatDate } from '../../common/utils'
Expand Down Expand Up @@ -82,7 +81,6 @@ const TooltipTitle = ({ repo }) => {
}}
/>
</span>
<FollowActionButton iconButton entity={entity} sx={{mr: 0, ml: 1.5}} size='small' />
</div>
<div style={{width: '100%', fontSize: '14px', marginTop: '6px'}}>
<span style={{color: '#000', cursor: 'pointer'}} onClick={() => history.push(url)}>
Expand Down
35 changes: 2 additions & 33 deletions src/components/search/Search.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ import TransformIcon from '@mui/icons-material/Transform';
import DownIcon from '@mui/icons-material/KeyboardArrowDown';
import { forEach, keys, pick, pickBy, isEmpty, find, uniq, has, orderBy as sortBy, uniqBy, omit, max, isEqual, isBoolean } from 'lodash';
import { COLORS } from '../../common/colors';
import { dropVersion, highlightTexts, isLoggedIn, getCurrentUserUsername } from '../../common/utils';
import usePins, { PIN_RESOURCES, MAX_PINS_ALLOWED } from '../../hooks/usePins';
import PinActionButton from '../common/PinActionButton';
import { dropVersion, highlightTexts, isLoggedIn } from '../../common/utils';
import APIService from '../../services/APIService';
import RepoIcon from '../repos/RepoIcon';
import ConceptIcon from '../concepts/ConceptIcon';
Expand Down Expand Up @@ -56,14 +54,6 @@ const getCollectionLookupUrl = url => (url || '').replace(/\/(concepts|mappings|

const Search = props => {
const { setAlert, contextRepo } = React.useContext(OperationsContext);

// Pins: the owning page (org/user home) supplies them; a standalone search pins to the current user.
const hasPinsFromProps = Boolean(props.onPinToggle)
const currentUsername = getCurrentUserUsername()
const ownPins = usePins((hasPinsFromProps || props.nested || !currentUsername) ? false : {type: 'user', id: currentUsername})
const pins = hasPinsFromProps ? props.pins : ownPins.pins
const canPin = hasPinsFromProps ? props.canPin : ownPins.canPin
const onPinToggle = hasPinsFromProps ? props.onPinToggle : ownPins.togglePin
const { t } = useTranslation()
const history = useHistory();
const location = useLocation();
Expand Down Expand Up @@ -714,27 +704,6 @@ const Search = props => {
setShowItem(false)
}, [location.search])

const findPinFor = item => (pins || []).find(pin => pin.resource_uri === item?.url)
const pinLimitReached = (pins || []).length >= MAX_PINS_ALLOWED
const pinColumn = (canPin && PIN_RESOURCES.includes(resource)) ? {
id: 'pin',
label: '',
sortable: false,
align: 'right',
sx: {width: '48px', padding: '0 8px'},
renderer: item => {
const pin = findPinFor(item)
return (
<PinActionButton
item={item}
pin={pin}
onToggle={onPinToggle}
disabled={!pin && pinLimitReached}
/>
)
}
} : null

return (
<div className='col-xs-12 padding-0'>
<div className={!props.nested && showItem?.id ? 'col-xs-7 split' : 'col-xs-12 split'} style={{backgroundColor: searchBgColor, borderRadius: '10px', height: '100%', ...(props.containerStyle || {})}}>
Expand Down Expand Up @@ -812,7 +781,7 @@ const Search = props => {
resultContainerStyle={props.resultContainerStyle}
resultSize={props.resultSize}
excludedColumns={props.excludedColumns}
extraColumns={[...(props.extraColumns || []), pinColumn].filter(Boolean)}
extraColumns={props.extraColumns}
properties={props.properties}
propertyDefinition={props.propertyDefinition}
propertyFilters={props.propertyFilters}
Expand Down
15 changes: 0 additions & 15 deletions src/components/search/browseConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ export const BROWSE_CONFIG = {
titleKey: 'user.my_organizations',
getItems: () => getCurrentUserOrgs(),
},
{
key: 'bookmarked',
titleKey: 'search.bookmarked_organizations',
getItems: () => filter(getCurrentUser()?.pins, pin => isOrg(pin.resource)).map(pin => pin.resource),
},
{
key: 'following',
titleKey: 'search.following_organizations',
Expand All @@ -54,11 +49,6 @@ export const BROWSE_CONFIG = {
titleKey: 'user.my_repositories',
getItems: fetchMyRepos,
},
{
key: 'bookmarked',
titleKey: 'search.bookmarked_repositories',
getItems: () => filter(getCurrentUser()?.pins, pin => isRepo(pin.resource)).map(pin => pin.resource),
},
{
key: 'following',
titleKey: 'search.following_repositories',
Expand All @@ -71,11 +61,6 @@ export const BROWSE_CONFIG = {
emptyMessageKey: 'search.no_users_found',
getSubtitle: item => item.username,
sections: [
{
key: 'bookmarked',
titleKey: 'search.bookmarked_users',
getItems: () => filter(getCurrentUser()?.pins, pin => isUser(pin.resource)).map(pin => pin.resource),
},
{
key: 'following',
titleKey: 'search.following_users',
Expand Down
5 changes: 1 addition & 4 deletions src/components/users/UserHome.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const UserHome = () => {
const [defaultFiltersOpen, onFiltersOpenChange] = useDefaultFiltersOpen()
const [user, setUser] = React.useState({})
const [status, setStatus] = React.useState(false)
const { pins: bookmarks, canPin, togglePin, deletePin } = usePins(params?.user ? {type: 'user', id: params.user} : false)
const { pins: bookmarks, canPin, deletePin } = usePins(params?.user ? {type: 'user', id: params.user} : false)
const [events, setEvents] = React.useState(false)
const [eventsPage, setEventsPage] = React.useState(0)
const [haveMoreEvents, setHaveMoreEvents] = React.useState(false)
Expand Down Expand Up @@ -116,9 +116,6 @@ const UserHome = () => {
<Search
resource='repos'
url={user?.url + 'repos/'}
pins={bookmarks}
canPin={canPin}
onPinToggle={togglePin}
nested
noTabs
filtersHeightToSubtract={baseHeightToDeduct}
Expand Down
2 changes: 0 additions & 2 deletions src/components/users/UserTooltip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import Avatar from '@mui/material/Avatar'
import Skeleton from '@mui/material/Skeleton'
import has from 'lodash/has'
import HTMLTooltip from '../common/HTMLTooltip'
import FollowActionButton from '../common/FollowActionButton'
import APIService from '../../services/APIService'

const TooltipTitle = ({ user }) => {
Expand Down Expand Up @@ -38,7 +37,6 @@ const TooltipTitle = ({ user }) => {
<UserIcon sx={{width: '24px', height: '24px'}} />
</Avatar>
}
<FollowActionButton iconButton entity={entity} sx={{mr: 0, ml: 1.5}} size='small' />
</div>
<div style={{width: '100%', fontSize: '14px', marginTop: '6px'}}>
<span style={{color: '#000', cursor: 'pointer'}} onClick={() => history.push(entity?.url)}>
Expand Down
Loading
Loading