snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import { GenericMenu } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useAttributeOptions } from '../hooks/useAttributeOptions';
export type AttributeMenuProps = {
attribute: { _id: string; key: string };
};
const AttributeMenu = ({ attribute }: AttributeMenuProps) => {
const { t } = useTranslation();
const items = useAttributeOptions(attribute);
return (
<GenericMenu
title={t('Options')}
icon='kebab'
sections={[
{
items,
},
]}
/>
);
};
export default AttributeMenu;

View File

@@ -0,0 +1,102 @@
import { ContextualbarClose, ContextualbarHeader, ContextualbarTitle } from '@rocket.chat/ui-client';
import { useEndpoint, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { AttributesFormFormData } from './AttributesForm';
import AttributesForm from './AttributesForm';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
export type AttributesContextualBarProps = {
attributeId?: string;
attributeData?: {
key: string;
values: string[];
};
onClose: () => void;
};
const AttributesContextualBar = ({ attributeData, onClose }: AttributesContextualBarProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const methods = useForm<{
name: string;
attributeValues: { value: string }[];
lockedAttributes: { value: string }[];
}>({
defaultValues: attributeData
? {
name: attributeData.key,
attributeValues: [],
lockedAttributes: attributeData.values.map((value) => ({ value })),
}
: {
name: '',
attributeValues: [{ value: '' }],
lockedAttributes: [],
},
mode: 'onChange',
});
const { getValues } = methods;
const attributeId = useRouteParameter('id');
const createAttribute = useEndpoint('POST', '/v1/abac/attributes');
const updateAttribute = useEndpoint('PUT', '/v1/abac/attributes/:_id', {
_id: attributeId ?? '',
});
const dispatchToastMessage = useToastMessageDispatch();
const saveMutation = useMutation({
mutationFn: async (data: AttributesFormFormData) => {
const payload = {
key: data.name,
values: [...data.lockedAttributes.map((attribute) => attribute.value), ...data.attributeValues.map((attribute) => attribute.value)],
};
if (attributeId) {
await updateAttribute(payload);
} else {
await createAttribute(payload);
}
},
onSuccess: () => {
if (attributeId) {
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_updated', { attributeName: getValues('name') }) });
} else {
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_created', { attributeName: getValues('name') }) });
}
onClose();
},
onError: (error: { errorType: string; error: string }) => {
if (error.errorType === 'invalid-params') {
dispatchToastMessage({ type: 'error', message: t('ABAC_Invalid_attribute') });
} else {
dispatchToastMessage({ type: 'error', message: error });
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.roomAttributes.list() });
},
});
return (
<>
<ContextualbarHeader>
<ContextualbarTitle>{t(attributeId ? 'ABAC_Edit_attribute' : 'ABAC_New_attribute')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<FormProvider {...methods}>
<AttributesForm
onSave={(values) => saveMutation.mutateAsync(values)}
onCancel={onClose}
description={t(attributeId ? 'ABAC_Edit_attribute_description' : 'ABAC_New_attribute_description')}
/>
</FormProvider>
</>
);
};
export default AttributesContextualBar;

View File

@@ -0,0 +1,28 @@
import { ContextualbarSkeletonBody } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import AttributesContextualBar from './AttributesContextualBar';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
export type AttributesContextualBarWithDataProps = {
id: string;
onClose: () => void;
};
const AttributesContextualBarWithData = ({ id, onClose }: AttributesContextualBarWithDataProps) => {
const getAttributes = useEndpoint('GET', '/v1/abac/attributes/:_id', { _id: id });
const { data, isLoading, isFetching } = useQuery({
queryKey: ABACQueryKeys.roomAttributes.attribute(id),
queryFn: () => getAttributes(),
staleTime: 0,
});
if (isLoading || isFetching) {
return <ContextualbarSkeletonBody />;
}
return <AttributesContextualBar attributeData={data} onClose={onClose} />;
};
export default AttributesContextualBarWithData;

View File

@@ -0,0 +1,287 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import type { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import AttributesForm, { type AttributesFormFormData } from './AttributesForm';
import * as stories from './AttributesForm.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Name: 'Name',
Values: 'Values',
Add_Value: 'Add Value',
Cancel: 'Cancel',
Save: 'Save',
Required_field: '{{field}} is required',
})
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
.build();
const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial<AttributesFormFormData> }) => {
const methods = useForm<AttributesFormFormData>({
defaultValues: {
name: '',
attributeValues: [{ value: '' }],
lockedAttributes: [],
...defaultValues,
},
mode: 'onChange',
});
return <FormProvider {...methods}>{children}</FormProvider>;
};
describe('AttributesForm', () => {
const defaultProps = {
onSave: jest.fn(),
onCancel: jest.fn(),
description: 'Create an attribute that can later be assigned to rooms.',
};
beforeEach(() => {
jest.clearAllMocks();
});
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />, { wrapper: appRoot });
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: appRoot });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should show validation errors for required fields', async () => {
render(
<FormProviderWrapper>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
// Making the form "touched"
const nameInput = screen.getByLabelText('Name*');
await userEvent.type(nameInput, 'Test Attribute');
await userEvent.clear(nameInput);
const saveButton = screen.getByRole('button', { name: 'Save' });
await userEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument();
});
});
it('should show validation error for empty attribute values', async () => {
render(
<FormProviderWrapper>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const nameInput = screen.getByLabelText('Name*');
await userEvent.type(nameInput, 'Test Attribute');
const saveButton = screen.getByRole('button', { name: 'Save' });
await userEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Values is required')).toBeInTheDocument();
});
});
it('should add new attribute value when Add Value button is clicked', async () => {
const defaultValues = {
name: 'Test Attribute',
attributeValues: [{ value: 'Value 1' }],
};
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const addButton = screen.getByRole('button', { name: 'Add Value' });
await userEvent.click(addButton);
const valueInputs = screen.getAllByLabelText('Values*');
expect(valueInputs).toHaveLength(2);
});
it('should remove attribute value when trash button is clicked', async () => {
const defaultValues = {
name: 'Test Attribute',
attributeValues: [{ value: 'Value 1' }, { value: 'Value 2' }],
};
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' });
expect(screen.getByDisplayValue('Value 1')).toBeInTheDocument();
expect(screen.getByDisplayValue('Value 2')).toBeInTheDocument();
await userEvent.click(trashButtons[0]);
expect(screen.getByDisplayValue('Value 1')).toBeInTheDocument();
expect(screen.queryByDisplayValue('Value 2')).not.toBeInTheDocument();
});
it('should remove locked attribute when trash button is clicked', async () => {
const defaultValues = {
name: 'Room Type',
lockedAttributes: [{ value: 'Locked Value 1' }, { value: 'Locked Value 2' }],
};
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const trashButtons = screen.queryAllByRole('button', { name: 'ABAC_Remove_attribute' });
expect(screen.getByDisplayValue('Locked Value 1')).toBeInTheDocument();
expect(screen.getByDisplayValue('Locked Value 2')).toBeInTheDocument();
await userEvent.click(trashButtons[0]);
expect(screen.getByDisplayValue('Locked Value 1')).toBeInTheDocument();
expect(screen.queryByDisplayValue('Locked Value 2')).not.toBeInTheDocument();
});
it('should disable Add Value button when there are empty values', async () => {
render(
<FormProviderWrapper>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const addButton = screen.getByRole('button', { name: 'Add Value' });
expect(addButton).toBeDisabled();
});
it('should enable Add Value button when all values are filled', async () => {
const defaultValues = {
name: 'Test Attribute',
attributeValues: [{ value: 'Value 1' }],
};
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const addButton = screen.getByRole('button', { name: 'Add Value' });
expect(addButton).not.toBeDisabled();
});
it('should call onSave with correct data when form is submitted', async () => {
const defaultValues = {
name: 'Test Attribute',
attributeValues: [{ value: 'Value 1' }, { value: 'Value 2' }],
};
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const trashButtons = screen.queryAllByRole('button', { name: 'ABAC_Remove_attribute' });
await userEvent.click(trashButtons[0]);
const saveButton = screen.getByRole('button', { name: 'Save' });
await userEvent.click(saveButton);
await waitFor(() => {
expect(defaultProps.onSave).toHaveBeenCalled();
});
});
it('should call onCancel when Cancel button is clicked', async () => {
render(
<FormProviderWrapper>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await userEvent.click(cancelButton);
expect(defaultProps.onCancel).toHaveBeenCalled();
});
it('should handle mixed locked and regular attributes correctly', async () => {
const defaultValues = {
name: 'Room Type',
lockedAttributes: [{ value: 'Locked Value' }],
attributeValues: [{ value: 'Regular Value' }],
};
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
expect(screen.getByDisplayValue('Locked Value')).toBeDisabled();
expect(screen.getByDisplayValue('Regular Value')).not.toBeDisabled();
const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' });
expect(trashButtons).toHaveLength(1);
});
it('should show disclaimer when trying to delete a locked attribute value that is in use', async () => {
const defaultValues = {
name: 'Test Attribute',
lockedAttributes: [{ value: 'Value 1' }, { value: 'Value 2' }],
};
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{
wrapper: mockAppRoot()
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: true }))
.withTranslations('en', 'core', {
ABAC_Cannot_delete_attribute_value_in_use: 'Cannot delete attribute value assigned to rooms. <1>View rooms</1>',
})
.build(),
},
);
const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' });
await userEvent.click(trashButtons[0]);
await waitFor(() => {
expect(screen.getByText('Cannot delete attribute value assigned to rooms.')).toBeInTheDocument();
});
expect(screen.getByText('Cannot delete attribute value assigned to rooms.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,77 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import AttributesForm, { type AttributesFormFormData } from './AttributesForm';
export default {
component: AttributesForm,
parameters: {
layout: 'padded',
},
args: {
description: 'Create an attribute that can later be assigned to rooms.',
},
decorators: [
mockAppRoot()
.withTranslations('en', 'core', {
Name: 'Name',
Values: 'Values',
Add_Value: 'Add Value',
Cancel: 'Cancel',
Save: 'Save',
Required_field: '{{field}} is required',
})
.buildStoryDecorator(),
],
} satisfies Meta<typeof AttributesForm>;
export const NewAttribute: StoryObj<typeof AttributesForm> = {
decorators: [
(fn) => {
const methods = useForm<AttributesFormFormData>({
defaultValues: {
name: '',
attributeValues: [{ value: '' }],
lockedAttributes: [],
},
mode: 'onChange',
});
return (
<FormProvider {...methods}>
<Contextualbar width='400px' padding={16}>
{fn()}
</Contextualbar>
</FormProvider>
);
},
],
};
export const WithLockedAttributes: StoryObj<typeof AttributesForm> = {
args: {
description: 'Attribute values cannot be edited, but can be added or deleted.',
},
decorators: [
(fn) => {
const methods = useForm<AttributesFormFormData>({
defaultValues: {
name: 'Room Type',
lockedAttributes: [{ value: 'Locked Value 1' }, { value: 'Locked Value 2' }, { value: 'Locked Value 3' }],
},
mode: 'onChange',
});
return (
<FormProvider {...methods}>
<Contextualbar width='400px' padding={16}>
{fn()}
</Contextualbar>
</FormProvider>
);
},
],
};

View File

@@ -0,0 +1,219 @@
import { Box, Button, ButtonGroup, Field, FieldError, FieldLabel, FieldRow, IconButton, TextInput } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { ContextualbarFooter, ContextualbarScrollableContent } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useCallback, useId, useMemo, Fragment, useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { useViewRoomsAction } from '../hooks/useViewRoomsAction';
export type AttributesFormFormData = {
name: string;
attributeValues: { value: string }[];
lockedAttributes: { value: string }[];
};
export type AttributesFormProps = {
onSave: (data: AttributesFormFormData) => void;
onCancel: () => void;
description: string;
};
const AttributesForm = ({ onSave, onCancel, description }: AttributesFormProps) => {
const {
handleSubmit,
register,
formState: { errors, isDirty },
watch,
getValues,
} = useFormContext<AttributesFormFormData>();
const { t } = useTranslation();
const attributeValues = watch('attributeValues');
const lockedAttributes = watch('lockedAttributes');
const isAttributeUsed = useEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', { key: getValues('name') });
const { fields: lockedAttributesFields, remove: removeLockedAttributeField } = useFieldArray({
name: 'lockedAttributes',
});
const validateRepeatedValues = useCallback(
(value: string) => {
// Only one instance of the same attribute value is allowed to be in the form at a time
const repeatedAttributes = [...lockedAttributes, ...attributeValues].filter((attribute) => attribute.value === value).length > 1;
return repeatedAttributes ? t('ABAC_No_repeated_values') : undefined;
},
[lockedAttributes, attributeValues, t],
);
const { fields, append, remove } = useFieldArray({
name: 'attributeValues',
rules: {
minLength: 1,
},
});
const formId = useId();
const nameField = useId();
const valuesField = useId();
const [showDisclaimer, setShowDisclaimer] = useState<number[]>([]);
const viewRoomsAction = useViewRoomsAction();
const removeLockedAttribute = useStableCallback(async (index: number) => {
const isInUse = await isAttributeUsed();
if (showDisclaimer.includes(index)) {
return;
}
if (isInUse?.inUse) {
return setShowDisclaimer((prev) => [...prev, index]);
}
return removeLockedAttributeField(index);
});
const getAttributeValuesError = useCallback(() => {
if (errors.attributeValues?.length && errors.attributeValues?.length > 0) {
return errors.attributeValues[0]?.value?.message;
}
return '';
}, [errors.attributeValues]);
const hasValuesErrors = useMemo(() => {
const attributeValuesErrors = Array.isArray(errors?.attributeValues) && errors.attributeValues.some((error) => !!error?.value?.message);
const lockedAttributesErrors =
Array.isArray(errors?.lockedAttributes) && errors.lockedAttributes.some((error) => !!error?.value?.message);
return attributeValuesErrors || lockedAttributesErrors;
}, [errors.attributeValues, errors.lockedAttributes]);
return (
<>
<ContextualbarScrollableContent is='form' onSubmit={handleSubmit(onSave)} id={formId}>
<Box>{description}</Box>
<Field>
<FieldLabel htmlFor={nameField} required>
{t('Name')}
</FieldLabel>
<FieldRow>
<TextInput
error={errors.name?.message}
id={nameField}
aria-required='true'
{...register('name', { required: t('Required_field', { field: t('Name') }) })}
aria-invalid={errors.name ? 'true' : 'false'}
aria-describedby={errors.name ? `${nameField}-error` : undefined}
/>
</FieldRow>
{errors.name && (
<FieldError id={`${nameField}-error`} role='alert'>
{errors.name.message}
</FieldError>
)}
</Field>
<Field>
<FieldLabel required id={valuesField}>
{t('Values')}
</FieldLabel>
{lockedAttributesFields.map((field, index) => (
<Fragment key={field.id}>
<FieldRow key={field.id}>
<TextInput
disabled
aria-labelledby={valuesField}
aria-describedby={errors.lockedAttributes?.[index]?.value ? `${valuesField}-lockedAttr-${index}-error` : undefined}
error={errors.lockedAttributes?.[index]?.value?.message || ''}
aria-invalid={errors.lockedAttributes?.[index]?.value ? 'true' : 'false'}
aria-required='true'
{...register(`lockedAttributes.${index}.value`, {
required: t('Required_field', { field: t('Values') }),
validate: (value: string) => validateRepeatedValues(value),
})}
/>
{index !== 0 && (
<IconButton
marginInlineStart={8}
small
title={t('ABAC_Remove_attribute')}
icon='trash'
onClick={() => removeLockedAttribute(index)}
/>
)}
</FieldRow>
{errors.lockedAttributes?.[index]?.value && (
<FieldError id={`${valuesField}-lockedAttr-${index}-error`} role='alert'>
{errors.lockedAttributes?.[index]?.value?.message}
</FieldError>
)}
{showDisclaimer.includes(index) && (
<FieldError>
<Trans
i18nKey='ABAC_Cannot_delete_attribute_value_in_use'
components={{
1: (
<Box
is='a'
onClick={(e) => {
e.preventDefault();
viewRoomsAction(getValues('name'));
}}
>
{t('ABAC_View_rooms')}
</Box>
),
}}
/>
</FieldError>
)}
</Fragment>
))}
{fields.map((field, index) => (
<Fragment key={field.id}>
<FieldRow>
<TextInput
aria-labelledby={valuesField}
aria-describedby={errors.attributeValues?.[index]?.value ? `${valuesField}-attr-${index}-error` : undefined}
error={errors.attributeValues?.[index]?.value?.message || ''}
aria-invalid={errors.attributeValues?.[index]?.value ? 'true' : 'false'}
aria-required='true'
{...register(`attributeValues.${index}.value`, {
required: t('Required_field', { field: t('Values') }),
validate: (value: string) => validateRepeatedValues(value),
})}
/>
{(index !== 0 || lockedAttributesFields.length > 0) && (
<IconButton marginInlineStart={8} small title={t('ABAC_Remove_attribute')} icon='trash' onClick={() => remove(index)} />
)}
</FieldRow>
{errors.attributeValues?.[index]?.value && (
<FieldError id={`${valuesField}-attr-${index}-error`} role='alert'>
{errors.attributeValues[index].value.message}
</FieldError>
)}
</Fragment>
))}
<Button
marginBlock={8}
onClick={() => append({ value: '' })}
// Checking for values since rhf does consider the newly added field as dirty after an append() call
disabled={!!getAttributeValuesError() || attributeValues?.some((value: { value: string }) => value?.value === '')}
>
{t('Add_Value')}
</Button>
</Field>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={() => onCancel()}>{t('Cancel')}</Button>
<Button type='submit' form={formId} disabled={hasValuesErrors || !!errors.name || !isDirty} primary>
{t('Save')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
};
export default AttributesForm;

View File

@@ -0,0 +1,112 @@
import { Box, Button, Icon, Margins, Pagination, TextInput } from '@rocket.chat/fuselage';
import { useDebouncedValue, useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
GenericTable,
GenericTableBody,
GenericTableCell,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableRow,
usePagination,
} from '@rocket.chat/ui-client';
import { useEndpoint, useRouter, useSearchParameter } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import AttributeMenu from './AttributeMenu';
import GenericNoResults from '../../../../components/GenericNoResults';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
import { useIsABACAvailable } from '../hooks/useIsABACAvailable';
const AttributesPage = () => {
const { t } = useTranslation();
const searchTerm = useSearchParameter('searchTerm');
const [text, setText] = useState(searchTerm ?? '');
const debouncedText = useDebouncedValue(text, 400);
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
const getAttributes = useEndpoint('GET', '/v1/abac/attributes');
const isABACAvailable = useIsABACAvailable();
const router = useRouter();
const handleNewAttribute = useStableCallback(() => {
router.navigate({
name: 'admin-ABAC',
params: {
tab: 'room-attributes',
context: 'new',
},
});
});
const query = useMemo(
() => ({
...(debouncedText ? { key: debouncedText, values: debouncedText } : {}),
offset: current,
count: itemsPerPage,
}),
[debouncedText, current, itemsPerPage],
);
const { data, isLoading } = useQuery({
queryKey: ABACQueryKeys.roomAttributes.list(query),
queryFn: () => getAttributes(query),
});
return (
<>
<Margins block={24}>
<Box display='flex'>
<TextInput
endAddon={<Icon name='magnifier' size='x20' />}
placeholder={t('ABAC_Search_attributes')}
value={text}
onChange={(e) => setText((e.target as HTMLInputElement).value)}
/>
<Button onClick={handleNewAttribute} primary marginInlineStart={8} disabled={!isABACAvailable}>
{t('ABAC_New_attribute')}
</Button>
</Box>
</Margins>
{(!data || data.attributes?.length === 0) && !isLoading ? (
<Box display='flex' justifyContent='center' height='full'>
<GenericNoResults icon='list-alt' title={t('ABAC_No_attributes')} description={t('ABAC_No_attributes_description')} />
</Box>
) : (
<>
<GenericTable>
<GenericTableHeader>
<GenericTableHeaderCell>{t('Name')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Value')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='spacer' width={40} />
</GenericTableHeader>
<GenericTableBody>
{data?.attributes?.map((attribute) => (
<GenericTableRow key={attribute._id}>
<GenericTableCell withTruncatedText>{attribute.key}</GenericTableCell>
<GenericTableCell withTruncatedText>{attribute.values.join(', ')}</GenericTableCell>
<GenericTableCell>
<AttributeMenu attribute={attribute} />
</GenericTableCell>
</GenericTableRow>
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
{...paginationProps}
/>
</>
)}
</>
);
};
export default AttributesPage;

View File

@@ -0,0 +1,389 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`AttributesForm renders NewAttribute without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-1d8k487"
>
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
>
<div
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
data-overlayscrollbars="host"
>
<div
class="os-size-observer"
>
<div
class="os-size-observer-listener"
/>
</div>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
>
<form
class="rcx-box rcx-box--full rcx-css-zg7o7h"
id="_r_0_"
>
<div
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1fuhgt0"
>
Create an attribute that can later be assigned to rooms.
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1fuhgt0"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="_r_1_"
>
Name
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
>
*
</span>
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<input
aria-invalid="false"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
id="_r_1_"
name="name"
size="1"
type="text"
/>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1fuhgt0"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
id="_r_2_"
>
Values
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
>
*
</span>
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<input
aria-invalid="false"
aria-labelledby="_r_2_"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
name="attributeValues.0.value"
size="1"
type="text"
/>
</span>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-cnkcl9"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
Add Value
</span>
</button>
</div>
</form>
</div>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-f51umh"
>
<div
class="rcx-button-group rcx-button-group--stretch rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
disabled=""
form="_r_0_"
type="submit"
>
<span
class="rcx-button--content"
>
Save
</span>
</button>
</div>
</div>
</div>
</div>
</body>
`;
exports[`AttributesForm renders WithLockedAttributes without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-1d8k487"
>
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
>
<div
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
data-overlayscrollbars="host"
>
<div
class="os-size-observer"
>
<div
class="os-size-observer-listener"
/>
</div>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
>
<form
class="rcx-box rcx-box--full rcx-css-zg7o7h"
id="_r_3_"
>
<div
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1fuhgt0"
>
Attribute values cannot be edited, but can be added or deleted.
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1fuhgt0"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="_r_4_"
>
Name
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
>
*
</span>
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<input
aria-invalid="false"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
id="_r_4_"
name="name"
size="1"
type="text"
/>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1fuhgt0"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
id="_r_5_"
>
Values
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
>
*
</span>
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<input
aria-invalid="false"
aria-labelledby="_r_5_"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
disabled=""
name="lockedAttributes.0.value"
size="1"
type="text"
/>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<input
aria-invalid="false"
aria-labelledby="_r_5_"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
disabled=""
name="lockedAttributes.1.value"
size="1"
type="text"
/>
<button
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ws1yyr"
title="ABAC_Remove_attribute"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-trash rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<input
aria-invalid="false"
aria-labelledby="_r_5_"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
disabled=""
name="lockedAttributes.2.value"
size="1"
type="text"
/>
<button
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ws1yyr"
title="ABAC_Remove_attribute"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-trash rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</span>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-cnkcl9"
type="button"
>
<span
class="rcx-button--content"
>
Add Value
</span>
</button>
</div>
</form>
</div>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-f51umh"
>
<div
class="rcx-button-group rcx-button-group--stretch rcx-button-group--align-start"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
disabled=""
form="_r_3_"
type="submit"
>
<span
class="rcx-button--content"
>
Save
</span>
</button>
</div>
</div>
</div>
</div>
</body>
`;