From a1c8b8c437e34d186f3bc44436f71b62371432f1 Mon Sep 17 00:00:00 2001 From: Zhongnan Su Date: Tue, 16 Apr 2024 09:30:56 -0700 Subject: [PATCH] [MD] Add dropdown header to Data source single selector (#6431) --- CHANGELOG.md | 1 + .../opensearch_dashboards.json | 2 +- .../create_data_source_menu.test.tsx | 15 +++- .../create_data_source_menu.tsx | 10 ++- .../data_source_menu/data_source_menu.tsx | 4 +- .../components/data_source_menu/types.ts | 2 + .../data_source_selectable.test.tsx.snap | 57 +++++++++++- .../data_source_selectable.test.tsx | 51 ++++++++++- .../data_source_selectable.tsx | 25 +++++- .../data_source_selector.test.tsx | 1 - .../drop_down_header.test.tsx.snap | 86 +++++++++++++++++++ .../drop_down_header/drop_down_header.scss | 3 + .../drop_down_header.test.tsx | 67 +++++++++++++++ .../drop_down_header/drop_down_header.tsx | 50 +++++++++++ .../components/drop_down_header/index.ts | 6 ++ .../public/components/utils.ts | 19 ++++ .../data_source_management/public/plugin.ts | 4 +- 17 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx create mode 100644 src/plugins/data_source_management/public/components/drop_down_header/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 06eccf2d30ec..4a8dca630e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [CSP Handler] Update CSP handler to only query and modify frame ancestors instead of all CSP directives ([#6398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6398)) - [MD] Add OpenSearch cluster group label to top of single selectable dropdown ([#6400](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6400)) - [Workspace] Support workspace in saved objects client in server side. ([#6365](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6365)) +- [MD] Add dropdown header to data source single selector ([#6431](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6431)) ### 🐛 Bug Fixes diff --git a/src/plugins/data_source_management/opensearch_dashboards.json b/src/plugins/data_source_management/opensearch_dashboards.json index cfcfdd2ce430..824f9eacc9f6 100644 --- a/src/plugins/data_source_management/opensearch_dashboards.json +++ b/src/plugins/data_source_management/opensearch_dashboards.json @@ -5,6 +5,6 @@ "ui": true, "requiredPlugins": ["management", "dataSource", "indexPatternManagement"], "optionalPlugins": [], - "requiredBundles": ["opensearchDashboardsReact", "dataSource"], + "requiredBundles": ["opensearchDashboardsReact", "dataSource", "opensearchDashboardsUtils"], "extraPublicDirs": ["public/components/utils"] } diff --git a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx index 6d7200b182c5..df299687928b 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx @@ -5,18 +5,29 @@ import { createDataSourceMenu } from './create_data_source_menu'; import { MountPoint, SavedObjectsClientContract } from '../../../../../core/public'; -import { coreMock, notificationServiceMock } from '../../../../../core/public/mocks'; +import { + applicationServiceMock, + coreMock, + notificationServiceMock, +} from '../../../../../core/public/mocks'; import React from 'react'; -import { act, getByText, render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import { DataSourceComponentType, DataSourceSelectableConfig } from './types'; import { ReactWrapper } from 'enzyme'; import { mockDataSourcePluginSetupWithShowLocalCluster } from '../../mocks'; +import * as utils from '../utils'; describe('create data source menu', () => { let client: SavedObjectsClientContract; const notifications = notificationServiceMock.createStartContract(); const { uiSettings } = coreMock.createSetup(); + beforeAll(() => { + jest + .spyOn(utils, 'getApplication') + .mockImplementation(() => applicationServiceMock.createStartContract()); + }); + beforeEach(() => { client = { find: jest.fn().mockResolvedValue([]), diff --git a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx index 56fd7a7a7cb3..51fcb0db0857 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx @@ -10,11 +10,13 @@ import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { DataSourceMenu } from './data_source_menu'; import { DataSourceMenuProps } from './types'; import { MountPointPortal } from '../../../../opensearch_dashboards_react/public'; +import { getApplication } from '../utils'; export function createDataSourceMenu( uiSettings: IUiSettingsClient, dataSourcePluginSetup: DataSourcePluginSetup ) { + const application = getApplication(); return (props: DataSourceMenuProps) => { const { hideLocalCluster } = dataSourcePluginSetup; if (props.setMenuMountPoint) { @@ -25,13 +27,19 @@ export function createDataSourceMenu( {...props} uiSettings={uiSettings} hideLocalCluster={hideLocalCluster} + application={application} /> ); } return ( - + ); }; } diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx index 9511090e6c5a..bc645a0b885f 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx @@ -19,8 +19,7 @@ import { import { DataSourceSelectable } from '../data_source_selectable'; export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null { - const { componentType, componentConfig, uiSettings, hideLocalCluster } = props; - + const { componentType, componentConfig, uiSettings, hideLocalCluster, application } = props; function renderDataSourceView(config: DataSourceViewConfig): ReactElement | null { const { activeOption, @@ -81,6 +80,7 @@ export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | hideLocalCluster={hideLocalCluster || false} fullWidth={fullWidth} uiSettings={uiSettings} + application={application} /> ); } diff --git a/src/plugins/data_source_management/public/components/data_source_menu/types.ts b/src/plugins/data_source_management/public/components/data_source_menu/types.ts index a37d7d0dd0c3..e5f34a3a2979 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/types.ts +++ b/src/plugins/data_source_management/public/components/data_source_menu/types.ts @@ -8,6 +8,7 @@ import { SavedObjectsClientContract, SavedObject, IUiSettingsClient, + ApplicationStart, } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; @@ -32,6 +33,7 @@ export interface DataSourceMenuProps { componentConfig: T; hideLocalCluster?: boolean; uiSettings?: IUiSettingsClient; + application?: ApplicationStart; setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; } diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap index e49222c37d7c..4601e70491d2 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap @@ -24,6 +24,7 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` display="inlineBlock" hasArrow={true} id="dataSourceSelectableContextMenuPopover" + initialFocus=".euiSelectableSearch" isOpen={false} ownFocus={true} panelPaddingSize="none" @@ -37,6 +38,12 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` color="transparent" paddingSize="s" > + + @@ -70,6 +77,7 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` renderOption={[Function]} searchProps={ Object { + "compressed": true, "placeholder": "Search", } } @@ -105,6 +113,7 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde display="inlineBlock" hasArrow={true} id="dataSourceSelectableContextMenuPopover" + initialFocus=".euiSelectableSearch" isOpen={false} ownFocus={true} panelPaddingSize="none" @@ -118,6 +127,12 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde color="transparent" paddingSize="s" > + + @@ -130,6 +145,7 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde renderOption={[Function]} searchProps={ Object { + "compressed": true, "placeholder": "Search", } } @@ -165,6 +181,7 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd display="inlineBlock" hasArrow={true} id="dataSourceSelectableContextMenuPopover" + initialFocus=".euiSelectableSearch" isOpen={false} ownFocus={true} panelPaddingSize="none" @@ -178,6 +195,12 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd color="transparent" paddingSize="s" > + + @@ -190,6 +213,7 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd renderOption={[Function]} searchProps={ Object { + "compressed": true, "placeholder": "Search", } } @@ -273,6 +297,35 @@ Object {
+
+
+ DATA SOURCES + ( + 3 + ) +
+
+
+ +
+
+
@@ -281,7 +334,7 @@ Object { data-test-subj="dataSourceSelectable" >
{ let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -409,7 +410,7 @@ describe('DataSourceSelectable', () => { component.instance().componentDidMount!(); await nextTick(); const optionsProp = component.find(EuiSelectable).prop('options'); - expect(optionsProp[0]).toEqual(opensearchClusterGroupLabel); + expect(optionsProp[0]).toEqual(dataSourceOptionGroupLabel.opensearchCluster); }); it('should not render opensearch cluster group label, when there is no option availiable', async () => { @@ -431,4 +432,50 @@ describe('DataSourceSelectable', () => { const optionsProp = component.find(EuiSelectable).prop('options'); expect(optionsProp).toEqual([]); }); + + it('should render group lablel normally after onChange', async () => { + const onSelectedDataSource = jest.fn(); + component = shallow( + + ); + const componentInstance = component.instance(); + + componentInstance.componentDidMount!(); + await nextTick(); + const optionsPropBefore = component.find(EuiSelectable).prop('options'); + expect(optionsPropBefore).toEqual([ + dataSourceOptionGroupLabel.opensearchCluster, + { + id: 'test1', + label: 'test1', + checked: 'on', + }, + { + id: 'test2', + label: 'test2', + }, + { + id: 'test3', + label: 'test3', + }, + ]); + componentInstance.onChange([ + dataSourceOptionGroupLabel.opensearchCluster, + { id: 'test2', label: 'test2', checked: 'on' }, + ]); + await nextTick(); + const optionsPropAfter = component.find(EuiSelectable).prop('options'); + expect(optionsPropAfter).toEqual([ + dataSourceOptionGroupLabel.opensearchCluster, + { id: 'test2', label: 'test2', checked: 'on' }, + ]); + }); }); diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx index 89f3d908dfe8..cf7c88526065 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx @@ -12,19 +12,27 @@ import { EuiButtonEmpty, EuiSelectable, EuiSpacer, + EuiHorizontalRule, } from '@elastic/eui'; import { + ApplicationStart, IUiSettingsClient, SavedObjectsClientContract, ToastsStart, } from 'opensearch-dashboards/public'; -import { getDataSourcesWithFields, getDefaultDataSource, getFilteredDataSources } from '../utils'; +import { + dataSourceOptionGroupLabel, + getDataSourcesWithFields, + getDefaultDataSource, + getFilteredDataSources, +} from '../utils'; import { LocalCluster } from '../data_source_selector/data_source_selector'; import { SavedObject } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; import { DataSourceGroupLabelOption, DataSourceOption } from '../data_source_menu/types'; import { DataSourceItem } from '../data_source_item'; import './data_source_selectable.scss'; +import { DataSourceDropDownHeader } from '../drop_down_header'; interface DataSourceSelectableProps { savedObjectsClient: SavedObjectsClientContract; @@ -33,6 +41,7 @@ interface DataSourceSelectableProps { disabled: boolean; hideLocalCluster: boolean; fullWidth: boolean; + application?: ApplicationStart; selectedOption?: DataSourceOption[]; dataSourceFilter?: (dataSource: SavedObject) => boolean; uiSettings?: IUiSettingsClient; @@ -193,9 +202,12 @@ export class DataSourceSelectable extends React.Component< onChange(options: DataSourceOption[]) { if (!this._isMounted) return; + const optionsWithoutGroupLabel = options.filter( + (option) => !option.hasOwnProperty('isGroupLabel') + ); const selectedDataSource = options.find(({ checked }) => checked); - this.setState({ dataSourceOptions: options }); + this.setState({ dataSourceOptions: optionsWithoutGroupLabel }); if (selectedDataSource) { this.setState({ @@ -213,7 +225,7 @@ export class DataSourceSelectable extends React.Component< if (dataSourceOptions.length === 0) { optionsWithGroupLabel = []; } else { - optionsWithGroupLabel = [opensearchClusterGroupLabel, ...dataSourceOptions]; + optionsWithGroupLabel = [dataSourceOptionGroupLabel.opensearchCluster, ...dataSourceOptions]; } return optionsWithGroupLabel; }; @@ -242,6 +254,7 @@ export class DataSourceSelectable extends React.Component< return ( + + this.onChange(newOptions)} diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx index af5086f35a50..4f8df2542059 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx @@ -14,7 +14,6 @@ import { mockResponseForSavedObjectsCalls, } from '../../mocks'; import { AuthType } from 'src/plugins/data_source/common/data_sources'; -import * as utils from '../utils'; import { EuiComboBox } from '@elastic/eui'; describe('DataSourceSelector', () => { diff --git a/src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap b/src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap new file mode 100644 index 000000000000..213c946d09b4 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataSourceDropDownHeader should render correctly with the provided totalDataSourceCount 1`] = ` + + + + DATA SOURCES + ( + 5 + ) + +
+ + + Manage + + + + +`; + +exports[`DataSourceDropDownHeader should render the activeDataSourceCount/totalDataSourceCount when both provided 1`] = ` + + + +
+ +
+ DATA SOURCES + ( + 2/5 + ) +
+
+
+ +
+ + + +
+
+
+ + + +`; diff --git a/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss new file mode 100644 index 000000000000..244ca77b90e1 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss @@ -0,0 +1,3 @@ +.dataSourceDropDownHeaderInvisibleFocusable { + opacity: 0; +} diff --git a/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx new file mode 100644 index 000000000000..920ad09271e1 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { coreMock } from '../../../../../core/public/mocks'; +import { DSM_APP_ID } from '../../plugin'; +import { DataSourceDropDownHeader } from '.'; + +describe('DataSourceDropDownHeader', () => { + it('should render correctly with the provided totalDataSourceCount', () => { + const totalDataSourceCount = 5; + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render "DATA SOURCES" when totalDataSourceCount is greater than 1', () => { + const totalDataSourceCount = 5; + const wrapper = mount(); + expect(wrapper.text()).toContain('DATA SOURCES'); + }); + + it.each([1, 0])( + 'should render "DATA SOURCE" when totalDataSourceCount is %s', + (totalDataSourceCount) => { + const wrapper = mount( + + ); + expect(wrapper.text()).toContain('DATA SOURCE'); + } + ); + + it('should render the activeDataSourceCount/totalDataSourceCount when both provided', () => { + const totalDataSourceCount = 5; + const activeDataSourceCount = 2; + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.text()).toContain(`${activeDataSourceCount}/${totalDataSourceCount}`); + }); + + it('should call application.navigateToApp when the "Manage" link is clicked', () => { + const totalDataSourceCount = 5; + const applicationMock = coreMock.createStart().application; + const navigateToAppMock = applicationMock.navigateToApp; + + const wrapper = mount( + + ); + + wrapper.find('EuiLink').simulate('click'); + expect(navigateToAppMock).toHaveBeenCalledWith('management', { + path: `opensearch-dashboards/${DSM_APP_ID}`, + }); + }); +}); diff --git a/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx new file mode 100644 index 000000000000..2eb457c47511 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './drop_down_header.scss'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import React from 'react'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { DSM_APP_ID } from '../../plugin'; + +interface DataSourceOptionItemProps { + totalDataSourceCount: number; + activeDataSourceCount?: number; + application?: ApplicationStart; +} + +export const DataSourceDropDownHeader: React.FC = ({ + activeDataSourceCount, + totalDataSourceCount, + application, +}) => { + const dataSourceCounterPrefix = totalDataSourceCount === 1 ? 'DATA SOURCE' : 'DATA SOURCES'; + const dataSourceCounter = + activeDataSourceCount !== undefined + ? `${activeDataSourceCount}/${totalDataSourceCount}` + : totalDataSourceCount; + + return ( + + + + {dataSourceCounterPrefix} ({dataSourceCounter}) + +
+ + + application?.navigateToApp('management', { + path: `opensearch-dashboards/${DSM_APP_ID}`, + }) + } + > + Manage + + + + + ); +}; diff --git a/src/plugins/data_source_management/public/components/drop_down_header/index.ts b/src/plugins/data_source_management/public/components/drop_down_header/index.ts new file mode 100644 index 000000000000..3fc657904637 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { DataSourceDropDownHeader } from './drop_down_header'; diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index e433cdff52c5..98c23cc140c3 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -9,8 +9,10 @@ import { SavedObject, IUiSettingsClient, ToastsStart, + ApplicationStart, } from 'src/core/public'; import { i18n } from '@osd/i18n'; +import { deepFreeze } from '@osd/std'; import { DataSourceAttributes, DataSourceTableItem, @@ -19,6 +21,8 @@ import { } from '../types'; import { AuthenticationMethodRegistry } from '../auth_registry'; import { DataSourceOption } from './data_source_menu/types'; +import { DataSourceGroupLabelOption } from './data_source_menu/types'; +import { createGetterSetter } from '../../../opensearch_dashboards_utils/public'; export async function getDataSources(savedObjectsClient: SavedObjectsClientContract) { return savedObjectsClient @@ -279,3 +283,18 @@ export const extractRegisteredAuthTypeCredentials = ( return registeredCredentials; }; + +interface DataSourceOptionGroupLabel { + [key: string]: DataSourceGroupLabelOption; +} + +export const dataSourceOptionGroupLabel = deepFreeze>({ + opensearchCluster: { + id: 'opensearchClusterGroupLabel', + label: 'OpenSearch cluster', + isGroupLabel: true, + }, + // TODO: add other group labels if needed +}); + +export const [getApplication, setApplication] = createGetterSetter('Application'); diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index abcc532b8a7e..2461044a680b 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -21,6 +21,7 @@ import { noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod import { DataSourceSelectorProps } from './components/data_source_selector/data_source_selector'; import { createDataSourceMenu } from './components/data_source_menu/create_data_source_menu'; import { DataSourceMenuProps } from './components/data_source_menu'; +import { setApplication } from './components/utils'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; @@ -40,7 +41,7 @@ export interface DataSourceManagementPluginStart { getAuthenticationMethodRegistry: () => IAuthenticationMethodRegistry; } -const DSM_APP_ID = 'dataSources'; +export const DSM_APP_ID = 'dataSourceManagement'; export class DataSourceManagementPlugin implements @@ -111,6 +112,7 @@ export class DataSourceManagementPlugin public start(core: CoreStart) { this.started = true; + setApplication(core.application); return { getAuthenticationMethodRegistry: () => this.authMethodsRegistry, };