diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index 786e1a19e9..f6da8f8403 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -73,6 +73,8 @@ "prosemirror-schema-list": "^1.3.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.3.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", "rxjs": "^7.8.1", "tslib": "^2.6.2", "zone.js": "~0.13.1" @@ -90,6 +92,7 @@ "@types/jasmine": "~4.3.5", "@types/jasminewd2": "~2.0.10", "@types/node": "^18.17.9", + "@types/react": "^18.2.21", "@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/parser": "^5.59.2", "@vendure/ngx-translate-extract": "^8.2.2", diff --git a/packages/admin-ui/scripts/build-public-api.js b/packages/admin-ui/scripts/build-public-api.js index 79472f163b..0c20ffdfe6 100644 --- a/packages/admin-ui/scripts/build-public-api.js +++ b/packages/admin-ui/scripts/build-public-api.js @@ -20,6 +20,7 @@ const MODULES = [ 'order', 'settings', 'system', + 'react', ]; for (const moduleDir of MODULES) { diff --git a/packages/admin-ui/src/lib/core/src/providers/component-registry/component-registry.service.ts b/packages/admin-ui/src/lib/core/src/providers/component-registry/component-registry.service.ts index 005555074b..5681632148 100644 --- a/packages/admin-ui/src/lib/core/src/providers/component-registry/component-registry.service.ts +++ b/packages/admin-ui/src/lib/core/src/providers/component-registry/component-registry.service.ts @@ -1,23 +1,25 @@ -import { Injectable, Type } from '@angular/core'; +import { Injectable, InjectionToken, Type } from '@angular/core'; import { FormInputComponent, InputComponentConfig } from '../../common/component-registry-types'; +export const INPUT_COMPONENT_OPTIONS = new InjectionToken<{ component?: any }>('INPUT_COMPONENT_OPTIONS'); + @Injectable({ providedIn: 'root', }) export class ComponentRegistryService { - private inputComponentMap = new Map>>(); + private inputComponentMap = new Map>; options?: any }>(); - registerInputComponent(id: string, component: Type>) { + registerInputComponent(id: string, component: Type>, options?: any) { if (this.inputComponentMap.has(id)) { throw new Error( `Cannot register an InputComponent with the id "${id}", as one with that id already exists`, ); } - this.inputComponentMap.set(id, component); + this.inputComponentMap.set(id, { type: component, options }); } - getInputComponent(id: string): Type> | undefined { + getInputComponent(id: string): { type: Type>; options?: any } | undefined { return this.inputComponentMap.get(id); } } diff --git a/packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts b/packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts index 5f4dff39fd..33fb53f97f 100644 --- a/packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts +++ b/packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts @@ -37,7 +37,10 @@ import { switchMap, take, takeUntil } from 'rxjs/operators'; import { FormInputComponent } from '../../../common/component-registry-types'; import { ConfigArgDefinition, CustomFieldConfig } from '../../../common/generated-types'; import { getConfigArgValue } from '../../../common/utilities/configurable-operation-utils'; -import { ComponentRegistryService } from '../../../providers/component-registry/component-registry.service'; +import { + ComponentRegistryService, + INPUT_COMPONENT_OPTIONS, +} from '../../../providers/component-registry/component-registry.service'; type InputListItem = { id: number; @@ -75,6 +78,7 @@ export class DynamicFormInputComponent private listId = 1; private listFormArray = new FormArray([] as Array>); private componentType: Type; + private componentOptions?: any; private onChange: (val: any) => void; private onTouch: () => void; private renderList$ = new Subject(); @@ -89,9 +93,10 @@ export class DynamicFormInputComponent ngOnInit() { const componentId = this.getInputComponentConfig(this.def).component; - const componentType = this.componentRegistryService.getInputComponent(componentId); - if (componentType) { - this.componentType = componentType; + const component = this.componentRegistryService.getInputComponent(componentId); + if (component) { + this.componentType = component.type; + this.componentOptions = component.options; } else { // eslint-disable-next-line no-console console.error( @@ -101,7 +106,7 @@ export class DynamicFormInputComponent this.getInputComponentConfig({ ...this.def, ui: undefined } as any).component, ); if (defaultComponentType) { - this.componentType = defaultComponentType; + this.componentType = defaultComponentType.type; } } } @@ -109,9 +114,13 @@ export class DynamicFormInputComponent ngAfterViewInit() { if (this.componentType) { const factory = this.componentFactoryResolver.resolveComponentFactory(this.componentType); + const injector = Injector.create({ + providers: [{ provide: INPUT_COMPONENT_OPTIONS, useValue: this.componentOptions }], + parent: this.injector, + }); // create a temp instance to check the value of `isListInput` - const cmpRef = factory.create(this.injector); + const cmpRef = factory.create(injector); const isListInputComponent = cmpRef.instance.isListInput ?? false; cmpRef.destroy(); @@ -124,6 +133,7 @@ export class DynamicFormInputComponent if (!this.renderAsList) { this.singleComponentRef = this.renderInputComponent( factory, + injector, this.singleViewContainer, this.control, ); @@ -142,6 +152,7 @@ export class DynamicFormInputComponent this.listFormArray.push(listItem.control); listItem.componentRef = this.renderInputComponent( factory, + injector, ref, listItem.control, ); @@ -244,10 +255,11 @@ export class DynamicFormInputComponent private renderInputComponent( factory: ComponentFactory, + injector: Injector, viewContainerRef: ViewContainerRef, formControl: UntypedFormControl, ) { - const componentRef = viewContainerRef.createComponent(factory); + const componentRef = viewContainerRef.createComponent(factory, undefined, injector); const { instance } = componentRef; instance.config = simpleDeepClone(this.def); instance.formControl = formControl; diff --git a/packages/admin-ui/src/lib/react/ng-package.json b/packages/admin-ui/src/lib/react/ng-package.json new file mode 100644 index 0000000000..6079cbad65 --- /dev/null +++ b/packages/admin-ui/src/lib/react/ng-package.json @@ -0,0 +1,7 @@ +{ + "lib": { + "styleIncludePaths": [ + "../static/styles" + ] + } +} diff --git a/packages/admin-ui/src/lib/react/src/adapters.ts b/packages/admin-ui/src/lib/react/src/adapters.ts new file mode 100644 index 0000000000..9741f74ba7 --- /dev/null +++ b/packages/admin-ui/src/lib/react/src/adapters.ts @@ -0,0 +1,47 @@ +import { APP_INITIALIZER, Component, FactoryProvider, inject, OnInit } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { + ComponentRegistryService, + CustomField, + FormInputComponent, + INPUT_COMPONENT_OPTIONS, +} from '@vendure/admin-ui/core'; +import { ElementType } from 'react'; +import { ReactComponentHostDirective } from './react-component-host.directive'; +import { ReactFormInputProps } from './types'; + +@Component({ + selector: 'vdr-react-form-input-component', + template: `
`, + standalone: true, + imports: [ReactComponentHostDirective], +}) +class ReactFormInputComponent implements FormInputComponent, OnInit { + static readonly id: string = 'react-form-input-component'; + readonly: boolean; + formControl: FormControl; + config: CustomField & Record; + + protected props: ReactFormInputProps; + + protected reactComponent = inject(INPUT_COMPONENT_OPTIONS).component; + + ngOnInit() { + this.props = { + formControl: this.formControl, + readonly: this.readonly, + config: this.config, + }; + } +} + +export function registerReactFormInputComponent(id: string, component: ElementType): FactoryProvider { + return { + provide: APP_INITIALIZER, + multi: true, + useFactory: (registry: ComponentRegistryService) => () => { + registry.registerInputComponent(id, ReactFormInputComponent, { component }); + }, + deps: [ComponentRegistryService], + }; +} diff --git a/packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts b/packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts new file mode 100644 index 0000000000..847ef9132b --- /dev/null +++ b/packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts @@ -0,0 +1,44 @@ +import { CustomFieldType } from '@vendure/common/lib/shared-types'; +import React, { useContext, useEffect, useState } from 'react'; +import { HostedComponentContext } from '../react-component-host.directive'; + +/** + * @description + * Provides access to the current FormControl value and a method to update the value. + */ +export function useFormControl() { + const context = useContext(HostedComponentContext); + if (!context) { + throw new Error('No HostedComponentContext found'); + } + const { formControl, config } = context; + const [value, setValue] = useState(formControl.value ?? 0); + + useEffect(() => { + const subscription = formControl.valueChanges.subscribe(v => { + setValue(v); + }); + return () => { + subscription.unsubscribe(); + }; + }, []); + + function setFormValue(newValue: any) { + formControl.setValue(coerceFormValue(newValue, config.type as CustomFieldType)); + formControl.markAsDirty(); + } + + return { value, setFormValue }; +} + +function coerceFormValue(value: any, type: CustomFieldType) { + switch (type) { + case 'int': + case 'float': + return Number(value); + case 'boolean': + return Boolean(value); + default: + return value; + } +} diff --git a/packages/admin-ui/src/lib/react/src/hooks/use-injector.ts b/packages/admin-ui/src/lib/react/src/hooks/use-injector.ts new file mode 100644 index 0000000000..445909ce97 --- /dev/null +++ b/packages/admin-ui/src/lib/react/src/hooks/use-injector.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { HostedComponentContext } from '../react-component-host.directive'; + +export function useInjector(token: any) { + const context = useContext(HostedComponentContext); + const instance = context?.injector.get(token); + if (!instance) { + throw new Error(`Could not inject ${token.name ?? token.toString()}`); + } + return instance; +} diff --git a/packages/admin-ui/src/lib/react/src/hooks/use-query.ts b/packages/admin-ui/src/lib/react/src/hooks/use-query.ts new file mode 100644 index 0000000000..ed9016404e --- /dev/null +++ b/packages/admin-ui/src/lib/react/src/hooks/use-query.ts @@ -0,0 +1,56 @@ +import { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { DataService } from '@vendure/admin-ui/core'; +import { DocumentNode } from 'graphql/index'; +import { useContext, useState, useCallback, useEffect } from 'react'; +import { Observable } from 'rxjs'; +import { HostedComponentContext } from '../react-component-host.directive'; + +export function useQuery = Record>( + query: DocumentNode | TypedDocumentNode, + variables?: V, +) { + const { data, loading, error, refetch } = useDataService( + dataService => dataService.query(query, variables).stream$, + ); + return { data, loading, error, refetch }; +} + +export function useMutation = Record>( + mutation: DocumentNode | TypedDocumentNode, +) { + const { data, loading, error, refetch } = useDataService(dataService => dataService.mutate(mutation)); + return { data, loading, error, refetch }; +} + +function useDataService = Record>( + operation: (dataService: DataService) => Observable, +) { + const context = useContext(HostedComponentContext); + const dataService = context?.injector.get(DataService); + if (!dataService) { + throw new Error('No DataService found in HostedComponentContext'); + } + + const [data, setData] = useState(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + + const runQuery = useCallback(() => { + setLoading(true); + operation(dataService).subscribe({ + next: (res: any) => { + setData(res.data); + }, + error: err => { + setError(err.message); + setLoading(false); + }, + }); + }, []); + + useEffect(() => { + runQuery(); + }, [runQuery]); + + return { data, loading, error, refetch: runQuery }; +} diff --git a/packages/admin-ui/src/lib/react/src/public_api.ts b/packages/admin-ui/src/lib/react/src/public_api.ts new file mode 100644 index 0000000000..01aba4931f --- /dev/null +++ b/packages/admin-ui/src/lib/react/src/public_api.ts @@ -0,0 +1,7 @@ +// This file was generated by the build-public-api.ts script +export * from './adapters'; +export * from './hooks/use-form-control'; +export * from './hooks/use-injector'; +export * from './hooks/use-query'; +export * from './react-component-host.directive'; +export * from './types'; diff --git a/packages/admin-ui/src/lib/react/src/react-component-host.directive.ts b/packages/admin-ui/src/lib/react/src/react-component-host.directive.ts new file mode 100644 index 0000000000..55c804ae2f --- /dev/null +++ b/packages/admin-ui/src/lib/react/src/react-component-host.directive.ts @@ -0,0 +1,44 @@ +import { Directive, ElementRef, Injector, Input } from '@angular/core'; +import { ComponentProps, createContext, createElement, ElementType } from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { HostedReactComponentContext } from './types'; + +export const HostedComponentContext = createContext(null); + +/** + * Based on https://netbasal.com/using-react-in-angular-applications-1bb907ecac91 + */ +@Directive({ + selector: '[vdrReactComponentHost]', + standalone: true, +}) +export class ReactComponentHostDirective { + @Input('vdrReactComponentHost') reactComponent: Comp; + @Input() props: ComponentProps; + + private root: Root | null = null; + + constructor(private host: ElementRef, private injector: Injector) {} + + async ngOnChanges() { + const Comp = this.reactComponent; + + if (!this.root) { + this.root = createRoot(this.host.nativeElement); + } + + this.root.render( + createElement( + HostedComponentContext.Provider, + { + value: { ...this.props, injector: this.injector }, + }, + createElement(Comp, this.props), + ), + ); + } + + ngOnDestroy() { + this.root?.unmount(); + } +} diff --git a/packages/admin-ui/src/lib/react/src/types.ts b/packages/admin-ui/src/lib/react/src/types.ts new file mode 100644 index 0000000000..fd0be55956 --- /dev/null +++ b/packages/admin-ui/src/lib/react/src/types.ts @@ -0,0 +1,13 @@ +import { Injector } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { CustomField } from '@vendure/admin-ui/core'; + +export interface ReactFormInputProps { + formControl: FormControl; + readonly: boolean; + config: CustomField & Record; +} + +export interface HostedReactComponentContext extends ReactFormInputProps { + injector: Injector; +} diff --git a/packages/dev-server/test-plugins/experimental-ui/ReactNumberInput.tsx b/packages/dev-server/test-plugins/experimental-ui/ReactNumberInput.tsx new file mode 100644 index 0000000000..2a50474fe8 --- /dev/null +++ b/packages/dev-server/test-plugins/experimental-ui/ReactNumberInput.tsx @@ -0,0 +1,22 @@ +import { NotificationService } from '@vendure/admin-ui/core'; +import { useFormControl, ReactFormInputProps, useInjector } from '@vendure/admin-ui/react'; +import React from 'react'; + +export function ReactNumberInput({ readonly }: ReactFormInputProps) { + const { value, setFormValue } = useFormControl(); + const notificationService = useInjector(NotificationService); + const handleChange = (e: React.ChangeEvent) => { + const val = +e.target.value; + if (val === 0) { + notificationService.error('Cannot be zero'); + } else { + setFormValue(val); + } + }; + return ( +
+ This is a React component! + +
+ ); +} diff --git a/packages/dev-server/test-plugins/experimental-ui/ui-extensions.ts b/packages/dev-server/test-plugins/experimental-ui/ui-extensions.ts new file mode 100644 index 0000000000..5f121cd413 --- /dev/null +++ b/packages/dev-server/test-plugins/experimental-ui/ui-extensions.ts @@ -0,0 +1,33 @@ +import { addNavMenuItem, addNavMenuSection } from '@vendure/admin-ui/core'; +import { registerReactFormInputComponent } from '@vendure/admin-ui/react'; + +import { ReactNumberInput } from './ReactNumberInput'; + +export default [ + addNavMenuSection( + { + id: 'greeter', + label: 'My Extensions', + items: [ + { + id: 'greeter', + label: 'Greeter', + routerLink: ['/extensions/greet'], + icon: 'cursor-hand-open', + }, + ], + }, + // Add this section before the "settings" section + 'settings', + ), + addNavMenuItem( + { + id: 'reviews', + label: 'Product Reviews', + routerLink: ['/extensions/reviews'], + icon: 'star', + }, + 'marketing', + ), + registerReactFormInputComponent('react-number-input', ReactNumberInput), +]; diff --git a/packages/dev-server/tsconfig.json b/packages/dev-server/tsconfig.json index e08181bee5..b5588f8da2 100644 --- a/packages/dev-server/tsconfig.json +++ b/packages/dev-server/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig", "compilerOptions": { "module": "commonjs", - "sourceMap": true + "sourceMap": true, + "jsx": "react", }, "exclude": [ "node_modules" diff --git a/yarn.lock b/yarn.lock index 6099410b4b..18014b92f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5388,6 +5388,11 @@ "@types/node" "*" kleur "^3.0.3" +"@types/prop-types@*": + version "15.7.5" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" + integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== + "@types/qs@*": version "6.9.7" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" @@ -5398,6 +5403,15 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/react@^18.2.21": + version "18.2.21" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9" + integrity sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/resize-observer-browser@^0.1.3": version "0.1.7" resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz#294aaadf24ac6580b8fbd1fe3ab7b59fe85f9ef3" @@ -5413,6 +5427,11 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== +"@types/scheduler@*": + version "0.16.3" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" + integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ== + "@types/semver@^6.2.2": version "6.2.3" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-6.2.3.tgz#5798ecf1bec94eaa64db39ee52808ec0693315aa" @@ -8218,6 +8237,11 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" + integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== + csv-parse@*: version "5.4.0" resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.4.0.tgz#6793210a4a49a9a74b3fde3f9d00f3f52044fd89" @@ -12890,7 +12914,7 @@ long@^5.0.0: resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== -loose-envify@^1.0.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -15929,6 +15953,14 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-dom@^18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" + integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.0" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -15939,6 +15971,13 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react@^18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== + dependencies: + loose-envify "^1.1.0" + read-cmd-shim@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz#640a08b473a49043e394ae0c7a34dd822c73b9bb" @@ -16568,6 +16607,13 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" +scheduler@^0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" + integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== + dependencies: + loose-envify "^1.1.0" + schema-utils@^3.1.1, schema-utils@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe"