Merge branch 'feature/add-reports' into 'master'

Add ability to read reports

Closes #14

See merge request pleroma/admin-fe!11
This commit is contained in:
feld 2019-05-30 15:45:40 +00:00
commit 3a8a032e81
20 changed files with 1217 additions and 8 deletions

1
.gitignore vendored
View file

@ -19,3 +19,4 @@ selenium-debug.log
*.sln *.sln
package-lock.json package-lock.json
coverage/

View file

@ -42,7 +42,7 @@
"driver.js": "0.8.1", "driver.js": "0.8.1",
"dropzone": "5.2.0", "dropzone": "5.2.0",
"echarts": "4.1.0", "echarts": "4.1.0",
"element-ui": "2.4.11", "element-ui": "^2.7.0",
"file-saver": "1.3.8", "file-saver": "1.3.8",
"fuse.js": "3.4.2", "fuse.js": "3.4.2",
"js-cookie": "2.2.0", "js-cookie": "2.2.0",
@ -50,6 +50,7 @@
"jszip": "3.1.5", "jszip": "3.1.5",
"lodash": "^4.17.11", "lodash": "^4.17.11",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"moment": "^2.24.0",
"normalize.css": "7.0.0", "normalize.css": "7.0.0",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",

View file

@ -0,0 +1,37 @@
const reports = [
{ created_at: '2019-05-21T21:35:33.000Z', account: { acct: 'benj', display_name: 'Benjamin Fame' }, actor: { acct: 'admin' }, state: 'open', id: '2', content: 'This is a report', statuses: [] },
{ created_at: '2019-05-20T22:45:33.000Z', account: { acct: 'alice', display_name: 'Alice Pool' }, actor: { acct: 'admin2' }, state: 'resolved', id: '1', content: 'Please block this user', statuses: [] },
{ created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys' }, actor: { acct: 'admin' }, state: 'closed', id: '3', content: '', statuses: [] },
{ created_at: '2019-05-21T21:35:33.000Z', account: { acct: 'benj', display_name: 'Benjamin Fame' }, actor: { acct: 'admin' }, state: 'open', id: '5', content: 'This is a report', statuses: [] },
{ created_at: '2019-05-20T22:45:33.000Z', account: { acct: 'alice', display_name: 'Alice Pool' }, actor: { acct: 'admin2' }, state: 'resolved', id: '7', content: 'Please block this user', statuses: [
{ account: { display_name: 'Alice Pool', avatar: '' }, visibility: 'public', sensitive: false, id: '11', content: 'Hey!', url: '', created_at: '2019-05-10T21:35:33.000Z' },
{ account: { display_name: 'Alice Pool', avatar: '' }, visibility: 'unlisted', sensitive: true, id: '10', content: 'Bye!', url: '', created_at: '2019-05-10T21:00:33.000Z' }
] },
{ created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys' }, actor: { acct: 'admin' }, state: 'closed', id: '6', content: '', statuses: [] },
{ created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys' }, actor: { acct: 'admin' }, state: 'closed', id: '4', content: '', statuses: [] }
]
export async function fetchReports(limit, max_id, authHost, token) {
const paginatedReports = max_id.length > 0 ? reports.slice(5) : reports.slice(0, 5)
return Promise.resolve({ data: { reports: paginatedReports }})
}
export async function filterReports(filter, limit, max_id, authHost, token) {
const filteredReports = reports.filter(report => report.state === filter)
const paginatedReports = max_id.length > 0 ? filteredReports.slice(5) : filteredReports.slice(0, 5)
return Promise.resolve({ data: { reports: paginatedReports }})
}
export async function changeState(state, id, authHost, token) {
const report = reports.find(report => report.id === id)
return Promise.resolve({ data: { ...report, state }})
}
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {
const status = reports[4].statuses[0]
return Promise.resolve({ data: { ...status, sensitive, visibility }})
}
export async function deleteStatus(statusId, authHost, token) {
return Promise.resolve()
}

52
src/api/reports.js Normal file
View file

@ -0,0 +1,52 @@
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function changeState(state, id, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports/${id}`,
method: 'put',
headers: authHeaders(token),
data: { state }
})
}
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'put',
headers: authHeaders(token),
data: { sensitive, visibility }
})
}
export async function deleteStatus(id, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'delete',
headers: authHeaders(token)
})
}
export async function fetchReports(limit, max_id, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports?limit=${limit}&max_id=${max_id}`,
method: 'get',
headers: authHeaders(token)
})
}
export async function filterReports(filter, limit, max_id, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports?state=${filter}&limit=${limit}&max_id=${max_id}`,
method: 'get',
headers: authHeaders(token)
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}

218
src/api/reportsData.js Normal file
View file

@ -0,0 +1,218 @@
export const reports = [
{
id: '1',
timestamp: '2019/4/12',
local: true,
from: 'John', // actor nickname
object: 'Bob', // user nickname
header: 'Report #1', // content
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Nick', text: 'Lorem ipsum', id: '1', timestamp: '2019/4/13' },
{ author: 'Val', text: 'dolor sit amet', id: '2', timestamp: '2019/4/13' }
]
},
{
id: '2',
timestamp: '2019/4/1',
local: true,
from: 'Max',
object: 'Vic',
header: 'Report #2',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Tony', text: 'consectetur adipiscing elit', id: '3', timestamp: '2019/4/2' },
{ author: 'Zac', text: 'sed do eiusmod tempor incididunt', id: '4', timestamp: '2019/4/3' }
]
},
{
id: '3',
timestamp: '2019/2/28',
local: true,
from: 'Tim',
object: 'Jen',
header: 'Report #3',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [{ author: 'Bruce', text: 'ut labore et dolore magna aliqua', id: '5', timestamp: '2019/3/1' }]
},
{
id: '4',
timestamp: '2019/4/12',
local: true,
from: 'John', // actor nickname
object: 'Bob', // user nickname
header: 'Report #4', // content
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Nick', text: 'Lorem ipsum', id: '6', timestamp: '2019/4/13' },
{ author: 'Val', text: 'dolor sit amet', id: '7', timestamp: '2019/4/13' }
]
},
{
id: '5',
timestamp: '2019/4/1',
local: true,
from: 'Max',
object: 'Vic',
header: 'Report #5',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Tony', text: 'consectetur adipiscing elit', id: '8', timestamp: '2019/4/2' },
{ author: 'Zac', text: 'sed do eiusmod tempor incididunt', id: '9', timestamp: '2019/4/3' }
]
},
{
id: '6',
timestamp: '2019/2/28',
local: true,
from: 'Tim',
object: 'Jen',
header: 'Report #6',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [{ author: 'Bruce', text: 'ut labore et dolore magna aliqua', id: '10', timestamp: '2019/3/1' }]
},
{
id: '7',
timestamp: '2019/4/12',
local: true,
from: 'John', // actor nickname
object: 'Bob', // user nickname
header: 'Report #7', // content
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Nick', text: 'Lorem ipsum', id: '11', timestamp: '2019/4/13' },
{ author: 'Val', text: 'dolor sit amet', id: '12', timestamp: '2019/4/13' }
]
},
{
id: '8',
timestamp: '2019/4/1',
local: true,
from: 'Max',
object: 'Vic',
header: 'Report #8',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Tony', text: 'consectetur adipiscing elit', id: '13', timestamp: '2019/4/2' },
{ author: 'Zac', text: 'sed do eiusmod tempor incididunt', id: '14', timestamp: '2019/4/3' }
]
},
{
id: '9',
timestamp: '2019/2/28',
local: true,
from: 'Tim',
object: 'Jen',
header: 'Report #9',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [{ author: 'Bruce', text: 'ut labore et dolore magna aliqua', id: '15', timestamp: '2019/3/1' }]
},
{
id: '10',
timestamp: '2019/4/12',
local: true,
from: 'John', // actor nickname
object: 'Bob', // user nickname
header: 'Report #10', // content
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Nick', text: 'Lorem ipsum', id: '16', timestamp: '2019/4/13' },
{ author: 'Val', text: 'dolor sit amet', id: '17', timestamp: '2019/4/13' }
]
},
{
id: '11',
timestamp: '2019/4/1',
local: true,
from: 'Max',
object: 'Vic',
header: 'Report #11',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Tony', text: 'consectetur adipiscing elit', id: '18', timestamp: '2019/4/2' },
{ author: 'Zac', text: 'sed do eiusmod tempor incididunt', id: '19', timestamp: '2019/4/3' }
]
},
{
id: '12',
timestamp: '2019/2/28',
local: true,
from: 'Tim',
object: 'Jen',
header: 'Report #12',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [{ author: 'Bruce', text: 'ut labore et dolore magna aliqua', id: '20', timestamp: '2019/3/1' }]
},
{
id: '13',
timestamp: '2019/4/12',
local: true,
from: 'John', // actor nickname
object: 'Bob', // user nickname
header: 'Report #13', // content
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Nick', text: 'Lorem ipsum', id: '21', timestamp: '2019/4/13' },
{ author: 'Val', text: 'dolor sit amet', id: '22', timestamp: '2019/4/13' }
]
},
{
id: '14',
timestamp: '2019/4/1',
local: true,
from: 'Max',
object: 'Vic',
header: 'Report #14',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Tony', text: 'consectetur adipiscing elit', id: '23', timestamp: '2019/4/2' },
{ author: 'Zac', text: 'sed do eiusmod tempor incididunt', id: '24', timestamp: '2019/4/3' }
]
},
{
id: '15',
timestamp: '2019/2/28',
local: true,
from: 'Tim',
object: 'Jen',
header: 'Report #15',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [{ author: 'Bruce', text: 'ut labore et dolore magna aliqua', id: '25', timestamp: '2019/3/1' }]
},
{
id: '16',
timestamp: '2019/4/12',
local: true,
from: 'John', // actor nickname
object: 'Bob', // user nickname
header: 'Report #16', // content
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Nick', text: 'Lorem ipsum', id: '26', timestamp: '2019/4/13' },
{ author: 'Val', text: 'dolor sit amet', id: '27', timestamp: '2019/4/13' }
]
},
{
id: '17',
timestamp: '2019/4/1',
local: true,
from: 'Max',
object: 'Vic',
header: 'Report #17',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [
{ author: 'Tony', text: 'consectetur adipiscing elit', id: '28', timestamp: '2019/4/2' },
{ author: 'Zac', text: 'sed do eiusmod tempor incididunt', id: '29', timestamp: '2019/4/3' }
]
},
{
id: '18',
timestamp: '2019/2/28',
local: true,
from: 'Tim',
object: 'Jen',
header: 'Report #18',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
notes: [{ author: 'Bruce', text: 'ut labore et dolore magna aliqua', id: '30', timestamp: '2019/3/1' }]
}
]

View file

@ -64,7 +64,8 @@ export default {
clipboardDemo: 'Clipboard', clipboardDemo: 'Clipboard',
i18n: 'I18n', i18n: 'I18n',
externalLink: 'External Link', externalLink: 'External Link',
users: 'Users' users: 'Users',
reports: 'Reports'
}, },
navbar: { navbar: {
logOut: 'Log Out', logOut: 'Log Out',
@ -200,5 +201,37 @@ export default {
byStatus: 'By status', byStatus: 'By status',
active: 'Active', active: 'Active',
deactivated: 'Deactivated' deactivated: 'Deactivated'
},
reports: {
reports: 'Reports',
reply: 'Reply',
from: 'From',
showNotes: 'Show notes',
newNote: 'New note',
submit: 'Submit',
confirmMsg: 'Are you sure you want to delete this note?',
delete: 'Delete',
cancel: 'Cancel',
deleteCompleted: 'Delete comleted',
deleteCanceled: 'Delete canceled',
noNotes: 'No notes to display',
changeState: 'Change state',
changeScope: 'Change scope',
resolve: 'Resolve',
reopen: 'Reopen',
close: 'Close',
addSensitive: 'Add Sensitive flag',
removeSensitive: 'Remove Sensitive flag',
public: 'Make status public',
private: 'Make status private',
unlisted: 'Make status unlisted',
sensitive: 'Sensitive',
deleteStatus: 'Delete status'
},
reportsFilter: {
inputPlaceholder: 'Select filter',
open: 'Open',
closed: 'Closed',
resolved: 'Resolved'
} }
} }

View file

@ -64,5 +64,17 @@ export const asyncRouterMap = [
} }
] ]
}, },
{
path: '/reports',
component: Layout,
children: [
{
path: 'index',
component: () => import('@/views/reports/index'),
name: 'Reports',
meta: { title: 'reports', icon: 'documentation', noCache: true }
}
]
},
{ path: '*', redirect: '/404', hidden: true } { path: '*', redirect: '/404', hidden: true }
] ]

View file

@ -3,6 +3,7 @@ import Vuex from 'vuex'
import app from './modules/app' import app from './modules/app'
import errorLog from './modules/errorLog' import errorLog from './modules/errorLog'
import permission from './modules/permission' import permission from './modules/permission'
import reports from './modules/reports'
import tagsView from './modules/tagsView' import tagsView from './modules/tagsView'
import user from './modules/user' import user from './modules/user'
import users from './modules/users' import users from './modules/users'
@ -15,6 +16,7 @@ const store = new Vuex.Store({
app, app,
errorLog, errorLog,
permission, permission,
reports,
tagsView, tagsView,
user, user,
users users

View file

@ -0,0 +1,79 @@
import { changeState, changeStatusScope, deleteStatus, fetchReports, filterReports } from '@/api/reports'
const reports = {
state: {
fetchedReports: [],
idOfLastReport: '',
page_limit: 5,
stateFilter: '',
loading: true
},
mutations: {
SET_LAST_REPORT_ID: (state, id) => {
state.idOfLastReport = id
},
SET_LOADING: (state, status) => {
state.loading = status
},
SET_REPORTS: (state, reports) => {
state.fetchedReports = reports
},
SET_REPORTS_FILTER: (state, filter) => {
state.stateFilter = filter
}
},
actions: {
async ChangeReportState({ commit, getters, state }, { reportState, reportId }) {
const { data } = await changeState(reportState, reportId, getters.authHost, getters.token)
const updatedReports = state.fetchedReports.map(report => report.id === reportId ? data : report)
commit('SET_REPORTS', updatedReports)
},
async ChangeStatusScope({ commit, getters, state }, { statusId, isSensitive, visibility, reportId }) {
const { data } = await changeStatusScope(statusId, isSensitive, visibility, getters.authHost, getters.token)
const updatedReports = state.fetchedReports.map(report => {
if (report.id === reportId) {
const statuses = report.statuses.map(status => status.id === statusId ? data : status)
return { ...report, statuses }
} else {
return report
}
})
commit('SET_REPORTS', updatedReports)
},
ClearFetchedReports({ commit }) {
commit('SET_REPORTS', [])
commit('SET_LAST_REPORT_ID', '')
},
async DeleteStatus({ commit, getters, state }, { statusId, reportId }) {
deleteStatus(statusId, getters.authHost, getters.token)
const updatedReports = state.fetchedReports.map(report => {
if (report.id === reportId) {
const statuses = report.statuses.filter(status => status.id !== statusId)
return { ...report, statuses }
} else {
return report
}
})
commit('SET_REPORTS', updatedReports)
},
async FetchReports({ commit, getters, state }) {
commit('SET_LOADING', true)
const response = state.stateFilter.length === 0
? await fetchReports(state.page_limit, state.idOfLastReport, getters.authHost, getters.token)
: await filterReports(state.stateFilter, state.page_limit, state.idOfLastReport, getters.authHost, getters.token)
const reports = state.fetchedReports.concat(response.data.reports)
const id = reports.length > 0 ? reports[reports.length - 1].id : state.idOfLastReport
commit('SET_REPORTS', reports)
commit('SET_LAST_REPORT_ID', id)
commit('SET_LOADING', false)
},
SetFilter({ commit }, filter) {
commit('SET_REPORTS_FILTER', filter)
}
}
}
export default reports

View file

@ -67,11 +67,11 @@ const users = {
commit('SWAP_USER', updatedUser) commit('SWAP_USER', updatedUser)
}, },
async FetchUsers({ commit, state, getters }, { page }) { async FetchUsers({ commit, state, getters }, { page }) {
commit('SET_LOADING', true)
const filters = Object.keys(state.filters).filter(filter => state.filters[filter]).join() const filters = Object.keys(state.filters).filter(filter => state.filters[filter]).join()
const response = await fetchUsers(filters, getters.authHost, getters.token, page) const response = await fetchUsers(filters, getters.authHost, getters.token, page)
commit('SET_LOADING', true)
loadUsers(commit, page, response.data) loadUsers(commit, page, response.data)
}, },
async RemoveTag({ commit, getters }, { users, tag }) { async RemoveTag({ commit, getters }, { users, tag }) {

View file

@ -0,0 +1,43 @@
<template>
<el-select
v-model="filter"
:placeholder="$t('reportsFilter.inputPlaceholder')"
clearable
class="select-field"
@change="toggleFilters">
<el-option value="open">{{ $t('reportsFilter.open') }}</el-option>
<el-option value="closed">{{ $t('reportsFilter.closed') }}</el-option>
<el-option value="resolved">{{ $t('reportsFilter.resolved') }}</el-option>
</el-select>
</template>
<script>
export default {
data() {
return {
filter: []
}
},
methods: {
toggleFilters() {
this.$store.dispatch('SetFilter', this.$data.filter)
this.$store.dispatch('ClearFetchedReports')
this.$store.dispatch('FetchReports')
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss' scoped>
.select-field {
width: 350px;
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.select-field {
width: 100%;
margin-bottom: 5px;
}
}
</style>

View file

@ -0,0 +1,140 @@
<template>
<el-collapse-item :title="getStatusesTitle(report.statuses)">
<el-card v-for="status in report.statuses" :key="status.id" class="status-card">
<div slot="header">
<div class="status-header">
<div class="status-account">
<img :src="status.account.avatar" alt="avatar" class="status-avatar-img">
<h3 class="status-account-name">{{ status.account.display_name }}</h3>
</div>
<div class="status-actions">
<el-tag v-if="status.sensitive" type="warning" size="large">{{ $t('reports.sensitive') }}</el-tag>
<el-tag size="large">{{ capitalizeFirstLetter(status.visibility) }}</el-tag>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-edit">{{ $t('reports.changeScope') }}<i class="el-icon-arrow-down el-icon--right"/></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="!status.sensitive"
@click.native="changeStatus(status.id, true, status.visibility, report.id)">
{{ $t('reports.addSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.sensitive"
@click.native="changeStatus(status.id, false, status.visibility, report.id)">
{{ $t('reports.removeSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'public'"
@click.native="changeStatus(status.id, status.sensitive, 'public', report.id)">
{{ $t('reports.public') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'private'"
@click.native="changeStatus(status.id, status.sensitive, 'private', report.id)">
{{ $t('reports.private') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'unlisted'"
@click.native="changeStatus(status.id, status.sensitive, 'unlisted', report.id)">
{{ $t('reports.unlisted') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="deleteStatus(status.id, report.id)">
{{ $t('reports.deleteStatus') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<a :href="status.account.url" target="_blank" class="account">
@{{ status.account.acct }}
</a>
</div>
<div class="status-body">
<span class="status-content">{{ status.content }}</span>
<a :href="status.url" target="_blank" class="account">
{{ parseTimestamp(status.created_at) }}
</a>
</div>
</el-card>
</el-collapse-item>
</template>
<script>
import moment from 'moment'
export default {
name: 'Statuses',
props: {
report: {
type: Object,
required: true
}
},
methods: {
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
changeStatus(statusId, isSensitive, visibility, reportId) {
this.$store.dispatch('ChangeStatusScope', { statusId, isSensitive, visibility, reportId })
},
deleteStatus(statusId, reportId) {
this.$confirm('Are you sure you want to delete this status?', 'Warning', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
}).then(() => {
this.$store.dispatch('DeleteStatus', { statusId, reportId })
this.$message({
type: 'success',
message: 'Delete completed'
})
}).catch(() => {
this.$message({
type: 'info',
message: 'Delete canceled'
})
})
},
getStatusesTitle(statuses) {
return `Reported statuses: ${statuses.length} item(s)`
},
parseTimestamp(timestamp) {
return moment(timestamp).format('YYYY-MM-DD HH:mm')
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
.account {
text-decoration: underline;
}
.status-account {
display: flex;
align-items: center;
}
.status-avatar-img {
width: 15px;
height: 15px;
margin-right: 5px;
}
.status-account-name {
margin: 0;
}
.status-body {
display: flex;
flex-direction: column;
}
.status-content {
font-size: 15px;
}
.status-card {
margin-bottom: 15px;
}
.status-header {
display: flex;
justify-content: space-between;
}
</style>

View file

@ -0,0 +1,192 @@
<template>
<el-timeline-item :timestamp="parseTimestamp(report.created_at)" placement="top" class="timeline-item-container">
<el-card>
<div class="header-container">
<h3 class="report-title">Report on {{ report.account.display_name }}</h3>
<div>
<el-tag :type="getStateType(report.state)" size="large">{{ capitalizeFirstLetter(report.state) }}</el-tag>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-edit">{{ $t('reports.changeState') }}<i class="el-icon-arrow-down el-icon--right"/></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-if="report.state !== 'resolved'" @click.native="changeReportState('resolved', report.id)">{{ $t('reports.resolve') }}</el-dropdown-item>
<el-dropdown-item v-if="report.state !== 'open'" @click.native="changeReportState('open', report.id)">{{ $t('reports.reopen') }}</el-dropdown-item>
<el-dropdown-item v-if="report.state !== 'closed'" @click.native="changeReportState('closed', report.id)">{{ $t('reports.close') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<h5 class="id">ID: {{ report.id }}</h5>
<div>
<div class="line"/>
<span class="report-row-key">Account:</span>
<img
:src="report.account.avatar"
alt="avatar"
class="avatar-img">
<a :href="report.account.url" target="_blank" class="account">
<span class="report-row-value">{{ report.account.acct }}</span>
</a>
</div>
<div v-if="report.content.length > 0">
<div class="line"/>
<span class="report-row-key">Content:
<span class="report-row-value">{{ report.content }}</span>
</span>
</div>
<div>
<div class="line"/>
<span class="report-row-key">Actor:</span>
<img
:src="report.actor.avatar"
alt="avatar"
class="avatar-img">
<a :href="report.actor.url" target="_blank" class="account">
<span class="report-row-value">{{ report.actor.acct }}</span>
</a>
</div>
<div v-if="report.statuses.length > 0" class="statuses">
<el-collapse>
<statuses :report="report"/>
</el-collapse>
</div>
</el-card>
</el-timeline-item>
</template>
<script>
import moment from 'moment'
import Statuses from './Statuses'
export default {
name: 'TimelineItem',
components: { Statuses },
props: {
report: {
type: Object,
required: true
}
},
methods: {
changeReportState(reportState, reportId) {
this.$store.dispatch('ChangeReportState', { reportState, reportId })
},
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
getStateType(state) {
switch (state) {
case 'closed':
return 'info'
case 'resolved':
return 'success'
default:
return 'primary'
}
},
parseTimestamp(timestamp) {
return moment(timestamp).format('YYYY-MM-DD HH:mm')
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
.account {
text-decoration: underline;
}
.avatar-img {
vertical-align: bottom;
width: 15px;
height: 15px;
margin-left: 5px;
}
.el-card__body {
padding: 17px;
}
.el-card__header {
background-color: #FAFAFA;
padding: 10px 20px;
}
.el-collapse {
border-bottom: none;
}
.el-collapse-item__header {
height: 46px;
font-size: 14px;
}
.el-collapse-item__content {
padding-bottom: 7px;
}
.el-icon-arrow-right {
margin-right: 6px;
}
.el-icon-close {
padding: 10px 5px 10px 10px;
cursor: pointer;
}
h4 {
margin: 0;
height: 17px;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: baseline;
height: 30px;
}
.id {
color: gray;
margin: 0 0 12px;
}
.line {
width: 100%;
height: 0;
border: 0.5px solid #EBEEF5;
margin: 15px 0 15px;
}
.new-note {
p {
font-size: 14px;
font-weight: 500;
height: 17px;
margin: 13px 0 7px;
}
}
.note {
box-shadow: 0 2px 5px 0 rgba(0,0,0,.1);
margin-bottom: 10px;
}
.no-notes {
font-style: italic;
color: gray;
}
.report-row-key {
font-size: 14px;
font-weight: 500;
}
.report-row-key {
font-size: 14px;
}
.report-title {
margin: 0;
}
.statuses {
margin-top: 15px;
}
.submit-button {
display: block;
margin: 7px 0 17px auto;
}
.timestamp {
margin: 0;
font-style: italic;
color: gray;
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.confirm-message {
width: 98%;
}
}
</style>

View file

@ -0,0 +1,83 @@
<template>
<div class="reports-container">
<h1>{{ $t('reports.reports') }}</h1>
<div class="filter-container">
<reports-filter/>
</div>
<div class="block">
<el-timeline class="timeline">
<timeline-item v-loading="loading" v-for="report in reports" :report="report" :key="report.id"/>
</el-timeline>
<div v-if="reports.length === 0" class="no-reports-message">
<p>There are no reports to display</p>
</div>
</div>
</div>
</template>
<script>
import TimelineItem from './components/TimelineItem'
import ReportsFilter from './components/ReportsFilter'
export default {
components: { TimelineItem, ReportsFilter },
computed: {
loading() {
return this.$store.state.users.loading
},
reports() {
return this.$store.state.reports.fetchedReports
}
},
mounted() {
this.$store.dispatch('FetchReports')
},
created() {
window.addEventListener('scroll', this.handleScroll)
},
destroyed() {
window.removeEventListener('scroll', this.handleScroll)
},
methods: {
handleScroll(reports) {
const bottomOfWindow = document.documentElement.scrollHeight - document.documentElement.scrollTop === document.documentElement.clientHeight
if (bottomOfWindow) {
this.$store.dispatch('FetchReports')
}
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss' scoped>
.reports-container {
.el-timeline {
margin: 45px 45px 45px 19px;
padding: 0px;
}
.filter-container {
margin: 22px 15px 22px 15px;
padding-bottom: 0
}
h1 {
margin: 22px 0 0 15px;
}
.no-reports-message {
color: gray;
margin-left: 19px
}
}
@media
only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
.reports-container {
h1 {
margin: 7px 10px 7px 10px;
}
.filter-container {
margin: 0 10px 7px 10px
}
}
}
</style>

View file

@ -120,6 +120,9 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div v-if="users.length === 0" class="no-users-message">
<p>There are no users to display</p>
</div>
<div v-if="!loading" class="pagination"> <div v-if="!loading" class="pagination">
<el-pagination <el-pagination
:total="usersCount" :total="usersCount"
@ -155,6 +158,11 @@ export default {
search: '' search: ''
} }
}, },
data() {
return {
search: ''
}
},
computed: { computed: {
loading() { loading() {
return this.$store.state.users.loading return this.$store.state.users.loading

View file

@ -0,0 +1,52 @@
import Vuex from 'vuex'
import { mount, createLocalVue, config } from '@vue/test-utils'
import Element from 'element-ui'
import Reports from '@/views/reports/index'
import storeConfig from './store.conf'
import { cloneDeep } from 'lodash'
import flushPromises from 'flush-promises'
config.mocks["$t"] = () => {}
config.stubs['reports-filter'] = '<div />'
config.stubs['timeline-item'] = '<div />'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Element)
jest.mock('@/api/reports')
describe('Reports', () => {
let store
beforeEach(() => {
store = new Vuex.Store(cloneDeep(storeConfig))
})
it('initially fetches reports', async (done) => {
const wrapper = mount(Reports, {
store,
localVue
})
await flushPromises()
const initialReports = store.state.reports.fetchedReports.length
expect(initialReports).toEqual(5)
done()
})
it('loads more reports on scroll', async (done) => {
const wrapper = mount(Reports, {
store,
localVue
})
await flushPromises()
expect(store.state.reports.fetchedReports.length).toEqual(5)
window.dispatchEvent(new CustomEvent('scroll', { detail: 2000 }))
await flushPromises()
expect(store.state.reports.fetchedReports.length).toEqual(7)
done()
})
})

View file

@ -0,0 +1,79 @@
import Vuex from 'vuex'
import { createLocalVue, config } from '@vue/test-utils'
import Element from 'element-ui'
import storeConfig from './store.conf'
import { cloneDeep } from 'lodash'
import flushPromises from 'flush-promises'
config.mocks["$t"] = () => {}
config.stubs.transition = false
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Element)
jest.mock('@/api/reports')
describe('Reports filter', () => {
let store
beforeEach(async() => {
store = new Vuex.Store(cloneDeep(storeConfig))
store.dispatch('FetchReports')
await flushPromises()
})
it('shows open reports when "Open" filter is applied', async (done) => {
expect(store.state.reports.fetchedReports.length).toEqual(5)
store.dispatch('SetFilter', 'open')
store.dispatch('ClearFetchedReports')
store.dispatch('FetchReports')
await flushPromises()
expect(store.state.reports.fetchedReports.length).toEqual(2)
done()
})
it('shows resolved reports when "Resolved" filter is applied', async (done) => {
expect(store.state.reports.fetchedReports.length).toEqual(5)
store.dispatch('SetFilter', 'resolved')
store.dispatch('ClearFetchedReports')
store.dispatch('FetchReports')
await flushPromises()
expect(store.state.reports.fetchedReports.length).toEqual(2)
done()
})
it('shows closed reports when "Closed" filter is applied', async (done) => {
expect(store.state.reports.fetchedReports.length).toEqual(5)
store.dispatch('SetFilter', 'closed')
store.dispatch('ClearFetchedReports')
store.dispatch('FetchReports')
await flushPromises()
expect(store.state.reports.fetchedReports.length).toEqual(3)
done()
})
it('shows all users after removing filters', async (done) => {
expect(store.state.reports.fetchedReports.length).toEqual(5)
store.dispatch('SetFilter', 'open')
store.dispatch('ClearFetchedReports')
store.dispatch('FetchReports')
await flushPromises()
expect(store.state.reports.fetchedReports.length).toEqual(2)
store.dispatch('SetFilter', '')
store.dispatch('ClearFetchedReports')
store.dispatch('FetchReports')
await flushPromises()
expect(store.state.reports.fetchedReports.length).toEqual(5)
done()
})
})

View file

@ -0,0 +1,15 @@
import app from '@/store/modules/app'
import user from '@/store/modules/user'
import users from '@/store/modules/users'
import reports from '@/store/modules/reports'
import getters from '@/store/getters'
export default {
modules: {
app,
user,
users,
reports
},
getters
}

View file

@ -0,0 +1,157 @@
import Vuex from 'vuex'
import { mount, createLocalVue, config } from '@vue/test-utils'
import Element from 'element-ui'
import TimelineItem from '@/views/reports/components/TimelineItem'
import storeConfig from './store.conf'
import { cloneDeep } from 'lodash'
import flushPromises from 'flush-promises'
config.mocks["$t"] = () => {}
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Element)
jest.mock('@/api/reports')
describe('Report in a timeline', () => {
let store
beforeEach(async() => {
store = new Vuex.Store(cloneDeep(storeConfig))
store.dispatch('FetchReports')
await flushPromises()
})
it('changes report state from open to resolved', async (done) => {
const report = store.state.reports.fetchedReports[0]
const wrapper = mount(TimelineItem, {
store,
localVue,
propsData: {
report: report
}
})
expect(report.state).toBe('open')
const button = wrapper.find(`li.el-dropdown-menu__item:nth-child(${1})`)
button.trigger('click')
await flushPromises()
expect(store.state.reports.fetchedReports[0].state).toBe('resolved')
done()
})
it('changes report state from open to closed', async (done) => {
const report = store.state.reports.fetchedReports[3]
const wrapper = mount(TimelineItem, {
store,
localVue,
propsData: {
report: report
}
})
expect(report.state).toBe('open')
const button = wrapper.find(`li.el-dropdown-menu__item:nth-child(${2})`)
button.trigger('click')
await flushPromises()
expect(store.state.reports.fetchedReports[3].state).toBe('closed')
done()
})
it('shows statuses', () => {
const report = store.state.reports.fetchedReports[4]
const wrapper = mount(TimelineItem, {
store,
localVue,
propsData: {
report: report
}
})
const statuses = wrapper.findAll(`.status-card`)
expect(statuses.length).toEqual(2)
})
it('adds sensitive flag to a status', async (done) => {
const report = store.state.reports.fetchedReports[4]
const wrapper = mount(TimelineItem, {
store,
localVue,
propsData: {
report: report
}
})
expect(report.statuses[0].sensitive).toBe(false)
const button = wrapper.find(`.status-card li.el-dropdown-menu__item`)
button.trigger('click')
await flushPromises()
expect(store.state.reports.fetchedReports[4].statuses[0].sensitive).toEqual(true)
done()
})
it('removes sensitive flag to a status', async (done) => {
const report = store.state.reports.fetchedReports[4]
const wrapper = mount(TimelineItem, {
store,
localVue,
propsData: {
report: report
}
})
expect(report.statuses[1].sensitive).toBe(true)
const button = wrapper.find(`.status-card:nth-child(${2}) li.el-dropdown-menu__item`)
button.trigger('click')
await flushPromises()
expect(store.state.reports.fetchedReports[4].statuses[1].sensitive).toEqual(false)
done()
})
it('changes status visibility from public to unlisted', async (done) => {
const report = store.state.reports.fetchedReports[4]
const wrapper = mount(TimelineItem, {
store,
localVue,
propsData: {
report: report
}
})
expect(report.statuses[0].visibility).toBe('public')
const button = wrapper.find(`.status-card li.el-dropdown-menu__item:nth-child(${3})`)
button.trigger('click')
await flushPromises()
expect(store.state.reports.fetchedReports[4].statuses[0].visibility).toEqual('unlisted')
done()
})
it('changes status visibility from unlisted to private', async (done) => {
const report = store.state.reports.fetchedReports[4]
const wrapper = mount(TimelineItem, {
store,
localVue,
propsData: {
report: report
}
})
expect(report.statuses[1].visibility).toBe('unlisted')
const button = wrapper.find(`.status-card:nth-child(${2}) li.el-dropdown-menu__item:nth-child(${3})`)
button.trigger('click')
await flushPromises()
expect(store.state.reports.fetchedReports[4].statuses[1].visibility).toEqual('private')
done()
})
it('deletes a status', async (done) => {
const report = store.state.reports.fetchedReports[4]
expect(report.statuses.length).toEqual(2)
store.dispatch('DeleteStatus', { statusId: '11', reportId: '7'})
await flushPromises()
expect(store.state.reports.fetchedReports[4].statuses.length).toEqual(1)
done()
})
})

View file

@ -3446,10 +3446,10 @@ elegant-spinner@^1.0.1:
resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e"
integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=
element-ui@2.4.11: element-ui@^2.7.0:
version "2.4.11" version "2.7.0"
resolved "https://registry.yarnpkg.com/element-ui/-/element-ui-2.4.11.tgz#db6a2d37001b8fe5fff9f176fb58bb3908cfa9c9" resolved "https://registry.yarnpkg.com/element-ui/-/element-ui-2.7.0.tgz#6bfcdfa5c75bfc4cda835186f2a1f98b93cd5d14"
integrity sha512-RtgK0t840NAFTajGMWvylzZRSX1EkZ7V4YgAoBxhv4TtkeMscLuk/IdYOzPdlQq6IN0byx1YVBxCX+u4yYkGvw== integrity sha512-FalWzOmT/K4w4C/8tw2kGvzzQnRJ5MqEvSL5rEKNa081PFGIcUS9exyVpYrNPKF8ua/W6qaqrXPC6DQ8sNcmOQ==
dependencies: dependencies:
async-validator "~1.8.1" async-validator "~1.8.1"
babel-helper-vue-jsx-merge-props "^2.0.0" babel-helper-vue-jsx-merge-props "^2.0.0"
@ -6645,6 +6645,11 @@ mkdirp@0.5.1, mkdirp@0.5.x, mkdirp@0.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@
dependencies: dependencies:
minimist "0.0.8" minimist "0.0.8"
moment@^2.24.0:
version "2.24.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
move-concurrently@^1.0.1: move-concurrently@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"