1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 09:08:32 +00:00

Merge branch '1912-row-change-history' into 'develop'

Introduce row edit history tab

Closes 

See merge request 
This commit is contained in:
Petr Stribny 2023-08-25 07:25:27 +00:00
commit 46121e5591
21 changed files with 663 additions and 39 deletions

View file

@ -12,7 +12,6 @@ import {
GitLabAuthProviderType,
OpenIdConnectAuthProviderType,
} from '@baserow_enterprise/authProviderTypes'
import { TeamsWorkspaceSettingsPageType } from '@baserow_enterprise/workspaceSettingsPageTypes'
import { EnterpriseMembersPagePluginType } from '@baserow_enterprise/membersPagePluginTypes'
import en from '@baserow_enterprise/locales/en.json'
@ -25,7 +24,6 @@ import {
EnterpriseWithoutSupportLicenseType,
EnterpriseLicenseType,
} from '@baserow_enterprise/licenseTypes'
import { EnterprisePlugin } from '@baserow_enterprise/plugins'
export default (context) => {

View file

@ -1,18 +1,7 @@
import RowCommentsSidebar from '@baserow_premium/components/row_comments/RowCommentsSidebar'
import { DatabaseApplicationType } from '@baserow/modules/database/applicationTypes'
import GridViewRowExpandButtonWithCommentCount from '@baserow_premium/components/row_comments/GridViewRowExpandButtonWithCommentCount'
export class PremiumDatabaseApplicationType extends DatabaseApplicationType {
getRowEditModalRightSidebarComponent(database, table) {
return this.app.$hasPermission(
'database.table.list_comments',
table,
database.workspace.id
)
? RowCommentsSidebar
: null
}
getRowExpandButtonComponent() {
return GridViewRowExpandButtonWithCommentCount
}

View file

@ -33,7 +33,7 @@
"noComment": "No comments for this row yet. Use the form below to add a comment.",
"comment": "Comment",
"more": "More information",
"name": "Row comments"
"name": "Comments"
},
"rowComment": {
"you": "You",

View file

@ -39,6 +39,7 @@ import { PremiumLicenseType } from '@baserow_premium/licenseTypes'
import { PersonalViewOwnershipType } from '@baserow_premium/viewOwnershipTypes'
import { ViewOwnershipPermissionManagerType } from '@baserow_premium/permissionManagerTypes'
import { RowCommentMentionNotificationType } from '@baserow_premium/notificationTypes'
import { CommentsRowModalSidebarType } from '@baserow_premium/rowModalSidebarTypes'
export default (context) => {
const { store, app, isDev } = context
@ -133,4 +134,9 @@ export default (context) => {
'notification',
new RowCommentMentionNotificationType(context)
)
app.$registry.register(
'rowModalSidebar',
new CommentsRowModalSidebarType(context)
)
}

View file

@ -0,0 +1,33 @@
import { RowModalSidebarType } from '@baserow/modules/database/rowModalSidebarTypes'
import RowCommentsSidebar from '@baserow_premium/components/row_comments/RowCommentsSidebar'
import PremiumFeatures from '@baserow_premium/features'
export class CommentsRowModalSidebarType extends RowModalSidebarType {
static getType() {
return 'comments'
}
getName() {
return this.app.i18n.t('rowCommentSidebar.name')
}
getComponent() {
return RowCommentsSidebar
}
isDeactivated(database, table) {
return !this.app.$hasPermission(
'database.table.list_comments',
table,
database.workspace.id
)
}
isSelectedByDefault(database) {
return this.app.$hasFeature(PremiumFeatures.PREMIUM, database.workspace.id)
}
getOrder() {
return 0
}
}

View file

@ -106,6 +106,9 @@
@import 'snapshots_modal';
@import 'import_modal';
@import 'row_edit_modal';
@import 'row_edit_modal_sidebar';
@import 'row_history';
@import 'row_history_entry';
@import 'data_table';
@import 'auth';
@import 'expand_on_overflow_list';

View file

@ -0,0 +1,3 @@
.row-edit-modal-sidebar {
background-color: $palette-neutral-25;
}

View file

@ -0,0 +1,44 @@
.row-history {
@include absolute(0, 0, 0, 0);
display: flex;
flex-direction: column;
}
.row-history__body {
position: relative;
height: 100%;
overflow-y: auto;
border-top-left-radius: 6px;
.infinite-scroll {
padding-bottom: 10px;
}
}
.row-history__empty {
text-align: center;
display: flex;
justify-content: center;
flex-direction: column;
margin: 0 30px 0 30px;
height: 100%;
border-top-left-radius: 6px;
}
.row-history__empty-icon {
font-size: 30px;
margin-bottom: 30px;
}
.row-history__empty-text {
font-size: 14px;
line-height: 160%;
margin-bottom: 30px;
}
.row-history__loading {
display: flex;
justify-content: center;
padding: 20px 0;
}

View file

@ -0,0 +1,90 @@
.row-history-entry {
display: flex;
flex-direction: column;
margin: 20px;
}
.row-history-entry__header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.row-history-entry__content {
padding: 10px 14px 10px 14px;
border: 1px solid #d9dbde;
border-radius: 3px;
background-color: #f0f0f0;
}
.row-history-entry__name {
font-size: 12px;
height: 14px;
margin-left: 8px;
color: #062e47;
font-weight: 400;
}
.row_history-entry__initials {
flex: 0 0 24px;
font-weight: bold;
color: $white;
background-color: $color-primary-500;
border-radius: 100%;
margin-left: 0;
@include center-text(24px, 12px);
}
.row-history-entry__timestamp {
@extend %ellipsis;
font-size: 12px;
font-weight: 400;
color: $color-neutral-400;
// Prevent long characters like 'g' in the time display being cut off
// partway down.
height: 14px;
margin-left: 6px;
}
.row-history-entry__field {
font-weight: 600;
font-size: 10px;
color: $color-neutral-600;
margin-bottom: 10px;
&:not(:first-child) {
margin-top: 14px;
}
}
.row-history-entry__field-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.row-history-entry__diff {
display: inline-block;
padding: 2px 4px 4px 4px;
border-radius: 3px;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
&:last-of-type {
margin-bottom: 0;
}
}
.row-history-entry__diff--removed {
background-color: #f5d3ce;
text-decoration: line-through;
}
.row-history-entry__diff--added {
background-color: #c2f0d3;
}

View file

@ -27,6 +27,11 @@
gap: 24px;
padding: 0 0 0 40px;
}
&.tabs__header--full-width {
gap: 0;
padding: 0;
}
}
.tabs__item {
@ -50,6 +55,11 @@
@include absolute(auto, 0, -1px, 0);
}
&.tabs__item--full-width {
width: 100%;
text-align: center;
}
}
.tabs__link {

View file

@ -7,7 +7,13 @@
'tabs--large': large,
}"
>
<ul class="tabs__header">
<ul
v-if="!collapseOneTab || tabs.length > 1"
class="tabs__header"
:class="{
'tabs__header--full-width': fullWidthHeader,
}"
>
<li
v-for="(tab, index) in tabs"
:key="tab.title"
@ -16,6 +22,7 @@
:class="{
'tabs__item--active': isActive(index),
'tabs__item--disabled': tab.disabled,
'tabs__item--full-width': fullWidthHeader,
}"
@click="tab.disabled ? null : selectTab(index)"
>
@ -62,6 +69,16 @@ export default {
required: false,
default: false,
},
fullWidthHeader: {
type: Boolean,
required: false,
default: false,
},
collapseOneTab: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {

View file

@ -11,18 +11,6 @@ export class DatabaseApplicationType extends ApplicationType {
return 'database'
}
/**
* By default there is no right sidebar in the row edit modal. Override this method
* and provide a Sidebar component class if you wish there to be one. This component
* will be provided two props row and table.
*
* @return The component to use as the row edit modal's right sidebar or null to not
* use one.
*/
getRowEditModalRightSidebarComponent(database, table) {
return null
}
/**
* @return The component to use as the button the user clicks to expand and view the
* row edit modal for a particular row. Takes a single row prop and should emit a

View file

@ -1,9 +1,9 @@
<template>
<Modal
ref="modal"
:full-height="!!optionalRightSideBar"
:right-sidebar="!!optionalRightSideBar"
:content-scrollable="!!optionalRightSideBar"
:full-height="hasRightSidebar"
:right-sidebar="hasRightSidebar"
:content-scrollable="hasRightSidebar"
:right-sidebar-scrollable="false"
:collapsible-right-sidebar="true"
@hidden="$emit('hidden', { row })"
@ -92,13 +92,12 @@
></CreateFieldContext>
</div>
</template>
<template v-if="!!optionalRightSideBar" #sidebar>
<component
:is="optionalRightSideBar"
<template #sidebar>
<RowEditModalSidebar
:row="row"
:table="table"
:database="database"
></component>
></RowEditModalSidebar>
</template>
</Modal>
</template>
@ -109,6 +108,7 @@ import modal from '@baserow/modules/core/mixins/modal'
import CreateFieldContext from '@baserow/modules/database/components/field/CreateFieldContext'
import RowEditModalFieldsList from './RowEditModalFieldsList.vue'
import RowEditModalHiddenFieldsSection from './RowEditModalHiddenFieldsSection.vue'
import RowEditModalSidebar from './RowEditModalSidebar.vue'
import { getPrimaryOrFirstField } from '@baserow/modules/database/utils/field'
export default {
@ -117,6 +117,7 @@ export default {
CreateFieldContext,
RowEditModalFieldsList,
RowEditModalHiddenFieldsSection,
RowEditModalSidebar,
},
mixins: [modal],
props: {
@ -165,11 +166,6 @@ export default {
...mapGetters({
navigationLoading: 'rowModalNavigation/getLoading',
}),
optionalRightSideBar() {
return this.$registry
.get('application', 'database')
.getRowEditModalRightSidebarComponent(this.database, this.table)
},
modalRow() {
return this.$store.getters['rowModal/get'](this._uid)
},
@ -209,6 +205,15 @@ export default {
return null
}
},
hasRightSidebar() {
const allSidebarTypes = this.$registry.getOrderedList('rowModalSidebar')
const activeSidebarTypes = allSidebarTypes.filter(
(type) =>
type.isDeactivated(this.database, this.table) === false &&
type.getComponent()
)
return activeSidebarTypes.length > 0
},
},
watch: {
/**

View file

@ -0,0 +1,67 @@
<template>
<Tabs
:selected-index="selectedTabIndex"
:full-height="true"
:collapse-one-tab="true"
:large="true"
:full-width-header="true"
class="row-edit-modal-sidebar"
>
<Tab
v-for="sidebarType in sidebarTypes"
:key="sidebarType.getType()"
:title="sidebarType.getName()"
>
<component
:is="sidebarType.getComponent()"
:row="row"
:table="table"
:database="database"
></component>
</Tab>
</Tabs>
</template>
<script>
import Tabs from '@baserow/modules/core/components/Tabs.vue'
import Tab from '@baserow/modules/core/components/Tab.vue'
export default {
name: 'RowEditModalSidebar',
components: {
Tabs,
Tab,
},
props: {
database: {
type: Object,
required: true,
},
table: {
type: Object,
required: true,
},
row: {
type: Object,
required: true,
},
},
computed: {
selectedTabIndex() {
const types = this.sidebarTypes
const index = types.findIndex((type) =>
type.isSelectedByDefault(this.database, this.table)
)
return Math.max(index, 0)
},
sidebarTypes() {
const allSidebarTypes = this.$registry.getOrderedList('rowModalSidebar')
return allSidebarTypes.filter(
(type) =>
type.isDeactivated(this.database, this.table) === false &&
type.getComponent()
)
},
},
}
</script>

View file

@ -0,0 +1,68 @@
<template>
<div class="row-history-entry">
<div class="row-history-entry__header">
<span class="row_history-entry__initials">{{ initials }}</span>
<span class="row-history-entry__name">{{ name }}</span>
<span class="row-history-entry__timestamp" :title="timestampTooltip">{{
formattedTimestamp
}}</span>
</div>
<div class="row-history-entry__content">
<template v-for="field in entryFields">
<div :key="field" class="row-history-entry__field">{{ field }}</div>
<div :key="field + 'content'" class="row-history-entry__field-content">
<div v-if="entry.before[field]">
<div
class="row-history-entry__diff row-history-entry__diff--removed"
>
{{ entry.before[field] }}
</div>
</div>
<div v-if="entry.after[field]">
<div class="row-history-entry__diff row-history-entry__diff--added">
{{ entry.after[field] }}
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script>
import moment from '@baserow/modules/core/moment'
export default {
name: 'RowHistoryEntry',
props: {
entry: {
type: Object,
required: true,
},
},
computed: {
name() {
return this.entry.user.name
},
initials() {
return this.name.slice(0, 1).toUpperCase()
},
timestampTooltip() {
return this.getLocalizedMoment(this.entry.timestamp).format('L LT')
},
formattedTimestamp() {
return this.getLocalizedMoment(this.entry.timestamp).format('LT')
},
entryFields() {
return new Set(
Object.keys(this.entry.before).concat(Object.keys(this.entry.after))
)
},
},
methods: {
getLocalizedMoment(timestamp) {
return moment.utc(timestamp).tz(moment.tz.guess())
},
},
}
</script>

View file

@ -0,0 +1,89 @@
<template>
<div>
<div v-if="!loaded && loading" class="loading-absolute-center" />
<template v-else>
<div class="row-history">
<div v-if="totalCount > 0">
<InfiniteScroll
ref="infiniteScroll"
:current-count="currentCount"
:max-count="totalCount"
:loading="loading"
:reverse="true"
:render-end="false"
>
<template #default>
<RowHistoryEntry
v-for="entry in entries"
:key="entry.id"
:entry="entry"
>
</RowHistoryEntry>
</template>
</InfiniteScroll>
</div>
<div v-else class="row-history__empty">
<i class="row-history__empty-icon fas fa-history"></i>
<div class="row-history__empty-text">
{{ $t('rowHistorySidebar.empty') }}
</div>
</div>
</div>
</template>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
import InfiniteScroll from '@baserow/modules/core/components/helpers/InfiniteScroll'
import RowHistoryEntry from '@baserow/modules/database/components/row/RowHistoryEntry.vue'
export default {
name: 'RowHistorySidebar',
components: {
InfiniteScroll,
RowHistoryEntry,
},
props: {
database: {
type: Object,
required: true,
},
table: {
type: Object,
required: true,
},
row: {
type: Object,
required: true,
},
},
computed: {
...mapGetters({
entries: 'rowHistory/getSortedEntries',
loading: 'rowHistory/getLoading',
loaded: 'rowHistory/getLoaded',
currentCount: 'rowHistory/getCurrentCount',
totalCount: 'rowHistory/getTotalCount',
}),
},
async created() {
await this.initialLoad()
},
methods: {
async initialLoad() {
try {
const tableId = this.table.id
const rowId = this.row.id
await this.$store.dispatch('rowHistory/fetchInitial', {
tableId,
rowId,
})
} catch (e) {
notifyIf(e, 'application')
}
},
},
}
</script>

View file

@ -773,5 +773,9 @@
},
"collaboratorAddedToRowNotification": {
"title": "{sender} assigned you to {fieldName} in row {rowId} in {tableName}"
},
"rowHistorySidebar": {
"name": "History",
"empty": "No changes yet. You'll be able to track any changes to this row here."
}
}

View file

@ -103,6 +103,7 @@ import formStore from '@baserow/modules/database/store/view/form'
import rowModal from '@baserow/modules/database/store/rowModal'
import publicStore from '@baserow/modules/database/store/view/public'
import rowModalNavigationStore from '@baserow/modules/database/store/rowModalNavigation'
import rowHistoryStore from '@baserow/modules/database/store/rowHistory'
import { registerRealtimeEvents } from '@baserow/modules/database/realtime'
import { CSVTableExporterType } from '@baserow/modules/database/exporterTypes'
@ -229,6 +230,7 @@ import { FormViewFormModeType } from '@baserow/modules/database/formViewModeType
import { CollaborativeViewOwnershipType } from '@baserow/modules/database/viewOwnershipTypes'
import { DatabasePlugin } from '@baserow/modules/database/plugins'
import { CollaboratorAddedToRowNotificationType } from '@baserow/modules/database/notificationTypes'
import { HistoryRowModalSidebarType } from '@baserow/modules/database/rowModalSidebarTypes'
import en from '@baserow/modules/database/locales/en.json'
import fr from '@baserow/modules/database/locales/fr.json'
@ -258,6 +260,7 @@ export default (context) => {
store.registerModule('field', fieldStore)
store.registerModule('rowModal', rowModal)
store.registerModule('rowModalNavigation', rowModalNavigationStore)
store.registerModule('rowHistory', rowHistoryStore)
store.registerModule('page/view/grid', gridStore)
store.registerModule('page/view/gallery', galleryStore)
store.registerModule('page/view/form', formStore)
@ -646,5 +649,10 @@ export default (context) => {
new CollaboratorAddedToRowNotificationType(context)
)
app.$registry.register(
'rowModalSidebar',
new HistoryRowModalSidebarType(context)
)
registerRealtimeEvents(app.$realtime)
}

View file

@ -0,0 +1,76 @@
import { Registerable } from '@baserow/modules/core/registry'
import RowHistorySidebar from '@baserow/modules/database/components/row/RowHistorySidebar.vue'
import {
featureFlagIsEnabled,
getFeatureFlags,
} from '@baserow/modules/core/utils/env'
export class RowModalSidebarType extends Registerable {
/**
* A human readable name
*/
getName() {
return null
}
/**
* The component to render in the sidebar
*/
getComponent() {
return null
}
/**
* Whether the sidebar component should be shown
*/
isDeactivated(database, table) {
return false
}
/**
* When true, the sidebar type indicates
* that it should be focused first.
*/
isSelectedByDefault(database) {
return false
}
getOrder() {
return 50
}
}
export class HistoryRowModalSidebarType extends RowModalSidebarType {
static getType() {
return 'history'
}
getName() {
return this.app.i18n.t('rowHistorySidebar.name')
}
getComponent() {
return RowHistorySidebar
}
isDeactivated(database, table) {
const featureFlags = getFeatureFlags(this.app.$config)
const featureFlagEnabled = featureFlagIsEnabled(featureFlags, 'row_history')
return !featureFlagEnabled
// TODO:
// return this.app.$hasPermission(
// 'database.table.read_row_history',
// table,
// database.workspace.id
// )
}
isSelectedByDefault(database) {
return true
}
getOrder() {
return 10
}
}

