Merge pull request #3663 from omnivore-app/feature/notion-integration-web
Web UI for notion integration
This commit is contained in:
@ -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',
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
5
packages/web/components/templates/Beta.tsx
Normal file
5
packages/web/components/templates/Beta.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { Alert } from 'antd'
|
||||
|
||||
export function Beta(): JSX.Element {
|
||||
return <Alert message="Beta" type="warning" showIcon />
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
|
||||
207
packages/web/pages/settings/integrations/notion.tsx
Normal file
207
packages/web/pages/settings/integrations/notion.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
BIN
packages/web/public/static/icons/notion.png
Normal file
BIN
packages/web/public/static/icons/notion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Reference in New Issue
Block a user