adjust new select

This commit is contained in:
Nate Kelley 2025-07-10 14:19:12 -06:00
parent f39a309a38
commit d83193f0c4
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
16 changed files with 199 additions and 76 deletions

View File

@ -6,7 +6,6 @@ export * from './dataset_groups';
export * from './datasets'; export * from './datasets';
export * from './datasources'; export * from './datasources';
export * from './metric'; export * from './metric';
export * from './organizations';
export * from './permission'; export * from './permission';
export * from './permission_groups'; export * from './permission_groups';
export * from './search'; export * from './search';

View File

@ -1 +0,0 @@
export * from './interfaces';

View File

@ -1,9 +0,0 @@
import type { OrganizationRole } from '@buster/server-shared/organization';
export const BusterOrganizationRoleLabels: Record<OrganizationRole, string> = {
workspace_admin: 'Workspace Admin',
data_admin: 'Data Admin',
querier: 'Querier',
restricted_querier: 'Restricted Querier',
viewer: 'Viewer',
};

View File

@ -32,7 +32,7 @@ export const ListUsersComponent: React.FC<{
dataIndex: 'role', dataIndex: 'role',
width: 165, width: 165,
render: (role: OrganizationUser['role']) => { render: (role: OrganizationUser['role']) => {
return <Text variant="secondary">{OrganizationUserRoleText[role]}</Text>; return <Text variant="secondary">{OrganizationUserRoleText[role].title}</Text>;
} }
} }
], ],

