Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(organizations): add theming/story for Mantine Select TASK-1379 #5414

Merged
merged 15 commits into from
Jan 21, 2025
63 changes: 29 additions & 34 deletions jsapp/js/account/organization/MemberRoleSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,46 @@
import KoboSelect from 'jsapp/js/components/common/koboSelect';
import {Select} from 'jsapp/js/components/common/Select';
import {usePatchOrganizationMember} from './membersQuery';
import {OrganizationUserRole} from './organizationQuery';
import {type KoboDropdownPlacement} from 'jsapp/js/components/common/koboDropdown';
import {LoadingOverlay} from '@mantine/core';

interface MemberRoleSelectorProps {
username: string;
/** The role of the `username` user - the one we are modifying here. */
role: OrganizationUserRole;
/** The role of the currently logged in user. */
currentUserRole: OrganizationUserRole;
placement: KoboDropdownPlacement;
}

export default function MemberRoleSelector(
{username, role, currentUserRole, placement}: MemberRoleSelectorProps
) {
export default function MemberRoleSelector({
username,
role,
}: MemberRoleSelectorProps) {
const patchMember = usePatchOrganizationMember(username);

const canModifyRole = (
currentUserRole === 'owner' ||
currentUserRole === 'admin'
);
const handleRoleChange = (newRole: string | null) => {
if (newRole) {
patchMember.mutateAsync({role: newRole as OrganizationUserRole});
}
};

return (
<KoboSelect
name={`member-role-selector-${username}`}
type='outline'
size='m'
placement={placement}
options={[
{
value: OrganizationUserRole.admin,
label: t('Admin'),
},
{
value: OrganizationUserRole.member,
label: t('Member'),
},
]}
selectedOption={role}
onChange={(newRole: string | null) => {
if (newRole) {
patchMember.mutateAsync({role: newRole as OrganizationUserRole});
}
}}
isPending={patchMember.isPending}
isDisabled={!canModifyRole}
/>
<>
<LoadingOverlay visible={patchMember.isPending} />
<Select
size='sm'
data={[
{
value: OrganizationUserRole.admin,
label: t('Admin'),
},
{
value: OrganizationUserRole.member,
label: t('Member'),
},
]}
value={role}
onChange={handleRoleChange}
/>
</>
);
}
28 changes: 18 additions & 10 deletions jsapp/js/account/organization/MembersRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,30 @@ export default function MembersRoute() {
{
key: 'role',
label: t('Role'),
size: 120,
cellFormatter: (member: OrganizationMember, rowIndex: number) => {
if (member.role === OrganizationUserRole.owner) {
return t('Owner');
size: 140,
cellFormatter: (member: OrganizationMember) => {
if (
member.role === OrganizationUserRole.owner ||
!['owner', 'admin'].includes(orgQuery.data.request_user_role)
) {
// If the member is the Owner or
// If the user is not an owner or admin, we don't show the selector
switch (member.role) {
case OrganizationUserRole.owner:
return t('Owner');
case OrganizationUserRole.admin:
return t('Admin');
case OrganizationUserRole.member:
return t('Member');
default:
return t('Unknown');
}
}
return (
<MemberRoleSelector
username={member.user__username}
role={member.role}
currentUserRole={orgQuery.data.request_user_role}
// To avoid opening selector outside the container (causing
// unnecessary scrollbar), we open first 2 rows down, and the other
// rows up.
// TODO: this should be fixed by using a component with Portal
// functionality (looking at Mantine or MUI).
placement={rowIndex <= 1 ? 'down-center' : 'up-center'}
/>
);
},
Expand Down
141 changes: 141 additions & 0 deletions jsapp/js/components/common/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {Stack, type MantineSize} from '@mantine/core';
import {Select} from './Select';
import type {Meta, StoryObj} from '@storybook/react';

const sizes: MantineSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];

const data = [
{label: 'Apple', value: '1'},
{label: 'Banana', value: '2'},
{label: 'Cherry', value: '3'},
{label: 'Grape', value: '7'},
{label: 'Lemon', value: '12'},
];

const largeData = [
{label: 'Apple', value: '1'},
{label: 'Banana', value: '2'},
{label: 'Cherry', value: '3'},
{label: 'Date', value: '4'},
{label: 'Elderberry', value: '5'},
{label: 'Fig', value: '6'},
{label: 'Grape', value: '7'},
{label: 'Honeydew', value: '8'},
{label: 'Indian Fig', value: '9'},
{label: 'Jackfruit', value: '10'},
{label: 'Kiwi', value: '11'},
{label: 'Lemon', value: '12'},
{label: 'Mango', value: '13'},
{label: 'Nectarine', value: '14'},
{label: 'Orange', value: '15'},
{label: 'Papaya', value: '16'},
{label: 'Quince', value: '17'},
];

/**
* Mantine [Select](https://mantine.dev/core/select/) component stories.
* See detailed uses in [Mantine's Select page](https://mantine.dev/core/select/)
*/
const meta: Meta<typeof Select> = {
title: 'Common/Select',
component: Select,
decorators: [
(Story) => (
<div style={{maxWidth: 400, padding: 40, margin: 'auto'}}>
<Story />
</div>
),
],
parameters: {
controls: {expanded: false},
},
argTypes: {
label: {
description: 'Select label',
control: {type: 'text'},
},
placeholder: {
description: 'Placeholder for the input',
control: {type: 'text'},
},
size: {
description: 'Select size',
options: sizes,
control: {type: 'select'},
},
clearable: {
description: 'Add clear button to the right side of the input',
control: 'boolean',
},
searchable: {
description: 'Filter items by typing',
control: 'boolean',
},
data: {
description: 'Array of objects with label and value',
control: {type: 'object'},
},
},
args: {
label: 'Select',
placeholder: 'Pick one',
size: 'md',
clearable: false,
searchable: false,
data,
},
};

type Story = StoryObj<typeof Select>;

/**
* Basic usage of Select component
*/
export const Basic: Story = {};

/**
* Different sizes of the Select component
*/
export const Sizes = () => (
<Stack gap='md'>
{sizes.map((size) => (
<Select
key={size}
label={size}
placeholder='Pick one'
data={data}
size={size}
/>
))}
</Stack>
);

/**
* Clear button is added to the right side of the input when an option is selected
*/
export const Clearable: Story = {
args: {
clearable: true,
value: data[3].value,
},
};

/**
* Items are filtered by the input when typing the value. Custom icon can be added to the `leftSection` property
*/
export const Searchable: Story = {
args: {
searchable: true,
},
};

/**
* Select with large data set and scrollable dropdown
*/
export const Scrollable: Story = {
args: {
data: largeData,
},
};

export default meta;
62 changes: 62 additions & 0 deletions jsapp/js/components/common/Select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type {SelectProps, ComboboxItem} from '@mantine/core';
import {CloseButton, Group, Select as MantineSelect} from '@mantine/core';
import type {IconSize} from './icon';
import Icon from './icon';
import {useState} from 'react';

declare module '@mantine/core/lib/components/Select' {
/** @deprecated use Kobo implementation instead. (deprecating a new interface because can't augment variables) */
export interface Select {}
}

const iconSizeMap: Record<string, IconSize> = {
xs: 'xxs',
sm: 'xs',
md: 's',
lg: 'm',
xl: 'l',
};

export const Select = (props: SelectProps) => {
const [value, setValue] = useState<string | null>(props.value || null);
const [isOpened, setIsOpened] = useState(
props.defaultDropdownOpened || false
);

const onChange = (newValue: string | null, option: ComboboxItem) => {
setValue(newValue);
props.onChange?.(newValue, option);
};

const clear = () => {
setValue(null);
props.onClear?.();
};

const iconSize =
typeof props.size === 'string' ? iconSizeMap[props.size] : 's';

const clearButton =
props.clearable && value && !props.disabled && !props.readOnly ? (
<CloseButton
onClick={clear}
icon={<Icon name='close' size={iconSize} />}
/>
) : null;

return (
<MantineSelect
{...props}
value={value}
onChange={onChange}
onDropdownOpen={() => setIsOpened(true)}
onDropdownClose={() => setIsOpened(false)}
rightSection={
<Group gap={1} mr='sm'>
{clearButton}
<Icon name={isOpened ? 'angle-up' : 'angle-down'} size={iconSize} />
</Group>
}
/>
);
};
2 changes: 1 addition & 1 deletion jsapp/js/components/common/koboSelect.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {ComponentStory, ComponentMeta} from '@storybook/react';
import KoboSelect from 'js/components/common/koboSelect';

export default {
title: 'common/KoboSelect',
title: 'commonDeprecated/KoboSelect',
component: KoboSelect,
argTypes: {
selectedOption: {
Expand Down
43 changes: 43 additions & 0 deletions jsapp/js/theme/kobo/Select.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
.input {
border-color: var(--mantine-color-gray-6);

&::placeholder {
color: var(--mantine-color-gray-4);
opacity: 1;
}
}

.dropdown {
border-color: var(--mantine-color-gray-6);
}

.option {
border-radius: 0;
color: var(--mantine-color-gray-1);
}

.option:hover {
background-color: var(--mantine-color-blue-9);
}

.option[data-combobox-selected],
.option[data-combobox-active] {
background-color: var(--mantine-color-blue-9);
}

.section[data-position='right'] {
width: auto;
}

.section i {
display: flex;
color: var(--mantine-color-gray-4);
}

.section button {
background-color: transparent;
}

.section button:hover {
background-color: transparent;
}
15 changes: 15 additions & 0 deletions jsapp/js/theme/kobo/Select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {Select} from '@mantine/core';

import classes from './Select.module.css';

export const SelectThemeKobo = Select.extend({
classNames: classes,
defaultProps: {
withCheckIcon: false,
allowDeselect: false,
comboboxProps: {
offset: 0,
dropdownPadding: 0,
},
},
});
Loading
Loading