Merge pull request #462 from Human-Connection/447-generate_mentioning_link_in_editor

[WIP] Generate a link for @-Mentionings
This commit is contained in:
Robert Schäfer 2019-04-18 12:19:15 +02:00 committed by GitHub
commit f733efcd86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 562 additions and 151 deletions

View File

@ -11,7 +11,7 @@ import userMiddleware from './userMiddleware'
import includedFieldsMiddleware from './includedFieldsMiddleware'
import orderByMiddleware from './orderByMiddleware'
import validUrlMiddleware from './validUrlMiddleware'
import notificationsMiddleware from './notificationsMiddleware'
import notificationsMiddleware from './notifications'
export default schema => {
let middleware = [
@ -20,9 +20,9 @@ export default schema => {
validUrlMiddleware,
sluggifyMiddleware,
excerptMiddleware,
notificationsMiddleware,
xssMiddleware,
fixImageUrlsMiddleware,
notificationsMiddleware,
softDeleteMiddleware,
userMiddleware,
includedFieldsMiddleware,

View File

@ -0,0 +1,17 @@
import cheerio from 'cheerio'
const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g
export default function (content) {
const $ = cheerio.load(content)
const urls = $('.mention').map((_, el) => {
return $(el).attr('href')
}).get()
const ids = []
urls.forEach((url) => {
let match
while ((match = ID_REGEX.exec(url)) != null) {
ids.push(match[1])
}
})
return ids
}

View File

@ -0,0 +1,46 @@
import extractIds from './extractMentions'
describe('extract', () => {
describe('searches through links', () => {
it('ignores links without .mention class', () => {
const content = '<p>Something inspirational about <a href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual([])
})
describe('given a link with .mention class', () => {
it('extracts ids', () => {
const content = '<p>Something inspirational about <a href="/profile/u2" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u2', 'u3'])
})
describe('handles links', () => {
it('with slug and id', () => {
const content = '<p>Something inspirational about <a href="/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u2', 'u3'])
})
it('with domains', () => {
const content = '<p>Something inspirational about <a href="http://localhost:3000/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u2', 'u3'])
})
it('special characters', () => {
const content = '<p>Something inspirational about <a href="http://localhost:3000/profile/u!*(),2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u.~-3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual(['u!*(),2', 'u.~-3'])
})
})
describe('does not crash if', () => {
it('`href` contains no user id', () => {
const content = '<p>Something inspirational about <a href="/profile" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual([])
})
it('`href` is empty or invalid', () => {
const content = '<p>Something inspirational about <a href="" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="not-a-url" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractIds(content)).toEqual([])
})
})
})
})
})

View File

