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 './datasources';
export * from './metric';
export * from './organizations';
export * from './permission';
export * from './permission_groups';
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',
width: 165,
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 { BusterOrganizationRoleLabels } from '@/api/asset_interfaces';
import { useUpdateUser } from '@/api/buster_rest/users';
import {
Card,
@ -14,6 +13,8 @@ import { Text } from '@/components/ui/typography';
import { useMemoizedFn } from '@/hooks';
import { User } from '@buster/server-shared/user';
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<{
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(
({
role,
@ -91,12 +78,7 @@ const DefaultAccessCard = React.memo(
: 'Only admins can change access'
: undefined
}>
<Select
items={accessOptions}
value={role}
onChange={onChange}
disabled={isDisabled}
/>
<AccessRoleSelect role={role} onChange={onChange} />
</AppTooltip>
</div>
</div>

View File

@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react';
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 { 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,
UpdateWorkspaceSettingsRequest
} from '@buster/server-shared/security';
import { Select, type SelectItem } from '@/components/ui/select';
import { type OrganizationRole, OrganizationRoleEnum } from '@buster/server-shared/organization';
import { OrganizationUserRoleText } from '@/lib/organization/translations';
import { type SelectItem } from '@/components/ui/select';
import { type OrganizationRole } from '@buster/server-shared/organization';
import { useGetDatasets } from '@/api/buster_rest/datasets';
import { SelectMultiple } from '@/components/ui/select/SelectMultiple';
import { AccessRoleSelect } from './AccessRoleSelect';
import { useMemoizedFn } from '@/hooks';
export const WorkspaceRestrictions = React.memo(() => {
const { data: workspaceSettings } = useGetWorkspaceSettings();
@ -31,7 +32,7 @@ export const WorkspaceRestrictions = React.memo(() => {
/>,
<DefaultRole
key="default-role"
default_role={workspaceSettings?.default_role ?? 'viewer' as OrganizationRole}
default_role={workspaceSettings?.default_role ?? ('viewer' as OrganizationRole)}
updateWorkspaceSettings={updateWorkspaceSettings}
/>,
<DefaultDatasets
@ -84,13 +85,6 @@ const DefaultRole = ({
}: Pick<GetWorkspaceSettingsResponse, 'default_role'> & {
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 (
<div className="flex items-center justify-between">
<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`}
</Text>
</div>
<Select
items={items}
className="w-36 max-w-72"
value={default_role}
onChange={(v) => {
<AccessRoleSelect
role={default_role}
onChange={useMemoizedFn((v) => {
updateWorkspaceSettings({ default_role: v });
}}
})}
/>
</div>
);

View File

@ -27,10 +27,10 @@ export interface SelectItem<T = string> {
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>[];
disabled?: boolean;
onChange: (value: T | null) => void;
placeholder?: string;
value?: string | undefined;
onOpenChange?: (open: boolean) => void;
@ -41,10 +41,24 @@ export interface SelectProps<T> {
dataTestId?: string;
loading?: boolean;
search?: boolean | SearchFunction<T>;
clearable?: boolean;
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>(
items: SelectItem<T>[] | SelectItemGroup<T>[]
): items is SelectItemGroup<T>[] {
@ -83,17 +97,19 @@ const SelectItemComponent = React.memo(
onSelect={onSelect}
disabled={item.disabled}
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',
isSelected && 'bg-item-select'
)}>
{item.icon}
<span className="flex-1">
{showIndex && `${index + 1}. `}
{item.label}
{item.secondaryLabel && (
<span className="text-text-secondary ml-2 text-sm">{item.secondaryLabel}</span>
)}
<div className="flex flex-col space-y-0">
<span className="text-foreground">{item.label}</span>
{item.secondaryLabel && (
<span className="text-text-secondary text-sm">{item.secondaryLabel}</span>
)}
</div>
</span>
{isSelected && (
<div className="text-icon-color flex h-4 w-4 items-center">
@ -191,11 +207,14 @@ function SelectComponent<T = string>({
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onChange(null);
setSearchValue('');
handleOpenChange(false);
// Type assertion is safe here because handleClear is only called when clearable is true
if (clearable) {
(onChange as (value: T | null) => void)(null);
setSearchValue('');
handleOpenChange(false);
}
},
[onChange, handleOpenChange]
[onChange, handleOpenChange, clearable]
);
const handleInputChange = React.useCallback(

View File

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

View File

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

View File

@ -36,6 +36,7 @@ export interface SelectProps<T> {
open?: boolean;
showIndex?: boolean;
className?: string;
contentClassName?: string;
defaultValue?: string;
dataTestId?: string;
loading?: boolean;
@ -50,6 +51,7 @@ export const Select = <T extends string>({
value,
onOpenChange,
open,
contentClassName,
loading = false,
className = '',
defaultValue,
@ -69,7 +71,7 @@ export const Select = <T extends string>({
<SelectTrigger className={className} data-testid={dataTestId} loading={loading}>
<SelectValue placeholder={placeholder} defaultValue={value || defaultValue} />
</SelectTrigger>
<SelectContent>
<SelectContent className={contentClassName}>
{items.map((item, index) => (
<SelectItemSelector
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 { cn } from '@/lib/classMerge';
import type { useNewChatWarning } from './useNewChatWarning';
import { BusterOrganizationRoleLabels } from '@/api/asset_interfaces/organizations';
import type { OrganizationRole } from '@buster/server-shared/organization';
import { OrganizationUserRoleText } from '@/lib/organization/translations';
const translateRole = (role: OrganizationRole) => {
return BusterOrganizationRoleLabels[role];
const translateRole = (role: OrganizationRole): string => {
return OrganizationUserRoleText[role].title;
};
export const NewChatWarning = React.memo(

View File

@ -1,9 +1,30 @@
import type { OrganizationRole } from '@buster/server-shared/organization';
export const OrganizationUserRoleText: Record<OrganizationRole, string> = {
data_admin: 'Data Admin',
workspace_admin: 'Workspace Admin',
querier: 'Querier',
restricted_querier: 'Restricted Querier',
viewer: 'Viewer',
export const OrganizationUserRoleText: Record<
OrganizationRole,
{
title: string;
description: string;
}
> = {
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.'
}
};