View file

@ -0,0 +1,47 @@
export default (client) => {
return {
fetchAll({ tableId, rowId }) {
// mocked for now
return {
data: {
count: 2,
next: null,
previous: null,
results: [
{
id: 1,
action_type: 'update_row',
user: {
id: 1,
name: 'John Wick',
},
timestamp: '2023-08-09T00:30:00Z',
before: {
field_1: 'a',
},
after: {
field_1: 'aa',
},
},
{
id: 2,
action_type: 'update_row',
user: {
id: 2,
name: 'Paul Smith',
},
timestamp: '2023-08-09T00:30:00Z',
before: {
field_2: 'a',
},
after: {
field_1: 'aa',
field_2: 'a',
},
},
],
},
}
},
}
}

View file

@ -0,0 +1,79 @@
import RowHistoryService from '@baserow/modules/database/services/rowHistory'
export const state = () => ({
entries: [],
loading: false,
loaded: false,
totalCount: 0,
loadedRowId: false,
loadedTableId: false,
})
export const mutations = {
ADD_ENTRIES(state, { entries }) {
state.entries = entries
},
RESET_ENTRIES(state) {
state.entries = []
state.totalCount = 0
},
SET_LOADING(state, loading) {
state.loading = loading
},
SET_LOADED(state, loaded) {
state.loaded = loaded
},
SET_LOADED_TABLE_AND_ROW(state, { tableId, rowId }) {
state.loadedRowId = rowId
state.loadedTableId = tableId
},
SET_TOTAL_COUNT(state, totalCount) {
state.totalCount = totalCount
},
}
export const actions = {
async fetchInitial({ commit }, { tableId, rowId }) {
commit('RESET_ENTRIES')
commit('SET_LOADING', true)
commit('SET_LOADED', false)
try {
const { data } = await RowHistoryService(this.$client).fetchAll(
tableId,
rowId
)
commit('ADD_ENTRIES', { entries: data.results })
commit('SET_TOTAL_COUNT', data.count)
commit('SET_LOADED_TABLE_AND_ROW', { tableId, rowId })
commit('SET_LOADED', true)
} finally {
commit('SET_LOADING', false)
}
},
}
export const getters = {
getSortedEntries(state) {
return state.entries
},
getCurrentCount(state) {
return state.entries.length
},
getTotalCount(state) {
return state.totalCount
},
getLoading(state) {
return state.loading
},
getLoaded(state) {
return state.loaded
},
}
export default {
namespaced: true,
state,
getters,
actions,
mutations,
}