Base model refactor (#810)

* Big upgrades

* WIP: Stash

* Stash, 30 flow errors left

* Downgrade mobx

* WIP

* When I understand the difference between class and instance methods

* 💚

* Fixes: File import
Model saving edge cases
pinning and starring docs
Collection editing
Upgrade mobx devtools

* Notification settings saving works

* Disabled settings

* Document mailer

* Working notifications

* Colletion created notification
Ensure not notified for own actions

* Tidy up

* Document updated event only for document creation
Add indexes
Notification setting on user creation

* Commentary

* Fixed: Notification setting on signup

* Fix document move / duplicate stale data
Add BaseModel.refresh method

* Fixes: Title in sidebar not updated after editing document

* 💚

* Improve / restore error handling
Better handle offline errors

* 👕
This commit is contained in:
Tom Moor
2018-12-04 22:24:30 -08:00
committed by GitHub
parent 67cd250316
commit 8cbcb77486
222 changed files with 2273 additions and 2361 deletions

10
app/models/ApiKey.js Normal file
View File

@@ -0,0 +1,10 @@
// @flow
import BaseModel from './BaseModel';
class ApiKey extends BaseModel {
id: string;
name: string;
secret: string;
}
export default ApiKey;

View File

@@ -1,3 +1,50 @@
// @flow
import BaseStore from 'stores/BaseStore';
export default BaseStore;
import { set, observable } from 'mobx';
export default class BaseModel {
@observable id: string;
@observable isSaving: boolean;
store: *;
constructor(fields: Object, store: *) {
set(this, fields);
this.store = store;
}
save = async params => {
this.isSaving = true;
try {
// ensure that the id is passed if the document has one
if (params) params = { ...params, id: this.id };
await this.store.save(params || this.toJS());
// if saving is successful set the new values on the model itself
if (params) set(this, params);
return this;
} finally {
this.isSaving = false;
}
};
fetch = (options: *) => {
return this.store.fetch(this.id, options);
};
refresh = () => {
return this.fetch({ force: true });
};
delete = async () => {
this.isSaving = true;
try {
return await this.store.delete(this);
} finally {
this.isSaving = false;
}
};
toJS = () => {
return { ...this };
};
}

View File

@@ -1,26 +1,22 @@
// @flow
import { extendObservable, action, computed, runInAction } from 'mobx';
import invariant from 'invariant';
import { pick } from 'lodash';
import { action, computed } from 'mobx';
import BaseModel from 'models/BaseModel';
import Document from 'models/Document';
import { client } from 'utils/ApiClient';
import stores from 'stores';
import UiStore from 'stores/UiStore';
import type { NavigationNode } from 'types';
class Collection extends BaseModel {
isSaving: boolean = false;
ui: UiStore;
export default class Collection extends BaseModel {
isSaving: boolean;
createdAt: string;
description: string;
id: string;
name: string;
description: string;
color: string;
type: 'atlas' | 'journal';
documents: NavigationNode[];
updatedAt: string;
createdAt: ?string;
updatedAt: ?string;
url: string;
@computed
@@ -56,103 +52,11 @@ class Collection extends BaseModel {
travelDocuments(this.documents);
}
@action
fetch = async () => {
try {
const res = await client.post('/collections.info', { id: this.id });
invariant(res && res.data, 'API response should be available');
const { data } = res;
runInAction('Collection#fetch', () => {
this.updateData(data);
});
} catch (e) {
this.ui.showToast('Collection failed loading');
}
return this;
toJS = () => {
return pick(this, ['name', 'color', 'description']);
};
@action
save = async () => {
if (this.isSaving) return this;
this.isSaving = true;
const params = {
name: this.name,
color: this.color,
description: this.description,
};
try {
let res;
if (this.id) {
res = await client.post('/collections.update', {
id: this.id,
...params,
});
} else {
res = await client.post('/collections.create', params);
}
runInAction('Collection#save', () => {
invariant(res && res.data, 'Data should be available');
this.updateData(res.data);
});
} catch (e) {
this.ui.showToast('Collection failed saving');
return false;
} finally {
this.isSaving = false;
}
return true;
export = () => {
return client.post('/collections.export', { id: this.id });
};
@action
delete = async () => {
try {
await client.post('/collections.delete', { id: this.id });
this.emit('collections.delete', { id: this.id });
return true;
} catch (e) {
this.ui.showToast('Collection failed to delete');
}
return false;
};
@action
export = async () => {
await client.post('/collections.export', { id: this.id });
};
@action
updateData(data: Object = {}) {
extendObservable(this, data);
}
constructor(collection: $Shape<Collection>) {
super();
this.updateData(collection);
this.ui = stores.ui;
this.on('documents.delete', (data: { collectionId: string }) => {
if (data.collectionId === this.id) this.fetch();
});
this.on(
'documents.update',
(data: { collectionId: string, document: Document }) => {
if (data.collectionId === this.id) {
this.updateDocument(data.document);
}
}
);
this.on('documents.publish', (data: { collectionId: string }) => {
if (data.collectionId === this.id) this.fetch();
});
this.on('documents.move', (data: { collectionId: string }) => {
if (data.collectionId === this.id) this.fetch();
});
}
}
export default Collection;

View File

@@ -1,32 +1,12 @@
/* eslint-disable */
import Collection from './Collection';
const { client } = require('utils/ApiClient');
import stores from '../stores';
describe('Collection model', () => {
test('should initialize with data', () => {
const collection = new Collection({
const collection = stores.collections.add({
id: 123,
name: 'Engineering',
});
expect(collection.name).toBe('Engineering');
});
describe('#fetch', () => {
test('should update data', async () => {
client.post = jest.fn(() => ({
data: {
name: 'New collection',
},
}))
const collection = new Collection({
id: 123,
name: 'Engineering',
});
await collection.fetch();
expect(client.post).toHaveBeenCalledWith('/collections.info', { id: 123 });
expect(collection.name).toBe('New collection');
});
});
});

View File

@@ -1,49 +1,57 @@
// @flow
import { extendObservable, action, runInAction, computed } from 'mobx';
import { action, set, computed } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import stores from 'stores';
import parseTitle from '../../shared/utils/parseTitle';
import unescape from '../../shared/utils/unescape';
import parseTitle from 'shared/utils/parseTitle';
import unescape from 'shared/utils/unescape';
import type { NavigationNode, Revision, User } from 'types';
import BaseModel from './BaseModel';
import Collection from './Collection';
import type { NavigationNode } from 'types';
import BaseModel from 'models/BaseModel';
import Revision from 'models/Revision';
import User from 'models/User';
import Collection from 'models/Collection';
type SaveOptions = { publish?: boolean, done?: boolean, autosave?: boolean };
class Document extends BaseModel {
isSaving: boolean = false;
export default class Document extends BaseModel {
isSaving: boolean;
ui: *;
store: *;
collaborators: User[];
collection: $Shape<Collection>;
collection: Collection;
collectionId: string;
firstViewedAt: ?string;
lastViewedAt: ?string;
modifiedSinceViewed: ?boolean;
createdAt: string;
createdBy: User;
updatedAt: string;
updatedBy: User;
html: string;
id: string;
team: string;
starred: boolean;
pinned: boolean;
text: string;
title: string;
emoji: string;
starred: boolean = false;
pinned: boolean = false;
text: string = '';
title: string = '';
parentDocument: ?string;
publishedAt: ?string;
url: string;
urlId: string;
shareUrl: ?string;
views: number;
revision: number;
/* Computed */
constructor(data?: Object = {}, store: *) {
super(data, store);
this.updateTitle();
}
@action
updateTitle() {
set(this, parseTitle(this.text));
}
@computed
get modifiedSinceViewed(): boolean {
@@ -59,9 +67,8 @@ class Document extends BaseModel {
if (childNode.id === this.id) {
path = newPath;
return;
} else {
return traveler(childNode.children, newPath);
}
return traveler(childNode.children, newPath);
});
};
@@ -96,44 +103,32 @@ class Document extends BaseModel {
: null;
}
/* Actions */
@action
share = async () => {
try {
const res = await client.post('/shares.create', { documentId: this.id });
invariant(res && res.data, 'Document API response should be available');
this.shareUrl = res.data.url;
} catch (e) {
this.ui.showToast('Document failed to share');
}
const res = await client.post('/shares.create', { documentId: this.id });
invariant(res && res.data, 'Share data should be available');
this.shareUrl = res.data.url;
return this.shareUrl;
};
@action
restore = async (revision: Revision) => {
try {
const res = await client.post('/documents.restore', {
id: this.id,
revisionId: revision.id,
});
runInAction('Document#save', () => {
invariant(res && res.data, 'Data should be available');
this.updateData(res.data);
});
} catch (e) {
this.ui.showToast('Document failed to restore');
}
updateFromJson = data => {
set(this, data);
this.updateTitle();
};
restore = (revision: Revision) => {
return this.store.restore(this, revision);
};
@action
pin = async () => {
this.pinned = true;
try {
await client.post('/documents.pin', { id: this.id });
} catch (e) {
await this.store.pin(this);
} catch (err) {
this.pinned = false;
this.ui.showToast('Document failed to pin');
throw err;
}
};
@@ -141,10 +136,10 @@ class Document extends BaseModel {
unpin = async () => {
this.pinned = false;
try {
await client.post('/documents.unpin', { id: this.id });
} catch (e) {
await this.store.unpin(this);
} catch (err) {
this.pinned = true;
this.ui.showToast('Document failed to unpin');
throw err;
}
};
@@ -152,10 +147,10 @@ class Document extends BaseModel {
star = async () => {
this.starred = true;
try {
await client.post('/documents.star', { id: this.id });
} catch (e) {
await this.store.star(this);
} catch (err) {
this.starred = false;
this.ui.showToast('Document failed star');
throw err;
}
};
@@ -163,10 +158,10 @@ class Document extends BaseModel {
unstar = async () => {
this.starred = false;
try {
await client.post('/documents.unstar', { id: this.id });
} catch (e) {
this.starred = false;
this.ui.showToast('Document failed unstar');
await this.store.unstar(this);
} catch (err) {
this.starred = true;
throw err;
}
};
@@ -178,28 +173,21 @@ class Document extends BaseModel {
@action
fetch = async () => {
try {
const res = await client.post('/documents.info', { id: this.id });
invariant(res && res.data, 'Document API response should be available');
const { data } = res;
runInAction('Document#update', () => {
this.updateData(data);
});
} catch (e) {
this.ui.showToast('Document failed loading');
}
const res = await client.post('/documents.info', { id: this.id });
invariant(res && res.data, 'Data should be available');
this.updateFromJson(res.data);
};
@action
save = async (options: SaveOptions) => {
if (this.isSaving) return this;
const wasDraft = !this.publishedAt;
const isCreating = !this.id;
const wasDraft = !this.publishedAt;
this.isSaving = true;
this.updateTitle();
try {
let res;
if (isCreating) {
const data = {
parentDocument: undefined,
@@ -211,77 +199,30 @@ class Document extends BaseModel {
if (this.parentDocument) {
data.parentDocument = this.parentDocument;
}
res = await client.post('/documents.create', data);
const document = await this.store.create(data);
return document;
} else {
res = await client.post('/documents.update', {
const document = await this.store.update({
id: this.id,
title: this.title,
text: this.text,
lastRevision: this.revision,
...options,
});
return document;
}
runInAction('Document#save', () => {
invariant(res && res.data, 'Data should be available');
this.updateData(res.data);
if (isCreating) {
this.emit('documents.create', this);
}
this.emit('documents.update', {
document: this,
collectionId: this.collection.id,
});
if (wasDraft && this.publishedAt) {
this.emit('documents.publish', {
id: this.id,
collectionId: this.collection.id,
});
}
});
} catch (e) {
this.ui.showToast('Document failed to save');
} finally {
if (wasDraft && options.publish) {
this.store.rootStore.collections.fetch(this.collection.id, {
force: true,
});
}
this.isSaving = false;
}
return this;
};
@action
move = async (parentDocumentId: ?string) => {
try {
const res = await client.post('/documents.move', {
id: this.id,
parentDocument: parentDocumentId,
});
invariant(res && res.data, 'Data not available');
this.updateData(res.data);
this.emit('documents.move', {
id: this.id,
collectionId: this.collection.id,
});
} catch (e) {
this.ui.showToast('Error while moving the document');
}
return;
};
@action
delete = async () => {
try {
await client.post('/documents.delete', { id: this.id });
this.emit('documents.delete', {
id: this.id,
collectionId: this.collection.id,
});
return true;
} catch (e) {
this.ui.showToast('Error while deleting the document');
}
return false;
move = (parentDocumentId: ?string) => {
return this.store.move(this, parentDocumentId);
};
duplicate = () => {
@@ -289,6 +230,7 @@ class Document extends BaseModel {
};
download = async () => {
// Ensure the document is upto date with latest server contents
await this.fetch();
const blob = new Blob([unescape(this.text)], { type: 'text/markdown' });
@@ -301,23 +243,4 @@ class Document extends BaseModel {
a.download = `${this.title}.md`;
a.click();
};
updateData(data: Object = {}) {
if (data.text) {
const { title, emoji } = parseTitle(data.text);
data.title = title;
data.emoji = emoji;
}
extendObservable(this, data);
}
constructor(data?: Object = {}) {
super();
this.updateData(data);
this.ui = stores.ui;
this.store = stores.documents;
}
}
export default Document;

View File

@@ -1,9 +1,9 @@
/* eslint-disable */
import Document from './Document';
import stores from '../stores';
describe('Document model', () => {
test('should initialize with data', () => {
const document = new Document({
const document = stores.documents.add({
id: 123,
text: '# Onboarding\nSome body text',
});

View File

@@ -3,8 +3,6 @@ import { extendObservable, action } from 'mobx';
import BaseModel from 'models/BaseModel';
import { client } from 'utils/ApiClient';
import stores from 'stores';
import UiStore from 'stores/UiStore';
type Settings = {
url: string,
@@ -15,8 +13,6 @@ type Settings = {
type Events = 'documents.create' | 'collections.create';
class Integration extends BaseModel {
ui: UiStore;
id: string;
service: string;
collectionId: string;
@@ -25,33 +21,15 @@ class Integration extends BaseModel {
@action
update = async (data: Object) => {
try {
await client.post('/integrations.update', { id: this.id, ...data });
extendObservable(this, data);
} catch (e) {
this.ui.showToast('Integration failed to update');
}
return false;
await client.post('/integrations.update', { id: this.id, ...data });
extendObservable(this, data);
return true;
};
@action
delete = async () => {
try {
await client.post('/integrations.delete', { id: this.id });
this.emit('integrations.delete', { id: this.id });
return true;
} catch (e) {
this.ui.showToast('Integration failed to delete');
}
return false;
delete = () => {
return this.store.delete(this);
};
constructor(data?: Object = {}) {
super();
extendObservable(this, data);
this.ui = stores.ui;
}
}
export default Integration;

View File

@@ -0,0 +1,9 @@
// @flow
import BaseModel from './BaseModel';
class NotificationSetting extends BaseModel {
id: string;
event: string;
}
export default NotificationSetting;

14
app/models/Revision.js Normal file
View File

@@ -0,0 +1,14 @@
// @flow
import BaseModel from './BaseModel';
import User from './User';
class Revision extends BaseModel {
id: string;
documentId: string;
title: string;
text: string;
createdAt: string;
createdBy: User;
}
export default Revision;

15
app/models/Share.js Normal file
View File

@@ -0,0 +1,15 @@
// @flow
import BaseModel from './BaseModel';
import User from './User';
class Share extends BaseModel {
id: string;
url: string;
documentTitle: string;
documentUrl: string;
createdBy: User;
createdAt: string;
updatedAt: string;
}
export default Share;

15
app/models/Team.js Normal file
View File

@@ -0,0 +1,15 @@
// @flow
import BaseModel from './BaseModel';
class Team extends BaseModel {
id: string;
name: string;
avatarUrl: string;
slackConnected: boolean;
googleConnected: boolean;
sharing: boolean;
subdomain: ?string;
url: string;
}
export default Team;

15
app/models/User.js Normal file
View File

@@ -0,0 +1,15 @@
// @flow
import BaseModel from './BaseModel';
class User extends BaseModel {
avatarUrl: string;
id: string;
name: string;
email: string;
username: string;
isAdmin: boolean;
isSuspended: boolean;
createdAt: string;
}
export default User;