Skip to content
Snippets Groups Projects
Verified Commit 6e82780e authored by Eliot Berriot's avatar Eliot Berriot
Browse files

See #890: improved report card design, to include relevant context

parent f48f74dc
No related branches found
No related tags found
No related merge requests found
<template>
<div class="expandable-wrapper">
<div :class="['expandable-content', {expandable: truncated.length < content.length}, {expanded: isExpanded}]">
<slot>{{ content }}</slot>
</div>
<a v-if="truncated.length < content.length" role="button" @click.prevent="isExpanded = !isExpanded">
<br>
<translate v-if="isExpanded" key="1" translate-context="*/*/Button,Label">Show less</translate>
<translate v-else key="2" translate-context="*/*/Button,Label">Show more</translate>
</a>
</div>
</template>
<script>
import sanitize from "@/sanitize"
export default {
props: {
content: {type: String, required: true},
length: {type: Number, default: 150, required: false},
},
data () {
return {
isExpanded: false,
}
},
computed: {
truncated () {
return this.content.substring(0, this.length)
}
}
}
</script>
...@@ -48,4 +48,7 @@ import EmptyState from '@/components/common/EmptyState' ...@@ -48,4 +48,7 @@ import EmptyState from '@/components/common/EmptyState'
Vue.component('empty-state', EmptyState) Vue.component('empty-state', EmptyState)
import ExpandableDiv from '@/components/common/ExpandableDiv'
Vue.component('expandable-div', ExpandableDiv)
export default {} export default {}
...@@ -158,6 +158,9 @@ export default { ...@@ -158,6 +158,9 @@ export default {
}, },
updatedFields () { updatedFields () {
if (!this.obj.target) {
return []
}
let payload = this.obj.payload let payload = this.obj.payload
let previousState = this.previousState let previousState = this.previousState
let fields = Object.keys(payload) let fields = Object.keys(payload)
......
<template>
<div class="ui fluid report card">
<div class="content">
<div class="header">
<router-link :to="{name: 'manage.moderation.reports.detail', params: {id: obj.uuid}}">
<translate translate-context="Content/Moderation/Card/Short" :translate-params="{id: obj.uuid.substring(0, 8)}">Report %{ id }</translate>
</router-link>
</div>
<div class="content">
<div class="ui stackable two column grid">
<div class="column">
<table class="ui very basic unstackable table">
<tbody>
<tr>
<td>
<translate translate-context="Content/Moderation/*">Submitted by</translate>
</td>
<td>
<div v-if="obj.submitter">
<actor-link :actor="obj.submitter" />
</div>
<div v-else="obj.submitter_email">
{{ obj.submitter_email }}
</div>
</td>
</tr>
<tr>
<td>
<translate translate-context="*/*/*">Category</translate>
</td>
<td>
<i class="tag icon"></i>
{{ obj.type }}
</td>
</tr>
<tr>
<td>
<translate translate-context="*/*/*/Noun">Creation date</translate>
</td>
<td>
<human-date :date="obj.creation_date" :icon="true"></human-date>
</td>
</tr>
</tbody>
</table>
</div>
<div class="column">
<table class="ui very basic unstackable table">
<tbody>
<tr>
<td>
<translate translate-context="*/*/*">Status</translate>
</td>
<td v-if="obj.is_handled">
<span v-if="obj.is_handled">
<i class="green check icon"></i>
<translate translate-context="Content/*/*/Short">Resolved</translate>
</span>
</td>
<td v-else>
<i class="red x icon"></i>
<translate translate-context="Content/*/*/Short">Unresolved</translate>
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/Moderation/*">Assignee</translate>
</td>
<td>
<div v-if="obj.assigned_to">
<actor-link :actor="obj.assigned_to" />
</div>
<translate v-else translate-context="*/*/*">N/A</translate>
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/*/*/Noun">Resolution date</translate>
</td>
<td>
<human-date v-if="obj.handled_date" :date="obj.handled_date" :icon="true"></human-date>
<translate v-else translate-context="*/*/*">N/A</translate>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="main content">
<div class="ui stackable two column grid">
<div class="column">
<h3>
<translate translate-context="Content/*/*/Short">Message</translate>
</h3>
<expandable-div v-if="obj.summary" class="summary" :content="obj.summary">
<div v-html="markdown.makeHtml(obj.summary)"></div>
</expandable-div>
</div>
<aside class="column">
<h3>
<translate translate-context="Content/*/*/Short">Reported object</translate>
</h3>
<router-link class="ui basic button" v-if="configs[obj.target.type].urls.getAdminDetail" :to="configs[obj.target.type].urls.getAdminDetail(obj.target_state)">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
<table class="ui very basic unstackable table">
<tbody>
<tr>
<td>
<translate translate-context="*/*/*">Type</translate>
</td>
<td>
<i :class="[configs[obj.target.type].icon, 'icon']"></i>
<translate translate-context="*/*/*">{{ configs[obj.target.type].label }}</translate>
</td>
</tr>
<tr v-if="obj.target_state.is_local">
<td>
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
</td>
<td>
<i class="home icon"></i>
<translate translate-context="Content/Moderation/*/Short, Noun">Local</translate>
</td>
</tr>
<tr v-else-if="obj.target_state.domain">
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: obj.target_state.domain }}">
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
</router-link>
</td>
<td>
{{ obj.target_state.domain }}
</td>
</tr>
<tr v-for="field in targetFields" :key="field.id">
<td>{{ field.label }}</td>
<td>{{ field.repr }}</td>
</tr>
</tbody>
</table>
</aside>
</div>
</div>
<div class="ui bottom attached buttons">
<button
v-if="obj.is_handled === false"
@click="resolve(true)"
:class="['ui', {loading: isLoading}, 'green', 'basic', 'button']">
<translate translate-context="Content/*/Button.Label/Verb">Resolve</translate>
</button>
<button
v-if="obj.is_handled === true"
@click="resolve(false)"
:class="['ui', {loading: isLoading}, 'yellow', 'basic', 'button']">
<translate translate-context="Content/*/Button.Label">Unresolve</translate>
</button>
<dangerous-button
:class="['ui', {loading: isLoading}, 'basic button']"
:action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/*/Title">Delete this report?</translate></p>
<div slot="modal-content">
<p><translate translate-context="Popup/*/Paragraph">The report will be completely removed, this action is irreversible.</translate></p>
</div>
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
</dangerous-button>
</div>
</div>
</template>
<script>
import axios from 'axios'
import { diffWordsWithSpace } from 'diff'
import entities from '@/entities'
import showdown from 'showdown'
function castValue (value) {
if (value === null || value === undefined) {
return ''
}
return String(value)
}
export default {
props: {
obj: {required: true},
currentState: {required: false}
},
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
}
},
computed: {
configs: entities.getConfigs,
previousState () {
if (this.obj.is_applied) {
// mutation was applied, we use the previous state that is stored
// on the mutation itself
return this.obj.previous_state
}
// mutation is not applied yet, so we use the current state that was
// passed to the component, if any
return this.currentState
},
detailUrl () {
if (!this.obj.target) {
return ''
}
let namespace
let id = this.obj.target.id
if (this.obj.target.type === 'track') {
namespace = 'library.tracks.edit.detail'
}
if (this.obj.target.type === 'album') {
namespace = 'library.albums.edit.detail'
}
if (this.obj.target.type === 'artist') {
namespace = 'library.artists.edit.detail'
}
return this.$router.resolve({name: namespace, params: {id, editId: this.obj.uuid}}).href
},
targetFields () {
let payload = this.obj.target_state
let fields = this.configs[this.obj.target.type].moderatedFields
let self = this
return fields.map((fieldConfig) => {
let dummyRepr = (v) => { return v }
let getValueRepr = fieldConfig.getValueRepr || dummyRepr
let d = {
id: fieldConfig.id,
label: fieldConfig.label,
value: payload[fieldConfig.id],
repr: castValue(getValueRepr(payload[fieldConfig.id])),
}
return d
})
}
},
methods: {
remove () {
let self = this
this.isLoading = true
axios.delete(`manage/moderation/reports/${this.obj.uuid}/`).then((response) => {
self.$emit('deleted')
self.isLoading = false
if (!self.obj.is_handled) {
self.$store.commit('ui/incrementNotifications', {count: -1, type: 'pendingReviewReports'})
}
}, error => {
self.isLoading = false
})
},
resolve (v) {
let url = `manage/moderation/reports/${this.obj.uuid}/`
let self = this
this.isLoading = true
axios.patch(url, {is_handled: v}).then((response) => {
self.$emit('handled', v)
self.isLoading = false
let increment
if (v) {
increment = -1
} else {
increment = 1
}
self.$store.commit('ui/incrementNotifications', {count: increment, type: 'pendingReviewReports'})
}, error => {
self.isLoading = false
})
},
}
}
</script>
function getTagsValueRepr (val) {
if (!val) {
return ''
}
return val.slice().sort().join('\n')
}
export default {
getConfigs () {
return {
artist: {
label: this.$pgettext('*/*/*', 'Artist'),
icon: 'users',
urls: {
getAdminDetail: (obj) => { return {name: 'manage.library.artists.detail', params: {id: obj.id}}}
},
moderatedFields: [
{
id: 'name',
label: this.$pgettext('*/*/*/Noun', 'Name'),
getValue: (obj) => { return obj.name }
},
{
id: 'creation_date',
label: this.$pgettext('*/*/*/Noun', 'Creation date'),
getValue: (obj) => { return obj.creation_date }
},
{
id: 'tags',
type: 'tags',
label: this.$pgettext('*/*/*/Noun', 'Tags'),
getValue: (obj) => { return obj.tags },
getValueRepr: getTagsValueRepr
},
{
id: 'mbid',
label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'),
getValue: (obj) => { return obj.mbid }
},
]
},
album: {
label: this.$pgettext('*/*/*', 'Album'),
icon: 'play',
urls: {
getAdminDetail: (obj) => { return {name: 'manage.library.albums.detail', params: {id: obj.id}}}
},
moderatedFields: [
{
id: 'title',
label: this.$pgettext('*/*/*/Noun', 'Title'),
getValue: (obj) => { return obj.title }
},
{
id: 'creation_date',
label: this.$pgettext('*/*/*/Noun', 'Creation date'),
getValue: (obj) => { return obj.creation_date }
},
{
id: 'release_date',
label: this.$pgettext('Content/*/*/Noun', 'Release date'),
getValue: (obj) => { return obj.release_date }
},
{
id: 'tags',
type: 'tags',
required: true,
label: this.$pgettext('*/*/*/Noun', 'Tags'),
getValue: (obj) => { return obj.tags },
getValueRepr: getTagsValueRepr
},
{
id: 'mbid',
label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'),
getValue: (obj) => { return obj.mbid }
},
]
},
track: {
label: this.$pgettext('*/*/*', 'Track'),
icon: 'music',
urls: {
getAdminDetail: (obj) => { return {name: 'manage.library.tracks.detail', params: {id: obj.id}}}
},
moderatedFields: [
{
id: 'title',
label: this.$pgettext('*/*/*/Noun', 'Title'),
getValue: (obj) => { return obj.title }
},
{
id: 'position',
label: this.$pgettext('*/*/*/Short, Noun', 'Position'),
getValue: (obj) => { return obj.position }
},
{
id: 'copyright',
label: this.$pgettext('Content/Track/*/Noun', 'Copyright'),
getValue: (obj) => { return obj.copyright }
},
{
id: 'license',
label: this.$pgettext('Content/*/*/Noun', 'License'),
getValue: (obj) => { return obj.license },
},
{
id: 'tags',
label: this.$pgettext('*/*/*/Noun', 'Tags'),
getValue: (obj) => { return obj.tags },
getValueRepr: getTagsValueRepr
},
{
id: 'mbid',
label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'),
getValue: (obj) => { return obj.mbid }
},
]
},
library: {
label: this.$pgettext('*/*/*', 'Library'),
icon: 'book',
urls: {
getAdminDetail: (obj) => { return {name: 'manage.library.libraries.detail', params: {id: obj.uuid}}}
},
moderatedFields: [
{
id: 'name',
label: this.$pgettext('*/*/*/Noun', 'Name'),
getValue: (obj) => { return obj.name }
},
{
id: 'description',
label: this.$pgettext('*/*/*/Noun', 'Description'),
getValue: (obj) => { return obj.position }
},
{
id: 'privacy_level',
label: this.$pgettext('*/*/*', 'Visibility'),
getValue: (obj) => { return obj.privacy_level }
},
]
},
playlist: {
label: this.$pgettext('*/*/*', 'Playlist'),
icon: 'list',
urls: {
// getAdminDetail: (obj) => { return {name: 'manage.playlists.detail', params: {id: obj.id}}}
},
moderatedFields: [
{
id: 'name',
label: this.$pgettext('*/*/*/Noun', 'Name'),
getValue: (obj) => { return obj.name }
},
{
id: 'privacy_level',
label: this.$pgettext('*/*/*', 'Visibility'),
getValue: (obj) => { return obj.privacy_level }
},
]
},
account: {
label: this.$pgettext('*/*/*', 'Account'),
icon: 'user',
urls: {
getAdminDetail: (obj) => { return {name: 'manage.moderation.accounts.detail', params: {id: `${obj.preferred_username}@${obj.domain}`}}}
},
moderatedFields: [
{
id: 'name',
label: this.$pgettext('*/*/*/Noun', 'Name'),
getValue: (obj) => { return obj.name }
},
{
id: 'summary',
label: this.$pgettext('*/*/*/Noun', 'Bio'),
getValue: (obj) => { return obj.summary }
},
]
},
}
},
getConfig () {
return this.configs[this.objectType]
},
getFieldConfig (configs, type, fieldId) {
let c = configs[type]
return c.fields.filter((f) => {
return f.id == fieldId
})[0]
},
getCurrentStateForObj (obj, config) {
let s = {}
config.fields.forEach(f => {
s[f.id] = {value: f.getValue(obj)}
})
return s
},
}
...@@ -368,5 +368,13 @@ input + .help { ...@@ -368,5 +368,13 @@ input + .help {
margin-top: 0.5em; margin-top: 0.5em;
} }
.expandable {
&:not(.expanded) {
overflow: hidden;
max-height: 15vh;
background: linear-gradient(top, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0.3) 100%);
}
}
@import "./themes/_light.scss"; @import "./themes/_light.scss";
@import "./themes/_dark.scss"; @import "./themes/_dark.scss";
<template> <template>
<div class="main pusher" v-title="labels.moderation"> <div class="main pusher" v-title="labels.moderation">
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu"> <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
<router-link
class="ui item"
:to="{name: 'manage.moderation.reports.list'}"><translate translate-context="*/Moderation/*/Noun">Reports</translate></router-link>
<router-link <router-link
class="ui item" class="ui item"
:to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link> :to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link>
......
...@@ -4,52 +4,61 @@ ...@@ -4,52 +4,61 @@
<h2 class="ui header"><translate translate-context="*/Moderation/Title,Name">Reports</translate></h2> <h2 class="ui header"><translate translate-context="*/Moderation/Title,Name">Reports</translate></h2>
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<div class="ui inline form"> <div class="ui inline form">
<div class="fields"> <div class="fields">
<div class="ui field"> <div class="ui field">
<label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> <label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label>
<form @submit.prevent="search.query = $refs.search.value"> <form @submit.prevent="search.query = $refs.search.value">
<input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" /> <input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
</form> </form>
</div> </div>
<div class="field"> <div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label (Value is All/Resolved/Unresolved)">Status</translate></label> <label><translate translate-context="Content/Search/Dropdown.Label (Value is All/Resolved/Unresolved)">Status</translate></label>
<select class="ui dropdown" @change="addSearchToken('resolved', $event.target.value)" :value="getTokenValue('resolved', '')"> <select class="ui dropdown" @change="addSearchToken('resolved', $event.target.value)" :value="getTokenValue('resolved', '')">
<option value=""> <option value="">
<translate translate-context="Content/*/Dropdown">All</translate> <translate translate-context="Content/*/Dropdown">All</translate>
</option> </option>
<option value="yes"> <option value="yes">
<translate translate-context="Content/*/*/Short">Resolved</translate> <translate translate-context="Content/*/*/Short">Resolved</translate>
</option> </option>
<option value="no"> <option value="no">
<translate translate-context="Content/*/*/Short">Unresolved</translate> <translate translate-context="Content/*/*/Short">Unresolved</translate>
</option> </option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
<select class="ui dropdown" v-model="ordering"> <select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]"> <option v-for="option in orderingOptions" :value="option[0]">
{{ sharedLabels.filters[option[1]] }} {{ sharedLabels.filters[option[1]] }}
</option> </option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label> <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label>
<select class="ui dropdown" v-model="orderingDirection"> <select class="ui dropdown" v-model="orderingDirection">
<option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option> <option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option>
<option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option> <option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option>
</select> </select>
</div>
</div> </div>
</div> </div>
</div> <div v-if="isLoading" class="ui active inverted dimmer">
<div class="ui loader"></div>
</div>
<div v-else-if="!result || result.count === 0">
<empty-state @refresh="fetchData()" :refresh="true"></empty-state>
</div>
<div v-else-if="mode === 'card'">
<report-card :obj="obj" v-for="obj in result.results" :key="obj.uuid" @handled="fetchData()" @deleted="fetchData()" />
</div>
<action-table <action-table
v-if="result" v-else-if="mode === 'table'"
:objects-data="result" :objects-data="result"
:actions="actions" :actions="actions"
action-url="manage/moderation/reports/action/" action-url="manage/moderation/reports/action/"
:filters="[]"> :filters="[]">
<template slot="header-cells"> <template slot="header-cells">
<th><translate translate-context="*/*/*">Submitter</translate></th> <th><translate translate-context="*/*/*">Submitted by</translate></th>
<th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th> <th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th>
<th><translate translate-context="*/*/*">Category</translate></th> <th><translate translate-context="*/*/*">Category</translate></th>
<th><translate translate-context="*/*/*">Status</translate></th> <th><translate translate-context="*/*/*">Status</translate></th>
...@@ -120,7 +129,7 @@ import time from '@/utils/time' ...@@ -120,7 +129,7 @@ import time from '@/utils/time'
import Pagination from '@/components/Pagination' import Pagination from '@/components/Pagination'
import OrderingMixin from '@/components/mixins/Ordering' import OrderingMixin from '@/components/mixins/Ordering'
import TranslationsMixin from '@/components/mixins/Translations' import TranslationsMixin from '@/components/mixins/Translations'
// import EditCard from '@/components/library/EditCard' import ReportCard from '@/components/manage/moderation/ReportCard'
import {normalizeQuery, parseTokens} from '@/search' import {normalizeQuery, parseTokens} from '@/search'
import SmartSearchMixin from '@/components/mixins/SmartSearch' import SmartSearchMixin from '@/components/mixins/SmartSearch'
import ActionTable from '@/components/common/ActionTable' import ActionTable from '@/components/common/ActionTable'
...@@ -130,8 +139,11 @@ export default { ...@@ -130,8 +139,11 @@ export default {
mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin],
components: { components: {
Pagination, Pagination,
ActionTable ActionTable,
// EditCard ReportCard,
},
props: {
mode: {default: 'card'},
}, },
data () { data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment