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:
Rijk van Zanten
2020-05-22 15:45:35 -04:00
committed by GitHub
parent dbe4d319a9
commit df2587810c
15 changed files with 826 additions and 281 deletions

View File

@@ -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",

View File

@@ -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>

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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[];
};

View File

@@ -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==