feat(builder): Add input validation on the frontend (#7606)
- Add `ajv` dependency to check values against json schema - Add `errors` and `setErrors` to `CustomNodeData` - Add `validateNodes` run before executing agent - Add `*` on labels for required fields - Add `setNestedProperty` and `removeEmptyStringsAndNulls` utility functionpull/7607/head^2
parent
edf84fb9f8
commit
905b1df218
|
@ -19,6 +19,7 @@
|
|||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"ajv": "^8.17.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import React, { useState, useEffect, FC, memo, useRef } from 'react';
|
||||
import React, { useState, useEffect, FC, memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import './customnode.css';
|
||||
import InputModalComponent from './InputModalComponent';
|
||||
import OutputModalComponent from './OutputModalComponent';
|
||||
import { BlockSchema } from '@/lib/types';
|
||||
import { beautifyString } from '@/lib/utils';
|
||||
import { beautifyString, setNestedProperty } from '@/lib/utils';
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import NodeHandle from './NodeHandle';
|
||||
import NodeInputField from './NodeInputField';
|
||||
|
||||
type CustomNodeData = {
|
||||
export type CustomNodeData = {
|
||||
blockType: string;
|
||||
title: string;
|
||||
inputSchema: BlockSchema;
|
||||
|
@ -21,6 +21,10 @@ type CustomNodeData = {
|
|||
isOutputOpen: boolean;
|
||||
status?: string;
|
||||
output_data?: any;
|
||||
block_id: string;
|
||||
backend_id?: string;
|
||||
errors?: { [key: string]: string | null };
|
||||
setErrors: (errors: { [key: string]: string | null }) => void;
|
||||
};
|
||||
|
||||
const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
|
||||
|
@ -29,9 +33,7 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
|
|||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [modalValue, setModalValue] = useState<string>('');
|
||||
const [errors, setErrors] = useState<{ [key: string]: string | null }>({});
|
||||
const [isOutputModalOpen, setIsOutputModalOpen] = useState(false);
|
||||
const outputDataRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.output_data || data.status) {
|
||||
|
@ -80,11 +82,13 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
|
|||
|
||||
console.log(`Updating hardcoded values for node ${id}:`, newValues);
|
||||
data.setHardcodedValues(newValues);
|
||||
setErrors((prevErrors) => ({ ...prevErrors, [key]: null }));
|
||||
const errors = data.errors || {};
|
||||
// Remove error with the same key
|
||||
setNestedProperty(errors, key, null);
|
||||
data.setErrors({ ...errors });
|
||||
};
|
||||
|
||||
const getValue = (key: string) => {
|
||||
console.log(`Getting value for key: ${key}`);
|
||||
const keys = key.split('.');
|
||||
return keys.reduce((acc, k) => (acc && acc[k] !== undefined) ? acc[k] : '', data.hardcodedValues);
|
||||
};
|
||||
|
@ -122,28 +126,6 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
|
|||
setActiveKey(null);
|
||||
};
|
||||
|
||||
const validateInputs = () => {
|
||||
const newErrors: { [key: string]: string | null } = {};
|
||||
const validateRecursive = (schema: any, parentKey: string = '') => {
|
||||
Object.entries(schema.properties).forEach(([key, propSchema]: [string, any]) => {
|
||||
const fullKey = parentKey ? `${parentKey}.${key}` : key;
|
||||
const value = getValue(fullKey);
|
||||
|
||||
if (propSchema.type === 'object' && propSchema.properties) {
|
||||
validateRecursive(propSchema, fullKey);
|
||||
} else {
|
||||
if (propSchema.required && !value) {
|
||||
newErrors[fullKey] = `${fullKey} is required`;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
validateRecursive(data.inputSchema);
|
||||
setErrors(newErrors);
|
||||
return Object.values(newErrors).every((error) => error === null);
|
||||
};
|
||||
|
||||
const handleOutputClick = () => {
|
||||
setIsOutputModalOpen(true);
|
||||
setModalValue(typeof data.output_data === 'object' ? JSON.stringify(data.output_data, null, 2) : data.output_data);
|
||||
|
@ -166,16 +148,16 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
|
|||
const isRequired = data.inputSchema.required?.includes(key);
|
||||
return (isRequired || isAdvancedOpen) && (
|
||||
<div key={key}>
|
||||
<NodeHandle keyName={key} isConnected={isHandleConnected(key)} schema={schema} side="left" />
|
||||
<NodeHandle keyName={key} isConnected={isHandleConnected(key)} isRequired={isRequired} schema={schema} side="left" />
|
||||
{!isHandleConnected(key) &&
|
||||
<NodeInputField
|
||||
keyName={key}
|
||||
schema={schema}
|
||||
value={getValue(key)}
|
||||
handleInputClick={handleInputClick}
|
||||
handleInputChange={handleInputChange}
|
||||
errors={errors}
|
||||
/>}
|
||||
<NodeInputField
|
||||
keyName={key}
|
||||
schema={schema}
|
||||
value={getValue(key)}
|
||||
handleInputClick={handleInputClick}
|
||||
handleInputChange={handleInputChange}
|
||||
errors={data.errors?.[key]}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -13,32 +13,17 @@ import ReactFlow, {
|
|||
MarkerType,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import CustomNode from './CustomNode';
|
||||
import CustomNode, { CustomNodeData } from './CustomNode';
|
||||
import './flow.css';
|
||||
import AutoGPTServerAPI, { Block, Graph, NodeExecutionResult, ObjectSchema } from '@/lib/autogpt-server-api';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import { deepEquals, getTypeColor } from '@/lib/utils';
|
||||
import { deepEquals, getTypeColor, removeEmptyStringsAndNulls, setNestedProperty } from '@/lib/utils';
|
||||
import { beautifyString } from '@/lib/utils';
|
||||
import { CustomEdge, CustomEdgeData } from './CustomEdge';
|
||||
import ConnectionLine from './ConnectionLine';
|
||||
|
||||
|
||||
type CustomNodeData = {
|
||||
blockType: string;
|
||||
title: string;
|
||||
inputSchema: ObjectSchema;
|
||||
outputSchema: ObjectSchema;
|
||||
hardcodedValues: { [key: string]: any };
|
||||
setHardcodedValues: (values: { [key: string]: any }) => void;
|
||||
connections: Array<{ source: string; sourceHandle: string; target: string; targetHandle: string }>;
|
||||
isOutputOpen: boolean;
|
||||
status?: string;
|
||||
output_data?: any;
|
||||
block_id: string;
|
||||
backend_id?: string;
|
||||
};
|
||||
import Ajv from 'ajv';
|
||||
|
||||
const Sidebar: React.FC<{ isOpen: boolean, availableNodes: Block[], addNode: (id: string, name: string) => void }> =
|
||||
({ isOpen, availableNodes, addNode }) => {
|
||||
|
@ -69,6 +54,8 @@ const Sidebar: React.FC<{ isOpen: boolean, availableNodes: Block[], addNode: (id
|
|||
);
|
||||
};
|
||||
|
||||
const ajv = new Ajv({ strict: false, allErrors: true });
|
||||
|
||||
const FlowEditor: React.FC<{
|
||||
flowID?: string;
|
||||
template?: boolean;
|
||||
|
@ -225,6 +212,13 @@ const FlowEditor: React.FC<{
|
|||
connections: [],
|
||||
isOutputOpen: false,
|
||||
block_id: blockId,
|
||||
setErrors: (errors: { [key: string]: string | null }) => {
|
||||
setNodes((nds) => nds.map((node) =>
|
||||
node.id === newNode.id
|
||||
? { ...node, data: { ...node.data, errors } }
|
||||
: node
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -265,6 +259,13 @@ const FlowEditor: React.FC<{
|
|||
targetHandle: link.sink_name,
|
||||
})),
|
||||
isOutputOpen: false,
|
||||
setErrors: (errors: { [key: string]: string | null }) => {
|
||||
setNodes((nds) => nds.map((node) =>
|
||||
node.id === newNode.id
|
||||
? { ...node, data: { ...node.data, errors } }
|
||||
: node
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
return newNode;
|
||||
|
@ -323,12 +324,13 @@ const FlowEditor: React.FC<{
|
|||
return inputData;
|
||||
};
|
||||
|
||||
async function saveAgent (asTemplate: boolean = false) {
|
||||
async function saveAgent(asTemplate: boolean = false) {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
hardcodedValues: removeEmptyStringsAndNulls(node.data.hardcodedValues),
|
||||
status: undefined,
|
||||
},
|
||||
}))
|
||||
|
@ -363,6 +365,10 @@ const FlowEditor: React.FC<{
|
|||
input_default: inputDefault,
|
||||
input_nodes: inputNodes,
|
||||
output_nodes: outputNodes,
|
||||
data: {
|
||||
...node.data,
|
||||
hardcodedValues: removeEmptyStringsAndNulls(node.data.hardcodedValues),
|
||||
},
|
||||
metadata: { position: node.position }
|
||||
};
|
||||
});
|
||||
|
@ -422,6 +428,40 @@ const FlowEditor: React.FC<{
|
|||
return newSavedAgent.id;
|
||||
};
|
||||
|
||||
const validateNodes = (): boolean => {
|
||||
let isValid = true;
|
||||
|
||||
nodes.forEach(node => {
|
||||
const validate = ajv.compile(node.data.inputSchema);
|
||||
const errors = {} as { [key: string]: string | null };
|
||||
|
||||
// Validate values against schema using AJV
|
||||
const valid = validate(node.data.hardcodedValues);
|
||||
if (!valid) {
|
||||
// Populate errors if validation fails
|
||||
validate.errors?.forEach((error) => {
|
||||
// Skip error if there's an edge connected
|
||||
const handle = error.instancePath.split(/[\/.]/)[0];
|
||||
if (node.data.connections.some(conn => conn.target === node.id || conn.targetHandle === handle)) {
|
||||
return;
|
||||
}
|
||||
isValid = false;
|
||||
if (error.instancePath && error.message) {
|
||||
const key = error.instancePath.slice(1);
|
||||
console.log("Error", key, error.message);
|
||||
setNestedProperty(errors, key, error.message[0].toUpperCase() + error.message.slice(1));
|
||||
} else if (error.keyword === "required") {
|
||||
const key = error.params.missingProperty;
|
||||
setNestedProperty(errors, key, "This field is required");
|
||||
}
|
||||
});
|
||||
}
|
||||
node.data.setErrors(errors);
|
||||
});
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const runAgent = async () => {
|
||||
try {
|
||||
const newAgentId = await saveAgent();
|
||||
|
@ -430,6 +470,11 @@ const FlowEditor: React.FC<{
|
|||
return;
|
||||
}
|
||||
|
||||
if (!validateNodes()) {
|
||||
console.error('Validation failed; aborting run');
|
||||
return;
|
||||
}
|
||||
|
||||
api.subscribeToExecution(newAgentId);
|
||||
api.runGraph(newAgentId);
|
||||
|
||||
|
@ -438,8 +483,6 @@ const FlowEditor: React.FC<{
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const updateNodesWithExecutionData = (executionData: NodeExecutionResult[]) => {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) => {
|
||||
|
|
|
@ -8,10 +8,11 @@ type HandleProps = {
|
|||
keyName: string,
|
||||
schema: BlockSchema,
|
||||
isConnected: boolean,
|
||||
isRequired?: boolean,
|
||||
side: 'left' | 'right'
|
||||
}
|
||||
|
||||
const NodeHandle: FC<HandleProps> = ({ keyName, isConnected, schema, side }) => {
|
||||
const NodeHandle: FC<HandleProps> = ({ keyName, schema, isConnected, isRequired, side }) => {
|
||||
|
||||
const typeName: Record<string, string> = {
|
||||
string: 'text',
|
||||
|
@ -26,7 +27,9 @@ const NodeHandle: FC<HandleProps> = ({ keyName, isConnected, schema, side }) =>
|
|||
|
||||
const label = (
|
||||
<div className="flex flex-col flex-grow">
|
||||
<span className="text-m text-gray-900 -mb-1 green">{schema.title || beautifyString(keyName)}</span>
|
||||
<span className="text-m text-gray-900 -mb-1 green">
|
||||
{schema.title || beautifyString(keyName)}{isRequired ? '*' : ''}
|
||||
</span>
|
||||
<span className={typeClass}>{typeName[schema.type]}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -10,7 +10,7 @@ type BlockInputFieldProps = {
|
|||
value: string | Array<string> | { [key: string]: string }
|
||||
handleInputClick: (key: string) => void
|
||||
handleInputChange: (key: string, value: any) => void
|
||||
errors: { [key: string]: string | null }
|
||||
errors?: { [key: string]: string } | string | null
|
||||
}
|
||||
|
||||
const NodeInputField: FC<BlockInputFieldProps> =
|
||||
|
@ -20,7 +20,7 @@ const NodeInputField: FC<BlockInputFieldProps> =
|
|||
const [keyValuePairs, setKeyValuePairs] = useState<{ key: string, value: string }[]>([]);
|
||||
|
||||
const fullKey = parentKey ? `${parentKey}.${key}` : key;
|
||||
const error = errors[fullKey];
|
||||
const error = typeof errors === 'string' ? errors : errors?.[key] ?? "";
|
||||
const displayKey = schema.title || beautifyString(key);
|
||||
|
||||
const handleAddProperty = () => {
|
||||
|
@ -35,14 +35,15 @@ const NodeInputField: FC<BlockInputFieldProps> =
|
|||
};
|
||||
|
||||
const renderClickableInput = (value: string | null = null, placeholder: string = "", secret: boolean = false) => {
|
||||
const className = `clickable-input ${error ? 'border-error' : ''}`
|
||||
|
||||
// if secret is true, then the input field will be a password field if the value is not null
|
||||
return secret ? (
|
||||
<div className="clickable-input" onClick={() => handleInputClick(fullKey)}>
|
||||
{value ? <i className="text-gray-500">********</i> : <i className="text-gray-500">{placeholder}</i>}
|
||||
<div className={className} onClick={() => handleInputClick(fullKey)}>
|
||||
{value ? <span>********</span> : <i className="text-gray-500">{placeholder}</i>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="clickable-input" onClick={() => handleInputClick(fullKey)}>
|
||||
<div className={className} onClick={() => handleInputClick(fullKey)}>
|
||||
{value || <i className="text-gray-500">{placeholder}</i>}
|
||||
</div>
|
||||
)
|
||||
|
@ -247,11 +248,11 @@ const NodeInputField: FC<BlockInputFieldProps> =
|
|||
case 'integer':
|
||||
return (
|
||||
<div key={fullKey} className="input-container">
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={value as string || ''}
|
||||
onChange={(e) => handleInputChange(fullKey, parseFloat(e.target.value))}
|
||||
className="number-input"
|
||||
className={`number-input ${error ? 'border-error' : ''}`}
|
||||
/>
|
||||
{error && <span className="error-message">{error}</span>}
|
||||
</div>
|
||||
|
@ -263,7 +264,7 @@ const NodeInputField: FC<BlockInputFieldProps> =
|
|||
<div key={fullKey} className="input-container">
|
||||
{arrayValues.map((item: string, index: number) => (
|
||||
<div key={`${fullKey}.${index}`} className="array-item-container">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => handleInputChange(`${fullKey}.${index}`, e.target.value)}
|
||||
|
@ -277,7 +278,7 @@ const NodeInputField: FC<BlockInputFieldProps> =
|
|||
<Button onClick={() => handleInputChange(fullKey, [...arrayValues, ''])} className="array-item-add">
|
||||
Add Item
|
||||
</Button>
|
||||
{error && <span className="error-message">{error}</span>}
|
||||
{error && <span className="error-message ml-2">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -54,6 +54,10 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.border-error {
|
||||
border: 1px solid #d9534f;
|
||||
}
|
||||
|
||||
.clickable-input span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
|
@ -82,7 +86,6 @@
|
|||
width: 100%;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
|
|
@ -119,3 +119,40 @@ export function exportAsJSONFile(obj: object, filename: string): void {
|
|||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function setNestedProperty(obj: any, path: string, value: any) {
|
||||
const keys = path.split(/[\/.]/); // Split by / or .
|
||||
let current = obj;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (!current[key] || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
}
|
||||
|
||||
export function removeEmptyStringsAndNulls(obj: any): any {
|
||||
if (Array.isArray(obj)) {
|
||||
// If obj is an array, recursively remove empty strings and nulls from its elements
|
||||
return obj
|
||||
.map(item => removeEmptyStringsAndNulls(item))
|
||||
.filter(item => item !== null && (typeof item !== 'string' || item.trim() !== ''));
|
||||
} else if (typeof obj === 'object' && obj !== null) {
|
||||
// If obj is an object, recursively remove empty strings and nulls from its properties
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const value = obj[key];
|
||||
if (value === null || (typeof value === 'string' && value.trim() === '')) {
|
||||
delete obj[key];
|
||||
} else {
|
||||
obj[key] = removeEmptyStringsAndNulls(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
|
|
@ -997,6 +997,16 @@ ajv@^6.12.4:
|
|||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ajv@^8.17.1:
|
||||
version "8.17.1"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6"
|
||||
integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==
|
||||
dependencies:
|
||||
fast-deep-equal "^3.1.3"
|
||||
fast-uri "^3.0.1"
|
||||
json-schema-traverse "^1.0.0"
|
||||
require-from-string "^2.0.2"
|
||||
|
||||
ansi-regex@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
|
@ -2075,6 +2085,11 @@ fast-levenshtein@^2.0.6:
|
|||
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
||||
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
||||
|
||||
fast-uri@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.1.tgz#cddd2eecfc83a71c1be2cc2ef2061331be8a7134"
|
||||
integrity sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==
|
||||
|
||||
fastq@^1.6.0:
|
||||
version "1.17.1"
|
||||
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47"
|
||||
|
@ -2703,6 +2718,11 @@ json-schema-traverse@^0.4.1:
|
|||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
|
||||
|
||||
json-schema-traverse@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
|
||||
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
|
||||
|
||||
json-stable-stringify-without-jsonify@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||
|
@ -3712,6 +3732,11 @@ remark-rehype@^11.0.0:
|
|||
unified "^11.0.0"
|
||||
vfile "^6.0.0"
|
||||
|
||||
require-from-string@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
|
||||
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
|
||||
|
||||
resolve-from@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||
|
|
Loading…
Reference in New Issue