@ -1,20 +1,22 @@
import { extractSlugs } from './notifications/mentions'
import extractIds from './extractMentions'
const notify = async (resolve, root, args, context, resolveInfo) => {
// extract user ids before xss-middleware removes link classes
const ids = extractIds(args.content)
const post = await resolve(root, args, context, resolveInfo)
const session = context.driver.session()
const { content, id: postId } = post
const slugs = extractSlugs(content)
const { id: postId } = post
const createdAt = (new Date()).toISOString()
const cypher = `
match(u:User) where u.slug in $slugs
match(u:User) where u.id in $ids
match(p:Post) where p.id = $postId
create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt})
merge (n)-[:NOTIFIED]->(u)
merge (p)-[:NOTIFIED]->(n)
`
await session.run(cypher, { slugs, createdAt, postId })
await session.run(cypher, { ids, createdAt, postId })
session.close()
return post
@ -22,6 +24,7 @@ const notify = async (resolve, root, args, context, resolveInfo) => {
export default {
Mutation: {
CreatePost: notify
CreatePost: notify,
UpdatePost: notify
}
}

View File

@ -1,10 +0,0 @@
const MENTION_REGEX = /\s@([\w_-]+)/g
export function extractSlugs (content) {
let slugs = []
let match
while ((match = MENTION_REGEX.exec(content)) != null) {
slugs.push(match[1])
}
return slugs
}

View File

@ -1,30 +0,0 @@
import { extractSlugs } from './mentions'
describe('extract', () => {
describe('finds mentions in the form of', () => {
it('@user', () => {
const content = 'Hello @user'
expect(extractSlugs(content)).toEqual(['user'])
})
it('@user-with-dash', () => {
const content = 'Hello @user-with-dash'
expect(extractSlugs(content)).toEqual(['user-with-dash'])
})
it('@user.', () => {
const content = 'Hello @user.'
expect(extractSlugs(content)).toEqual(['user'])
})
it('@user-With-Capital-LETTERS', () => {
const content = 'Hello @user-With-Capital-LETTERS'
expect(extractSlugs(content)).toEqual(['user-With-Capital-LETTERS'])
})
})
it('ignores email addresses', () => {
const content = 'Hello somebody@example.org'
expect(extractSlugs(content)).toEqual([])
})
})

View File

@ -0,0 +1,124 @@
import { GraphQLClient } from 'graphql-request'
import { host, login } from '../../jest/helpers'
import Factory from '../../seed/factories'
const factory = Factory()
let client
beforeEach(async () => {
await factory.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
email: 'test@example.org',
password: '1234'
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('currentUser { notifications }', () => {
const query = `query($read: Boolean) {
currentUser {
notifications(read: $read, orderBy: createdAt_desc) {
read
post {
content
}
}
}
}`
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
})
describe('given another user', () => {
let authorClient
let authorParams
let authorHeaders
beforeEach(async () => {
authorParams = {
email: 'author@example.org',
password: '1234',
id: 'author'
}
await factory.create('User', authorParams)
authorHeaders = await login(authorParams)
})
describe('who mentions me in a post', () => {
let post
const title = 'Mentioning Al Capone'
const content = 'Hey <a class="mention" href="/profile/you/al-capone">@al-capone</a> how do you do?'
beforeEach(async () => {
const createPostMutation = `
mutation($title: String!, $content: String!) {
CreatePost(title: $title, content: $content) {
id
title
content
}
}
`
authorClient = new GraphQLClient(host, { headers: authorHeaders })
const { CreatePost } = await authorClient.request(createPostMutation, { title, content })
post = CreatePost
})
it('sends you a notification', async () => {
const expectedContent = 'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
const expected = {
currentUser: {
notifications: [
{ read: false, post: { content: expectedContent } }
]
}
}
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
})
describe('who mentions me again', () => {
beforeEach(async () => {
const updatedContent = `${post.content} One more mention to <a href="/profile/you" class="mention">@al-capone</a>`
// The response `post.content` contains a link but the XSSmiddleware
// should have the `mention` CSS class removed. I discovered this
// during development and thought: A feature not a bug! This way we
// can encode a re-mentioning of users when you edit your post or
// comment.
const createPostMutation = `
mutation($id: ID!, $content: String!) {
UpdatePost(id: $id, content: $content) {
title
content
}
}
`
authorClient = new GraphQLClient(host, { headers: authorHeaders })
await authorClient.request(createPostMutation, { id: post.id, content: updatedContent })
})
it('creates exactly one more notification', async () => {
const expectedContent = 'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do? One more mention to <a href="/profile/you" target="_blank">@al-capone</a>'
const expected = {
currentUser: {
notifications: [
{ read: false, post: { content: expectedContent } },
{ read: false, post: { content: expectedContent } }
]
}
}
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
})
})
})
})
})
})

View File

@ -1,85 +0,0 @@
import Factory from '../seed/factories'
import { GraphQLClient } from 'graphql-request'
import { host, login } from '../jest/helpers'
const factory = Factory()
let client
beforeEach(async () => {
await factory.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
email: 'test@example.org',
password: '1234'
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('currentUser { notifications }', () => {
const query = `query($read: Boolean) {
currentUser {
notifications(read: $read, orderBy: createdAt_desc) {
read
post {
content
}
}
}
}`
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
})
describe('given another user', () => {
let authorClient
let authorParams
let authorHeaders
beforeEach(async () => {
authorParams = {
email: 'author@example.org',
password: '1234',
id: 'author'
}
await factory.create('User', authorParams)
authorHeaders = await login(authorParams)
})
describe('who mentions me in a post', () => {
beforeEach(async () => {
const content = 'Hey @al-capone how do you do?'
const title = 'Mentioning Al Capone'
const createPostMutation = `
mutation($title: String!, $content: String!) {
CreatePost(title: $title, content: $content) {
title
content
}
}
`
authorClient = new GraphQLClient(host, { headers: authorHeaders })
await authorClient.request(createPostMutation, { title, content })
})
it('sends you a notification', async () => {
const expected = {
currentUser: {
notifications: [
{ read: false, post: { content: 'Hey @al-capone how do you do?' } }
]
}
}
await expect(client.request(query, { read: false })).resolves.toEqual(expected)
})
})
})
})
})

