diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8dd653122c603cc20fc8e3f24a6bfb5bf11d2e..c2ce8f4007d214382acaa4de288a63ce97210d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- Route for single status +- Create `/statuses/:id` route that shows single status ### Changed @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Send `true` and `false` as booleans if they are values of single selects on the Settings page +- Fix sorting users on Users page if there is an acount with missing nickname or ID ## [2.0.3] - 2020-04-29 diff --git a/src/api/__mocks__/status.js b/src/api/__mocks__/status.js index c8f98545c36847fda07d5ee74ec80536fe82cf01..232ba69a8d441a920972e9b0ce1b8ad4277700fb 100644 --- a/src/api/__mocks__/status.js +++ b/src/api/__mocks__/status.js @@ -6,6 +6,29 @@ export async function deleteStatus(id, authHost, token) { return Promise.resolve() } +export async function fetchStatus(id, authHost, token) { + const data = { + account: { + id: '9n1bySks25olxWrku0', + avatar: 'http://localhost:4000/images/avi.png', + display_name: 'dolin', + tags: ['strip_media', 'sandbox', 'disable_any_subscription', 'force_nsfw'], + url: 'http://localhost:4000/users/dolin' + }, + content: 'pizza makes everything better', + created_at: '2020-05-22T17:34:34.000Z', + id: '9vJOO3iFPyjNaEhJ5s', + media_attachments: [], + poll: null, + sensitive: false, + spoiler_text: '', + visibility: 'public', + url: 'http://localhost:4000/notice/9vJOO3iFPyjNaEhJ5s' + } + + return Promise.resolve({ data }) +} + export async function fetchStatusesByInstance({ instance, authHost, token, pageSize, page }) { let data if (pageSize === 1) { diff --git a/src/api/__mocks__/users.js b/src/api/__mocks__/users.js index 9a3afd41ab858e52f6c80718b2b4cfecb7bdb17d..bdcf404aa03e6f1f457c10ef52bf88230956255e 100644 --- a/src/api/__mocks__/users.js +++ b/src/api/__mocks__/users.js @@ -6,7 +6,11 @@ export let users = [ const userProfile = { avatar: 'avatar.jpg', display_name: 'Allis', nickname: 'allis', id: '2', tags: [], roles: { admin: true, moderator: false }, local: true, external: false } -const userStatuses = [] +const userStatuses = [ + { account: { id: '9n1bySks25olxWrku0', display_name: 'dolin' }, content: 'pizza makes everything better', id: '9vJOO3iFPyjNaEhJ5s', created_at: '2020-05-22T17:34:34.000Z', visibility: 'public' }, + { account: { id: '9n1bySks25olxWrku0', display_name: 'dolin' }, content: 'pizza time', id: '9vJPD5XKOdzQ0bvGLY', created_at: '2020-05-22T17:34:34.000Z', visibility: 'public' }, + { account: { id: '9n1bySks25olxWrku0', display_name: 'dolin' }, content: 'what is yout favorite pizza?', id: '9jop82OBXeFPYulVjM', created_at: '2020-05-22T17:34:34.000Z', visibility: 'public' } +] const filterUsers = (str) => { const filters = str.split(',').filter(item => item.length > 0) diff --git a/src/api/status.js b/src/api/status.js index e3bb3fd413e8843962a9408ad7b8a3747bf54733..0f3455c77becbf07b88ec2b89449a3759897e044 100644 --- a/src/api/status.js +++ b/src/api/status.js @@ -21,6 +21,15 @@ export async function deleteStatus(id, authHost, token) { }) } +export async function fetchStatus(id, authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: `/api/pleroma/admin/statuses/${id}`, + method: 'get', + headers: authHeaders(token) + }) +} + export async function fetchStatuses({ godmode, localOnly, authHost, token, pageSize, page }) { return await request({ baseURL: baseName(authHost), diff --git a/src/components/Status/index.vue b/src/components/Status/index.vue index 90058332e38f51d691884c76b40c2a4590c8af28..ffb8f74c3514125c31a7b17c67bbd0b6578bde6a 100644 --- a/src/components/Status/index.vue +++ b/src/components/Status/index.vue @@ -1,85 +1,72 @@ <template> - <div> - <el-card v-if="!status.deleted" class="status-card"> - <div slot="header"> - <div class="status-header"> - <div class="status-account-container"> - <div class="status-account"> - <el-checkbox v-if="showCheckbox" class="status-checkbox" @change="handleStatusSelection(account)"/> - <img :src="account.avatar" class="status-avatar-img"> - <a v-if="!account.deactivated" :href="account.url" target="_blank" class="account"> + <el-card v-if="!status.deleted" class="status-card" @click.native="handleRouteChange()"> + <div slot="header"> + <div class="status-header"> + <div class="status-account-container"> + <div class="status-account"> + <el-checkbox v-if="showCheckbox" class="status-checkbox" @change="handleStatusSelection(account)"/> + <router-link v-if="!account.deactivated && account.id" :to="{ name: 'UsersShow', params: { id: account.id }}" @click.native.stop> + <div class="status-card-header"> + <img :src="account.avatar" class="status-avatar-img"> <h3 class="status-account-name">{{ account.display_name }}</h3> - </a> - <span v-else> - <h3 class="status-account-name">{{ account.display_name }}</h3> - <h3 class="status-account-name deactivated"> (deactivated)</h3> - </span> - </div> - + </div> + </router-link> + <span v-else> + <h3 class="status-account-name">{{ account.display_name }}</h3> + <h3 class="status-account-name deactivated"> (deactivated)</h3> + </span> </div> - <div class="status-actions"> + </div> + <div class="status-actions"> + <div class="status-tags"> <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" class="status-actions-button"> - {{ $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)"> - {{ $t('reports.addSensitive') }} - </el-dropdown-item> - <el-dropdown-item - v-if="status.sensitive" - @click.native="changeStatus(status.id, false, status.visibility)"> - {{ $t('reports.removeSensitive') }} - </el-dropdown-item> - <el-dropdown-item - v-if="status.visibility !== 'public'" - @click.native="changeStatus(status.id, status.sensitive, 'public')"> - {{ $t('reports.public') }} - </el-dropdown-item> - <el-dropdown-item - v-if="status.visibility !== 'private'" - @click.native="changeStatus(status.id, status.sensitive, 'private')"> - {{ $t('reports.private') }} - </el-dropdown-item> - <el-dropdown-item - v-if="status.visibility !== 'unlisted'" - @click.native="changeStatus(status.id, status.sensitive, 'unlisted')"> - {{ $t('reports.unlisted') }} - </el-dropdown-item> - <el-dropdown-item - @click.native="deleteStatus(status.id)"> - {{ $t('reports.deleteStatus') }} - </el-dropdown-item> - </el-dropdown-menu> - </el-dropdown> </div> + <el-dropdown trigger="click" @click.native.stop> + <el-button plain size="small" icon="el-icon-edit" class="status-actions-button"> + {{ $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)"> + {{ $t('reports.addSensitive') }} + </el-dropdown-item> + <el-dropdown-item + v-if="status.sensitive" + @click.native="changeStatus(status.id, false, status.visibility)"> + {{ $t('reports.removeSensitive') }} + </el-dropdown-item> + <el-dropdown-item + v-if="status.visibility !== 'public'" + @click.native="changeStatus(status.id, status.sensitive, 'public')"> + {{ $t('reports.public') }} + </el-dropdown-item> + <el-dropdown-item + v-if="status.visibility !== 'private'" + @click.native="changeStatus(status.id, status.sensitive, 'private')"> + {{ $t('reports.private') }} + </el-dropdown-item> + <el-dropdown-item + v-if="status.visibility !== 'unlisted'" + @click.native="changeStatus(status.id, status.sensitive, 'unlisted')"> + {{ $t('reports.unlisted') }} + </el-dropdown-item> + <el-dropdown-item + @click.native="deleteStatus(status.id)"> + {{ $t('reports.deleteStatus') }} + </el-dropdown-item> + </el-dropdown-menu> + </el-dropdown> </div> </div> - <div class="status-body"> - <div v-if="status.spoiler_text"> - <strong>{{ status.spoiler_text }}</strong> - <el-button v-if="!showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = true">Show more</el-button> - <el-button v-if="showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = false">Show less</el-button> - <div v-if="showHiddenStatus"> - <span class="status-content" v-html="status.content"/> - <div v-if="status.poll" class="poll"> - <ul> - <li v-for="(option, index) in status.poll.options" :key="index"> - {{ option.title }} - <el-progress :percentage="optionPercent(status.poll, option)" /> - </li> - </ul> - </div> - <div v-for="(attachment, index) in status.media_attachments" :key="index" class="image"> - <img :src="attachment.preview_url"> - </div> - </div> - </div> - <div v-if="!status.spoiler_text"> + </div> + <div class="status-body"> + <div v-if="status.spoiler_text"> + <strong>{{ status.spoiler_text }}</strong> + <el-button v-if="!showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = true">Show more</el-button> + <el-button v-if="showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = false">Show less</el-button> + <div v-if="showHiddenStatus"> <span class="status-content" v-html="status.content"/> <div v-if="status.poll" class="poll"> <ul> @@ -93,30 +80,52 @@ <img :src="attachment.preview_url"> </div> </div> - <a :href="status.url" target="_blank" class="account"> - {{ parseTimestamp(status.created_at) }} + </div> + <div v-if="!status.spoiler_text"> + <span class="status-content" v-html="status.content"/> + <div v-if="status.poll" class="poll"> + <ul> + <li v-for="(option, index) in status.poll.options" :key="index"> + {{ option.title }} + <el-progress :percentage="optionPercent(status.poll, option)" /> + </li> + </ul> + </div> + <div v-for="(attachment, index) in status.media_attachments" :key="index" class="image"> + <img :src="attachment.preview_url"> + </div> + </div> + <div class="status-footer"> + <span class="status-created-at">{{ parseTimestamp(status.created_at) }}</span> + <a v-if="status.url" :href="status.url" target="_blank" class="account" @click.stop> + Open status in instance + <i class="el-icon-top-right"/> </a> </div> - </el-card> - <el-card v-else class="status-card"> - <div slot="header"> - <div class="status-header"> - <div class="status-account-container"> - <div class="status-account"> - <h4 class="status-deleted">{{ $t('reports.statusDeleted') }}</h4> - </div> + </div> + </el-card> + <el-card v-else class="status-card"> + <div slot="header"> + <div class="status-header"> + <div class="status-account-container"> + <div class="status-account"> + <h4 class="status-deleted">{{ $t('reports.statusDeleted') }}</h4> </div> </div> </div> - <div class="status-body"> - <span v-if="status.content" class="status-content" v-html="status.content"/> - <span v-else class="status-without-content">no content</span> - </div> - <a v-if="status.created_at" :href="status.url" target="_blank" class="account"> - {{ parseTimestamp(status.created_at) }} + </div> + <div class="status-body"> + <span v-if="status.content" class="status-content" v-html="status.content"/> + <span v-else class="status-without-content">no content</span> + </div> + <div class="status-footer"> + <span v-if="status.created_at" class="status-created-at">{{ parseTimestamp(status.created_at) }}</span> + <a v-if="status.url" :href="status.url" target="_blank" class="account" @click.stop> + Open status in instance + <i class="el-icon-top-right"/> </a> - </el-card> - </div> + </div> + </el-card> </template> <script> @@ -204,6 +213,12 @@ export default { }) }) }, + handleStatusSelection(account) { + this.$emit('status-selection', account) + }, + handleRouteChange() { + this.$router.push({ name: 'StatusShow', params: { id: this.status.id }}) + }, optionPercent(poll, pollOption) { const allVotes = poll.options.reduce((acc, option) => (acc + option.votes_count), 0) if (allVotes === 0) { @@ -213,9 +228,6 @@ export default { }, parseTimestamp(timestamp) { return moment(timestamp).format('YYYY-MM-DD HH:mm') - }, - handleStatusSelection(account) { - this.$emit('status-selection', account) } } } @@ -224,10 +236,14 @@ export default { <style rel='stylesheet/scss' lang='scss'> .status-card { margin-bottom: 10px; + cursor: pointer; .account { - text-decoration: underline; line-height: 26px; font-size: 13px; + color: #606266; + } + .account:hover { + text-decoration: underline; } .image { width: 20%; @@ -251,12 +267,16 @@ export default { .status-account-name { display: inline-block; margin: 0; - height: 22px; + font-size: 16px; } .status-body { display: flex; flex-direction: column; } + .status-card-header { + display: flex; + align-items: center; + } .status-checkbox { margin-right: 7px; } @@ -264,13 +284,26 @@ export default { font-size: 15px; line-height: 26px; } + .status-created-at { + font-size: 13px; + color: #606266; + } .status-deleted { font-style: italic; margin-top: 3px; } + .status-footer { + display: flex; + justify-content: space-between; + align-items: center; + } .status-header { display: flex; justify-content: space-between; + align-items: center; + } + .status-tags { + display: inline; } .status-without-content { font-style: italic; @@ -289,7 +322,7 @@ export default { padding: 10px 17px; } .el-tag { - margin: 3px 4px 3px 0; + margin: 3px 0; } .status-account-container { margin-bottom: 5px; @@ -298,12 +331,20 @@ export default { margin: 3px 0 3px; } .status-actions { + width: 100%; display: flex; flex-wrap: wrap; + justify-content: space-between; + } + .status-footer { + flex-direction: column; + align-items: flex-start; + margin-top: 10px; } .status-header { display: flex; flex-direction: column; + align-items: flex-start; } } } diff --git a/src/router/index.js b/src/router/index.js index e8510bb8f719b2df1733c0d5e0302562c1c4277a..40d4d7cd176441d38b1821633f2c23c8d1d2eb5f 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -172,5 +172,17 @@ export const asyncRouterMap = [ ], hidden: true }, + { + path: '/statuses/:id', + component: Layout, + children: [ + { + path: '', + name: 'StatusShow', + component: () => import('@/views/statuses/show') + } + ], + hidden: true + }, { path: '*', redirect: '/404', hidden: true } ] diff --git a/src/store/modules/status.js b/src/store/modules/status.js index 74fb66415663e15ddffb7da50ff351581d107dfc..5b908c3c99e9a1a84d3b78556285bb554830a8d9 100644 --- a/src/store/modules/status.js +++ b/src/store/modules/status.js @@ -1,9 +1,11 @@ -import { changeStatusScope, deleteStatus, fetchStatuses, fetchStatusesCount, fetchStatusesByInstance } from '@/api/status' +import { changeStatusScope, deleteStatus, fetchStatus, fetchStatuses, fetchStatusesCount, fetchStatusesByInstance } from '@/api/status' const status = { state: { + fetchedStatus: {}, fetchedStatuses: [], loading: false, + statusAuthor: {}, statusesByInstance: { selectedInstance: '', showLocal: false, @@ -28,6 +30,9 @@ const status = { CHANGE_SELECTED_INSTANCE: (state, instance) => { state.statusesByInstance.selectedInstance = instance }, + SET_STATUS: (state, status) => { + state.fetchedStatus = status + }, SET_STATUSES_BY_INSTANCE: (state, statuses) => { state.fetchedStatuses = statuses }, @@ -45,6 +50,9 @@ const status = { }, SET_STATUS_VISIBILITY: (state, visibility) => { state.statusVisibility = visibility + }, + SET_STATUS_AUTHOR: (state, user) => { + state.statusAuthor = user } }, actions: { @@ -56,6 +64,8 @@ const status = { dispatch('FetchUserStatuses', { userId, godmode }) } else if (fetchStatusesByInstance) { // called from Statuses by Instance dispatch('FetchStatusesByInstance') + } else { // called from Status show page + dispatch('FetchStatusAfterUserModeration', statusId) } }, ClearState({ commit }) { @@ -76,6 +86,21 @@ const status = { dispatch('FetchStatusesByInstance') } }, + async FetchStatus({ commit, dispatch, getters, state }, id) { + commit('SET_LOADING', true) + const status = await fetchStatus(id, getters.authHost, getters.token) + + commit('SET_STATUS', status.data) + commit('SET_STATUS_AUTHOR', status.data.account) + commit('SET_LOADING', false) + dispatch('FetchUserStatuses', { userId: state.fetchedStatus.account.id, godmode: false }) + }, + FetchStatusAfterUserModeration({ commit, dispatch, getters, state }, id) { + commit('SET_LOADING', true) + fetchStatus(id, getters.authHost, getters.token) + .then(status => dispatch('SetStatus', status.data)) + commit('SET_LOADING', false) + }, async FetchStatusesCount({ commit, getters }, instance) { commit('SET_LOADING', true) const { data } = await fetchStatusesCount(instance, getters.authHost, getters.token) @@ -159,6 +184,10 @@ const status = { }, HandlePageChange({ commit }, page) { commit('CHANGE_PAGE', page) + }, + SetStatus({ commit }, status) { + commit('SET_STATUS', status) + commit('SET_STATUS_AUTHOR', status.account) } } } diff --git a/src/store/modules/userProfile.js b/src/store/modules/userProfile.js index af54072c5205783fcb16a37ea55274665a98ff21..0cdc0df61a1bece0ac64c592550f130c65124010 100644 --- a/src/store/modules/userProfile.js +++ b/src/store/modules/userProfile.js @@ -35,18 +35,21 @@ const userProfile = { dispatch('FetchUserStatuses', { userId, godmode }) }, - async FetchUserStatuses({ commit, getters }, { userId, godmode }) { + FetchUserStatuses({ commit, dispatch, getters }, { userId, godmode }) { commit('SET_STATUSES_LOADING', true) - const statuses = await fetchUserStatuses(userId, getters.authHost, godmode, getters.token) + fetchUserStatuses(userId, getters.authHost, godmode, getters.token) + .then(statuses => dispatch('SetStatuses', statuses.data)) - commit('SET_STATUSES', statuses.data) commit('SET_STATUSES_LOADING', false) }, async FetchUserCredentials({ commit, getters }, { nickname }) { const userResponse = await fetchUserCredentials(nickname, getters.authHost, getters.token) commit('SET_USER_CREDENTIALS', userResponse.data) }, + SetStatuses({ commit }, statuses) { + commit('SET_STATUSES', statuses) + }, async UpdateUserCredentials({ dispatch, getters }, { nickname, credentials }) { await updateUserCredentials(nickname, credentials, getters.authHost, getters.token) dispatch('FetchUserCredentials', { nickname }) diff --git a/src/store/modules/users.js b/src/store/modules/users.js index 2a1540e3564f92e91faedfa9f584c188b9a33cb1..e0645784362873a5bfb34a8998831a6689c1a582 100644 --- a/src/store/modules/users.js +++ b/src/store/modules/users.js @@ -24,6 +24,7 @@ const users = { searchQuery: '', totalUsersCount: 0, currentPage: 1, + pageSize: 50, filters: { local: false, external: false, @@ -51,9 +52,11 @@ const users = { return } - state.fetchedUsers = [...usersWithoutSwapped, ...users].sort((a, b) => - a.nickname.localeCompare(b.nickname) - ) + const updatedUsers = [...usersWithoutSwapped, ...users] + state.fetchedUsers = updatedUsers + .filter(user => user.nickname && user.id) + .sort((a, b) => a.nickname.localeCompare(b.nickname)) + .concat(updatedUsers.filter(user => !user.nickname || !user.id)) }, SET_COUNT: (state, count) => { state.totalUsersCount = count @@ -73,9 +76,6 @@ const users = { }, SET_USERS_FILTERS: (state, filters) => { state.filters = filters - }, - SET_USER_PROFILE: (state, user) => { - state.userProfile = user } }, actions: { @@ -88,7 +88,7 @@ const users = { dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId }) }, - async ApplyChanges({ commit, dispatch, state }, { updatedUsers, callApiFn, userId }) { + async ApplyChanges({ commit, dispatch, state }, { updatedUsers, callApiFn, userId, statusId }) { commit('SWAP_USERS', updatedUsers) try { @@ -98,29 +98,30 @@ const users = { } finally { dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage }) } - - if (userId) { + if (statusId) { + dispatch('FetchStatusAfterUserModeration', statusId) + } else if (userId) { dispatch('FetchUserProfile', { userId, godmode: false }) } dispatch('SuccessMessage') }, - async AddRight({ dispatch, getters }, { users, right, _userId }) { + async AddRight({ dispatch, getters }, { users, right, _userId, _statusId }) { const updatedUsers = users.map(user => { return user.local ? { ...user, roles: { ...user.roles, [right]: true }} : user }) const nicknames = users.map(user => user.nickname) const callApiFn = async() => await addRight(nicknames, right, getters.authHost, getters.token) - dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId }) + dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId }) }, - async AddTag({ dispatch, getters }, { users, tag, _userId }) { + async AddTag({ dispatch, getters }, { users, tag, _userId, _statusId }) { const updatedUsers = users.map(user => { return { ...user, tags: [...user.tags, tag] } }) const nicknames = users.map(user => user.nickname) const callApiFn = async() => await tagUser(nicknames, [tag], getters.authHost, getters.token) - dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId }) + dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId }) }, async ClearFilters({ commit, dispatch, state }) { commit('CLEAR_USERS_FILTERS') @@ -145,14 +146,14 @@ const users = { dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId }) }, - async ConfirmUsersEmail({ dispatch, getters }, { users, _userId }) { + async ConfirmUsersEmail({ dispatch, getters }, { users, _userId, _statusId }) { const updatedUsers = users.map(user => { return { ...user, confirmation_pending: false } }) const nicknames = users.map(user => user.nickname) const callApiFn = async() => await confirmUserEmail(nicknames, getters.authHost, getters.token) - dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId }) + dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId }) }, async ResendConfirmationEmail({ dispatch, getters }, users) { const usersNicknames = users.map(user => user.nickname) @@ -163,14 +164,14 @@ const users = { } dispatch('SuccessMessage') }, - async DeleteRight({ dispatch, getters }, { users, right, _userId }) { + async DeleteRight({ dispatch, getters }, { users, right, _userId, _statusId }) { const updatedUsers = users.map(user => { return user.local ? { ...user, roles: { ...user.roles, [right]: false }} : user }) const nicknames = users.map(user => user.nickname) const callApiFn = async() => await deleteRight(nicknames, right, getters.authHost, getters.token) - dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId }) + dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId }) }, async DeleteUsers({ commit, dispatch, getters, state }, { users, _userId }) { const usersNicknames = users.map(user => user.nickname) @@ -200,14 +201,14 @@ const users = { RemovePasswordToken({ commit }) { commit('SET_PASSWORD_RESET_TOKEN', { link: '', token: '' }) }, - async RemoveTag({ dispatch, getters }, { users, tag, _userId }) { + async RemoveTag({ dispatch, getters }, { users, tag, _userId, _statusId }) { const updatedUsers = users.map(user => { return { ...user, tags: user.tags.filter(userTag => userTag !== tag) } }) const nicknames = users.map(user => user.nickname) const callApiFn = async() => await untagUser(nicknames, [tag], getters.authHost, getters.token) - dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId }) + dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId }) }, async RequirePasswordReset({ dispatch, getters }, users) { const nicknames = users.map(user => user.nickname) diff --git a/src/views/statuses/show.vue b/src/views/statuses/show.vue new file mode 100644 index 0000000000000000000000000000000000000000..8ae05bcb7e13f4fa10002ad4a762ec1312927e0f --- /dev/null +++ b/src/views/statuses/show.vue @@ -0,0 +1,273 @@ +<template> + <div v-if="!loading" class="status-show-container"> + <header v-if="isDesktop || isTablet" class="user-page-header"> + <div class="avatar-name-container"> + <router-link :to="{ name: 'UsersShow', params: { id: user.id }}"> + <div class="avatar-name-header"> + <el-avatar v-if="accountExists(user, 'avatar')" :src="user.avatar" size="large" /> + <h1 v-if="accountExists(user, 'display_name')">{{ user.display_name }}</h1> + </div> + </router-link> + <a v-if="accountExists(user, 'url')" :href="user.url" target="_blank" class="account"> + <i class="el-icon-top-right" title="Open user in instance"/> + </a> + </div> + <div class="left-header-container"> + <moderation-dropdown + :user="user" + :page="'statusPage'" + :status-id="status.id" + @open-reset-token-dialog="openResetPasswordDialog"/> + <reboot-button/> + </div> + </header> + <div v-if="isMobile" class="status-page-header-container"> + <header class="user-page-header"> + <div class="avatar-name-container"> + <el-avatar v-if="accountExists(user, 'avatar')" :src="user.avatar" size="large" /> + <h1 v-if="accountExists(user, 'display_name')">{{ user.display_name }}</h1> + </div> + <reboot-button/> + </header> + <moderation-dropdown + :user="user" + :page="'userPage'" + @open-reset-token-dialog="openResetPasswordDialog"/> + </div> + <reset-password-dialog + :reset-password-dialog-open="resetPasswordDialogOpen" + @close-reset-token-dialog="closeResetPasswordDialog"/> + <div class="status-container"> + <status :status="status" :account="user" :show-checkbox="false" :godmode="showPrivate"/> + </div> + <div class="recent-statuses-container-show"> + <h2 class="recent-statuses">{{ $t('userProfile.recentStatuses') }} by {{ user.display_name }}</h2> + <el-checkbox v-model="showPrivate" class="show-private-statuses" @change="onTogglePrivate"> + {{ $t('statuses.showPrivateStatuses') }} + </el-checkbox> + <el-timeline v-if="!statusesLoading" class="statuses"> + <el-timeline-item v-for="status in statuses" :key="status.id"> + <status :status="status" :account="status.account" :show-checkbox="false" :user-id="user.id" :godmode="showPrivate"/> + </el-timeline-item> + <p v-if="statuses.length === 0" class="no-statuses">{{ $t('userProfile.noStatuses') }}</p> + </el-timeline> + </div> + </div> +</template> + +<script> +import Status from '@/components/Status' +import ModerationDropdown from '../users/components/ModerationDropdown' +import RebootButton from '@/components/RebootButton' +import ResetPasswordDialog from '@/views/users/components/ResetPasswordDialog' + +export default { + name: 'StatusShow', + components: { ModerationDropdown, RebootButton, ResetPasswordDialog, Status }, + data() { + return { + showPrivate: false, + resetPasswordDialogOpen: false + } + }, + computed: { + isDesktop() { + return this.$store.state.app.device === 'desktop' + }, + isMobile() { + return this.$store.state.app.device === 'mobile' + }, + isTablet() { + return this.$store.state.app.device === 'tablet' + }, + loading() { + return this.$store.state.status.loading + }, + status() { + return this.$store.state.status.fetchedStatus + }, + statuses() { + return this.$store.state.userProfile.statuses + }, + statusesLoading() { + return this.$store.state.userProfile.statusesLoading + }, + user() { + return this.$store.state.status.statusAuthor + } + }, + beforeMount: function() { + this.$store.dispatch('NeedReboot') + this.$store.dispatch('GetNodeInfo') + this.$store.dispatch('FetchStatus', this.$route.params.id) + }, + methods: { + accountExists(account, key) { + return account[key] + }, + closeResetPasswordDialog() { + this.resetPasswordDialogOpen = false + this.$store.dispatch('RemovePasswordToken') + }, + onTogglePrivate() { + this.$store.dispatch('FetchUserStatuses', { userId: this.user.id, godmode: this.showPrivate }) + }, + openResetPasswordDialog() { + this.resetPasswordDialogOpen = true + } + } +} +</script> + +<style rel='stylesheet/scss' lang='scss'> +.avatar-name-container { + display: flex; + align-items: center; + .el-icon-top-right { + font-size: 2em; + line-height: 36px; + color: #606266; + } +} +.avatar-name-header { + display: flex; + height: 40px; + align-items: center; +} +.no-statuses { + margin-left: 28px; + color: #606266; +} +.password-reset-token { + margin: 0 0 14px 0; +} +.password-reset-token-dialog { + width: 50% +} +.reboot-button { + padding: 10px; + margin-left: 6px; +} + +.recent-statuses-container-show { + display: flex; + flex-direction: column; + .el-timeline-item { + margin-left: 20px; + } + .recent-statuses { + margin-left: 20px; + } + .show-private-statuses { + margin-left: 20px; + margin-bottom: 20px; + } +} +.reset-password-link { + text-decoration: underline; +} +.status-container { + margin: 0 15px 0 20px; +} +.statuses { + padding: 0 20px 0 0; +} +.user-page-header { + display: flex; + justify-content: space-between; + margin: 22px 15px 22px 20px; + align-items: center; + h1 { + display: inline; + margin: 0 0 0 10px; + } +} + +@media only screen and (min-width: 1824px) { + .status-show-container { + max-width: 1824px; + margin: auto; + } +} + +@media only screen and (max-width:480px) { + .avatar-name-container { + margin-bottom: 10px; + } + .el-timeline-item__wrapper { + padding-left: 18px; + } + .left-header-container { + align-items: center; + display: flex; + justify-content: space-between; + } + .password-reset-token-dialog { + width: 85% + } + .recent-statuses { + margin: 20px 10px 15px 10px; + } + .recent-statuses-container-show { + width: 100%; + margin: 0 0 0 10px; + .el-timeline-item { + margin-left: 0; + } + .recent-statuses { + margin-left: 0; + } + .show-private-statuses { + margin: 0 10px 20px 0; + } + } + .status-card { + .el-card__body { + padding: 15px; + } + } + .status-container { + margin: 0 10px; + } + .statuses { + padding-right: 10px; + margin-left: 0; + .el-timeline-item__wrapper { + margin-right: 10px; + } + } + .user-page-header { + padding: 0; + margin: 7px 15px 5px 10px; + } + .status-page-header-container { + width: 100%; + .el-dropdown { + width: stretch; + margin: 0 10px 15px 10px; + } + } +} +@media only screen and (max-width:801px) and (min-width: 481px) { + .recent-statuses-container-show { + width: 97%; + margin: 0 20px; + .el-timeline-item { + margin-left: 2px; + } + .recent-statuses { + margin: 20px 10px 15px 0; + } + .show-private-statuses { + margin: 0 10px 20px 0; + } + } + .show-private-statuses { + margin: 0 10px 20px 0; + } + .user-page-header { + padding: 0; + margin: 7px 15px 20px 20px; + } +} +</style> diff --git a/src/views/users/components/ModerationDropdown.vue b/src/views/users/components/ModerationDropdown.vue index a893b5d5819d1f097e365e8f995d5746532a0ce2..f8cf5ee60339a1950de76875cd0d8da4225bcc27 100644 --- a/src/views/users/components/ModerationDropdown.vue +++ b/src/views/users/components/ModerationDropdown.vue @@ -1,11 +1,11 @@ <template> - <el-dropdown :hide-on-click="false" size="small" trigger="click"> + <el-dropdown :hide-on-click="false" size="small" trigger="click" placement="top-start"> <div> <span v-if="page === 'users'" class="el-dropdown-link"> {{ $t('users.moderation') }} <i v-if="isDesktop" class="el-icon-arrow-down el-icon--right"/> </span> - <el-button v-if="page === 'userPage'" class="moderate-user-button"> + <el-button v-if="page === 'userPage' || page === 'statusPage'" class="moderate-user-button"> <span class="moderate-user-button-container"> <span> <i class="el-icon-edit" /> @@ -27,13 +27,13 @@ {{ user.roles.moderator ? $t('users.revokeModerator') : $t('users.grantModerator') }} </el-dropdown-item> <el-dropdown-item - v-if="showDeactivatedButton(user.id)" + v-if="showDeactivatedButton(user.id) && page !== 'statusPage'" :divided="showAdminAction(user)" @click.native="toggleActivation(user)"> {{ user.deactivated ? $t('users.activateAccount') : $t('users.deactivateAccount') }} </el-dropdown-item> <el-dropdown-item - v-if="showDeactivatedButton(user.id)" + v-if="showDeactivatedButton(user.id) && page !== 'statusPage'" @click.native="handleDeletion(user)"> {{ $t('users.deleteAccount') }} </el-dropdown-item> @@ -115,6 +115,10 @@ export default { page: { type: String, default: 'users' + }, + statusId: { + type: String, + default: '' } }, computed: { @@ -134,7 +138,7 @@ export default { this.$store.dispatch('DeleteUsers', { users: [user], _userId: user.id }) }, handleEmailConfirmation(user) { - this.$store.dispatch('ConfirmUsersEmail', { users: [user], _userId: user.id }) + this.$store.dispatch('ConfirmUsersEmail', { users: [user], _userId: user.id, _statusId: this.statusId }) }, requirePasswordReset(user) { const mailerEnabled = this.$store.state.user.nodeInfo.metadata.mailerEnabled @@ -157,13 +161,13 @@ export default { }, toggleTag(user, tag) { user.tags.includes(tag) - ? this.$store.dispatch('RemoveTag', { users: [user], tag, _userId: user.id }) - : this.$store.dispatch('AddTag', { users: [user], tag, _userId: user.id }) + ? this.$store.dispatch('RemoveTag', { users: [user], tag, _userId: user.id, _statusId: this.statusId }) + : this.$store.dispatch('AddTag', { users: [user], tag, _userId: user.id, _statusId: this.statusId }) }, toggleUserRight(user, right) { user.roles[right] - ? this.$store.dispatch('DeleteRight', { users: [user], right, _userId: user.id }) - : this.$store.dispatch('AddRight', { users: [user], right, _userId: user.id }) + ? this.$store.dispatch('DeleteRight', { users: [user], right, _userId: user.id, _statusId: this.statusId }) + : this.$store.dispatch('AddRight', { users: [user], right, _userId: user.id, _statusId: this.statusId }) } } } diff --git a/src/views/users/components/ResetPasswordDialog.vue b/src/views/users/components/ResetPasswordDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..b70f289da75ca62106a625f22f9a931ced795c12 --- /dev/null +++ b/src/views/users/components/ResetPasswordDialog.vue @@ -0,0 +1,47 @@ +<template> + <el-dialog + v-loading="loading" + :visible="dialogOpen" + :title="$t('users.passwordResetTokenCreated')" + custom-class="password-reset-token-dialog" + @close="closeResetPasswordDialog"> + <div> + <p class="password-reset-token">Password reset token was generated: {{ passwordResetToken }}</p> + <p>You can also use this link to reset password: + <a :href="passwordResetLink" target="_blank" class="reset-password-link">{{ passwordResetLink }}</a> + </p> + </div> + </el-dialog> +</template> + +<script> +export default { + name: 'ResetPasswordDialog', + props: { + resetPasswordDialogOpen: { + type: Boolean, + default: false + } + }, + computed: { + dialogOpen() { + return this.resetPasswordDialogOpen + }, + loading() { + return this.$store.state.users.loading + }, + passwordResetLink() { + return this.$store.state.users.passwordResetToken.link + }, + passwordResetToken() { + return this.$store.state.users.passwordResetToken.token + } + }, + methods: { + closeResetPasswordDialog() { + this.$emit('close-reset-token-dialog') + } + } +} +</script> + diff --git a/src/views/users/index.vue b/src/views/users/index.vue index 6d6deb4746b16f1a083c7bd255d2423d521494a6..4c28f9b8fa340a94feedf1310c326fb9c75bae45 100644 --- a/src/views/users/index.vue +++ b/src/views/users/index.vue @@ -81,19 +81,9 @@ </template> </el-table-column> </el-table> - <el-dialog - v-loading="loading" - :visible.sync="resetPasswordDialogOpen" - :title="$t('users.passwordResetTokenCreated')" - custom-class="password-reset-token-dialog" - @close="closeResetPasswordDialog"> - <div> - <p class="password-reset-token">Password reset token was generated: {{ passwordResetToken }}</p> - <p>You can also use this link to reset password: - <a :href="passwordResetLink" target="_blank" class="reset-password-link">{{ passwordResetLink }}</a> - </p> - </div> - </el-dialog> + <reset-password-dialog + :reset-password-dialog-open="resetPasswordDialogOpen" + @close-reset-token-dialog="closeResetPasswordDialog"/> <div v-if="!loading" class="pagination"> <el-pagination :total="usersCount" @@ -115,6 +105,7 @@ import MultipleUsersMenu from './components/MultipleUsersMenu' import NewAccountDialog from './components/NewAccountDialog' import ModerationDropdown from './components/ModerationDropdown' import RebootButton from '@/components/RebootButton' +import ResetPasswordDialog from './components/ResetPasswordDialog' export default { name: 'Users', @@ -123,6 +114,7 @@ export default { ModerationDropdown, MultipleUsersMenu, RebootButton, + ResetPasswordDialog, UsersFilter }, data() { @@ -149,12 +141,6 @@ export default { pageSize() { return this.$store.state.users.pageSize }, - passwordResetLink() { - return this.$store.state.users.passwordResetToken.link - }, - passwordResetToken() { - return this.$store.state.users.passwordResetToken.token - }, currentPage() { return this.$store.state.users.currentPage }, @@ -248,11 +234,6 @@ export default { .create-account > .el-icon-plus { margin-right: 5px; } -.users-header-container { - display: flex; - align-items: center; - justify-content: space-between; -} .password-reset-token { margin: 0 0 14px 0; } @@ -262,6 +243,11 @@ export default { .reset-password-link { text-decoration: underline; } +.users-header-container { + display: flex; + align-items: center; + justify-content: space-between; +} .users-container { h1 { margin: 10px 0 0 15px; diff --git a/src/views/users/show.vue b/src/views/users/show.vue index dfc1a163c297b0feb3869a3dbcbc6278dc1135c2..1a9d6b871fc54a4736de44d87016eeb0c6b3cea8 100644 --- a/src/views/users/show.vue +++ b/src/views/users/show.vue @@ -26,19 +26,9 @@ :page="'userPage'" @open-reset-token-dialog="openResetPasswordDialog"/> </div> - <el-dialog - v-loading="loading" - :visible.sync="resetPasswordDialogOpen" - :title="$t('users.passwordResetTokenCreated')" - custom-class="password-reset-token-dialog" - @close="closeResetPasswordDialog"> - <div> - <p class="password-reset-token">Password reset token was generated: {{ passwordResetToken }}</p> - <p>You can also use this link to reset password: - <a :href="passwordResetLink" target="_blank" class="reset-password-link">{{ passwordResetLink }}</a> - </p> - </div> - </el-dialog> + <reset-password-dialog + :reset-password-dialog-open="resetPasswordDialogOpen" + @close-reset-token-dialog="closeResetPasswordDialog"/> <div class="user-profile-container"> <el-card class="user-profile-card"> <div class="el-table el-table--fit el-table--enable-row-hover el-table--enable-row-transition el-table--medium"> @@ -121,10 +111,11 @@ import Status from '@/components/Status' import ModerationDropdown from './components/ModerationDropdown' import SecuritySettingsModal from './components/SecuritySettingsModal' import RebootButton from '@/components/RebootButton' +import ResetPasswordDialog from './components/ResetPasswordDialog' export default { name: 'UsersShow', - components: { ModerationDropdown, RebootButton, Status, SecuritySettingsModal }, + components: { ModerationDropdown, RebootButton, ResetPasswordDialog, Status, SecuritySettingsModal }, data() { return { showPrivate: false, @@ -145,12 +136,6 @@ export default { loading() { return this.$store.state.users.loading }, - passwordResetLink() { - return this.$store.state.users.passwordResetToken.link - }, - passwordResetToken() { - return this.$store.state.users.passwordResetToken.token - }, statuses() { return this.$store.state.userProfile.statuses }, @@ -198,7 +183,7 @@ export default { } </script> -<style rel='stylesheet/scss' lang='scss' scoped> +<style rel='stylesheet/scss' lang='scss'> header { align-items: center; display: flex; @@ -218,7 +203,6 @@ table { display: flex; align-items: center; } - .el-table--border::after, .el-table--group::after, .el-table::before { background-color: transparent; } @@ -237,6 +221,12 @@ table { margin-left: 28px; color: #606266; } +.password-reset-token { + margin: 0 0 14px 0; +} +.password-reset-token-dialog { + width: 50% +} .poll ul { list-style-type: none; padding: 0; @@ -254,6 +244,9 @@ table { .recent-statuses-header { margin-top: 10px; } +.reset-password-link { + text-decoration: underline; +} .security-setting-button { margin-top: 20px; width: 100%; @@ -302,16 +295,29 @@ table { .avatar-name-container { margin-bottom: 10px; } + .el-timeline-item__wrapper { + padding-left: 18px; + } + .password-reset-token-dialog { + width: 85% + } .recent-statuses { margin: 20px 10px 15px 10px; } .recent-statuses-container { width: 100%; - margin: 0 10px; + margin: 0; } .show-private-statuses { margin: 0 10px 20px 10px; } + .status-container { + margin: 0 10px; + } + .statuses { + padding-right: 10px; + margin-left: 8px; + } .user-page-header { padding: 0; margin: 7px 15px 15px 10px; diff --git a/test/views/statuses/show.test.js b/test/views/statuses/show.test.js new file mode 100644 index 0000000000000000000000000000000000000000..6bf4fdffc70c8bd7ffd737a1cc322c625f9b983e --- /dev/null +++ b/test/views/statuses/show.test.js @@ -0,0 +1,91 @@ +import Vuex from 'vuex' +import { mount, createLocalVue, config } from '@vue/test-utils' +import flushPromises from 'flush-promises' +import Element from 'element-ui' +import StatusShow from '@/views/statuses/show' +import storeConfig from './statusShowStore.conf' +import { cloneDeep } from 'lodash' + +config.mocks["$t"] = () => {} + +const localVue = createLocalVue() +localVue.use(Vuex) +localVue.use(Element) + +const $route = { + params: { + id: '9vJOO3iFPyjNaEhJ5s' + } +} + +jest.mock('@/api/app') +jest.mock('@/api/status') +jest.mock('@/api/peers') +jest.mock('@/api/nodeInfo') +jest.mock('@/api/users') + +describe('Status show page', () => { + let store + + beforeEach(() => { + store = new Vuex.Store(cloneDeep(storeConfig)) + }) + + it(`fetches single status and user's statuses`, async (done) => { + const wrapper = mount(StatusShow, { + store, + localVue, + sync: false, + stubs: ['router-link'], + mocks: { + $route + } + }) + await flushPromises() + + expect(wrapper.find('.status-container').isVisible()).toBe(true) + expect(store.state.status.fetchedStatus.id).toBe('9vJOO3iFPyjNaEhJ5s') + expect(store.state.status.fetchedStatus.account.display_name).toBe('dolin') + expect(store.state.userProfile.statuses.length).toEqual(3) + done() + }) + + it(`renders links and user's moderation menu`, async (done) => { + const wrapper = mount(StatusShow, { + store, + localVue, + sync: false, + stubs: ['router-link'], + mocks: { + $route + } + }) + await flushPromises() + + expect(wrapper.find('router-link-stub h1').text()).toBe('dolin') + expect(wrapper.find('button.moderate-user-button').exists()).toBe(true) + expect(wrapper.find('.el-dropdown-menu').exists()).toBe(true) + done() + }) + + it(`renders status card`, async (done) => { + const wrapper = mount(StatusShow, { + store, + localVue, + sync: false, + stubs: ['router-link'], + mocks: { + $route + } + }) + await flushPromises() + + expect(wrapper.find('.status-card').exists()).toBe(true) + expect(wrapper.find('router-link-stub h3').text()).toBe('dolin') + expect(wrapper.find('span.el-tag').text()).not.toBe('Sensitive') + expect(wrapper.find('span.el-tag').text()).toBe('Public') + expect(wrapper.find('button.status-actions-button').exists()).toBe(true) + expect(wrapper.find('.status-body .status-content').text()).toBe('pizza makes everything better') + done() + }) +}) diff --git a/test/views/statuses/statusShowStore.conf.js b/test/views/statuses/statusShowStore.conf.js new file mode 100644 index 0000000000000000000000000000000000000000..c83a8660947fb0ba52e56c5b44eb54cc7e89d4a1 --- /dev/null +++ b/test/views/statuses/statusShowStore.conf.js @@ -0,0 +1,21 @@ +import app from '@/store/modules/app' +import peers from '@/store/modules/peers' +import user from '@/store/modules/user' +import userProfile from '@/store/modules/userProfile' +import users from '@/store/modules/users' +import settings from '@/store/modules/settings' +import status from '@/store/modules/status' +import getters from '@/store/getters' + +export default { + modules: { + app, + peers, + settings, + status, + user, + userProfile, + users + }, + getters +} diff --git a/test/views/users/show.test.js b/test/views/users/show.test.js index b57501c589bc09467c8fb576e936e30674be2316..2197d5f6ebd0815cbeab8ddf6e4a7ef4754115b2 100644 --- a/test/views/users/show.test.js +++ b/test/views/users/show.test.js @@ -21,7 +21,7 @@ const $route = { jest.mock('@/api/nodeInfo') jest.mock('@/api/users') -describe('Search and filter users', () => { +describe('User profile', () => { let store beforeEach(() => {