fix: First auto-save unfocuses document (#1046)

* fix: Autosave unfocuses document

* Revert unneeded change

* test: le fix

* fix: Handle offline state
fix: Untitled documents appear with empty titles

* fix: Draft bubble roundness (yes, it doesnt belong here but see it, fix it)
This commit is contained in:
Tom Moor
2019-09-22 17:09:11 -07:00
committed by GitHub
parent b1a1d24f9c
commit 4164fc178c
10 changed files with 104 additions and 113 deletions

View File

@ -14,18 +14,18 @@ const Bubble = ({ count }: Props) => {
const Count = styled.div` const Count = styled.div`
animation: ${bounceIn} 600ms; animation: ${bounceIn} 600ms;
transform-origin: center center; transform-origin: center center;
border-radius: 100%;
color: ${props => props.theme.white}; color: ${props => props.theme.white};
background: ${props => props.theme.slateDark}; background: ${props => props.theme.slateDark};
display: inline-block; display: inline-block;
font-feature-settings: 'tnum'; font-feature-settings: 'tnum';
font-weight: 600; font-weight: 600;
font-size: 9px; font-size: 9px;
line-height: 16px;
white-space: nowrap; white-space: nowrap;
vertical-align: baseline; vertical-align: baseline;
min-width: 16px; min-width: 16px;
min-height: 16px; min-height: 16px;
line-height: 16px;
border-radius: 8px;
text-align: center; text-align: center;
padding: 0 4px; padding: 0 4px;
margin-left: 8px; margin-left: 8px;

View File

@ -44,7 +44,11 @@ export default class Document extends BaseModel {
@action @action
updateTitle() { updateTitle() {
set(this, parseTitle(this.text)); const { title, emoji } = parseTitle(this.text);
if (title) {
set(this, { title, emoji });
}
} }
@computed @computed

View File

@ -7,8 +7,8 @@ import Starred from 'scenes/Starred';
import Drafts from 'scenes/Drafts'; import Drafts from 'scenes/Drafts';
import Archive from 'scenes/Archive'; import Archive from 'scenes/Archive';
import Collection from 'scenes/Collection'; import Collection from 'scenes/Collection';
import Document from 'scenes/Document';
import KeyedDocument from 'scenes/Document/KeyedDocument'; import KeyedDocument from 'scenes/Document/KeyedDocument';
import DocumentNew from 'scenes/DocumentNew';
import Search from 'scenes/Search'; import Search from 'scenes/Search';
import Settings from 'scenes/Settings'; import Settings from 'scenes/Settings';
import Details from 'scenes/Settings/Details'; import Details from 'scenes/Settings/Details';
@ -30,7 +30,6 @@ import RouteSidebarHidden from 'components/RouteSidebarHidden';
import { matchDocumentSlug as slug } from 'utils/routeHelpers'; import { matchDocumentSlug as slug } from 'utils/routeHelpers';
const NotFound = () => <Search notFound />; const NotFound = () => <Search notFound />;
const NewDocument = () => <Document newDocument />;
const RedirectDocument = ({ match }: { match: Object }) => ( const RedirectDocument = ({ match }: { match: Object }) => (
<Redirect to={`/doc/${match.params.documentSlug}`} /> <Redirect to={`/doc/${match.params.documentSlug}`} />
); );
@ -77,7 +76,7 @@ export default function Routes() {
<RouteSidebarHidden <RouteSidebarHidden
exact exact
path="/collections/:id/new" path="/collections/:id/new"
component={NewDocument} component={DocumentNew}
/> />
<Route <Route
exact exact

View File

@ -62,7 +62,6 @@ type Props = {
location: Location, location: Location,
documents: DocumentsStore, documents: DocumentsStore,
revisions: RevisionsStore, revisions: RevisionsStore,
newDocument?: boolean,
auth: AuthStore, auth: AuthStore,
ui: UiStore, ui: UiStore,
}; };
@ -75,7 +74,6 @@ class DocumentScene extends React.Component<Props> {
@observable editorComponent = EditorImport; @observable editorComponent = EditorImport;
@observable document: ?Document; @observable document: ?Document;
@observable revision: ?Revision; @observable revision: ?Revision;
@observable newDocument: ?Document;
@observable isUploading: boolean = false; @observable isUploading: boolean = false;
@observable isSaving: boolean = false; @observable isSaving: boolean = false;
@observable isPublishing: boolean = false; @observable isPublishing: boolean = false;
@ -138,67 +136,50 @@ class DocumentScene extends React.Component<Props> {
} }
loadDocument = async props => { loadDocument = async props => {
if (props.newDocument) { const { shareId, revisionId } = props.match.params;
this.document = new Document(
{ try {
collectionId: props.match.params.id, this.document = await props.documents.fetch(
parentDocumentId: new URLSearchParams(props.location.search).get( props.match.params.documentSlug,
'parentDocumentId' { shareId }
),
title: '',
text: '',
},
props.documents
); );
} else {
const { shareId, revisionId } = props.match.params;
try { if (revisionId) {
this.document = await props.documents.fetch( this.revision = await props.revisions.fetch(
props.match.params.documentSlug, props.match.params.documentSlug,
{ shareId } { revisionId }
); );
} else {
this.revision = undefined;
}
} catch (err) {
this.error = err;
return;
}
if (revisionId) { this.isDirty = false;
this.revision = await props.revisions.fetch( this.isEmpty = false;
props.match.params.documentSlug,
{ revisionId } const document = this.document;
);
} else { if (document) {
this.revision = undefined; this.props.ui.setActiveDocument(document);
}
} catch (err) { if (document.isArchived && this.isEditing) {
this.error = err; return this.goToDocumentCanonical();
return;
} }
this.isDirty = false; if (this.props.auth.user && !shareId) {
this.isEmpty = false; if (!this.isEditing && document.publishedAt) {
this.viewTimeout = setTimeout(document.view, MARK_AS_VIEWED_AFTER);
const document = this.document;
if (document) {
this.props.ui.setActiveDocument(document);
if (document.isArchived && this.isEditing) {
return this.goToDocumentCanonical();
} }
if (this.props.auth.user && !shareId) { const isMove = props.location.pathname.match(/move$/);
if (!this.isEditing && document.publishedAt) { const canRedirect = !this.revision && !isMove;
this.viewTimeout = setTimeout(document.view, MARK_AS_VIEWED_AFTER); if (canRedirect) {
} const canonicalUrl = updateDocumentUrl(props.match.url, document.url);
if (props.location.pathname !== canonicalUrl) {
const isMove = props.location.pathname.match(/move$/); props.history.replace(canonicalUrl);
const canRedirect = !this.revision && !isMove;
if (canRedirect) {
const canonicalUrl = updateDocumentUrl(
props.match.url,
document.url
);
if (props.location.pathname !== canonicalUrl) {
props.history.replace(canonicalUrl);
}
} }
} }
} }
@ -327,7 +308,7 @@ class DocumentScene extends React.Component<Props> {
title={location.state ? location.state.title : 'Untitled'} title={location.state ? location.state.title : 'Untitled'}
/> />
<CenteredContent> <CenteredContent>
<LoadingState /> <LoadingPlaceholder />
</CenteredContent> </CenteredContent>
</Container> </Container>
); );
@ -446,10 +427,6 @@ const Container = styled(Flex)`
margin-top: ${props => (props.isShare ? '50px' : '0')}; margin-top: ${props => (props.isShare ? '50px' : '0')};
`; `;
const LoadingState = styled(LoadingPlaceholder)`
margin: 40px 0;
`;
export default withRouter( export default withRouter(
inject('ui', 'auth', 'documents', 'revisions')(DocumentScene) inject('ui', 'auth', 'documents', 'revisions')(DocumentScene)
); );

View File

@ -14,17 +14,21 @@ const randomValues = Array.from(
const LoadingPlaceholder = (props: Object) => { const LoadingPlaceholder = (props: Object) => {
return ( return (
<Fade> <Wrapper>
<Flex column auto {...props}> <Flex column auto {...props}>
<Mask style={{ width: randomValues[0] }} header /> <Mask style={{ width: randomValues[0] }} header />
<Mask style={{ width: randomValues[1] }} /> <Mask style={{ width: randomValues[1] }} />
<Mask style={{ width: randomValues[2] }} /> <Mask style={{ width: randomValues[2] }} />
<Mask style={{ width: randomValues[3] }} /> <Mask style={{ width: randomValues[3] }} />
</Flex> </Flex>
</Fade> </Wrapper>
); );
}; };
const Wrapper = styled(Fade)`
margin: 40px 0;
`;
const Mask = styled(Flex)` const Mask = styled(Flex)`
height: ${props => (props.header ? 28 : 18)}px; height: ${props => (props.header ? 28 : 18)}px;
margin-bottom: ${props => (props.header ? 32 : 14)}px; margin-bottom: ${props => (props.header ? 32 : 14)}px;

49
app/scenes/DocumentNew.js Normal file
View File

@ -0,0 +1,49 @@
// @flow
import * as React from 'react';
import { inject } from 'mobx-react';
import type { RouterHistory, Location } from 'react-router-dom';
import Flex from 'shared/components/Flex';
import CenteredContent from 'components/CenteredContent';
import LoadingPlaceholder from 'scenes/Document/components/LoadingPlaceholder';
import DocumentsStore from 'stores/DocumentsStore';
import UiStore from 'stores/UiStore';
import { documentEditUrl } from 'utils/routeHelpers';
type Props = {
history: RouterHistory,
location: Location,
documents: DocumentsStore,
ui: UiStore,
match: Object,
};
class DocumentNew extends React.Component<Props> {
async componentDidMount() {
try {
const document = await this.props.documents.create({
collectionId: this.props.match.params.id,
parentDocumentId: new URLSearchParams(this.props.location.search).get(
'parentDocumentId'
),
title: '',
text: '',
});
this.props.history.replace(documentEditUrl(document));
} catch (err) {
this.props.ui.showToast('Couldnt create the document, try again?');
this.props.history.goBack();
}
}
render() {
return (
<Flex column auto>
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
</Flex>
);
}
}
export default inject('documents', 'ui')(DocumentNew);

View File

@ -585,15 +585,14 @@ router.post('documents.unstar', auth(), async ctx => {
router.post('documents.create', auth(), async ctx => { router.post('documents.create', auth(), async ctx => {
const { const {
title, title = '',
text, text = '',
publish, publish,
collectionId, collectionId,
parentDocumentId, parentDocumentId,
index, index,
} = ctx.body; } = ctx.body;
ctx.assertUuid(collectionId, 'collectionId must be an uuid'); ctx.assertUuid(collectionId, 'collectionId must be an uuid');
ctx.assertPresent(text, 'text is required');
if (parentDocumentId) { if (parentDocumentId) {
ctx.assertUuid(parentDocumentId, 'parentDocumentId must be an uuid'); ctx.assertUuid(parentDocumentId, 'parentDocumentId must be an uuid');
} }

View File

@ -1039,22 +1039,6 @@ describe('#documents.create', async () => {
expect(newDocument.collection.id).toBe(collection.id); expect(newDocument.collection.id).toBe(collection.id);
}); });
it('should fallback to a default title', async () => {
const { user, collection } = await seed();
const res = await server.post('/api/documents.create', {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
title: ' ',
text: ' ',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toBe('Untitled document');
expect(body.data.text).toBe('# Untitled document');
});
it('should not allow very long titles', async () => { it('should not allow very long titles', async () => {
const { user, collection } = await seed(); const { user, collection } = await seed();
const res = await server.post('/api/documents.create', { const res = await server.post('/api/documents.create', {
@ -1183,25 +1167,6 @@ describe('#documents.update', async () => {
expect(revisionRecords).toBe(prevRevisionRecords); expect(revisionRecords).toBe(prevRevisionRecords);
}); });
it('should fallback to a default title', async () => {
const { user, document } = await seed();
const res = await server.post('/api/documents.update', {
body: {
token: user.getJwtToken(),
id: document.id,
title: ' ',
text: ' ',
lastRevision: document.revision,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toBe('Untitled document');
expect(body.data.text).toBe('# Untitled document');
});
it('should fail if document lastRevision does not match', async () => { it('should fail if document lastRevision does not match', async () => {
const { user, document } = await seed(); const { user, document } = await seed();

View File

@ -17,7 +17,7 @@ import Revision from './Revision';
const Op = Sequelize.Op; const Op = Sequelize.Op;
const Markdown = new MarkdownSerializer(); const Markdown = new MarkdownSerializer();
const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/; const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/;
const DEFAULT_TITLE = 'Untitled document'; const DEFAULT_TITLE = 'Untitled';
slug.defaults.mode = 'rfc3986'; slug.defaults.mode = 'rfc3986';
const slugify = text => const slugify = text =>
@ -55,10 +55,9 @@ const beforeSave = async doc => {
// emoji in the title is split out for easier display // emoji in the title is split out for easier display
doc.emoji = emoji; doc.emoji = emoji;
// ensure document has a title // ensure documents have a title
if (!title) { if (!title) {
doc.title = DEFAULT_TITLE; doc.title = DEFAULT_TITLE;
doc.text = doc.text.replace(/^.*$/m, `# ${DEFAULT_TITLE}`);
} }
// add the current user as a collaborator on this doc // add the current user as a collaborator on this doc

View File

@ -13,11 +13,6 @@ export default async function present(document: Document, options: ?Options) {
...options, ...options,
}; };
// For empty document content, return the title
if (!document.text.trim()) {
document.text = `# ${document.title}`;
}
const data = { const data = {
id: document.id, id: document.id,
url: document.url, url: document.url,