diff --git a/package-lock.json b/package-lock.json index 1d646470..805f9f08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,18 @@ "version": "0.14.1", "dependencies": { "@hey-api/client-fetch": "^0.7.1", + "@jsonforms/core": "^3.5.1", + "@jsonforms/react": "^3.5.1", + "@jsonforms/vanilla-renderers": "^3.5.1", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@sinclair/typebox": "^0.34.16", "@stacklok/ui-kit": "^1.0.1-1", "@tanstack/react-query": "^5.64.1", "@tanstack/react-query-devtools": "^5.66.0", + "@types/lodash": "^4.17.15", "@types/prismjs": "^1.26.5", "@types/react-syntax-highlighter": "^15.5.13", "@untitled-ui/icons-react": "^0.1.4", @@ -23,6 +28,7 @@ "date-fns": "^4.1.0", "fuse.js": "^7.0.0", "highlight.js": "^11.11.1", + "lodash": "^4.17.21", "prismjs": "^1.29.0", "react": "19.0.0", "react-dom": "19.0.0", @@ -1511,6 +1517,62 @@ "dev": true, "license": "MIT" }, + "node_modules/@jsonforms/core": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.5.1.tgz", + "integrity": "sha512-Jrq/UcfvKsAprLJ+9TMFa8pKsfdyv3dAw85XstSNRcjDT19LreBlhVqIvTvtgZidg8Iet3yqy5xlNnB+XyrvrQ==", + "dependencies": { + "@types/json-schema": "^7.0.3", + "ajv": "^8.6.1", + "ajv-formats": "^2.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/@jsonforms/core/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@jsonforms/core/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/@jsonforms/react": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.5.1.tgz", + "integrity": "sha512-fQwCpzyNcf0FruYhc46dK6GfCcX09HkRX2PGYir7dllQPRI1axHd6t98To/h+48/L2PkFdRMGMCcIsoTXNC1qg==", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@jsonforms/core": "3.5.1", + "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@jsonforms/vanilla-renderers": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.5.1.tgz", + "integrity": "sha512-lqb678VFZuns6E60SjxgtRo8Cx1E5MdloPEz9HSSZ2JRzotjXUXiUr/93b/9XPlgQFJ5DMJl5gEyV1VYC2BcwQ==", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@jsonforms/core": "3.5.1", + "@jsonforms/react": "3.5.1", + "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@monaco-editor/loader": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz", @@ -3903,6 +3965,11 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.34.16", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.16.tgz", + "integrity": "sha512-rIljj8VPYAfn26ANY+5pCNVBPiv6hSufuKGe46y65cJZpvx8vHvPXlU0Q/Le4OGtlNaL8Jg2FuhtvQX18lSIqA==" + }, "node_modules/@snyk/github-codeowners": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@snyk/github-codeowners/-/github-codeowners-1.1.0.tgz", @@ -4415,9 +4482,13 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -5087,6 +5158,42 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/ansi-escapes": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", @@ -6499,7 +6606,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -6544,6 +6650,21 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/fastq": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", @@ -7900,8 +8021,8 @@ }, "node_modules/lodash": { "version": "4.17.21", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.castarray": { "version": "4.4.0", @@ -10532,6 +10653,14 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -12600,7 +12729,6 @@ "version": "3.24.1", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", - "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 09c29047..e03aeb43 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,18 @@ }, "dependencies": { "@hey-api/client-fetch": "^0.7.1", + "@jsonforms/core": "^3.5.1", + "@jsonforms/react": "^3.5.1", + "@jsonforms/vanilla-renderers": "^3.5.1", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@sinclair/typebox": "^0.34.16", "@stacklok/ui-kit": "^1.0.1-1", "@tanstack/react-query": "^5.64.1", "@tanstack/react-query-devtools": "^5.66.0", + "@types/lodash": "^4.17.15", "@types/prismjs": "^1.26.5", "@types/react-syntax-highlighter": "^15.5.13", "@untitled-ui/icons-react": "^0.1.4", @@ -35,6 +40,7 @@ "date-fns": "^4.1.0", "fuse.js": "^7.0.0", "highlight.js": "^11.11.1", + "lodash": "^4.17.21", "prismjs": "^1.29.0", "react": "19.0.0", "react-dom": "19.0.0", diff --git a/src/App.test.tsx b/src/App.test.tsx index 4f1c8079..a224397b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -93,7 +93,7 @@ describe("App", () => { ); const workspaceSelectionButton = screen.getByRole("button", { - name: "Workspace default", + name: "Active workspace default", }); await waitFor(() => expect(workspaceSelectionButton).toBeVisible()); diff --git a/src/features/workspace/components/workspace-heading.tsx b/src/features/workspace/components/workspace-heading.tsx index 880f9959..8daaf93a 100644 --- a/src/features/workspace/components/workspace-heading.tsx +++ b/src/features/workspace/components/workspace-heading.tsx @@ -5,7 +5,7 @@ export function WorkspaceHeading({ title, children, }: { - title: string; + title: React.ReactNode; children?: React.ReactNode; }) { return ( diff --git a/src/features/workspace/components/workspace-name.tsx b/src/features/workspace/components/workspace-name.tsx index 513ac47b..8c56bb2d 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -1,17 +1,14 @@ -import { - Button, - Card, - CardBody, - CardFooter, - Form, - Input, - Label, - TextField, -} from "@stacklok/ui-kit"; -import { twMerge } from "tailwind-merge"; import { useMutationCreateWorkspace } from "../hooks/use-mutation-create-workspace"; -import { FormEvent, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { Static, Type } from "@sinclair/typebox"; +import { FormCard } from "@/forms/FormCard"; + +const schema = Type.Object({ + workspaceName: Type.String({ + title: "Workspace name", + minLength: 1, + }), +}); export function WorkspaceName({ className, @@ -23,59 +20,30 @@ export function WorkspaceName({ isArchived: boolean | undefined; }) { const navigate = useNavigate(); - const { mutateAsync, isPending, error, reset } = useMutationCreateWorkspace(); + const { mutateAsync, isPending, error } = useMutationCreateWorkspace(); const errorMsg = error?.detail ? `${error?.detail}` : ""; - const [name, setName] = useState(() => workspaceName); - // NOTE: When navigating from one settings page to another, this value is not - // updated, hence the synchronization effect - useEffect(() => { - setName(workspaceName); - reset(); - }, [reset, workspaceName]); + const initialData = { workspaceName }; - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); + const handleSubmit = (data: Static) => { mutateAsync( - { body: { name: workspaceName, rename_to: name } }, + { body: { name: workspaceName, rename_to: data.workspaceName } }, { - onSuccess: () => navigate(`/workspace/${name}`), + onSuccess: () => navigate(`/workspace/${data.workspaceName}`), }, ); }; return ( -
- - - - - - {errorMsg &&
{errorMsg}
} -
-
- - - -
-
+ ); } diff --git a/src/features/workspace/components/workspaces-selection.tsx b/src/features/workspace/components/workspaces-selection.tsx index b49cad1c..b142452c 100644 --- a/src/features/workspace/components/workspaces-selection.tsx +++ b/src/features/workspace/components/workspaces-selection.tsx @@ -46,7 +46,8 @@ export function WorkspacesSelection() { return ( setIsOpen(test)}> diff --git a/src/forms/BaseSchemaForm.tsx b/src/forms/BaseSchemaForm.tsx new file mode 100644 index 00000000..5dfbb69c --- /dev/null +++ b/src/forms/BaseSchemaForm.tsx @@ -0,0 +1,20 @@ +import type { JsonSchema, ValidationMode } from "@jsonforms/core"; +import type { + JsonFormsInitStateProps, + JsonFormsReactProps, +} from "@jsonforms/react"; +import { JsonForms } from "@jsonforms/react"; + +type FormProps = Omit< + JsonFormsInitStateProps & + JsonFormsReactProps & { + validationMode?: ValidationMode; + schema: JsonSchema; + isDisabled?: boolean; + }, + "readonly" +>; + +export function BaseSchemaForm({ isDisabled = false, ...props }: FormProps) { + return ; +} diff --git a/src/forms/FormCard.tsx b/src/forms/FormCard.tsx new file mode 100644 index 00000000..a217d358 --- /dev/null +++ b/src/forms/FormCard.tsx @@ -0,0 +1,69 @@ +import { Button, Card, CardBody, CardFooter, Form } from "@stacklok/ui-kit"; +import { twMerge } from "tailwind-merge"; +import { ComponentProps, useState } from "react"; +import { SchemaForm } from "@/forms"; +import { Static, TSchema } from "@sinclair/typebox"; +import { isEqual } from "lodash"; +import { FlipBackward } from "@untitled-ui/icons-react"; + +export function FormCard({ + className, + isDisabled = false, + schema, + initialData, + formError = null, + onSubmit, + isPending = false, + ...props +}: { + /* + * The error message to display at the bottom of the form + */ + formError?: string | null; + className?: string; + isDisabled?: boolean; + schema: T; + initialData: Static; + onSubmit: (data: Static) => void; + isPending?: boolean; +} & Omit, "onSubmit">) { + const [data, setData] = useState(() => initialData); + const isDirty = !isEqual(data, initialData); + + return ( +
{ + e.preventDefault(); + onSubmit(data); + }} + > + + + setData(data)} + isDisabled={isDisabled} + validationMode="ValidateAndShow" + /> + + + {formError &&
{formError}
} + {isDirty && ( + + )} + +
+
+
+ ); +} diff --git a/src/forms/index.tsx b/src/forms/index.tsx new file mode 100644 index 00000000..0febe7b9 --- /dev/null +++ b/src/forms/index.tsx @@ -0,0 +1,49 @@ +export { BaseSchemaForm } from "./BaseSchemaForm"; + +import type { + JsonFormsRendererRegistryEntry, + JsonSchema, + ValidationMode, +} from "@jsonforms/core"; + +import Checkbox from "./rerenders/controls/Checkbox"; +import TextField from "./rerenders/controls/TextField"; +import EnumField from "./rerenders/controls/EnumField"; +import ObjectRenderer from "./rerenders/ObjectRenderer"; +import VerticalLayout from "./rerenders/VerticalLayout"; + +import { BaseSchemaForm } from "./BaseSchemaForm"; +import { JsonFormsInitStateProps, JsonFormsReactProps } from "@jsonforms/react"; +import { JSX } from "react/jsx-runtime"; +import { vanillaCells, vanillaRenderers } from "@jsonforms/vanilla-renderers"; + +const formRenderers: JsonFormsRendererRegistryEntry[] = [ + TextField, + Checkbox, + EnumField, + + // layouts + ObjectRenderer, + VerticalLayout, + + // default stuff, not based on mui but not ui-kit based either + // must be last, otherwise it would override our custom stuff + ...vanillaRenderers, +]; + +const formCells = [...vanillaCells]; + +type SchemaFormProps = Omit< + JSX.IntrinsicAttributes & + JsonFormsInitStateProps & + JsonFormsReactProps & { validationMode?: ValidationMode }, + "renderers" | "schema" +> & { schema: T; isDisabled?: boolean }; + +export function SchemaForm({ + ...props +}: SchemaFormProps) { + return ( + + ); +} diff --git a/src/forms/rerenders/ObjectRenderer.tsx b/src/forms/rerenders/ObjectRenderer.tsx new file mode 100644 index 00000000..939e394a --- /dev/null +++ b/src/forms/rerenders/ObjectRenderer.tsx @@ -0,0 +1,63 @@ +import isEmpty from "lodash/isEmpty"; +import { + findUISchema, + GroupLayout, + isObjectControl, + RankedTester, + rankWith, + StatePropsOfControlWithDetail, +} from "@jsonforms/core"; +import { JsonFormsDispatch, withJsonFormsDetailProps } from "@jsonforms/react"; +import { useMemo } from "react"; + +const ObjectRenderer = ({ + renderers, + cells, + uischemas, + schema, + label, + path, + visible, + enabled, + uischema, + rootSchema, +}: StatePropsOfControlWithDetail) => { + const detailUiSchema = useMemo( + () => + findUISchema( + uischemas ?? [], + schema, + uischema.scope, + path, + "Group", + uischema, + rootSchema, + ), + [uischemas, schema, path, uischema, rootSchema], + ); + if (isEmpty(path)) { + detailUiSchema.type = "VerticalLayout"; + } else { + (detailUiSchema as GroupLayout).label = label; + } + return ( +
+ +
+ ); +}; + +export const tester: RankedTester = rankWith(2, isObjectControl); +const renderer = withJsonFormsDetailProps(ObjectRenderer); + +const config = { tester, renderer }; + +export default config; diff --git a/src/forms/rerenders/VerticalLayout.tsx b/src/forms/rerenders/VerticalLayout.tsx new file mode 100644 index 00000000..936662b7 --- /dev/null +++ b/src/forms/rerenders/VerticalLayout.tsx @@ -0,0 +1,28 @@ +import { + RankedTester, + rankWith, + RendererProps, + uiTypeIs, + VerticalLayout, +} from "@jsonforms/core"; +import { withJsonFormsLayoutProps } from "@jsonforms/react"; +import { renderChildren } from "./renderChildren"; + +function VerticalLayoutRenderer({ + uischema, + enabled, + schema, + path, +}: RendererProps) { + const verticalLayout = uischema as VerticalLayout; + + return
{renderChildren(verticalLayout, schema, path, enabled)}
; +} + +export const renderer = withJsonFormsLayoutProps(VerticalLayoutRenderer, false); + +export const tester: RankedTester = rankWith(1, uiTypeIs("VerticalLayout")); + +const config = { tester, renderer }; + +export default config; diff --git a/src/forms/rerenders/controls/Checkbox.tsx b/src/forms/rerenders/controls/Checkbox.tsx new file mode 100644 index 00000000..736a7bb4 --- /dev/null +++ b/src/forms/rerenders/controls/Checkbox.tsx @@ -0,0 +1,37 @@ +import type { ControlProps, RankedTester } from "@jsonforms/core"; +import { isBooleanControl, rankWith } from "@jsonforms/core"; +import { withJsonFormsControlProps } from "@jsonforms/react"; +import { Checkbox, Tooltip, TooltipInfoButton } from "@stacklok/ui-kit"; +import { TooltipTrigger } from "react-aria-components"; + +import { getRACPropsFromJSONForms, JsonFormsError } from "../utils"; + +const CheckboxControl = (props: ControlProps) => { + const { label, description } = props; + const { value: isSelected, ...mappedProps } = getRACPropsFromJSONForms(props); + + return ( + <> + +
+ {label} + {description !== undefined && description.length > 0 ? ( + + + {description} + + ) : null} +
+
+ + + ); +}; + +const tester: RankedTester = rankWith(2, isBooleanControl); + +const renderer = withJsonFormsControlProps(CheckboxControl); + +const config = { tester, renderer }; + +export default config; diff --git a/src/forms/rerenders/controls/EnumField.tsx b/src/forms/rerenders/controls/EnumField.tsx new file mode 100644 index 00000000..eb783536 --- /dev/null +++ b/src/forms/rerenders/controls/EnumField.tsx @@ -0,0 +1,58 @@ +import type { + ControlProps, + EnumCellProps, + OwnPropsOfEnum, + RankedTester, +} from "@jsonforms/core"; +import { isEnumControl, rankWith } from "@jsonforms/core"; +import { withJsonFormsEnumProps } from "@jsonforms/react"; +import { Select, SelectButton } from "@stacklok/ui-kit"; +import { getRACPropsFromJSONForms, LabelWithDescription } from "../utils"; + +// eslint-disable-next-line react-refresh/only-export-components +const EnumFieldControl = ( + props: EnumCellProps & OwnPropsOfEnum & ControlProps, +) => { + const items = [ + { + label: "Select an option", + value: "", + }, + ...(props.options ?? []), + ].map(({ label, value }) => ({ + textValue: label, + id: value, + })); + const mappedProps = getRACPropsFromJSONForms(props); + + return ( + + ); +}; + +const tester: RankedTester = (...args) => { + const x = rankWith(2, isEnumControl)(...args); + return x; +}; + +// @ts-expect-error the types are not properly handled here for some reason +// for pragmatic reasons I ignored this +const renderer = withJsonFormsEnumProps(EnumFieldControl, false); + +const config = { tester, renderer }; + +export default config; diff --git a/src/forms/rerenders/controls/TextField.tsx b/src/forms/rerenders/controls/TextField.tsx new file mode 100644 index 00000000..8ebe7080 --- /dev/null +++ b/src/forms/rerenders/controls/TextField.tsx @@ -0,0 +1,26 @@ +import type { ControlProps, RankedTester } from "@jsonforms/core"; +import { isStringControl, rankWith } from "@jsonforms/core"; +import { withJsonFormsControlProps } from "@jsonforms/react"; +import { Input, TextField } from "@stacklok/ui-kit"; + +import { getRACPropsFromJSONForms, LabelWithDescription } from "../utils"; + +// eslint-disable-next-line react-refresh/only-export-components +const TextFieldControl = (props: ControlProps) => { + const mappedProps = getRACPropsFromJSONForms(props); + + return ( + + + + + ); +}; + +const tester: RankedTester = rankWith(1, isStringControl); + +const renderer = withJsonFormsControlProps(TextFieldControl); + +const config = { tester, renderer }; + +export default config; diff --git a/src/forms/rerenders/renderChildren.tsx b/src/forms/rerenders/renderChildren.tsx new file mode 100644 index 00000000..501cd963 --- /dev/null +++ b/src/forms/rerenders/renderChildren.tsx @@ -0,0 +1,62 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import isEmpty from "lodash/isEmpty"; +import { JsonSchema, Layout } from "@jsonforms/core"; +import { JsonFormsDispatch, useJsonForms } from "@jsonforms/react"; +export interface RenderChildrenProps { + layout: Layout; + schema: JsonSchema; + className: string; + path: string; +} + +export const renderChildren = ( + layout: Layout, + schema: JsonSchema, + path: string, + enabled: boolean, +) => { + if (isEmpty(layout.elements)) { + return []; + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + const { renderers, cells } = useJsonForms(); + + return layout.elements.map((child, index) => { + return ( +
+ +
+ ); + }); +}; diff --git a/src/forms/rerenders/utils.tsx b/src/forms/rerenders/utils.tsx new file mode 100644 index 00000000..edae117c --- /dev/null +++ b/src/forms/rerenders/utils.tsx @@ -0,0 +1,62 @@ +import type { ControlProps } from "@jsonforms/core"; +import { Description, FieldError, Label } from "@stacklok/ui-kit"; + +export function getRACPropsFromJSONForms(props: ControlProps) { + const { id, errors, required, enabled, handleChange, path, data } = props; + + return { + isRequired: required, + isInvalid: errors.length > 0, + id: id, + isDisabled: !enabled, + onChange: (newValue: unknown) => handleChange(path, newValue), + value: data, + }; +} + +/** + * Displays a `jsonforms` validation error if there is one. + * Use when displaying the error in a different place + * than the errors. Otherwise use + */ +export function JsonFormsError({ errors }: ControlProps) { + if (errors.length > 0) { + return {errors}; + } + + return null; +} + +export function JsonFormsDescription(props: ControlProps) { + const { description, errors } = props; + + if (errors.length > 0) { + return ; + } + + if ((description ?? "").length === 0) { + return null; + } + + return ( + + {description} + + ); +} + +export function LabelWithDescription({ + label, + isRequired = false, + ...props +}: ControlProps & { isRequired?: boolean }) { + return ( + + ); +} diff --git a/src/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts index a852dc8f..4d6af386 100644 --- a/src/mocks/msw/handlers.ts +++ b/src/mocks/msw/handlers.ts @@ -14,10 +14,10 @@ export const handlers = [ latest_version: "bar", is_latest: false, error: null, - }) + }), ), http.get(mswEndpoint("/api/v1/version"), () => - HttpResponse.json({ status: "healthy" }) + HttpResponse.json({ status: "healthy" }), ), http.get(mswEndpoint("/api/v1/workspaces/active"), () => HttpResponse.json({ @@ -28,11 +28,11 @@ export const handlers = [ last_updated: new Date(Date.now()).toISOString(), }, ], - }) + }), ), http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/messages"), () => { return HttpResponse.json( - Array.from({ length: 10 }).map(() => mockConversation()) + Array.from({ length: 10 }).map(() => mockConversation()), ); }), http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { @@ -56,25 +56,25 @@ export const handlers = [ }), http.post( mswEndpoint("/api/v1/workspaces/active"), - () => new HttpResponse(null, { status: 204 }) + () => new HttpResponse(null, { status: 204 }), ), http.post( mswEndpoint("/api/v1/workspaces/archive/:workspace_name/recover"), - () => new HttpResponse(null, { status: 204 }) + () => new HttpResponse(null, { status: 204 }), ), http.delete( mswEndpoint("/api/v1/workspaces/:workspace_name"), - () => new HttpResponse(null, { status: 204 }) + () => new HttpResponse(null, { status: 204 }), ), http.delete( mswEndpoint("/api/v1/workspaces/archive/:workspace_name"), - () => new HttpResponse(null, { status: 204 }) + () => new HttpResponse(null, { status: 204 }), ), http.get( mswEndpoint("/api/v1/workspaces/:workspace_name/custom-instructions"), () => { return HttpResponse.json({ prompt: "foo" }); - } + }, ), http.get( mswEndpoint("/api/v1/workspaces/:workspace_name/token-usage"), @@ -99,11 +99,11 @@ export const handlers = [ output_cost: 0.006495, }, }); - } + }, ), http.put( mswEndpoint("/api/v1/workspaces/:workspace_name/custom-instructions"), - () => new HttpResponse(null, { status: 204 }) + () => new HttpResponse(null, { status: 204 }), ), http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/muxes"), () => HttpResponse.json([ @@ -118,34 +118,34 @@ export const handlers = [ model: "davinci", matcher_type: "catch_all", }, - ]) + ]), ), http.put( mswEndpoint("/api/v1/workspaces/:workspace_name/muxes"), - () => new HttpResponse(null, { status: 204 }) + () => new HttpResponse(null, { status: 204 }), ), http.get(mswEndpoint("/api/v1/provider-endpoints/:provider_id/models"), () => - HttpResponse.json(mockedProvidersModels) + HttpResponse.json(mockedProvidersModels), ), http.get(mswEndpoint("/api/v1/provider-endpoints/models"), () => - HttpResponse.json(mockedProvidersModels) + HttpResponse.json(mockedProvidersModels), ), http.get(mswEndpoint("/api/v1/provider-endpoints/:provider_id"), () => - HttpResponse.json(mockedProviders[0]) + HttpResponse.json(mockedProviders[0]), ), http.get(mswEndpoint("/api/v1/provider-endpoints"), () => - HttpResponse.json(mockedProviders) + HttpResponse.json(mockedProviders), ), http.post( mswEndpoint("/api/v1/provider-endpoints"), - () => new HttpResponse(null, { status: 204 }) + () => new HttpResponse(null, { status: 204 }), ), http.put( mswEndpoint("/api/v1/provider-endpoints"), - () => new HttpResponse(null, { status: 204 }) + () => new HttpResponse(null, { status: 204 }), ), http.delete( mswEndpoint("/api/v1/provider-endpoints"), - () => new HttpResponse(null, { status: 204 }) + () => new HttpResponse(null, { status: 204 }), ), ]; diff --git a/src/routes/__tests__/route-workspace.test.tsx b/src/routes/__tests__/route-workspace.test.tsx index 8b2ea6ea..30e80b97 100644 --- a/src/routes/__tests__/route-workspace.test.tsx +++ b/src/routes/__tests__/route-workspace.test.tsx @@ -2,6 +2,7 @@ import { render, waitFor, within } from "@/lib/test-utils"; import { test, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { RouteWorkspace } from "../route-workspace"; +import { useParams } from "react-router-dom"; const mockNavigate = vi.fn(); @@ -27,6 +28,10 @@ vi.mock("@monaco-editor/react", () => { return { default: FakeEditor }; }); +vi.mock("@/features/workspace/hooks/use-active-workspace-name", () => ({ + useActiveWorkspaceName: vi.fn(() => ({ data: "baz" })), +})); + vi.mock("react-router-dom", async () => { const original = await vi.importActual( @@ -35,6 +40,7 @@ vi.mock("react-router-dom", async () => { return { ...original, useNavigate: () => mockNavigate, + useParams: vi.fn(() => ({ name: "foo" })), }; }); @@ -42,7 +48,7 @@ test("renders title", () => { const { getByRole } = renderComponent(); expect( - getByRole("heading", { name: "Workspace settings", level: 4 }), + getByRole("heading", { name: "Workspace settings for foo", level: 4 }), ).toBeVisible(); }); @@ -75,6 +81,9 @@ test("has breadcrumbs", () => { }); test("rename workspace", async () => { + (useParams as unknown as ReturnType).mockReturnValue({ + name: "foo", + }); const { getByRole, getByTestId } = renderComponent(); const workspaceName = getByRole("textbox", { @@ -89,3 +98,51 @@ test("rename workspace", async () => { await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1)); expect(mockNavigate).toHaveBeenCalledWith("/workspace/foo_renamed"); }); + +test("revert changes button", async () => { + (useParams as unknown as ReturnType).mockReturnValue({ + name: "foo", + }); + const { getByRole, getByTestId } = renderComponent(); + + const workspaceName = getByRole("textbox", { + name: /workspace name/i, + }); + await userEvent.type(workspaceName, "_renamed"); + + await waitFor(() => { + expect( + within(getByTestId("workspace-name")).getByRole("button", { + name: /revert changes/i, + }), + ).toBeEnabled(); + }); + + const revertButton = within(getByTestId("workspace-name")).getByRole( + "button", + { + name: /.*revert changes.*/i, + }, + ); + await userEvent.click(revertButton); + + await waitFor(() => { + expect( + within(getByTestId("workspace-name")).getByRole("button", { + name: /save/i, + }), + ).toBeDisabled(); + }); + + expect( + within(getByTestId("workspace-name")).queryByRole("button", { + name: /.*revert changes.*/i, + }), + ).toBe(null); + + expect( + getByRole("textbox", { + name: /workspace name/i, + }), + ).toHaveValue("foo"); +}); diff --git a/src/routes/route-workspace.tsx b/src/routes/route-workspace.tsx index b42ddd24..76e9bf20 100644 --- a/src/routes/route-workspace.tsx +++ b/src/routes/route-workspace.tsx @@ -44,7 +44,13 @@ export function RouteWorkspace() { Workspace Settings - + + Workspace settings for {name} + + } + /> {isArchived ? : null}