Skip to content

Commit

Permalink
[#163] Add notion.so support (#224)
Browse files Browse the repository at this point in the history
This adds support for the notion.so tracker. We can distinguish two
types of views onto tickets:
1. The modal overlay when clicking on a ticket on a board
2. The single page view of a ticket

Note: Tickets are "pages" in notion.so's domain.

Mention support for notion in Readme and popup

[closes #163]

Co-authored-by: Paul Meinhardt <paul@bitcrowd.net>
  • Loading branch information
klappradla and pmeinhardt committed Apr 16, 2020
1 parent 50ceb9b commit f30a1e2
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Currently, we support:
- [GitHub](/~https://github.com/)
- [GitLab](https://gitlab.com/)
- [Jira](https://www.atlassian.com/software/jira)
- [Notion](https://www.notion.so/)
- [Trello](https://trello.com/)

Your ticket system is missing? Go ahead and implement an adapter for it! We are always happy about contributions and here to support you 👋.
Expand Down
78 changes: 78 additions & 0 deletions src/common/adapters/notion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* notion.so adapter
*
* The adapter extracts the UUID of a selected notion.so task ("page") from the
* page URL and uses the notion.so API to retrieve the corresponding task
* information.
*
* Note: this adapter uses notion.so's internal v3 API as there is no public
* API yet.
*/

import { match } from 'micro-match';

import client from '../client';

/**
* Turns a slugged page ID without dashes into a dasherized RFC 4122 UUID.
* UUID-Format: 96ec637d-e4b0-4a5e-acf3-8d4d9a1b2e4b
*/
function uuid(slugId) {
return [
slugId.substring(0, 8),
slugId.substring(8, 12),
slugId.substring(12, 16),
slugId.substring(16, 20),
slugId.substring(20),
].join('-');
}

function getPageFromPath(path) {
const { slug } = match('/:organization/:slug', path);
if (!slug) return null;

return slug.replace(/.*-/, ''); // strip title from slug
}

function getSelectedPageId(loc) {
const { pathname: path, searchParams: params } = new URL(loc.href);
const isPageModal = params.has('p');
const slugId = isPageModal ? params.get('p') : getPageFromPath(path);

if (!slugId) return null;
return uuid(slugId);
}

function extractTicketInfo(result) {
const { value } = result;
if (!value) return null;

const { id, type } = value;
if (type !== 'page') return null;

const title = value.properties.title[0][0];
return { id, title, type };
}

function getTickets(response, id) {
return (response.results || [])
.map(extractTicketInfo)
.filter(Boolean)
.filter((t) => t.id === id);
}

async function scan(loc) {
if (loc.host !== 'www.notion.so') return [];

const id = getSelectedPageId(loc);

if (!id) return [];

const api = client(`https://${loc.host}`);
const request = { json: { requests: [{ table: 'block', id }] } };
const response = await api.post('api/v3/getRecordValues', request).json();

return getTickets(response, id);
}

export default scan;
127 changes: 127 additions & 0 deletions src/common/adapters/notion.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import client from '../client';
import loc from './__helpers__/location';
import scan from './notion';

jest.mock('../client', () => jest.fn());

describe('notion adapter', () => {
const id = '5b1d7dd7-9107-4890-b2ec-83175b8eda83';
const title = 'Add notion.so support';
const slugId = '5b1d7dd791074890b2ec83175b8eda83';
const slug = `Add-notion-so-support-${slugId}`;
const response = {
results: [
{
role: 'editor',
value: {
id,
type: 'page',
properties: { title: [[title]] },
},
},
],
};

const ticket = { id, title, type: 'page' };
const api = { post: jest.fn() };

beforeEach(() => {
api.post.mockReturnValue({ json: () => response });
client.mockReturnValue(api);
});

afterEach(() => {
api.post.mockReset();
client.mockReset();
});

it('returns an empty array when not on a www.notion.so page', async () => {
const result = await scan(loc('another-domain.com'));
expect(api.post).not.toHaveBeenCalled();
expect(result).toEqual([]);
});

it('uses the notion.so api', async () => {
const location = loc('www.notion.so', `/notionuser/${slug}`);
await scan(location);
expect(client).toHaveBeenCalledWith('https://www.notion.so');
expect(api.post).toHaveBeenCalled();
});

it('returns an empty array when the current page is a board view', async () => {
const res = {
results: [
{
role: 'editor',
value: { id, type: 'collection_view_page' },
},
],
};
api.post.mockReturnValueOnce({ json: () => res });

const location = loc(
'www.notion.so',
`/notionuser/${slugId}`,
'?v=77ff97cab6ff4beab7fa6e27f992dd5e'
);
const result = await scan(location);
const request = { requests: [{ table: 'block', id }] };
expect(api.post).toHaveBeenCalledWith('api/v3/getRecordValues', {
json: request,
});
expect(result).toEqual([]);
});

it('returns an emtpy array when the page does not exist', async () => {
const res = { results: [{ role: 'editor' }] };
api.post.mockReturnValueOnce({ json: () => res });

const location = loc('www.notion.so', `/notionuser/${slug}`);
const result = await scan(location);
const request = { requests: [{ table: 'block', id }] };
expect(api.post).toHaveBeenCalledWith('api/v3/getRecordValues', {
json: request,
});
expect(result).toEqual([]);
});

it('returns an empty array if the page id does not match the requested one', async () => {
const otherId = '7c1e7ee7-9107-4890-b2ec-83175b8edv99';
const otherSlugId = otherId.replace(/-/g, '');

const location = loc(
'www.notion.so',
`/notionuser/Some-ticket-${otherSlugId}`
);
const result = await scan(location);
const request = { requests: [{ table: 'block', id: otherId }] };
expect(api.post).toHaveBeenCalledWith('api/v3/getRecordValues', {
json: request,
});
expect(result).toEqual([]);
});

it('extracts tickets from page modals (board view)', async () => {
const location = loc(
'www.notion.so',
'/notionuser/0e8608aa770a4d36a246d7a3c64f51af',
`?v=77ff97cab6ff4beab7fa6e27f992dd5e&p=${slugId}`
);
const result = await scan(location);
const request = { requests: [{ table: 'block', id }] };
expect(api.post).toHaveBeenCalledWith('api/v3/getRecordValues', {
json: request,
});
expect(result).toEqual([ticket]);
});

it('extracts tickets from the page view', async () => {
const location = loc('www.notion.so', `/notionuser/${slug}`);
const result = await scan(location);
const request = { requests: [{ table: 'block', id }] };
expect(api.post).toHaveBeenCalledWith('api/v3/getRecordValues', {
json: request,
});
expect(result).toEqual([ticket]);
});
});
2 changes: 1 addition & 1 deletion src/common/popup/components/no-tickets.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function Hint() {
<p>
Tickety-Tick currently supports
<br />
GitHub, GitLab, Jira and Trello.
GitHub, GitLab, Jira, Trello and Notion.
</p>
<h6>Missing anything or found a bug?</h6>
<p className="pb-1">
Expand Down
3 changes: 2 additions & 1 deletion src/common/search.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import GitHub from './adapters/github';
import GitLab from './adapters/gitlab';
import Jira from './adapters/jira';
import Notion from './adapters/notion';
import Trello from './adapters/trello';
import serializable from './utils/serializable-errors';

Expand Down Expand Up @@ -29,6 +30,6 @@ export async function search(adapters, loc, doc) {
return aggregate(results);
}

export const stdadapters = [GitHub, GitLab, Jira, Trello];
export const stdadapters = [GitHub, GitLab, Jira, Notion, Trello];

export default search.bind(null, stdadapters);

0 comments on commit f30a1e2

Please sign in to comment.