Browse Source

Merge pull request #15 from Kholid060/dev

v0.3.0
Ahmad Kholid 3 years ago
parent
commit
886433ae39

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.2.0",
+  "version": "0.3.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -28,6 +28,7 @@
     "drawflow": "^0.0.51",
     "mousetrap": "^1.6.5",
     "nanoid": "3.1.28",
+    "object-path": "^0.11.8",
     "papaparse": "^5.3.1",
     "prismjs": "^1.25.0",
     "tiny-emitter": "^2.1.0",

+ 13 - 0
src/assets/css/prism-editor.css

@@ -0,0 +1,13 @@
+.my-editor,
+.prism-editor-wrapper {
+  color: #ccc;
+  font-family: JetBrains Mono, Fira code, Fira Mono, Consolas, Menlo, Courier,
+    monospace;
+  font-size: 14px;
+  line-height: 1.5;
+  padding: 5px;
+  @apply bg-gray-900 rounded-lg;
+}
+.prism-editor__textarea:focus {
+  outline: none;
+}

+ 49 - 10
src/background/blocks-handler.js

@@ -1,6 +1,6 @@
 /* eslint-disable no-underscore-dangle */
 import browser from 'webextension-polyfill';
-import { objectHasKey, fileSaver } from '@/utils/helper';
+import { objectHasKey, fileSaver, isObject } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
 import dataExporter from '@/utils/data-exporter';
 import compareBlockValue from '@/utils/compare-block-value';
@@ -26,8 +26,8 @@ function convertData(data, type) {
 
   return result;
 }
