Compare commits
1 Commits
v0.77.0-cl
...
multiselec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0a6adf177 |
@@ -207,7 +207,7 @@ export const PasswordReset = Loadable(
|
|||||||
export const SomethingWentWrong = Loadable(
|
export const SomethingWentWrong = Loadable(
|
||||||
() =>
|
() =>
|
||||||
import(
|
import(
|
||||||
/* webpackChunkName: "ErrorBoundaryFallback" */ 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'
|
/* webpackChunkName: "SomethingWentWrong" */ 'pages/SomethingWentWrong'
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -299,3 +299,10 @@ export const MetricsExplorer = Loadable(
|
|||||||
export const ApiMonitoring = Loadable(
|
export const ApiMonitoring = Loadable(
|
||||||
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
|
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const DynamicVariableTest = Loadable(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "DynamicVariableTest" */ 'pages/DynamicVariableTest'
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
CustomDomainSettings,
|
CustomDomainSettings,
|
||||||
DashboardPage,
|
DashboardPage,
|
||||||
DashboardWidget,
|
DashboardWidget,
|
||||||
|
DynamicVariableTest,
|
||||||
EditAlertChannelsAlerts,
|
EditAlertChannelsAlerts,
|
||||||
EditRulesPage,
|
EditRulesPage,
|
||||||
ErrorDetails,
|
ErrorDetails,
|
||||||
@@ -505,6 +506,13 @@ const routes: AppRoutes[] = [
|
|||||||
key: 'API_MONITORING',
|
key: 'API_MONITORING',
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ROUTES.DYNAMIC_VARIABLE_TEST,
|
||||||
|
exact: true,
|
||||||
|
component: DynamicVariableTest,
|
||||||
|
key: 'DYNAMIC_VARIABLE_TEST',
|
||||||
|
isPrivate: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SUPPORT_ROUTE: AppRoutes = {
|
export const SUPPORT_ROUTE: AppRoutes = {
|
||||||
|
|||||||
231
frontend/src/components/MultiSelect/MultiSelect.styles.scss
Normal file
231
frontend/src/components/MultiSelect/MultiSelect.styles.scss
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
.multi-select-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-label {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: text;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&.multi-select-input-focused {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-chip {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
|
.multi-select-chip-remove {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 50px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override Ant Design's styles
|
||||||
|
.ant-input {
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-clear-all {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1050;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-section-label {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-loading {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-no-results {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-options-container,
|
||||||
|
.multi-select-section-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
/* For WebKit browsers */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #ccc #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-error {
|
||||||
|
border-color: #ff4d4f;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&.multi-select-input-focused {
|
||||||
|
border-color: #ff7875;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-error-text {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-disabled {
|
||||||
|
.multi-select-input {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: rgba(0, 0, 0, 0.25);
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-chip {
|
||||||
|
color: rgba(0, 0, 0, 0.25);
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
}
|
||||||
595
frontend/src/components/MultiSelect/MultiSelect.tsx
Normal file
595
frontend/src/components/MultiSelect/MultiSelect.tsx
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
import './MultiSelect.styles.scss';
|
||||||
|
|
||||||
|
import { CloseOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { Checkbox, Input, Spin } from 'antd';
|
||||||
|
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||||
|
import { InputRef } from 'antd/lib/input';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface MultiSelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
selected?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectSection {
|
||||||
|
title: string;
|
||||||
|
options: MultiSelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectProps {
|
||||||
|
/** Array of options to display in the dropdown */
|
||||||
|
options: MultiSelectOption[];
|
||||||
|
/** Callback when selected values change */
|
||||||
|
onChange: (selectedValues: string[]) => void;
|
||||||
|
/** Currently selected values */
|
||||||
|
value?: string[];
|
||||||
|
/** Placeholder text for the search input */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the component is in loading state */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Allow users to add custom values */
|
||||||
|
allowCustomValues?: boolean;
|
||||||
|
/** Callback when search text changes - can be used for server filtering */
|
||||||
|
onSearch?: (searchText: string) => void;
|
||||||
|
/** Custom class name */
|
||||||
|
className?: string;
|
||||||
|
/** Additional sections to display (e.g., "Related Values") */
|
||||||
|
additionalSections?: MultiSelectSection[];
|
||||||
|
/** Show "Select All" option */
|
||||||
|
showSelectAll?: boolean;
|
||||||
|
/** Maximum height of dropdown in pixels */
|
||||||
|
dropdownMaxHeight?: number;
|
||||||
|
/** Maximum width of dropdown in pixels (defaults to matching input width) */
|
||||||
|
dropdownMaxWidth?: number;
|
||||||
|
/** Disable the component */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Error message to display */
|
||||||
|
error?: string;
|
||||||
|
/** Label text */
|
||||||
|
label?: string;
|
||||||
|
/** Allow users to clear all selections */
|
||||||
|
allowClear?: boolean;
|
||||||
|
/** Maximum height of a section */
|
||||||
|
sectionMaxHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiSelect({
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
value = [],
|
||||||
|
placeholder = 'Search...',
|
||||||
|
loading = false,
|
||||||
|
allowCustomValues = true,
|
||||||
|
onSearch,
|
||||||
|
className = '',
|
||||||
|
additionalSections = [],
|
||||||
|
showSelectAll = true,
|
||||||
|
dropdownMaxHeight = 400,
|
||||||
|
dropdownMaxWidth,
|
||||||
|
disabled = false,
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
allowClear = true,
|
||||||
|
sectionMaxHeight = 150,
|
||||||
|
}: MultiSelectProps): JSX.Element {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||||
|
const [searchText, setSearchText] = useState<string>('');
|
||||||
|
const [selectedValues, setSelectedValues] = useState<string[]>(value);
|
||||||
|
const [displayOptions, setDisplayOptions] = useState<MultiSelectOption[]>(
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<InputRef>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [focusedChipIndex, setFocusedChipIndex] = useState<number>(-1);
|
||||||
|
const chipRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Handle save action - memoize with useCallback
|
||||||
|
const handleSave = useCallback((): void => {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
setSearchText('');
|
||||||
|
onChange(selectedValues);
|
||||||
|
}, [onChange, selectedValues]);
|
||||||
|
|
||||||
|
// Synchronize value prop with internal state
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedValues(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Filter and sort options based on search text
|
||||||
|
useEffect(() => {
|
||||||
|
// Filter options based on search text
|
||||||
|
const filteredOptions = options.filter((option) =>
|
||||||
|
option.label.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add custom value option if no matches found and allowCustomValues is true
|
||||||
|
if (
|
||||||
|
allowCustomValues &&
|
||||||
|
searchText &&
|
||||||
|
!filteredOptions.some(
|
||||||
|
(option) => option.label.toLowerCase() === searchText.toLowerCase(),
|
||||||
|
) &&
|
||||||
|
!filteredOptions.some(
|
||||||
|
(option) => option.value.toLowerCase() === searchText.toLowerCase(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
filteredOptions.unshift({
|
||||||
|
label: `Add "${searchText}"`,
|
||||||
|
value: searchText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort options: selected first, then matching search term
|
||||||
|
const sortedOptions = [...filteredOptions].sort((a, b) => {
|
||||||
|
// First by selection status
|
||||||
|
if (selectedValues.includes(a.value) && !selectedValues.includes(b.value))
|
||||||
|
return -1;
|
||||||
|
if (!selectedValues.includes(a.value) && selectedValues.includes(b.value))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
// Then by match position (exact matches or starts with come first)
|
||||||
|
const aLower = a.label.toLowerCase();
|
||||||
|
const bLower = b.label.toLowerCase();
|
||||||
|
const searchLower = searchText.toLowerCase();
|
||||||
|
|
||||||
|
if (aLower === searchLower && bLower !== searchLower) return -1;
|
||||||
|
if (aLower !== searchLower && bLower === searchLower) return 1;
|
||||||
|
if (aLower.startsWith(searchLower) && !bLower.startsWith(searchLower))
|
||||||
|
return -1;
|
||||||
|
if (!aLower.startsWith(searchLower) && bLower.startsWith(searchLower))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
setDisplayOptions(sortedOptions);
|
||||||
|
}, [options, searchText, selectedValues, allowCustomValues]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent): void => {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return (): void => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [selectedValues, handleSave]);
|
||||||
|
|
||||||
|
// Adjust dropdown position if needed
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDropdownOpen && dropdownRef.current && containerRef.current) {
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const dropdownHeight = dropdownRef.current.offsetHeight;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// Check if dropdown extends beyond viewport bottom
|
||||||
|
if (
|
||||||
|
containerRect.bottom + dropdownHeight > viewportHeight &&
|
||||||
|
containerRect.top > dropdownHeight
|
||||||
|
) {
|
||||||
|
dropdownRef.current.style.top = 'auto';
|
||||||
|
dropdownRef.current.style.bottom = '100%';
|
||||||
|
dropdownRef.current.style.marginTop = '0';
|
||||||
|
dropdownRef.current.style.marginBottom = '4px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isDropdownOpen, displayOptions]);
|
||||||
|
|
||||||
|
// Handle search input change
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const text = e.target.value;
|
||||||
|
setSearchText(text);
|
||||||
|
if (onSearch) {
|
||||||
|
onSearch(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle selection change
|
||||||
|
const handleSelectionChange = (
|
||||||
|
option: MultiSelectOption,
|
||||||
|
e: CheckboxChangeEvent,
|
||||||
|
): void => {
|
||||||
|
const { checked } = e.target;
|
||||||
|
let newSelectedValues: string[];
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
newSelectedValues = [...selectedValues, option.value];
|
||||||
|
} else {
|
||||||
|
newSelectedValues = selectedValues.filter((val) => val !== option.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle "All" checkbox change
|
||||||
|
const handleSelectAll = (e: CheckboxChangeEvent): void => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
const allValues = options
|
||||||
|
.filter((option) => !option.disabled)
|
||||||
|
.map((option) => option.value);
|
||||||
|
setSelectedValues(allValues);
|
||||||
|
} else {
|
||||||
|
setSelectedValues([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove a selected item
|
||||||
|
const handleRemoveItem = useCallback(
|
||||||
|
(value: string): void => {
|
||||||
|
const newSelectedValues = selectedValues.filter((val) => val !== value);
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
},
|
||||||
|
[selectedValues],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle clicking the input area
|
||||||
|
const handleInputClick = (): void => {
|
||||||
|
if (!disabled) {
|
||||||
|
setIsDropdownOpen(true);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clear all selections
|
||||||
|
const handleClearAll = (): void => {
|
||||||
|
setSelectedValues([]);
|
||||||
|
setSearchText('');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get display value of a selection (chips)
|
||||||
|
const getSelectedOptions = (): MultiSelectOption[] =>
|
||||||
|
selectedValues.map((value) => {
|
||||||
|
const option = options.find((opt) => opt.value === value);
|
||||||
|
return {
|
||||||
|
label: option?.label || value,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedOptions = getSelectedOptions();
|
||||||
|
const allSelectableOptions = options.filter((option) => !option.disabled);
|
||||||
|
const allSelected =
|
||||||
|
allSelectableOptions.length > 0 &&
|
||||||
|
selectedValues.length === allSelectableOptions.length;
|
||||||
|
|
||||||
|
const containerClasses = [
|
||||||
|
'multi-select-container',
|
||||||
|
className,
|
||||||
|
disabled ? 'multi-select-disabled' : '',
|
||||||
|
error ? 'multi-select-error' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const inputClasses = [
|
||||||
|
'multi-select-input',
|
||||||
|
isDropdownOpen ? 'multi-select-input-focused' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
// Reset chip refs array when selected options change
|
||||||
|
useEffect(() => {
|
||||||
|
chipRefs.current = Array(selectedOptions.length).fill(null);
|
||||||
|
}, [selectedOptions.length]);
|
||||||
|
|
||||||
|
// Handle chip keyboard navigation
|
||||||
|
const handleChipKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent, index: number) => {
|
||||||
|
e.stopPropagation(); // Prevent bubbling to container
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
// Move focus to previous chip
|
||||||
|
if (index > 0) {
|
||||||
|
setFocusedChipIndex(index - 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
// Move focus to next chip or input
|
||||||
|
if (index < selectedOptions.length - 1) {
|
||||||
|
setFocusedChipIndex(index + 1);
|
||||||
|
} else {
|
||||||
|
// Focus the input when at the last chip
|
||||||
|
setFocusedChipIndex(-1);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Delete':
|
||||||
|
case 'Backspace':
|
||||||
|
e.preventDefault();
|
||||||
|
// Remove current chip
|
||||||
|
handleRemoveItem(selectedOptions[index].value);
|
||||||
|
|
||||||
|
// Adjust focus after deletion
|
||||||
|
if (selectedOptions.length > 1) {
|
||||||
|
// Focus previous chip if not at beginning
|
||||||
|
const newIndex = Math.min(index, selectedOptions.length - 2);
|
||||||
|
setFocusedChipIndex(newIndex);
|
||||||
|
} else {
|
||||||
|
// If this was the last chip, focus input
|
||||||
|
setFocusedChipIndex(-1);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
// Return focus to input
|
||||||
|
setFocusedChipIndex(-1);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// No-op for unhandled keys
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedOptions, handleRemoveItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle key events in the input
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
// Add custom value on Enter if it doesn't exist
|
||||||
|
if (
|
||||||
|
allowCustomValues &&
|
||||||
|
searchText &&
|
||||||
|
!options.some(
|
||||||
|
(option) => option.value.toLowerCase() === searchText.toLowerCase(),
|
||||||
|
) &&
|
||||||
|
!options.some(
|
||||||
|
(option) => option.label.toLowerCase() === searchText.toLowerCase(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const newSelectedValues = [...selectedValues, searchText];
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
setSearchText('');
|
||||||
|
} else if (isDropdownOpen) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
handleSave();
|
||||||
|
} else if (
|
||||||
|
e.key === 'Backspace' &&
|
||||||
|
!searchText &&
|
||||||
|
selectedValues.length > 0
|
||||||
|
) {
|
||||||
|
// Remove the last selected item when pressing backspace in an empty input
|
||||||
|
const newSelectedValues = [...selectedValues];
|
||||||
|
newSelectedValues.pop();
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
} else if (e.key === 'Tab' && isDropdownOpen) {
|
||||||
|
// Close dropdown but keep focus within component
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add navigation TO chips when in input field
|
||||||
|
if (e.key === 'ArrowLeft' && !searchText && selectedOptions.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedChipIndex(selectedOptions.length - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Focus the appropriate chip when focusedChipIndex changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedChipIndex >= 0 && chipRefs.current[focusedChipIndex]) {
|
||||||
|
chipRefs.current[focusedChipIndex]?.focus();
|
||||||
|
}
|
||||||
|
}, [focusedChipIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses} ref={containerRef}>
|
||||||
|
{label && <div className="multi-select-label">{label}</div>}
|
||||||
|
<div
|
||||||
|
className={inputClasses}
|
||||||
|
onClick={handleInputClick}
|
||||||
|
onKeyDown={(e): void => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
handleInputClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={isDropdownOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls="multi-select-dropdown"
|
||||||
|
aria-owns="multi-select-dropdown"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
>
|
||||||
|
<div className="multi-select-chips">
|
||||||
|
{selectedOptions.map((option, index) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={`multi-select-chip ${
|
||||||
|
focusedChipIndex === index ? 'multi-select-chip-focused' : ''
|
||||||
|
}`}
|
||||||
|
ref={(el): void => {
|
||||||
|
chipRefs.current[index] = el;
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e): void => handleChipKeyDown(e, index)}
|
||||||
|
onFocus={(): void => setFocusedChipIndex(index)}
|
||||||
|
onClick={(e): void => e.stopPropagation()}
|
||||||
|
aria-label={`Selected option: ${option.label}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="multi-select-chip-remove"
|
||||||
|
onClick={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveItem(option.value);
|
||||||
|
}}
|
||||||
|
aria-label={`Remove ${option.label}`}
|
||||||
|
tabIndex={-1} // Don't make the inner button tabbable
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
className="multi-select-search"
|
||||||
|
placeholder={selectedOptions.length === 0 ? placeholder : ''}
|
||||||
|
value={searchText}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={(): void => setIsDropdownOpen(true)}
|
||||||
|
suffix={<SearchOutlined />}
|
||||||
|
bordered={false}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{allowClear && selectedValues.length > 0 && !disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="multi-select-clear-all"
|
||||||
|
onClick={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClearAll();
|
||||||
|
}}
|
||||||
|
aria-label="Clear all selections"
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="multi-select-error-text">{error}</div>}
|
||||||
|
|
||||||
|
{isDropdownOpen && !disabled && (
|
||||||
|
<div
|
||||||
|
className="multi-select-dropdown"
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={{
|
||||||
|
maxHeight: `${dropdownMaxHeight}px`,
|
||||||
|
maxWidth: dropdownMaxWidth ? `${dropdownMaxWidth}px` : undefined,
|
||||||
|
}}
|
||||||
|
id="multi-select-dropdown"
|
||||||
|
role="listbox"
|
||||||
|
aria-multiselectable="true"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="multi-select-loading">
|
||||||
|
<Spin size="small" />
|
||||||
|
<span>We are updating the values ...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{showSelectAll && (
|
||||||
|
<>
|
||||||
|
<div className="multi-select-option">
|
||||||
|
<Checkbox checked={allSelected} onChange={handleSelectAll}>
|
||||||
|
ALL
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<div className="multi-select-divider" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="multi-select-options-container"
|
||||||
|
style={{ maxHeight: `${sectionMaxHeight}px` }}
|
||||||
|
>
|
||||||
|
{displayOptions.length > 0 ? (
|
||||||
|
displayOptions.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className="multi-select-option"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedValues.includes(option.value)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedValues.includes(option.value)}
|
||||||
|
onChange={(e): void => handleSelectionChange(option, e)}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="multi-select-no-results">
|
||||||
|
{allowCustomValues && searchText
|
||||||
|
? `Add "${searchText}"`
|
||||||
|
: 'No results found'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{additionalSections.map(
|
||||||
|
(section) =>
|
||||||
|
section.options.length > 0 && (
|
||||||
|
<div key={`section-${section.title}`}>
|
||||||
|
<div className="multi-select-divider" />
|
||||||
|
<div className="multi-select-section-label">{section.title}</div>
|
||||||
|
<div
|
||||||
|
className="multi-select-section-content"
|
||||||
|
style={{ maxHeight: `${sectionMaxHeight}px` }}
|
||||||
|
>
|
||||||
|
{section.options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className="multi-select-option"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedValues.includes(option.value)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedValues.includes(option.value)}
|
||||||
|
onChange={(e): void => handleSelectionChange(option, e)}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define defaultProps to fix linter warnings
|
||||||
|
MultiSelect.defaultProps = {
|
||||||
|
value: [],
|
||||||
|
placeholder: 'Search...',
|
||||||
|
loading: false,
|
||||||
|
allowCustomValues: true,
|
||||||
|
onSearch: undefined,
|
||||||
|
className: '',
|
||||||
|
additionalSections: [],
|
||||||
|
showSelectAll: true,
|
||||||
|
dropdownMaxHeight: 400,
|
||||||
|
dropdownMaxWidth: undefined,
|
||||||
|
disabled: false,
|
||||||
|
error: undefined,
|
||||||
|
label: undefined,
|
||||||
|
allowClear: true,
|
||||||
|
sectionMaxHeight: 150,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiSelect;
|
||||||
8
frontend/src/components/MultiSelect/index.ts
Normal file
8
frontend/src/components/MultiSelect/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import MultiSelect from './MultiSelect';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
MultiSelectOption,
|
||||||
|
MultiSelectProps,
|
||||||
|
MultiSelectSection,
|
||||||
|
} from './MultiSelect';
|
||||||
|
export default MultiSelect;
|
||||||
@@ -75,6 +75,7 @@ const ROUTES = {
|
|||||||
METRICS_EXPLORER_BASE: '/metrics-explorer',
|
METRICS_EXPLORER_BASE: '/metrics-explorer',
|
||||||
WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted',
|
WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted',
|
||||||
HOME_PAGE: '/',
|
HOME_PAGE: '/',
|
||||||
|
DYNAMIC_VARIABLE_TEST: '/dynamic-variable-test',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default ROUTES;
|
export default ROUTES;
|
||||||
|
|||||||
211
frontend/src/pages/DynamicVariableTest/index.tsx
Normal file
211
frontend/src/pages/DynamicVariableTest/index.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import './styles.scss';
|
||||||
|
|
||||||
|
import { Button, Card, Col, Divider, Row, Switch, Typography } from 'antd';
|
||||||
|
import MultiSelect, {
|
||||||
|
MultiSelectOption,
|
||||||
|
MultiSelectSection,
|
||||||
|
} from 'components/MultiSelect';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
// Sample data for the component
|
||||||
|
const sampleOptions: MultiSelectOption[] = [
|
||||||
|
{ label: 'abc', value: 'abc' },
|
||||||
|
{ label: 'acbewc', value: 'acbewc' },
|
||||||
|
{ label: 'custom-value', value: 'custom-value' },
|
||||||
|
{ label: 'option1', value: 'option1' },
|
||||||
|
{ label: 'option2', value: 'option2' },
|
||||||
|
{ label: 'another-option', value: 'another-option' },
|
||||||
|
{ label: 'test-option', value: 'test-option' },
|
||||||
|
{ label: 'disabled-option', value: 'disabled-option', disabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sample related values for the "Related Values" section
|
||||||
|
const relatedValues: MultiSelectOption[] = [
|
||||||
|
{ label: 'gke-mgmt-pl-generator-e2st4-sp-f1c1bde8-skbl', value: 'gke-1' },
|
||||||
|
{ label: 'gke-mgmt-pl-generator-e2st4-sp-f1c1bde8-skb2', value: 'gke-2' },
|
||||||
|
{ label: 'gke-mgmt-pl-generator-e2st4-sp-f1c1bde8-skb3', value: 'gke-3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sample all values for the "All Values" section
|
||||||
|
const allValues: MultiSelectOption[] = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
label: `gke-mgmt-pl-generator-e2st4-sp-f1c1bde8-7a7w-${i + 1}`,
|
||||||
|
value: `all-${i + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Creating sections
|
||||||
|
const sections: MultiSelectSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Related Values',
|
||||||
|
options: relatedValues,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ALL Values',
|
||||||
|
options: allValues,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function DynamicVariableTestPage(): JSX.Element {
|
||||||
|
const [selectedValues, setSelectedValues] = useState<string[]>([
|
||||||
|
'abc',
|
||||||
|
'acbewc',
|
||||||
|
]);
|
||||||
|
const [loadingDemo, setLoadingDemo] = useState<boolean>(false);
|
||||||
|
const [allowCustom, setAllowCustom] = useState<boolean>(true);
|
||||||
|
const [showError, setShowError] = useState<boolean>(false);
|
||||||
|
const [disabled, setDisabled] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleChange = (values: string[]): void => {
|
||||||
|
setSelectedValues(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLoading = (): void => {
|
||||||
|
setLoadingDemo((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dynamic-variable-page">
|
||||||
|
<Card>
|
||||||
|
<Title level={3}>Dynamic Variable MultiSelect</Title>
|
||||||
|
<Paragraph>
|
||||||
|
This page demonstrates the MultiSelect component with various features. The
|
||||||
|
component is now fully reusable and production-ready with support for
|
||||||
|
dynamic data from APIs, proper error states, accessibility, and UI
|
||||||
|
improvements.
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Title level={5}>Basic MultiSelect</Title>
|
||||||
|
<Text>This example shows the basic usage with pre-selected values.</Text>
|
||||||
|
<div className="multiselect-demo-container">
|
||||||
|
<MultiSelect
|
||||||
|
options={sampleOptions}
|
||||||
|
value={selectedValues}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Search or add values..."
|
||||||
|
label="Select options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="selected-values-display">
|
||||||
|
<Title level={5}>Selected Values:</Title>
|
||||||
|
<pre>{JSON.stringify(selectedValues, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Title level={5}>With Sections & All Values</Title>
|
||||||
|
<Text>This example shows the component with additional sections.</Text>
|
||||||
|
<div className="multiselect-demo-container">
|
||||||
|
<MultiSelect
|
||||||
|
options={sampleOptions}
|
||||||
|
value={selectedValues}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Search or add values..."
|
||||||
|
additionalSections={sections}
|
||||||
|
sectionMaxHeight={120}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Title level={5}>Loading State</Title>
|
||||||
|
<Text>This example demonstrates the loading state.</Text>
|
||||||
|
<div className="multiselect-demo-container">
|
||||||
|
<MultiSelect
|
||||||
|
options={sampleOptions}
|
||||||
|
value={[]}
|
||||||
|
onChange={(): void => {}}
|
||||||
|
loading={loadingDemo}
|
||||||
|
placeholder="This shows loading state..."
|
||||||
|
/>
|
||||||
|
<Button onClick={toggleLoading} style={{ marginTop: 16 }}>
|
||||||
|
{loadingDemo ? 'Stop Loading' : 'Simulate Loading'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Title level={5}>Custom Values Configuration</Title>
|
||||||
|
<Text>Toggle to enable/disable custom values.</Text>
|
||||||
|
<div className="multiselect-demo-container">
|
||||||
|
<MultiSelect
|
||||||
|
options={sampleOptions}
|
||||||
|
value={[]}
|
||||||
|
onChange={(): void => {}}
|
||||||
|
allowCustomValues={allowCustom}
|
||||||
|
placeholder={
|
||||||
|
allowCustom ? 'Custom values allowed...' : 'Only predefined values...'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Switch
|
||||||
|
checked={allowCustom}
|
||||||
|
onChange={setAllowCustom}
|
||||||
|
checkedChildren="Custom values on"
|
||||||
|
unCheckedChildren="Custom values off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Title level={5}>Error State</Title>
|
||||||
|
<Text>This example shows the component with an error.</Text>
|
||||||
|
<div className="multiselect-demo-container">
|
||||||
|
<MultiSelect
|
||||||
|
options={sampleOptions}
|
||||||
|
value={[]}
|
||||||
|
onChange={(): void => {}}
|
||||||
|
placeholder="Select some options..."
|
||||||
|
error={showError ? 'Please select at least one option' : undefined}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={(): void => setShowError(!showError)}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
type={showError ? 'primary' : 'default'}
|
||||||
|
>
|
||||||
|
{showError ? 'Hide Error' : 'Show Error'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Title level={5}>Disabled State</Title>
|
||||||
|
<Text>This example shows the disabled state of the component.</Text>
|
||||||
|
<div className="multiselect-demo-container">
|
||||||
|
<MultiSelect
|
||||||
|
options={sampleOptions}
|
||||||
|
value={['abc', 'option1']}
|
||||||
|
onChange={(): void => {}}
|
||||||
|
placeholder="This component is disabled..."
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Switch
|
||||||
|
checked={disabled}
|
||||||
|
onChange={setDisabled}
|
||||||
|
checkedChildren="Disabled"
|
||||||
|
unCheckedChildren="Enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicVariableTestPage;
|
||||||
20
frontend/src/pages/DynamicVariableTest/styles.scss
Normal file
20
frontend/src/pages/DynamicVariableTest/styles.scss
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.dynamic-variable-page {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect-demo-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-values-display {
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,4 +120,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
|||||||
API_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
API_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||||
WORKSPACE_ACCESS_RESTRICTED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
WORKSPACE_ACCESS_RESTRICTED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||||
METRICS_EXPLORER_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
METRICS_EXPLORER_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||||
|
DYNAMIC_VARIABLE_TEST: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user