Repository: chaoss/grimoirelab-sortinghat (language: python)

Issue:
[UI] Indentities importing scheduler
Design a form to import identities to SortingHat. The form should include an URL, an API token, the type of the backend, and the refreshing time when the task will run again.




Discussion / hints:

Fix patch:
diff --git a/ui/.storybook/preview.js b/ui/.storybook/preview.js
index 04aeed5c..15087f47 100644
--- a/ui/.storybook/preview.js
+++ b/ui/.storybook/preview.js
@@ -32,7 +32,9 @@ const VuetifyConfig = new Vuetify({
     themes: {
       light: {
         primary: "#003756",
-        secondary: "#f4bc00"
+        secondary: "#f4bc00",
+        finished: "#3fa500",
+        failed: "#f41900"
       }
     }
   }
diff --git a/ui/src/App.vue b/ui/src/App.vue
index c2559110..5d4705dc 100644
--- a/ui/src/App.vue
+++ b/ui/src/App.vue
@@ -24,6 +24,12 @@
             <v-list-item-title>Dashboard</v-list-item-title>
           </v-list-item>
           <v-divider />
+          <v-list-item to="/import-identities">
+            <v-list-item-icon class="mr-2">
+              <v-icon small>mdi-account-sync</v-icon>
+            </v-list-item-icon>
+            <v-list-item-title>Import identities</v-list-item-title>
+          </v-list-item>
           <v-list-item to="/jobs">
             <v-list-item-icon class="mr-2">
               <v-icon small>mdi-tray-full</v-icon>
diff --git a/ui/src/apollo/mutations.js b/ui/src/apollo/mutations.js
index 460a02f3..744d3220 100644
--- a/ui/src/apollo/mutations.js
+++ b/ui/src/apollo/mutations.js
@@ -312,6 +312,48 @@ const RECOMMEND_MATCHES = gql`
   }
 `;
 
+const IMPORT_IDENTITIES = gql`
+  mutation importIdentities(
+    $backend: String!
+    $interval: Int
+    $params: JSONString
+    $url: String
+  ) {
+    addImportIdentitiesTask(
+      backend: $backend
+      interval: $interval
+      params: $params
+      url: $url
+    ) {
+      task {
+        id
+      }
+    }
+  }
+`;
+
+const DELETE_IMPORT_TASK = gql`
+  mutation deleteImportTask($taskId: Int!) {
+    deleteImportIdentitiesTask(taskId: $taskId) {
+      deleted
+    }
+  }
+`;
+
+const UPDATE_IMPORT_TASK = gql`
+  mutation updateImportTask(
+    $data: ImportIdentitiesTaskInputType
+    $taskId: Int!
+  ) {
+    updateImportIdentitiesTask(data: $data, taskId: $taskId) {
+      task {
+        id
+        lastModified
+      }
+    }
+  }
+`;
+
 const tokenAuth = (apollo, username, password) => {
   const response = apollo.mutate({
     mutation: TOKEN_AUTH,
@@ -564,6 +606,35 @@ const recommendMatches = (apollo, criteria, exclude) => {
   });
 };
 
+const importIdentities = (apollo, backend, interval, params, url) => {
+  return apollo.mutate({
+    mutation: IMPORT_IDENTITIES,
+    variables: {
+      backend: backend,
+      interval: interval,
+      params: params,
+      url: url,
+    },
+  });
+};
+
+const deleteImportTask = (apollo, taskId) => {
+  return apollo.mutate({
+    mutation: DELETE_IMPORT_TASK,
+    variables: { taskId },
+  });
+};
+
+const updateImportTask = (apollo, taskId, data) => {
+  return apollo.mutate({
+    mutation: UPDATE_IMPORT_TASK,
+    variables: {
+      taskId: taskId,
+      data: data,
+    },
+  });
+};
+
 export {
   tokenAuth,
   lockIndividual,
@@ -588,4 +659,7 @@ export {
   unify,
   manageMergeRecommendation,
   recommendMatches,
+  importIdentities,
+  deleteImportTask,
+  updateImportTask,
 };
diff --git a/ui/src/apollo/queries.js b/ui/src/apollo/queries.js
index b0e6d3ad..892a678c 100644
--- a/ui/src/apollo/queries.js
+++ b/ui/src/apollo/queries.js
@@ -232,6 +232,39 @@ const GET_PAGINATED_RECOMMENDED_MERGE = gql`
   ${FULL_INDIVIDUAL}
 `;
 
+const GET_IMPORTERS = gql`
+  query getImporters {
+    identitiesImportersTypes {
+      name
+      args
+    }
+  }
+`;
+
+const GET_IMPORT_IDENTITIES_TASKS = gql`
+  query getImportIdentitiesTasks(
+    $page: Int
+    $pageSize: Int
+    $filters: ImporterTasksFilterType
+  ) {
+    importIdentitiesTask(page: $page, pageSize: $pageSize, filters: $filters) {
+      entities {
+        id
+        backend
+        url
+        interval
+        args
+        jobId
+        lastExecution
+        scheduledDatetime
+        failures
+        executions
+        failed
+      }
+    }
+  }
+`;
+
 const getIndividualByUuid = (apollo, uuid) => {
   let response = apollo.query({
     query: GET_INDIVIDUAL_BYUUID,
@@ -372,6 +405,24 @@ const getPaginatedMergeRecommendations = (apollo, page, pageSize) => {
   });
 };
 
+const getImporterTypes = (apollo) => {
+  return apollo.query({
+    query: GET_IMPORTERS,
+  });
+};
+
+const getImportIdentitiesTasks = (apollo, page, pageSize, filters) => {
+  return apollo.query({
+    query: GET_IMPORT_IDENTITIES_TASKS,
+    variables: {
+      page: page,
+      pageSize: pageSize,
+      filters: filters,
+    },
+    fetchPolicy: "no-cache",
+  });
+};
+
 export {
   getCountries,
   getIndividuals,
@@ -384,4 +435,6 @@ export {
   getGroups,
   getRecommendedMergesCount,
   getPaginatedMergeRecommendations,
+  getImporterTypes,
+  getImportIdentitiesTasks,
 };
diff --git a/ui/src/components/ImporterModal.stories.js b/ui/src/components/ImporterModal.stories.js
new file mode 100644
index 00000000..38a6a617
--- /dev/null
+++ b/ui/src/components/ImporterModal.stories.js
@@ -0,0 +1,55 @@
+import ImporterModal from "./ImporterModal.vue";
+
+export default {
+  title: "ImporterModal",
+  excludeStories: /.*Data$/,
+};
+
+const template = `
+<div class="ma-auto">
+  <v-btn color="primary" dark @click.stop="modal.isOpen = true">
+    Open modal
+  </v-btn>
+  <importer-modal
+    :is-open="modal.isOpen"
+    :create-task="() => {}"
+    :edit-task="() => {}"
+    :task="modal.task"
+    :importer="modal.importer"
+  />
+</div>
+`;
+
+export const Default = () => ({
+  components: { ImporterModal },
+  template: template,
+  data: () => ({
+    modal: {
+      isOpen: false,
+      task: {},
+      importer: {
+        args: ["url", "token"],
+        name: "Backend",
+      },
+    },
+  }),
+});
+
+export const EditTask = () => ({
+  components: { ImporterModal },
+  template: template,
+  data: () => ({
+    modal: {
+      isOpen: false,
+      task: {
+        url: "http://test.com",
+        interval: 45,
+        id: 1,
+      },
+      importer: {
+        args: ["url", "token"],
+        name: "Backend",
+      },
+    },
+  }),
+});
diff --git a/ui/src/components/ImporterModal.vue b/ui/src/components/ImporterModal.vue
new file mode 100644
index 00000000..20853181
--- /dev/null
+++ b/ui/src/components/ImporterModal.vue
@@ -0,0 +1,183 @@
+<template>
+  <v-dialog
+    v-model="isOpen"
+    max-width="450"
+    @click:outside="$emit('update:open')"
+  >
+    <v-card class="section">
+      <v-card-title class="header title">
+        Import identities from {{ importer.name }}
+      </v-card-title>
+      <v-card-text class="mt-3">
+        <h3 class="subheader mb-2">Settings</h3>
+        <v-row class="pa-3">
+          <v-text-field
+            v-model="form.url"
+            label="URL"
+            dense
+            hide-details
+            outlined
+          />
+        </v-row>
+        <v-row v-for="(value, field) in form.fields" :key="field" class="pa-3">
+          <v-text-field
+            v-model="form.fields[field]"
+            :label="field"
+            dense
+            hide-details
+            outlined
+          />
+        </v-row>
+        <v-row>
+          <v-col cols="6">
+            <v-radio-group v-model="form.interval" class="mt-0" dense>
+              <template v-slot:label>
+                <h3 class="subheader">Run script</h3>
+              </template>
+              <v-radio :value="0" label="Once"></v-radio>
+              <v-radio :value="10080" label="Every week"></v-radio>
+              <v-radio :value="43800" label="Every month"></v-radio>
+              <v-radio value="custom" label="Custom"></v-radio>
+            </v-radio-group>
+            <v-text-field
+              v-model="form.customInterval"
+              label="Every"
+              type="number"
+              suffix="minutes"
+              class="ml-8"
+              dense
+              hide-details
+              outlined
+            >
+            </v-text-field>
+          </v-col>
+        </v-row>
+
+        <v-alert v-if="error" text type="error" class="ma-5">
+          {{ error }}
+        </v-alert>
+      </v-card-text>
+
+      <v-card-actions>
+        <v-spacer></v-spacer>
+        <v-btn color="primary" text @click="onClose"> Cancel </v-btn>
+
+        <v-btn color="primary" depressed :disabled="!form.url" @click="onSave">
+          Save
+        </v-btn>
+      </v-card-actions>
+    </v-card>
+  </v-dialog>
+</template>
+<script>
+export default {
+  name: "ImporterModal",
+  props: {
+    importer: {
+      type: Object,
+      required: true,
+    },
+    task: {
+      type: Object,
+      required: true,
+    },
+    createTask: {
+      type: Function,
+      required: true,
+    },
+    editTask: {
+      type: Function,
+      required: true,
+    },
+    isOpen: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      form: {
+        fields: {},
+        interval: this.task.interval || 0,
+        customInterval: "",
+        url: "",
+      },
+      error: null,
+    };
+  },
+  methods: {
+    onSave() {
+      if (this.task.id) {
+        this.updateTask();
+      } else {
+        this.addTask();
+      }
+    },
+    onClose() {
+      this.$emit("update:open", false);
+    },
+    async addTask() {
+      try {
+        const interval =
+          this.form.interval === "custom"
+            ? this.form.customInterval
+            : this.form.interval;
+        const params = JSON.stringify(this.form.fields);
+        const response = await this.createTask(
+          this.importer.name,
+          interval,
+          params,
+          this.form.url
+        );
+        if (response && !response.errors) {
+          this.$emit("update:open", false);
+          this.$emit("update:tasks");
+        }
+      } catch (error) {
+        this.error = this.$getErrorMessage(error);
+      }
+    },
+    async updateTask() {
+      try {
+        const interval =
+          this.form.interval === "custom"
+            ? this.form.customInterval
+            : this.form.interval;
+        const data = {
+          url: this.form.url,
+          interval: interval,
+          params: JSON.stringify(this.form.fields),
+        };
+        const response = await this.editTask(this.task.id, data);
+        if (response && !response.errors) {
+          this.$emit("update:open", false);
+          this.$emit("update:tasks");
+        }
+      } catch (error) {
+        this.error = this.$getErrorMessage(error);
+      }
+    },
+    buildForm() {
+      const intervals = [0, 10080, 43800];
+      const taskArgs = this.task.args || {};
+      const fields = this.importer.args.reduce(
+        (accumulator, arg) => ({ ...accumulator, [arg]: taskArgs[arg] }),
+        {}
+      );
+      const { url, ...rest } = fields;
+      this.form.url = url || this.task.url;
+      this.form.fields = rest;
+      if (
+        this.task.interval &&
+        !intervals.find((interval) => interval === this.task.interval)
+      ) {
+        this.form.customInterval = this.task.interval;
+        this.form.interval = "custom";
+      }
+    },
+  },
+  mounted() {
+    this.buildForm();
+  },
+};
+</script>
diff --git a/ui/src/components/TasksTable.stories.js b/ui/src/components/TasksTable.stories.js
new file mode 100644
index 00000000..8e7103e0
--- /dev/null
+++ b/ui/src/components/TasksTable.stories.js
@@ -0,0 +1,115 @@
+import TasksTable from "./TasksTable.vue";
+
+export default {
+  title: "TasksTable",
+  excludeStories: /.*Data$/,
+};
+
+const template = `
+  <tasks-table
+    :fetch-backends="fetchBackends"
+    :fetch-tasks="fetchTasks"
+    :create-task="() => {}"
+    :delete-task="deleteTask"
+    :edit-task="() => {}"
+  />
+`;
+
+export const Default = () => ({
+  components: { TasksTable },
+  template: template,
+  data: () => ({
+    backends: {
+      data: {
+        identitiesImportersTypes: [
+          { name: "mailmap", args: ["url"] },
+          { name: "external plugin", args: ["url", "token"] },
+        ],
+      },
+    },
+    tasks: {
+      data: {
+        importIdentitiesTask: {
+          entities: [
+            {
+              id: 1,
+              backend: "mailmap",
+              url: "http://test.com",
+              interval: 43800,
+              lastExecution: "2023-03-14T14:21:10.041445+00:00",
+              failed: true,
+              scheduledDatetime: "2023-03-14T14:56:10.041450+00:00",
+              failures: 1,
+              executions: 22,
+            },
+            {
+              id: 2,
+              backend: "external plugin",
+              url: "http://test.com",
+              args: JSON.stringify({ token: "XXXX" }),
+              interval: 0,
+              lastExecution: "2023-03-12T11:01:40.041445+00:00",
+              failed: false,
+              failures: 0,
+              executions: 1,
+            },
+          ],
+        },
+      },
+    },
+  }),
+  methods: {
+    fetchBackends() {
+      return this.backends;
+    },
+    fetchTasks() {
+      return this.tasks;
+    },
+    deleteTask(id) {
+      this.tasks.data.importIdentitiesTask.entities =
+        this.tasks.data.importIdentitiesTask.entities.filter(
+          (task) => task.id !== id
+        );
+      return {
+        data: {
+          deleteImportIdentitiesTask: {
+            deleted: true,
+          },
+        },
+      };
+    },
+  },
+});
+
+export const Empty = () => ({
+  components: { TasksTable },
+  template: template,
+  data: () => ({
+    backends: {
+      data: {
+        identitiesImportersTypes: [
+          { name: "mailmap", args: ["url"] },
+          { name: "external plugin", args: ["url", "token"] },
+        ],
+      },
+    },
+    tasks: {
+      data: {
+        importIdentitiesTask: {
+          entities: [],
+        },
+      },
+    },
+  }),
+  methods: {
+    fetchBackends() {
+      return this.backends;
+    },
+    fetchTasks() {
+      return this.tasks;
+    },
+    deleteTask() {
+      return;
+    },
+  },
+});
diff --git a/ui/src/components/TasksTable.vue b/ui/src/components/TasksTable.vue
new file mode 100644
index 00000000..44000b2a
--- /dev/null
+++ b/ui/src/components/TasksTable.vue
@@ -0,0 +1,207 @@
+<template>
+  <v-container class="section jobs mt-6 pa-0">
+    <header class="header">
+      <h1 class="title">Import identities</h1>
+      <v-menu offset-y>
+        <template v-slot:activator="{ on }">
+          <v-btn color="primary" depressed small v-on="on">
+            Select source
+            <v-icon color="white" small right>mdi-chevron-down</v-icon>
+          </v-btn>
+        </template>
+        <v-list v-if="importers" dense>
+          <v-list-item
+            v-for="(backend, index) in importers"
+            :key="index"
+            @click="openModal(backend)"
+          >
+            <v-list-item-title>
+              {{ backend.name }}
+            </v-list-item-title>
+          </v-list-item>
+        </v-list>
+      </v-menu>
+    </header>
+    <v-alert v-if="error" text type="error">
+      {{ error }}
+    </v-alert>
+    <v-progress-linear
+      v-if="isLoading"
+      indeterminate
+      color="primary"
+    ></v-progress-linear>
+    <v-simple-table>
+      <template v-slot:default>
+        <thead v-if="tasks.length > 0">
+          <tr>
+            <th class="text-left">Source</th>
+            <th class="text-left">Last run</th>
+            <th class="text-left">Next run</th>
+            <th class="text-right">Executions</th>
+            <th class="text-right">Failures</th>
+            <th></th>
+          </tr>
+        </thead>
+        <div v-else class="ma-9">
+          <h3 class="text-h6">No scheduled tasks</h3>
+          <p class="text-body-2">
+            You can sync identities from different data sources automatically
+            and periodically.
+          </p>
+        </div>
+        <tbody>
+          <tr v-for="task in tasks" :key="task.id">
+            <td>
+              <span class="font-weight-medium">{{ task.backend }}</span>
+            </td>
+            <td>
+              <v-icon
+                v-if="task.lastExecution"
+                :color="task.failed ? 'failed' : 'finished'"
+                :aria-label="task.failed ? 'failed' : 'finished'"
+                aria-hidden="false"
+                class="mb-1 mr-1"
+                small
+              >
+                {{ task.failed ? "mdi-alert-circle" : "mdi-check-circle" }}
+              </v-icon>
+              {{ formatDate(task.lastExecution) }}
+            </td>
+            <td>{{ formatDate(task.scheduledDatetime) }}</td>
+            <td class="text-right">{{ task.executions }}</td>
+            <td class="text-right">{{ task.failures }}</td>
+            <td class="text-right pr-8">
+              <v-btn
+                class="mr-2"
+                color="primary"
+                text
+                small
+                outlined
+                @click="openModal(task.backend, task)"
+              >
+                <v-icon small>mdi-pencil</v-icon>
+                <span class="d-sr-only">Edit task</span>
+              </v-btn>
+              <v-btn
+                color="primary"
+                text
+                small
+                outlined
+                @click="deleteItem(task.id)"
+              >
+                <v-icon small>mdi-delete</v-icon>
+                <span class="d-sr-only">Delete task</span>
+              </v-btn>
+            </td>
+          </tr>
+        </tbody>
+      </template>
+    </v-simple-table>
+    <importer-modal
+      v-if="modal.isOpen"
+      :is-open="modal.isOpen"
+      :create-task="createTask"
+      :edit-task="editTask"
+      :task="modal.task"
+      :importer="modal.importer"
+      @update:open="modal.isOpen = $event"
+      @update:tasks="getTasks()"
+    />
+  </v-container>
+</template>
+<script>
+import ImporterModal from "./ImporterModal";
+export default {
+  name: "TasksTable",
+  components: { ImporterModal },
+  props: {
+    fetchBackends: {
+      type: Function,
+      required: true,
+    },
+    fetchTasks: {
+      type: Function,
+      required: true,
+    },
+    createTask: {
+      type: Function,
+      required: true,
+    },
+    deleteTask: {
+      type: Function,
+      required: true,
+    },
+    editTask: {
+      type: Function,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      importers: [],
+      tasks: [],
+      error: null,
+      isLoading: false,
+      modal: {
+        isOpen: false,
+        task: {},
+        importer: {},
+      },
+    };
+  },
+  methods: {
+    formatDate(dateTime) {
+      if (!dateTime) {
+        return "-";
+      }
+      return new Date(dateTime).toLocaleString();
+    },
+    openModal(backend, task = {}) {
+      if (typeof backend === "string") {
+        backend = this.importers.find((importer) => importer.name === backend);
+      }
+      Object.assign(this.modal, {
+        isOpen: true,
+        importer: backend,
+        task,
+      });
+    },
+    async getBackends() {
+      try {
+        const response = await this.fetchBackends();
+        this.importers = response.data.identitiesImportersTypes;
+      } catch (error) {
+        this.error = this.$getErrorMessage(error);
+      }
+    },
+    async getTasks() {
+      try {
+        this.isLoading = true;
+        const response = await this.fetchTasks();
+        this.tasks = response.data.importIdentitiesTask.entities;
+      } catch (error) {
+        this.error = this.$getErrorMessage(error);
+      } finally {
+        this.isLoading = false;
+      }
+    },
+    async deleteItem(id) {
+      try {
+        const response = await this.deleteTask(id);
+        if (response.data.deleteImportIdentitiesTask) {
+          this.getTasks();
+        }
+      } catch (error) {
+        this.error = this.$getErrorMessage(error);
+      }
+    },
+  },
+  async mounted() {
+    this.getTasks();
+    await this.getBackends();
+  },
+};
+</script>
+<style lang="scss" scoped>
+@import "../styles/index.scss";
+</style>
diff --git a/ui/src/plugins/vuetify.js b/ui/src/plugins/vuetify.js
index 23d1f6fc..0d2b2d3e 100644
--- a/ui/src/plugins/vuetify.js
+++ b/ui/src/plugins/vuetify.js
@@ -13,6 +13,8 @@ const opts = {
       light: {
         primary: "#003756",
         secondary: "#f4bc00",
+        finished: "#3fa500",
+        failed: "#f41900",
       },
     },
   },
diff --git a/ui/src/router/index.js b/ui/src/router/index.js
index f757e4c7..d7d1f5b6 100644
--- a/ui/src/router/index.js
+++ b/ui/src/router/index.js
@@ -38,6 +38,12 @@ const routes = [
     component: () => import("../views/Jobs"),
     meta: { title: "Jobs - Sorting Hat" },
   },
+  {
+    path: "/import-identities",
+    name: "ImportIdentities",
+    component: () => import("../views/ImportIdentities"),
+    meta: { requiresAuth: true, title: "Import identities - Sorting Hat" },
+  },
 ];
 
 const router = new Router({
diff --git a/ui/src/views/ImportIdentities.vue b/ui/src/views/ImportIdentities.vue
new file mode 100644
index 00000000..5397acff
--- /dev/null
+++ b/ui/src/views/ImportIdentities.vue
@@ -0,0 +1,61 @@
+<template>
+  <v-main>
+    <tasks-table
+      :fetch-backends="fetchBackends"
+      :fetch-tasks="fetchTasks"
+      :create-task="createTask"
+      :delete-task="deleteTask"
+      :edit-task="editTask"
+    />
+  </v-main>
+</template>
+<script>
+import {
+  getImporterTypes,
+  getImportIdentitiesTasks,
+} from "./../apollo/queries";
+import {
+  importIdentities,
+  deleteImportTask,
+  updateImportTask,
+} from "./../apollo/mutations";
+import TasksTable from "./../components/TasksTable";
+
+export default {
+  name: "ImportIdentities",
+  components: { TasksTable },
+  methods: {
+    async fetchBackends() {
+      const response = await getImporterTypes(this.$apollo);
+      return response;
+    },
+    async fetchTasks() {
+      const response = await getImportIdentitiesTasks(this.$apollo);
+      return response;
+    },
+    async createTask(backend, interval, params, url) {
+      const response = await importIdentities(
+        this.$apollo,
+        backend,
+        interval,
+        params,
+        url
+      );
+      return response;
+    },
+    async deleteTask(id) {
+      const response = await deleteImportTask(this.$apollo, id);
+      return response;
+    },
+    async editTask(id, data) {
+      const response = await updateImportTask(this.$apollo, id, data);
+      return response;
+    },
+  },
+};
+</script>
+<style lang="scss" scoped>
+.container {
+  max-width: 1160px;
+}
+</style>


Test patch:
diff --git a/ui/tests/unit/__snapshots__/storybook.spec.js.snap b/ui/tests/unit/__snapshots__/storybook.spec.js.snap
index 15cf6ca3..c5ca03c2 100644
--- a/ui/tests/unit/__snapshots__/storybook.spec.js.snap
+++ b/ui/tests/unit/__snapshots__/storybook.spec.js.snap
@@ -3660,6 +3660,76 @@ exports[`Storyshots Identity Source 1`] = `
 </div>
 `;
 
+exports[`Storyshots ImporterModal Default 1`] = `
+<div
+  class="v-application v-application--is-ltr theme--light"
+  data-app="true"
+  id="app"
+>
+  <div
+    class="v-application--wrap"
+  >
+    <div
+      class="ma-auto"
+    >
+      <button
+        class="v-btn v-btn--is-elevated v-btn--has-bg theme--dark v-size--default primary"
+        type="button"
+      >
+        <span
+          class="v-btn__content"
+        >
+          
+    Open modal
+  
+        </span>
+      </button>
+       
+      <div
+        class="v-dialog__container"
+      >
+        <!---->
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Storyshots ImporterModal Edit Task 1`] = `
+<div
+  class="v-application v-application--is-ltr theme--light"
+  data-app="true"
+  id="app"
+>
+  <div
+    class="v-application--wrap"
+  >
+    <div
+      class="ma-auto"
+    >
+      <button
+        class="v-btn v-btn--is-elevated v-btn--has-bg theme--dark v-size--default primary"
+        type="button"
+      >
+        <span
+          class="v-btn__content"
+        >
+          
+    Open modal
+  
+        </span>
+      </button>
+       
+      <div
+        class="v-dialog__container"
+      >
+        <!---->
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
 exports[`Storyshots IndividualCard Closable 1`] = `
 <div
   class="v-application v-application--is-ltr theme--light"
@@ -7685,7 +7755,7 @@ exports[`Storyshots IndividualsTable Default 1`] = `
                 />
                 <input
                   aria-checked="false"
-                  id="input-977"
+                  id="input-991"
                   role="checkbox"
                   type="checkbox"
                   value=""
@@ -7696,7 +7766,7 @@ exports[`Storyshots IndividualsTable Default 1`] = `
               </div>
               <label
                 class="v-label theme--light"
-                for="input-977"
+                for="input-991"
                 style="left: 0px; position: relative;"
               >
                 Select all
@@ -7784,13 +7854,13 @@ exports[`Storyshots IndividualsTable Default 1`] = `
                 >
                   <label
                     class="v-label theme--light"
-                    for="input-982"
+                    for="input-996"
                     style="left: 0px; position: absolute;"
                   >
                     Search
                   </label>
                   <input
-                    id="input-982"
+                    id="input-996"
                     type="text"
                   />
                 </div>
@@ -7853,7 +7923,7 @@ exports[`Storyshots IndividualsTable Default 1`] = `
               <div
                 aria-expanded="false"
                 aria-haspopup="listbox"
-                aria-owns="list-989"
+                aria-owns="list-1003"
                 class="v-input__slot"
                 role="button"
               >
@@ -7873,7 +7943,7 @@ exports[`Storyshots IndividualsTable Default 1`] = `
                 >
                   <label
                     class="v-label theme--light"
-                    for="input-989"
+                    for="input-1003"
                     style="left: 0px; position: absolute;"
                   >
                     Order by
@@ -7884,7 +7954,7 @@ exports[`Storyshots IndividualsTable Default 1`] = `
                     <input
                       aria-readonly="false"
                       autocomplete="off"
-                      id="input-989"
+                      id="input-1003"
                       readonly="readonly"
                       type="text"
                     />
@@ -8075,13 +8145,13 @@ exports[`Storyshots IndividualsTable Default 1`] = `
                 >
                   <label
                     class="v-label v-label--active theme--light"
-                    for="input-1009"
+                    for="input-1023"
                     style="left: 0px; position: absolute;"
                   >
                     Items per page
                   </label>
                   <input
-                    id="input-1009"
+                    id="input-1023"
                     max="0"
                     min="1"
                     type="number"
@@ -8611,7 +8681,7 @@ exports[`Storyshots OrganizationSelector Default 1`] = `
           <div
             aria-expanded="false"
             aria-haspopup="listbox"
-            aria-owns="list-1228"
+            aria-owns="list-1242"
             class="v-input__slot"
             role="combobox"
           >
@@ -8633,14 +8703,14 @@ exports[`Storyshots OrganizationSelector Default 1`] = `
             >
               <label
                 class="v-label theme--light"
-                for="input-1228"
+                for="input-1242"
                 style="left: 0px; position: absolute;"
               >
                 Organization
               </label>
               <input
                 autocomplete="off"
-                id="input-1228"
+                id="input-1242"
                 type="text"
               />
               <div
@@ -8709,7 +8779,7 @@ exports[`Storyshots OrganizationSelector Selected Organization 1`] = `
           <div
             aria-expanded="false"
             aria-haspopup="listbox"
-            aria-owns="list-1238"
+            aria-owns="list-1252"
             class="v-input__slot"
             role="combobox"
           >
@@ -8731,14 +8801,14 @@ exports[`Storyshots OrganizationSelector Selected Organization 1`] = `
             >
               <label
                 class="v-label v-label--active theme--light"
-                for="input-1238"
+                for="input-1252"
                 style="left: 0px; position: absolute;"
               >
                 Organization
               </label>
               <input
                 autocomplete="off"
-                id="input-1238"
+                id="input-1252"
                 type="text"
               />
               <div
@@ -8905,13 +8975,13 @@ exports[`Storyshots OrganizationsTable Groups 1`] = `
                 >
                   <label
                     class="v-label theme--light"
-                    for="input-1256"
+                    for="input-1270"
                     style="left: 0px; position: absolute;"
                   >
                     Search
                   </label>
                   <input
-                    id="input-1256"
+                    id="input-1270"
                     type="text"
                   />
                 </div>
@@ -9035,13 +9105,13 @@ exports[`Storyshots OrganizationsTable Groups 1`] = `
               >
                 <label
                   class="v-label v-label--active theme--light"
-                  for="input-1266"
+                  for="input-1280"
                   style="left: 0px; position: absolute;"
                 >
                   Items per page
                 </label>
                 <input
-                  id="input-1266"
+                  id="input-1280"
                   max="0"
                   min="1"
                   type="number"
@@ -9180,13 +9250,13 @@ exports[`Storyshots OrganizationsTable Organizations 1`] = `
                 >
                   <label
                     class="v-label theme--light"
-                    for="input-1302"
+                    for="input-1316"
                     style="left: 0px; position: absolute;"
                   >
                     Search
                   </label>
                   <input
-                    id="input-1302"
+                    id="input-1316"
                     type="text"
                   />
                 </div>
@@ -9310,13 +9380,13 @@ exports[`Storyshots OrganizationsTable Organizations 1`] = `
               >
                 <label
                   class="v-label v-label--active theme--light"
-                  for="input-1312"
+                  for="input-1326"
                   style="left: 0px; position: absolute;"
                 >
                   Items per page
                 </label>
                 <input
-                  id="input-1312"
+                  id="input-1326"
                   max="0"
                   min="1"
                   type="number"
@@ -9704,13 +9774,13 @@ exports[`Storyshots Search Default 1`] = `
               >
                 <label
                   class="v-label theme--light"
-                  for="input-1403"
+                  for="input-1417"
                   style="left: 0px; position: absolute;"
                 >
                   Search
                 </label>
                 <input
-                  id="input-1403"
+                  id="input-1417"
                   type="text"
                 />
               </div>
@@ -9831,13 +9901,13 @@ exports[`Storyshots Search Filter Selector 1`] = `
               >
                 <label
                   class="v-label theme--light"
-                  for="input-1412"
+                  for="input-1426"
                   style="left: 0px; position: absolute;"
                 >
                   Search
                 </label>
                 <input
-                  id="input-1412"
+                  id="input-1426"
                   type="text"
                 />
               </div>
@@ -9928,13 +9998,13 @@ exports[`Storyshots Search Order Selector 1`] = `
               >
                 <label
                   class="v-label theme--light"
-                  for="input-1424"
+                  for="input-1438"
                   style="left: 0px; position: absolute;"
                 >
                   Search
                 </label>
                 <input
-                  id="input-1424"
+                  id="input-1438"
                   type="text"
                 />
               </div>
@@ -9997,7 +10067,7 @@ exports[`Storyshots Search Order Selector 1`] = `
             <div
               aria-expanded="false"
               aria-haspopup="listbox"
-              aria-owns="list-1428"
+              aria-owns="list-1442"
               class="v-input__slot"
               role="button"
             >
@@ -10017,7 +10087,7 @@ exports[`Storyshots Search Order Selector 1`] = `
               >
                 <label
                   class="v-label theme--light"
-                  for="input-1428"
+                  for="input-1442"
                   style="left: 0px; position: absolute;"
                 >
                   Order by
@@ -10028,7 +10098,7 @@ exports[`Storyshots Search Order Selector 1`] = `
                   <input
                     aria-readonly="false"
                     autocomplete="off"
-                    id="input-1428"
+                    id="input-1442"
                     readonly="readonly"
                     type="text"
                   />
@@ -10074,6 +10144,178 @@ exports[`Storyshots Search Order Selector 1`] = `
 </div>
 `;
 
+exports[`Storyshots TasksTable Default 1`] = `
+<div
+  class="v-application v-application--is-ltr theme--light"
+  data-app="true"
+  id="app"
+>
+  <div
+    class="v-application--wrap"
+  >
+    <div
+      class="container section jobs mt-6 pa-0"
+    >
+      <header
+        class="header"
+      >
+        <h1
+          class="title"
+        >
+          Import identities
+        </h1>
+         
+        <div
+          class="v-menu"
+        >
+          <button
+            class="v-btn v-btn--has-bg theme--light v-size--small primary"
+            type="button"
+          >
+            <span
+              class="v-btn__content"
+            >
+              
+          Select source
+          
+              <i
+                aria-hidden="true"
+                class="v-icon notranslate v-icon--right mdi mdi-chevron-down theme--light white--text"
+                style="font-size: 16px;"
+              />
+            </span>
+          </button>
+          <!---->
+        </div>
+      </header>
+       
+      <!---->
+       
+      <!---->
+       
+      <div
+        class="v-data-table theme--light"
+      >
+        <div
+          class="v-data-table__wrapper"
+        >
+          <table>
+            <div
+              class="ma-9"
+            >
+              <h3
+                class="text-h6"
+              >
+                No scheduled tasks
+              </h3>
+               
+              <p
+                class="text-body-2"
+              >
+                
+          You can sync identities from different data sources automatically
+          and periodically.
+        
+              </p>
+            </div>
+             
+            <tbody />
+          </table>
+        </div>
+      </div>
+       
+      <!---->
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Storyshots TasksTable Empty 1`] = `
+<div
+  class="v-application v-application--is-ltr theme--light"
+  data-app="true"
+  id="app"
+>
+  <div
+    class="v-application--wrap"
+  >
+    <div
+      class="container section jobs mt-6 pa-0"
+    >
+      <header
+        class="header"
+      >
+        <h1
+          class="title"
+        >
+          Import identities
+        </h1>
+         
+        <div
+          class="v-menu"
+        >
+          <button
+            class="v-btn v-btn--has-bg theme--light v-size--small primary"
+            type="button"
+          >
+            <span
+              class="v-btn__content"
+            >
+              
+          Select source
+          
+              <i
+                aria-hidden="true"
+                class="v-icon notranslate v-icon--right mdi mdi-chevron-down theme--light white--text"
+                style="font-size: 16px;"
+              />
+            </span>
+          </button>
+          <!---->
+        </div>
+      </header>
+       
+      <!---->
+       
+      <!---->
+       
+      <div
+        class="v-data-table theme--light"
+      >
+        <div
+          class="v-data-table__wrapper"
+        >
+          <table>
+            <div
+              class="ma-9"
+            >
+              <h3
+                class="text-h6"
+              >
+                No scheduled tasks
+              </h3>
+               
+              <p
+                class="text-body-2"
+              >
+                
+          You can sync identities from different data sources automatically
+          and periodically.
+        
+              </p>
+            </div>
+             
+            <tbody />
+          </table>
+        </div>
+      </div>
+       
+      <!---->
+    </div>
+  </div>
+</div>
+`;
+
 exports[`Storyshots TeamEnrollModal Default 1`] = `
 <div
   class="v-application v-application--is-ltr theme--light"
@@ -10744,7 +10986,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                   />
                   <input
                     aria-checked="false"
-                    id="input-1529"
+                    id="input-1575"
                     role="checkbox"
                     type="checkbox"
                     value=""
@@ -10755,7 +10997,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                 </div>
                 <label
                   class="v-label theme--light"
-                  for="input-1529"
+                  for="input-1575"
                   style="left: 0px; position: relative;"
                 >
                   Select all
@@ -10843,13 +11085,13 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                   >
                     <label
                       class="v-label theme--light"
-                      for="input-1534"
+                      for="input-1580"
                       style="left: 0px; position: absolute;"
                     >
                       Search
                     </label>
                     <input
-                      id="input-1534"
+                      id="input-1580"
                       type="text"
                     />
                   </div>
@@ -10912,7 +11154,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                 <div
                   aria-expanded="false"
                   aria-haspopup="listbox"
-                  aria-owns="list-1541"
+                  aria-owns="list-1587"
                   class="v-input__slot"
                   role="button"
                 >
@@ -10932,7 +11174,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                   >
                     <label
                       class="v-label theme--light"
-                      for="input-1541"
+                      for="input-1587"
                       style="left: 0px; position: absolute;"
                     >
                       Order by
@@ -10943,7 +11185,7 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                       <input
                         aria-readonly="false"
                         autocomplete="off"
-                        id="input-1541"
+                        id="input-1587"
                         readonly="readonly"
                         type="text"
                       />
@@ -11134,13 +11376,13 @@ exports[`Storyshots WorkSpace Drag And Drop 1`] = `
                   >
                     <label
                       class="v-label v-label--active theme--light"
-                      for="input-1561"
+                      for="input-1607"
                       style="left: 0px; position: absolute;"
                     >
                       Items per page
                     </label>
                     <input
-                      id="input-1561"
+                      id="input-1607"
                       max="0"
                       min="1"
                       type="number"