View File

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { BusterOrganizationRoleLabels } from '@/api/asset_interfaces';
import { useUpdateUser } from '@/api/buster_rest/users'; import { useUpdateUser } from '@/api/buster_rest/users';
import { import {
Card, Card,
@ -14,6 +13,8 @@ import { Text } from '@/components/ui/typography';
import { useMemoizedFn } from '@/hooks'; import { useMemoizedFn } from '@/hooks';
import { User } from '@buster/server-shared/user'; import { User } from '@buster/server-shared/user';
import type { OrganizationUser } from '@buster/server-shared/organization'; import type { OrganizationUser } from '@buster/server-shared/organization';
import { OrganizationUserRoleText } from '@/lib/organization/translations';
import { AccessRoleSelect } from '@/components/features/security/AccessRoleSelect';
export const UserDefaultAccess: React.FC<{ export const UserDefaultAccess: React.FC<{
user: OrganizationUser; user: OrganizationUser;
@ -41,20 +42,6 @@ export const UserDefaultAccess: React.FC<{
); );
}; };
const accessOptions: SelectItem<OrganizationUser['role']>[] = [
{ label: BusterOrganizationRoleLabels.data_admin, value: 'data_admin' },
{
label: BusterOrganizationRoleLabels.workspace_admin,
value: 'workspace_admin'
},
{ label: BusterOrganizationRoleLabels.querier, value: 'querier' },
{
label: BusterOrganizationRoleLabels.restricted_querier,
value: 'restricted_querier'
},
{ label: BusterOrganizationRoleLabels.viewer, value: 'viewer' }
];
const DefaultAccessCard = React.memo( const DefaultAccessCard = React.memo(
({ ({
role, role,
@ -91,12 +78,7 @@ const DefaultAccessCard = React.memo(
: 'Only admins can change access' : 'Only admins can change access'
: undefined : undefined
}> }>
<Select <AccessRoleSelect role={role} onChange={onChange} />
items={accessOptions}
value={role}
onChange={onChange}
disabled={isDisabled}
/>
</AppTooltip> </AppTooltip>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useGetDatasets } from '@/api/buster_rest/datasets'; import { useGetDatasets } from '@/api/buster_rest/datasets';
import { Select, type SelectItem } from '@/components/ui/select/SelectOld'; import { Select, type SelectItem } from '@/components/ui/select';
import { SelectMultiple } from '@/components/ui/select/SelectMultiple'; import { SelectMultiple } from '@/components/ui/select/SelectMultiple';
import { useMemoizedFn } from '@/hooks'; import { useMemoizedFn } from '@/hooks';

View File

@ -0,0 +1,92 @@
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { AccessRoleSelect } from './AccessRoleSelect';
import type { OrganizationRole } from '@buster/server-shared/organization';
const meta: Meta<typeof AccessRoleSelect> = {
title: 'Features/Security/AccessRoleSelect',
component: AccessRoleSelect,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'A select component for choosing organization access roles with titles and descriptions.'
}
}
},
argTypes: {
role: {
control: 'select',
options: ['viewer', 'restricted_querier', 'querier', 'data_admin', 'workspace_admin'],
description: 'The currently selected organization role'
},
onChange: {
action: 'role-changed',
description: 'Callback function called when role selection changes'
}
},
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof AccessRoleSelect>;
// Default story
export const Default: Story = {
args: {
role: 'viewer',
onChange: action('role-changed')
}
};
// Interactive story where users can change roles
export const Interactive: Story = {
args: {
role: 'querier',
onChange: action('role-changed')
}
};
// Stories for each specific role
export const Viewer: Story = {
args: {
role: 'viewer',
onChange: action('role-changed')
}
};
export const RestrictedQuerier: Story = {
args: {
role: 'restricted_querier',
onChange: action('role-changed')
}
};
export const Querier: Story = {
args: {
role: 'querier',
onChange: action('role-changed')
}
};
export const DataAdmin: Story = {
args: {
role: 'data_admin',
onChange: action('role-changed')
}
};
export const WorkspaceAdmin: Story = {
args: {
role: 'workspace_admin',
onChange: action('role-changed')
}
};
// Story without initial role (uses default)
export const NoInitialRole: Story = {
args: {
onChange: action('role-changed')
}
};

View File

@ -0,0 +1,26 @@
import React from 'react';
import { Select, type SelectItem } from '@/components/ui/select';
import { OrganizationRoleEnum, type OrganizationRole } from '@buster/server-shared/organization';
import { OrganizationUserRoleText } from '@/lib/organization';
const items: SelectItem<OrganizationRole>[] = Object.values(OrganizationRoleEnum).map((role) => ({
label: OrganizationUserRoleText[role as OrganizationRole].title,
secondaryLabel: OrganizationUserRoleText[role as OrganizationRole].description,
value: role as OrganizationRole
}));
interface AccessRoleSelectProps {
role?: OrganizationRole;
onChange: (role: OrganizationRole) => void;
}
export const AccessRoleSelect = ({ role = 'viewer', onChange }: AccessRoleSelectProps) => {
return (
<Select
items={items}
className="w-36 max-w-72"
value={role}
onChange={(v) => onChange(v as OrganizationRole)}
/>
);
};

View File

@ -12,11 +12,12 @@ import type {
GetWorkspaceSettingsResponse, GetWorkspaceSettingsResponse,
UpdateWorkspaceSettingsRequest UpdateWorkspaceSettingsRequest
} from '@buster/server-shared/security'; } from '@buster/server-shared/security';
import { Select, type SelectItem } from '@/components/ui/select'; import { type SelectItem } from '@/components/ui/select';
import { type OrganizationRole, OrganizationRoleEnum } from '@buster/server-shared/organization'; import { type OrganizationRole } from '@buster/server-shared/organization';
import { OrganizationUserRoleText } from '@/lib/organization/translations';
import { useGetDatasets } from '@/api/buster_rest/datasets'; import { useGetDatasets } from '@/api/buster_rest/datasets';
import { SelectMultiple } from '@/components/ui/select/SelectMultiple'; import { SelectMultiple } from '@/components/ui/select/SelectMultiple';
import { AccessRoleSelect } from './AccessRoleSelect';
import { useMemoizedFn } from '@/hooks';
export const WorkspaceRestrictions = React.memo(() => { export const WorkspaceRestrictions = React.memo(() => {
const { data: workspaceSettings } = useGetWorkspaceSettings(); const { data: workspaceSettings } = useGetWorkspaceSettings();
@ -31,7 +32,7 @@ export const WorkspaceRestrictions = React.memo(() => {
/>, />,
<DefaultRole <DefaultRole
key="default-role" key="default-role"
default_role={workspaceSettings?.default_role ?? 'viewer' as OrganizationRole} default_role={workspaceSettings?.default_role ?? ('viewer' as OrganizationRole)}
updateWorkspaceSettings={updateWorkspaceSettings} updateWorkspaceSettings={updateWorkspaceSettings}
/>, />,
<DefaultDatasets <DefaultDatasets
@ -84,13 +85,6 @@ const DefaultRole = ({
}: Pick<GetWorkspaceSettingsResponse, 'default_role'> & { }: Pick<GetWorkspaceSettingsResponse, 'default_role'> & {
updateWorkspaceSettings: (request: UpdateWorkspaceSettingsRequest) => Promise<unknown>; updateWorkspaceSettings: (request: UpdateWorkspaceSettingsRequest) => Promise<unknown>;
}) => { }) => {
const items: SelectItem<OrganizationRole>[] = useMemo(() => {
return Object.values(OrganizationRoleEnum).map((role) => ({
label: OrganizationUserRoleText[role as OrganizationRole],
value: role as OrganizationRole
}));
}, []);
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 flex-col space-y-0.5"> <div className="flex min-w-0 flex-1 flex-col space-y-0.5">
@ -99,13 +93,11 @@ const DefaultRole = ({
{`Select which default role is assigned to new users`} {`Select which default role is assigned to new users`}
</Text> </Text>
</div> </div>
<Select <AccessRoleSelect
items={items} role={default_role}
className="w-36 max-w-72" onChange={useMemoizedFn((v) => {
value={default_role}
onChange={(v) => {
updateWorkspaceSettings({ default_role: v }); updateWorkspaceSettings({ default_role: v });
}} })}
/> />
</div> </div>
); );

View File

@ -27,10 +27,10 @@ export interface SelectItem<T = string> {
type SearchFunction<T> = (item: SelectItem<T>, searchTerm: string) => boolean; type SearchFunction<T> = (item: SelectItem<T>, searchTerm: string) => boolean;
export interface SelectProps<T> { // Base interface with common properties
interface BaseSelectProps<T> {
items: SelectItem<T>[] | SelectItemGroup<T>[]; items: SelectItem<T>[] | SelectItemGroup<T>[];
disabled?: boolean; disabled?: boolean;
onChange: (value: T | null) => void;
placeholder?: string; placeholder?: string;
value?: string | undefined; value?: string | undefined;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
@ -41,10 +41,24 @@ export interface SelectProps<T> {
dataTestId?: string; dataTestId?: string;
loading?: boolean; loading?: boolean;
search?: boolean | SearchFunction<T>; search?: boolean | SearchFunction<T>;
clearable?: boolean;
emptyMessage?: string; emptyMessage?: string;
} }
// Clearable version - onChange can return null
interface ClearableSelectProps<T> extends BaseSelectProps<T> {
clearable: true;
onChange: (value: T | null) => void;
}
// Non-clearable version - onChange cannot return null
interface NonClearableSelectProps<T> extends BaseSelectProps<T> {
clearable?: false;
onChange: (value: T) => void;
}
// Union type for type-safe props
export type SelectProps<T> = ClearableSelectProps<T> | NonClearableSelectProps<T>;
function isGroupedItems<T>( function isGroupedItems<T>(
items: SelectItem<T>[] | SelectItemGroup<T>[] items: SelectItem<T>[] | SelectItemGroup<T>[]
): items is SelectItemGroup<T>[] { ): items is SelectItemGroup<T>[] {
@ -83,17 +97,19 @@ const SelectItemComponent = React.memo(
onSelect={onSelect} onSelect={onSelect}
disabled={item.disabled} disabled={item.disabled}
className={cn( className={cn(
'flex h-7 items-center gap-2 px-2', 'flex min-h-7 items-center gap-2 px-2',
item.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer', item.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
isSelected && 'bg-item-select' isSelected && 'bg-item-select'
)}> )}>
{item.icon} {item.icon}
<span className="flex-1"> <span className="flex-1">
{showIndex && `${index + 1}. `} {showIndex && `${index + 1}. `}
{item.label} <div className="flex flex-col space-y-0">
{item.secondaryLabel && ( <span className="text-foreground">{item.label}</span>
<span className="text-text-secondary ml-2 text-sm">{item.secondaryLabel}</span> {item.secondaryLabel && (
)} <span className="text-text-secondary text-sm">{item.secondaryLabel}</span>
)}
</div>
</span> </span>
{isSelected && ( {isSelected && (
<div className="text-icon-color flex h-4 w-4 items-center"> <div className="text-icon-color flex h-4 w-4 items-center">
@ -191,11 +207,14 @@ function SelectComponent<T = string>({
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
onChange(null); // Type assertion is safe here because handleClear is only called when clearable is true
setSearchValue(''); if (clearable) {
handleOpenChange(false); (onChange as (value: T | null) => void)(null);
setSearchValue('');
handleOpenChange(false);
}
}, },
[onChange, handleOpenChange] [onChange, handleOpenChange, clearable]
); );
const handleInputChange = React.useCallback( const handleInputChange = React.useCallback(

View File

@ -4,7 +4,7 @@ import { faker } from '@faker-js/faker';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from '@storybook/test';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { SelectItem } from './SelectOld'; import type { SelectItem } from './Select';
import { SelectMultiple } from './SelectMultiple'; import { SelectMultiple } from './SelectMultiple';
const meta = { const meta = {

View File

@ -6,7 +6,7 @@ import { useMemoizedFn } from '@/hooks';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
import { Dropdown, type DropdownItem, type DropdownProps } from '../dropdown/Dropdown'; import { Dropdown, type DropdownItem, type DropdownProps } from '../dropdown/Dropdown';
import { InputTag } from '../inputs/InputTag'; import { InputTag } from '../inputs/InputTag';
import type { SelectItem } from './SelectOld'; import type { SelectItem } from './Select';
import { selectVariants } from './SelectBase'; import { selectVariants } from './SelectBase';
import { CircleSpinnerLoader } from '../loaders'; import { CircleSpinnerLoader } from '../loaders';

View File

@ -36,6 +36,7 @@ export interface SelectProps<T> {
open?: boolean; open?: boolean;
showIndex?: boolean; showIndex?: boolean;
className?: string; className?: string;
contentClassName?: string;
defaultValue?: string; defaultValue?: string;
dataTestId?: string; dataTestId?: string;
loading?: boolean; loading?: boolean;
@ -50,6 +51,7 @@ export const Select = <T extends string>({
value, value,
onOpenChange, onOpenChange,
open, open,
contentClassName,
loading = false, loading = false,
className = '', className = '',
defaultValue, defaultValue,
@ -69,7 +71,7 @@ export const Select = <T extends string>({
<SelectTrigger className={className} data-testid={dataTestId} loading={loading}> <SelectTrigger className={className} data-testid={dataTestId} loading={loading}>
<SelectValue placeholder={placeholder} defaultValue={value || defaultValue} /> <SelectValue placeholder={placeholder} defaultValue={value || defaultValue} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className={contentClassName}>
{items.map((item, index) => ( {items.map((item, index) => (
<SelectItemSelector <SelectItemSelector
key={index.toString()} key={index.toString()}

View File

@ -1 +1 @@
export * from './SelectOld'; export * from './Select';

View File

@ -5,11 +5,11 @@ import { ArrowUpRight, CircleCheck, AlertWarning } from '@/components/ui/icons';
import { Paragraph, Text } from '@/components/ui/typography'; import { Paragraph, Text } from '@/components/ui/typography';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
import type { useNewChatWarning } from './useNewChatWarning'; import type { useNewChatWarning } from './useNewChatWarning';
import { BusterOrganizationRoleLabels } from '@/api/asset_interfaces/organizations';
import type { OrganizationRole } from '@buster/server-shared/organization'; import type { OrganizationRole } from '@buster/server-shared/organization';
import { OrganizationUserRoleText } from '@/lib/organization/translations';
const translateRole = (role: OrganizationRole) => { const translateRole = (role: OrganizationRole): string => {
return BusterOrganizationRoleLabels[role]; return OrganizationUserRoleText[role].title;
}; };
export const NewChatWarning = React.memo( export const NewChatWarning = React.memo(

View File

@ -1,9 +1,30 @@
import type { OrganizationRole } from '@buster/server-shared/organization'; import type { OrganizationRole } from '@buster/server-shared/organization';
export const OrganizationUserRoleText: Record<OrganizationRole, string> = { export const OrganizationUserRoleText: Record<
data_admin: 'Data Admin', OrganizationRole,
workspace_admin: 'Workspace Admin', {
querier: 'Querier', title: string;
restricted_querier: 'Restricted Querier', description: string;
viewer: 'Viewer', }
> = {
viewer: {
title: 'Viewer',
description: 'Can only view metrics that have been shared with them.'
},
restricted_querier: {
title: 'Restricted Querier',
description: 'Can only query datasets that have been provisioned to them.'
},
querier: {
title: 'Querier',
description: 'Can query all datasets associated with the workspace.'
},
data_admin: {
title: 'Data Admin',
description: 'Full access, except for billing.'
},
workspace_admin: {
title: 'Workspace Admin',
description: 'Full access, including billing.'
}
}; };