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
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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user