feat: Improved onboarding documents (#970)

* feat: New onboarding documents

* Images -> blocks

* Add tips

* test: removes assumptions of welcome documents

this actually results in the tests being much more understandable too

* add db flag when document was created from welcome flow
This commit is contained in:
Tom Moor
2019-07-04 10:33:00 -07:00
committed by GitHub
parent eb3a1dd673
commit ccc0906b0a
12 changed files with 163 additions and 111 deletions

View File

@ -188,7 +188,7 @@ describe('#documents.list', async () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2); expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(document.id); expect(body.data[0].id).toEqual(document.id);
}); });
@ -203,7 +203,7 @@ describe('#documents.list', async () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1); expect(body.data.length).toEqual(0);
}); });
it('should not return documents in private collections not a member of', async () => { it('should not return documents in private collections not a member of', async () => {
@ -222,13 +222,20 @@ describe('#documents.list', async () => {
it('should allow changing sort direction', async () => { it('should allow changing sort direction', async () => {
const { user, document } = await seed(); const { user, document } = await seed();
const anotherDoc = await buildDocument({
title: 'another document',
text: 'random text',
userId: user.id,
teamId: user.teamId,
});
const res = await server.post('/api/documents.list', { const res = await server.post('/api/documents.list', {
body: { token: user.getJwtToken(), direction: 'ASC' }, body: { token: user.getJwtToken(), direction: 'ASC' },
}); });
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data[1].id).toEqual(document.id); expect(body.data[0].id).toEqual(document.id);
expect(body.data[1].id).toEqual(anotherDoc.id);
}); });
it('should allow filtering by collection', async () => { it('should allow filtering by collection', async () => {
@ -242,7 +249,7 @@ describe('#documents.list', async () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2); expect(body.data.length).toEqual(1);
}); });
it('should require authentication', async () => { it('should require authentication', async () => {
@ -339,7 +346,7 @@ describe('#documents.search', async () => {
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1); expect(body.data.length).toEqual(1);
expect(body.data[0].document.text).toEqual('# Much guidance'); expect(body.data[0].document.text).toEqual('# Much test support');
}); });
it('should return results in ranked order', async () => { it('should return results in ranked order', async () => {

View File

@ -0,0 +1,12 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'isWelcome', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'isWelcome');
}
}

View File

@ -5,7 +5,6 @@ import randomstring from 'randomstring';
import { DataTypes, sequelize } from '../sequelize'; import { DataTypes, sequelize } from '../sequelize';
import Document from './Document'; import Document from './Document';
import CollectionUser from './CollectionUser'; import CollectionUser from './CollectionUser';
import { welcomeMessage } from '../utils/onboarding';
slug.defaults.mode = 'rfc3986'; slug.defaults.mode = 'rfc3986';
@ -37,33 +36,6 @@ const Collection = sequelize.define(
beforeValidate: (collection: Collection) => { beforeValidate: (collection: Collection) => {
collection.urlId = collection.urlId || randomstring.generate(10); collection.urlId = collection.urlId || randomstring.generate(10);
}, },
afterCreate: async (collection: Collection) => {
const team = await collection.getTeam();
const collections = await team.getCollections();
// Don't auto-create for journal types, yet
if (collection.type !== 'atlas') return;
if (collections.length < 2) {
// Create intro document if first collection for team
const document = await Document.create({
parentDocumentId: null,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
lastModifiedById: collection.creatorId,
createdById: collection.creatorId,
publishedAt: new Date(),
title: 'Welcome to Outline',
text: welcomeMessage(collection.id),
});
collection.documentStructure = [document.toJSON()];
} else {
// Let user create first document
collection.documentStructure = [];
}
await collection.save();
},
}, },
getterMethods: { getterMethods: {
url() { url() {
@ -140,7 +112,9 @@ Collection.prototype.addDocumentToStructure = async function(
index: number, index: number,
options = {} options = {}
) { ) {
if (!this.documentStructure) return; if (!this.documentStructure) {
this.documentStructure = [];
}
let transaction; let transaction;

View File

@ -24,8 +24,8 @@ describe('#addDocumentToStructure', async () => {
}); });
await collection.addDocumentToStructure(newDocument); await collection.addDocumentToStructure(newDocument);
expect(collection.documentStructure.length).toBe(3); expect(collection.documentStructure.length).toBe(2);
expect(collection.documentStructure[2].id).toBe(id); expect(collection.documentStructure[1].id).toBe(id);
}); });
test('should add with an index', async () => { test('should add with an index', async () => {
@ -38,7 +38,7 @@ describe('#addDocumentToStructure', async () => {
}); });
await collection.addDocumentToStructure(newDocument, 1); await collection.addDocumentToStructure(newDocument, 1);
expect(collection.documentStructure.length).toBe(3); expect(collection.documentStructure.length).toBe(2);
expect(collection.documentStructure[1].id).toBe(id); expect(collection.documentStructure[1].id).toBe(id);
}); });
@ -52,10 +52,10 @@ describe('#addDocumentToStructure', async () => {
}); });
await collection.addDocumentToStructure(newDocument, 1); await collection.addDocumentToStructure(newDocument, 1);
expect(collection.documentStructure.length).toBe(2); expect(collection.documentStructure.length).toBe(1);
expect(collection.documentStructure[1].id).toBe(document.id); expect(collection.documentStructure[0].id).toBe(document.id);
expect(collection.documentStructure[1].children.length).toBe(1); expect(collection.documentStructure[0].children.length).toBe(1);
expect(collection.documentStructure[1].children[0].id).toBe(id); expect(collection.documentStructure[0].children[0].id).toBe(id);
}); });
test('should add as a child if with parent with index', async () => { test('should add as a child if with parent with index', async () => {
@ -74,10 +74,10 @@ describe('#addDocumentToStructure', async () => {
await collection.addDocumentToStructure(newDocument); await collection.addDocumentToStructure(newDocument);
await collection.addDocumentToStructure(secondDocument, 0); await collection.addDocumentToStructure(secondDocument, 0);
expect(collection.documentStructure.length).toBe(2); expect(collection.documentStructure.length).toBe(1);
expect(collection.documentStructure[1].id).toBe(document.id); expect(collection.documentStructure[0].id).toBe(document.id);
expect(collection.documentStructure[1].children.length).toBe(2); expect(collection.documentStructure[0].children.length).toBe(2);
expect(collection.documentStructure[1].children[0].id).toBe(id); expect(collection.documentStructure[0].children[0].id).toBe(id);
}); });
describe('options: documentJson', async () => { describe('options: documentJson', async () => {
@ -101,8 +101,8 @@ describe('#addDocumentToStructure', async () => {
], ],
}, },
}); });
expect(collection.documentStructure[2].children.length).toBe(1); expect(collection.documentStructure[1].children.length).toBe(1);
expect(collection.documentStructure[2].children[0].id).toBe(id); expect(collection.documentStructure[1].children[0].id).toBe(id);
}); });
}); });
}); });
@ -112,16 +112,15 @@ describe('#updateDocument', () => {
const { collection, document } = await seed(); const { collection, document } = await seed();
document.title = 'Updated title'; document.title = 'Updated title';
await document.save();
await document.save();
await collection.updateDocument(document); await collection.updateDocument(document);
expect(collection.documentStructure[1].title).toBe('Updated title'); expect(collection.documentStructure[0].title).toBe('Updated title');
}); });
test("should update child document's data", async () => { test("should update child document's data", async () => {
const { collection, document } = await seed(); const { collection, document } = await seed();
// Add a child for testing
const newDocument = await Document.create({ const newDocument = await Document.create({
parentDocumentId: document.id, parentDocumentId: document.id,
collectionId: collection.id, collectionId: collection.id,
@ -139,7 +138,7 @@ describe('#updateDocument', () => {
await collection.updateDocument(newDocument); await collection.updateDocument(newDocument);
expect(collection.documentStructure[1].children[0].title).toBe( expect(collection.documentStructure[0].children[0].title).toBe(
'Updated title' 'Updated title'
); );
}); });
@ -158,7 +157,7 @@ describe('#removeDocument', () => {
const { collection, document } = await seed(); const { collection, document } = await seed();
await collection.deleteDocument(document); await collection.deleteDocument(document);
expect(collection.documentStructure.length).toBe(1); expect(collection.documentStructure.length).toBe(0);
// Verify that the document was removed // Verify that the document was removed
const collectionDocuments = await Document.findAndCountAll({ const collectionDocuments = await Document.findAndCountAll({
@ -166,7 +165,7 @@ describe('#removeDocument', () => {
collectionId: collection.id, collectionId: collection.id,
}, },
}); });
expect(collectionDocuments.count).toBe(1); expect(collectionDocuments.count).toBe(0);
}); });
test('should remove a document with child documents', async () => { test('should remove a document with child documents', async () => {
@ -184,17 +183,17 @@ describe('#removeDocument', () => {
text: 'content', text: 'content',
}); });
await collection.addDocumentToStructure(newDocument); await collection.addDocumentToStructure(newDocument);
expect(collection.documentStructure[1].children.length).toBe(1); expect(collection.documentStructure[0].children.length).toBe(1);
// Remove the document // Remove the document
await collection.deleteDocument(document); await collection.deleteDocument(document);
expect(collection.documentStructure.length).toBe(1); expect(collection.documentStructure.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({ const collectionDocuments = await Document.findAndCountAll({
where: { where: {
collectionId: collection.id, collectionId: collection.id,
}, },
}); });
expect(collectionDocuments.count).toBe(1); expect(collectionDocuments.count).toBe(0);
}); });
test('should remove a child document', async () => { test('should remove a child document', async () => {
@ -213,21 +212,20 @@ describe('#removeDocument', () => {
text: 'content', text: 'content',
}); });
await collection.addDocumentToStructure(newDocument); await collection.addDocumentToStructure(newDocument);
expect(collection.documentStructure.length).toBe(2); expect(collection.documentStructure.length).toBe(1);
expect(collection.documentStructure[1].children.length).toBe(1); expect(collection.documentStructure[0].children.length).toBe(1);
// Remove the document // Remove the document
await collection.deleteDocument(newDocument); await collection.deleteDocument(newDocument);
expect(collection.documentStructure.length).toBe(2); expect(collection.documentStructure.length).toBe(1);
expect(collection.documentStructure[0].children.length).toBe(0); expect(collection.documentStructure[0].children.length).toBe(0);
expect(collection.documentStructure[1].children.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({ const collectionDocuments = await Document.findAndCountAll({
where: { where: {
collectionId: collection.id, collectionId: collection.id,
}, },
}); });
expect(collectionDocuments.count).toBe(2); expect(collectionDocuments.count).toBe(1);
}); });
}); });

