From 8c60b22180f021f5ca3931d51fb59c67bc2924e3 Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Fri, 22 Apr 2022 22:17:42 +0530 Subject: [PATCH] Work on manage tab to improve UI (#4366) --- .../components/ManageTab/DeleteWidgetBody.tsx | 52 ---- .../ManageTab/ManageTab.component.tsx | 263 +++--------------- .../ManageTab/ManageTab.interface.ts | 1 + .../components/ManageTab/ManageTab.test.tsx | 6 + .../EntityDeleteModal/EntityDeleteModal.tsx | 2 +- .../components/TeamDetails/TeamDetails.tsx | 8 +- .../common/DeleteWidget/DeleteWidget.tsx | 170 +++++++++++ .../common/DeleteWidget/DeleteWidgetBody.tsx | 66 +++++ .../common/OwnerWidget/OwnerWidget.tsx | 186 +++++++++++++ .../main/resources/ui/src/styles/tailwind.css | 7 + 10 files changed, 480 insertions(+), 281 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/ManageTab/DeleteWidgetBody.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidget.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidgetBody.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/OwnerWidget/OwnerWidget.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/DeleteWidgetBody.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/DeleteWidgetBody.tsx deleted file mode 100644 index 7ed578ef36f..00000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/DeleteWidgetBody.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import { TITLE_FOR_NON_ADMIN_ACTION } from '../../constants/constants'; -import { Button } from '../buttons/Button/Button'; -import NonAdminAction from '../common/non-admin-action/NonAdminAction'; - -type DeleteWidgetBodyProps = { - header: string; - description: string; - buttonText: string; - isOwner?: boolean; - hasPermission: boolean; - onClick: () => void; -}; - -const DeleteWidgetBody = ({ - header, - description, - buttonText, - isOwner, - hasPermission, - onClick, -}: DeleteWidgetBodyProps) => { - return ( -
-
-

- {header} -

-

{description}

-
- {TITLE_FOR_NON_ADMIN_ACTION}

} - isOwner={isOwner} - position="left"> - -
-
- ); -}; - -export default DeleteWidgetBody; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.component.tsx index 6ef5e66931b..d901290f7fa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.component.tsx @@ -11,36 +11,27 @@ * limitations under the License. */ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { AxiosError, AxiosResponse } from 'axios'; import classNames from 'classnames'; import { isUndefined } from 'lodash'; import { observer } from 'mobx-react'; import { TableDetail } from 'Models'; import React, { Fragment, FunctionComponent, useEffect, useState } from 'react'; -import { useHistory } from 'react-router-dom'; import appState from '../../AppState'; import { useAuthContext } from '../../authentication/auth-provider/AuthProvider'; -import { deleteEntity } from '../../axiosAPIs/miscAPI'; import { getCategory } from '../../axiosAPIs/tagAPI'; import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants'; -import { ENTITY_DELETE_STATE } from '../../constants/entity.constants'; -import { EntityType } from '../../enums/entity.enum'; import { Operation } from '../../generated/entity/policies/accessControl/rule'; import { useAuth } from '../../hooks/authHooks'; import jsonData from '../../jsons/en'; import { getOwnerList } from '../../utils/ManageUtils'; -import SVGIcons from '../../utils/SvgUtils'; -import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; -import { Button } from '../buttons/Button/Button'; +import { showErrorToast } from '../../utils/ToastUtils'; import CardListItem from '../card-list/CardListItem/CardWithListItems'; import { CardWithListItems } from '../card-list/CardListItem/CardWithListItems.interface'; +import DeleteWidget from '../common/DeleteWidget/DeleteWidget'; import NonAdminAction from '../common/non-admin-action/NonAdminAction'; -import ToggleSwitchV1 from '../common/toggle-switch/ToggleSwitchV1'; -import DropDownList from '../dropdown/DropDownList'; +import OwnerWidget from '../common/OwnerWidget/OwnerWidget'; import Loader from '../Loader/Loader'; -import EntityDeleteModal from '../Modals/EntityDeleteModal/EntityDeleteModal'; -import DeleteWidgetBody from './DeleteWidgetBody'; import { ManageProps, Status } from './ManageTab.interface'; const ManageTab: FunctionComponent = ({ @@ -58,9 +49,9 @@ const ManageTab: FunctionComponent = ({ allowSoftDelete, isRecursiveDelete, deletEntityMessage, + manageSectionType, handleIsJoinable, }: ManageProps) => { - const history = useHistory(); const { userPermissions, isAdminUser } = useAuth(); const { isAuthDisabled } = useAuthContext(); @@ -74,17 +65,11 @@ const ManageTab: FunctionComponent = ({ const [listOwners, setListOwners] = useState(getOwnerList()); const [owner, setOwner] = useState(currentUser); const [isLoadingTierData, setIsLoadingTierData] = useState(false); - const [entityDeleteState, setEntityDeleteState] = - useState(ENTITY_DELETE_STATE); const getOwnerById = (): string => { return listOwners.find((item) => item.value === owner)?.name || ''; }; - const getOwnerGroup = () => { - return allowTeamOwner ? ['Teams', 'Users'] : ['Users']; - }; - const setInitialOwnerLoadingState = () => { setStatusOwner('initial'); }; @@ -164,114 +149,19 @@ const ManageTab: FunctionComponent = ({ setActiveTier(cardId); }; - const handleOnEntityDelete = (softDelete = false) => { - setEntityDeleteState((prev) => ({ ...prev, state: true, softDelete })); - }; - - const handleOnEntityDeleteCancel = () => { - setEntityDeleteState(ENTITY_DELETE_STATE); - }; - - const prepareEntityType = () => { - const services = [ - EntityType.DASHBOARD_SERVICE, - EntityType.DATABASE_SERVICE, - EntityType.MESSAGING_SERVICE, - EntityType.PIPELINE_SERVICE, - ]; - - if (services.includes((entityType || '') as EntityType)) { - return `services/${entityType}s`; - } else { - return `${entityType}s`; - } - }; - - const prepareDeleteMessage = () => { - return `Once you delete this ${entityType}, it will be removed permanently`; - }; - - const handleOnEntityDeleteConfirm = () => { - setEntityDeleteState((prev) => ({ ...prev, loading: 'waiting' })); - deleteEntity( - prepareEntityType(), - entityId, - isRecursiveDelete, - allowSoftDelete - ) - .then((res: AxiosResponse) => { - if (res.status === 200) { - setTimeout(() => { - handleOnEntityDeleteCancel(); - showSuccessToast( - jsonData['api-success-messages']['delete-entity-success'] - ); - setTimeout(() => { - history.push('/'); - }, 500); - }, 1000); - } else { - showErrorToast( - jsonData['api-error-messages']['unexpected-server-response'] - ); - } - }) - .catch((error: AxiosError) => { - showErrorToast( - error, - jsonData['api-error-messages']['delete-entity-error'] - ); - }) - .finally(() => { - handleOnEntityDeleteCancel(); - }); - }; - - const getDeleteModal = () => { - if (allowDelete && entityDeleteState.state) { - return ( - - ); - } else { - return null; - } - }; - const getDeleteEntityWidget = () => { return allowDelete && entityId && entityName && entityType ? (
-
-
- {allowSoftDelete && ( -
- handleOnEntityDelete(true)} - /> -
- )} - - -
+
) : null; }; @@ -311,29 +201,13 @@ const ManageTab: FunctionComponent = ({ } }; - const getJoinableWidget = () => { - const isActionAllowed = + const isJoinableActionAllowed = () => { + return ( isAdminUser || isAuthDisabled || userPermissions[Operation.UpdateTeam] || - !hasEditAccess; - - const joinableSwitch = - isActionAllowed && !isUndefined(teamJoinable) ? ( -
- - { - handleIsJoinable?.(!teamJoinable); - }} - /> -
- ) : null; - - return !isUndefined(isJoinable) ? ( -
{joinableSwitch}
- ) : null; + !hasEditAccess + ); }; const ownerName = getOwnerById(); @@ -372,23 +246,6 @@ const ManageTab: FunctionComponent = ({ }); }; - const getOwnerUpdateLoader = () => { - return ( - - {statusOwner === 'waiting' ? ( - - ) : statusOwner === 'success' ? ( - - ) : null} - - ); - }; - useEffect(() => { if (!hideTier) { getTierData(); @@ -434,77 +291,33 @@ const ManageTab: FunctionComponent = ({ className="tw-max-w-3xl tw-mx-auto" data-testid="manage-tab" id="manageTabDetails"> +

+ Manage {manageSectionType ? manageSectionType : 'Section'} +

-
- Owner: - - -

You do not have permissions to update the owner.

- - } - isOwner={hasEditAccess} - permission={Operation.UpdateOwner} - position="left"> - -
- {listVisible && ( - - )} - {getOwnerUpdateLoader()} -
-
- {getJoinableWidget()} + + setListVisible((visible) => !visible) + } + hasEditAccess={hasEditAccess} + isAuthDisabled={isAuthDisabled} + isJoinableActionAllowed={isJoinableActionAllowed()} + listOwners={listOwners} + listVisible={listVisible} + owner={owner} + ownerName={ownerName} + statusOwner={statusOwner} + teamJoinable={teamJoinable} + />
{getTierCards()} {getDeleteEntityWidget()} - {getDeleteModal()} ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.interface.ts index 37163fcb02b..1e68158f051 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.interface.ts @@ -16,6 +16,7 @@ import { TableDetail } from 'Models'; export interface ManageProps { currentTier?: string; currentUser?: string; + manageSectionType?: string; hideTier?: boolean; isJoinable?: boolean; allowSoftDelete?: boolean; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.test.tsx index d7c36005f20..836b8222db2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ManageTab/ManageTab.test.tsx @@ -45,6 +45,10 @@ jest.mock('../common/toggle-switch/ToggleSwitchV1', () => { return jest.fn().mockImplementation(() =>

ToggleSwitchV1.Component

); }); +jest.mock('../common/DeleteWidget/DeleteWidget', () => { + return jest.fn().mockImplementation(() =>

DeleteWidget.Component

); +}); + const mockTierData = { children: [ { @@ -121,7 +125,9 @@ describe('Test Manage tab Component', () => { ); const dangerZone = await findByTestId(container, 'danger-zone'); + const DeleteWidget = await findByText(container, 'DeleteWidget.Component'); expect(dangerZone).toBeInTheDocument(); + expect(DeleteWidget).toBeInTheDocument(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityDeleteModal/EntityDeleteModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityDeleteModal/EntityDeleteModal.tsx index d2eadfa1037..93f7e4914a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityDeleteModal/EntityDeleteModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityDeleteModal/EntityDeleteModal.tsx @@ -85,7 +85,7 @@ const EntityDeleteModal: FC = ({ data-testid="confirmation-text-input" disabled={loadingState === 'waiting'} name="entityName" - placeholder={`${entityType}/${entityName}`} + placeholder="DELETE" type="text" value={name} onChange={handleOnChange} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TeamDetails/TeamDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/TeamDetails/TeamDetails.tsx index d25e2bd6afb..ecc9adac708 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/TeamDetails/TeamDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/TeamDetails/TeamDetails.tsx @@ -493,7 +493,7 @@ const TeamDetails = ({ }; return ( -
+
{teams.length && currentTeam ? (
setCurrentTab(tab)} tabs={tabs} /> -
+
{currentTab === 1 && getUserCards()} {currentTab === 2 && getDatasetCards()} @@ -580,7 +581,7 @@ const TeamDetails = ({ {currentTab === 3 && getDefaultRoles()} {currentTab === 4 && ( -
+
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidget.tsx new file mode 100644 index 00000000000..869320464d5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidget.tsx @@ -0,0 +1,170 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AxiosError, AxiosResponse } from 'axios'; +import React, { Fragment, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { deleteEntity } from '../../../axiosAPIs/miscAPI'; +import { ENTITY_DELETE_STATE } from '../../../constants/entity.constants'; +import { EntityType } from '../../../enums/entity.enum'; +import jsonData from '../../../jsons/en'; +import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; +import EntityDeleteModal from '../../Modals/EntityDeleteModal/EntityDeleteModal'; +import DeleteWidgetBody from './DeleteWidgetBody'; + +interface DeleteSectionProps { + allowSoftDelete?: boolean; + entityName: string; + entityType: string; + deletEntityMessage?: string; + hasPermission: boolean; + isAdminUser?: boolean; + entityId: string; + isRecursiveDelete?: boolean; +} + +const DeleteWidget = ({ + allowSoftDelete, + entityName, + entityType, + hasPermission, + isAdminUser, + deletEntityMessage, + entityId, + isRecursiveDelete, +}: DeleteSectionProps) => { + const history = useHistory(); + const [entityDeleteState, setEntityDeleteState] = + useState(ENTITY_DELETE_STATE); + + const prepareDeleteMessage = (softDelete = false) => { + const softDeleteText = `Soft deleting will deactivate the ${entityName}. This will disable any discovery, read or write operations on ${entityName}`; + const hardDeleteText = `Once you delete this ${entityType}, it will be removed permanently`; + + return softDelete ? softDeleteText : hardDeleteText; + }; + + const handleOnEntityDelete = (softDelete = false) => { + setEntityDeleteState((prev) => ({ ...prev, state: true, softDelete })); + }; + + const handleOnEntityDeleteCancel = () => { + setEntityDeleteState(ENTITY_DELETE_STATE); + }; + + const prepareEntityType = () => { + const services = [ + EntityType.DASHBOARD_SERVICE, + EntityType.DATABASE_SERVICE, + EntityType.MESSAGING_SERVICE, + EntityType.PIPELINE_SERVICE, + ]; + + if (services.includes((entityType || '') as EntityType)) { + return `services/${entityType}s`; + } else { + return `${entityType}s`; + } + }; + + const handleOnEntityDeleteConfirm = () => { + setEntityDeleteState((prev) => ({ ...prev, loading: 'waiting' })); + deleteEntity( + prepareEntityType(), + entityId, + isRecursiveDelete, + entityDeleteState.softDelete + ) + .then((res: AxiosResponse) => { + if (res.status === 200) { + setTimeout(() => { + handleOnEntityDeleteCancel(); + showSuccessToast( + jsonData['api-success-messages']['delete-entity-success'] + ); + setTimeout(() => { + history.push('/'); + }, 500); + }, 1000); + } else { + showErrorToast( + jsonData['api-error-messages']['unexpected-server-response'] + ); + } + }) + .catch((error: AxiosError) => { + showErrorToast( + error, + jsonData['api-error-messages']['delete-entity-error'] + ); + }) + .finally(() => { + handleOnEntityDeleteCancel(); + }); + }; + + const getDeleteModal = () => { + if (entityDeleteState.state) { + return ( + + ); + } else { + return null; + } + }; + + return ( + +

Delete section

+
+
+ {allowSoftDelete && ( +
+ handleOnEntityDelete(true)} + /> +
+ )} + + handleOnEntityDelete(false)} + /> +
+
+ {getDeleteModal()} +
+ ); +}; + +export default DeleteWidget; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidgetBody.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidgetBody.tsx new file mode 100644 index 00000000000..cfc5f4d44b1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidgetBody.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { TITLE_FOR_NON_ADMIN_ACTION } from '../../../constants/constants'; +import NonAdminAction from '../non-admin-action/NonAdminAction'; + +type DeleteWidgetBodyProps = { + header: string; + description: string; + buttonText: string; + isOwner?: boolean; + hasPermission: boolean; + onClick: () => void; +}; + +const DeleteWidgetBody = ({ + header, + description, + buttonText, + isOwner, + hasPermission, + onClick, +}: DeleteWidgetBodyProps) => { + return ( +
+
+

+ {header} +

+

+ {description} +

+
+ {TITLE_FOR_NON_ADMIN_ACTION}

} + isOwner={isOwner} + position="left"> + +
+
+ ); +}; + +export default DeleteWidgetBody; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/OwnerWidget/OwnerWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/OwnerWidget/OwnerWidget.tsx new file mode 100644 index 00000000000..f6ba3639d40 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/OwnerWidget/OwnerWidget.tsx @@ -0,0 +1,186 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import classNames from 'classnames'; +import { isUndefined } from 'lodash'; +import React, { Fragment } from 'react'; +import { Operation } from '../../../generated/entity/policies/policy'; +import { useAuth } from '../../../hooks/authHooks'; +import { Button } from '../../buttons/Button/Button'; +import DropDownList from '../../dropdown/DropDownList'; +import Loader from '../../Loader/Loader'; +import { Status } from '../../ManageTab/ManageTab.interface'; +import NonAdminAction from '../non-admin-action/NonAdminAction'; +import ToggleSwitchV1 from '../toggle-switch/ToggleSwitchV1'; + +interface OwnerWidgetProps { + isJoinableActionAllowed: boolean; + hasEditAccess: boolean; + isAuthDisabled: boolean; + listVisible: boolean; + teamJoinable?: boolean; + allowTeamOwner?: boolean; + ownerName: string; + statusOwner: Status; + owner: string; + listOwners: { + name: string; + value: string; + group: string; + type: string; + }[]; + handleIsJoinable?: (bool: boolean) => void; + handleSelectOwnerDropdown: () => void; + handleOwnerSelection: ( + _e: React.MouseEvent, + value?: string | undefined + ) => void; +} + +const OwnerWidget = ({ + isJoinableActionAllowed, + teamJoinable, + isAuthDisabled, + hasEditAccess, + ownerName, + listVisible, + owner, + allowTeamOwner, + statusOwner, + listOwners, + handleIsJoinable, + handleSelectOwnerDropdown, + handleOwnerSelection, +}: OwnerWidgetProps) => { + const { userPermissions } = useAuth(); + + const getOwnerGroup = () => { + return allowTeamOwner ? ['Teams', 'Users'] : ['Users']; + }; + + const getOwnerUpdateLoader = () => { + switch (statusOwner) { + case 'waiting': + return ( + + ); + + case 'success': + return ; + + default: + return <>; + } + }; + + return ( + +
+
+
+
+

Owner

+

+ Lorem ipsum dolor, sit amet consectetur adipisicing elit. + Necessitatibus, sint. +

+
+ + + +

You do not have permissions to update the owner.

+ + } + isOwner={hasEditAccess} + permission={Operation.UpdateOwner} + position="left"> + +
+ {listVisible && ( + + )} +
+
+ {isJoinableActionAllowed && !isUndefined(teamJoinable) && ( +
+
+

+ Open to join +

+

+ Lorem ipsum dolor, sit amet consectetur adipisicing elit. + Necessitatibus, sint. +

+
+
+ { + handleIsJoinable?.(!teamJoinable); + }} + /> +
+
+ )} +
+
+
+ ); +}; + +export default OwnerWidget; diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/tailwind.css b/openmetadata-ui/src/main/resources/ui/src/styles/tailwind.css index d2368611302..f339ce2ac80 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/tailwind.css +++ b/openmetadata-ui/src/main/resources/ui/src/styles/tailwind.css @@ -457,4 +457,11 @@ .Toastify__toast-body { @apply tw-items-start; } + + /* delete button style */ + + .tw-delete-outline-button { + @apply tw-border-gray-300 tw-border tw-text-error focus:tw-outline-none focus:tw-bg-transparent hover:tw-border-error focus:tw-border-error + hover:tw-text-error focus:tw-text-error focus:tw-ring focus:tw-ring-error; + } }