Commit 68daf79f authored by StyleZhang's avatar StyleZhang

feat: notion-page-selector

parent cca2ba95
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 3L4.5 8.5L2 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
.wrapper {
border-color: #d0d5dd;
}
.checked {
background: #155eef url(./assets/check.svg) center center no-repeat;
background-size: 12px 12px;
border-color: #155eef;
}
\ No newline at end of file
import cn from 'classnames' import cn from 'classnames'
import s from './index.module.css'
type CheckboxProps = { type CheckboxProps = {
checked?: boolean checked?: boolean
...@@ -9,7 +10,7 @@ type CheckboxProps = { ...@@ -9,7 +10,7 @@ type CheckboxProps = {
const Checkbox = ({ checked, onCheck, className }: CheckboxProps) => { const Checkbox = ({ checked, onCheck, className }: CheckboxProps) => {
return ( return (
<div <div
className={cn('w-4 h-4 border rounded', checked ? 'border-primary-600 bg-primary-600' : 'border-gray-300', className)} className={cn(s.wrapper, checked && s.checked, 'w-4 h-4 border rounded border-gray-300', className)}
onClick={onCheck} onClick={onCheck}
/> />
) )
......
...@@ -35,16 +35,15 @@ const NotionIcon = ({ ...@@ -35,16 +35,15 @@ const NotionIcon = ({
} }
if (src) { if (src) {
return ( if (src.includes('https://')) {
<img return (
alt='workspace icon' <img
src={src} alt='page icon'
className={cn('block object-cover w-5 h-5', className)} src={src}
/> className={cn('block object-cover w-5 h-5', className)}
) />
} )
}
if (name) {
return ( return (
<div className={cn('flex items-center justify-center w-5 h-5', className)}>{src}</div> <div className={cn('flex items-center justify-center w-5 h-5', className)}>{src}</div>
) )
......
...@@ -6,8 +6,19 @@ import WorkspaceSelector from './workspace-selector' ...@@ -6,8 +6,19 @@ import WorkspaceSelector from './workspace-selector'
import SearchInput from './search-input' import SearchInput from './search-input'
import PageSelector from './page-selector' import PageSelector from './page-selector'
import { fetchDataSource } from '@/service/common' import { fetchDataSource } from '@/service/common'
import type { DataSourceNotionPage } from '@/models/common'
const NotionPageSelector = () => { type NotionPageSelectorProps = {
onSelect: (selectedPages: DataSourceNotionPage[]) => void
canPreview?: boolean
onPreview?: (selectedPage: DataSourceNotionPage) => void
}
const NotionPageSelector = ({
onSelect,
canPreview,
onPreview,
}: NotionPageSelectorProps) => {
const [searchValue, setSearchValue] = useState('') const [searchValue, setSearchValue] = useState('')
const { data } = useSWR({ url: 'data-source/integrates' }, fetchDataSource) const { data } = useSWR({ url: 'data-source/integrates' }, fetchDataSource)
const notionWorkspaces = data?.data.filter(item => item.provider === 'notion') || [] const notionWorkspaces = data?.data.filter(item => item.provider === 'notion') || []
...@@ -42,7 +53,16 @@ const NotionPageSelector = () => { ...@@ -42,7 +53,16 @@ const NotionPageSelector = () => {
/> />
</div> </div>
<div className='rounded-b-xl overflow-hidden'> <div className='rounded-b-xl overflow-hidden'>
<PageSelector list={currentWorkspace?.source_info.pages || []} /> {
currentWorkspace?.source_info.pages.length && (
<PageSelector
list={currentWorkspace?.source_info.pages}
onSelect={onSelect}
canPreview={canPreview}
onPreview={onPreview}
/>
)
}
</div> </div>
</div> </div>
) )
......
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import cn from 'classnames' import cn from 'classnames'
import { XMarkIcon } from '@heroicons/react/24/outline' import { XMarkIcon } from '@heroicons/react/24/outline'
import NotionPageSelector from '../base' import NotionPageSelector from '../base'
import s from './index.module.css' import s from './index.module.css'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import type { DataSourceNotionPage } from '@/models/common'
type NotionPageSelectorModalProps = { type NotionPageSelectorModalProps = {
isShow: boolean isShow: boolean
onClose: () => void onClose: () => void
onSave: (selectedPages: DataSourceNotionPage[]) => void
} }
const NotionPageSelectorModal = ({ const NotionPageSelectorModal = ({
isShow, isShow,
onClose, onClose,
onSave,
}: NotionPageSelectorModalProps) => { }: NotionPageSelectorModalProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [selectedPages, setSelectedPages] = useState<DataSourceNotionPage[]>([])
const handleClose = () => { const handleClose = () => {
onClose() onClose()
} }
const handleSelectPage = (newSelectedPages: DataSourceNotionPage[]) => {
setSelectedPages(newSelectedPages)
}
const handleSave = () => {
onSave(selectedPages)
}
return ( return (
<Modal <Modal
...@@ -33,10 +44,13 @@ const NotionPageSelectorModal = ({ ...@@ -33,10 +44,13 @@ const NotionPageSelectorModal = ({
<XMarkIcon className='w-4 h-4' /> <XMarkIcon className='w-4 h-4' />
</div> </div>
</div> </div>
<NotionPageSelector /> <NotionPageSelector
onSelect={handleSelectPage}
canPreview={false}
/>
<div className='mt-8 flex justify-end'> <div className='mt-8 flex justify-end'>
<div className={s.operate} onClick={handleClose}>{t('common.operation.cancel')}</div> <div className={s.operate} onClick={handleClose}>{t('common.operation.cancel')}</div>
<div className={cn(s.operate, s['operate-save'])}>{t('common.operation.save')}</div> <div className={cn(s.operate, s['operate-save'])} onClick={handleSave}>{t('common.operation.save')}</div>
</div> </div>
</Modal> </Modal>
) )
......
...@@ -3,8 +3,9 @@ ...@@ -3,8 +3,9 @@
height: 20px; height: 20px;
background: url(../assets/down-arrow.svg) center center no-repeat; background: url(../assets/down-arrow.svg) center center no-repeat;
background-size: 16px 16px; background-size: 16px 16px;
transform: rotate(-90deg);
} }
.arrow-collapse { .arrow-expand {
transform: rotate(-90deg); transform: rotate(0);
} }
\ No newline at end of file
import { memo } from 'react' import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FixedSizeList as List, areEqual } from 'react-window' import { FixedSizeList as List, areEqual } from 'react-window'
import type { ListChildComponentProps } from 'react-window' import type { ListChildComponentProps } from 'react-window'
import cn from 'classnames' import cn from 'classnames'
...@@ -7,60 +8,216 @@ import NotionIcon from '../../notion-icon' ...@@ -7,60 +8,216 @@ import NotionIcon from '../../notion-icon'
import s from './index.module.css' import s from './index.module.css'
import type { DataSourceNotionPage } from '@/models/common' import type { DataSourceNotionPage } from '@/models/common'
const Item = memo(({ index, style, data }: ListChildComponentProps<{ list: DataSourceNotionPage[] }>) => { type PageSelectorProps = {
const current = data.list[index] list: DataSourceNotionPage[]
let src, name onSelect: (selectedPages: DataSourceNotionPage[]) => void
canPreview?: boolean
onPreview?: (selectedPage: DataSourceNotionPage) => void
}
type NotionPageMap = Record<string, DataSourceNotionPage>
type NotionPageTreeItem = {
children: Set<string>
descendants: Set<string>
deepth: number
} & DataSourceNotionPage
type NotionPageTreeMap = Record<string, NotionPageTreeItem>
type NotionPageItem = {
expand: boolean
deepth: number
} & DataSourceNotionPage
if (current.page_icon) { const recursivePushInParentDescendants = (
try { listMap: Record<string, DataSourceNotionPage>,
const icon = JSON.parse(current.page_icon) listTreeMap: NotionPageTreeMap,
current: NotionPageTreeItem,
leafItem: NotionPageTreeItem,
) => {
const parentId = current.parent_id
const pageId = current.page_id
if (icon?.type === 'emoji') if (parentId !== 'root') {
name = icon?.emoji if (!listTreeMap[parentId]) {
const children = new Set([pageId])
const descendants = new Set([pageId, leafItem.page_id])
if (icon?.type === 'external') listTreeMap[parentId] = {
src = icon?.external?.url ...listMap[parentId],
children,
descendants,
deepth: 0,
}
} }
catch (e: any) {} else {
listTreeMap[parentId].children.add(pageId)
listTreeMap[parentId].descendants.add(pageId)
listTreeMap[parentId].descendants.add(leafItem.page_id)
}
leafItem.deepth++
if (listTreeMap[parentId].parent_id !== 'root')
recursivePushInParentDescendants(listMap, listTreeMap, listTreeMap[parentId], leafItem)
} }
}
const Item = memo(({ index, style, data }: ListChildComponentProps<{
dataList: NotionPageItem[]
handleToggle: (index: number) => void
checkedIds: Set<string>
handleCheck: (index: number) => void
canPreview?: boolean
handlePreview: (index: number) => void
}>) => {
const { t } = useTranslation()
const { dataList, handleToggle, checkedIds, handleCheck, canPreview, handlePreview } = data
const current = dataList[index]
let iconSrc
if (current.page_icon && current.page_icon.type === 'url')
iconSrc = current.page_icon.url
if (current.page_icon && current.page_icon.type === 'emoji')
iconSrc = current.page_icon.emoji
return ( return (
<div <div
className='group flex items-center px-2 rounded-md hover:bg-gray-100 cursor-pointer' className='group flex items-center pl-2 pr-[2px] rounded-md hover:bg-gray-100 cursor-pointer'
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }} style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
> >
<Checkbox className='shrink-0 mr-2 group-hover:border-primary-600 group-hover:border-[2px]' /> <Checkbox
<div className={cn(s.arrow, s['arrow-collapse'], 'shrink-0 mr-1 w-5 h-5 hover:bg-gray-200 rounded-md')} /> className='shrink-0 mr-2 group-hover:border-primary-600 group-hover:border-[2px]'
checked={checkedIds.has(current.page_id)}
onCheck={() => handleCheck(index)}
/>
<div
className={cn(s.arrow, current.expand && s['arrow-expand'], 'shrink-0 mr-1 w-5 h-5 hover:bg-gray-200 rounded-md')}
style={{ marginLeft: current.deepth * 8 }}
onClick={() => handleToggle(index)}
/>
<NotionIcon <NotionIcon
className='shrink-0 mr-1' className='shrink-0 mr-1'
type='page' type='page'
src={src} src={iconSrc}
name={name}
/> />
<div <div
className='text-sm font-medium text-gray-700 truncate' className='grow text-sm font-medium text-gray-700 truncate'
title={current.page_name} title={current.page_name}
> >
{current.page_name} {current.page_name}
</div> </div>
{
canPreview && (
<div
className='shrink-0 hidden group-hover:flex items-center ml-4 px-2 h-6 rounded-md text-xs font-medium text-gray-700 cursor-pointer hover:bg-gray-50'
onClick={() => handlePreview(index)}>
{t('common.dataSource.notion.selector.preview')}
</div>
)
}
</div> </div>
) )
}, areEqual) }, areEqual)
type PageSelectorProps = {
list: DataSourceNotionPage[]
}
const PageSelector = ({ const PageSelector = ({
list, list,
onSelect,
canPreview,
onPreview,
}: PageSelectorProps) => { }: PageSelectorProps) => {
const [dataList, setDataList] = useState<NotionPageItem[]>(
list.filter(item => item.parent_id === 'root').map((item) => {
return {
...item,
expand: false,
deepth: 0,
}
}),
)
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
const listMap = list.reduce((prev: NotionPageMap, next: DataSourceNotionPage) => {
prev[next.page_id] = next
return prev
}, {})
const listMapWithChildrenAndDescendants = list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
const pageId = next.page_id
if (!prev[pageId])
prev[pageId] = { ...next, children: new Set(), descendants: new Set(), deepth: 0 }
recursivePushInParentDescendants(listMap, prev, prev[pageId], prev[pageId])
return prev
}, {})
const handleToggle = (index: number) => {
const current = dataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants)
const childrenIds = Array.from(currentWithChildrenAndDescendants.children)
let newDataList = []
if (current.expand) {
current.expand = false
newDataList = [...dataList.filter(item => !descendantsIds.includes(item.page_id))]
}
else {
current.expand = true
newDataList = [
...dataList.slice(0, index + 1),
...childrenIds.map(item => ({
...listMap[item],
expand: false,
deepth: listMapWithChildrenAndDescendants[item].deepth,
})),
...dataList.slice(index + 1)]
}
setDataList(newDataList)
}
const handleCheck = (index: number) => {
const current = dataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
if (checkedIds.has(pageId)) {
for (const item of currentWithChildrenAndDescendants.descendants)
checkedIds.delete(item)
checkedIds.delete(pageId)
}
else {
for (const item of currentWithChildrenAndDescendants.descendants)
checkedIds.add(item)
checkedIds.add(pageId)
}
setCheckedIds(new Set([...checkedIds]))
onSelect([...checkedIds].map(item => listMap[item]))
}
const handlePreview = (index: number) => {
if (onPreview) {
const current = dataList[index]
const pageId = current.page_id
onPreview(listMap[pageId])
}
}
return ( return (
<List <List
className='py-2' className='py-2'
height={296} height={296}
itemCount={list.length} itemCount={dataList.length}
itemSize={28} itemSize={28}
width='100%' width='100%'
itemData={{ list }} itemData={{
dataList,
handleToggle,
checkedIds,
handleCheck,
canPreview,
handlePreview,
}}
> >
{Item} {Item}
</List> </List>
......
...@@ -32,7 +32,7 @@ export default function WorkspaceSelector({ ...@@ -32,7 +32,7 @@ export default function WorkspaceSelector({
name={currentWorkspace?.workspace_name} name={currentWorkspace?.workspace_name}
/> />
<div className='mr-1 w-[90px] text-left text-sm font-medium text-gray-700 truncate' title={currentWorkspace?.workspace_name}>{currentWorkspace?.workspace_name}</div> <div className='mr-1 w-[90px] text-left text-sm font-medium text-gray-700 truncate' title={currentWorkspace?.workspace_name}>{currentWorkspace?.workspace_name}</div>
<div className='mr-1 w-5 h-[18px] bg-primary-50 rounded-lg text-xs font-medium text-primary-600'>{currentWorkspace?.total}</div> <div className='mr-1 px-1 h-[18px] bg-primary-50 rounded-lg text-xs font-medium text-primary-600'>{currentWorkspace?.total}</div>
<div className={cn(s['down-arrow'], 'mr-2 w-3 h-3')} /> <div className={cn(s['down-arrow'], 'mr-2 w-3 h-3')} />
</Menu.Button> </Menu.Button>
<Transition <Transition
......
...@@ -16,6 +16,7 @@ const DataSourceNotion = ({ ...@@ -16,6 +16,7 @@ const DataSourceNotion = ({
workspaces, workspaces,
}: DataSourceNotionProps) => { }: DataSourceNotionProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const connected = !!workspaces.length
return ( return (
<div className='mb-2 border-[0.5px] border-gray-200 bg-gray-50 rounded-xl'> <div className='mb-2 border-[0.5px] border-gray-200 bg-gray-50 rounded-xl'>
...@@ -26,7 +27,7 @@ const DataSourceNotion = ({ ...@@ -26,7 +27,7 @@ const DataSourceNotion = ({
{t('common.dataSource.notion.title')} {t('common.dataSource.notion.title')}
</div> </div>
{ {
!workspaces.length && ( !connected && (
<div className='leading-5 text-xs text-gray-500'> <div className='leading-5 text-xs text-gray-500'>
{t('common.dataSource.notion.description')} {t('common.dataSource.notion.description')}
</div> </div>
...@@ -34,7 +35,7 @@ const DataSourceNotion = ({ ...@@ -34,7 +35,7 @@ const DataSourceNotion = ({
} }
</div> </div>
{ {
!workspaces.length !connected
? ( ? (
<Link <Link
className='flex items-center ml-3 px-3 h-7 bg-white border border-gray-200 rounded-md text-xs font-medium text-gray-700 cursor-pointer' className='flex items-center ml-3 px-3 h-7 bg-white border border-gray-200 rounded-md text-xs font-medium text-gray-700 cursor-pointer'
...@@ -52,40 +53,48 @@ const DataSourceNotion = ({ ...@@ -52,40 +53,48 @@ const DataSourceNotion = ({
) )
} }
</div> </div>
<div className='flex items-center px-3 h-[18px]'> {
<div className='text-xs font-medium text-gray-500'> connected && (
{t('common.dataSource.notion.connectedWorkspace')} <div className='flex items-center px-3 h-[18px]'>
</div> <div className='text-xs font-medium text-gray-500'>
<div className='grow ml-3 border-t border-t-gray-100' /> {t('common.dataSource.notion.connectedWorkspace')}
</div>
<div className='px-3 pt-2 pb-3'>
{
workspaces.map(workspace => (
<div className={cn(s['workspace-item'], 'flex items-center mb-1 py-1 pr-1 bg-white rounded-lg')} key={workspace.id}>
<NotionIcon
className='ml-3 mr-[6px]'
src={workspace.source_info.workspace_icon || ''}
name={workspace.source_info.workspace_name}
/>
<div className='grow py-[7px] leading-[18px] text-[13px] font-medium text-gray-700'>{workspace.source_info.workspace_name}</div>
{
workspace.is_bound
? <Indicator className='mr-[6px]' />
: <Indicator className='mr-[6px]' color='yellow' />
}
<div className='mr-3 text-xs font-medium'>
{
workspace.is_bound
? t('common.dataSource.notion.connected')
: t('common.dataSource.notion.disconnected')
}
</div>
<div className='mr-2 w-[1px] h-3 bg-gray-100' />
<Operate workspace={workspace} />
</div> </div>
)) <div className='grow ml-3 border-t border-t-gray-100' />
} </div>
</div> )
}
{
connected && (
<div className='px-3 pt-2 pb-3'>
{
workspaces.map(workspace => (
<div className={cn(s['workspace-item'], 'flex items-center mb-1 py-1 pr-1 bg-white rounded-lg')} key={workspace.id}>
<NotionIcon
className='ml-3 mr-[6px]'
src={workspace.source_info.workspace_icon || ''}
name={workspace.source_info.workspace_name}
/>
<div className='grow py-[7px] leading-[18px] text-[13px] font-medium text-gray-700'>{workspace.source_info.workspace_name}</div>
{
workspace.is_bound
? <Indicator className='mr-[6px]' />
: <Indicator className='mr-[6px]' color='yellow' />
}
<div className='mr-3 text-xs font-medium'>
{
workspace.is_bound
? t('common.dataSource.notion.connected')
: t('common.dataSource.notion.disconnected')
}
</div>
<div className='mr-2 w-[1px] h-3 bg-gray-100' />
<Operate workspace={workspace} />
</div>
))
}
</div>
)
}
</div> </div>
) )
} }
......
...@@ -192,6 +192,7 @@ const translation = { ...@@ -192,6 +192,7 @@ const translation = {
searchPages: 'Search pages...', searchPages: 'Search pages...',
noSerachResult: 'No search resluts', noSerachResult: 'No search resluts',
addPages: 'Add pages', addPages: 'Add pages',
preview: 'PREVIEW',
}, },
}, },
}, },
......
...@@ -193,6 +193,7 @@ const translation = { ...@@ -193,6 +193,7 @@ const translation = {
searchPages: '搜索页面...', searchPages: '搜索页面...',
noSerachResult: '无搜索结果', noSerachResult: '无搜索结果',
addPages: '添加页面', addPages: '添加页面',
preview: '预览',
}, },
}, },
}, },
......
...@@ -101,9 +101,14 @@ export type IWorkspace = { ...@@ -101,9 +101,14 @@ export type IWorkspace = {
} }
export type DataSourceNotionPage = { export type DataSourceNotionPage = {
page_icon: string | null page_icon: null | {
type: string | null
url: string | null
emoji: string | null
}
page_id: string page_id: string
page_name: string page_name: string
parent_id: string
} }
export type DataSourceNotionWorkspace = { export type DataSourceNotionWorkspace = {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment