mirror of
https://github.com/directus/directus.git
synced 2026-02-15 16:05:06 -05:00
Revisions flow (#612)
* WIP rework revisions, fetch data * Render revision items in sidebar * Show created externally * Install diff * Add strings for revisions modal * Allow slot to override subtitle in modal * Add tabs content to revisions modal * Add revert button
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"base-64": "^0.1.0",
|
||||
"cropperjs": "^1.5.6",
|
||||
"date-fns": "^2.14.0",
|
||||
"diff": "^4.0.2",
|
||||
"lodash": "^4.17.15",
|
||||
"marked": "^1.1.0",
|
||||
"micromustache": "^7.1.0",
|
||||
@@ -57,6 +58,7 @@
|
||||
"@storybook/core": "^5.3.18",
|
||||
"@storybook/vue": "^5.3.18",
|
||||
"@types/base-64": "^0.1.3",
|
||||
"@types/diff": "^4.0.2",
|
||||
"@types/jest": "^25.2.3",
|
||||
"@types/marked": "^0.7.4",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
<header class="header">
|
||||
<v-icon class="menu-toggle" name="menu" @click="sidebarActive = !sidebarActive" />
|
||||
<h2 class="title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
|
||||
<slot name="subtitle">
|
||||
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
|
||||
</slot>
|
||||
<div class="spacer" />
|
||||
<slot name="header:append" />
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"edit_field": "Edit Field",
|
||||
"item_revision": "Item Revision",
|
||||
"duplicate_field": "Duplicate Field",
|
||||
"half_width": "Half Width",
|
||||
"full_width": "Full Width",
|
||||
@@ -68,6 +69,8 @@
|
||||
"show_all_activity": "Show All Activity",
|
||||
"page_not_found": "Page Not Found",
|
||||
"page_not_found_body": "The page you are looking for doesn't seem to exist.",
|
||||
"confirm_revert": "Confirm Revert",
|
||||
"confirm_revert_body": "This will revert the item to the selected state.",
|
||||
|
||||
"setting_update_success": "Setting {setting} updated",
|
||||
"setting_update_failed": "Updating setting {setting} failed",
|
||||
@@ -79,8 +82,12 @@
|
||||
"revision_delta_updated": "Updated {count} Fields",
|
||||
"revision_delta_soft_deleted": "Soft Deleted",
|
||||
"revision_delta_deleted": "Deleted",
|
||||
"revision_delta_reverted": "Reverted",
|
||||
"revision_delta_by": "{date} by {user}",
|
||||
"private_user": "Private User",
|
||||
"revision_unknown": "Created Outside System",
|
||||
"revision_preview": "Revision Preview",
|
||||
"updates_made": "Updates Made",
|
||||
|
||||
"leave_comment": "Leave a comment...",
|
||||
"post_comment_success": "Comment posted",
|
||||
|
||||
@@ -295,7 +295,11 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
return _filters.value.filter((filter) => !filter.locked);
|
||||
let count = _filters.value.filter((filter) => !filter.locked).length;
|
||||
|
||||
if (searchQuery.value && searchQuery.value.length > 0) count++;
|
||||
|
||||
return count;
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
:collection="collection"
|
||||
:primary-key="primaryKey"
|
||||
ref="revisionsDrawerDetail"
|
||||
@revert="refresh"
|
||||
/>
|
||||
<comments-drawer-detail
|
||||
v-if="isBatch === false && isNew === false"
|
||||
@@ -219,6 +220,7 @@ export default defineComponent({
|
||||
softDeleting,
|
||||
saveAsCopy,
|
||||
isBatch,
|
||||
refresh,
|
||||
} = useItem(collection, primaryKey);
|
||||
|
||||
const hasEdits = computed<boolean>(() => Object.keys(edits.value).length > 0);
|
||||
@@ -273,6 +275,7 @@ export default defineComponent({
|
||||
breadcrumb,
|
||||
title,
|
||||
revisionsDrawerDetail,
|
||||
refresh,
|
||||
};
|
||||
|
||||
function useBreadcrumb() {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="revision-item" @click="$emit('click')" :class="{ last }">
|
||||
<div class="header">
|
||||
<span class="dot" :class="revision.activity.action" />
|
||||
{{ headerMessage }}
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="time">{{ time }}</span>
|
||||
–
|
||||
<user-popover :user="revision.activity.action_by.id">
|
||||
<span>{{ user }}</span>
|
||||
</user-popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { Revision } from './types';
|
||||
import i18n from '@/lang';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
revision: {
|
||||
type: Object as PropType<Revision>,
|
||||
required: true,
|
||||
},
|
||||
last: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const revisionCount = computed(() => {
|
||||
return Object.keys(props.revision.delta).length;
|
||||
});
|
||||
|
||||
const headerMessage = computed(() => {
|
||||
switch (props.revision.activity.action.toLowerCase()) {
|
||||
case 'create':
|
||||
return i18n.t('revision_delta_created');
|
||||
case 'update':
|
||||
return i18n.t('revision_delta_updated', { count: revisionCount.value });
|
||||
case 'soft-delete':
|
||||
return i18n.t('revision_delta_soft_deleted');
|
||||
case 'delete':
|
||||
return i18n.t('revision_delta_deleted');
|
||||
case 'revert':
|
||||
return i18n.t('revision_delta_reverted');
|
||||
}
|
||||
});
|
||||
|
||||
const time = computed(() => {
|
||||
return format(
|
||||
new Date(props.revision.activity.action_on),
|
||||
String(i18n.t('date-fns_time'))
|
||||
);
|
||||
});
|
||||
|
||||
const user = computed(() => {
|
||||
if (
|
||||
props.revision.activity.action_by !== null &&
|
||||
typeof props.revision.activity.action_by === 'object'
|
||||
) {
|
||||
const { first_name, last_name } = props.revision.activity.action_by as {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
};
|
||||
return `${first_name} ${last_name}`;
|
||||
}
|
||||
|
||||
return i18n.t('private_user');
|
||||
});
|
||||
|
||||
return { headerMessage, time, user };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.revision-item {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
margin-left: 20px;
|
||||
|
||||
&:not(.last)::after {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: -17px;
|
||||
z-index: 1;
|
||||
width: 2px;
|
||||
height: calc(100% + 12px);
|
||||
background-color: var(--background-normal-alt);
|
||||
content: '';
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -32px;
|
||||
z-index: 1;
|
||||
width: calc(100% + 32px);
|
||||
height: calc(100% + 8px);
|
||||
background-color: var(--background-normal-alt);
|
||||
border-radius: var(--border-radius);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: -22px;
|
||||
z-index: 2;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--warning);
|
||||
border: 2px solid var(--background-normal);
|
||||
border-radius: 8px;
|
||||
|
||||
&.create {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
&.update {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
&.delete {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
color: var(--foreground-subdued);
|
||||
line-height: 16px;
|
||||
|
||||
.time {
|
||||
text-transform: lowercase;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,84 +1,52 @@
|
||||
<template>
|
||||
<drawer-detail :title="$t('revisions')" icon="change_history">
|
||||
<!-- "Today", "Yesterday", and then: "Sep 6", "Sep 6, 2019" (previous years) -->
|
||||
<div class="day-break"><span>Today</span></div>
|
||||
<v-progress-linear indeterminate v-if="loading" />
|
||||
|
||||
<transition-group name="slide" tag="div">
|
||||
<div v-for="act in activity" :key="act.id" class="revision internal">
|
||||
<div class="header">
|
||||
<template v-if="act.action === 'create'">
|
||||
<span class="dot create"></span>
|
||||
{{ $t('revision_delta_created') }}
|
||||
</template>
|
||||
<template v-else-if="act.action === 'update'">
|
||||
<span class="dot update"></span>
|
||||
{{ $t('revision_delta_updated', { count: 3 }) }}
|
||||
</template>
|
||||
<template v-else-if="act.action === 'soft-delete'">
|
||||
<span class="dot delete"></span>
|
||||
{{ $t('revision_delta_soft_deleted') }}
|
||||
</template>
|
||||
<template v-else-if="act.action === 'delete'">
|
||||
<span class="dot delete"></span>
|
||||
{{ $t('revision_delta_deleted') }}
|
||||
</template>
|
||||
<template v-else v-for="group in revisionsByDate">
|
||||
<v-divider :key="group.date.toString()">{{ group.dateFormatted }}</v-divider>
|
||||
|
||||
<v-icon name="expand_more" class="more" />
|
||||
</div>
|
||||
<template v-for="(item, index) in group.revisions">
|
||||
<revision-item
|
||||
:key="item.id"
|
||||
:revision="item"
|
||||
:last="index === group.revisions.length - 1"
|
||||
@click="openModal(item.id)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<span class="time">{{ getFormattedTime(act.action_on) }}</span>
|
||||
—
|
||||
<span class="name">
|
||||
<template v-if="act.action_by && act.action_by">
|
||||
{{ act.action_by.first_name }} {{ act.action_by.last_name }}
|
||||
</template>
|
||||
<template v-else-if="act.action_by && action.action_by">
|
||||
{{ $t('private_user') }}
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<div v-if="act.comment" class="comment" v-html="marked(act.comment)" />
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
|
||||
<!-- Only show this if "created" revision doesn't exist for this item -->
|
||||
<div class="revision external">
|
||||
<div class="header">
|
||||
<span class="dot"></span>
|
||||
<template v-if="loading === false && hasCreate === false">
|
||||
<v-divider />
|
||||
<div class="external">
|
||||
{{ $t('revision_delta_created_externally') }}
|
||||
</div>
|
||||
<div class="content">{{ $t('revision_unknown') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<revisions-modal
|
||||
v-if="revisions"
|
||||
:revisions="revisions"
|
||||
:current.sync="modalCurrentRevision"
|
||||
:active.sync="modalActive"
|
||||
@revert="onRevert"
|
||||
/>
|
||||
</drawer-detail>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from '@vue/composition-api';
|
||||
import { defineComponent, ref, computed } from '@vue/composition-api';
|
||||
import { Revision, RevisionsByDate } from './types';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import api from '@/api';
|
||||
import localizedFormatDistance from '@/utils/localized-format-distance';
|
||||
import marked from 'marked';
|
||||
import { Avatar } from '@/stores/user/types';
|
||||
import notify from '@/utils/notify';
|
||||
import format from 'date-fns/format';
|
||||
import { groupBy, orderBy } from 'lodash';
|
||||
import { isToday, isYesterday, isThisYear } from 'date-fns';
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
import i18n from '@/lang';
|
||||
|
||||
type Activity = {
|
||||
action: 'create' | 'update' | 'soft-delete' | 'delete';
|
||||
action_by: null | {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar: null | Avatar;
|
||||
};
|
||||
action_on: string;
|
||||
edited_on: null | string;
|
||||
comment: null | string;
|
||||
};
|
||||
import formatLocalized from '@/utils/localized-format';
|
||||
import RevisionItem from './revision-item.vue';
|
||||
import RevisionsModal from './revisions-modal.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: { RevisionItem, RevisionsModal },
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
@@ -89,135 +57,152 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
setup(props, { emit }) {
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const { activity, loading, error, refresh } = useActivity(
|
||||
const { revisions, revisionsByDate, loading, error, refresh } = useRevisions(
|
||||
props.collection,
|
||||
props.primaryKey
|
||||
);
|
||||
const { newCommentContent, postComment, saving } = useComment();
|
||||
|
||||
const hasCreate = computed(() => {
|
||||
// We expect the very first revision record to be a creation
|
||||
return (
|
||||
revisions.value &&
|
||||
revisions.value.length > 0 &&
|
||||
revisions.value[revisions.value.length - 1].activity.action === 'create'
|
||||
);
|
||||
});
|
||||
|
||||
const modalActive = ref(false);
|
||||
const modalCurrentRevision = ref<number>(null);
|
||||
|
||||
return {
|
||||
activity,
|
||||
revisions,
|
||||
revisionsByDate,
|
||||
loading,
|
||||
error,
|
||||
marked,
|
||||
newCommentContent,
|
||||
postComment,
|
||||
saving,
|
||||
getFormattedTime,
|
||||
refresh,
|
||||
hasCreate,
|
||||
modalActive,
|
||||
modalCurrentRevision,
|
||||
openModal,
|
||||
onRevert,
|
||||
};
|
||||
|
||||
function getFormattedTime(datetime: string) {
|
||||
return format(new Date(datetime), String(i18n.t('date-fns_time')));
|
||||
function openModal(id: number) {
|
||||
modalCurrentRevision.value = id;
|
||||
modalActive.value = true;
|
||||
}
|
||||
|
||||
function useActivity(collection: string, primaryKey: string | number) {
|
||||
const activity = ref<Activity[]>(null);
|
||||
function useRevisions(collection: string, primaryKey: number | string) {
|
||||
const revisions = ref<Revision[]>(null);
|
||||
const revisionsByDate = ref<RevisionsByDate[]>(null);
|
||||
const error = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
getActivity();
|
||||
getRevisions();
|
||||
|
||||
return { activity, error, loading, refresh };
|
||||
return { revisions, revisionsByDate, error, loading, refresh };
|
||||
|
||||
async function getRevisions() {
|
||||
const { currentProjectKey } = projectsStore.state;
|
||||
|
||||
async function getActivity() {
|
||||
error.value = null;
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/${projectsStore.state.currentProjectKey}/activity`,
|
||||
`/${currentProjectKey}/items/${collection}/${primaryKey}/revisions`,
|
||||
{
|
||||
params: {
|
||||
'filter[collection][eq]': collection,
|
||||
'filter[item][eq]': primaryKey,
|
||||
'filter[action][in]': 'create,update,soft-delete,delete',
|
||||
sort: '-id', // directus_activity has auto increment and is therefore in chronological order
|
||||
fields: [
|
||||
'id',
|
||||
'action',
|
||||
'action_on',
|
||||
'action_by.id',
|
||||
'action_by.first_name',
|
||||
'action_by.last_name',
|
||||
'action_by.avatar.data',
|
||||
'comment',
|
||||
'data',
|
||||
'delta',
|
||||
'collection',
|
||||
'item',
|
||||
'activity.action',
|
||||
'activity.action_on',
|
||||
'activity.action_by.id',
|
||||
'activity.action_by.first_name',
|
||||
'activity.action_by.last_name',
|
||||
'activity.ip',
|
||||
'activity.user_agent',
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const records = [];
|
||||
const revisionsGroupedByDate = groupBy(
|
||||
response.data.data,
|
||||
(revision: Revision) => {
|
||||
// revision's action_on date is in iso-8601
|
||||
const date = new Date(
|
||||
new Date(revision.activity.action_on).toDateString()
|
||||
);
|
||||
return date;
|
||||
}
|
||||
);
|
||||
|
||||
for (const record of response.data.data) {
|
||||
records.push({
|
||||
...record,
|
||||
date_relative: await localizedFormatDistance(
|
||||
new Date(record.action_on),
|
||||
new Date(),
|
||||
{
|
||||
addSuffix: true,
|
||||
}
|
||||
),
|
||||
const revisionsGrouped: RevisionsByDate[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(revisionsGroupedByDate)) {
|
||||
const date = new Date(key);
|
||||
const today = isToday(date);
|
||||
const yesterday = isYesterday(date);
|
||||
const thisYear = isThisYear(date);
|
||||
|
||||
let dateFormatted: TranslateResult;
|
||||
|
||||
if (today) dateFormatted = i18n.t('today');
|
||||
else if (yesterday) dateFormatted = i18n.t('yesterday');
|
||||
else if (thisYear)
|
||||
dateFormatted = await formatLocalized(
|
||||
date,
|
||||
String(i18n.t('date-fns_date_short_no_year'))
|
||||
);
|
||||
else
|
||||
dateFormatted = await formatLocalized(
|
||||
date,
|
||||
String(i18n.t('date-fns_date_short'))
|
||||
);
|
||||
|
||||
revisionsGrouped.push({
|
||||
date: date,
|
||||
dateFormatted: String(dateFormatted),
|
||||
revisions: orderBy(value, ['activity.action_on'], ['desc']),
|
||||
});
|
||||
}
|
||||
|
||||
activity.value = records;
|
||||
} catch (error) {
|
||||
error.value = error;
|
||||
revisionsByDate.value = orderBy(revisionsGrouped, ['date'], ['desc']);
|
||||
revisions.value = orderBy(response.data.data, ['activity.action_on'], ['desc']);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await getActivity();
|
||||
await getRevisions();
|
||||
}
|
||||
}
|
||||
|
||||
function useComment() {
|
||||
const newCommentContent = ref(null);
|
||||
const saving = ref(false);
|
||||
|
||||
return { newCommentContent, postComment, saving };
|
||||
|
||||
async function postComment() {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
await api.post(`/${projectsStore.state.currentProjectKey}/activity/comment`, {
|
||||
collection: props.collection,
|
||||
item: props.primaryKey,
|
||||
comment: newCommentContent.value,
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
newCommentContent.value = null;
|
||||
|
||||
notify({
|
||||
title: i18n.t('post_comment_success'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch {
|
||||
notify({
|
||||
title: i18n.t('post_comment_failed'),
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
function onRevert() {
|
||||
refresh();
|
||||
emit('revert');
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.day-break {
|
||||
.v-progress-linear {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
@@ -227,145 +212,18 @@ export default defineComponent({
|
||||
padding-bottom: 8px;
|
||||
background-color: var(--background-normal);
|
||||
box-shadow: 0 0 4px 2px var(--background-normal);
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
z-index: 2;
|
||||
padding-right: 8px;
|
||||
color: var(--foreground-subdued);
|
||||
background-color: var(--background-normal);
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
height: 2px;
|
||||
background-color: var(--border-normal);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
.revision {
|
||||
position: relative;
|
||||
.empty {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--foreground-subdued);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.external {
|
||||
margin-left: 20px;
|
||||
|
||||
&.internal {
|
||||
cursor: pointer;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: -17px;
|
||||
z-index: 1;
|
||||
width: 2px;
|
||||
height: calc(100% + 12px);
|
||||
background-color: var(--background-normal-alt);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&.external {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: -22px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--warning);
|
||||
border: 2px solid var(--background-normal);
|
||||
border-radius: 8px;
|
||||
|
||||
&.create {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
&.update {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
&.delete {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.more {
|
||||
flex-basis: 24px;
|
||||
color: var(--foreground-subdued);
|
||||
transition: color var(--fast) var(--transition);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
color: var(--foreground-subdued);
|
||||
line-height: 16px;
|
||||
|
||||
.time {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.comment {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background-color: var(--background-page);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 20px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--background-page);
|
||||
border-radius: 2px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.header {
|
||||
.more {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all var(--slow) var(--transition);
|
||||
}
|
||||
|
||||
.slide-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.slide-move {
|
||||
transition: all 500ms var(--transition);
|
||||
}
|
||||
|
||||
.slide-enter,
|
||||
.slide-leave-to {
|
||||
opacity: 0;
|
||||
color: var(--foreground-subdued);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<v-menu close-on-content-click show-arrow>
|
||||
<template #activator="{ toggle }">
|
||||
<span @click="toggle" class="picker">
|
||||
{{ selectedOption && selectedOption.text }}
|
||||
<v-icon name="expand_more" small />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<v-list-item
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
@click="_current = option.value"
|
||||
:active="_current === option.value"
|
||||
>
|
||||
<v-list-item-content>{{ option.text }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, watch, ref } from '@vue/composition-api';
|
||||
import { Revision } from './types';
|
||||
import useSync from '@/composables/use-sync';
|
||||
import localizedFormat from '@/utils/localized-format';
|
||||
import i18n from '@/lang';
|
||||
|
||||
type Option = {
|
||||
text: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
revisions: {
|
||||
type: Array as PropType<Revision[]>,
|
||||
required: true,
|
||||
},
|
||||
current: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const _current = useSync(props, 'current', emit);
|
||||
|
||||
const options = ref<Option[]>(null);
|
||||
|
||||
watch(
|
||||
() => props.revisions,
|
||||
async () => {
|
||||
const newOptions = [];
|
||||
|
||||
for (const revision of props.revisions) {
|
||||
const date = await getFormattedDate(revision);
|
||||
let user = i18n.t('private_user');
|
||||
|
||||
if (typeof revision.activity.action_by === 'object') {
|
||||
const { first_name, last_name } = revision.activity.action_by;
|
||||
user = `${first_name} ${last_name}`;
|
||||
}
|
||||
|
||||
const text = String(i18n.t('revision_delta_by', { date, user }));
|
||||
const value = revision.id;
|
||||
newOptions.push({ text, value });
|
||||
}
|
||||
|
||||
options.value = newOptions;
|
||||
}
|
||||
);
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return options.value?.find((option) => option.value === _current.value);
|
||||
});
|
||||
|
||||
return { _current, options, selectedOption };
|
||||
|
||||
async function getFormattedDate(revision: Revision) {
|
||||
const date = await localizedFormat(
|
||||
new Date(revision!.activity.action_on),
|
||||
String(i18n.t('date-fns_date'))
|
||||
);
|
||||
|
||||
const time = await localizedFormat(
|
||||
new Date(revision!.activity.action_on),
|
||||
String(i18n.t('date-fns_time'))
|
||||
);
|
||||
|
||||
return `${date} (${time})`;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.picker {
|
||||
color: var(--foreground-subdued);
|
||||
cursor: pointer;
|
||||
transition: color var(--fast) var(--transition);
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<v-form
|
||||
disabled
|
||||
:collection="revision.collection"
|
||||
:primary-key="revision.item"
|
||||
:initial-values="revision.data"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from '@vue/composition-api';
|
||||
import { Revision } from './types';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
revision: {
|
||||
type: Object as PropType<Revision>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="change-line" :class="{ added, deleted, 'no-highlight': wholeThing }">
|
||||
<v-icon :name="added ? 'add' : 'remove'" />
|
||||
<div class="delta">
|
||||
<span
|
||||
v-for="(part, index) in changesFiltered"
|
||||
:key="index"
|
||||
:class="{ changed: part.added || part.removed }"
|
||||
>
|
||||
{{ part.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
|
||||
type Change = {
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
count: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
changes: {
|
||||
type: Array as PropType<Change[]>,
|
||||
required: true,
|
||||
},
|
||||
added: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
deleted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const changesFiltered = computed(() => {
|
||||
return props.changes.filter((change) => {
|
||||
if (props.added === true) {
|
||||
return change.removed !== true;
|
||||
}
|
||||
|
||||
return change.added !== true;
|
||||
});
|
||||
});
|
||||
|
||||
// The whole value changed instead of parts, this should disable the highlighting
|
||||
const wholeThing = computed(() => {
|
||||
return props.changes.length === 2; // before/after
|
||||
});
|
||||
|
||||
return { changesFiltered, wholeThing };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.change-line {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 52px;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
.v-icon {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.changed {
|
||||
position: relative;
|
||||
margin-right: 0.2em;
|
||||
padding: 2px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.added {
|
||||
color: var(--success);
|
||||
background-color: var(--success-alt);
|
||||
|
||||
.changed {
|
||||
background-color: var(--success-25);
|
||||
}
|
||||
}
|
||||
|
||||
.deleted {
|
||||
color: var(--danger);
|
||||
background-color: var(--danger-alt);
|
||||
|
||||
.changed {
|
||||
background-color: var(--danger-25);
|
||||
}
|
||||
}
|
||||
|
||||
.no-highlight .changed {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="updates">
|
||||
<div class="change" v-for="change in changes" :key="change.name">
|
||||
<div class="type-label">{{ change.name }}</div>
|
||||
<revisions-modal-updates-change deleted :changes="change.changes" />
|
||||
<revisions-modal-updates-change added :changes="change.changes" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { Revision } from './types';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
import { diffWordsWithSpace, diffJson, diffArrays } from 'diff';
|
||||
import RevisionsModalUpdatesChange from './revisions-modal-updates-change.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: { RevisionsModalUpdatesChange },
|
||||
props: {
|
||||
revision: {
|
||||
type: Object as PropType<Revision>,
|
||||
required: true,
|
||||
},
|
||||
revisions: {
|
||||
type: Array as PropType<Revision[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const previousRevision = computed(() => {
|
||||
const currentIndex = props.revisions.findIndex(
|
||||
(revision) => revision.id === props.revision.id
|
||||
);
|
||||
|
||||
// This is assuming props.revisions is in chronological order from newest to oldest
|
||||
return props.revisions[currentIndex + 1];
|
||||
});
|
||||
|
||||
const changes = computed(() => {
|
||||
if (!previousRevision.value) return null;
|
||||
|
||||
const changedFields = Object.keys(props.revision.delta);
|
||||
|
||||
return changedFields.map((fieldKey) => {
|
||||
const name = fieldsStore.getField(props.revision.collection, fieldKey).name;
|
||||
const currentValue = props.revision.delta[fieldKey];
|
||||
const previousValue = previousRevision.value.data[fieldKey];
|
||||
|
||||
let changes;
|
||||
|
||||
if (typeof currentValue === 'string' && currentValue.length > 25) {
|
||||
changes = diffWordsWithSpace(previousValue, currentValue);
|
||||
} else if (Array.isArray(currentValue)) {
|
||||
changes = diffArrays(previousValue, currentValue);
|
||||
} else if (typeof currentValue === 'object') {
|
||||
changes = diffJson(previousValue, currentValue);
|
||||
} else {
|
||||
// This is considering the whole thing a change
|
||||
changes = [
|
||||
{
|
||||
removed: true,
|
||||
value: previousValue,
|
||||
},
|
||||
{
|
||||
added: true,
|
||||
value: currentValue,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return { name, changes };
|
||||
});
|
||||
});
|
||||
|
||||
return { changes, previousRevision };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.change {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.type-label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.change-line {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-modal v-model="_active" :title="$t('item_revision')">
|
||||
<template #subtitle>
|
||||
<revisions-modal-picker :revisions="revisions" :current.sync="_current" />
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<v-tabs vertical v-model="currentTab">
|
||||
<v-tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
|
||||
{{ tab.text }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<revisions-modal-preview
|
||||
v-if="currentTab[0] === 'preview'"
|
||||
:revision="currentRevision"
|
||||
/>
|
||||
<revisions-modal-updates
|
||||
v-if="currentTab[0] === 'updates'"
|
||||
:revision="currentRevision"
|
||||
:revisions="revisions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer="{ close }">
|
||||
<v-button @click="confirmRevert = true" class="revert">
|
||||
{{ $t('revert') }}
|
||||
</v-button>
|
||||
<v-button @click="close">{{ $t('done') }}</v-button>
|
||||
</template>
|
||||
</v-modal>
|
||||
<v-dialog v-model="confirmRevert" :persistent="reverting">
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('confirm_revert') }}</v-card-title>
|
||||
<v-card-text>{{ $t('confirm_revert_body') }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="confirmRevert = false" :disabled="reverting">
|
||||
{{ $t('cancel') }}
|
||||
</v-button>
|
||||
<v-button class="revert" @click="revert" :loading="reverting">
|
||||
{{ $t('revert') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
|
||||
import useSync from '@/composables/use-sync';
|
||||
import { Revision } from './types';
|
||||
import i18n from '@/lang';
|
||||
import RevisionsModalPicker from './revisions-modal-picker.vue';
|
||||
import RevisionsModalPreview from './revisions-modal-preview.vue';
|
||||
import RevisionsModalUpdates from './revisions-modal-updates.vue';
|
||||
import api from '@/api';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
|
||||
export default defineComponent({
|
||||
components: { RevisionsModalPicker, RevisionsModalPreview, RevisionsModalUpdates },
|
||||
props: {
|
||||
revisions: {
|
||||
type: Array as PropType<Revision[]>,
|
||||
required: true,
|
||||
},
|
||||
current: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const _active = useSync(props, 'active', emit);
|
||||
const _current = useSync(props, 'current', emit);
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const currentTab = ref(['preview']);
|
||||
|
||||
const currentRevision = computed(() => {
|
||||
return props.revisions.find((revision) => revision.id === props.current);
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
text: i18n.t('revision_preview'),
|
||||
value: 'preview',
|
||||
},
|
||||
{
|
||||
text: i18n.t('updates_made'),
|
||||
value: 'updates',
|
||||
},
|
||||
];
|
||||
|
||||
const { confirmRevert, reverting, revert } = useRevert();
|
||||
|
||||
return {
|
||||
_active,
|
||||
_current,
|
||||
currentRevision,
|
||||
currentTab,
|
||||
tabs,
|
||||
confirmRevert,
|
||||
reverting,
|
||||
revert,
|
||||
};
|
||||
|
||||
function useRevert() {
|
||||
const { currentProjectKey } = projectsStore.state;
|
||||
const confirmRevert = ref(false);
|
||||
const reverting = ref(false);
|
||||
|
||||
return { reverting, revert, confirmRevert };
|
||||
|
||||
async function revert() {
|
||||
reverting.value = true;
|
||||
if (!currentRevision.value) return;
|
||||
|
||||
try {
|
||||
await api.patch(
|
||||
`/${currentProjectKey}/items/${currentRevision.value.collection}/${currentRevision.value.item}/revert/${currentRevision.value.id}`
|
||||
);
|
||||
confirmRevert.value = false;
|
||||
_active.value = false;
|
||||
emit('revert');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
reverting.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.revert {
|
||||
--v-button-background-color: var(--warning);
|
||||
--v-button-background-color-hover: var(--warning-125);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Revision = {
|
||||
id: number;
|
||||
data: Record<string, any>;
|
||||
delta: Record<string, any>;
|
||||
collection: string;
|
||||
item: string | number;
|
||||
activity: {
|
||||
action: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
action_on: string;
|
||||
action_by:
|
||||
| number
|
||||
| {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type RevisionsByDate = {
|
||||
date: Date;
|
||||
dateFormatted: string;
|
||||
revisions: Revision[];
|
||||
};
|
||||
@@ -1914,6 +1914,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd"
|
||||
integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==
|
||||
|
||||
"@types/diff@^4.0.2":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c"
|
||||
integrity sha512-mIenTfsIe586/yzsyfql69KRnA75S8SVXQbTLpDejRrjH0QSJcpu3AUOi/Vjnt9IOsXKxPhJfGpQUNMueIU1fQ==
|
||||
|
||||
"@types/eslint-visitor-keys@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
|
||||
@@ -5506,7 +5511,7 @@ diff-sequences@^25.2.6:
|
||||
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd"
|
||||
integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==
|
||||
|
||||
diff@^4.0.1:
|
||||
diff@^4.0.1, diff@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
|
||||
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
|
||||
|
||||
Reference in New Issue
Block a user