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 s from './index.module.css'
type CheckboxProps = {
checked?: boolean
......@@ -9,7 +10,7 @@ type CheckboxProps = {
const Checkbox = ({ checked, onCheck, className }: CheckboxProps) => {
return (
<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}
/>
)
......
......@@ -35,16 +35,15 @@ const NotionIcon = ({
}
if (src) {
return (
<img
alt='workspace icon'
src={src}
className={cn('block object-cover w-5 h-5', className)}
/>
)
}
if (name) {
if (src.includes('https://')) {
return (
<img
alt='page icon'
src={src}
className={cn('block object-cover w-5 h-5', className)}
/>
)
}
return (
<div className={cn('flex items-center justify-center w-5 h-5', className)}>{src}</div>
)
......
......@@ -6,8 +6,19 @@ import WorkspaceSelector from './workspace-selector'
import SearchInput from './search-input'
import PageSelector from './page-selector'
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 { data } = useSWR({ url: 'data-source/integrates' }, fetchDataSource)
const notionWorkspaces = data?.data.filter(item => item.provider === 'notion') || []
......@@ -42,7 +53,16 @@ const NotionPageSelector = () => {
/>
</div>
<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>
)
......
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import { XMarkIcon } from '@heroicons/react/24/outline'
import NotionPageSelector from '../base'
import s from './index.module.css'
import Modal from '@/app/components/base/modal'
import type { DataSourceNotionPage } from '@/models/common'
type NotionPageSelectorModalProps = {
isShow: boolean
onClose: () => void
onSave: (selectedPages: DataSourceNotionPage[]) => void
}
const NotionPageSelectorModal = ({
isShow,
onClose,
onSave,
}: NotionPageSelectorModalProps) => {
const { t } = useTranslation()
const [selectedPages, setSelectedPages] = useState<DataSourceNotionPage[]>([])
const handleClose = () => {
onClose()
}
const handleSelectPage = (newSelectedPages: DataSourceNotionPage[]) => {
setSelectedPages(newSelectedPages)
}
const handleSave = () => {
onSave(selectedPages)
}
return (
<Modal
......@@ -33,10 +44,13 @@ const NotionPageSelectorModal = ({
<XMarkIcon className='w-4 h-4' />
</div>
</div>
<NotionPageSelector />
<NotionPageSelector
onSelect={handleSelectPage}
canPreview={false}
/>
<div className='mt-8 flex justify-end'>
<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>
</Modal>
)
......
......@@ -3,8 +3,9 @@
height: 20px;
background: url(../assets/down-arrow.svg) center center no-repeat;
background-size: 16px 16px;
transform: rotate(-90deg);
}
.arrow-collapse {
transform: rotate(-90deg);
.arrow-expand {
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 type { ListChildComponentProps } from 'react-window'
import cn from 'classnames'
......@@ -7,60 +8,216 @@ import NotionIcon from '../../notion-icon'
import s from './index.module.css'
import type { DataSourceNotionPage } from '@/models/common'
const Item = memo(({ index, style, data }: ListChildComponentProps<{ list: DataSourceNotionPage[] }>) => {
const current = data.list[index]
let src, name
type PageSelectorProps = {
list: DataSourceNotionPage[]
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) {
try {
const icon = JSON.parse(current.page_icon)
const recursivePushInParentDescendants = (
listMap: Record<string, DataSourceNotionPage>,
listTreeMap: NotionPageTreeMap,
current: NotionPageTreeItem,
leafItem: NotionPageTreeItem,
) => {
const parentId = current.parent_id
const pageId = current.page_id
if (icon?.type === 'emoji')
name = icon?.emoji
if (parentId !== 'root') {
if (!listTreeMap[parentId]) {
const children = new Set([pageId])
const descendants = new Set([pageId, leafItem.page_id])
if (icon?.type === 'external')
src = icon?.external?.url
listTreeMap[parentId] = {
...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 (
<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)' }}
>
<Checkbox className='shrink-0 mr-2 group-hover:border-primary-600 group-hover:border-[2px]' />
<div className={cn(s.arrow, s['arrow-collapse'], 'shrink-0 mr-1 w-5 h-5 hover:bg-gray-200 rounded-md')} />
<Checkbox
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
className='shrink-0 mr-1'
type='page'
src={src}
name={name}
src={iconSrc}
/>
<div
className='text-sm font-medium text-gray-700 truncate'
className='grow text-sm font-medium text-gray-700 truncate'
title={current.page_name}
>
{current.page_name}
</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>
)
}, areEqual)
type PageSelectorProps = {
list: DataSourceNotionPage[]
}
const PageSelector = ({
list,
onSelect,
canPreview,
onPreview,
}: 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 (
<List
className='py-2'
height={296}
itemCount={list.length}
itemCount={dataList.length}
itemSize={28}
width='100%'
itemData={{ list }}
itemData={{
dataList,
handleToggle,
checkedIds,
handleCheck,
canPreview,
handlePreview,
}}
>
{Item}
</List>
......
......@@ -32,7 +32,7 @@ export default function WorkspaceSelector({
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-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')} />
</Menu.Button>
<Transition
......
......@@ -16,6 +16,7 @@ const DataSourceNotion = ({
workspaces,
}: DataSourceNotionProps) => {
const { t } = useTranslation()
const connected = !!workspaces.length
return (
<div className='mb-2 border-[0.5px] border-gray-200 bg-gray-50 rounded-xl'>
......@@ -26,7 +27,7 @@ const DataSourceNotion = ({
{t('common.dataSource.notion.title')}
</div>
{
!workspaces.length && (
!connected && (
<div className='leading-5 text-xs text-gray-500'>
{t('common.dataSource.notion.description')}
</div>
......@@ -34,7 +35,7 @@ const DataSourceNotion = ({
}
</div>
{
!workspaces.length
!connected
? (
<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'
......@@ -52,40 +53,48 @@ const DataSourceNotion = ({
)
}
</div>
<div className='flex items-center px-3 h-[18px]'>
<div className='text-xs font-medium text-gray-500'>
{t('common.dataSource.notion.connectedWorkspace')}
</div>
<div className='grow ml-3 border-t border-t-gray-100' />
</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} />
{
connected && (
<div className='flex items-center px-3 h-[18px]'>
<div className='text-xs font-medium text-gray-500'>
{t('common.dataSource.notion.connectedWorkspace')}
</div>
))
}
</div>
<div className='grow ml-3 border-t border-t-gray-100' />
</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>
)
}
......
......@@ -192,6 +192,7 @@ const translation = {
searchPages: 'Search pages...',
noSerachResult: 'No search resluts',
addPages: 'Add pages',
preview: 'PREVIEW',
},
},
},
......
......@@ -193,6 +193,7 @@ const translation = {
searchPages: '搜索页面...',
noSerachResult: '无搜索结果',
addPages: '添加页面',
preview: '预览',
},
},
},
......
......@@ -101,9 +101,14 @@ export type IWorkspace = {
}
export type DataSourceNotionPage = {
page_icon: string | null
page_icon: null | {
type: string | null
url: string | null
emoji: string | null
}
page_id: string
page_name: string
parent_id: string
}
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