diff --git a/client/src/app/components/HookFormPFFields/HookFormPFAddLabels.tsx b/client/src/app/components/HookFormPFFields/HookFormPFAddLabels.tsx new file mode 100644 index 000000000..aa8cff660 --- /dev/null +++ b/client/src/app/components/HookFormPFFields/HookFormPFAddLabels.tsx @@ -0,0 +1,144 @@ +import React from "react"; + +import { + Card, + FormHelperText, + HelperText, + HelperTextItem, + Label, + LabelGroup, + Stack, + StackItem, + TextInput, +} from "@patternfly/react-core"; +import type { FieldValues, Path } from "react-hook-form"; + +import { + type BaseHookFormPFGroupControllerProps, + HookFormPFGroupController, + extractGroupControllerProps, +} from "./HookFormPFGroupController"; + +export type HookFormPFAddLabelsProps< + TFieldValues extends FieldValues, + TName extends Path, +> = BaseHookFormPFGroupControllerProps & { + /** + * Placeholder text for the label input. + */ + inputPlaceholder?: string; + /** + * Aria-label for the label input. + */ + inputAriaLabel?: string; + /** + * Restricted labels for adding, f.e Product inside SBOM create group form. + */ + restrictedLabels?: string[]; +}; + +export const HookFormPFAddLabels = < + TFieldValues extends FieldValues = FieldValues, + TName extends Path = Path, +>( + props: HookFormPFAddLabelsProps, +) => { + const { extractedProps, remainingProps } = extractGroupControllerProps< + TFieldValues, + TName, + HookFormPFAddLabelsProps + >(props); + + const { + inputPlaceholder = "Add label", + inputAriaLabel = "add-label-input", + restrictedLabels = [], + } = remainingProps as { + inputPlaceholder?: string; + inputAriaLabel?: string; + restrictedLabels?: string[]; + }; + + const [newLabel, setNewLabel] = React.useState(""); + const [labelError, setLabelError] = React.useState(null); + + return ( + + {...extractedProps} + renderInput={({ field: { value, onChange } }) => { + const labels = (value ?? []) as string[]; + + const handleAdd = () => { + const trimmed = newLabel.trim(); + if (!trimmed) { + return; + } + if (restrictedLabels.includes(trimmed)) { + setLabelError(`The label '${trimmed}' is reserved`); + return; + } + if (labels.includes(trimmed)) { + setLabelError("Label already exists"); + return; + } + onChange([...labels, trimmed]); + setNewLabel(""); + setLabelError(null); + }; + + const handleDelete = (labelToRemove: string) => { + onChange(labels.filter((l) => l !== labelToRemove)); + }; + + return ( + + Add metadata labels + + + + {labels.map((label) => ( + + ))} + + + + + { + setNewLabel(value); + if (labelError) { + setLabelError(null); + } + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAdd(); + } + }} + /> + {labelError && ( + + + + {labelError} + + + + )} + + + ); + }} + /> + ); +}; diff --git a/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx b/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx index 0fe28575f..c94805a0a 100644 --- a/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx +++ b/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx @@ -22,6 +22,14 @@ export interface BaseHookFormPFGroupControllerProps< TName extends Path, > { control: Control; + /** + * Extra props forwarded directly to react-hook-form's Controller + * (e.g. rules, defaultValue, shouldUnregister, disabled, etc.). + */ + controllerProps?: Omit< + ControllerProps, + "render" | "name" | "control" + >; label?: React.ReactNode; labelIcon?: React.ReactElement; name: TName; @@ -45,6 +53,7 @@ export const HookFormPFGroupController = < TName extends Path = Path, >({ control, + controllerProps, label, labelIcon, name, @@ -59,6 +68,7 @@ export const HookFormPFGroupController = < control={control} name={name} + {...controllerProps} render={({ field, fieldState, formState }) => { const { isDirty, isTouched, error } = fieldState; const shouldDisplayError = @@ -107,6 +117,7 @@ export const extractGroupControllerProps = < } => { const { control, + controllerProps, label, labelIcon, name, @@ -121,6 +132,7 @@ export const extractGroupControllerProps = < return { extractedProps: { control, + controllerProps, labelIcon, label, name, diff --git a/client/src/app/components/HookFormPFFields/index.ts b/client/src/app/components/HookFormPFFields/index.ts index b8a01668b..a45a9f9f0 100644 --- a/client/src/app/components/HookFormPFFields/index.ts +++ b/client/src/app/components/HookFormPFFields/index.ts @@ -2,3 +2,4 @@ export * from "./HookFormPFGroupController"; export * from "./HookFormPFTextInput"; export * from "./HookFormPFTextArea"; export * from "./HookFormPFSelect"; +export * from "./HookFormPFAddLabels"; diff --git a/client/src/app/pages/sbom-list/components/SBOMGroupFormModal.tsx b/client/src/app/pages/sbom-list/components/SBOMGroupFormModal.tsx new file mode 100644 index 000000000..95a65fbd7 --- /dev/null +++ b/client/src/app/pages/sbom-list/components/SBOMGroupFormModal.tsx @@ -0,0 +1,210 @@ +import React from "react"; + +import { + Button, + ButtonVariant, + ExpandableSection, + Form, + FormSelectOption, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Radio, + Stack, + StackItem, +} from "@patternfly/react-core"; +import { useForm } from "react-hook-form"; + +import { + HookFormPFGroupController, + HookFormPFSelect, + HookFormPFTextArea, + HookFormPFTextInput, +} from "@app/components/HookFormPFFields"; +import { HookFormPFAddLabels } from "@app/components/HookFormPFFields/HookFormPFAddLabels"; + +type SBOMGroupFormValues = { + name: string; + parentGroup?: string; + isProduct: "yes" | "no"; + description?: string; + labels: string[]; +}; + +export interface SBOMGroupFormModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (values: SBOMGroupFormValues) => void | Promise; + initialValues?: Partial; + type?: "Create" | "Edit"; +} + +export const SBOMGroupFormModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + initialValues, + type = "Create", +}) => { + const defaultValues: SBOMGroupFormValues = { + name: "", + parentGroup: "", + isProduct: "no", + description: "", + labels: [], + }; + + const [isAdvancedExpanded, setIsAdvancedExpanded] = + React.useState(false); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting, isValid, isValidating }, + } = useForm({ + defaultValues: { + ...defaultValues, + ...initialValues, + }, + }); + + React.useEffect(() => { + if (isOpen) { + reset({ + ...defaultValues, + ...initialValues, + }); + } + }, [isOpen, initialValues, reset]); + + const onSubmitHandler = (values: SBOMGroupFormValues) => { + onSubmit(values); + }; + + const parentGroups: string[] = []; + + return ( + + + +
+ + + + {parentGroups.map((groupName) => ( + + ))} + + + ( + + + { + onChange("yes"); + }} + /> + + + { + onChange("no"); + }} + /> + + + )} + /> + + + + setIsAdvancedExpanded(val)} + isExpanded={isAdvancedExpanded} + > + + + +
+ + + + +
+ ); +}; diff --git a/client/src/app/pages/sbom-list/sbom-toolbar.tsx b/client/src/app/pages/sbom-list/sbom-toolbar.tsx index ae0e9c4c5..90f715171 100644 --- a/client/src/app/pages/sbom-list/sbom-toolbar.tsx +++ b/client/src/app/pages/sbom-list/sbom-toolbar.tsx @@ -16,6 +16,7 @@ import { ToolbarBulkSelector } from "@app/components/ToolbarBulkSelector"; import { Paths } from "@app/Routes"; import { SbomSearchContext } from "./sbom-context"; +import { SBOMGroupFormModal } from "./components/SBOMGroupFormModal"; interface SbomToolbarProps { showFilters?: boolean; @@ -28,6 +29,10 @@ export const SbomToolbar: React.FC = ({ }) => { const navigate = useNavigate(); + const [createGroupOpened, setCreateGroupOpened] = + React.useState(false); + const closeCreateGroup = () => setCreateGroupOpened(false); + const { tableControls, bulkSelection: { @@ -50,53 +55,65 @@ export const SbomToolbar: React.FC = ({ } = bulkSelectionControls; return ( - - - {showBulkSelector && ( - - )} - {showFilters && } - {showActions && ( - <> - - - - - - - - navigate(Paths.sbomUpload)} - > - Upload SBOM - , - navigate(Paths.sbomScan)} - > - Generate vulnerability report - , - ]} - /> - - - )} - - - - - + <> + + + {showBulkSelector && ( + + )} + {showFilters && } + {showActions && ( + <> + + + + + + + + navigate(Paths.sbomUpload)} + > + Upload SBOM + , + navigate(Paths.sbomScan)} + > + Generate vulnerability report + , + ]} + /> + + + )} + + + + + + console.log(val)} + /> + ); };