diff --git a/backend/src/baserow/contrib/database/table/handler.py b/backend/src/baserow/contrib/database/table/handler.py index 5a9d69576..5d71947b5 100644 --- a/backend/src/baserow/contrib/database/table/handler.py +++ b/backend/src/baserow/contrib/database/table/handler.py @@ -26,12 +26,7 @@ from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.view_types import GridViewType from baserow.core.registries import application_type_registry from baserow.core.trash.handler import TrashHandler -from baserow.core.utils import ( - ChildProgressBuilder, - Progress, - find_unused_name, - split_ending_number, -) +from baserow.core.utils import ChildProgressBuilder, Progress, find_unused_name from .constants import TABLE_CREATION from .exceptions import ( @@ -410,8 +405,7 @@ class TableHandler: """ existing_tables_names = list(database.table_set.values_list("name", flat=True)) - name, _ = split_ending_number(proposed_name) - return find_unused_name([name], existing_tables_names, max_length=255) + return find_unused_name([proposed_name], existing_tables_names, max_length=255) def _create_related_link_fields_in_existing_tables_to_import( self, serialized_table: Dict[str, Any], id_mapping: Dict[str, Any] @@ -476,18 +470,20 @@ class TableHandler: if not isinstance(table, Table): raise ValueError("The table is not an instance of Table") - progress = ChildProgressBuilder.build(progress_builder, child_total=2) + start_progress, export_progress, import_progress = 10, 30, 60 + progress = ChildProgressBuilder.build(progress_builder, child_total=100) + progress.increment(by=start_progress) database = table.database database.group.has_user(user, raise_error=True) database_type = application_type_registry.get_by_model(database) serialized_tables = database_type.export_tables_serialized([table]) - progress.increment() # Set a unique name for the table to import back as a new one. exported_table = serialized_tables[0] exported_table["name"] = self.find_unused_table_name(database, table.name) + exported_table["order"] = Table.get_last_order(database) id_mapping: Dict[str, Any] = {"database_tables": {}} @@ -496,13 +492,17 @@ class TableHandler: exported_table, id_mapping ) ) + progress.increment(by=export_progress) + imported_tables = database_type.import_tables_serialized( database, [exported_table], id_mapping, external_table_fields_to_import=link_fields_to_import_to_existing_tables, + progress_builder=progress.create_child_builder( + represents_progress=import_progress + ), ) - progress.increment() new_table_clone = imported_tables[0] diff --git a/backend/src/baserow/contrib/database/table/job_types.py b/backend/src/baserow/contrib/database/table/job_types.py index 5da83bf7a..6ed19f8d5 100644 --- a/backend/src/baserow/contrib/database/table/job_types.py +++ b/backend/src/baserow/contrib/database/table/job_types.py @@ -53,7 +53,9 @@ class DuplicateTableJobType(JobType): new_table_clone = action_type_registry.get_by_type(DuplicateTableActionType).do( job.user, job.original_table, - progress.create_child_builder(represents_progress=progress.total), + progress_builder=progress.create_child_builder( + represents_progress=progress.total + ), ) # update the job with the new duplicated table diff --git a/backend/src/baserow/core/handler.py b/backend/src/baserow/core/handler.py index b9b176c8e..6dcb8efe6 100644 --- a/backend/src/baserow/core/handler.py +++ b/backend/src/baserow/core/handler.py @@ -847,25 +847,32 @@ class CoreHandler: group = application.group group.has_user(user, raise_error=True) - progress = ChildProgressBuilder.build(progress_builder, child_total=2) + start_progress, export_progress, import_progress = 10, 30, 60 + progress = ChildProgressBuilder.build(progress_builder, child_total=100) + progress.increment(by=start_progress) # export the application specific_application = application.specific application_type = application_type_registry.get_by_model(specific_application) serialized = application_type.export_serialized(specific_application) - progress.increment() + progress.increment(by=export_progress) # Set a new unique name for the new application serialized["name"] = self.find_unused_application_name( group.id, serialized["name"] ) + serialized["order"] = application_type.model_class.get_last_order(group) # import it back as a new application id_mapping: Dict[str, Any] = {} new_application_clone = application_type.import_serialized( - group, serialized, id_mapping + group, + serialized, + id_mapping, + progress_builder=progress.create_child_builder( + represents_progress=import_progress + ), ) - progress.increment() # broadcast the application_created signal application_created.send( diff --git a/changelog.md b/changelog.md index 1918378ae..bdab18bc4 100644 --- a/changelog.md +++ b/changelog.md @@ -45,6 +45,7 @@ For example: * Add API token authentication support to multipart and via-URL file uploads. [#255](https://gitlab.com/bramw/baserow/-/issues/255) * Add a rich preview while importing data to an existing table. [#1120](https://gitlab.com/bramw/baserow/-/issues/1120) * Add env vars for controlling which URLs and IPs webhooks are allowed to use. [#931](https://gitlab.com/bramw/baserow/-/issues/931) +* Show database and table duplication progress in the left sidebar. [#1059](https://gitlab.com/bramw/baserow/-/issues/1059) ### Bug Fixes * Resolve circular dependency in `FieldWithFiltersAndSortsSerializer` [#1113](https://gitlab.com/bramw/baserow/-/issues/1113) diff --git a/web-frontend/modules/core/assets/scss/components/tree.scss b/web-frontend/modules/core/assets/scss/components/tree.scss index 40a058b04..27cc7c016 100644 --- a/web-frontend/modules/core/assets/scss/components/tree.scss +++ b/web-frontend/modules/core/assets/scss/components/tree.scss @@ -10,6 +10,10 @@ } } +.tree:not(:last-child) { + margin-bottom: 3px; +} + .tree__item { @extend %first-last-no-margin; @@ -40,6 +44,11 @@ padding: 0 6px; border-radius: 3px; + &.tree__action--disabled { + opacity: 0.6; + cursor: not-allowed; + } + &:not(.tree__action--disabled):hover { background-color: $color-neutral-100; } @@ -79,6 +88,13 @@ } } +.tree__progress_percentage { + display: inline-block; + position: absolute; + right: 35px; + color: $color-primary-500; +} + .tree__icon { @extend %tree__size; @@ -135,6 +151,14 @@ } } +.tree__subs:not(:last-of-type) { + padding: 0; + + & .tree__sub:last-child::before { + height: 28px; + } +} + .tree__sub-link { @extend %tree_sub-size; @extend %ellipsis; @@ -154,6 +178,23 @@ } } +.tree__sub-link--loading { + @include loading(14px); + @include absolute(9px, 9px, auto, auto); +} + +.tree__sub-link--disabled { + opacity: 0.6; + cursor: not-allowed; + + &:hover { + color: $color-primary-900; + opacity: 0.6; + text-decoration: none; + cursor: not-allowed; + } +} + .tree__sub-add { display: inline-block; margin: 0 0 10px 10px; diff --git a/web-frontend/modules/core/components/sidebar/Sidebar.vue b/web-frontend/modules/core/components/sidebar/Sidebar.vue index b0fead6b5..5985f33b3 100644 --- a/web-frontend/modules/core/components/sidebar/Sidebar.vue +++ b/web-frontend/modules/core/components/sidebar/Sidebar.vue @@ -179,6 +179,15 @@ :group="selectedGroup" ></component> </ul> + <ul v-if="pendingJobs.length" class="tree"> + <component + :is="getPendingJobComponent(job)" + v-for="job in pendingJobs" + :key="job.id" + :job="job" + > + </component> + </ul> <li class="sidebar__new-wrapper"> <a ref="createApplicationContextLink" @@ -329,6 +338,13 @@ export default { .map((plugin) => plugin.getSidebarMainMenuComponent()) .filter((component) => component !== null) }, + pendingJobs() { + return this.$store.getters['job/getAll'].filter((job) => + this.$registry + .get('job', job.type) + .isJobPartOfGroup(job, this.selectedGroup) + ) + }, /** * Indicates whether the current user is visiting an admin page. */ @@ -358,6 +374,9 @@ export default { .get('application', application.type) .getSidebarComponent() }, + getPendingJobComponent(job) { + return this.$registry.get('job', job.type).getSidebarComponent() + }, logoff() { this.$store.dispatch('auth/logoff') this.$nuxt.$router.push({ name: 'login' }) diff --git a/web-frontend/modules/core/components/sidebar/SidebarApplication.vue b/web-frontend/modules/core/components/sidebar/SidebarApplication.vue index 5e99a3f44..0596586b1 100644 --- a/web-frontend/modules/core/components/sidebar/SidebarApplication.vue +++ b/web-frontend/modules/core/components/sidebar/SidebarApplication.vue @@ -38,27 +38,18 @@ <a @click="enableRename()"> <i class="context__menu-icon fas fa-fw fa-pen"></i> {{ - $t('sidebarApplication.renameApplication', { + $t('sidebarApplication.rename', { type: application._.type.name.toLowerCase(), }) }} </a> </li> <li> - <a - :class="{ - 'context__menu-item--loading': duplicateLoading, - disabled: duplicateLoading || deleteLoading, - }" - @click="duplicateApplication()" - > - <i class="context__menu-icon fas fa-fw fa-copy"></i> - {{ - $t('sidebarApplication.duplicateApplication', { - type: application._.type.name.toLowerCase(), - }) - }} - </a> + <SidebarDuplicateApplicationContextItem + :application="application" + :disabled="deleting" + @click="$refs.context.hide()" + ></SidebarDuplicateApplicationContextItem> </li> <li> <a @click="openSnapshots"> @@ -78,12 +69,12 @@ </li> <li> <a - :class="{ 'context__menu-item--loading': deleteLoading }" + :class="{ 'context__menu-item--loading': deleting }" @click="deleteApplication()" > <i class="context__menu-icon fas fa-fw fa-trash"></i> {{ - $t('sidebarApplication.deleteApplication', { + $t('sidebarApplication.delete', { type: application._.type.name.toLowerCase(), }) }} @@ -103,17 +94,18 @@ </template> <script> -import { mapGetters } from 'vuex' import { notifyIf } from '@baserow/modules/core/utils/error' -import ApplicationService from '@baserow/modules/core/services/application' -import jobProgress from '@baserow/modules/core/mixins/jobProgress' +import SidebarDuplicateApplicationContextItem from '@baserow/modules/core/components/sidebar/SidebarDuplicateApplicationContextItem.vue' import TrashModal from '@baserow/modules/core/components/trash/TrashModal' import SnapshotsModal from '@baserow/modules/core/components/snapshots/SnapshotsModal' export default { name: 'SidebarApplication', - components: { TrashModal, SnapshotsModal }, - mixins: [jobProgress], + components: { + TrashModal, + SidebarDuplicateApplicationContextItem, + SnapshotsModal, + }, props: { application: { type: Object, @@ -126,18 +118,9 @@ export default { }, data() { return { - deleteLoading: false, - duplicateLoading: false, + deleting: false, } }, - computed: { - ...mapGetters({ - selectedTable: 'table/getSelected', - }), - }, - beforeDestroy() { - this.stopPollIfRunning() - }, methods: { setLoading(application, value) { this.$store.dispatch('application/setItemLoading', { @@ -166,92 +149,12 @@ export default { this.setLoading(application, false) }, - showError(title, message) { - this.$store.dispatch( - 'notification/error', - { title, message }, - { root: true } - ) - }, - // eslint-disable-next-line require-await - async onJobFailed() { - this.duplicateLoading = false - this.$refs.context.hide() - this.showError( - this.$t('clientHandler.notCompletedTitle'), - this.$t('clientHandler.notCompletedDescription') - ) - }, - // eslint-disable-next-line require-await - async onJobPollingError(error) { - this.duplicateLoading = false - this.$refs.context.hide() - notifyIf(error, 'application') - }, - async onJobDone() { - const newApplicationId = this.job.duplicated_application.id - let newApplication - try { - newApplication = await this.$store.dispatch('application/fetch', { - applicationId: newApplicationId, - }) - } catch (error) { - notifyIf(error, 'application') - } finally { - this.duplicateLoading = false - this.$refs.context.hide() - } - - // find the matching table in the duplicated application if any - // otherwise just select the first table and show it - if (newApplication) { - if (newApplication.tables.length) { - let selectTable = newApplication.tables[0] - const originalSelectedTable = this.selectedTable - if (originalSelectedTable) { - for (const table of newApplication.tables) { - if (table.name === originalSelectedTable.name) { - selectTable = table - break - } - } - } - this.$nuxt.$router.push({ - name: 'database-table', - params: { - databaseId: newApplication.id, - tableId: selectTable.id, - }, - }) - } else { - this.$emit('selected', newApplication) - } - } - }, - async duplicateApplication() { - if (this.duplicateLoading || this.deleteLoading) { - return - } - - const application = this.application - this.duplicateLoading = true - - try { - const { data: job } = await ApplicationService( - this.$client - ).asyncDuplicate(application.id) - this.startJobPoller(job) - } catch (error) { - this.duplicateLoading = false - notifyIf(error, 'application') - } - }, async deleteApplication() { - if (this.deleteLoading) { + if (this.deleting) { return } - this.deleteLoading = true + this.deleting = true try { await this.$store.dispatch('application/delete', this.application) @@ -263,7 +166,7 @@ export default { notifyIf(error, 'application') } - this.deleteLoading = false + this.deleting = false }, showApplicationTrashModal() { this.$refs.context.hide() diff --git a/web-frontend/modules/core/components/sidebar/SidebarApplicationPendingJob.vue b/web-frontend/modules/core/components/sidebar/SidebarApplicationPendingJob.vue new file mode 100644 index 000000000..11f0b661c --- /dev/null +++ b/web-frontend/modules/core/components/sidebar/SidebarApplicationPendingJob.vue @@ -0,0 +1,36 @@ +<template> + <li class="tree__item tree__item--loading"> + <div class="tree__action tree__action--disabled"> + <a class="tree__link"> + <i + class="tree__icon tree__icon--type fas" + :class="'fa-' + jobIconClass" + ></i> + {{ jobSidebarText }} + <div class="tree__progress_percentage"> + {{ job.progress_percentage }} % + </div> + </a> + </div> + </li> +</template> + +<script> +export default { + name: 'SidebarApplicationPendingJob', + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + jobSidebarText() { + return this.$registry.get('job', this.job.type).getSidebarText(this.job) + }, + jobIconClass() { + return this.$registry.get('job', this.job.type).getIconClass() + }, + }, +} +</script> diff --git a/web-frontend/modules/core/components/sidebar/SidebarDuplicateApplicationContextItem.vue b/web-frontend/modules/core/components/sidebar/SidebarDuplicateApplicationContextItem.vue new file mode 100644 index 000000000..c1e3bf503 --- /dev/null +++ b/web-frontend/modules/core/components/sidebar/SidebarDuplicateApplicationContextItem.vue @@ -0,0 +1,98 @@ +<template> + <a + :class="{ + 'context__menu-item--loading': duplicating, + disabled: disabled || duplicating, + }" + @click="duplicateApplication()" + > + <i class="context__menu-icon fas fa-fw fa-copy"></i> + {{ + $t('sidebarApplication.duplicate', { + type: application._.type.name.toLowerCase(), + }) + }} + </a> +</template> + +<script> +import { notifyIf } from '@baserow/modules/core/utils/error' +import ApplicationService from '@baserow/modules/core/services/application' +import jobProgress from '@baserow/modules/core/mixins/jobProgress' + +export default { + name: 'SidebarDuplicateApplicationContextItem', + mixins: [jobProgress], + props: { + application: { + type: Object, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + duplicating: false, + } + }, + methods: { + showError(title, message) { + this.$store.dispatch( + 'notification/error', + { title, message }, + { root: true } + ) + }, + // eslint-disable-next-line require-await + async onJobFailed() { + await this.$store.dispatch('job/forceUpdate', { + job: this.job, + data: this.job, + }) + + this.duplicating = false + }, + async onJobPollingError(error) { + await this.$store.dispatch('job/forceDelete', this.job) + this.duplicating = false + notifyIf(error, 'application') + }, + async onJobUpdated() { + await this.$store.dispatch('job/forceUpdate', { + job: this.job, + data: this.job, + }) + }, + // eslint-disable-next-line require-await + async onJobDone() { + await this.$store.dispatch('job/forceUpdate', { + job: this.job, + data: this.job, + }) + this.duplicating = false + }, + async duplicateApplication() { + if (this.duplicating || this.disabled) { + return + } + + this.duplicating = true + try { + const { data: job } = await ApplicationService( + this.$client + ).asyncDuplicate(this.application.id) + this.startJobPoller(job) + this.$store.dispatch('job/forceCreate', job) + } catch (error) { + this.duplicating = false + notifyIf(error, 'application') + } + this.$emit('click') + }, + }, +} +</script> diff --git a/web-frontend/modules/core/jobTypes.js b/web-frontend/modules/core/jobTypes.js new file mode 100644 index 000000000..c1edb3365 --- /dev/null +++ b/web-frontend/modules/core/jobTypes.js @@ -0,0 +1,159 @@ +import { notifyIf } from '@baserow/modules/core/utils/error' +import { Registerable } from '@baserow/modules/core/registry' +import SidebarApplicationPendingJob from '@baserow/modules/core/components/sidebar/SidebarApplicationPendingJob' + +/** + * The job type base class that can be extended when creating a plugin + * for the frontend. + */ +export class JobType extends Registerable { + /** + * The font awesome 5 icon name that is used as convenience for the user to + * recognize certain job types. If you for example want the database + * icon, you must return 'database' here. This will result in the classname + * 'fas fa-database'. + */ + getIconClass() { + return null + } + + /** + * A human readable name of the job type. + */ + getName() { + return null + } + + /** + * Returns the text to show in the sidebar when the job is running. + */ + getSidebarText(job) { + return '' + } + + /** + * The sidebar component will be rendered in the sidebar if the application is + * in the selected group. All the applications of a group are listed in the + * sidebar and this component should give the user the possibility to select + * that application. + */ + getSidebarComponent() { + return null + } + + constructor(...args) { + super(...args) + this.type = this.getType() + + if (this.type === null) { + throw new Error('The type name of an application type must be set.') + } + if (this.name === null) { + throw new Error('The name of an application type must be set.') + } + } + + /** + * Every time a fresh job object is fetched from the backend, it will + * be populated, this is the moment to update some values. Because each + * jobType can have unique properties, they might need to be populated. + * This method can be overwritten in order the populate the correct values. + */ + populate(job) { + return job + } + + /** + * Returns true if the specific job is part of the group + */ + isJobPartOfGroup(job, group) { + return false + } + + /** + * Returns true if the specific job is part of the application + */ + isJobPartOfApplication(job, application) { + return false + } + + /** + * When an application is deleted it could be that an action should be taken, + * like redirect the user to another page. This method is called when application + * of this type is deleted. + */ + beforeDelete(job) {} + + /** + * Before the application values are updated, they can be modified here. This + * might be needed because providing certain values could break the update. + */ + async beforeUpdate(job, data) {} + + async afterUpdate(job, data) { + if (job.state === 'finished') { + await this.onJobDone(job, data) + } else if (job.state === 'failed') { + await this.onJobFailed(job, data) + } + } + + async onJobDone(job) {} + async onJobFailed(job) {} +} + +export class DuplicateApplicationJobType extends JobType { + static getType() { + return 'duplicate_application' + } + + getIconClass() { + // TODO: This should be moved to a registry and in the database module. + return 'database' + } + + getName() { + return 'duplicateApplication' + } + + getSidebarText(job) { + const { i18n } = this.app + return i18n.t('duplicateApplicationJobType.duplicating') + '... ' + } + + getSidebarComponent() { + return SidebarApplicationPendingJob + } + + isJobPartOfGroup(job, group) { + return job.original_application.group.id === group.id + } + + async onJobDone(job) { + const { i18n, store } = this.app + const application = job.duplicated_application + try { + await store.dispatch('application/fetch', application.id) + store.dispatch('notification/info', { + title: i18n.t('duplicateApplicationJobType.duplicatedTitle'), + message: application.name, + }) + await store.dispatch('job/forceDelete', job) + } catch (error) { + notifyIf(error, 'application') + } + } + + async onJobFailed(job) { + const { i18n, store } = this.app + await store.dispatch( + 'notification/error', + { + title: i18n.t('clientHandler.notCompletedTitle'), + message: i18n.t('clientHandler.notCompletedDescription'), + }, + { root: true } + ) + await this.app.store.dispatch('job/forceDelete', job) + } +} diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json index 1178730ed..a03b90fc1 100644 --- a/web-frontend/modules/core/locales/en.json +++ b/web-frontend/modules/core/locales/en.json @@ -7,12 +7,16 @@ "label": "Copied!" }, "sidebarApplication": { - "renameApplication": "Rename {type}", - "duplicateApplication": "Duplicate {type}", + "rename": "Rename", + "duplicate": "Duplicate", "viewTrash": "View trash", - "deleteApplication": "Delete {type}", + "delete": "Delete", "snapshots": "Snapshots" }, + "duplicateApplicationJobType": { + "duplicating": "Duplicating", + "duplicatedTitle": "Application duplicated" + }, "sidebar": { "createGroup": "Create group", "inviteOthers": "Invite others", @@ -397,4 +401,4 @@ "monthsAgo": "0 months ago | 1 month ago | {n} months ago", "yearsAgo": "0 years ago | 1 year ago | {n} years ago" } -} +} \ No newline at end of file diff --git a/web-frontend/modules/core/mixins/jobProgress.js b/web-frontend/modules/core/mixins/jobProgress.js index 73e69acd5..5be505c2a 100644 --- a/web-frontend/modules/core/mixins/jobProgress.js +++ b/web-frontend/modules/core/mixins/jobProgress.js @@ -4,35 +4,34 @@ import JobService from '@baserow/modules/core/services/job' * To use this mixin you need to create the following methods on your component: * - `getCustomHumanReadableJobState(state)` returns the human readable message your * custom state values. - * - onJobDone() (optional) is called when the job is finished - * - onJobFailed() (optional) is called if the job failed - * - onJobPollingError(error) (optional) is there is an exception during the job polling + * - onJobUpdated() (optional) is called during the polling for not finished jobs. + * - onJobDone() (optional) is called if the job successfully finishes. + * - onJobFailed() (optional) is called if the job fails. + * - onJobPollingError(error) (optional) is called if the polling fails. * */ export default { data() { return { job: null, - pollInterval: null, + nextPollTimeout: null, + pollTimeoutId: null, } }, computed: { - jobIsRunning() { - return ( - this.job !== null && !['failed', 'finished'].includes(this.job.state) - ) - }, - jobIsFinished() { - return ( - this.job !== null && ['failed', 'finished'].includes(this.job.state) - ) - }, jobHasSucceeded() { return this.job?.state === 'finished' }, jobHasFailed() { return this.job?.state === 'failed' }, + jobIsFinished() { + return this.jobHasSucceeded || this.jobHasFailed + }, + jobIsRunning() { + return this.job !== null && !this.jobIsFinished + }, + jobHumanReadableState() { if (this.job === null) { return '' @@ -57,35 +56,46 @@ export default { */ startJobPoller(job) { this.job = job - this.pollInterval = setTimeout(this.getLatestJobInfo, 1000) + this.nextPollTimeout = 200 + this.pollTimeoutId = setTimeout( + this.getLatestJobInfo, + this.nextPollTimeout + ) }, async getLatestJobInfo() { try { - const { data } = await JobService(this.$client).get(this.job.id) - this.job = data - if (this.jobHasFailed) { - if (this.onJobFailed) { + const { data: job } = await JobService(this.$client).get(this.job.id) + this.job = job + if (job.state === 'failed') { + if (typeof this.onJobFailed === 'function') { await this.onJobFailed() } - } else if (!this.jobIsRunning) { - if (this.onJobDone) { + } else if (job.state === 'finished') { + if (typeof this.onJobDone === 'function') { await this.onJobDone() } } else { - // job unfinished, set the next polling timeout - this.pollInterval = setTimeout(this.getLatestJobInfo, 1000) + // job unfinished, keep polling + if (typeof this.onJobUpdated === 'function') { + await this.onJobUpdated() + } + this.nextPollTimeout = Math.min(this.nextPollTimeout * 1.5, 2500) + this.pollTimeoutId = setTimeout( + this.getLatestJobInfo, + this.nextPollTimeout + ) } } catch (error) { - if (this.onJobPollingError) { + if (typeof this.onJobPollingError === 'function') { this.onJobPollingError(error) } this.job = null } }, stopPollIfRunning() { - if (this.pollInterval) { - clearTimeout(this.pollInterval) - this.pollInterval = null + if (this.pollTimeoutId) { + clearTimeout(this.pollTimeoutId) + this.pollTimeoutId = null } }, }, diff --git a/web-frontend/modules/core/plugin.js b/web-frontend/modules/core/plugin.js index 560b9871b..1a03632bc 100644 --- a/web-frontend/modules/core/plugin.js +++ b/web-frontend/modules/core/plugin.js @@ -1,6 +1,7 @@ import Vue from 'vue' import { Registry } from '@baserow/modules/core/registry' +import { DuplicateApplicationJobType } from '@baserow/modules/core/jobTypes' import { AccountSettingsType, @@ -17,6 +18,7 @@ import settingsStore from '@baserow/modules/core/store/settings' import applicationStore from '@baserow/modules/core/store/application' import authStore from '@baserow/modules/core/store/auth' import groupStore from '@baserow/modules/core/store/group' +import jobStore from '@baserow/modules/core/store/job' import notificationStore from '@baserow/modules/core/store/notification' import sidebarStore from '@baserow/modules/core/store/sidebar' import undoRedoStore from '@baserow/modules/core/store/undoRedo' @@ -46,6 +48,7 @@ export default (context, inject) => { const registry = new Registry() registry.registerNamespace('plugin') registry.registerNamespace('application') + registry.registerNamespace('job') registry.registerNamespace('view') registry.registerNamespace('field') registry.registerNamespace('settings') @@ -64,8 +67,11 @@ export default (context, inject) => { store.registerModule('settings', settingsStore) store.registerModule('application', applicationStore) store.registerModule('auth', authStore) + store.registerModule('job', jobStore) store.registerModule('group', groupStore) store.registerModule('notification', notificationStore) store.registerModule('sidebar', sidebarStore) store.registerModule('undoRedo', undoRedoStore) + + registry.register('job', new DuplicateApplicationJobType(context)) } diff --git a/web-frontend/modules/core/store/application.js b/web-frontend/modules/core/store/application.js index 4fc446587..b237b0998 100644 --- a/web-frontend/modules/core/store/application.js +++ b/web-frontend/modules/core/store/application.js @@ -42,19 +42,23 @@ export const mutations = { }, UPDATE_ITEM(state, { id, values }) { const index = state.items.findIndex((item) => item.id === id) - Object.assign(state.items[index], state.items[index], values) + if (index !== -1) { + Object.assign(state.items[index], state.items[index], values) + } }, ORDER_ITEMS(state, { group, order }) { state.items .filter((item) => item.group.id === group.id) .forEach((item) => { const index = order.findIndex((value) => value === item.id) - item.order = index === -1 ? 0 : index + 1 + item.order = index === -1 ? undefined : index + 1 }) }, DELETE_ITEM(state, id) { const index = state.items.findIndex((item) => item.id === id) - state.items.splice(index, 1) + if (index !== -1) { + state.items.splice(index, 1) + } }, DELETE_ITEMS_FOR_GROUP(state, groupId) { state.items = state.items.filter((app) => app.group.id !== groupId) @@ -111,9 +115,8 @@ export const actions = { /** * Fetches one application for the authenticated user. */ - async fetch({ commit, dispatch }, { applicationId }) { + async fetch({ commit, dispatch }, applicationId) { commit('SET_LOADING', true) - try { const { data } = await ApplicationService(this.$client).get(applicationId) dispatch('forceCreate', data) @@ -246,8 +249,9 @@ export const actions = { /** * Forcefully delete an item in the store without making a call to the server. */ - forceDelete({ commit }, application) { + forceDelete({ commit, dispatch }, application) { const type = this.$registry.get('application', application.type) + dispatch('job/deleteForApplication', application, { root: true }) type.delete(application, this) commit('DELETE_ITEM', application.id) }, diff --git a/web-frontend/modules/core/store/group.js b/web-frontend/modules/core/store/group.js index 815bdd469..66e311429 100644 --- a/web-frontend/modules/core/store/group.js +++ b/web-frontend/modules/core/store/group.js @@ -226,6 +226,7 @@ export const actions = { * example a Table is open that has been deleted because the group has been deleted. */ forceDelete({ commit, dispatch, rootGetters }, group) { + dispatch('job/deleteForGroup', group, { root: true }) const applications = rootGetters['application/getAllOfGroup'](group) applications.forEach((application) => { return dispatch('application/forceDelete', application, { root: true }) diff --git a/web-frontend/modules/core/store/job.js b/web-frontend/modules/core/store/job.js new file mode 100644 index 000000000..3803319c8 --- /dev/null +++ b/web-frontend/modules/core/store/job.js @@ -0,0 +1,90 @@ +export function populateJob(job, registry) { + const type = registry.get('job', job.type) + return type.populate(job) +} + +export const state = () => ({ + items: [], +}) + +export const mutations = { + ADD_ITEM(state, item) { + state.items.push(item) + }, + UPDATE_ITEM(state, { id, values }) { + const index = state.items.findIndex((item) => item.id === id) + if (index !== -1) { + Object.assign(state.items[index], state.items[index], values) + } + }, + DELETE_ITEM(state, id) { + const index = state.items.findIndex((item) => item.id === id) + if (index !== -1) { + state.items.splice(index, 1) + } + }, +} + +export const actions = { + /** + * Forcefully create an item in the store without making a call to the server. + */ + forceCreate({ commit }, job) { + populateJob(job, this.$registry) + commit('ADD_ITEM', job) + }, + /** + * Forcefully update an item in the store without making a call to the server. + */ + async forceUpdate({ commit }, { job, data }) { + const type = this.$registry.get('job', job.type) + await type.beforeUpdate(job, data) + commit('UPDATE_ITEM', { id: job.id, values: data }) + await type.afterUpdate(job, data) + }, + /** + * Forcefully delete an item in the store without making a call to the server. + */ + forceDelete({ commit }, job) { + const type = this.$registry.get('job', job.type) + type.beforeDelete(job, this) + commit('DELETE_ITEM', job.id) + }, + /** + * Deletes all the jobs belonging to the given group. + */ + deleteForGroup({ commit, state }, group) { + const jobs = state.items.filter((job) => + this.$registry.get('job', job.type).isJobPartOfGroup(job, group) + ) + jobs.forEach((job) => commit('DELETE_ITEM', job.id)) + }, + /** + * Deletes all the jobs belonging to the given application. + */ + deleteForApplication({ commit, state }, application) { + const jobs = state.items.filter((job) => + this.$registry + .get('job', job.type) + .isJobPartOfApplication(job, application) + ) + jobs.forEach((job) => commit('DELETE_ITEM', job.id)) + }, +} + +export const getters = { + get: (state) => (id) => { + return state.items.find((item) => item.id === id) + }, + getAll: (state) => { + return state.items + }, +} + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +} diff --git a/web-frontend/modules/database/components/sidebar/Sidebar.vue b/web-frontend/modules/database/components/sidebar/Sidebar.vue index d4be25785..36b7839af 100644 --- a/web-frontend/modules/database/components/sidebar/Sidebar.vue +++ b/web-frontend/modules/database/components/sidebar/Sidebar.vue @@ -35,6 +35,14 @@ :table="table" ></SidebarItem> </ul> + <ul v-if="pendingJobs.length" class="tree__subs"> + <SidebarItemPendingJob + v-for="job in pendingJobs" + :key="job.id" + :job="job" + > + </SidebarItemPendingJob> + </ul> <a class="tree__sub-add" @click="$refs.importFileModal.show()"> <i class="fas fa-plus"></i> {{ $t('sidebar.createTable') }} @@ -47,6 +55,7 @@ <script> import { notifyIf } from '@baserow/modules/core/utils/error' import SidebarItem from '@baserow/modules/database/components/sidebar/SidebarItem' +import SidebarItemPendingJob from '@baserow/modules/database/components/sidebar/SidebarItemPendingJob' import ImportFileModal from '@baserow/modules/database/components/table/ImportFileModal' import SidebarApplication from '@baserow/modules/core/components/sidebar/SidebarApplication' @@ -55,6 +64,7 @@ export default { components: { SidebarApplication, SidebarItem, + SidebarItemPendingJob, ImportFileModal, }, props: { @@ -73,6 +83,13 @@ export default { .map((table) => table) .sort((a, b) => a.order - b.order) }, + pendingJobs() { + return this.$store.getters['job/getAll'].filter((job) => + this.$registry + .get('job', job.type) + .isJobPartOfApplication(job, this.application) + ) + }, }, methods: { async selected(application) { diff --git a/web-frontend/modules/database/components/sidebar/SidebarItem.vue b/web-frontend/modules/database/components/sidebar/SidebarItem.vue index cb15a3a1d..1d49891c1 100644 --- a/web-frontend/modules/database/components/sidebar/SidebarItem.vue +++ b/web-frontend/modules/database/components/sidebar/SidebarItem.vue @@ -47,10 +47,7 @@ :database="database" :table="table" :disabled="deleteLoading" - @table-duplicated=" - $refs.context.hide() - selectTable(database, $event.table) - " + @click="$refs.context.hide()" ></SidebarDuplicateTableContextItem> </li> <li> diff --git a/web-frontend/modules/database/components/sidebar/SidebarItemPendingJob.vue b/web-frontend/modules/database/components/sidebar/SidebarItemPendingJob.vue new file mode 100644 index 000000000..6cae5c448 --- /dev/null +++ b/web-frontend/modules/database/components/sidebar/SidebarItemPendingJob.vue @@ -0,0 +1,28 @@ +<template> + <li class="tree__sub"> + <a class="tree__sub-link tree__sub-link--disabled"> + {{ jobSidebarText }} + <div class="tree__progress_percentage"> + {{ job.progress_percentage }} % + </div> + </a> + <div class="tree__sub-link--loading"></div> + </li> +</template> + +<script> +export default { + name: 'SidebarItemPendingJob', + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + jobSidebarText() { + return this.$registry.get('job', this.job.type).getSidebarText(this.job) + }, + }, +} +</script> diff --git a/web-frontend/modules/database/components/sidebar/table/SidebarDuplicateTableContextItem.vue b/web-frontend/modules/database/components/sidebar/table/SidebarDuplicateTableContextItem.vue index d5d80a2aa..22623c1e2 100644 --- a/web-frontend/modules/database/components/sidebar/table/SidebarDuplicateTableContextItem.vue +++ b/web-frontend/modules/database/components/sidebar/table/SidebarDuplicateTableContextItem.vue @@ -1,8 +1,8 @@ <template> <a :class="{ - 'context__menu-item--loading': loading, - disabled: disabled || loading, + 'context__menu-item--loading': duplicating, + disabled: disabled || duplicating, }" @click="duplicateTable()" > @@ -36,7 +36,7 @@ export default { }, data() { return { - loading: false, + duplicating: false, } }, methods: { @@ -47,44 +47,48 @@ export default { { root: true } ) }, - // eslint-disable-next-line require-await async onJobFailed() { - this.loading = false - this.showError( - this.$t('clientHandler.notCompletedTitle'), - this.$t('clientHandler.notCompletedDescription') - ) + await this.$store.dispatch('job/forceUpdate', { + job: this.job, + data: this.job, + }) + this.duplicating = false }, - // eslint-disable-next-line require-await async onJobPollingError(error) { - this.loading = false + await this.$store.dispatch('job/forceDelete', this.job) + this.duplicating = false notifyIf(error, 'table') }, - async onJobDone() { - const database = this.database - const table = this.job.duplicated_table - await this.$store.dispatch('table/forceCreate', { - database, - data: table, + async onJobUpdated() { + await this.$store.dispatch('job/forceUpdate', { + job: this.job, + data: this.job, }) - this.loading = false - this.$emit('table-duplicated', { table }) + }, + async onJobDone() { + await this.$store.dispatch('job/forceUpdate', { + job: this.job, + data: this.job, + }) + this.duplicating = false }, async duplicateTable() { - if (this.loading || this.disabled) { + if (this.duplicating || this.disabled) { return } - this.loading = true + this.duplicating = true try { const { data: job } = await TableService(this.$client).asyncDuplicate( this.table.id ) this.startJobPoller(job) + this.$store.dispatch('job/forceCreate', job) } catch (error) { - this.loading = false + this.duplicating = false notifyIf(error, 'table') } + this.$emit('click') }, }, } diff --git a/web-frontend/modules/database/jobTypes.js b/web-frontend/modules/database/jobTypes.js new file mode 100644 index 000000000..775a33013 --- /dev/null +++ b/web-frontend/modules/database/jobTypes.js @@ -0,0 +1,61 @@ +import { JobType } from '@baserow/modules/core/jobTypes' + +import SidebarItemPendingJob from '@baserow/modules/database/components/sidebar/SidebarItemPendingJob.vue' + +export class DuplicateTableJobType extends JobType { + static getType() { + return 'duplicate_table' + } + + getName() { + return 'duplicateTable' + } + + getSidebarText(job) { + const { i18n } = this.app + return i18n.t('duplicateTableJobType.duplicating') + '...' + } + + getSidebarComponent() { + return SidebarItemPendingJob + } + + isJobPartOfApplication(job, application) { + return job.original_table.database_id === application.id + } + + async onJobFailed(job) { + const { i18n, store } = this.app + + store.dispatch( + 'notification/error', + { + title: i18n.t('clientHandler.notCompletedTitle'), + message: i18n.t('clientHandler.notCompletedDescription'), + }, + { root: true } + ) + await store.dispatch('job/forceDelete', job) + } + + async onJobDone(job) { + const { i18n, store } = this.app + + const duplicatedTable = job.duplicated_table + const database = store.getters['application/get']( + duplicatedTable.database_id + ) + + await store.dispatch('table/forceCreate', { + database, + data: duplicatedTable, + }) + + store.dispatch('notification/info', { + title: i18n.t('duplicateTableJobType.duplicatedTitle'), + message: duplicatedTable.name, + }) + + store.dispatch('job/forceDelete', job) + } +} diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json index 9e7dac81b..9d6ffb6f9 100644 --- a/web-frontend/modules/database/locales/en.json +++ b/web-frontend/modules/database/locales/en.json @@ -71,6 +71,10 @@ "sidebarItem": { "exportTable": "Export table" }, + "duplicateTableJobType": { + "duplicating": "Duplicating", + "duplicatedTitle": "Table duplicated" + }, "apiToken": { "create": "create", "read": "read", diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js index 12f16f084..e47ec9531 100644 --- a/web-frontend/modules/database/plugin.js +++ b/web-frontend/modules/database/plugin.js @@ -1,4 +1,5 @@ import { DatabaseApplicationType } from '@baserow/modules/database/applicationTypes' +import { DuplicateTableJobType } from '@baserow/modules/database/jobTypes' import { GridViewType, GalleryViewType, @@ -225,6 +226,7 @@ export default (context) => { app.$registry.register('plugin', new DatabasePlugin(context)) app.$registry.register('application', new DatabaseApplicationType(context)) + app.$registry.register('job', new DuplicateTableJobType(context)) app.$registry.register('view', new GridViewType(context)) app.$registry.register('view', new GalleryViewType(context)) app.$registry.register('view', new FormViewType(context))