-function generateBlockError(block) {
-  const message = errorMessage('no-tab', tasks[block.name]);
+function generateBlockError(block, code) {
+  const message = errorMessage(code || 'no-tab', tasks[block.name]);
   const error = new Error(message);
   error.nextBlockId = getBlockConnection(block);
 
@@ -279,22 +279,61 @@ export function interactionHandler(block) {
       once: true,
       delay: block.name === 'link' ? 5000 : 0,
       callback: (data) => {
+        if (data?.isError) {
+          const error = new Error(data.message);
+          error.nextBlockId = nextBlockId;
+
+          reject(error);
+          return;
+        }
+
+        const getColumn = (name) =>
+          this.workflow.dataColumns.find((item) => item.name === name) || {
+            name: 'column',
+            type: 'text',
+          };
+        const pushData = (column, value) => {
+          this.data[column.name]?.push(convertData(value, column.type));
+        };
+
         if (objectHasKey(block.data, 'dataColumn')) {
-          const { name, type } = Object.values(this.workflow.dataColumns).find(
-            (item) => item.name === block.data.dataColumn
-          ) || { name: 'column', type: 'text' };
+          const column = getColumn(block.data.dataColumn);
 
           if (block.data.saveData) {
-            if (!objectHasKey(this.data, name)) this.data[name] = [];
-
             if (Array.isArray(data)) {
               data.forEach((item) => {
-                this.data[name].push(convertData(item, type));
+                pushData(column, item);
               });
             } else {
-              this.data[name].push(convertData(data, type));
+              pushData(column, data);
             }
           }
+        } else if (block.name === 'javascript-code') {
+          const memoColumn = {};
+          const pushObjectData = (obj) => {
+            Object.entries(obj).forEach(([key, value]) => {
+              let column;
+
+              if (memoColumn[key]) {
+                column = memoColumn[key];
+              } else {
+                const currentColumn = getColumn(key);
+
+                column = currentColumn;
+                memoColumn[key] = currentColumn;
+              }
+
+              pushData(column, value);
+            });
+          };
+
+          if (Array.isArray(data)) {
+            data.forEach((obj) => {
+              if (isObject(obj)) pushObjectData(obj);
+            });
+          } else if (isObject(data)) {
+            pushObjectData(data);
+          }
         }
 
         resolve({

+ 1 - 1
src/background/index.js

@@ -66,7 +66,7 @@ chrome.runtime.onInstalled.addListener((details) => {
       .set({
         logs: [],
         workflows: [],
-        shortcuts: [],
+        shortcuts: {},
         workflowState: [],
         isFirstTime: true,
         visitWebTriggers: [],

+ 16 - 0
src/background/workflow-engine.js

@@ -3,6 +3,7 @@ import browser from 'webextension-polyfill';
 import { nanoid } from 'nanoid';
 import { toCamelCase } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
+import referenceData from '@/utils/reference-data';
 import errorMessage from './error-message';
 import workflowState from './workflow-state';
 import * as blocksHandler from './blocks-handler';
@@ -117,8 +118,21 @@ class WorkflowEngine {
     browser.tabs.onUpdated.addListener(this.tabUpdatedHandler);
     browser.tabs.onRemoved.addListener(this.tabRemovedHandler);
 
+    const dataColumns = Array.isArray(this.workflow.dataColumns)
+      ? this.workflow.dataColumns
+      : Object.values(this.workflow.dataColumns);
+
     this.blocks = blocks;
     this.startedTimestamp = Date.now();
+    this.workflow.dataColumns = dataColumns;
+    this.data = dataColumns.reduce(
+      (acc, column) => {
+        acc[column.name] = [];
+
+        return acc;
+      },
+      { column: [] }
+    );
 
     workflowState
       .add(this.id, {
@@ -246,6 +260,8 @@ class WorkflowEngine {
     const handler = blocksHandler[handlerName];
 
     if (handler) {
+      referenceData(block, { data: this.data, prevBlockData });
+
       handler
         .call(this, block, prevBlockData)
         .then((result) => {

+ 12 - 10
src/components/block/BlockNewTab.vue

@@ -21,6 +21,7 @@
       placeholder="http://example.com"
       type="url"
       required
+      @blur="checkInputValue"
       @input="handleInput"
     />
     <ui-checkbox :model-value="block.data.active" @change="handleCheckbox">
@@ -44,20 +45,16 @@ const props = defineProps({
 const componentId = useComponentId('new-tab');
 const block = useEditorBlock(`#${componentId}`, props.editor);
 
+const isValidURL = (url) => /^(https?):\/\//i.test(url);
 const handleInput = debounce(({ target }) => {
   target.reportValidity();
 
-  const res = target.value.match(
-    /* eslint-disable-next-line */
-    /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g
-  );
+  block.data.url = isValidURL(target.value) ? target.value : '';
 
-  if (!res) return;
-
-  const [url] = res;
-
-  props.editor.updateNodeDataFromId(block.id, { ...block.data, url });
-  block.data.url = url;
+  props.editor.updateNodeDataFromId(block.id, {
+    ...block.data,
+    url: block.data.url,
+  });
   emitter.emit('editor:data-changed', block.id);
 }, 250);
 function handleCheckbox(value) {
@@ -65,4 +62,9 @@ function handleCheckbox(value) {
   block.data.active = value;
   emitter.emit('editor:data-changed', block.id);
 }
+function checkInputValue({ target }) {
+  if (!isValidURL(target.value)) {
+    target.value = '';
+  }
+}
 </script>

+ 2 - 19
src/components/newtab/logs/LogsDataViewer.vue

@@ -24,7 +24,7 @@
   </div>
   <prism-editor
     :model-value="dataStr"
-    :highlight="highlighter"
+    :highlight="highlighter('json')"
     :class="editorClass"
     readonly
     class="my-editor p-4 bg-gray-900 rounded-lg mt-4"
@@ -33,12 +33,9 @@
 <script setup>
 import { ref } from 'vue';
 import { PrismEditor } from 'vue-prism-editor';
-import { highlight, languages } from 'prismjs/components/prism-core';
+import { highlighter } from '@/lib/prism';
 import { dataExportTypes } from '@/utils/shared';
 import dataExporter, { generateJSON } from '@/utils/data-exporter';
-import 'vue-prism-editor/dist/prismeditor.min.css';
-import 'prismjs/components/prism-json';
-import 'prismjs/themes/prism-tomorrow.css';
 
 const props = defineProps({
   log: {
@@ -55,22 +52,8 @@ const data = generateJSON(Object.keys(props.log.data), props.log.data);
 const dataStr = JSON.stringify(data, null, 2);
 
 const fileName = ref(props.log.name);
-const highlighter = (code) => highlight(code, languages.json);
 
 function exportData(type) {
   dataExporter(data, { name: fileName.value, type }, true);
 }
 </script>
-<style scoped>
-.my-editor {
-  color: #ccc;
-  font-family: JetBrains Mono, Fira code, Fira Mono, Consolas, Menlo, Courier,
-    monospace;
-  font-size: 14px;
-  line-height: 1.5;
-  padding: 5px;
-}
-.prism-editor__textarea:focus {
-  outline: none;
-}
-</style>

+ 8 - 0
src/components/newtab/shared/SharedLogsTable.vue

@@ -28,6 +28,7 @@
         <td class="text-right">
           <span
             :class="statusColors[log.status]"
+            :title="log.status === 'error' ? getErrorMessage(log) : null"
             class="inline-block py-1 w-16 text-center text-sm rounded-lg"
           >
             {{ log.status }}
@@ -60,6 +61,13 @@ function formatDate(date, format) {
 
   return dayjs(date).format(format);
 }
+function getErrorMessage({ history }) {
+  const lastHistory = history[history.length - 1];
+
+  return lastHistory && lastHistory.type === 'error'
+    ? lastHistory.message
+    : null;
+}
 </script>
 <style scoped>
 .log-time svg {

+ 4 - 0
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -145,6 +145,10 @@ export default {
       editor.value.on('connectionRemoved', () => {
         emitter.emit('editor:data-changed');
       });
+
+      setTimeout(() => {
+        editor.value.zoom_refresh();
+      }, 500);
     });
 
     return {

+ 15 - 22
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -17,30 +17,23 @@
 </template>
 <script>
 import { computed } from 'vue';
-import EditForms from './edit/EditForms.vue';
-import EditTrigger from './edit/EditTrigger.vue';
-import EditGetText from './edit/EditGetText.vue';
-import EditCloseTab from './edit/EditCloseTab.vue';
-import EditTriggerEvent from './edit/EditTriggerEvent.vue';
-import EditElementExists from './edit/EditElementExists.vue';
-import EditScrollElement from './edit/EditScrollElement.vue';
-import EditAttributeValue from './edit/EditAttributeValue.vue';
-import EditTakeScreenshot from './edit/EditTakeScreenshot.vue';
-import EditInteractionBase from './edit/EditInteractionBase.vue';
+
+const editComponents = require.context(
+  './edit',
+  false,
+  /^(?:.*\/)?Edit[^/]*\.vue$/
+);
+const components = editComponents.keys().reduce((acc, key) => {
+  const name = key.replace(/(.\/)|\.vue$/g, '');
+  const componentObj = editComponents(key)?.default ?? {};
+
+  acc[name] = componentObj;
+
+  return acc;
+}, {});
 
 export default {
-  components: {
-    EditForms,
-    EditTrigger,
-    EditGetText,
-    EditCloseTab,
-    EditTriggerEvent,
-    EditElementExists,
-    EditScrollElement,
-    EditAttributeValue,
-    EditTakeScreenshot,
-    EditInteractionBase,
-  },
+  components,
 };
 </script>
 <script setup>

+ 91 - 0
src/components/newtab/workflow/edit/EditJavascriptCode.vue

@@ -0,0 +1,91 @@
+<template>
+  <div class="mb-2 mt-4">
+    <ui-textarea
+      :model-value="data.description"
+      autoresize
+      placeholder="Description"
+      class="w-full mb-2"
+      @change="updateData({ description: $event })"
+    />
+    <ui-input
+      type="number"
+      :model-value="data.timeout"
+      class="mb-2 w-full"
+      placeholder="Timeout"
+      title="Javascript code execution timeout"
+      @change="updateData({ timeout: +$event })"
+    />
+    <prism-editor
+      v-if="!showCodeModal"
+      :model-value="data.code"
+      :highlight="highlighter('javascript')"
+      readonly
+      class="p-4 max-h-80"
+      @click="showCodeModal = true"
+    />
+    <ui-modal
+      v-model="showCodeModal"
+      title="Javascript code"
+      content-class="max-w-3xl"
+    >
+      <prism-editor
+        v-model="code"
+        class="py-4"
+        :highlight="highlighter('javascript')"
+        line-numbers
+        style="height: calc(100vh - 18rem)"
+      />
+      <div>
+        Note:
+        <ul class="list-disc pl-5">
+          <li>
+            To execute the next block, you can call the
+            <code>automaNextBlock</code> function. This function accepts one
+            parameter, which you can use to save data to the workflow. Data
+            format:
+            <ul class="list-disc space-y-2 mt-2 text-sm pl-5">
+              <li><code>{ key: value }</code></li>
+              <li>
+                <code>[{ key: value }, { key: value }]</code>
+              </li>
+            </ul>
+            You must use the column that you added as a key.
+          </li>
+          <li>
+            To reset the execution timeout of the code, you can call the
+            <code>automaResetTimeout</code> function.
+          </li>
+        </ul>
+      </div>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+import { PrismEditor } from 'vue-prism-editor';
+import { highlighter } from '@/lib/prism';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const code = ref(props.data.code);
+const showCodeModal = ref(false);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+
+watch(code, (value) => {
+  updateData({ code: value });
+});
+</script>
+<style scoped>
+code {
+  @apply bg-gray-900 text-sm text-white p-1 rounded-md;
+}
+</style>

+ 5 - 1
src/components/ui/UiModal.vue

@@ -31,7 +31,7 @@
             <div class="mb-4">
               <div class="flex items-center justify-between">
                 <span class="content-header">
-                  <slot name="header"></slot>
+                  <slot name="header">{{ title }}</slot>
                 </span>
                 <v-remixicon
                   v-show="!persist"
@@ -66,6 +66,10 @@ export default {
       type: String,
       default: 'max-w-lg',
     },
+    title: {
+      type: String,
+      default: '',
+    },
     customContent: Boolean,
     persist: Boolean,
     blur: Boolean,

+ 42 - 0
src/content/blocks-handler.js

@@ -74,6 +74,48 @@ function incScrollPos(element, data, vertical = true) {
 
   return currentPos;
 }
+
+const automaScript = `
+function automaNextBlock(data) {
+  window.dispatchEvent(new CustomEvent('__automa-next-block__', { detail: data }));
+}
+function automaResetTimeout() {
+ window.dispatchEvent(new CustomEvent('__automa-reset-timeout__'));
+}
+`;
+
+export function javascriptCode(block) {
+  return new Promise((resolve) => {
+    const isScriptExists = document.getElementById('automa-custom-js');
+
+    if (isScriptExists) isScriptExists.remove();
+
+    const script = document.createElement('script');
+    let timeout;
+
+    script.id = 'automa-custom-js';
+    script.innerHTML = `${automaScript} ${block.data.code}`;
+
+    window.addEventListener('__automa-next-block__', ({ detail }) => {
+      clearTimeout(timeout);
+      resolve(detail || {});
+    });
+    window.addEventListener('__automa-reset-timeout__', () => {
+      clearTimeout(timeout);
+
+      timeout = setTimeout(() => {
+        resolve('');
+      }, block.data.timeout);
+    });
+
+    document.body.appendChild(script);
+
+    timeout = setTimeout(() => {
+      resolve('');
+    }, block.data.timeout);
+  });
+}
+
 export function elementScroll(block) {
   return new Promise((resolve) => {
     const { data } = block;

+ 2 - 0
src/content/element-selector/ElementSelector.ce.vue

@@ -56,6 +56,8 @@ import { finder } from '@medv/finder';
 import { VRemixIcon } from 'v-remixicon';
 import { riFileCopyLine, riArrowDownLine } from 'v-remixicon/icons';
 
+/* to-do get list of attribute value */
+
 const element = reactive({
   hovered: {},
   selected: {},

+ 10 - 3
src/content/index.js

@@ -9,9 +9,16 @@ function onConnectListener() {
       const handler = blocksHandler[toCamelCase(data.name)];
 
       if (handler) {
-        handler(data).then((result) => {
-          port.postMessage({ type: data.name, data: result });
-        });
+        handler(data)
+          .then((result) => {
+            port.postMessage({ type: data.name, data: result });
+          })
+          .catch((error) => {
+            port.postMessage({
+              isError: true,
+              message: error?.message || error,
+            });
+          });
       } else {
         console.error(`"${data.name}" doesn't have a handler`);
       }

+ 1 - 1
src/content/shortcut.js

@@ -50,7 +50,7 @@ function getTriggerBlock(workflow) {
         sendMessage('workflow:execute', item.workflow, 'background');
       });
 
-      return false;
+      return true;
     });
   } catch (error) {
     console.error(error);

+ 13 - 0
src/lib/prism.js

@@ -0,0 +1,13 @@
+import { highlight, languages } from 'prismjs/components/prism-core';
+import 'vue-prism-editor/dist/prismeditor.min.css';
+import 'prismjs/components/prism-clike';
+import 'prismjs/components/prism-javascript';
+import 'prismjs/components/prism-json';
+import 'prismjs/themes/prism-tomorrow.css';
+import '@/assets/css/prism-editor.css';
+
+export function highlighter(language) {
+  return function (code) {
+    return highlight(code, languages[language]);
+  };
+}

+ 2 - 0
src/lib/v-remixicon.js

@@ -2,6 +2,7 @@ import vRemixicon from 'v-remixicon';
 import {
   riHome5Line,
   riGithubFill,
+  riCodeSSlashLine,
   riRecordCircleLine,
   riErrorWarningLine,
   riCalendarLine,
@@ -66,6 +67,7 @@ import {
 export const icons = {
   riHome5Line,
   riGithubFill,
+  riCodeSSlashLine,
   riRecordCircleLine,
   riErrorWarningLine,
   riCalendarLine,

+ 1 - 0
src/newtab/App.vue

@@ -6,6 +6,7 @@
   <ui-dialog />
 </template>
 <script setup>
+/* to-do add documentation of the extension */
 import { ref } from 'vue';
 import { useStore } from 'vuex';
 import browser from 'webextension-polyfill';

+ 2 - 1
src/newtab/pages/Home.vue

@@ -3,7 +3,7 @@
     <h1 class="text-2xl font-semibold mb-8">Dashboard</h1>
     <div class="flex items-start">
       <div class="w-8/12 mr-8">
-        <div class="grid gap-4 mb-8 grid-cols-3">
+        <div class="grid gap-4 mb-8 2xl:grid-cols-4 grid-cols-3">
           <p v-if="workflows.length === 0" class="text-center text-gray-600">
             No data
           </p>
@@ -12,6 +12,7 @@
             :key="workflow.id"
             :workflow="workflow"
             :show-details="false"
+            style="max-width: 250px"
             @execute="executeWorkflow"
           />
         </div>

+ 19 - 8
src/newtab/pages/logs/[id].vue

@@ -24,20 +24,31 @@
     </div>
     <div class="flex items-start">
       <ui-list class="w-7/12 mr-6">
-        <ui-list-item
-          v-for="(item, index) in activeLog.history"
-          :key="index"
-          :title="item.message || item.type"
-        >
+        <ui-list-item v-for="(item, index) in activeLog.history" :key="index">
           <span
             :class="logsType[item.type].color"
             class="p-1 rounded-lg align-middle inline-block mr-2"
           >
             <v-remixicon :name="logsType[item.type].icon" size="20" />
           </span>
-          <p class="flex-1 text-overflow">
-            {{ item.name }}
-          </p>
+          <div class="flex-1">
+            <p class="w-full text-overflow leading-tight">
+              {{ item.name }}
+            </p>
+            <p
+              v-if="item.type === 'error'"
+              :title="item.message"
+              class="
+                text-sm
+                leading-tight
+                line-clamp
+                text-gray-600
+                dark:text-gray-200
+              "
+            >
+              {{ item.message }}
+            </p>
+          </div>
           <p class="text-gray-600">
             {{ countDuration(0, item.duration || 0) }}
           </p>

+ 1 - 1
src/newtab/pages/workflows/[id].vue

@@ -205,7 +205,7 @@ async function handleWorkflowTrigger({ data }) {
     let visitWebTriggerIndex = visitWebTriggers.findIndex(
       (item) => item.id === workflowId
     );
-    const keyboardShortcuts = shortcuts || {};
+    const keyboardShortcuts = Array.isArray(shortcuts) ? {} : shortcuts || {};
     delete keyboardShortcuts[workflowId];
 
     if (workflowAlarm) await browser.alarms.clear(workflowId);

+ 4 - 1
src/store/index.js

@@ -39,7 +39,10 @@ const store = createStore({
             entity: 'workflows',
             data: firstWorkflows,
           });
-          await browser.storage.local.set({ isFirstTime: false });
+          await browser.storage.local.set({
+            isFirstTime: false,
+            workflows: firstWorkflows,
+          });
         }
 
         return await Promise.allSettled(promises);

+ 1 - 1
src/utils/helper.js

@@ -26,7 +26,7 @@ export function toCamelCase(str) {
 }
 
 export function isObject(obj) {
-  return typeof obj === 'object' && object !== null;
+  return typeof obj === 'object' && obj !== null;
 }
 
 export function objectHasKey(obj, key) {

+ 55 - 0
src/utils/reference-data.js

@@ -0,0 +1,55 @@
+import objectPath from 'object-path';
+import { isObject, objectHasKey } from '@/utils/helper';
+
+function parseKey(key) {
+  const [dataKey, path] = key.split('@');
+
+  if (dataKey === 'prevBlockData') return { dataKey, path: path || '0' };
+
+  const pathArr = path.split('.');
+  let dataPath = '';
+
+  if (pathArr.length === 1) {
+    dataPath = `${pathArr[0]}.0`;
+  } else if (typeof +pathArr[0] !== 'number') {
+    const firstPath = pathArr.shift();
+
+    dataPath = `${firstPath}.0.${pathArr.join('.')}`;
+  } else {
+    const index = pathArr.shift();
+    const firstPath = pathArr.shift();
+
+    dataPath = `${firstPath}.${index}.${pathArr.join('.')}`;
+  }
+
+  if (dataPath.endsWith('.')) dataPath = dataPath.slice(0, -1);
+
+  return { dataKey: 'data', path: dataPath };
+}
+
+export default function (block, data) {
+  const replaceKeys = ['url', 'fileName', 'name', 'value'];
+
+  replaceKeys.forEach((blockDataKey) => {
+    if (!objectHasKey(block.data, blockDataKey)) return;
+
+    const newDataValue = block.data[blockDataKey].replace(
+      /\[(.+?)]/g,
+      (match) => {
+        const key = match.replace(/\[|]/g, '');
+        const { dataKey, path } = parseKey(key);
+
+        if (
+          dataKey === 'prevBlockData' &&
+          (!isObject(data.prevBlockData) || !Array.isArray(data.prevBlockData))
+        ) {
+          return data.prevBlockData;
+        }
+
+        return objectPath.get(data, `${dataKey}.${path}`) || match;
+      }
+    );
+
+    block.data[blockDataKey] = newDataValue;
+  });
+}

+ 17 - 1
src/utils/shared.js

@@ -1,5 +1,4 @@
 /* to-do screenshot, looping, cookies, assets, tab loaded, opened tab, and close tab block? */
-/* prev and next page block? */
 
 export const tasks = {
   trigger: {
@@ -294,6 +293,23 @@ export const tasks = {
   //   disableEdit: true,
   //   data: {},
   // },
+  'javascript-code': {
+    name: 'JavaScript code',
+    description: 'Execute your custom javascript code in a webpage',
+    icon: 'riCodeSSlashLine',
+    component: 'BlockBasic',
+    editComponent: 'EditJavascriptCode',
+    category: 'interaction',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      description: '',
+      timeout: 10000,
+      code: 'console.log("Hello world!")',
+    },
+  },
   'trigger-event': {
     name: 'Trigger event',
     description: 'Trigger event',

+ 5 - 0
yarn.lock

@@ -4806,6 +4806,11 @@ object-keys@^1.0.12, object-keys@^1.1.1:
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
 
+object-path@^0.11.8:
+  version "0.11.8"
+  resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.8.tgz#ed002c02bbdd0070b78a27455e8ae01fc14d4742"
+  integrity sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==
+
 object-visit@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"