From c44b3295acb2bdef389c039f8f27557430155a30 Mon Sep 17 00:00:00 2001 From: Bram Wiepjes <bramw@protonmail.com> Date: Tue, 1 Apr 2025 20:23:41 +0000 Subject: [PATCH] "From template" onboarding step --- .../templates/content-scheduling-manager.json | 5 +- backend/templates/employee-directory.json | 5 +- backend/templates/lightweight-crm.json | 5 +- backend/templates/new-hire-onboarding.json | 5 +- backend/templates/objectives-key-results.json | 5 +- backend/templates/user-feedback.json | 5 +- ...38_install_template_during_onboarding.json | 8 ++ .../core/assets/scss/components/all.scss | 1 + .../components/onboarding_tool_preview.scss | 5 + .../scss/components/template_import_item.scss | 51 ++++++++++ .../modules/core/assets/scss/helpers.scss | 4 + .../modules/core/assets/scss/variables.scss | 1 - .../core/components/SegmentControl.vue | 1 + web-frontend/modules/core/onboardingTypes.js | 2 +- .../modules/core/pages/onboarding.vue | 2 +- .../components/onboarding/DatabaseStep.vue | 31 +++++- .../onboarding/DatabaseTemplatePreview.vue | 31 ++++++ .../onboarding/TemplateImportForm.vue | 97 +++++++++++++++++++ web-frontend/modules/database/locales/en.json | 9 +- .../modules/database/onboardingTypes.js | 53 +++++++--- 20 files changed, 292 insertions(+), 34 deletions(-) create mode 100644 changelog/entries/unreleased/feature/2638_install_template_during_onboarding.json create mode 100644 web-frontend/modules/core/assets/scss/components/template_import_item.scss create mode 100644 web-frontend/modules/database/components/onboarding/DatabaseTemplatePreview.vue create mode 100644 web-frontend/modules/database/components/onboarding/TemplateImportForm.vue diff --git a/backend/templates/content-scheduling-manager.json b/backend/templates/content-scheduling-manager.json index a0fa9e9f2..457a2d9e8 100644 --- a/backend/templates/content-scheduling-manager.json +++ b/backend/templates/content-scheduling-manager.json @@ -12,7 +12,8 @@ "publisher", "planner", "publish", - "social media" + "social media", + "onboarding" ], "categories": [ "Marketing" @@ -4891,4 +4892,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/backend/templates/employee-directory.json b/backend/templates/employee-directory.json index 1203b36e0..f9c5e30c8 100644 --- a/backend/templates/employee-directory.json +++ b/backend/templates/employee-directory.json @@ -10,7 +10,8 @@ "teams", "employee list", "staff catalog", - "staff records" + "staff records", + "onboarding" ], "categories": [ "HR and Recruiting" @@ -3777,4 +3778,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/backend/templates/lightweight-crm.json b/backend/templates/lightweight-crm.json index 6d91e7760..816ae3132 100644 --- a/backend/templates/lightweight-crm.json +++ b/backend/templates/lightweight-crm.json @@ -5,7 +5,8 @@ "keywords": [ "CRM", "Sales", - "Local Business" + "Local Business", + "onboarding" ], "categories": [ "Sales and CRM" @@ -25570,4 +25571,4 @@ "type": "builder" } ] -} \ No newline at end of file +} diff --git a/backend/templates/new-hire-onboarding.json b/backend/templates/new-hire-onboarding.json index c57107890..c26f41390 100644 --- a/backend/templates/new-hire-onboarding.json +++ b/backend/templates/new-hire-onboarding.json @@ -12,7 +12,8 @@ "recruits", "hiring", "hr", - "human resources" + "human resources", + "onboarding" ], "categories": [ "HR and Recruiting" @@ -11144,4 +11145,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/backend/templates/objectives-key-results.json b/backend/templates/objectives-key-results.json index 650350983..63661b4c4 100644 --- a/backend/templates/objectives-key-results.json +++ b/backend/templates/objectives-key-results.json @@ -9,7 +9,8 @@ "goal tracker", "business goal tracker", "objectives tracker", - "progress tracker" + "progress tracker", + "onboarding" ], "categories": [ "Business Strategy" @@ -6312,4 +6313,4 @@ "type": "builder" } ] -} \ No newline at end of file +} diff --git a/backend/templates/user-feedback.json b/backend/templates/user-feedback.json index 1f48a03f9..b9d16c1bc 100644 --- a/backend/templates/user-feedback.json +++ b/backend/templates/user-feedback.json @@ -16,7 +16,8 @@ "customer survey", "client feedback", "UAT", - "user acceptance testing" + "user acceptance testing", + "onboarding" ], "categories": [ "Product Management", @@ -3716,4 +3717,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/changelog/entries/unreleased/feature/2638_install_template_during_onboarding.json b/changelog/entries/unreleased/feature/2638_install_template_during_onboarding.json new file mode 100644 index 000000000..a75673215 --- /dev/null +++ b/changelog/entries/unreleased/feature/2638_install_template_during_onboarding.json @@ -0,0 +1,8 @@ +{ + "type": "feature", + "message": "Install template during onboarding", + "domain": "database", + "issue_number": 2638, + "bullet_points": [], + "created_at": "2025-03-24" +} diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss index 8ec9775aa..f1fe092f5 100644 --- a/web-frontend/modules/core/assets/scss/components/all.scss +++ b/web-frontend/modules/core/assets/scss/components/all.scss @@ -184,3 +184,4 @@ @import 'admin_dashboard'; @import 'user_admin'; @import 'group_admin'; +@import 'template_import_item'; diff --git a/web-frontend/modules/core/assets/scss/components/onboarding_tool_preview.scss b/web-frontend/modules/core/assets/scss/components/onboarding_tool_preview.scss index 552e34ee7..43883b64c 100644 --- a/web-frontend/modules/core/assets/scss/components/onboarding_tool_preview.scss +++ b/web-frontend/modules/core/assets/scss/components/onboarding_tool_preview.scss @@ -5,6 +5,7 @@ overflow: hidden; pointer-events: none; transition: left 0.5s ease; + z-index: $z-index-layout-col-1; @include absolute(10%, 0, 0, 10%); } @@ -15,6 +16,10 @@ .onboarding-tool-preview__inner { @include absolute(0, 0, 0, 0); + + &--reserve-space-right { + right: -370px; + } } .onboarding-tool-preview__highlight { diff --git a/web-frontend/modules/core/assets/scss/components/template_import_item.scss b/web-frontend/modules/core/assets/scss/components/template_import_item.scss new file mode 100644 index 000000000..cdfc753b2 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/template_import_item.scss @@ -0,0 +1,51 @@ +.template-import-items { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.template-import-item { + &:hover { + text-decoration: none; + } +} + +.template-import-item__head { + border: 1px solid $palette-neutral-200; + background: $palette-neutral-25; + height: 76px; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 14px; + + @include rounded($rounded-md); + + .template-import-item--active & { + border: 2px solid $palette-blue-500; + } +} + +.template-import-item__icon { + display: flex; + justify-content: center; + align-items: center; + flex: 0 0 40px; + height: 40px; + font-size: 16px; + border: solid 1px $palette-neutral-200; + border-radius: 100%; + color: $palette-neutral-700; + background-color: $white; + + @include rounded($rounded-md); + @include elevation($elevation-low); +} + +.template-import-item__name { + @extend %ellipsis; + + font-weight: 500; + line-height: 20px; + color: $palette-neutral-1200; +} diff --git a/web-frontend/modules/core/assets/scss/helpers.scss b/web-frontend/modules/core/assets/scss/helpers.scss index 8cf3b9d6b..d947fd665 100644 --- a/web-frontend/modules/core/assets/scss/helpers.scss +++ b/web-frontend/modules/core/assets/scss/helpers.scss @@ -218,6 +218,10 @@ justify-content: space-between; } +.justify-content-center { + justify-content: center; +} + .justify-content-end { justify-content: end; } diff --git a/web-frontend/modules/core/assets/scss/variables.scss b/web-frontend/modules/core/assets/scss/variables.scss index 358874aee..6b6aa5b45 100644 --- a/web-frontend/modules/core/assets/scss/variables.scss +++ b/web-frontend/modules/core/assets/scss/variables.scss @@ -34,7 +34,6 @@ $base-font-family: $text-font-stack !default; $radius-none: 0 !default; $rounded: 4px !default; -$rounded-left: 4px 0 0 4px !default; $rounded-md: 6px !default; $rounded-xl: 12px !default; $rounded-2xl: 16px !default; diff --git a/web-frontend/modules/core/components/SegmentControl.vue b/web-frontend/modules/core/components/SegmentControl.vue index b79df8f15..5a9f92b6c 100644 --- a/web-frontend/modules/core/components/SegmentControl.vue +++ b/web-frontend/modules/core/components/SegmentControl.vue @@ -15,6 +15,7 @@ :class="{ 'segment-control__button--active': index === activeIndex, }" + :title="segment.label" class="segment-control__button" @click="setActiveIndex(index)" > diff --git a/web-frontend/modules/core/onboardingTypes.js b/web-frontend/modules/core/onboardingTypes.js index 129931ffb..22a3676a2 100644 --- a/web-frontend/modules/core/onboardingTypes.js +++ b/web-frontend/modules/core/onboardingTypes.js @@ -35,7 +35,7 @@ export class OnboardingType extends Registerable { * so it should just be for demo purposes. It can accept the data property * containing the data of all the steps. */ - getPreviewComponent() { + getPreviewComponent(data) { return null } diff --git a/web-frontend/modules/core/pages/onboarding.vue b/web-frontend/modules/core/pages/onboarding.vue index a31d59b9a..c7860627f 100644 --- a/web-frontend/modules/core/pages/onboarding.vue +++ b/web-frontend/modules/core/pages/onboarding.vue @@ -83,7 +83,7 @@ </div> <div class="onboarding__preview"> <component - :is="step.getPreviewComponent()" + :is="step.getPreviewComponent(data)" v-bind="step.getAdditionalPreviewProps()" :data="data" ></component> diff --git a/web-frontend/modules/database/components/onboarding/DatabaseStep.vue b/web-frontend/modules/database/components/onboarding/DatabaseStep.vue index 67ad15526..a42da53db 100644 --- a/web-frontend/modules/database/components/onboarding/DatabaseStep.vue +++ b/web-frontend/modules/database/components/onboarding/DatabaseStep.vue @@ -4,7 +4,7 @@ <p> {{ $t('databaseStep.description') }} </p> - <div class="margin-bottom-3"> + <div class="margin-bottom-2"> <SegmentControl :active-index.sync="selectedTypeIndex" :segments="types" @@ -31,17 +31,29 @@ ref="airtable" @input="updateValue($event)" ></AirtableImportForm> + <TemplateImportForm + v-if="selectedType === 'template'" + @selected-template="selectedTemplate" + ></TemplateImportForm> </div> </template> <script> import { useVuelidate } from '@vuelidate/core' import { required, helpers } from '@vuelidate/validators' -import AirtableImportForm from '@baserow/modules/database/components/airtable/AirtableImportForm.vue' +import AirtableImportForm from '@baserow/modules/database/components/airtable/AirtableImportForm' +import TemplateImportForm from '@baserow/modules/database/components/onboarding/TemplateImportForm' +import { DatabaseOnboardingType } from '@baserow/modules/database/onboardingTypes' export default { name: 'DatabaseStep', - components: { AirtableImportForm }, + components: { AirtableImportForm, TemplateImportForm }, + props: { + data: { + required: true, + type: Object, + }, + }, setup() { return { v$: useVuelidate({ $lazy: true }) } }, @@ -60,6 +72,10 @@ export default { type: 'airtable', label: this.$t('databaseStep.airtable'), }, + { + type: 'template', + label: this.$t('databaseStep.template'), + }, ], selectedTypeIndex: 0, name: '', @@ -82,6 +98,9 @@ export default { if (this.selectedType === 'airtable') { const airtable = this.$refs.airtable return !!airtable && !airtable.v$.$invalid && airtable.v$.$dirty + } else if (this.selectedType === 'template') { + const template = this.data[DatabaseOnboardingType.getType()].template + return !!template } else { return !this.v$.$invalid && this.v$.$dirty } @@ -93,6 +112,12 @@ export default { ...airtable, }) }, + selectedTemplate(template) { + this.$emit('update-data', { + type: this.selectedType, + template, + }) + }, }, validations() { const rules = {} diff --git a/web-frontend/modules/database/components/onboarding/DatabaseTemplatePreview.vue b/web-frontend/modules/database/components/onboarding/DatabaseTemplatePreview.vue new file mode 100644 index 000000000..0ab1b24aa --- /dev/null +++ b/web-frontend/modules/database/components/onboarding/DatabaseTemplatePreview.vue @@ -0,0 +1,31 @@ +<template> + <div :class="{ 'onboarding-tool-preview': true }"> + <div + ref="inner" + class="onboarding-tool-preview__inner onboarding-tool-preview__inner--reserve-space-right" + > + <TemplatePreview :template="template"></TemplatePreview> + </div> + </div> +</template> + +<script> +import TemplatePreview from '@baserow/modules/core/components/template/TemplatePreview' +import { DatabaseOnboardingType } from '@baserow/modules/database/onboardingTypes' + +export default { + name: 'DatabaseTemplatePreview', + components: { TemplatePreview }, + props: { + data: { + type: Object, + required: true, + }, + }, + computed: { + template() { + return this.data[DatabaseOnboardingType.getType()].template + }, + }, +} +</script> diff --git a/web-frontend/modules/database/components/onboarding/TemplateImportForm.vue b/web-frontend/modules/database/components/onboarding/TemplateImportForm.vue new file mode 100644 index 000000000..951638d1e --- /dev/null +++ b/web-frontend/modules/database/components/onboarding/TemplateImportForm.vue @@ -0,0 +1,97 @@ +<template> + <div> + <div v-if="loading" class="flex justify-content-center"> + <div class="loading"></div> + </div> + <div v-else> + <FormInput + v-model="search" + icon-left="iconoir-search" + :placeholder="$t('templateCategories.search')" + class="margin-bottom-2" + ></FormInput> + <div class="template-import-items"> + <a + v-for="template in templates" + :key="template.id" + class="template-import-item" + :class="{ + 'template-import-item--active': template.id === selectedTemplate, + }" + @click="selectTemplate(template)" + > + <div class="template-import-item__head"> + <i class="template-import-item__icon" :class="template.icon"></i> + </div> + <div class="template-import-item__name">{{ template.name }}</div> + </a> + </div> + </div> + </div> +</template> + +<script> +import TemplateService from '@baserow/modules/core/services/template' +import { notifyIf } from '@baserow/modules/core/utils/error' +import { escapeRegExp } from '@baserow/modules/core/utils/string' + +export default { + name: 'TemplateImportForm', + data() { + return { + loading: true, + categories: [], + search: '', + selectedTemplate: 0, + } + }, + computed: { + templates() { + let allTemplates = [] + this.categories.forEach((category) => { + category.templates.forEach((template) => { + // Categories can have the same templates. We should not have duplicates. + if (allTemplates.findIndex((t) => t.id === template.id) === -1) { + allTemplates.push(template) + } + }) + }) + + // A few selected templates have the keyword `onboarding`. These are shown when + // no search query is provided. + const search = this.search || 'onboarding' + allTemplates = allTemplates + .filter((template) => { + // If `open_application == null`, then it falls back on the normal behavior, + // which is opening the first database. An `open_application` is typically + // set, if an application must be opened first. The onboarding experience + // works best by starting with a database, so we're filtering those out. + return template.open_application === null + }) + .filter((template) => { + const keywords = template.keywords.split(',') + keywords.push(template.name) + const regex = new RegExp('(' + escapeRegExp(search) + ')', 'i') + return keywords.some((value) => value.match(regex)) + }) + + return allTemplates.slice(0, 6) + }, + }, + async mounted() { + try { + const { data } = await TemplateService(this.$client).fetchAll() + this.categories = data + } catch (error) { + notifyIf(error, 'templates') + } + this.loading = false + }, + methods: { + selectTemplate(template) { + this.selectedTemplate = template.id + this.$emit('selected-template', template) + }, + }, +} +</script> diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json index b41c17676..b1f950a71 100644 --- a/web-frontend/modules/database/locales/en.json +++ b/web-frontend/modules/database/locales/en.json @@ -1001,11 +1001,12 @@ }, "databaseStep": { "title": "Create your first database", - "description": "Let us know what you're working on.", + "description": "Select what where you'd like to start from:", "databaseNameLabel": "Database name", - "scratch": "From scratch", - "import": "From file", - "airtable": "From Airtable" + "scratch": "Scratch", + "import": "File", + "airtable": "Airtable", + "template": "Template" }, "ViewFilterTypeDateUpgradeToMultiStep": { "migrateButtonText": "Migrate to multi-step date filter", diff --git a/web-frontend/modules/database/onboardingTypes.js b/web-frontend/modules/database/onboardingTypes.js index a6fc61d1e..bc87cbdb9 100644 --- a/web-frontend/modules/database/onboardingTypes.js +++ b/web-frontend/modules/database/onboardingTypes.js @@ -14,6 +14,8 @@ import FieldService from '@baserow/modules/database/services/field' import RowService from '@baserow/modules/database/services/row' import AirtableService from '@baserow/modules/database/services/airtable' import DatabaseScratchTrackFieldsStep from '@baserow/modules/database/components/onboarding/DatabaseScratchTrackFieldsStep.vue' +import DatabaseTemplatePreview from '@baserow/modules/database/components/onboarding/DatabaseTemplatePreview' +import TemplateService from '@baserow/modules/core/services/template' const databaseTypeCondition = (data, type) => { const dependingType = DatabaseOnboardingType.getType() @@ -52,8 +54,14 @@ export class DatabaseOnboardingType extends OnboardingType { return DatabaseStep } - getPreviewComponent() { - return DatabaseAppLayoutPreview + getPreviewComponent(data) { + const type = data[this.getType()]?.type + const template = data[this.getType()]?.template + if (type === 'template' && template) { + return DatabaseTemplatePreview + } else { + return DatabaseAppLayoutPreview + } } getAdditionalPreviewProps() { @@ -61,14 +69,15 @@ export class DatabaseOnboardingType extends OnboardingType { } async complete(data, responses) { - const type = data[this.getType()].type - if (type === 'airtable') { - const workspace = responses[WorkspaceOnboardingType.getType()] - const airtableUrl = data[this.getType()].airtableUrl - const skipFiles = data[this.getType()].skipFiles - const useSession = data[this.getType()].useSession - const session = data[this.getType()].session - const sessionSignature = data[this.getType()].sessionSignature + const workspace = responses[WorkspaceOnboardingType.getType()] + const stepData = data[this.getType()] + const fromType = stepData.type + if (fromType === 'airtable') { + const airtableUrl = stepData.airtableUrl + const skipFiles = stepData.skipFiles + const useSession = stepData.useSession + const session = stepData.session + const sessionSignature = stepData.sessionSignature const { data: job } = await AirtableService(this.app.$client).create( workspace.id, airtableUrl, @@ -77,6 +86,15 @@ export class DatabaseOnboardingType extends OnboardingType { useSession ? sessionSignature : null ) + // Responds with the newly created job, so that the `getJobForPolling` can use + // the response to mark the onboarding as an async job. + return job + } else if (fromType === 'template') { + const template = stepData.template + const { data: job } = await TemplateService( + this.app.$client + ).asyncInstall(workspace.id, template.id) + // Responds with the newly created job, so that the `getJobForPolling` can use // the response to mark the onboarding as an async job. return job @@ -85,15 +103,26 @@ export class DatabaseOnboardingType extends OnboardingType { getJobForPolling(data, responses) { const type = data[this.getType()].type - if (type === 'airtable') { + if (type === 'airtable' || type === 'template') { return responses[this.getType()] } } getCompletedRoute(data, responses) { const type = data[this.getType()].type + let database = null if (type === 'airtable') { - const database = responses[this.getType()].database + database = responses[this.getType()].database + } else if (type === 'template') { + database = responses[this.getType()].installed_applications.find( + (application) => application.type === DatabaseApplicationType.getType() + ) + } + + // Deliberately open the database first because that's where the user must start + // their journey. If no database exist, return nothing, so that the dashboard + // is opened. + if (database) { const firstTableId = database.tables[0]?.id || 0 return { name: 'database-table',