diff --git a/.changeset/pink-falcons-serve.md b/.changeset/pink-falcons-serve.md new file mode 100644 index 0000000000..8cc6859c25 --- /dev/null +++ b/.changeset/pink-falcons-serve.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder-react': minor +--- + +Embed scaffolder workflow in other components diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md index adc097775b..85ba2c937a 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -21,6 +21,7 @@ import { JsonValue } from '@backstage/types'; import { Observable } from '@backstage/types'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; +import { ReactNode } from 'react'; import { SetStateAction } from 'react'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { TaskStep } from '@backstage/plugin-scaffolder-common'; @@ -79,6 +80,9 @@ export type CustomFieldValidator = ( }, ) => void | Promise; +// @alpha (undocumented) +export const EmbeddableWorkflow: (props: WorkflowProps) => JSX.Element; + // @alpha export const extractSchemaFromStep: (inputStep: JsonObject) => { uiSchema: UiSchema; @@ -342,7 +346,7 @@ export const SecretsContextProvider: ({ }: PropsWithChildren<{}>) => JSX.Element; // @alpha -export const Stepper: (props: StepperProps) => JSX.Element; +export const Stepper: (stepperProps: StepperProps) => JSX.Element; // @alpha export type StepperProps = { @@ -351,7 +355,12 @@ export type StepperProps = { templateName?: string; FormProps?: FormProps; initialState?: Record; - onComplete: (values: Record) => Promise; + onCreate: (values: Record) => Promise; + components?: { + ReviewStateComponent?: (props: ReviewStateProps) => JSX.Element; + createButtonText?: ReactNode; + reviewButtonText?: ReactNode; + }; }; // @alpha @@ -418,6 +427,13 @@ export const useFormDataFromQuery: ( initialState?: Record, ) => [Record, Dispatch>>]; +// @alpha (undocumented) +export const useTemplateParameterSchema: (templateRef: string) => { + manifest: TemplateParameterSchema | undefined; + loading: boolean; + error: Error | undefined; +}; + // @alpha export const useTemplateSchema: (manifest: TemplateParameterSchema) => { steps: ParsedTemplateSchema[]; @@ -426,5 +442,20 @@ export const useTemplateSchema: (manifest: TemplateParameterSchema) => { // @public export const useTemplateSecrets: () => ScaffolderUseTemplateSecrets; +// @alpha (undocumented) +export const Workflow: (workflowProps: WorkflowProps) => JSX.Element | null; + +// @alpha (undocumented) +export type WorkflowProps = { + title?: string; + description?: string; + namespace: string; + templateName: string; + onError(error: Error | undefined): JSX.Element | null; +} & Pick< + StepperProps, + 'extensions' | 'FormProps' | 'components' | 'onCreate' | 'initialState' +>; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index 229d0dfd5c..93ffd18d51 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -58,6 +58,7 @@ "json-schema-library": "^7.3.9", "lodash": "^4.17.21", "qs": "^6.9.4", + "react-use": "^17.2.4", "zen-observable": "^0.10.0", "zod": "~3.18.0", "zod-to-json-schema": "~3.18.0" diff --git a/plugins/scaffolder-react/src/index.ts b/plugins/scaffolder-react/src/index.ts index 0b053e6bf4..41a1298b37 100644 --- a/plugins/scaffolder-react/src/index.ts +++ b/plugins/scaffolder-react/src/index.ts @@ -21,3 +21,4 @@ export * from './api'; export * from './hooks'; export * from './next'; +export type { FormProps } from './next'; diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx index 786f45b05d..5dbce3243f 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx @@ -33,7 +33,7 @@ describe('Stepper', () => { }; const { getByText } = await renderInTestApp( - , + , ); for (const step of manifest.steps) { @@ -51,7 +51,7 @@ describe('Stepper', () => { }; const { getByRole } = await renderInTestApp( - , + , ); expect(getByRole('button', { name: 'Next' })).toBeInTheDocument(); @@ -91,7 +91,7 @@ describe('Stepper', () => { }; const { getByRole } = await renderInTestApp( - , + , ); await fireEvent.change(getByRole('textbox', { name: 'name' }), { @@ -162,7 +162,7 @@ describe('Stepper', () => { title: 'React JSON Schema Form Test', }; - const onComplete = jest.fn(async (values: Record) => { + const onCreate = jest.fn(async (values: Record) => { expect(values).toEqual({ first: { repository: 'Repo' }, second: { owner: 'Owner' }, @@ -172,7 +172,7 @@ describe('Stepper', () => { const { getByRole } = await renderInTestApp( { await fireEvent.click(getByRole('button', { name: 'Create' })); }); - expect(onComplete).toHaveBeenCalled(); + expect(onCreate).toHaveBeenCalled(); }); it('should render custom field extensions properly', async () => { @@ -229,7 +229,7 @@ describe('Stepper', () => { , ); @@ -266,7 +266,7 @@ describe('Stepper', () => { , ); @@ -308,7 +308,7 @@ describe('Stepper', () => { }); const { getByRole } = await renderInTestApp( - , + , ); expect(getByRole('textbox', { name: 'firstName' })).toHaveValue('John'); @@ -331,12 +331,12 @@ describe('Stepper', () => { title: 'initialize formData', }; - const onComplete = jest.fn(async (values: Record) => { + const onCreate = jest.fn(async (values: Record) => { expect(values).toHaveProperty('firstName'); }); const { getByRole } = await renderInTestApp( - , + , ); await act(async () => { @@ -352,4 +352,44 @@ describe('Stepper', () => { // flush promises return new Promise(process.nextTick); }); + + it('should override the Create and Review button text', async () => { + const manifest: TemplateParameterSchema = { + title: 'Custom Fields', + steps: [ + { + title: 'Test', + schema: { + properties: { + name: { + type: 'string', + }, + }, + }, + }, + ], + }; + + const { getByRole } = await renderInTestApp( + Make, + reviewButtonText: Inspect, + }} + />, + ); + + await act(async () => { + await fireEvent.click(getByRole('button', { name: 'Inspect' })); + }); + + expect(getByRole('button', { name: 'Make' })).toBeInTheDocument(); + + await act(async () => { + await fireEvent.click(getByRole('button', { name: 'Make' })); + }); + }); }); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index cde299da7e..950c9720de 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -24,17 +24,16 @@ import { } from '@material-ui/core'; import { type IChangeEvent, withTheme } from '@rjsf/core-v5'; import { ErrorSchema, FieldValidation } from '@rjsf/utils'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState, type ReactNode } from 'react'; import { NextFieldExtensionOptions } from '../../extensions'; import { TemplateParameterSchema } from '../../../types'; import { createAsyncValidators } from './createAsyncValidators'; +import type { FormProps } from '../../types'; +import { ReviewState, type ReviewStateProps } from '../ReviewState'; import { useTemplateSchema } from '../../hooks/useTemplateSchema'; -import { ReviewState } from '../ReviewState'; +import { useFormDataFromQuery } from '../../hooks/useFormDataFromQuery'; import validator from '@rjsf/validator-ajv6'; -import { useFormDataFromQuery } from '../../hooks'; -import { FormProps } from '../../types'; - const useStyles = makeStyles(theme => ({ backButton: { marginRight: theme.spacing(1), @@ -60,8 +59,12 @@ export type StepperProps = { templateName?: string; FormProps?: FormProps; initialState?: Record; - - onComplete: (values: Record) => Promise; + onCreate: (values: Record) => Promise; + components?: { + ReviewStateComponent?: (props: ReviewStateProps) => JSX.Element; + createButtonText?: ReactNode; + reviewButtonText?: ReactNode; + }; }; // TODO(blam): We require here, as the types in this package depend on @rjsf/core explicitly @@ -73,7 +76,15 @@ const Form = withTheme(require('@rjsf/material-ui-v5').Theme); * The `Stepper` component is the Wizard that is rendered when a user selects a template * @alpha */ -export const Stepper = (props: StepperProps) => { + +export const Stepper = (stepperProps: StepperProps) => { + const { components = {}, ...props } = stepperProps; + const { + ReviewStateComponent = ReviewState, + createButtonText = 'Create', + reviewButtonText = 'Review', + } = components; + const analytics = useAnalytics(); const { steps } = useTemplateSchema(props.manifest); const apiHolder = useApiHolder(); @@ -177,13 +188,13 @@ export const Stepper = (props: StepperProps) => { Back ) : ( <> - +
diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx new file mode 100644 index 0000000000..03604881d2 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider } from '@backstage/core-app-api'; +import { + MockAnalyticsApi, + renderInTestApp, + TestApiRegistry, +} from '@backstage/test-utils'; +import { act, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { Workflow } from './Workflow'; +import { analyticsApiRef } from '@backstage/core-plugin-api'; +import { ScaffolderApi } from '../../../api/types'; +import { scaffolderApiRef } from '../../../api/ref'; + +const scaffolderApiMock: jest.Mocked = { + scaffold: jest.fn(), + getTemplateParameterSchema: jest.fn(), + getIntegrationsList: jest.fn(), + getTask: jest.fn(), + streamLogs: jest.fn(), + listActions: jest.fn(), + listTasks: jest.fn(), +}; + +const analyticsMock = new MockAnalyticsApi(); +const apis = TestApiRegistry.from( + [scaffolderApiRef, scaffolderApiMock], + [analyticsApiRef, analyticsMock], +); + +describe('', () => { + it('should complete a workflow', async () => { + const onCreate = jest.fn(); + const onError = jest.fn(); + scaffolderApiMock.scaffold.mockResolvedValue({ taskId: 'xyz' }); + + scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({ + steps: [ + { + title: 'Step 1', + schema: { + properties: { + name: { + type: 'string', + }, + }, + }, + }, + { + title: 'Step 2', + schema: { + properties: { + age: { + type: 'string', + }, + }, + }, + }, + ], + title: 'React JSON Schema Form Test', + }); + + const { getByRole, getAllByRole, getByText } = await renderInTestApp( + + ( +

This is a different wrapper for the review page

+ ), + reviewButtonText: Onwards, + createButtonText: Make, + }} + extensions={[]} + /> +
, + ); + + // Test template title is overriden + expect(getByRole('heading', { level: 2 }).innerHTML).toBe( + 'Different title than template', + ); + + const nameInput = getByRole('textbox', { + name: 'name', + }) as HTMLInputElement; + + expect(nameInput).toBeInTheDocument(); + + expect(nameInput.value).toBe('prefilled-name'); + + await act(async () => { + fireEvent.click(getByRole('button', { name: 'Next' })); + }); + + const ageInput = getByRole('textbox', { name: 'age' }) as HTMLInputElement; + + expect(ageInput.value).toBe('53'); + + await act(async () => { + fireEvent.click(getByRole('button', { name: 'Onwards' })); + }); + + expect( + getByText('This is a different wrapper for the review page'), + ).toBeDefined(); + + await act(async () => { + fireEvent.click(getAllByRole('button')[1] as HTMLButtonElement); + }); + + expect(onCreate).toHaveBeenCalledWith({ + name: 'prefilled-name', + age: '53', + }); + }); +}); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx new file mode 100644 index 0000000000..cca69ab7b3 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useEffect } from 'react'; +import { + Content, + InfoCard, + MarkdownContent, + Progress, +} from '@backstage/core-components'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema'; +import { Stepper, type StepperProps } from '../Stepper/Stepper'; +import { SecretsContextProvider } from '../../../secrets/SecretsContext'; + +const useStyles = makeStyles(() => ({ + markdown: { + /** to make the styles for React Markdown not leak into the description */ + '& :first-child': { + marginTop: 0, + }, + '& :last-child': { + marginBottom: 0, + }, + }, +})); + +/** + * @alpha + */ +export type WorkflowProps = { + title?: string; + description?: string; + namespace: string; + templateName: string; + onError(error: Error | undefined): JSX.Element | null; +} & Pick< + StepperProps, + 'extensions' | 'FormProps' | 'components' | 'onCreate' | 'initialState' +>; + +/** + * @alpha + */ +export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { + const { title, description, namespace, templateName, ...props } = + workflowProps; + + const styles = useStyles(); + const templateRef = stringifyEntityRef({ + kind: 'Template', + namespace: namespace, + name: templateName, + }); + + const errorApi = useApi(errorApiRef); + + const { loading, manifest, error } = useTemplateParameterSchema(templateRef); + + useEffect(() => { + if (error) { + errorApi.post(new Error(`Failed to load template, ${error}`)); + } + }, [error, errorApi]); + + if (error) { + return props.onError(error); + } + + return ( + + {loading && } + {manifest && ( + + } + noPadding + titleTypographyProps={{ component: 'h2' }} + > + + + )} + + ); +}; + +/** + * @alpha + */ +export const EmbeddableWorkflow = (props: WorkflowProps) => ( + + + +); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/index.ts b/plugins/scaffolder-react/src/next/components/Workflow/index.ts new file mode 100644 index 0000000000..193a6417fc --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Workflow/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { Workflow, EmbeddableWorkflow, type WorkflowProps } from './Workflow'; diff --git a/plugins/scaffolder-react/src/next/components/index.ts b/plugins/scaffolder-react/src/next/components/index.ts index c710bd8891..babe3b4dcb 100644 --- a/plugins/scaffolder-react/src/next/components/index.ts +++ b/plugins/scaffolder-react/src/next/components/index.ts @@ -17,3 +17,4 @@ export * from './Stepper'; export * from './TemplateCard'; export * from './ReviewState'; export * from './TemplateGroup'; +export * from './Workflow'; diff --git a/plugins/scaffolder-react/src/next/hooks/index.ts b/plugins/scaffolder-react/src/next/hooks/index.ts index 78a4d2a777..ca8ce10eff 100644 --- a/plugins/scaffolder-react/src/next/hooks/index.ts +++ b/plugins/scaffolder-react/src/next/hooks/index.ts @@ -18,3 +18,4 @@ export { useTemplateSchema, type ParsedTemplateSchema, } from './useTemplateSchema'; +export { useTemplateParameterSchema } from './useTemplateParameterSchema'; diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts new file mode 100644 index 0000000000..36b8b1e0bc --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts @@ -0,0 +1,32 @@ +import { useApi } from '@backstage/core-plugin-api'; + +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import useAsync from 'react-use/lib/useAsync'; +import { scaffolderApiRef } from '../../api/ref'; + +/** + * @alpha + */ +export const useTemplateParameterSchema = (templateRef: string) => { + const scaffolderApi = useApi(scaffolderApiRef); + const { value, loading, error } = useAsync( + () => scaffolderApi.getTemplateParameterSchema(templateRef), + [scaffolderApi, templateRef], + ); + + return { manifest: value, loading, error }; +}; diff --git a/plugins/scaffolder-react/src/next/index.ts b/plugins/scaffolder-react/src/next/index.ts index a18649f506..ea009ea556 100644 --- a/plugins/scaffolder-react/src/next/index.ts +++ b/plugins/scaffolder-react/src/next/index.ts @@ -15,6 +15,6 @@ */ export * from './components'; export * from './extensions'; -export * from './types'; +export type { FormProps } from './types'; export * from './lib'; export * from './hooks'; diff --git a/plugins/scaffolder/src/next/Router/Router.tsx b/plugins/scaffolder/src/next/Router/Router.tsx index 784e17dd55..f91e56bba7 100644 --- a/plugins/scaffolder/src/next/Router/Router.tsx +++ b/plugins/scaffolder/src/next/Router/Router.tsx @@ -26,7 +26,7 @@ import { import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '../TemplateListPage/TemplateGroups'; import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default'; -import { FormProps } from '../types'; +import { type FormProps } from '../types'; import { nextSelectedTemplateRouteRef } from '../routes'; /** diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx index c4e8a51e1f..24c529b094 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx @@ -13,20 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect } from 'react'; -import { - Page, - Header, - Content, - Progress, - InfoCard, - MarkdownContent, -} from '@backstage/core-components'; +import React from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { AnalyticsContext, - errorApiRef, useApi, useRouteRef, useRouteRefParams, @@ -34,48 +25,21 @@ import { import { scaffolderApiRef, useTemplateSecrets, -} from '@backstage/plugin-scaffolder-react'; -import useAsync from 'react-use/lib/useAsync'; -import { makeStyles } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; -import { - Stepper, NextFieldExtensionOptions, } from '@backstage/plugin-scaffolder-react'; import { JsonValue } from '@backstage/types'; -import { FormProps } from '../types'; +import { type FormProps } from '../types'; import { nextRouteRef } from '../routes'; import { scaffolderTaskRouteRef, selectedTemplateRouteRef } from '../../routes'; +import { Header, Page } from '@backstage/core-components'; +import { Workflow } from '@backstage/plugin-scaffolder-react'; type TemplateWizardPageProps = { customFieldExtensions: NextFieldExtensionOptions[]; FormProps?: FormProps; }; -const useStyles = makeStyles(() => ({ - markdown: { - /** to make the styles for React Markdown not leak into the description */ - '& :first-child': { - marginTop: 0, - }, - '& :last-child': { - marginBottom: 0, - }, - }, -})); - -const useTemplateParameterSchema = (templateRef: string) => { - const scaffolderApi = useApi(scaffolderApiRef); - const { value, loading, error } = useAsync( - () => scaffolderApi.getTemplateParameterSchema(templateRef), - [scaffolderApi, templateRef], - ); - - return { manifest: value, loading, error }; -}; - export const TemplateWizardPage = (props: TemplateWizardPageProps) => { - const styles = useStyles(); const rootRef = useRouteRef(nextRouteRef); const taskRoute = useRouteRef(scaffolderTaskRouteRef); const { secrets } = useTemplateSecrets(); @@ -91,10 +55,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { name: templateName, }); - const errorApi = useApi(errorApiRef); - const { loading, manifest, error } = useTemplateParameterSchema(templateRef); - - const onComplete = async (values: Record) => { + const onCreate = async (values: Record) => { const { taskId } = await scaffolderApi.scaffold({ templateRef, values, @@ -104,15 +65,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { navigate(taskRoute({ taskId })); }; - useEffect(() => { - if (error) { - errorApi.post(new Error(`Failed to load template, ${error}`)); - } - }, [error, errorApi]); - - if (error) { - return ; - } + const onError = () => ; return ( @@ -122,29 +75,14 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { title="Create a new component" subtitle="Create new software components using standard templates in your organization" /> - - {loading && } - {manifest && ( - - } - noPadding - titleTypographyProps={{ component: 'h2' }} - > - - - )} - + ); diff --git a/yarn.lock b/yarn.lock index a61e12bd31..b543acf479 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7262,6 +7262,7 @@ __metadata: json-schema-library: ^7.3.9 lodash: ^4.17.21 qs: ^6.9.4 + react-use: ^17.2.4 zen-observable: ^0.10.0 zod: ~3.18.0 zod-to-json-schema: ~3.18.0