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:
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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,19 +136,6 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadDocument = async props => {
|
loadDocument = async props => {
|
||||||
if (props.newDocument) {
|
|
||||||
this.document = new Document(
|
|
||||||
{
|
|
||||||
collectionId: props.match.params.id,
|
|
||||||
parentDocumentId: new URLSearchParams(props.location.search).get(
|
|
||||||
'parentDocumentId'
|
|
||||||
),
|
|
||||||
title: '',
|
|
||||||
text: '',
|
|
||||||
},
|
|
||||||
props.documents
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const { shareId, revisionId } = props.match.params;
|
const { shareId, revisionId } = props.match.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -192,17 +177,13 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
const isMove = props.location.pathname.match(/move$/);
|
const isMove = props.location.pathname.match(/move$/);
|
||||||
const canRedirect = !this.revision && !isMove;
|
const canRedirect = !this.revision && !isMove;
|
||||||
if (canRedirect) {
|
if (canRedirect) {
|
||||||
const canonicalUrl = updateDocumentUrl(
|
const canonicalUrl = updateDocumentUrl(props.match.url, document.url);
|
||||||
props.match.url,
|
|
||||||
document.url
|
|
||||||
);
|
|
||||||
if (props.location.pathname !== canonicalUrl) {
|
if (props.location.pathname !== canonicalUrl) {
|
||||||
props.history.replace(canonicalUrl);
|
props.history.replace(canonicalUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadEditor = async () => {
|
loadEditor = async () => {
|
||||||
@ -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)
|
||||||
);
|
);
|
||||||
|
@ -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
49
app/scenes/DocumentNew.js
Normal 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('Couldn’t create the document, try again?');
|
||||||
|
this.props.history.goBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Flex column auto>
|
||||||
|
<CenteredContent>
|
||||||
|
<LoadingPlaceholder />
|
||||||
|
</CenteredContent>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('documents', 'ui')(DocumentNew);
|
@ -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');
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
Reference in New Issue
Block a user