From 095e9e3be2662679f986855749348aa9ea197130 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 11 Feb 2025 17:29:40 +0100 Subject: [PATCH 1/7] remove jsonforms --- package-lock.json | 6 -- package.json | 1 - .../workspace/components/workspace-name.tsx | 79 +++++++++++++------ src/forms/BaseSchemaForm.tsx | 20 ----- src/forms/FormCard.tsx | 69 ---------------- src/forms/index.tsx | 49 ------------ src/forms/rerenders/ObjectRenderer.tsx | 63 --------------- src/forms/rerenders/VerticalLayout.tsx | 28 ------- src/forms/rerenders/controls/Checkbox.tsx | 37 --------- src/forms/rerenders/controls/EnumField.tsx | 58 -------------- src/forms/rerenders/controls/TextField.tsx | 26 ------ src/forms/rerenders/renderChildren.tsx | 62 --------------- src/forms/rerenders/utils.tsx | 62 --------------- src/hooks/useFormState.ts | 23 ++++++ 14 files changed, 80 insertions(+), 503 deletions(-) delete mode 100644 src/forms/BaseSchemaForm.tsx delete mode 100644 src/forms/FormCard.tsx delete mode 100644 src/forms/index.tsx delete mode 100644 src/forms/rerenders/ObjectRenderer.tsx delete mode 100644 src/forms/rerenders/VerticalLayout.tsx delete mode 100644 src/forms/rerenders/controls/Checkbox.tsx delete mode 100644 src/forms/rerenders/controls/EnumField.tsx delete mode 100644 src/forms/rerenders/controls/TextField.tsx delete mode 100644 src/forms/rerenders/renderChildren.tsx delete mode 100644 src/forms/rerenders/utils.tsx create mode 100644 src/hooks/useFormState.ts diff --git a/package-lock.json b/package-lock.json index 805f9f08..830239be 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", @@ -3965,11 +3964,6 @@ "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", diff --git a/package.json b/package.json index e03aeb43..af9af0a7 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/features/workspace/components/workspace-name.tsx b/src/features/workspace/components/workspace-name.tsx index 8c56bb2d..1261671f 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -1,14 +1,18 @@ +import { + Button, + 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 { FlipBackward } from "@untitled-ui/icons-react"; export function WorkspaceName({ className, @@ -22,28 +26,59 @@ export function WorkspaceName({ const navigate = useNavigate(); const { mutateAsync, isPending, error } = useMutationCreateWorkspace(); const errorMsg = error?.detail ? `${error?.detail}` : ""; + const { formState, updateFormState, isDirty, resetForm } = useFormState({ + workspaceName, + }); - 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: formState.workspaceName } }, { - onSuccess: () => navigate(`/workspace/${data.workspaceName}`), + onSuccess: () => navigate(`/workspace/${formState.workspaceName}`), }, ); }; return ( - + validationBehavior="aria" + data-testid="workspace-name" + > + + + updateFormState({ workspaceName })} + > + + + + + + {errorMsg &&
{errorMsg}
} + {isDirty && ( + + )} + +
+
+ ); } 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..63ad5d87 --- /dev/null +++ b/src/hooks/useFormState.ts @@ -0,0 +1,23 @@ +import { isEqual } from "lodash"; +import { useState } from "react"; + +export function useFormState>( + initialState: T, +) { + // this could be replaced with some form library later + const [formState, setFormState] = useState(initialState); + const updateFormState = (newState: Partial) => { + setFormState((prevState: T) => ({ + ...prevState, + ...newState, + })); + }; + + const resetForm = () => { + setFormState(initialState); + }; + + const isDirty = !isEqual(formState, initialState); + + return { formState, updateFormState, resetForm, isDirty }; +} From b15d1a0ac0c61cf4e1b099539fc66c0df61f35da Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 11 Feb 2025 17:47:41 +0100 Subject: [PATCH 2/7] extract --- src/components/FormButtons.tsx | 31 +++++++++++++++++++ .../workspace/components/workspace-name.tsx | 24 +++++--------- src/hooks/useFormState.ts | 9 +++++- 3 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 src/components/FormButtons.tsx diff --git a/src/components/FormButtons.tsx b/src/components/FormButtons.tsx new file mode 100644 index 00000000..253d9ef8 --- /dev/null +++ b/src/components/FormButtons.tsx @@ -0,0 +1,31 @@ +import { FormState } from "@/hooks/useFormState"; +import { Button } from "@stacklok/ui-kit"; +import { FlipBackward } from "@untitled-ui/icons-react"; + +type Props = { + canSubmit: boolean; + formErrorMessage?: string; + formState: FormState; +}; +export function FormButtons({ + formErrorMessage, + formState, + canSubmit, +}: Props) { + return ( +
+ {formErrorMessage && ( +
{formErrorMessage}
+ )} + {formState.isDirty && ( + + )} + +
+ ); +} diff --git a/src/features/workspace/components/workspace-name.tsx b/src/features/workspace/components/workspace-name.tsx index 1261671f..3b2fb2a9 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -1,5 +1,4 @@ import { - Button, Card, CardBody, CardFooter, @@ -12,7 +11,7 @@ import { useMutationCreateWorkspace } from "../hooks/use-mutation-create-workspa import { useNavigate } from "react-router-dom"; import { twMerge } from "tailwind-merge"; import { useFormState } from "@/hooks/useFormState"; -import { FlipBackward } from "@untitled-ui/icons-react"; +import { FormButtons } from "@/components/FormButtons"; export function WorkspaceName({ className, @@ -26,9 +25,10 @@ export function WorkspaceName({ const navigate = useNavigate(); const { mutateAsync, isPending, error } = useMutationCreateWorkspace(); const errorMsg = error?.detail ? `${error?.detail}` : ""; - const { formState, updateFormState, isDirty, resetForm } = useFormState({ + const formState2 = useFormState({ workspaceName, }); + const { formState, updateFormState } = formState2; const handleSubmit = (event: { preventDefault: () => void }) => { event.preventDefault(); @@ -64,19 +64,11 @@ export function WorkspaceName({ - {errorMsg &&
{errorMsg}
} - {isDirty && ( - - )} - +
diff --git a/src/hooks/useFormState.ts b/src/hooks/useFormState.ts index 63ad5d87..28fb67e8 100644 --- a/src/hooks/useFormState.ts +++ b/src/hooks/useFormState.ts @@ -1,9 +1,16 @@ import { isEqual } from "lodash"; import { useState } from "react"; +export type FormState = { + formState: T; + updateFormState: (newState: Partial) => void; + resetForm: () => void; + isDirty: boolean; +}; + export function useFormState>( initialState: T, -) { +): FormState { // this could be replaced with some form library later const [formState, setFormState] = useState(initialState); const updateFormState = (newState: Partial) => { From 441165f501baa7295c15c2b581b7c575a2bfd7e8 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 11 Feb 2025 18:07:52 +0100 Subject: [PATCH 3/7] better naming --- .../workspace/components/workspace-name.tsx | 14 ++++++------ src/hooks/useFormState.ts | 22 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/features/workspace/components/workspace-name.tsx b/src/features/workspace/components/workspace-name.tsx index 3b2fb2a9..e5a05259 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -25,18 +25,18 @@ export function WorkspaceName({ const navigate = useNavigate(); const { mutateAsync, isPending, error } = useMutationCreateWorkspace(); const errorMsg = error?.detail ? `${error?.detail}` : ""; - const formState2 = useFormState({ + const formState = useFormState({ workspaceName, }); - const { formState, updateFormState } = formState2; + const { values, updateFormValues } = formState; const handleSubmit = (event: { preventDefault: () => void }) => { event.preventDefault(); mutateAsync( - { body: { name: workspaceName, rename_to: formState.workspaceName } }, + { body: { name: workspaceName, rename_to: values.workspaceName } }, { - onSuccess: () => navigate(`/workspace/${formState.workspaceName}`), + onSuccess: () => navigate(`/workspace/${values.workspaceName}`), }, ); }; @@ -52,12 +52,12 @@ export function WorkspaceName({ updateFormState({ workspaceName })} + onChange={(workspaceName) => updateFormValues({ workspaceName })} > @@ -66,7 +66,7 @@ export function WorkspaceName({ diff --git a/src/hooks/useFormState.ts b/src/hooks/useFormState.ts index 28fb67e8..71589fbb 100644 --- a/src/hooks/useFormState.ts +++ b/src/hooks/useFormState.ts @@ -2,29 +2,29 @@ import { isEqual } from "lodash"; import { useState } from "react"; export type FormState = { - formState: T; - updateFormState: (newState: Partial) => void; + values: T; + updateFormValues: (newState: Partial) => void; resetForm: () => void; isDirty: boolean; }; -export function useFormState>( - initialState: T, -): FormState { +export function useFormState>( + initialValues: Values, +): FormState { // this could be replaced with some form library later - const [formState, setFormState] = useState(initialState); - const updateFormState = (newState: Partial) => { - setFormState((prevState: T) => ({ + const [values, setValues] = useState(initialValues); + const updateFormValues = (newState: Partial) => { + setValues((prevState: Values) => ({ ...prevState, ...newState, })); }; const resetForm = () => { - setFormState(initialState); + setValues(initialValues); }; - const isDirty = !isEqual(formState, initialState); + const isDirty = !isEqual(values, initialValues); - return { formState, updateFormState, resetForm, isDirty }; + return { values, updateFormValues, resetForm, isDirty }; } From d276d3ae59256cde3583d64e1665926a02b27b99 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 11 Feb 2025 18:26:02 +0100 Subject: [PATCH 4/7] reuse form hook for custom instructions --- src/components/FormButtons.tsx | 3 + .../workspace-custom-instructions.tsx | 135 ++++++++++-------- 2 files changed, 78 insertions(+), 60 deletions(-) diff --git a/src/components/FormButtons.tsx b/src/components/FormButtons.tsx index 253d9ef8..c64a6ebb 100644 --- a/src/components/FormButtons.tsx +++ b/src/components/FormButtons.tsx @@ -6,11 +6,13 @@ type Props = { canSubmit: boolean; formErrorMessage?: string; formState: FormState; + children?: React.ReactNode; }; export function FormButtons({ formErrorMessage, formState, canSubmit, + children, }: Props) { return (
@@ -23,6 +25,7 @@ export function FormButtons({ Revert changes )} + {children} diff --git a/src/features/workspace/components/workspace-custom-instructions.tsx b/src/features/workspace/components/workspace-custom-instructions.tsx index 80211c12..b25a43b3 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,67 @@ 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 }); + }} + /> + + + + + + +
+
); } From 6f6410eb8e04cb5859ef824056649343960ef945 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Tue, 11 Feb 2025 18:28:21 +0100 Subject: [PATCH 5/7] add pending state for form buttons --- src/components/FormButtons.tsx | 8 +++++++- .../components/workspace-custom-instructions.tsx | 1 + src/features/workspace/components/workspace-name.tsx | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/FormButtons.tsx b/src/components/FormButtons.tsx index c64a6ebb..ab59da5a 100644 --- a/src/components/FormButtons.tsx +++ b/src/components/FormButtons.tsx @@ -7,11 +7,13 @@ type Props = { formErrorMessage?: string; formState: FormState; children?: React.ReactNode; + isPending: boolean; }; export function FormButtons({ formErrorMessage, formState, canSubmit, + isPending, children, }: Props) { return ( @@ -26,7 +28,11 @@ export function FormButtons({ )} {children} -
diff --git a/src/features/workspace/components/workspace-custom-instructions.tsx b/src/features/workspace/components/workspace-custom-instructions.tsx index b25a43b3..03fa3e03 100644 --- a/src/features/workspace/components/workspace-custom-instructions.tsx +++ b/src/features/workspace/components/workspace-custom-instructions.tsx @@ -344,6 +344,7 @@ export function WorkspaceCustomInstructions({ From 6f50810c009bd56a774b34e4664ad7c135e48600 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Wed, 12 Feb 2025 10:36:03 +0100 Subject: [PATCH 6/7] make workspace name uneditable for the default workspace --- .../components/__tests__/workspace-name.test.tsx | 11 ++++++++++- src/features/workspace/components/workspace-name.tsx | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/features/workspace/components/__tests__/workspace-name.test.tsx b/src/features/workspace/components/__tests__/workspace-name.test.tsx index 84bfc93f..033cf01f 100644 --- a/src/features/workspace/components/__tests__/workspace-name.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-name.test.tsx @@ -55,7 +55,7 @@ test("can't rename active workspace", async () => { expect(getByRole("button", { name: /save/i })).toBeDisabled(); }); -test("can't rename default workspace", async () => { +test("can't rename archived workspace", async () => { const { getByRole } = render( , ); @@ -63,3 +63,12 @@ test("can't rename default workspace", async () => { expect(getByRole("textbox", { name: /workspace name/i })).toBeDisabled(); expect(getByRole("button", { name: /save/i })).toBeDisabled(); }); + +test("can't rename default workspace", async () => { + const { getByRole } = render( + , + ); + + expect(getByRole("textbox", { name: /workspace name/i })).toBeDisabled(); + expect(getByRole("button", { name: /save/i })).toBeDisabled(); +}); diff --git a/src/features/workspace/components/workspace-name.tsx b/src/features/workspace/components/workspace-name.tsx index e53a8f67..e553b486 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -29,6 +29,8 @@ export function WorkspaceName({ workspaceName, }); const { values, updateFormValues } = formState; + const isDefault = workspaceName === "default"; + const isUneditable = isArchived || isPending || isDefault; const handleSubmit = (event: { preventDefault: () => void }) => { event.preventDefault(); @@ -50,13 +52,14 @@ export function WorkspaceName({ updateFormValues({ workspaceName })} > From 0b7cc85c36dc985c0188dda174cead3591352e6d Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Wed, 12 Feb 2025 10:39:17 +0100 Subject: [PATCH 7/7] add sidenote when workspace cannot be renamed --- src/components/FormButtons.tsx | 3 +++ .../workspace/components/__tests__/workspace-name.test.tsx | 3 ++- src/features/workspace/components/workspace-name.tsx | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/FormButtons.tsx b/src/components/FormButtons.tsx index ab59da5a..382f1bc4 100644 --- a/src/components/FormButtons.tsx +++ b/src/components/FormButtons.tsx @@ -5,6 +5,7 @@ import { FlipBackward } from "@untitled-ui/icons-react"; type Props = { canSubmit: boolean; formErrorMessage?: string; + formSideNote?: string; formState: FormState; children?: React.ReactNode; isPending: boolean; @@ -15,9 +16,11 @@ export function FormButtons({ canSubmit, isPending, children, + formSideNote, }: Props) { return (
+ {formSideNote &&
{formSideNote}
} {formErrorMessage && (
{formErrorMessage}
)} diff --git a/src/features/workspace/components/__tests__/workspace-name.test.tsx b/src/features/workspace/components/__tests__/workspace-name.test.tsx index 033cf01f..d83ff4cb 100644 --- a/src/features/workspace/components/__tests__/workspace-name.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-name.test.tsx @@ -65,10 +65,11 @@ test("can't rename archived workspace", async () => { }); test("can't rename default workspace", async () => { - const { getByRole } = render( + 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-name.tsx b/src/features/workspace/components/workspace-name.tsx index e553b486..adb8add7 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -70,6 +70,7 @@ export function WorkspaceName({