Skip to content

Commit

Permalink
refactor: item linking (#1781)
Browse files Browse the repository at this point in the history
  • Loading branch information
amanharwara committed Oct 12, 2022
1 parent 2b89ad4 commit 81532f2
Show file tree
Hide file tree
Showing 12 changed files with 290 additions and 132 deletions.
2 changes: 1 addition & 1 deletion packages/mobile/src/Hooks/useFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const useFiles = ({ note }: Props) => {
const filesService = application.getFilesService()

const reloadAttachedFiles = useCallback(() => {
setAttachedFiles(application.items.getSortedFilesForItem(note).sort(filesService.sortByName))
setAttachedFiles(application.items.getSortedFilesLinkingToItem(note).sort(filesService.sortByName))
}, [application.items, filesService.sortByName, note])

const reloadAllFiles = useCallback(() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/mobile/src/Screens/SideMenu/NoteSideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,15 @@ export const NoteSideMenu = React.memo((props: Props) => {
setAttachedFilesLength(0)
return
}
setAttachedFilesLength(application.items.getSortedFilesForItem(note).length)
setAttachedFilesLength(application.items.getSortedFilesLinkingToItem(note).length)
}, [application, note])

useEffect(() => {
if (!note) {
return
}
const removeFilesObserver = application.streamItems(ContentType.File, () => {
setAttachedFilesLength(application.items.getSortedFilesForItem(note).length)
setAttachedFilesLength(application.items.getSortedFilesLinkingToItem(note).length)
})
return () => {
removeFilesObserver()
Expand Down
4 changes: 3 additions & 1 deletion packages/services/src/Domain/Item/ItemsClientInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ export interface ItemsClientInterface {
* @returns Array containing tags associated with an item
*/
getSortedTagsForItem(item: DecryptedItemInterface<ItemContent>): SNTag[]
getSortedFilesForItem(item: DecryptedItemInterface<ItemContent>): FileItem[]

getSortedLinkedFilesForItem(item: DecryptedItemInterface<ItemContent>): FileItem[]
getSortedFilesLinkingToItem(item: DecryptedItemInterface<ItemContent>): FileItem[]

getSortedLinkedNotesForItem(item: DecryptedItemInterface<ItemContent>): SNNote[]
getSortedNotesLinkingToItem(item: DecryptedItemInterface<ItemContent>): SNNote[]
Expand Down
34 changes: 26 additions & 8 deletions packages/snjs/lib/Services/Items/ItemManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@ describe('itemManager', () => {

await itemManager.associateFileWithNote(file, note)

const filesAssociatedWithNote = itemManager.getSortedFilesForItem(note)
const filesAssociatedWithNote = itemManager.getSortedFilesLinkingToItem(note)

expect(filesAssociatedWithNote).toHaveLength(1)
expect(filesAssociatedWithNote[0].uuid).toBe(file.uuid)
Expand Down Expand Up @@ -873,20 +873,38 @@ describe('itemManager', () => {

it('should get all linked files for item', async () => {
itemManager = createService()
const note = createNote('note')
const file = createFile('A1')
const file2 = createFile('B2')
const file3 = createFile('C3')

await itemManager.insertItems([note, file, file2])
await itemManager.insertItems([file, file2, file3])

await itemManager.associateFileWithNote(file2, note)
await itemManager.associateFileWithNote(file, note)
await itemManager.linkFileToFile(file, file3)
await itemManager.linkFileToFile(file, file2)

const sortedFilesForItem = itemManager.getSortedLinkedFilesForItem(file)

expect(sortedFilesForItem).toHaveLength(2)
expect(sortedFilesForItem[0].uuid).toEqual(file2.uuid)
expect(sortedFilesForItem[1].uuid).toEqual(file3.uuid)
})

it('should get all files linking to item', async () => {
itemManager = createService()
const baseFile = createFile('file')
const fileToLink1 = createFile('A1')
const fileToLink2 = createFile('B2')

await itemManager.insertItems([baseFile, fileToLink1, fileToLink2])

await itemManager.linkFileToFile(fileToLink2, baseFile)
await itemManager.linkFileToFile(fileToLink1, baseFile)

const sortedFilesForItem = itemManager.getSortedFilesForItem(note)
const sortedFilesForItem = itemManager.getSortedFilesLinkingToItem(baseFile)

expect(sortedFilesForItem).toHaveLength(2)
expect(sortedFilesForItem[0].uuid).toEqual(file.uuid)
expect(sortedFilesForItem[1].uuid).toEqual(file2.uuid)
expect(sortedFilesForItem[0].uuid).toEqual(fileToLink1.uuid)
expect(sortedFilesForItem[1].uuid).toEqual(fileToLink2.uuid)
})

it('should get all linked notes for item', async () => {
Expand Down
17 changes: 13 additions & 4 deletions packages/snjs/lib/Services/Items/ItemManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1192,19 +1192,28 @@ export class ItemManager
)
}

public getSortedFilesForItem(item: DecryptedItemInterface<ItemContent>): Models.FileItem[] {
public getSortedLinkedFilesForItem(item: DecryptedItemInterface<ItemContent>): Models.FileItem[] {
if (this.isTemplateItem(item)) {
return []
}

const filesReferencingItem = this.itemsReferencingItem(item).filter(
const filesReferencedByItem = this.referencesForItem(item).filter(
(ref) => ref.content_type === ContentType.File,
) as Models.FileItem[]
const filesReferencedByItem = this.referencesForItem(item).filter(

return naturalSort(filesReferencedByItem, 'title')
}

public getSortedFilesLinkingToItem(item: DecryptedItemInterface<ItemContent>): Models.FileItem[] {
if (this.isTemplateItem(item)) {
return []
}

const filesReferencingItem = this.itemsReferencingItem(item).filter(
(ref) => ref.content_type === ContentType.File,
) as Models.FileItem[]

return naturalSort(filesReferencingItem.concat(filesReferencedByItem), 'title')
return naturalSort(filesReferencingItem, 'title')
}

public getSortedLinkedNotesForItem(item: DecryptedItemInterface<ItemContent>): Models.SNNote[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
const editorForNote = application.componentManager.editorForNote(item as SNNote)
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type)
const hasFiles = application.items.getSortedFilesForItem(item).length > 0
const hasFiles = application.items.getSortedFilesLinkingToItem(item).length > 0

const openNoteContextMenu = (posX: number, posY: number) => {
notesController.setContextMenuOpen(false)
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/javascripts/Components/Icon/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as icons from '@standardnotes/icons'
export const ICONS = {
'account-circle': icons.AccountCircleIcon,
'arrow-left': icons.ArrowLeftIcon,
'arrow-right': icons.ArrowRightIcon,
'arrows-sort-down': icons.ArrowsSortDownIcon,
'arrows-sort-up': icons.ArrowsSortUpIcon,
'attachment-file': icons.AttachmentFileIcon,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { KeyboardKey } from '@standardnotes/ui-services'
import { observer } from 'mobx-react-lite'
import { KeyboardEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react'
import { ContentType } from '@standardnotes/snjs'
import Icon from '../Icon/Icon'

type Props = {
item: LinkableItem
link: ItemLink
getItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
activateItem: (item: LinkableItem) => Promise<void>
unlinkItem: (item: LinkableItem) => void
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
focusPreviousItem: () => void
focusNextItem: () => void
focusedId: string | undefined
setFocusedId: (id: string) => void
isBidirectional: boolean
}

const LinkedItemBubble = ({
item,
link,
getItemIcon,
getTitleForLinkedTag,
activateItem,
Expand All @@ -27,6 +29,7 @@ const LinkedItemBubble = ({
focusNextItem,
focusedId,
setFocusedId,
isBidirectional,
}: Props) => {
const ref = useRef<HTMLButtonElement>(null)

Expand All @@ -36,8 +39,8 @@ const LinkedItemBubble = ({
const [wasClicked, setWasClicked] = useState(false)

const handleFocus = () => {
if (focusedId !== item.uuid) {
setFocusedId(item.uuid)
if (focusedId !== link.id) {
setFocusedId(link.id)
}
setShowUnlinkButton(true)
}
Expand All @@ -50,22 +53,22 @@ const LinkedItemBubble = ({
const onClick: MouseEventHandler = (event) => {
if (wasClicked && event.target !== unlinkButtonRef.current) {
setWasClicked(false)
void activateItem(item)
void activateItem(link.item)
} else {
setWasClicked(true)
}
}

const onUnlinkClick: MouseEventHandler = (event) => {
event.stopPropagation()
unlinkItem(item)
unlinkItem(link)
}

const onKeyDown: KeyboardEventHandler = (event) => {
switch (event.key) {
case KeyboardKey.Backspace: {
focusPreviousItem()
unlinkItem(item)
unlinkItem(link)
break
}
case KeyboardKey.Left:
Expand All @@ -77,29 +80,34 @@ const LinkedItemBubble = ({
}
}

const [icon, iconClassName] = getItemIcon(item)
const tagTitle = getTitleForLinkedTag(item)
const [icon, iconClassName] = getItemIcon(link.item)
const tagTitle = getTitleForLinkedTag(link.item)

useEffect(() => {
if (item.uuid === focusedId) {
if (link.id === focusedId) {
ref.current?.focus()
}
}, [focusedId, item.uuid])
}, [focusedId, link.id])

return (
<button
ref={ref}
className="flex h-6 cursor-pointer items-center rounded border-0 bg-passive-4-opacity-variant py-2 pl-1 pr-2 text-xs text-text hover:bg-contrast focus:bg-contrast"
className="group flex h-6 cursor-pointer items-center rounded border-0 bg-passive-4-opacity-variant py-2 pl-1 pr-2 text-xs text-text hover:bg-contrast focus:bg-contrast"
onFocus={handleFocus}
onBlur={onBlur}
onClick={onClick}
title={tagTitle ? tagTitle.longTitle : item.title}
title={tagTitle ? tagTitle.longTitle : link.item.title}
onKeyDown={onKeyDown}
>
<Icon type={icon} className={classNames('mr-1 flex-shrink-0', iconClassName)} size="small" />
<span className="max-w-290px overflow-hidden overflow-ellipsis whitespace-nowrap">
<span className="max-w-290px flex items-center overflow-hidden overflow-ellipsis whitespace-nowrap">
{tagTitle && <span className="text-passive-1">{tagTitle.titlePrefix}</span>}
{item.title}
<span className="flex items-center gap-1">
{link.relationWithSelectedItem === 'indirect' && link.item.content_type !== ContentType.Tag && (
<span className={!isBidirectional ? 'hidden group-focus:block' : ''}>Linked By:</span>
)}
{link.item.title}
</span>
</span>
{showUnlinkButton && (
<a
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { observer } from 'mobx-react-lite'
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import LinkedItemBubble from './LinkedItemBubble'
import { useCallback, useState } from 'react'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
Expand All @@ -14,16 +14,23 @@ type Props = {
const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
const { toggleAppPane } = useResponsiveAppPane()
const {
allLinkedItems,
notesLinkingToItem,
allItemLinks,
notesLinkingToActiveItem,
filesLinkingToActiveItem,
unlinkItemFromSelectedItem: unlinkItem,
getTitleForLinkedTag,
getLinkedItemIcon: getItemIcon,
activateItem,
} = linkingController

const [focusedId, setFocusedId] = useState<string>()
const focusableIds = allLinkedItems.map((item) => item.uuid).concat([ElementIds.ItemLinkAutocompleteInput])
const focusableIds = allItemLinks
.map((link) => link.id)
.concat(
notesLinkingToActiveItem.map((link) => link.id),
filesLinkingToActiveItem.map((link) => link.id),
[ElementIds.ItemLinkAutocompleteInput],
)

const focusPreviousItem = useCallback(() => {
const currentFocusedIndex = focusableIds.findIndex((id) => id === focusedId)
Expand Down Expand Up @@ -53,27 +60,38 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
[activateItem, toggleAppPane],
)

const isItemBidirectionallyLinked = (link: ItemLink) => {
const existsInAllItemLinks = !!allItemLinks.find((item) => link.item.uuid === item.item.uuid)
const existsInNotesLinkingToItem = !!notesLinkingToActiveItem.find((item) => link.item.uuid === item.item.uuid)

return existsInAllItemLinks && existsInNotesLinkingToItem
}

return (
<div
className={classNames(
'hidden min-w-80 max-w-full flex-wrap items-center gap-2 bg-transparent md:-mr-2 md:flex',
allLinkedItems.length || notesLinkingToItem.length ? 'mt-1' : 'mt-0.5',
allItemLinks.length || notesLinkingToActiveItem.length ? 'mt-1' : 'mt-0.5',
)}
>
{allLinkedItems.concat(notesLinkingToItem).map((item) => (
<LinkedItemBubble
item={item}
key={item.uuid}
getItemIcon={getItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
activateItem={activateItemAndTogglePane}
unlinkItem={unlinkItem}
focusPreviousItem={focusPreviousItem}
focusNextItem={focusNextItem}
focusedId={focusedId}
setFocusedId={setFocusedId}
/>
))}
{allItemLinks
.concat(notesLinkingToActiveItem)
.concat(filesLinkingToActiveItem)
.map((link) => (
<LinkedItemBubble
link={link}
key={link.id}
getItemIcon={getItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
activateItem={activateItemAndTogglePane}
unlinkItem={unlinkItem}
focusPreviousItem={focusPreviousItem}
focusNextItem={focusNextItem}
focusedId={focusedId}
setFocusedId={setFocusedId}
isBidirectional={isItemBidirectionallyLinked(link)}
/>
))}
<ItemLinkAutocompleteInput
focusedId={focusedId}
linkingController={linkingController}
Expand Down

0 comments on commit 81532f2

Please sign in to comment.