View File

@ -8,6 +8,8 @@
"nonGlobalStepDefinitions": true
},
"scripts": {
"db:seed": "cd backend && yarn run db:seed",
"db:reset": "cd backend && yarn run db:reset",
"cypress:backend:server": "cd backend && yarn run test:before:server",
"cypress:backend:seeder": "cd backend && yarn run test:before:seeder",
"cypress:webapp": "cd webapp && cross-env GRAPHQL_URI=http://localhost:4123 yarn run dev",
@ -25,4 +27,4 @@
"neo4j-driver": "^1.7.3",
"npm-run-all": "^4.1.5"
}
}
}

View File

@ -16,6 +16,7 @@
/>
<no-ssr>
<hc-editor
:users="users"
:value="form.content"
@input="updateEditorContent"
/>
@ -48,7 +49,7 @@
<script>
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor.vue'
import HcEditor from '~/components/Editor'
export default {
components: {
@ -70,7 +71,8 @@ export default {
id: null,
loading: false,
disabled: false,
slug: null
slug: null,
users: []
}
},
watch: {
@ -125,6 +127,21 @@ export default {
// this.form.content = value
this.$refs.contributionForm.update('content', value)
}
},
apollo: {
User: {
query() {
return gql(`{
User(orderBy: slug_asc) {
id
slug
}
}`)
},
result(result) {
this.users = result.data.User
}
}
}
}
</script>

View File

