Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 2 additions & 14 deletions apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useId } from 'react';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';

import RoomFormAttributeField from './RoomFormAttributeField';
import RoomFormAttributeFields from './RoomFormAttributeFields';
import RoomFormAutocomplete from './RoomFormAutocomplete';
import RoomFormAutocompleteDummy from './RoomFormAutocompleteDummy';

Expand Down Expand Up @@ -104,19 +104,7 @@ const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: RoomFormP
</FieldRow>
{errors.room && <FieldError>{errors.room.message}</FieldError>}
</Field>
{fields.map((field, index) => (
<Field key={field.id} mb={16}>
<FieldLabel htmlFor={field.id} required>
{t('Attribute')}
</FieldLabel>
<RoomFormAttributeField
onRemove={() => {
remove(index);
}}
index={index}
/>
</Field>
))}
<RoomFormAttributeFields fields={fields} remove={remove} />
<Button
w='full'
disabled={fields.length >= 10}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,7 @@ import { render } from '@testing-library/react';

import * as stories from './RoomFormAttributeField.stories';

const mockAttribute1 = {
_id: 'attr1',
key: 'Department',
label: 'Department',
values: ['Engineering', 'Sales', 'Marketing'],
};

const mockAttribute2 = {
_id: 'attr2',
key: 'Security-Level',
label: 'Security Level',
values: ['Public', 'Internal', 'Confidential'],
};

const mockAttribute3 = {
_id: 'attr3',
key: 'Location',
label: 'Location',
values: ['US', 'EU', 'APAC'],
};

jest.mock('../hooks/useAttributeList', () => ({
useAttributeList: jest.fn(() => ({
data: [mockAttribute1, mockAttribute2, mockAttribute3],
fetchNextPage: jest.fn(),
isLoading: false,
})),
}));

describe('RoomFormAttributeField', () => {
// TODO: Once the autocomplete components are a11y compliant, and testable, add more tests
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);

test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,7 @@ const meta: Meta<typeof RoomFormAttributeField> = {
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot()
.withEndpoint('GET', '/v1/abac/attributes', () => ({
attributes: [mockAttribute1, mockAttribute2, mockAttribute3],
count: 3,
offset: 0,
total: 3,
}))
.build();
const AppRoot = mockAppRoot().build();

const methods = useForm<RoomFormData>({
defaultValues: {
Expand All @@ -65,6 +58,11 @@ const meta: Meta<typeof RoomFormAttributeField> = {
],
args: {
onRemove: action('onRemove'),
attributeList: [
{ value: mockAttribute1.key, label: mockAttribute1.key, attributeValues: mockAttribute1.values },
{ value: mockAttribute2.key, label: mockAttribute2.key, attributeValues: mockAttribute2.values },
{ value: mockAttribute3.key, label: mockAttribute3.key, attributeValues: mockAttribute3.values },
],
index: 0,
},
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
import { Box, Button, FieldError, FieldRow, InputBoxSkeleton, MultiSelect, PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useCallback, useMemo, useState } from 'react';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Button, FieldError, FieldRow, MultiSelect, SelectFiltered } from '@rocket.chat/fuselage';
import { useCallback, useMemo } from 'react';
import { useController, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';

import type { RoomFormData } from './RoomForm';
import { useAttributeList } from '../hooks/useAttributeList';

type ABACAttributeAutocompleteProps = {
onRemove: () => void;
index: number;
attributeList: { value: string; label: string; attributeValues: string[] }[];
};

const RoomFormAttributeField = ({ onRemove, index }: ABACAttributeAutocompleteProps) => {
const RoomFormAttributeField = ({ onRemove, index, attributeList }: ABACAttributeAutocompleteProps) => {
const { t } = useTranslation();
const [filter, setFilter] = useState<string>();
const filterDebounced = useDebouncedValue(filter, 300);

const { control, getValues, resetField } = useFormContext<RoomFormData>();

const { data: options, fetchNextPage, isLoading } = useAttributeList(filterDebounced || undefined);
const options: SelectOption[] = useMemo(() => attributeList.map((attribute) => [attribute.value, attribute.label]), [attributeList]);

const validateRepeatedAttributes = useCallback(
(value: string) => {
Expand Down Expand Up @@ -51,31 +49,25 @@ const RoomFormAttributeField = ({ onRemove, index }: ABACAttributeAutocompletePr
return [];
}

const selectedAttributeData = options.find((option) => option.value === keyField.value);
const selectedAttributeData = attributeList.find((option) => option.value === keyField.value);

return selectedAttributeData?.attributeValues.map((value) => [value, value]) || [];
}, [keyField.value, options]);
}, [attributeList, keyField.value]);

if (isLoading) {
return <InputBoxSkeleton />;
}
return (
<Box display='flex' flexDirection='column' w='full'>
<FieldRow>
<PaginatedSelectFiltered
<SelectFiltered
{...keyField}
onChange={(val) => {
resetField(`attributes.${index}.values`);
keyField.onChange(val);
}}
filter={filter}
setFilter={setFilter as (value: string | number | undefined) => void}
options={options}
endReached={() => fetchNextPage()}
placeholder={t('ABAC_Search_Attribute')}
mbe={4}
error={keyFieldState.error?.message}
withTruncatedText
onChange={(value) => {
keyField.onChange(value);
resetField(`attributes.${index}.values`, { defaultValue: [] });
}}
/>
</FieldRow>
{keyFieldState.error && <FieldError>{keyFieldState.error.message}</FieldError>}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';

import type { RoomFormData } from './RoomForm';
import RoomFormAttributeFields from './RoomFormAttributeFields';

const mockAttribute1 = {
_id: 'attr1',
key: 'Department',
values: ['Engineering', 'Sales', 'Marketing'],
};

const mockAttribute2 = {
_id: 'attr2',
key: 'Security-Level',
values: ['Public', 'Internal', 'Confidential'],
};

const mockAttribute3 = {
_id: 'attr3',
key: 'Location',
values: ['US', 'EU', 'APAC'],
};

jest.mock('../hooks/useAttributeList', () => ({
useAttributeList: jest.fn(() => ({
data: {
attributes: [
{ value: mockAttribute1.key, label: mockAttribute1.key, attributeValues: mockAttribute1.values },
{ value: mockAttribute2.key, label: mockAttribute2.key, attributeValues: mockAttribute2.values },
{ value: mockAttribute3.key, label: mockAttribute3.key, attributeValues: mockAttribute3.values },
],
},
isLoading: false,
})),
}));

const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Attribute: 'Attribute',
ABAC_Search_Attribute: 'Search attribute',
ABAC_Select_Attribute_Values: 'Select attribute values',
Remove: 'Remove',
})
.build();

const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial<RoomFormData> }) => {
const methods = useForm<RoomFormData>({
defaultValues: {
room: '',
attributes: [{ key: '', values: [] }],
...defaultValues,
},
mode: 'onChange',
});

return <FormProvider {...methods}>{children}</FormProvider>;
};

describe('RoomFormAttributeFields', () => {
const mockRemove = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
});

it('should render the correct number of fields', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }, { id: 'field-3' }];

render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);

