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',