Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions pkg/configuration/field.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

/*
Expand All @@ -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"`
}

/*
Expand Down
36 changes: 22 additions & 14 deletions pkg/grpc/actions/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions protos/configuration.proto
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ message SelectTypeOptions {

message MultiSelectTypeOptions {
repeated SelectOption options = 1;
bool use_checkboxes = 2;
}

message ResourceTypeOptions {
Expand Down Expand Up @@ -105,6 +106,7 @@ message ObjectTypeOptions {
message SelectOption {
string label = 1;
string value = 2;
string description = 3;
}

message VisibilityCondition {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<MultiSelectFieldRenderer field={createField(true)} value={["email"]} onChange={vi.fn()} />);

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(<MultiSelectFieldRenderer field={createField(true)} value={[]} onChange={handleChange} />);

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(<MultiSelectFieldRenderer field={createField(true)} value={["email"]} onChange={handleChange} />);

await user.click(screen.getByRole("checkbox", { name: /Email/ }));

expect(handleChange).toHaveBeenCalledWith(undefined);
});
});
141 changes: 112 additions & 29 deletions web_src/src/ui/configurationFieldRenderer/MultiSelectFieldRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,149 @@
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<FieldRendererProps> = ({ 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,
});
}

options.sort((firstOption, secondOption) => {
const labelComparison = firstOption.label.localeCompare(secondOption.label, undefined, { sensitivity: "base" });
if (labelComparison !== 0) {
return labelComparison;
}

return firstOption.value.localeCompare(secondOption.value, undefined, { sensitivity: "base" });
});
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

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 (
<div className="space-y-2">
{comboboxOptions.map((option) => {
const optionId = toCheckboxOptionId(field.name ?? "multi-select", option.value);

return (
<div key={option.id} className="rounded-md border border-border/70 px-3 py-2">
<div className="flex items-start gap-3">
<Checkbox
id={optionId}
checked={selectedValues.has(option.value)}
onCheckedChange={(checked) => handleCheckboxChange(option.value, checked === true)}
className="mt-0.5"
/>
<div className="flex flex-col gap-1 py-0.5">
<Label htmlFor={optionId} className="cursor-pointer font-medium leading-none">
{option.label}
</Label>
{option.description && (
<p className="text-xs leading-relaxed text-muted-foreground">{option.description}</p>
)}
</div>
</div>
</div>
);
})}
</div>
);
}

return (
<MultiCombobox<SelectOption>
options={comboboxOptions}
displayValue={(option) => option.label}
placeholder={`Select ${field.label || field.name}...`}
value={selectedOptions}
onChange={handleChange}
onChange={handleComboboxChange}
showButton={false}
>
{(option) => <MultiComboboxLabel>{option.label}</MultiComboboxLabel>}
Expand Down
Loading