View File

@ -88,6 +88,7 @@ const Document = sequelize.define(
}, },
}, },
text: DataTypes.TEXT, text: DataTypes.TEXT,
isWelcome: { type: DataTypes.BOOLEAN, defaultValue: false },
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 }, revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
archivedAt: DataTypes.DATE, archivedAt: DataTypes.DATE,
publishedAt: DataTypes.DATE, publishedAt: DataTypes.DATE,

View File

@ -1,16 +1,23 @@
// @flow // @flow
import uuid from 'uuid'; import uuid from 'uuid';
import { URL } from 'url'; import { URL } from 'url';
import fs from 'fs';
import util from 'util';
import path from 'path';
import { DataTypes, sequelize, Op } from '../sequelize'; import { DataTypes, sequelize, Op } from '../sequelize';
import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3'; import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3';
import { import {
stripSubdomain, stripSubdomain,
RESERVED_SUBDOMAINS, RESERVED_SUBDOMAINS,
} from '../../shared/utils/domains'; } from '../../shared/utils/domains';
import parseTitle from '../../shared/utils/parseTitle';
import Collection from './Collection'; import Collection from './Collection';
import Document from './Document';
import User from './User'; import User from './User';
const readFile = util.promisify(fs.readFile);
const Team = sequelize.define( const Team = sequelize.define(
'team', 'team',
{ {
@ -112,13 +119,37 @@ Team.prototype.provisionSubdomain = async function(subdomain) {
}; };
Team.prototype.provisionFirstCollection = async function(userId) { Team.prototype.provisionFirstCollection = async function(userId) {
return await Collection.create({ const collection = await Collection.create({
name: 'General', name: 'Welcome',
description: '', description:
'This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!',
type: 'atlas', type: 'atlas',
teamId: this.id, teamId: this.id,
creatorId: userId, creatorId: userId,
}); });
// For the first collection we go ahead and create some intitial documents to get
// the team started. You can edit these in /server/onboarding/x.md
const onboardingDocs = ['support', 'integrations', 'editor', 'philosophy'];
for (const name of onboardingDocs) {
const text = await readFile(
path.join(__dirname, '..', 'onboarding', `${name}.md`),
'utf8'
);
const { title } = parseTitle(text);
const document = await Document.create({
isWelcome: true,
parentDocumentId: null,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
lastModifiedById: collection.creatorId,
createdById: collection.creatorId,
title,
text,
});
await document.publish();
}
}; };
Team.prototype.addAdmin = async function(user: User) { Team.prototype.addAdmin = async function(user: User) {

View File

@ -0,0 +1,17 @@
# 📝 Our Editor
The heart of Outline is the document editor. We let you write in the way that you prefer be it Markdown, WYSIWYG, or taking advantage of the many keyboard shortcuts.
![The formatting toolbar](https://s3.amazonaws.com/dev.beautifulatlas.com/uploads/e2b85962-ca66-4e4c-90d3-b32d30f0610c/754830c0-2aca-467c-82de-2fd6e990b696/Group.png)
## Markdown
If youre comfortable writing markdown then all of the usual shortcuts are supported, for example type \*\*bold\*\* and hit `space` to instantly create bold text. If you forget some syntax or are after a refresher, simply type `?` to access the keyboard shortcut help.
*Tip:* You can even paste markdown from elsewhere directly into a document.
## Blocks
The editor supports a variety of content blocks including images, tables, lists, quotes, and more. You can also drag and drop images to include them in your document or paste a link to embed content from one of the many supported [integrations](/integrations)
*Tip:* Headings are collapsible, just click the arrow to the left of any heading to temporarily hide the content below.

View File

@ -0,0 +1,28 @@
# 🚀 Integrations & API
## Integrations
Outline supports tons of the most popular tools on the market out of the box. Just paste links to a YouTube video, Figma design, or Realtimeboard to get instant live-embeds in your documents.
Our integration code is [open-source](https://github.com/outline/outline) and we encourage third party developers and the community to build support for additional tools! Find out more on our [integrations directory](https://www.getoutline.com/integrations).
*Tip:* Most integrations work by simply pasting a link from a supported service into a document.
## Slack
If your team is using Slack to communicate then youll definitely want to enable our [Slack App](https://getoutline.slack.com/apps/A0W3UMKBQ-outline) to get instant link unfurling for Outline documents and access to the `/outline` slash command to search your knowledgebase from within Slack.
## API
Have some technical skills? Outline is built on a fully featured RPC-style [API](https://www.getoutline.com/developers). Create (or even append to) documents, collections, provision users, and more programmatically. All documents are edited and stored in markdown format try out this CURL request!
```bash
curl -XPOST -H "Content-type: application/json" -d '{
"title": "My first document",
"text": "# My first document \n Hello from the API 👋",
"collectionId": "COLLECTION_ID", // find the collection id in the URL bar
"token": "API_TOKEN", // get an API token from https://www.getoutline.com/settings/tokens
"publish": true
}' 'https://www.getoutline.com/api/documents.create'
```

View File

@ -0,0 +1,19 @@
# 👋 What is Outline
Outline is a place to build your team knowledge base, you could think of it like your teams shared library a place for important documentation, notes, and ideas to live and be discovered. Some things you might want to keep in Outline:
- Documentation
- Sales playbooks
- Support scripts
- Onboarding
- HR documents
- Meeting notes
- …and more
## Structure
Outline allows you to organize documents in "collections", for example these could represent topics like Sales, Product, or HR. Within collections documents can be interlinked and deeply nested to easily build relationships within your knowledgebase.
## Search
Outline is built to be crazy fast, and that includes [search](/search). You can start searching from anywhere with the `CMD+K` shortcut. Then filter by time, author, and more to get to the info you need.

View File

@ -0,0 +1,9 @@
# ❤️ Support
We hate bugs as much as you and do everything possible to keep the app bug-free. Help us out by getting in touch with the team if you see any problems with Outline. You can email [hello@getoutline.com](hello@getoutline.com) directly and well get back to you (hopefully with a fix!) as soon as possible.
If you already have a GitHub account then you can also submit issues directly to the development team on our [open issue tracker](https://github.com/outline/outline/issues).
## Ideas
Wed love to hear your ideas about how Outline can be improved and features you would like to see built. The best place to let the team know is through our [Spectrum community](https://spectrum.chat/outline).

View File

@ -70,11 +70,10 @@ const seed = async () => {
userId: collection.creatorId, userId: collection.creatorId,
lastModifiedById: collection.creatorId, lastModifiedById: collection.creatorId,
createdById: collection.creatorId, createdById: collection.creatorId,
publishedAt: new Date(), title: 'First ever document',
title: 'Second document', text: '# Much test support',
text: '# Much guidance',
}); });
await document.publish();
await collection.reload(); await collection.reload();
return { return {

View File

@ -1,43 +0,0 @@
// @flow
export const welcomeMessage = (collectionId: string) =>
`# Welcome to Outline
Outline is a place for your team to build your knowledge base. This can include:
* Team wiki
* Documentation
* Playbooks
* Employee onboarding
* ...or anything you can think of
## 🖋 A powerful editor
![Text formatting in Outline](https://s3.amazonaws.com/dev.beautifulatlas.com/uploads/e2b85962-ca66-4e4c-90d3-b32d30f0610c/754830c0-2aca-467c-82de-2fd6e990b696/Group.png)
Outline's editor lets you easily format your documents with keyboard shortcuts, Markdown syntax or by simply highlighting the text and making your selections. To add images, just drag and drop them to your canvas.
## 👩‍💻 Developer friendly
Outline features an [API](https://www.getoutline.com/developers) for programatic document creation. To create your first document using the API, simply write it in Markdown and make a call to add it into your collection:
\`\`\`
const newDocument = {
title: 'Getting started with codebase',
text: 'All the information needed in Markdown',
collectionId: '${collectionId}',
token: 'API_KEY', // Replace with a value from https://www.getoutline.com/settings/tokens
};
fetch('https://www.getoutline.com/api/documents.create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newDocument),
});
\`\`\`
## 👋 Say hi to the team
Outline is built by a small team and we would love to get to know our users. Drop by at [our Spectrum community](https://spectrum.chat/outline) or [drop us an email](mailto:hello@getoutline.com).
`;