const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(3);
});

it('should render a single field', () => {
const fields = [{ id: 'field-1' }];

render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);

const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(1);
});

it('should render multiple fields', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }, { id: 'field-3' }, { id: 'field-4' }, { id: 'field-5' }];

render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);

const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(5);
});

it('should render fields with provided default values', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }];

render(
<FormProviderWrapper
defaultValues={{
attributes: [
{ key: 'Department', values: ['Engineering'] },
{ key: 'Security-Level', values: ['Public', 'Internal'] },
],
}}
>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);

const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(2);
expect(screen.getByText('Department')).toBeInTheDocument();
expect(screen.getByText('Engineering')).toBeInTheDocument();
expect(screen.getByText('Security-Level')).toBeInTheDocument();
expect(screen.getByText('Public')).toBeInTheDocument();
expect(screen.getByText('Internal')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';

import RoomFormAttributeField from './RoomFormAttributeField';
import { useAttributeList } from '../hooks/useAttributeList';

type RoomFormAttributeFieldsProps = {
fields: { id: string }[];
remove: (index: number) => void;
};

const RoomFormAttributeFields = ({ fields, remove }: RoomFormAttributeFieldsProps) => {
const { t } = useTranslation();

const { data: attributeList, isLoading } = useAttributeList();

if (isLoading || !attributeList) {
return <InputBoxSkeleton />;
}

return fields.map((field, index) => (
<Field key={field.id} mb={16}>
<FieldLabel htmlFor={field.id} required>
{t('Attribute')}
</FieldLabel>
<RoomFormAttributeField
attributeList={attributeList.attributes}
onRemove={() => {
remove(index);
}}
index={index}
/>
</Field>
));
};

export default RoomFormAttributeFields;
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ exports[`RoomFormAttributeField renders Default without crashing 1`] = `
name="attributes.0.key"
>
<div
class="rcx-box rcx-box--full rcx-select__wrapper rcx-css-bpb89k"
class="rcx-box rcx-box--full rcx-select__wrapper--hidden rcx-select__wrapper rcx-css-bpb89k"
>
<span
class="rcx-box rcx-box--full rcx-select__item rcx-css-jmdrnf"
>
ABAC_Search_Attribute
</span>
<input
aria-haspopup="listbox"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-select__focus rcx-css-1l0s473"
placeholder="ABAC_Search_Attribute"
value=""
Expand Down
Loading
Loading