Merge pull request #3663 from omnivore-app/feature/notion-integration-web

Web UI for notion integration
This commit is contained in:
Hongbo Wu
2024-03-13 19:42:29 +08:00
committed by GitHub
12 changed files with 303 additions and 78 deletions

View File

@ -34,7 +34,6 @@ import {
import { analytics } from '../../utils/analytics'
import {
deleteTask,
enqueueExportAllItems,
enqueueImportFromIntegration,
} from '../../utils/createTask'
import { authorized } from '../../utils/gql-utils'
@ -85,40 +84,6 @@ export const setIntegrationResolver = authorized<
// save integration
const integration = await saveIntegration(integrationToSave, uid)
if (integrationToSave.type === IntegrationType.Export && !input.id) {
const authToken = await createIntegrationToken({
uid,
token: integration.token,
})
if (!authToken) {
log.error('failed to create auth token', {
integrationId: integration.id,
})
return {
errorCodes: [SetIntegrationErrorCode.BadRequest],
}
}
// create a task to sync all the pages if new integration or enable integration (export type)
await enqueueExportAllItems(integration.id, uid)
} else if (integrationToSave.taskName) {
// delete the task if disable integration and task exists
const result = await deleteTask(integrationToSave.taskName)
if (result) {
log.info('task deleted', integrationToSave.taskName)
}
// update task name in integration
await updateIntegration(
integration.id,
{
taskName: null,
},
uid
)
integration.taskName = null
}
analytics.capture({
distinctId: uid,
event: 'integration_set',

View File

@ -80,7 +80,11 @@ export const saveIntegration = async (
userId: string
) => {
return authTrx(
async (t) => t.getRepository(Integration).save(integration),
async (t) => {
const repo = t.getRepository(Integration)
const newIntegration = await repo.save(integration)
return repo.findOneByOrFail({ id: newIntegration.id })
},
undefined,
userId
)

View File

@ -222,17 +222,6 @@ describe('Integrations resolvers', () => {
expect(res.body.data.setIntegration.integration.enabled).to.be
.false
})
it('deletes cloud task', async () => {
const res = await graphqlRequest(
query(integrationId, integrationName, token, enabled),
authToken
)
const integration = await findIntegration({
id: res.body.data.setIntegration.integration.id,
}, loginUser.id)
expect(integration?.taskName).to.be.null
})
})
context('when enable is true', () => {

View File

@ -0,0 +1,5 @@
import { Alert } from 'antd'
export function Beta(): JSX.Element {
return <Alert message="Beta" type="warning" showIcon />
}

View File

@ -1,27 +1,18 @@
import { useCallback, useMemo, useState } from 'react'
import { styled } from '@stitches/react'
import Image from 'next/image'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { FormInput } from '../../elements/FormElements'
import { useRouter } from 'next/router'
import { useCallback, useMemo, useState } from 'react'
import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation'
import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation'
import {
Integration,
useGetIntegrationsQuery,
} from '../../../lib/networking/queries/useGetIntegrationsQuery'
import { useRouter } from 'next/router'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation'
// Styles
const Header = styled(Box, {
color: '$utilityTextDefault',
fontSize: 'x-large',
margin: '20px',
})
import { Button } from '../../elements/Button'
import { FormInput } from '../../elements/FormElements'
import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { Header } from '../settings/SettingsTable'
export function Readwise(): JSX.Element {
const { integrations, revalidate } = useGetIntegrationsQuery()

View File

@ -6,11 +6,18 @@ import { MoreOptionsIcon } from '../../elements/images/MoreOptionsIcon'
import { InfoLink } from '../../elements/InfoLink'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { StyledText } from '../../elements/StyledText'
import { theme } from '../../tokens/stitches.config'
import { styled, theme } from '../../tokens/stitches.config'
import { SettingsLayout } from '../SettingsLayout'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
import { FeatureHelpBox } from '../../elements/FeatureHelpBox'
// Styles
export const Header = styled(Box, {
color: '$utilityTextDefault',
fontSize: 'x-large',
margin: '20px',
})
type SettingsTableProps = {
pageId: string
pageInfoLink?: string | undefined

View File

@ -16,6 +16,7 @@ export type SetIntegrationInput = {
token: string
enabled: boolean
importItemState?: ImportItemState
settings?: any
}
type SetIntegrationResult = {
@ -52,6 +53,7 @@ export async function setIntegrationMutation(
enabled
createdAt
updatedAt
settings
}
}
... on SetIntegrationError {

View File

@ -11,6 +11,7 @@ export interface Integration {
createdAt: Date
updatedAt: Date
taskName?: string
settings?: any
}
export type IntegrationType = 'EXPORT' | 'IMPORT'
@ -43,6 +44,7 @@ export function useGetIntegrationsQuery(): IntegrationsQueryResponse {
createdAt
updatedAt
taskName
settings
}
}
... on IntegrationsError {

View File

@ -3,7 +3,7 @@ const ContentSecurityPolicy = `
base-uri 'self';
connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://api.segment.io https://cdn.segment.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com;
font-src 'self' data: https://cdn.jsdelivr.net https://js.intercomcdn.com https://fonts.intercomcdn.com;
form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io;
form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io https://www.notion.so https://api.notion.com;
frame-ancestors 'none';
frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com;
manifest-src 'self';

View File

@ -89,6 +89,9 @@ export default function Integrations(): JSX.Element {
const pocketConnected = useMemo(() => {
return integrations.find((i) => i.name == 'POCKET' && i.type == 'IMPORT')
}, [integrations])
const isConnected = (name: string) => {
return integrations.find((i) => i.name == name)?.enabled
}
const deleteIntegration = async (id: string) => {
try {
@ -110,17 +113,23 @@ export default function Integrations(): JSX.Element {
}
}
const redirectToPocket = (importItemState: ImportItemState) => {
const redirectToIntegration = (
name: string,
importItemState?: ImportItemState
) => {
// create a form and submit it to the backend
const form = document.createElement('form')
form.method = 'POST'
form.action = `${fetchEndpoint}/integration/pocket/auth`
const input = document.createElement('input')
input.type = 'hidden'
input.name = 'state'
input.value = importItemState
form.appendChild(input)
form.action = `${fetchEndpoint}/integration/${name.toLowerCase()}/auth`
if (importItemState) {
const input = document.createElement('input')
input.type = 'hidden'
input.name = 'state'
input.value = importItemState
form.appendChild(input)
}
document.body.appendChild(form)
form.submit()
}
@ -155,13 +164,41 @@ export default function Integrations(): JSX.Element {
{ duration: 5000 }
)
} finally {
router.replace('/settings/integrations')
router.push('/settings/integrations')
}
}
const connectWithNotion = async () => {
try {
// get the token from query string
const token = router.query.code as string
await setIntegrationMutation({
token,
name: 'NOTION',
type: 'EXPORT',
enabled: true,
})
showSuccessToast('Connected with Notion.')
router.push('/settings/integrations/notion')
} catch (err) {
showErrorToast(
'There was an error connecting to Notion. Please try again.',
{ duration: 5000 }
)
router.push('/settings/integrations')
}
}
if (!router.isReady) return
if (router.query.pocketToken && router.query.state && !pocketConnected) {
connectToPocket()
}
if (router.query.code) {
connectWithNotion()
}
}, [router])
useEffect(() => {
@ -210,7 +247,7 @@ export default function Integrations(): JSX.Element {
action: () => {
pocketConnected
? deleteIntegration(pocketConnected.id)
: redirectToPocket(ImportItemState.Unarchived)
: redirectToIntegration('pocket', ImportItemState.Unarchived)
},
disabled: isImporting(pocketConnected),
isDropdown: !pocketConnected,
@ -218,18 +255,34 @@ export default function Integrations(): JSX.Element {
{
text: 'Import All',
action: () => {
redirectToPocket(ImportItemState.All)
redirectToIntegration('pocket', ImportItemState.All)
},
},
{
text: 'Import Unarchived',
action: () => {
redirectToPocket(ImportItemState.Unarchived)
redirectToIntegration('pocket', ImportItemState.Unarchived)
},
},
],
},
},
{
icon: '/static/icons/notion.png',
title: 'Notion',
subText:
'Notion is an all-in-one workspace. Use our Notion integration to sync your Omnivore items to Notion.',
button: {
text: isConnected('NOTION') ? 'Settings' : 'Connect',
icon: <Link size={16} weight={'bold'} />,
style: isConnected('NOTION') ? 'ctaWhite' : 'ctaDarkYellow',
action: () => {
isConnected('NOTION')
? router.push('/settings/integrations/notion')
: redirectToIntegration('NOTION')
},
},
},
{
icon: '/static/icons/webhooks.svg',
title: 'Webhooks',
@ -258,7 +311,7 @@ export default function Integrations(): JSX.Element {
},
},
])
}, [pocketConnected, readwiseConnected, webhooks])
}, [pocketConnected, readwiseConnected, webhooks, integrations])
return (
<SettingsLayout>

View File

@ -0,0 +1,207 @@
import {
Button,
Checkbox,
Form,
FormProps,
Input,
message,
Space,
Switch,
} from 'antd'
import 'antd/dist/antd.compact.css'
import { CheckboxValueType } from 'antd/lib/checkbox/Group'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { HStack, VStack } from '../../../components/elements/LayoutPrimitives'
import { PageMetaData } from '../../../components/patterns/PageMetaData'
import { Beta } from '../../../components/templates/Beta'
import { Header } from '../../../components/templates/settings/SettingsTable'
import { SettingsLayout } from '../../../components/templates/SettingsLayout'
import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation'
import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation'
import {
Integration,
useGetIntegrationsQuery,
} from '../../../lib/networking/queries/useGetIntegrationsQuery'
import { applyStoredTheme } from '../../../lib/themeUpdater'
import { showSuccessToast } from '../../../lib/toastHelpers'
type FieldType = {
parentPageId?: string
parentDatabaseId?: string
autoSync?: boolean
properties?: string[]
}
export default function Notion(): JSX.Element {
applyStoredTheme()
const router = useRouter()
const { integrations, revalidate } = useGetIntegrationsQuery()
const [notion, setNotion] = useState<Integration>()
const [form] = Form.useForm<FieldType>()
const [messageApi, contextHolder] = message.useMessage()
useEffect(() => {
const notion = integrations.find(
(i) => i.name == 'NOTION' && i.type == 'EXPORT'
)
if (notion) {
setNotion(notion)
form.setFieldsValue({
parentPageId: notion.settings?.parentPageId,
parentDatabaseId: notion.settings?.parentDatabaseId,
autoSync: notion.settings?.autoSync,
properties: notion.settings?.properties,
})
}
}, [form, integrations])
const deleteNotion = async () => {
if (!notion) {
throw new Error('Notion integration not found')
}
await deleteIntegrationMutation(notion.id)
showSuccessToast('Notion integration disconnected successfully.')
router.push('/settings/integrations')
}
const updateNotion = async (values: FieldType) => {
if (!notion) {
throw new Error('Notion integration not found')
}
await setIntegrationMutation({
id: notion.id,
name: notion.name,
type: notion.type,
token: notion.token,
enabled: notion.enabled,
settings: values,
})
}
const onFinish: FormProps<FieldType>['onFinish'] = async (values) => {
try {
await updateNotion(values)
revalidate()
messageApi.success('Notion settings updated successfully.')
} catch (error) {
messageApi.error('There was an error updating Notion settings.')
}
}
const onFinishFailed: FormProps<FieldType>['onFinishFailed'] = (
errorInfo
) => {
console.log('Failed:', errorInfo)
}
const onDataChange = (value: Array<CheckboxValueType>) => {
form.setFieldsValue({ properties: value.map((v) => v.toString()) })
}
return (
<>
{contextHolder}
<PageMetaData title="Notion" path="/integrations/notion" />
<SettingsLayout>
<VStack
css={{
margin: '0 auto',
width: '80%',
}}
>
<HStack
css={{
width: '100%',
pb: '$2',
borderBottom: '1px solid $utilityTextDefault',
pr: '$1',
}}
>
<Image
src="/static/icons/notion.png"
alt="Integration Image"
width={75}
height={75}
/>
<Header>Notion integration settings</Header>
<Beta />
</HStack>
{notion && (
<div style={{ width: '100%', marginTop: '40px' }}>
<Form
labelCol={{ span: 6 }}
wrapperCol={{ span: 8 }}
labelAlign="left"
form={form}
onFinish={onFinish}
onFinishFailed={onFinishFailed}
>
<Form.Item<FieldType>
label="Notion Page Id"
name="parentPageId"
rules={[
{
required: true,
message: 'Please input your Notion Page Id!',
},
]}
>
<Input />
</Form.Item>
<Form.Item<FieldType>
label="Notion Database Id"
name="parentDatabaseId"
hidden
>
<Input disabled />
</Form.Item>
<Form.Item<FieldType>
label="Automatic Sync"
name="autoSync"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item<FieldType>
label="Properties to Export"
name="properties"
>
<Checkbox.Group onChange={onDataChange}>
<Checkbox value="highlights">Highlights</Checkbox>
<Checkbox value="labels">Labels</Checkbox>
<Checkbox value="notes">Notes</Checkbox>
</Checkbox.Group>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
Save
</Button>
<Button type="primary" danger onClick={deleteNotion}>
Disconnect
</Button>
</Space>
</Form.Item>
</Form>
</div>
)}
</VStack>
</SettingsLayout>
</>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB