diff --git a/eslint.config.js b/eslint.config.js index 6640e919..0ac4e0d8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -238,5 +238,5 @@ export default tseslint.config( }, ], }, - } + }, ); diff --git a/package-lock.json b/package-lock.json index 169b8e0c..ba014842 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@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", @@ -3984,12 +3983,6 @@ "dev": true, "license": "MIT" }, - "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==", - "license": "MIT" - }, "node_modules/@snyk/github-codeowners": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@snyk/github-codeowners/-/github-codeowners-1.1.0.tgz", diff --git a/package.json b/package.json index 955084d8..2520a963 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "@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", diff --git a/src/components/FormButtons.tsx b/src/components/FormButtons.tsx new file mode 100644 index 00000000..382f1bc4 --- /dev/null +++ b/src/components/FormButtons.tsx @@ -0,0 +1,43 @@ +import { FormState } from "@/hooks/useFormState"; +import { Button } from "@stacklok/ui-kit"; +import { FlipBackward } from "@untitled-ui/icons-react"; + +type Props = { + canSubmit: boolean; + formErrorMessage?: string; + formSideNote?: string; + formState: FormState; + children?: React.ReactNode; + isPending: boolean; +}; +export function FormButtons({ + formErrorMessage, + formState, + canSubmit, + isPending, + children, + formSideNote, +}: Props) { + return ( +
+ {formSideNote &&
{formSideNote}
} + {formErrorMessage && ( +
{formErrorMessage}
+ )} + {formState.isDirty && ( + + )} + {children} + +
+ ); +} diff --git a/src/features/dashboard-messages/components/__tests__/table-messages.alerts.test.tsx b/src/features/dashboard-messages/components/__tests__/table-messages.alerts.test.tsx index 54fd00ce..b1a5ef9a 100644 --- a/src/features/dashboard-messages/components/__tests__/table-messages.alerts.test.tsx +++ b/src/features/dashboard-messages/components/__tests__/table-messages.alerts.test.tsx @@ -14,8 +14,8 @@ it("shows zero in alerts counts when no alerts", async () => { mockConversation({ alertsConfig: { numAlerts: 0 }, }), - ]) - ) + ]), + ), ); render(); @@ -26,12 +26,12 @@ it("shows zero in alerts counts when no alerts", async () => { expect( screen.getByRole("button", { name: /malicious packages count/i, - }) + }), ).toHaveTextContent("0"); expect( screen.getByRole("button", { name: /secrets count/i, - }) + }), ).toHaveTextContent("0"); }); @@ -42,8 +42,8 @@ it("shows count of malicious alerts in row", async () => { mockConversation({ alertsConfig: { numAlerts: 10, type: "malicious" }, }), - ]) - ) + ]), + ), ); render(); @@ -54,7 +54,7 @@ it("shows count of malicious alerts in row", async () => { expect( screen.getByRole("button", { name: /malicious packages count/i, - }) + }), ).toHaveTextContent("10"); }); @@ -65,8 +65,8 @@ it("shows count of secret alerts in row", async () => { mockConversation({ alertsConfig: { numAlerts: 10, type: "secret" }, }), - ]) - ) + ]), + ), ); render(); @@ -77,6 +77,6 @@ it("shows count of secret alerts in row", async () => { expect( screen.getByRole("button", { name: /secrets count/i, - }) + }), ).toHaveTextContent("10"); }); diff --git a/src/features/dashboard-messages/components/__tests__/table-messages.pagination.test.tsx b/src/features/dashboard-messages/components/__tests__/table-messages.pagination.test.tsx index 837b5bd3..67d2f622 100644 --- a/src/features/dashboard-messages/components/__tests__/table-messages.pagination.test.tsx +++ b/src/features/dashboard-messages/components/__tests__/table-messages.pagination.test.tsx @@ -12,16 +12,16 @@ it("only displays a limited number of items in the table", async () => { server.use( http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/messages"), () => { return HttpResponse.json( - Array.from({ length: 30 }).map(() => mockConversation()) + Array.from({ length: 30 }).map(() => mockConversation()), ); - }) + }), ); render(); await waitFor(() => { expect( - within(screen.getByTestId("messages-table")).getAllByRole("row") + within(screen.getByTestId("messages-table")).getAllByRole("row"), ).toHaveLength(16); }); }); @@ -30,9 +30,9 @@ it("allows pagination", async () => { server.use( http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/messages"), () => { return HttpResponse.json( - Array.from({ length: 35 }).map(() => mockConversation()) + Array.from({ length: 35 }).map(() => mockConversation()), ); - }) + }), ); render(); @@ -42,10 +42,10 @@ it("allows pagination", async () => { await userEvent.click(screen.getByRole("button", { name: /next/i })); expect( - within(screen.getByTestId("messages-table")).getAllByRole("row").length + within(screen.getByTestId("messages-table")).getAllByRole("row").length, ).toBeLessThan(16); }, - { timeout: 5000 } + { timeout: 5000 }, ); // on the last page, we cannot go further @@ -63,7 +63,7 @@ it("allows pagination", async () => { expect(screen.getByRole("button", { name: /next/i })).toBeEnabled(); expect( - within(screen.getByTestId("messages-table")).getAllByRole("row").length + within(screen.getByTestId("messages-table")).getAllByRole("row").length, ).toEqual(16); }); }); diff --git a/src/features/dashboard-messages/components/__tests__/table-messages.test.tsx b/src/features/dashboard-messages/components/__tests__/table-messages.test.tsx index 2bbd1d80..47248cf0 100644 --- a/src/features/dashboard-messages/components/__tests__/table-messages.test.tsx +++ b/src/features/dashboard-messages/components/__tests__/table-messages.test.tsx @@ -12,7 +12,7 @@ it.each(TABLE_MESSAGES_COLUMNS)("contains $children header", async (column) => { expect( screen.getByRole("columnheader", { name: column.children as string, - }) + }), ).toBeVisible(); }); }); diff --git a/src/features/dashboard-messages/components/__tests__/tabs-messages.test.tsx b/src/features/dashboard-messages/components/__tests__/tabs-messages.test.tsx index b78be57f..5e795e17 100644 --- a/src/features/dashboard-messages/components/__tests__/tabs-messages.test.tsx +++ b/src/features/dashboard-messages/components/__tests__/tabs-messages.test.tsx @@ -15,7 +15,7 @@ test("shows correct count of all packages", async () => { type: "secret", numAlerts: 1, }, - }) + }), ), ...Array.from({ length: 13 }).map(() => mockConversation({ @@ -23,16 +23,16 @@ test("shows correct count of all packages", async () => { type: "malicious", numAlerts: 1, }, - }) + }), ), ]); - }) + }), ); const { getByRole } = render(
foo
-
+ , ); await waitFor(() => { @@ -50,16 +50,16 @@ test("shows correct count of malicious packages", async () => { type: "malicious", numAlerts: 1, }, - }) - ) + }), + ), ); - }) + }), ); const { getByRole } = render(
foo
-
+ , ); await waitFor(() => { @@ -77,16 +77,16 @@ test("shows correct count of secret packages", async () => { type: "secret", numAlerts: 1, }, - }) - ) + }), + ), ); - }) + }), ); const { getByRole } = render(
foo
-
+ , ); await waitFor(() => { diff --git a/src/features/dashboard-messages/lib/filter-messages-by-substring.ts b/src/features/dashboard-messages/lib/filter-messages-by-substring.ts index 1bf7f6ae..207c41e9 100644 --- a/src/features/dashboard-messages/lib/filter-messages-by-substring.ts +++ b/src/features/dashboard-messages/lib/filter-messages-by-substring.ts @@ -2,7 +2,7 @@ import { Conversation } from "@/api/generated"; export function filterMessagesBySubstring( conversation: Conversation, - substring: string | null + substring: string | null, ): boolean { if (conversation == null) return false; if (substring === null) return true; @@ -14,10 +14,10 @@ export function filterMessagesBySubstring( if (curr.answer) acc.push(curr.answer.message); return acc; }, - [] as string[] + [] as string[], ); return [...messages].some((i) => - i?.toLowerCase().includes(substring.toLowerCase()) + i?.toLowerCase().includes(substring.toLowerCase()), ); } diff --git a/src/features/header/components/__tests__/header-status-menu.test.tsx b/src/features/header/components/__tests__/header-status-menu.test.tsx index 81e12524..a957694c 100644 --- a/src/features/header/components/__tests__/header-status-menu.test.tsx +++ b/src/features/header/components/__tests__/header-status-menu.test.tsx @@ -13,8 +13,8 @@ describe("CardCodegateStatus", () => { test("renders 'healthy' state", async () => { server.use( http.get(mswEndpoint("/health"), () => - HttpResponse.json({ status: "healthy" }) - ) + HttpResponse.json({ status: "healthy" }), + ), ); const { getByRole } = renderComponent(); @@ -27,8 +27,8 @@ describe("CardCodegateStatus", () => { test("renders 'unhealthy' state", async () => { server.use( http.get(mswEndpoint("/health"), () => - HttpResponse.json({ status: null }) - ) + HttpResponse.json({ status: null }), + ), ); const { getByRole } = renderComponent(); @@ -51,9 +51,9 @@ describe("CardCodegateStatus", () => { test("renders 'error' state when version check request fails", async () => { server.use( http.get(mswEndpoint("/health"), () => - HttpResponse.json({ status: "healthy" }) + HttpResponse.json({ status: "healthy" }), ), - http.get(mswEndpoint("/api/v1/version"), () => HttpResponse.error()) + http.get(mswEndpoint("/api/v1/version"), () => HttpResponse.error()), ); const { getByRole } = renderComponent(); @@ -66,7 +66,7 @@ describe("CardCodegateStatus", () => { test("renders 'up to date' state", async () => { server.use( http.get(mswEndpoint("/health"), () => - HttpResponse.json({ status: "healthy" }) + HttpResponse.json({ status: "healthy" }), ), http.get(mswEndpoint("/api/v1/version"), () => HttpResponse.json({ @@ -74,8 +74,8 @@ describe("CardCodegateStatus", () => { latest_version: "foo", is_latest: true, error: null, - }) - ) + }), + ), ); const { getByRole, getByText } = renderComponent(); @@ -95,7 +95,7 @@ describe("CardCodegateStatus", () => { test("renders 'update available' state", async () => { server.use( http.get(mswEndpoint("/health"), () => - HttpResponse.json({ status: "healthy" }) + HttpResponse.json({ status: "healthy" }), ), http.get(mswEndpoint("/api/v1/version"), () => HttpResponse.json({ @@ -103,8 +103,8 @@ describe("CardCodegateStatus", () => { latest_version: "bar", is_latest: false, error: null, - }) - ) + }), + ), ); const { getByRole } = renderComponent(); @@ -121,7 +121,7 @@ describe("CardCodegateStatus", () => { expect(role).toBeVisible(); expect(role).toHaveAttribute( "href", - "https://docs.codegate.ai/how-to/install#upgrade-codegate" + "https://docs.codegate.ai/how-to/install#upgrade-codegate", ); }); }); @@ -129,7 +129,7 @@ describe("CardCodegateStatus", () => { test("renders 'version check error' state", async () => { server.use( http.get(mswEndpoint("/health"), () => - HttpResponse.json({ status: "healthy" }) + HttpResponse.json({ status: "healthy" }), ), http.get(mswEndpoint("/api/v1/version"), () => HttpResponse.json({ @@ -137,8 +137,8 @@ describe("CardCodegateStatus", () => { latest_version: "bar", is_latest: false, error: "foo", - }) - ) + }), + ), ); const { getByRole, getByText } = renderComponent(); diff --git a/src/features/workspace/components/__tests__/archive-workspace.test.tsx b/src/features/workspace/components/__tests__/archive-workspace.test.tsx index e0fde0c3..9a6b32de 100644 --- a/src/features/workspace/components/__tests__/archive-workspace.test.tsx +++ b/src/features/workspace/components/__tests__/archive-workspace.test.tsx @@ -8,7 +8,7 @@ import { mswEndpoint } from "@/test/msw-endpoint"; test("has correct buttons when not archived", async () => { const { getByRole, queryByRole } = render( - + , ); expect(getByRole("button", { name: /archive/i })).toBeVisible(); @@ -17,7 +17,7 @@ test("has correct buttons when not archived", async () => { test("has correct buttons when archived", async () => { const { getByRole } = render( - + , ); expect(getByRole("button", { name: /restore/i })).toBeVisible(); expect(getByRole("button", { name: /permanently delete/i })).toBeVisible(); @@ -25,7 +25,7 @@ test("has correct buttons when archived", async () => { test("can archive workspace", async () => { const { getByText, getByRole } = render( - + , ); await userEvent.click(getByRole("button", { name: /archive/i })); @@ -37,7 +37,7 @@ test("can archive workspace", async () => { test("can restore archived workspace", async () => { const { getByText, getByRole } = render( - + , ); await userEvent.click(getByRole("button", { name: /restore/i })); @@ -49,7 +49,7 @@ test("can restore archived workspace", async () => { test("can permanently delete archived workspace", async () => { const { getByText, getByRole } = render( - + , ); await userEvent.click(getByRole("button", { name: /permanently delete/i })); @@ -76,11 +76,11 @@ test("can't archive active workspace", async () => { last_updated: new Date(Date.now()).toISOString(), }, ], - }) - ) + }), + ), ); const { getByRole } = render( - + , ); await waitFor(() => { @@ -91,7 +91,7 @@ test("can't archive active workspace", async () => { test("can't archive default workspace", async () => { const { getByRole } = render( - + , ); await waitFor(() => { diff --git a/src/features/workspace/components/__tests__/workspace-custom-instructions.test.tsx b/src/features/workspace/components/__tests__/workspace-custom-instructions.test.tsx index 01ebbecc..b9d83c1f 100644 --- a/src/features/workspace/components/__tests__/workspace-custom-instructions.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-custom-instructions.test.tsx @@ -22,7 +22,7 @@ vi.mock("@monaco-editor/react", () => { const renderComponent = () => render( - + , ); test("can update custom instructions", async () => { @@ -31,8 +31,8 @@ test("can update custom instructions", async () => { mswEndpoint("/api/v1/workspaces/:workspace_name/custom-instructions"), () => { return HttpResponse.json({ prompt: "initial prompt from server" }); - } - ) + }, + ), ); const { getByRole, getByText } = renderComponent(); @@ -53,15 +53,15 @@ test("can update custom instructions", async () => { mswEndpoint("/api/v1/workspaces/:workspace_name/custom-instructions"), () => { return HttpResponse.json({ prompt: "new prompt from test" }); - } - ) + }, + ), ); await userEvent.click(getByRole("button", { name: /Save/i })); await waitFor(() => { expect( - getByText(/successfully updated custom instructions/i) + getByText(/successfully updated custom instructions/i), ).toBeVisible(); }); diff --git a/src/features/workspace/components/__tests__/workspace-name.test.tsx b/src/features/workspace/components/__tests__/workspace-name.test.tsx index 27e32f93..d83ff4cb 100644 --- a/src/features/workspace/components/__tests__/workspace-name.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-name.test.tsx @@ -8,7 +8,7 @@ import { mswEndpoint } from "@/test/msw-endpoint"; test("can rename workspace", async () => { const { getByRole, getByText } = render( - + , ); const input = getByRole("textbox", { name: /workspace name/i }); @@ -26,7 +26,7 @@ test("can rename workspace", async () => { test("can't rename archived workspace", async () => { const { getByRole } = render( - + , ); expect(getByRole("textbox", { name: /workspace name/i })).toBeDisabled(); @@ -44,22 +44,32 @@ test("can't rename active workspace", async () => { last_updated: new Date(Date.now()).toISOString(), }, ], - }) - ) + }), + ), ); const { getByRole } = render( - + , ); expect(getByRole("textbox", { name: /workspace name/i })).toBeDisabled(); expect(getByRole("button", { name: /save/i })).toBeDisabled(); }); -test("can't rename default workspace", async () => { +test("can't rename archived workspace", async () => { const { getByRole } = render( - + , + ); + + expect(getByRole("textbox", { name: /workspace name/i })).toBeDisabled(); + expect(getByRole("button", { name: /save/i })).toBeDisabled(); +}); + +test("can't rename default workspace", async () => { + const { getByRole, getByText } = render( + , ); expect(getByRole("textbox", { name: /workspace name/i })).toBeDisabled(); expect(getByRole("button", { name: /save/i })).toBeDisabled(); + expect(getByText(/cannot rename the default workspace/i)).toBeVisible(); }); diff --git a/src/features/workspace/components/workspace-custom-instructions.tsx b/src/features/workspace/components/workspace-custom-instructions.tsx index 80211c12..03fa3e03 100644 --- a/src/features/workspace/components/workspace-custom-instructions.tsx +++ b/src/features/workspace/components/workspace-custom-instructions.tsx @@ -15,6 +15,7 @@ import { DialogTitle, DialogTrigger, FieldGroup, + Form, Input, Link, Loader, @@ -25,6 +26,7 @@ import { } from "@stacklok/ui-kit"; import { Dispatch, + FormEvent, SetStateAction, useCallback, useContext, @@ -52,6 +54,8 @@ import Fuse from "fuse.js"; import systemPrompts from "../constants/built-in-system-prompts.json"; import { MessageTextSquare02, SearchMd } from "@untitled-ui/icons-react"; import { invalidateQueries } from "@/lib/react-query-utils"; +import { useFormState } from "@/hooks/useFormState"; +import { FormButtons } from "@/components/FormButtons"; type DarkModeContextValue = { preference: "dark" | "light" | null; @@ -119,7 +123,8 @@ function useCustomInstructionsValue({ options: V1GetWorkspaceCustomInstructionsData; queryClient: QueryClient; }) { - const [value, setValue] = useState(initialValue); + const formState = useFormState({ prompt: initialValue }); + const { values, updateFormValues } = formState; // Subscribe to changes in the workspace system prompt value in the query cache useEffect(() => { @@ -134,18 +139,18 @@ function useCustomInstructionsValue({ ) ) { const prompt: string | null = getCustomInstructionsFromEvent(event); - if (prompt === value || prompt === null) return; + if (prompt === values.prompt || prompt === null) return; - setValue(prompt); + updateFormValues({ prompt }); } }); return () => { return unsubscribe(); }; - }, [options, queryClient, value]); + }, [options, queryClient, updateFormValues, values.prompt]); - return { value, setValue }; + return { ...formState }; } type PromptPresetPickerProps = { @@ -280,12 +285,14 @@ export function WorkspaceCustomInstructions({ const { mutateAsync, isPending: isMutationPending } = useMutationSetWorkspaceCustomInstructions(options); - const { setValue, value } = useCustomInstructionsValue({ + const formState = useCustomInstructionsValue({ initialValue: customInstructionsResponse?.prompt ?? "", options, queryClient, }); + const { values, updateFormValues } = formState; + const handleSubmit = useCallback( (value: string) => { mutateAsync( @@ -302,59 +309,68 @@ export function WorkspaceCustomInstructions({ ); return ( - - - Custom instructions - - Pass custom instructions to your LLM to augment its behavior, and save - time & tokens. - -
- {isCustomInstructionsPending ? ( - - ) : ( - setValue(v ?? "")} - height="20rem" - defaultLanguage="Markdown" - theme={theme} - className={twMerge("bg-base", isArchived ? "opacity-25" : "")} - /> - )} -
-
- - - - - - - { - setValue(prompt); - }} - /> - - - - - - -
+
{ + e.preventDefault(); + handleSubmit(values.prompt); + }} + validationBehavior="aria" + > + + + Custom instructions + + Pass custom instructions to your LLM to augment its behavior, and + save time & tokens. + +
+ {isCustomInstructionsPending ? ( + + ) : ( + updateFormValues({ prompt: v ?? "" })} + height="20rem" + defaultLanguage="Markdown" + theme={theme} + className={twMerge("bg-base", isArchived ? "opacity-25" : "")} + /> + )} +
+
+ + + + + + + + { + updateFormValues({ prompt }); + }} + /> + + + + + + +
+
); } diff --git a/src/features/workspace/components/workspace-name.tsx b/src/features/workspace/components/workspace-name.tsx index 8c56bb2d..adb8add7 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -1,14 +1,17 @@ +import { + Card, + CardBody, + CardFooter, + Form, + Input, + Label, + TextField, +} from "@stacklok/ui-kit"; import { useMutationCreateWorkspace } from "../hooks/use-mutation-create-workspace"; 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, - }), -}); +import { twMerge } from "tailwind-merge"; +import { useFormState } from "@/hooks/useFormState"; +import { FormButtons } from "@/components/FormButtons"; export function WorkspaceName({ className, @@ -22,28 +25,57 @@ export function WorkspaceName({ const navigate = useNavigate(); const { mutateAsync, isPending, error } = useMutationCreateWorkspace(); const errorMsg = error?.detail ? `${error?.detail}` : ""; + const formState = useFormState({ + workspaceName, + }); + const { values, updateFormValues } = formState; + const isDefault = workspaceName === "default"; + const isUneditable = isArchived || isPending || isDefault; - const initialData = { workspaceName }; + const handleSubmit = (event: { preventDefault: () => void }) => { + event.preventDefault(); - const handleSubmit = (data: Static) => { mutateAsync( - { body: { name: workspaceName, rename_to: data.workspaceName } }, + { body: { name: workspaceName, rename_to: values.workspaceName } }, { - onSuccess: () => navigate(`/workspace/${data.workspaceName}`), + onSuccess: () => navigate(`/workspace/${values.workspaceName}`), }, ); }; return ( - + validationBehavior="aria" + data-testid="workspace-name" + > + + + updateFormValues({ workspaceName })} + > + + + + + + + + + ); } diff --git a/src/features/workspace/hooks/use-preferred-preferred-model.ts b/src/features/workspace/hooks/use-preferred-preferred-model.ts index 4d917084..5fdbf774 100644 --- a/src/features/workspace/hooks/use-preferred-preferred-model.ts +++ b/src/features/workspace/hooks/use-preferred-preferred-model.ts @@ -28,7 +28,7 @@ export const usePreferredModelWorkspace = (workspaceName: string) => { () => ({ path: { workspace_name: workspaceName }, }), - [workspaceName] + [workspaceName], ); const { data, isPending } = usePreferredModel(options); diff --git a/src/forms/BaseSchemaForm.tsx b/src/forms/BaseSchemaForm.tsx deleted file mode 100644 index 5dfbb69c..00000000 --- a/src/forms/BaseSchemaForm.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index a217d358..00000000 --- a/src/forms/FormCard.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 0febe7b9..00000000 --- a/src/forms/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 939e394a..00000000 --- a/src/forms/rerenders/ObjectRenderer.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 936662b7..00000000 --- a/src/forms/rerenders/VerticalLayout.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 736a7bb4..00000000 --- a/src/forms/rerenders/controls/Checkbox.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index eb783536..00000000 --- a/src/forms/rerenders/controls/EnumField.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 8ebe7080..00000000 --- a/src/forms/rerenders/controls/TextField.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 501cd963..00000000 --- a/src/forms/rerenders/renderChildren.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - 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 deleted file mode 100644 index edae117c..00000000 --- a/src/forms/rerenders/utils.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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/hooks/useFormState.ts b/src/hooks/useFormState.ts new file mode 100644 index 00000000..71589fbb --- /dev/null +++ b/src/hooks/useFormState.ts @@ -0,0 +1,30 @@ +import { isEqual } from "lodash"; +import { useState } from "react"; + +export type FormState = { + values: T; + updateFormValues: (newState: Partial) => void; + resetForm: () => void; + isDirty: boolean; +}; + +export function useFormState>( + initialValues: Values, +): FormState { + // this could be replaced with some form library later + const [values, setValues] = useState(initialValues); + const updateFormValues = (newState: Partial) => { + setValues((prevState: Values) => ({ + ...prevState, + ...newState, + })); + }; + + const resetForm = () => { + setValues(initialValues); + }; + + const isDirty = !isEqual(values, initialValues); + + return { values, updateFormValues, resetForm, isDirty }; +} diff --git a/src/main.tsx b/src/main.tsx index 0628ebc3..fea4b6cd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -37,5 +37,5 @@ createRoot(document.getElementById("root")!).render( - + , );