diff --git a/pkg/configuration/field.go b/pkg/configuration/field.go index 331a5c7296..fc69665b1b 100644 --- a/pkg/configuration/field.go +++ b/pkg/configuration/field.go @@ -190,7 +190,8 @@ type SelectTypeOptions struct { * MultiSelectTypeOptions specifies options for multi_select fields */ type MultiSelectTypeOptions struct { - Options []FieldOption `json:"options"` + Options []FieldOption `json:"options"` + UseCheckboxes bool `json:"useCheckboxes,omitempty"` } /* @@ -213,8 +214,9 @@ type ObjectTypeOptions struct { * FieldOption represents a selectable option for select / multi_select field types */ type FieldOption struct { - Label string `json:"label"` - Value string `json:"value"` + Label string `json:"label"` + Value string `json:"value"` + Description string `json:"description,omitempty"` } /* diff --git a/pkg/grpc/actions/common.go b/pkg/grpc/actions/common.go index af9d55ba56..eed3af00a7 100644 --- a/pkg/grpc/actions/common.go +++ b/pkg/grpc/actions/common.go @@ -123,8 +123,9 @@ func selectTypeOptionsToProto(opts *configuration.SelectTypeOptions) *configpb.S } for i, opt := range opts.Options { pbOpts.Options[i] = &configpb.SelectOption{ - Label: opt.Label, - Value: opt.Value, + Label: opt.Label, + Value: opt.Value, + Description: opt.Description, } } return pbOpts @@ -136,12 +137,14 @@ func multiSelectTypeOptionsToProto(opts *configuration.MultiSelectTypeOptions) * } pbOpts := &configpb.MultiSelectTypeOptions{ - Options: make([]*configpb.SelectOption, len(opts.Options)), + Options: make([]*configpb.SelectOption, len(opts.Options)), + UseCheckboxes: opts.UseCheckboxes, } for i, opt := range opts.Options { pbOpts.Options[i] = &configpb.SelectOption{ - Label: opt.Label, - Value: opt.Value, + Label: opt.Label, + Value: opt.Value, + Description: opt.Description, } } return pbOpts @@ -248,8 +251,9 @@ func anyPredicateListTypeOptionsToProto(opts *configuration.AnyPredicateListType } for i, opt := range opts.Operators { pbOpts.Operators[i] = &configpb.SelectOption{ - Label: opt.Label, - Value: opt.Value, + Label: opt.Label, + Value: opt.Value, + Description: opt.Description, } } return pbOpts @@ -400,8 +404,9 @@ func protoToSelectTypeOptions(pbOpts *configpb.SelectTypeOptions) *configuration } for i, pbOpt := range pbOpts.Options { opts.Options[i] = configuration.FieldOption{ - Label: pbOpt.Label, - Value: pbOpt.Value, + Label: pbOpt.Label, + Value: pbOpt.Value, + Description: pbOpt.Description, } } return opts @@ -413,12 +418,14 @@ func protoToMultiSelectTypeOptions(pbOpts *configpb.MultiSelectTypeOptions) *con } opts := &configuration.MultiSelectTypeOptions{ - Options: make([]configuration.FieldOption, len(pbOpts.Options)), + Options: make([]configuration.FieldOption, len(pbOpts.Options)), + UseCheckboxes: pbOpts.UseCheckboxes, } for i, pbOpt := range pbOpts.Options { opts.Options[i] = configuration.FieldOption{ - Label: pbOpt.Label, - Value: pbOpt.Value, + Label: pbOpt.Label, + Value: pbOpt.Value, + Description: pbOpt.Description, } } return opts @@ -575,8 +582,9 @@ func protoToAnyPredicateListTypeOptions(pbOpts *configpb.AnyPredicateListTypeOpt } for i, pbOpt := range pbOpts.Operators { opts.Operators[i] = configuration.FieldOption{ - Label: pbOpt.Label, - Value: pbOpt.Value, + Label: pbOpt.Label, + Value: pbOpt.Value, + Description: pbOpt.Description, } } return opts diff --git a/protos/configuration.proto b/protos/configuration.proto index 15186db6d3..48eba2e38e 100644 --- a/protos/configuration.proto +++ b/protos/configuration.proto @@ -73,6 +73,7 @@ message SelectTypeOptions { message MultiSelectTypeOptions { repeated SelectOption options = 1; + bool use_checkboxes = 2; } message ResourceTypeOptions { @@ -105,6 +106,7 @@ message ObjectTypeOptions { message SelectOption { string label = 1; string value = 2; + string description = 3; } message VisibilityCondition { diff --git a/web_src/src/ui/configurationFieldRenderer/MultiSelectFieldRenderer.spec.tsx b/web_src/src/ui/configurationFieldRenderer/MultiSelectFieldRenderer.spec.tsx new file mode 100644 index 0000000000..105466df52 --- /dev/null +++ b/web_src/src/ui/configurationFieldRenderer/MultiSelectFieldRenderer.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { ConfigurationField } from "@/api-client"; +import { MultiSelectFieldRenderer } from "./MultiSelectFieldRenderer"; + +function createField(useCheckboxes: boolean): ConfigurationField { + return { + name: "deliveryChannels", + label: "Delivery channels", + type: "multi-select", + typeOptions: { + multiSelect: { + useCheckboxes, + options: [ + { + label: "Email", + value: "email", + description: "Send notifications by email.", + }, + { + label: "Slack", + value: "slack", + description: "Post notifications to a Slack channel.", + }, + ], + }, + }, + }; +} + +describe("MultiSelectFieldRenderer", () => { + it("renders checkbox options with descriptions when useCheckboxes is enabled", () => { + render(); + + expect(screen.getByRole("checkbox", { name: /Email/ })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("checkbox", { name: /Slack/ })).toHaveAttribute("aria-checked", "false"); + expect(screen.getByText("Send notifications by email.")).toBeInTheDocument(); + expect(screen.getByText("Post notifications to a Slack channel.")).toBeInTheDocument(); + }); + + it("adds values when a checkbox is checked", async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + await user.click(screen.getByRole("checkbox", { name: /Email/ })); + + expect(handleChange).toHaveBeenCalledWith(["email"]); + }); + + it("sends undefined when the last selected value is unchecked", async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + await user.click(screen.getByRole("checkbox", { name: /Email/ })); + + expect(handleChange).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/web_src/src/ui/configurationFieldRenderer/MultiSelectFieldRenderer.tsx b/web_src/src/ui/configurationFieldRenderer/MultiSelectFieldRenderer.tsx index d830c8f7ce..9a6e742eb5 100644 --- a/web_src/src/ui/configurationFieldRenderer/MultiSelectFieldRenderer.tsx +++ b/web_src/src/ui/configurationFieldRenderer/MultiSelectFieldRenderer.tsx @@ -1,66 +1,140 @@ import React, { useEffect, useMemo } from "react"; import type { FieldRendererProps } from "./types"; import { MultiCombobox, MultiComboboxLabel } from "@/components/MultiCombobox/multi-combobox"; +import { Checkbox } from "@/ui/checkbox"; +import { Label } from "@/components/ui/label"; interface SelectOption { id: string; label: string; value: string; + description?: string; +} + +function parseMultiSelectValues(raw: unknown): string[] { + if (Array.isArray(raw)) { + return raw.filter((item): item is string => typeof item === "string"); + } + + if (typeof raw !== "string" || raw === "") { + return []; + } + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter((item): item is string => typeof item === "string"); + } catch { + return []; + } +} + +function toCheckboxOptionId(fieldName: string, optionValue: string): string { + const safeFieldName = fieldName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const safeOptionValue = optionValue.replace(/[^a-zA-Z0-9_-]/g, "-"); + return `${safeFieldName}-${safeOptionValue}`; } export const MultiSelectFieldRenderer: React.FC = ({ field, value, onChange }) => { const multiSelectOptions = field.typeOptions?.multiSelect?.options ?? []; + const useCheckboxes = field.typeOptions?.multiSelect?.useCheckboxes === true; - // Convert static options to SelectOption format const comboboxOptions: SelectOption[] = useMemo(() => { - return multiSelectOptions.map((opt) => ({ - id: opt.value!, - label: opt.label!, - value: opt.value!, - })); + const options: SelectOption[] = []; + for (const opt of multiSelectOptions) { + if (!opt.value) { + continue; + } + + options.push({ + id: opt.value, + label: opt.label ?? opt.value, + value: opt.value, + description: opt.description, + }); + } + + return options; }, [multiSelectOptions]); + const defaultValues = useMemo(() => parseMultiSelectValues(field.defaultValue), [field.defaultValue]); + // Set initial value on first render if no value is present but there's a default useEffect(() => { - if ((value === undefined || value === null) && field.defaultValue !== undefined) { - const defaultVal = Array.isArray(field.defaultValue) - ? field.defaultValue - : field.defaultValue - ? JSON.parse(field.defaultValue as string) - : []; - if (Array.isArray(defaultVal) && defaultVal.length > 0) { - onChange(defaultVal); - } + if ((value === undefined || value === null) && defaultValues.length > 0) { + onChange(defaultValues); + } + }, [value, defaultValues, onChange]); + + const currentValue = useMemo(() => { + if (value === undefined || value === null) { + return defaultValues; } - }, [value, field.defaultValue, onChange]); - // Get current selected values - const currentValue = - (typeof value !== "string" ? value : JSON.parse(value)) ?? - (field.defaultValue - ? Array.isArray(field.defaultValue) - ? field.defaultValue - : JSON.parse(field.defaultValue as string) - : []); + return parseMultiSelectValues(value); + }, [value, defaultValues]); + const selectedValues = useMemo(() => new Set(currentValue), [currentValue]); // Convert selected values to SelectOption objects - const selectedOptions: SelectOption[] = currentValue.map((val: string) => { + const selectedOptions: SelectOption[] = currentValue.map((val) => { const option = comboboxOptions.find((opt) => opt.value === val); return option || { id: val, label: val, value: val }; }); - const handleChange = (selectedOptions: SelectOption[]) => { - const selectedValues = selectedOptions.map((opt) => opt.value); - onChange(selectedValues.length > 0 ? selectedValues : undefined); + const handleComboboxChange = (nextSelectedOptions: SelectOption[]) => { + const nextSelectedValues = nextSelectedOptions.map((opt) => opt.value); + onChange(nextSelectedValues.length > 0 ? nextSelectedValues : undefined); }; + const handleCheckboxChange = (selectedOptionValue: string, checked: boolean) => { + const nextSelectedValues = checked + ? Array.from(new Set([...currentValue, selectedOptionValue])) + : currentValue.filter((selectedValue) => selectedValue !== selectedOptionValue); + + onChange(nextSelectedValues.length > 0 ? nextSelectedValues : undefined); + }; + + if (useCheckboxes) { + return ( +
+ {comboboxOptions.map((option) => { + const optionId = toCheckboxOptionId(field.name ?? "multi-select", option.value); + + return ( +
+
+ handleCheckboxChange(option.value, checked === true)} + className="mt-0.5" + /> +
+ + {option.description && ( +

{option.description}

+ )} +
+
+
+ ); + })} +
+ ); + } + return ( options={comboboxOptions} displayValue={(option) => option.label} placeholder={`Select ${field.label || field.name}...`} value={selectedOptions} - onChange={handleChange} + onChange={handleComboboxChange} showButton={false} > {(option) => {option.label}}