@ -1,5 +1,29 @@
<template>
<div class="editor">
<div
v-show="showSuggestions"
ref="suggestions"
class="suggestion-list"
>
<template v-if="hasResults">
<div
v-for="(user, index) in filteredUsers"
:key="user.id"
class="suggestion-list__item"
:class="{ 'is-selected': navigatedUserIndex === index }"
@click="selectUser(user)"
>
@{{ user.slug }}
</div>
</template>
<div
v-else
class="suggestion-list__item is-empty"
>
No users found
</div>
</div>
<editor-menu-bubble :editor="editor">
<div
ref="menu"
@ -137,6 +161,8 @@
<script>
import linkify from 'linkify-it'
import stringHash from 'string-hash'
import Fuse from 'fuse.js'
import tippy from 'tippy.js'
import {
Editor,
EditorContent,
@ -160,6 +186,7 @@ import {
Link,
History
} from 'tiptap-extensions'
import Mention from './nodes/Mention.js'
let throttleInputEvent
@ -170,6 +197,7 @@ export default {
EditorMenuBubble
},
props: {
users: { type: Array, default: () => [] },
value: { type: String, default: '' },
doc: { type: Object, default: () => {} }
},
@ -198,7 +226,72 @@ export default {
emptyNodeClass: 'is-empty',
emptyNodeText: 'Schreib etwas inspirerendes…'
}),
new History()
new History(),
new Mention({
items: () => {
return this.users
},
onEnter: ({ items, query, range, command, virtualNode }) => {
this.query = query
this.filteredUsers = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMention = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = query
this.filteredUsers = items
this.suggestionRange = range
this.navigatedUserIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
// reset all saved values
this.query = null
this.filteredUsers = []
this.suggestionRange = null
this.navigatedUserIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
if (!query) {
return items
}
const fuse = new Fuse(items, {
threshold: 0.2,
keys: ['slug']
})
return fuse.search(query)
}
})
],
onUpdate: e => {
clearTimeout(throttleInputEvent)
@ -206,7 +299,21 @@ export default {
}
}),
linkUrl: null,
linkMenuIsActive: false
linkMenuIsActive: false,
query: null,
suggestionRange: null,
filteredUsers: [],
navigatedUserIndex: 0,
insertMention: () => {},
observer: null
}
},
computed: {
hasResults() {
return this.filteredUsers.length
},
showSuggestions() {
return this.query || this.hasResults
}
},
watch: {
@ -226,6 +333,77 @@ export default {
this.editor.destroy()
},
methods: {
// navigate to the previous item
// if it's the first item, navigate to the last one
upHandler() {
this.navigatedUserIndex =
(this.navigatedUserIndex + this.filteredUsers.length - 1) %
this.filteredUsers.length
},
// navigate to the next item
// if it's the last item, navigate to the first one
downHandler() {
this.navigatedUserIndex =
(this.navigatedUserIndex + 1) % this.filteredUsers.length
},
enterHandler() {
const user = this.filteredUsers[this.navigatedUserIndex]
if (user) {
this.selectUser(user)
}
},
// we have to replace our suggestion text with a mention
// so it's important to pass also the position of your suggestion text
selectUser(user) {
this.insertMention({
range: this.suggestionRange,
attrs: {
// TODO: use router here
url: `/profile/${user.id}`,
label: user.slug
}
})
this.editor.focus()
},
// renders a popup with suggestions
// tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
renderPopup(node) {
if (this.popup) {
return
}
this.popup = tippy(node, {
content: this.$refs.suggestions,
trigger: 'mouseenter',
interactive: true,
theme: 'dark',
placement: 'top-start',
inertia: true,
duration: [400, 200],
showOnInit: true,
arrow: true,
arrowType: 'round'
})
// we have to update tippy whenever the DOM is updated
if (MutationObserver) {
this.observer = new MutationObserver(() => {
this.popup.popperInstance.scheduleUpdate()
})
this.observer.observe(this.$refs.suggestions, {
childList: true,
subtree: true,
characterData: true
})
}
},
destroyPopup() {
if (this.popup) {
this.popup.destroy()
this.popup = null
}
if (this.observer) {
this.observer.disconnect()
}
},
onUpdate(e) {
const content = e.getHTML()
const contentHash = stringHash(content)
@ -273,6 +451,60 @@ export default {
</script>
<style lang="scss">
.suggestion-list {
padding: 0.2rem;
border: 2px solid rgba($color-neutral-0, 0.1);
font-size: 0.8rem;
font-weight: bold;
&__no-results {
padding: 0.2rem 0.5rem;
}
&__item {
border-radius: 5px;
padding: 0.2rem 0.5rem;
margin-bottom: 0.2rem;
cursor: pointer;
&:last-child {
margin-bottom: 0;
}
&.is-selected,
&:hover {
background-color: rgba($color-neutral-100, 0.2);
}
&.is-empty {
opacity: 0.5;
}
}
}
.tippy-tooltip.dark-theme {
background-color: $color-neutral-0;
padding: 0;
font-size: 1rem;
text-align: inherit;
color: $color-neutral-100;
border-radius: 5px;
.tippy-backdrop {
display: none;
}
.tippy-roundarrow {
fill: $color-neutral-0;
}
.tippy-popper[x-placement^='top'] & .tippy-arrow {
border-top-color: $color-neutral-0;
}
.tippy-popper[x-placement^='bottom'] & .tippy-arrow {
border-bottom-color: $color-neutral-0;
}
.tippy-popper[x-placement^='left'] & .tippy-arrow {
border-left-color: $color-neutral-0;
}
.tippy-popper[x-placement^='right'] & .tippy-arrow {
border-right-color: $color-neutral-0;
}
}
.ProseMirror {
padding: $space-base;
margin: -$space-base;
@ -302,6 +534,9 @@ li > p {
}
.editor {
.mention-suggestion {
color: $color-primary;
}
&__floating-menu {
position: absolute;
margin-top: -0.25rem;

View File

@ -0,0 +1,29 @@
import { Node } from 'tiptap'
import { replaceText } from 'tiptap-commands'
import { Mention as TipTapMention } from 'tiptap-extensions'
export default class Mention extends TipTapMention {
get schema() {
const patchedSchema = super.schema
patchedSchema.attrs = {
url: {},
label: {}
}
patchedSchema.toDOM = node => {
return [
'a',
{
class: this.options.mentionClass,
href: node.attrs.url,
target: '_blank'
},
`${this.options.matcher.char}${node.attrs.label}`
]
}
patchedSchema.parseDOM = [
// this is not implemented
]
return patchedSchema
}
}

View File

@ -0,0 +1,44 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Editor from './'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('Editor.vue', () => {
let wrapper
let propsData
beforeEach(() => {
propsData = {}
})
describe('mount', () => {
let Wrapper = () => {
return (wrapper = mount(Editor, {
propsData,
localVue,
sync: false,
stubs: { transition: false }
}))
}
it('renders', () => {
expect(Wrapper().is('div')).toBe(true)
})
describe('given a piece of text', () => {
beforeEach(() => {
propsData.value = 'I am a piece of text'
})
it.skip('renders', () => {
wrapper = Wrapper()
expect(wrapper.find('.ProseMirror').text()).toContain(
'I am a piece of text'
)
})
})
})
})

View File

@ -196,15 +196,6 @@ module.exports = {
** You can extend webpack config here
*/
extend(config, ctx) {
// Run ESLint on save
if (ctx.isDev && ctx.isClient) {
config.module.rules.push({
enforce: 'pre',
test: /\.(js|vue)$/,
loader: 'eslint-loader',
exclude: /(node_modules)/
})
}
if (process.env.STYLEGUIDE_DEV) {
const path = require('path')
config.resolve.alias['@@'] = path.resolve(

View File

@ -74,11 +74,13 @@
"eslint-loader": "~2.1.2",
"eslint-plugin-prettier": "~3.0.1",
"eslint-plugin-vue": "~5.2.2",
"fuse.js": "^3.4.4",
"jest": "~24.7.1",
"node-sass": "~4.11.0",
"nodemon": "~1.18.11",
"prettier": "~1.14.3",
"sass-loader": "~7.1.0",
"tippy.js": "^4.2.1",
"vue-jest": "~3.0.4",
"vue-svg-loader": "~0.12.0"
}

View File

@ -14,7 +14,7 @@
<script>
import gql from 'graphql-tag'
import HcContributionForm from '~/components/ContributionForm.vue'
import HcContributionForm from '~/components/ContributionForm'
export default {
components: {

View File

@ -14,7 +14,7 @@
<script>
import gql from 'graphql-tag'
import HcContributionForm from '~/components/ContributionForm.vue'
import HcContributionForm from '~/components/ContributionForm'
export default {
components: {

View File

@ -1381,6 +1381,11 @@
"@types/express-serve-static-core" "*"
"@types/mime" "*"
"@types/stack-utils@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
"@types/strip-bom@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2"
@ -4878,6 +4883,11 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
fuse.js@^3.4.4:
version "3.4.4"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.4.tgz#f98f55fcb3b595cf6a3e629c5ffaf10982103e95"
integrity sha512-pyLQo/1oR5Ywf+a/tY8z4JygnIglmRxVUOiyFAbd11o9keUDpUJSMGRWJngcnkURj30kDHPmhoKY8ChJiz3EpQ==
gauge@~2.7.3:
version "2.7.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
@ -6170,6 +6180,15 @@ jest-message-util@^24.7.1:
version "24.7.1"
resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.7.1.tgz#f1dc3a6c195647096a99d0f1dadbc447ae547018"
integrity sha512-dk0gqVtyqezCHbcbk60CdIf+8UHgD+lmRHifeH3JRcnAqh4nEyPytSc9/L1+cQyxC+ceaeP696N4ATe7L+omcg==
dependencies:
"@babel/code-frame" "^7.0.0"
"@jest/test-result" "^24.7.1"
"@jest/types" "^24.7.0"
"@types/stack-utils" "^1.0.1"
chalk "^2.0.1"
micromatch "^3.1.10"
slash "^2.0.0"
stack-utils "^1.0.1"
jest-mock@^24.7.0:
version "24.7.0"
@ -8003,7 +8022,7 @@ pn@^1.1.0:
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
popper.js@^1.15.0:
popper.js@^1.14.7, popper.js@^1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2"
integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA==
@ -9871,7 +9890,7 @@ stack-trace@0.0.10:
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
stack-utils@^1.0.2:
stack-utils@^1.0.1, stack-utils@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8"
integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA==
@ -10336,6 +10355,13 @@ timsort@^0.3.0:
resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
tippy.js@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-4.2.1.tgz#9e4939d976465f77229b05a3cb233b5dc28cf850"
integrity sha512-xEE7zYNgQxCDdPcuT6T04f0frPh0wO7CcIqJKMFazU/NqusyjCgYSkLRosIHoiRkZMRzSPOudC8wRN5GjvAyOQ==
dependencies:
popper.js "^1.14.7"
tiptap-commands@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.7.0.tgz#d15cec2cb09264b5c1f6f712dab8819bb9ab7e13"