Add Risk Accepted status to finding forms

Findings create and update views filtered out RISK_ACCEPTED
even though the API requires a linked risk for that status.
Restore the option, collect riskId via a searchable paginated
picker, and clear stale links when status changes.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
This commit is contained in:
Cursor Agent
2026-07-27 10:27:41 +00:00
committed by Bryan Frimin
parent 9abea50507
commit 51144d25fc
5 changed files with 417 additions and 36 deletions

View File

@@ -0,0 +1,199 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 { Combobox, ComboboxItem, Field, InfiniteScrollTrigger } from "@probo/ui";
import { type ComponentProps, Suspense, useCallback, useMemo, useState } from "react";
import {
type Control,
Controller,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useDebounceCallback } from "usehooks-ts";
import { usePaginatedRisks } from "#/hooks/graph/usePaginatedRisks";
type SelectedRisk = {
id: string;
name: string;
};
type Props<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
organizationId: string;
control: Control<TFieldValues>;
name: TName;
label?: string;
error?: string;
disabled?: boolean;
optional?: boolean;
selectedRisk?: SelectedRisk | null;
} & ComponentProps<typeof Field>;
export function RiskSelectField<TFieldValues extends FieldValues = FieldValues>({
organizationId,
control,
disabled,
optional,
selectedRisk,
...props
}: Props<TFieldValues>) {
const { t } = useTranslation();
return (
<Field {...props}>
<Suspense
fallback={(
<Combobox
onSearch={() => {}}
placeholder={t("riskSelectField.placeholder")}
disabled
>
<div />
</Combobox>
)}
>
<RiskSelectWithQuery<TFieldValues>
organizationId={organizationId}
control={control}
name={props.name}
disabled={disabled}
optional={optional}
selectedRisk={selectedRisk}
/>
</Suspense>
</Field>
);
}
function RiskSelectWithQuery<TFieldValues extends FieldValues = FieldValues>(
props: Pick<
Props<TFieldValues>,
| "organizationId"
| "control"
| "name"
| "disabled"
| "optional"
| "selectedRisk"
>,
) {
const { t } = useTranslation();
const { name, organizationId, control, disabled, optional, selectedRisk }
= props;
const { data, loadNext, hasNext, isLoadingNext, refetch }
= usePaginatedRisks(organizationId);
const [search, setSearch] = useState("");
const refetchSearch = useDebounceCallback(
useCallback(
(query: string) => {
refetch(
{
first: 50,
filter: { query: query || null },
},
{ fetchPolicy: "network-only" },
);
},
[refetch],
),
300,
);
const handleSearch = (query: string) => {
setSearch(query);
refetchSearch(query);
};
const risks = useMemo(() => {
return data?.risks.edges?.map(edge => edge.node) ?? [];
}, [data?.risks.edges]);
return (
<Controller
control={control}
name={name}
render={({ field }) => {
const selectedFromList = field.value
? risks.find(risk => risk.id === field.value)
: null;
const selected
= selectedFromList
?? (selectedRisk && selectedRisk.id === field.value
? selectedRisk
: null);
return (
<Combobox
id={name}
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
placeholder={t("riskSelectField.placeholder")}
value={search || selected?.name || ""}
onSearch={handleSearch}
disabled={disabled}
>
{optional && (
<ComboboxItem
onClick={() => {
field.onChange(null);
setSearch("");
refetchSearch("");
}}
>
{t("riskSelectField.none")}
</ComboboxItem>
)}
{risks.map(risk => (
<ComboboxItem
key={risk.id}
onClick={() => {
field.onChange(risk.id);
setSearch(risk.name);
}}
>
<div className="space-y-1 text-start min-w-0">
<div className="max-w-75 ellipsis overflow-hidden whitespace-pre-wrap">
{risk.name}
</div>
{risk.category && (
<div className="text-sm text-txt-secondary">
{risk.category}
</div>
)}
</div>
</ComboboxItem>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(50)}
/>
)}
</Combobox>
);
}}
/>
);
}