feat: Trash (#1082)
* wip: trash * Enable restoration of deleted documents * update Trash icon * Add endpoint to trigger garbage collection * fix: account for drafts * fix: Archived documents should be deletable * fix: Missing delete cascade * bump: upgrade rich-markdown-editor
This commit is contained in:
@ -8,6 +8,7 @@ import {
|
||||
EditIcon,
|
||||
SearchIcon,
|
||||
StarredIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
} from 'outline-icons';
|
||||
|
||||
@ -111,7 +112,10 @@ class MainSidebar extends React.Component<Props> {
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active ? !documents.active.publishedAt : undefined
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
@ -125,7 +129,18 @@ class MainSidebar extends React.Component<Props> {
|
||||
exact={false}
|
||||
label="Archive"
|
||||
active={
|
||||
documents.active ? documents.active.isArchived : undefined
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon />}
|
||||
exact={false}
|
||||
label="Trash"
|
||||
active={
|
||||
documents.active ? documents.active.isDeleted : undefined
|
||||
}
|
||||
/>
|
||||
{can.invite && (
|
||||
|
@ -158,6 +158,7 @@ const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 4.4em;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
|
@ -59,7 +59,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
let document = documents.get(documentId) || {};
|
||||
|
||||
if (event.event === 'documents.delete') {
|
||||
documents.remove(documentId);
|
||||
const document = documents.get(documentId);
|
||||
if (document) {
|
||||
document.deletedAt = documentDescriptor.updatedAt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -132,6 +132,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = can.share && auth.team && auth.team.sharing;
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
@ -140,7 +141,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
{can.unarchive && (
|
||||
{(can.unarchive || can.restore) && (
|
||||
<DropdownMenuItem onClick={this.handleRestore}>
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
@ -176,11 +177,13 @@ class DocumentMenu extends React.Component<Props> {
|
||||
Share link…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canViewHistory && (
|
||||
<React.Fragment>
|
||||
<hr />
|
||||
{can.read && (
|
||||
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
||||
Document history
|
||||
</DropdownMenuItem>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{can.update && (
|
||||
<DropdownMenuItem
|
||||
|
@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import { action, set, computed } from 'mobx';
|
||||
import addDays from 'date-fns/add_days';
|
||||
import invariant from 'invariant';
|
||||
import { client } from 'utils/ApiClient';
|
||||
import parseTitle from 'shared/utils/parseTitle';
|
||||
@ -76,6 +77,15 @@ export default class Document extends BaseModel {
|
||||
return !this.publishedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get permanentlyDeletedAt(): ?string {
|
||||
if (!this.deletedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
return addDays(new Date(this.deletedAt), 30).toString();
|
||||
}
|
||||
|
||||
@action
|
||||
share = async () => {
|
||||
const res = await client.post('/shares.create', { documentId: this.id });
|
||||
|
@ -6,6 +6,7 @@ import Dashboard from 'scenes/Dashboard';
|
||||
import Starred from 'scenes/Starred';
|
||||
import Drafts from 'scenes/Drafts';
|
||||
import Archive from 'scenes/Archive';
|
||||
import Trash from 'scenes/Trash';
|
||||
import Collection from 'scenes/Collection';
|
||||
import KeyedDocument from 'scenes/Document/KeyedDocument';
|
||||
import DocumentNew from 'scenes/DocumentNew';
|
||||
@ -49,6 +50,7 @@ export default function Routes() {
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/settings/details" component={Details} />
|
||||
<Route exact path="/settings/security" component={Security} />
|
||||
|
@ -400,12 +400,27 @@ class DocumentScene extends React.Component<Props> {
|
||||
/>
|
||||
)}
|
||||
<MaxWidth archived={document.isArchived} column auto>
|
||||
{document.archivedAt && (
|
||||
{document.archivedAt &&
|
||||
!document.deletedAt && (
|
||||
<Notice muted>
|
||||
Archived by {document.updatedBy.name}{' '}
|
||||
<Time dateTime={document.archivedAt} /> ago
|
||||
</Notice>
|
||||
)}
|
||||
{document.deletedAt && (
|
||||
<Notice muted>
|
||||
Deleted by {document.updatedBy.name}{' '}
|
||||
<Time dateTime={document.deletedAt} /> ago
|
||||
{document.permanentlyDeletedAt && (
|
||||
<React.Fragment>
|
||||
<br />
|
||||
This document will be permanently deleted in{' '}
|
||||
<Time dateTime={document.permanentlyDeletedAt} /> unless
|
||||
restored.
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
<Editor
|
||||
id={document.id}
|
||||
key={embedsDisabled ? 'embeds-disabled' : 'embeds-enabled'}
|
||||
|
@ -50,8 +50,8 @@ class DocumentDelete extends React.Component<Props> {
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Are you sure about that? Deleting the{' '}
|
||||
<strong>{document.title}</strong> document is permanent, and will
|
||||
delete all of its history, and any child documents.
|
||||
<strong>{document.title}</strong> document will delete all of its
|
||||
history, and any child documents.
|
||||
</HelpText>
|
||||
{!document.isDraft &&
|
||||
!document.isArchived && (
|
||||
|
38
app/scenes/Trash.js
Normal file
38
app/scenes/Trash.js
Normal file
@ -0,0 +1,38 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
|
||||
import CenteredContent from 'components/CenteredContent';
|
||||
import Empty from 'components/Empty';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import Heading from 'components/Heading';
|
||||
import PaginatedDocumentList from 'components/PaginatedDocumentList';
|
||||
import Subheading from 'components/Subheading';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
|
||||
type Props = {
|
||||
documents: DocumentsStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Trash extends React.Component<Props> {
|
||||
render() {
|
||||
const { documents } = this.props;
|
||||
|
||||
return (
|
||||
<CenteredContent column auto>
|
||||
<PageTitle title="Trash" />
|
||||
<Heading>Trash</Heading>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.deleted}
|
||||
fetch={documents.fetchDeleted}
|
||||
heading={<Subheading>Documents</Subheading>}
|
||||
empty={<Empty>Trash is empty at the moment.</Empty>}
|
||||
showCollection
|
||||
/>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('documents')(Trash);
|
@ -121,6 +121,14 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get deleted(): Document[] {
|
||||
return filter(
|
||||
orderBy(this.orderedData, 'deletedAt', 'desc'),
|
||||
d => d.deletedAt
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get starredAlphabetical(): Document[] {
|
||||
return naturalSort(this.starred, 'title');
|
||||
@ -189,6 +197,11 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
return this.fetchNamedPage('archived', options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchDeleted = async (options: ?PaginationParams): Promise<*> => {
|
||||
return this.fetchNamedPage('deleted', options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchRecentlyUpdated = async (options: ?PaginationParams): Promise<*> => {
|
||||
return this.fetchNamedPage('list', options);
|
||||
|
@ -118,7 +118,7 @@
|
||||
"mobx-react": "^5.4.2",
|
||||
"natural-sort": "^1.0.0",
|
||||
"nodemailer": "^4.4.0",
|
||||
"outline-icons": "^1.9.0",
|
||||
"outline-icons": "^1.10.0",
|
||||
"oy-vey": "^0.10.0",
|
||||
"pg": "^6.1.5",
|
||||
"pg-hstore": "2.3.2",
|
||||
@ -139,7 +139,7 @@
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-waypoint": "^7.3.1",
|
||||
"redis": "^2.6.2",
|
||||
"rich-markdown-editor": "^9.8.9",
|
||||
"rich-markdown-editor": "^9.10.0",
|
||||
"sequelize": "^5.21.1",
|
||||
"sequelize-cli": "^5.5.0",
|
||||
"sequelize-encrypted": "^0.1.0",
|
||||
|
@ -187,6 +187,46 @@ router.post('documents.archived', auth(), pagination(), async ctx => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.deleted', auth(), pagination(), async ctx => {
|
||||
const { sort = 'deletedAt' } = ctx.body;
|
||||
let direction = ctx.body.direction;
|
||||
if (direction !== 'ASC') direction = 'DESC';
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collectionIds = await user.collectionIds();
|
||||
|
||||
const collectionScope = { method: ['withCollection', user.id] };
|
||||
const documents = await Document.scope(collectionScope).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId: collectionIds,
|
||||
deletedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
include: [
|
||||
{ model: User, as: 'createdBy', paranoid: false },
|
||||
{ model: User, as: 'updatedBy', paranoid: false },
|
||||
],
|
||||
paranoid: false,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
documents.map(document => presentDocument(document))
|
||||
);
|
||||
|
||||
const policies = presentPolicies(user, documents);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
policies,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.viewed', auth(), pagination(), async ctx => {
|
||||
let { sort = 'updatedAt', direction } = ctx.body;
|
||||
if (direction !== 'ASC') direction = 'DESC';
|
||||
@ -409,9 +449,27 @@ router.post('documents.restore', auth(), async ctx => {
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
if (document.archivedAt) {
|
||||
if (document.deletedAt) {
|
||||
authorize(user, 'restore', document);
|
||||
|
||||
// restore a previously deleted document
|
||||
await document.unarchive(user.id);
|
||||
|
||||
await Event.create({
|
||||
name: 'documents.restore',
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: { title: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
} else if (document.archivedAt) {
|
||||
authorize(user, 'unarchive', document);
|
||||
|
||||
// restore a previously archived document
|
||||
|
@ -15,6 +15,7 @@ import shares from './shares';
|
||||
import team from './team';
|
||||
import integrations from './integrations';
|
||||
import notificationSettings from './notificationSettings';
|
||||
import utils from './utils';
|
||||
|
||||
import { NotFoundError } from '../errors';
|
||||
import errorHandling from './middlewares/errorHandling';
|
||||
@ -47,6 +48,7 @@ router.use('/', shares.routes());
|
||||
router.use('/', team.routes());
|
||||
router.use('/', integrations.routes());
|
||||
router.use('/', notificationSettings.routes());
|
||||
router.use('/', utils.routes());
|
||||
router.post('*', ctx => {
|
||||
ctx.throw(new NotFoundError('Endpoint not found'));
|
||||
});
|
||||
|
31
server/api/utils.js
Normal file
31
server/api/utils.js
Normal file
@ -0,0 +1,31 @@
|
||||
// @flow
|
||||
import Router from 'koa-router';
|
||||
import subDays from 'date-fns/sub_days';
|
||||
import { AuthenticationError } from '../errors';
|
||||
import { Document } from '../models';
|
||||
import { Op } from '../sequelize';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post('utils.gc', async ctx => {
|
||||
const { token } = ctx.body;
|
||||
|
||||
if (process.env.UTILS_SECRET !== token) {
|
||||
throw new AuthenticationError('Invalid secret token');
|
||||
}
|
||||
|
||||
await Document.scope('withUnpublished').destroy({
|
||||
where: {
|
||||
deletedAt: {
|
||||
[Op.lt]: subDays(new Date(), 30),
|
||||
},
|
||||
},
|
||||
force: true,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
74
server/api/utils.test.js
Normal file
74
server/api/utils.test.js
Normal file
@ -0,0 +1,74 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import subDays from 'date-fns/sub_days';
|
||||
import app from '../app';
|
||||
import { Document } from '../models';
|
||||
import { sequelize } from '../sequelize';
|
||||
import { flushdb } from '../test/support';
|
||||
import { buildDocument } from '../test/factories';
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(flushdb);
|
||||
afterAll(server.close);
|
||||
|
||||
describe('#utils.gc', async () => {
|
||||
it('should destroy documents deleted more than 30 days ago', async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
await sequelize.query(
|
||||
`UPDATE documents SET "deletedAt" = '${subDays(
|
||||
new Date(),
|
||||
60
|
||||
).toISOString()}' WHERE id = '${document.id}'`
|
||||
);
|
||||
|
||||
const res = await server.post('/api/utils.gc', {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
const reloaded = await Document.scope().findOne({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
paranoid: false,
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(reloaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should destroy draft documents deleted more than 30 days ago', async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: undefined,
|
||||
});
|
||||
|
||||
await sequelize.query(
|
||||
`UPDATE documents SET "deletedAt" = '${subDays(
|
||||
new Date(),
|
||||
60
|
||||
).toISOString()}' WHERE id = '${document.id}'`
|
||||
);
|
||||
|
||||
const res = await server.post('/api/utils.gc', {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
const reloaded = await Document.scope().findOne({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
paranoid: false,
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(reloaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const res = await server.post('/api/utils.gc');
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
22
server/migrations/20191118023010-cascade-delete.js
Normal file
22
server/migrations/20191118023010-cascade-delete.js
Normal file
@ -0,0 +1,22 @@
|
||||
const tableName = 'revisions';
|
||||
const constraintName = 'revisions_documentId_fkey';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||
await queryInterface.sequelize.query(
|
||||
`alter table "${tableName}"
|
||||
add constraint "${constraintName}" foreign key("documentId") references "documents" ("id")
|
||||
on delete cascade`
|
||||
);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`)
|
||||
await queryInterface.sequelize.query(
|
||||
`alter table "${tableName}"\
|
||||
add constraint "${constraintName}" foreign key("documentId") references "documents" ("id")
|
||||
on delete no action`
|
||||
);
|
||||
},
|
||||
};
|
@ -147,9 +147,11 @@ Document.associate = models => {
|
||||
});
|
||||
Document.hasMany(models.Backlink, {
|
||||
as: 'backlinks',
|
||||
onDelete: 'cascade',
|
||||
});
|
||||
Document.hasMany(models.Star, {
|
||||
as: 'starred',
|
||||
onDelete: 'cascade',
|
||||
});
|
||||
Document.hasMany(models.View, {
|
||||
as: 'views',
|
||||
@ -514,6 +516,10 @@ Document.prototype.unarchive = async function(userId) {
|
||||
await collection.addDocumentToStructure(this);
|
||||
this.collection = collection;
|
||||
|
||||
if (this.deletedAt) {
|
||||
await this.restore();
|
||||
}
|
||||
|
||||
this.archivedAt = null;
|
||||
this.lastModifiedById = userId;
|
||||
await this.save();
|
||||
|
@ -56,6 +56,7 @@ Event.ACTIVITY_EVENTS = [
|
||||
'documents.pin',
|
||||
'documents.unpin',
|
||||
'documents.delete',
|
||||
'documents.restore',
|
||||
'collections.create',
|
||||
'collections.delete',
|
||||
];
|
||||
|
@ -18,6 +18,7 @@ allow(User, ['read', 'download'], Document, (user, document) => {
|
||||
|
||||
allow(User, ['share'], Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
// existance of collection option is not required here to account for share tokens
|
||||
if (document.collection && cannot(user, 'read', document.collection)) {
|
||||
@ -29,6 +30,7 @@ allow(User, ['share'], Document, (user, document) => {
|
||||
|
||||
allow(User, ['star', 'unstar'], Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (!document.publishedAt) return false;
|
||||
|
||||
invariant(
|
||||
@ -47,6 +49,7 @@ allow(User, 'update', Document, (user, document) => {
|
||||
);
|
||||
if (cannot(user, 'update', document.collection)) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
@ -58,6 +61,7 @@ allow(User, ['move', 'pin', 'unpin'], Document, (user, document) => {
|
||||
);
|
||||
if (cannot(user, 'update', document.collection)) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (!document.publishedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
@ -65,15 +69,26 @@ allow(User, ['move', 'pin', 'unpin'], Document, (user, document) => {
|
||||
|
||||
allow(User, 'delete', Document, (user, document) => {
|
||||
// unpublished drafts can always be deleted
|
||||
if (!document.publishedAt && user.teamId === document.teamId) {
|
||||
if (
|
||||
!document.deletedAt &&
|
||||
!document.publishedAt &&
|
||||
user.teamId === document.teamId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// allow deleting document without a collection
|
||||
if (document.collection && cannot(user, 'update', document.collection))
|
||||
if (document.collection && cannot(user, 'update', document.collection)) {
|
||||
return false;
|
||||
if (document.archivedAt) return false;
|
||||
}
|
||||
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, 'restore', Document, (user, document) => {
|
||||
if (!document.deletedAt) return false;
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
@ -86,6 +101,7 @@ allow(User, 'archive', Document, (user, document) => {
|
||||
|
||||
if (!document.publishedAt) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
24
yarn.lock
24
yarn.lock
@ -7120,10 +7120,10 @@ osenv@^0.1.4:
|
||||
os-homedir "^1.0.0"
|
||||
os-tmpdir "^1.0.0"
|
||||
|
||||
outline-icons@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.9.0.tgz#e17d998272209846aa3277ad7ed7063fc4dae984"
|
||||
integrity sha512-Uzh1aP9Js+9ieOrvZpyPjFOaVBjpC+OFzF1pRi5jemYpH6kiA236i7itv0OdLX6KFDpybP6b6OWrCVDle5RXLQ==
|
||||
outline-icons@^1.10.0:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.10.0.tgz#3c8e6957429e2b04c9d0fc72fe72e473813ce5bd"
|
||||
integrity sha512-1o3SnjzawEIh+QkZ6GHxPckuV+Tk5m5R2tjGY0CtosF3YA7JbgQ2jQrZdQsrqLzLa1j07f1bTEbAjGdbnunLpg==
|
||||
|
||||
oy-vey@^0.10.0:
|
||||
version "0.10.0"
|
||||
@ -8440,10 +8440,10 @@ retry-as-promised@^3.2.0:
|
||||
dependencies:
|
||||
any-promise "^1.3.0"
|
||||
|
||||
rich-markdown-editor@^9.8.9:
|
||||
version "9.8.9"
|
||||
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-9.8.9.tgz#ef9ee4d884988eca4ebc415495827a64fb0d7815"
|
||||
integrity sha512-7MX2Y4MX0v81GW5vtnwPAIF6h1IPI1YE1Ex0kYUuTb+ugoLt+kM/zDc9uPL+Ix7jyr1TljDzfgBbDuP+sR2JjQ==
|
||||
rich-markdown-editor@^9.10.0:
|
||||
version "9.10.0"
|
||||
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-9.10.0.tgz#df59720ed969c1288d672d58f239664a64dd1465"
|
||||
integrity sha512-9FGzjPExSYb/T0Rp5YEkz8qgEQkiwprAzlg4J6aj5acFZlaon2QZ4sNO/2feBaT3IBfAbnPNZEb+JeuZpL7s7w==
|
||||
dependencies:
|
||||
"@domoinc/slate-edit-table" "^0.22.2"
|
||||
"@tommoor/slate-edit-list" "0.19.0-0"
|
||||
@ -8458,7 +8458,7 @@ rich-markdown-editor@^9.8.9:
|
||||
eslint-plugin-prettier "^2.6.0"
|
||||
golery-slate-prism "0.6.0-golery.2"
|
||||
lodash "^4.17.11"
|
||||
outline-icons "^1.9.0"
|
||||
outline-icons "^1.10.0"
|
||||
prismjs "^1.16.0"
|
||||
react-autosize-textarea "^6.0.0"
|
||||
react-keydown "^1.9.10"
|
||||
@ -8467,6 +8467,7 @@ rich-markdown-editor@^9.8.9:
|
||||
slate "^0.45.0"
|
||||
slate-collapse-on-escape "^0.8.1"
|
||||
slate-drop-or-paste-images "^0.9.1"
|
||||
slate-instant-replace "^0.1.13"
|
||||
slate-md-serializer "5.4.4"
|
||||
slate-paste-linkify "^0.7.0"
|
||||
slate-react "^0.21.20"
|
||||
@ -8843,6 +8844,11 @@ slate-hotkeys@^0.2.9:
|
||||
is-hotkey "0.1.4"
|
||||
slate-dev-environment "^0.2.2"
|
||||
|
||||
slate-instant-replace@^0.1.13:
|
||||
version "0.1.13"
|
||||
resolved "https://registry.yarnpkg.com/slate-instant-replace/-/slate-instant-replace-0.1.13.tgz#03a2c908253c399a1ca5d6922a93867eb186c69b"
|
||||
integrity sha512-jesj33+TUgrmcVKGBy6QgqORaAq55hSuFmBOv70iNjif0CHqK9CapWfHOLg8N8HWw+VAX0YpSaqy/lAQSSmwWg==
|
||||
|
||||
slate-md-serializer@5.4.4:
|
||||
version "5.4.4"
|
||||
resolved "https://registry.yarnpkg.com/slate-md-serializer/-/slate-md-serializer-5.4.4.tgz#b0b55f7ab1dc9ed2159c6f97852594a81a5b76e9"
|
||||
|
Reference in New Issue
Block a user