diff --git a/UPDATING.md b/UPDATING.md index ee489ec5cd33e..22e879b1e7e0c 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -36,7 +36,6 @@ assists people when migrating to a new version. - [22328](https://github.com/apache/superset/pull/22328): For deployments that have enabled the "THUMBNAILS" feature flag, the function that calculates dashboard digests has been updated to consider additional properties to more accurately identify changes in the dashboard metadata. This change will invalidate all currently cached dashboard thumbnails. - [21765](https://github.com/apache/superset/pull/21765): For deployments that have enabled the "ALERT_REPORTS" feature flag, Gamma users will no longer have read and write access to Alerts & Reports by default. To give Gamma users the ability to schedule reports from the Dashboard and Explore view like before, create an additional role with "can read on ReportSchedule" and "can write on ReportSchedule" permissions. To further give Gamma users access to the "Alerts & Reports" menu and CRUD view, add "menu access on Manage" and "menu access on Alerts & Report" permissions to the role. -- [22325](https://github.com/apache/superset/pull/22325): "RLS_FORM_QUERY_REL_FIELDS" is replaced by "RLS_BASE_RELATED_FIELD_FILTERS" feature flag.Its value format stays same. ### Potential Downtime diff --git a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.test.tsx b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.test.tsx deleted file mode 100644 index 8a948bd99764c..0000000000000 --- a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.test.tsx +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 fetchMock from 'fetch-mock'; -import RowLevelSecurityList from 'src/views/CRUD/rowlevelsecurity/RowLevelSecurityList'; -import { render, screen, within } from 'spec/helpers/testing-library'; -import { act } from 'react-dom/test-utils'; -import { MemoryRouter } from 'react-router-dom'; -import { QueryParamProvider } from 'use-query-params'; -import { styledMount as mount } from 'spec/helpers/theming'; -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; -import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; -import ListView from 'src/components/ListView/ListView'; -import userEvent from '@testing-library/user-event'; - -const ruleListEndpoint = 'glob:*/api/v1/rowlevelsecurity/?*'; -const ruleInfoEndpoint = 'glob:*/api/v1/rowlevelsecurity/_info*'; - -const mockRules = [ - { - changed_on_delta_humanized: '1 days ago', - clause: '1=1', - description: 'some description', - filter_type: 'Regular', - group_key: 'group-1', - id: 1, - name: 'rule 1', - roles: [ - { - id: 3, - name: 'Alpha', - }, - { - id: 5, - name: 'granter', - }, - ], - tables: [ - { - id: 6, - table_name: 'flights', - }, - { - id: 13, - table_name: 'messages', - }, - ], - }, - { - changed_on_delta_humanized: '2 days ago', - clause: '2=2', - description: 'some description 2', - filter_type: 'Base', - group_key: 'group-1', - id: 2, - name: 'rule 2', - roles: [ - { - id: 3, - name: 'Alpha', - }, - { - id: 5, - name: 'granter', - }, - ], - tables: [ - { - id: 6, - table_name: 'flights', - }, - { - id: 13, - table_name: 'messages', - }, - ], - }, -]; -fetchMock.get(ruleListEndpoint, { result: mockRules, count: 2 }); -fetchMock.get(ruleInfoEndpoint, { permissions: ['can_read', 'can_write'] }); -global.URL.createObjectURL = jest.fn(); - -const mockUser = { - userId: 1, -}; - -const mockedProps = {}; - -const mockStore = configureStore([thunk]); -const store = mockStore({}); - -describe('RulesList Enzyme', () => { - let wrapper: any; - - beforeAll(async () => { - fetchMock.resetHistory(); - wrapper = mount( - - - - - , - ); - - await waitForComponentToPaint(wrapper); - }); - - it('renders', () => { - expect(wrapper.find(RowLevelSecurityList)).toExist(); - }); - it('renders a ListView', () => { - expect(wrapper.find(ListView)).toExist(); - }); - it('fetched data', () => { - // wrapper.update(); - const apiCalls = fetchMock.calls(/rowlevelsecurity\/\?q/); - expect(apiCalls).toHaveLength(1); - expect(apiCalls[0][0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/rowlevelsecurity/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, - ); - }); -}); - -describe('RuleList RTL', () => { - async function renderAndWait() { - const mounted = act(async () => { - const mockedProps = {}; - render( - - - - - , - { useRedux: true }, - ); - }); - return mounted; - } - - it('renders add rule button on empty state', async () => { - fetchMock.get( - ruleListEndpoint, - { result: [], count: 0 }, - { overwriteRoutes: true }, - ); - await renderAndWait(); - - const emptyAddRuleButton = await screen.findByTestId('add-rule-empty'); - expect(emptyAddRuleButton).toBeInTheDocument(); - fetchMock.get( - ruleListEndpoint, - { result: mockRules, count: 2 }, - { overwriteRoutes: true }, - ); - }); - - it('renders a "Rule" button to add a rule in bulk action', async () => { - await renderAndWait(); - - const addRuleButton = await screen.findByTestId('add-rule'); - const emptyAddRuleButton = screen.queryByTestId('add-rule-empty'); - expect(addRuleButton).toBeInTheDocument(); - expect(emptyAddRuleButton).not.toBeInTheDocument(); - }); - - it('renders filter options', async () => { - await renderAndWait(); - - const searchFilters = screen.queryAllByTestId('filters-search'); - expect(searchFilters).toHaveLength(2); - - const typeFilter = await screen.findByTestId('filters-select'); - expect(typeFilter).toBeInTheDocument(); - }); - - it('renders correct list columns', async () => { - await renderAndWait(); - - const table = screen.getByRole('table'); - expect(table).toBeInTheDocument(); - - const nameColumn = await within(table).findByText('Name'); - const fitlerTypeColumn = await within(table).findByText('Filter Type'); - const groupKeyColumn = await within(table).findByText('Group Key'); - const clauseColumn = await within(table).findByText('Clause'); - const modifiedColumn = await within(table).findByText('Modified'); - const actionsColumn = await within(table).findByText('Actions'); - - expect(nameColumn).toBeInTheDocument(); - expect(fitlerTypeColumn).toBeInTheDocument(); - expect(groupKeyColumn).toBeInTheDocument(); - expect(clauseColumn).toBeInTheDocument(); - expect(modifiedColumn).toBeInTheDocument(); - expect(actionsColumn).toBeInTheDocument(); - }); - - it('renders correct action buttons with write permission', async () => { - await renderAndWait(); - - const deleteActionIcon = screen.queryAllByTestId('rls-list-trash-icon'); - expect(deleteActionIcon).toHaveLength(2); - - const editActionIcon = screen.queryAllByTestId('edit-alt'); - expect(editActionIcon).toHaveLength(2); - }); - - it('should not renders correct action buttons without write permission', async () => { - fetchMock.get( - ruleInfoEndpoint, - { permissions: ['can_read'] }, - { overwriteRoutes: true }, - ); - - await renderAndWait(); - - const deleteActionIcon = screen.queryByTestId('rls-list-trash-icon'); - expect(deleteActionIcon).not.toBeInTheDocument(); - - const editActionIcon = screen.queryByTestId('edit-alt'); - expect(editActionIcon).not.toBeInTheDocument(); - - fetchMock.get( - ruleInfoEndpoint, - { permissions: ['can_read', 'can_write'] }, - { overwriteRoutes: true }, - ); - }); - - it('renders popover on new clicking rule button', async () => { - await renderAndWait(); - - const modal = screen.queryByTestId('rls-modal-title'); - expect(modal).not.toBeInTheDocument(); - - const addRuleButton = await screen.findByTestId('add-rule'); - userEvent.click(addRuleButton); - - const modalAfterClick = screen.queryByTestId('rls-modal-title'); - expect(modalAfterClick).toBeInTheDocument(); - }); -}); diff --git a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.tsx b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.tsx deleted file mode 100644 index a7dfa2058c1f1..0000000000000 --- a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.tsx +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 { t, styled, SupersetClient } from '@superset-ui/core'; -import React, { useMemo, useState } from 'react'; -import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; -import Icons from 'src/components/Icons'; -import ListView, { - FetchDataConfig, - FilterOperator, - ListViewProps, - Filters, -} from 'src/components/ListView'; -import withToasts from 'src/components/MessageToasts/withToasts'; -import { Tooltip } from 'src/components/Tooltip'; -import SubMenu, { SubMenuProps } from 'src/views/components/SubMenu'; -import rison from 'rison'; -import { useListViewResource } from '../hooks'; -import RowLevelSecurityModal from './RowLevelSecurityModal'; -import { RLSObject } from './types'; -import { createErrorHandler } from '../utils'; - -const Actions = styled.div` - color: ${({ theme }) => theme.colors.grayscale.base}; -`; - -interface RLSProps { - addDangerToast: (msg: string) => void; - addSuccessToast: (msg: string) => void; - user: { - userId?: string | number; - firstName: string; - lastName: string; - }; -} - -function RowLevelSecurityList(props: RLSProps) { - const { addDangerToast, addSuccessToast, user } = props; - const [ruleModalOpen, setRuleModalOpen] = useState(false); - const [currentRule, setCurrentRule] = useState(null); - - const { - state: { - loading, - resourceCount: rulesCount, - resourceCollection: rules, - bulkSelectEnabled, - }, - hasPerm, - fetchData, - refreshData, - toggleBulkSelect, - } = useListViewResource( - 'rowlevelsecurity', - t('Row Level Security'), - addDangerToast, - true, - undefined, - undefined, - true, - ); - - function handleRuleEdit(rule: null) { - setCurrentRule(rule); - setRuleModalOpen(true); - } - - function handleRuleDelete( - { id, name }: RLSObject, - refreshData: (arg0?: FetchDataConfig | null) => void, - addSuccessToast: (arg0: string) => void, - addDangerToast: (arg0: string) => void, - ) { - return SupersetClient.delete({ - endpoint: `/api/v1/rowlevelsecurity/${id}`, - }).then( - () => { - refreshData(); - addSuccessToast(t('Deleted %s', name)); - }, - createErrorHandler(errMsg => - addDangerToast(t('There was an issue deleting %s: %s', name, errMsg)), - ), - ); - } - function handleBulkRulesDelete(rulesToDelete: RLSObject[]) { - const ids = rulesToDelete.map(({ id }) => id); - return SupersetClient.delete({ - endpoint: `/api/v1/rowlevelsecurity/?q=${rison.encode(ids)}`, - }).then( - () => { - refreshData(); - addSuccessToast(t(`Deleted`)); - }, - createErrorHandler(errMsg => - addDangerToast(t('There was an issue deleting rules: %s', errMsg)), - ), - ); - } - - function handleRuleModalHide() { - setCurrentRule(null); - setRuleModalOpen(false); - refreshData(); - } - - const canWrite = hasPerm('can_write'); - const canEdit = hasPerm('can_write'); - const canExport = hasPerm('can_export'); - - const columns = useMemo( - () => [ - { - accessor: 'name', - Header: t('Name'), - }, - { - accessor: 'filter_type', - Header: t('Filter Type'), - size: 'xl', - }, - { - accessor: 'group_key', - Header: t('Group Key'), - size: 'xl', - }, - { - accessor: 'clause', - Header: t('Clause'), - }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => {changedOn}, - Header: t('Modified'), - accessor: 'changed_on_delta_humanized', - size: 'xl', - }, - { - Cell: ({ row: { original } }: any) => { - const handleDelete = () => - handleRuleDelete( - original, - refreshData, - addSuccessToast, - addDangerToast, - ); - const handleEdit = () => handleRuleEdit(original); - return ( - - {canWrite && ( - - {t('Are you sure you want to delete')}{' '} - {original.name} - - } - onConfirm={handleDelete} - > - {confirmDelete => ( - - - - - - )} - - )} - {canEdit && ( - - - - - - )} - - ); - }, - Header: t('Actions'), - id: 'actions', - hidden: !canEdit && !canWrite && !canExport, - disableSortBy: true, - }, - ], - [ - user.userId, - canEdit, - canWrite, - canExport, - hasPerm, - refreshData, - addDangerToast, - addSuccessToast, - ], - ); - - const emptyState = { - title: t('No Rules yet'), - image: 'filter-results.svg', - buttonAction: () => handleRuleEdit(null), - buttonText: canEdit ? ( - <> - {'Rule'}{' '} - - ) : null, - }; - - const filters: Filters = useMemo( - () => [ - { - Header: t('Name'), - key: 'search', - id: 'name', - input: 'search', - operator: FilterOperator.startsWith, - }, - { - Header: t('Filter Type'), - key: 'filter_type', - id: 'filter_type', - input: 'select', - operator: FilterOperator.equals, - unfilteredLabel: t('Any'), - selects: [ - { label: t('Regular'), value: 'Regular' }, - { label: t('Base'), value: 'Base' }, - ], - }, - { - Header: t('Group Key'), - key: 'search', - id: 'group_key', - input: 'search', - operator: FilterOperator.startsWith, - }, - ], - [user], - ); - - const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; - const PAGE_SIZE = 25; - - const subMenuButtons: SubMenuProps['buttons'] = []; - - if (canWrite) { - subMenuButtons.push({ - name: ( - <> - {t('Rule')} - - ), - buttonStyle: 'primary', - onClick: () => handleRuleEdit(null), - }); - subMenuButtons.push({ - name: t('Bulk select'), - buttonStyle: 'secondary', - 'data-test': 'bulk-select', - onClick: toggleBulkSelect, - }); - } - - return ( - <> - - - {confirmDelete => { - const bulkActions: ListViewProps['bulkActions'] = []; - if (canWrite) { - bulkActions.push({ - key: 'delete', - name: t('Delete'), - type: 'danger', - onSelect: confirmDelete, - }); - } - return ( - <> - - - className="rls-list-view" - bulkActions={bulkActions} - bulkSelectEnabled={bulkSelectEnabled} - disableBulkSelect={toggleBulkSelect} - columns={columns} - count={rulesCount} - data={rules} - emptyState={emptyState} - fetchData={fetchData} - filters={filters} - initialSort={initialSort} - loading={loading} - pageSize={PAGE_SIZE} - /> - - ); - }} - - - ); -} - -export default withToasts(RowLevelSecurityList); diff --git a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.test.tsx b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.test.tsx deleted file mode 100644 index 6253c42c82750..0000000000000 --- a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.test.tsx +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 fetchMock from 'fetch-mock'; -import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; -import { act } from 'react-dom/test-utils'; -import userEvent from '@testing-library/user-event'; -import RowLevelSecurityModal, { - RowLevelSecurityModalProps, -} from './RowLevelSecurityModal'; -import { FilterType } from './types'; - -const getRuleEndpoint = 'glob:*/api/v1/rowlevelsecurity/1'; -const getRelatedRolesEndpoint = - 'glob:*/api/v1/rowlevelsecurity/related/roles?q*'; -const getRelatedTablesEndpoint = - 'glob:*/api/v1/rowlevelsecurity/related/tables?q*'; -const postRuleEndpoint = 'glob:*/api/v1/rowlevelsecurity/*'; -const putRuleEndpoint = 'glob:*/api/v1/rowlevelsecurity/1'; - -const mockGetRuleResult = { - description_columns: {}, - id: 1, - label_columns: { - clause: 'Clause', - description: 'Description', - filter_type: 'Filter Type', - group_key: 'Group Key', - name: 'Name', - 'roles.id': 'Roles Id', - 'roles.name': 'Roles Name', - 'tables.id': 'Tables Id', - 'tables.table_name': 'Tables Table Name', - }, - result: { - clause: 'gender="girl"', - description: 'test rls rule with RTL', - filter_type: 'Base', - group_key: 'g1', - id: 1, - name: 'rls 1', - roles: [ - { - id: 1, - name: 'Admin', - }, - ], - tables: [ - { - id: 2, - table_name: 'birth_names', - }, - ], - }, - show_columns: [ - 'name', - 'description', - 'filter_type', - 'tables.id', - 'tables.table_name', - 'roles.id', - 'roles.name', - 'group_key', - 'clause', - ], - show_title: 'Show Row Level Security Filter', -}; - -const mockGetRolesResult = { - count: 3, - result: [ - { - extra: {}, - text: 'Admin', - value: 1, - }, - { - extra: {}, - text: 'Public', - value: 2, - }, - { - extra: {}, - text: 'Alpha', - value: 3, - }, - ], -}; - -const mockGetTablesResult = { - count: 3, - result: [ - { - extra: {}, - text: 'wb_health_population', - value: 1, - }, - { - extra: {}, - text: 'birth_names', - value: 2, - }, - { - extra: {}, - text: 'long_lat', - value: 3, - }, - ], -}; - -fetchMock.get(getRuleEndpoint, mockGetRuleResult); -fetchMock.get(getRelatedRolesEndpoint, mockGetRolesResult); -fetchMock.get(getRelatedTablesEndpoint, mockGetTablesResult); -fetchMock.post(postRuleEndpoint, {}); -fetchMock.put(putRuleEndpoint, {}); - -global.URL.createObjectURL = jest.fn(); - -const NOOP = () => {}; - -const addNewRuleDefaultProps: RowLevelSecurityModalProps = { - addDangerToast: NOOP, - addSuccessToast: NOOP, - show: true, - rule: null, - onHide: NOOP, -}; - -describe('Rule modal', () => { - async function renderAndWait(props: RowLevelSecurityModalProps) { - const mounted = act(async () => { - render(, { useRedux: true }); - }); - return mounted; - } - - it('Sets correct title for adding new rule', async () => { - await renderAndWait(addNewRuleDefaultProps); - const title = screen.getByText('Add Rule'); - expect(title).toBeInTheDocument(); - expect(fetchMock.calls(getRuleEndpoint)).toHaveLength(0); - expect(fetchMock.calls(getRelatedTablesEndpoint)).toHaveLength(0); - expect(fetchMock.calls(getRelatedRolesEndpoint)).toHaveLength(0); - }); - - it('Sets correct title for editing existing rule', async () => { - await renderAndWait({ - ...addNewRuleDefaultProps, - rule: { - id: 1, - name: 'test rule', - filter_type: FilterType.BASE, - tables: [{ key: 1, id: 1, value: 'birth_names' }], - roles: [], - }, - }); - const title = screen.getByText('Edit Rule'); - expect(title).toBeInTheDocument(); - expect(fetchMock.calls(getRuleEndpoint)).toHaveLength(1); - expect(fetchMock.calls(getRelatedTablesEndpoint)).toHaveLength(0); - expect(fetchMock.calls(getRelatedRolesEndpoint)).toHaveLength(0); - }); - - it('Fills correct values when editing rule', async () => { - await renderAndWait({ - ...addNewRuleDefaultProps, - rule: { - id: 1, - name: 'rls 1', - filter_type: FilterType.BASE, - }, - }); - - const name = await screen.findByTestId('rule-name-test'); - expect(name).toHaveDisplayValue('rls 1'); - userEvent.type(name, 'rls 2'); - expect(name).toHaveDisplayValue('rls 2'); - - const filterType = await screen.findByText('Base'); - expect(filterType).toBeInTheDocument(); - - const roles = await screen.findByText('Admin'); - expect(roles).toBeInTheDocument(); - - const tables = await screen.findByText('birth_names'); - expect(tables).toBeInTheDocument(); - - const groupKey = await screen.findByTestId('group-key-test'); - expect(groupKey).toHaveValue('g1'); - userEvent.clear(groupKey); - userEvent.type(groupKey, 'g2'); - expect(groupKey).toHaveValue('g2'); - - const clause = await screen.findByTestId('clause-test'); - expect(clause).toHaveValue('gender="girl"'); - userEvent.clear(clause); - userEvent.type(clause, 'gender="boy"'); - expect(clause).toHaveValue('gender="boy"'); - - const description = await screen.findByTestId('description-test'); - expect(description).toHaveValue('test rls rule with RTL'); - userEvent.clear(description); - userEvent.type(description, 'test description'); - expect(description).toHaveValue('test description'); - }); - - it('Does not allow to create rule without name, tables and clause', async () => { - await renderAndWait(addNewRuleDefaultProps); - - const addButton = screen.getByRole('button', { name: /add/i }); - expect(addButton).toBeDisabled(); - - const nameTextBox = screen.getByTestId('rule-name-test'); - userEvent.type(nameTextBox, 'name'); - - expect(addButton).toBeDisabled(); - - const getSelect = () => screen.getByRole('combobox', { name: 'Tables' }); - const getElementByClassName = (className: string) => - document.querySelector(className)! as HTMLElement; - - const findSelectOption = (text: string) => - waitFor(() => - within(getElementByClassName('.rc-virtual-list')).getByText(text), - ); - const open = () => waitFor(() => userEvent.click(getSelect())); - await open(); - userEvent.click(await findSelectOption('birth_names')); - expect(addButton).toBeDisabled(); - - const clause = await screen.findByTestId('clause-test'); - userEvent.type(clause, 'gender="girl"'); - - expect(addButton).toBeEnabled(); - }); - - it('Creates a new rule', async () => { - await renderAndWait(addNewRuleDefaultProps); - - const addButton = screen.getByRole('button', { name: /add/i }); - - const nameTextBox = screen.getByTestId('rule-name-test'); - userEvent.type(nameTextBox, 'name'); - - const getSelect = () => screen.getByRole('combobox', { name: 'Tables' }); - const getElementByClassName = (className: string) => - document.querySelector(className)! as HTMLElement; - - const findSelectOption = (text: string) => - waitFor(() => - within(getElementByClassName('.rc-virtual-list')).getByText(text), - ); - const open = () => waitFor(() => userEvent.click(getSelect())); - await open(); - userEvent.click(await findSelectOption('birth_names')); - - const clause = await screen.findByTestId('clause-test'); - userEvent.type(clause, 'gender="girl"'); - - await waitFor(() => userEvent.click(addButton)); - - expect(fetchMock.calls(postRuleEndpoint)).toHaveLength(1); - }); - - it('Updates existing rule', async () => { - await renderAndWait({ - ...addNewRuleDefaultProps, - rule: { - id: 1, - name: 'rls 1', - filter_type: FilterType.BASE, - }, - }); - - const addButton = screen.getByRole('button', { name: /save/i }); - await waitFor(() => userEvent.click(addButton)); - expect(fetchMock.calls(putRuleEndpoint)).toHaveLength(4); - }); -}); diff --git a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.tsx b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.tsx deleted file mode 100644 index 1498527c1f94a..0000000000000 --- a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.tsx +++ /dev/null @@ -1,480 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 { - css, - styled, - SupersetClient, - SupersetTheme, - t, -} from '@superset-ui/core'; -import Modal from 'src/components/Modal'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import Icons from 'src/components/Icons'; -import Select from 'src/components/Select/Select'; -import AsyncSelect from 'src/components/Select/AsyncSelect'; -import rison from 'rison'; -import { LabeledErrorBoundInput } from 'src/components/Form'; -import { noBottomMargin } from 'src/components/ReportModal/styles'; -import InfoTooltip from 'src/components/InfoTooltip'; -import { useSingleViewResource } from '../hooks'; -import { FilterOptions } from './constants'; -import { FilterType, RLSObject, RoleObject, TableObject } from './types'; - -const StyledModal = styled(Modal)` - max-width: 1200px; - width: 100%; - .ant-modal-body { - overflow: initial; - } -`; -const StyledIcon = (theme: SupersetTheme) => css` - margin: auto ${theme.gridUnit * 2}px auto 0; - color: ${theme.colors.grayscale.base}; -`; - -const StyledSectionContainer = styled.div` - display: flex; - flex-direction: column; - padding: ${({ theme }) => - `${theme.gridUnit * 3}px ${theme.gridUnit * 4}px ${theme.gridUnit * 2}px`}; - - label { - font-size: ${({ theme }) => theme.typography.sizes.s}px; - color: ${({ theme }) => theme.colors.grayscale.light1}; - } -} -`; - -const StyledInputContainer = styled.div` - display: flex; - flex-direction: column; - margin: ${({ theme }) => theme.gridUnit}px; - - margin-bottom: ${({ theme }) => theme.gridUnit * 4}px; - - .input-container { - display: flex; - align-items: center; - - > div { - width: 100%; - } - - label { - display: flex; - margin-right: ${({ theme }) => theme.gridUnit * 2}px; - } - } - - input, - textarea { - flex: 1 1 auto; - } - - textarea { - height: 100px; - resize: none; - } - - .required { - margin-left: ${({ theme }) => theme.gridUnit / 2}px; - color: ${({ theme }) => theme.colors.error.base}; - } -`; - -export interface RowLevelSecurityModalProps { - rule: RLSObject | null; - addSuccessToast: (msg: string) => void; - addDangerToast: (msg: string) => void; - onAdd?: (alert?: any) => void; - onHide: () => void; - show: boolean; -} - -const DEAFULT_RULE = { - name: '', - filter_type: FilterType.REGULAR, - tables: [], - roles: [], - clause: '', - group_key: '', - description: '', -}; - -function RowLevelSecurityModal(props: RowLevelSecurityModalProps) { - const { rule, addDangerToast, addSuccessToast, onHide, show } = props; - - const [currentRule, setCurrentRule] = useState({ - ...DEAFULT_RULE, - }); - const [disableSave, setDisableSave] = useState(true); - - const isEditMode = rule !== null; - - // * hooks * - const { - state: { loading, resource, error: fetchError }, - fetchResource, - createResource, - updateResource, - clearError, - } = useSingleViewResource( - `rowlevelsecurity`, - t('rowlevelsecurity'), - addDangerToast, - ); - - // initialize - useEffect(() => { - if (!isEditMode) { - setCurrentRule({ ...DEAFULT_RULE }); - } else if (rule?.id !== null && !loading && !fetchError) { - fetchResource(rule.id as number); - } - }, [rule]); - - useEffect(() => { - if (resource) { - setCurrentRule({ ...resource, id: rule?.id }); - const selectedTableAndRoles = getSelectedData(); - updateRuleState('tables', selectedTableAndRoles?.tables || []); - updateRuleState('roles', selectedTableAndRoles?.roles || []); - } - }, [resource]); - - // find selected tables and roles - const getSelectedData = useCallback(() => { - if (!resource) { - return null; - } - const tables: TableObject[] = []; - const roles: RoleObject[] = []; - - resource.tables?.forEach(selectedTable => { - tables.push({ - key: selectedTable.id, - label: selectedTable.schema - ? `${selectedTable.schema}.${selectedTable.table_name}` - : selectedTable.table_name, - value: selectedTable.id, - }); - }); - - resource.roles?.forEach(selectedRole => { - roles.push({ - key: selectedRole.id, - label: selectedRole.name, - value: selectedRole.id, - }); - }); - - return { tables, roles }; - }, [resource?.tables, resource?.roles]); - - // validate - const currentRuleSafe = currentRule || {}; - useEffect(() => { - validate(); - }, [currentRuleSafe.name, currentRuleSafe.clause, currentRuleSafe?.tables]); - - // * event handlers * - type SelectValue = { - value: string; - label: string; - }; - - const updateRuleState = (name: string, value: any) => { - setCurrentRule(currentRuleData => ({ - ...currentRuleData, - [name]: value, - })); - }; - - const onTextChange = (target: HTMLInputElement | HTMLTextAreaElement) => { - updateRuleState(target.name, target.value); - }; - - const onFilterChange = (type: string) => { - updateRuleState('filter_type', type); - }; - - const onTablesChange = (tables: Array) => { - updateRuleState('tables', tables || []); - }; - - const onRolesChange = (roles: Array) => { - updateRuleState('roles', roles || []); - }; - - const hide = () => { - clearError(); - setCurrentRule({ ...DEAFULT_RULE }); - onHide(); - }; - - const onSave = () => { - const tables: number[] = []; - const roles: number[] = []; - - currentRule.tables?.forEach(table => tables.push(table.key)); - currentRule.roles?.forEach(role => roles.push(role.key)); - - const data: any = { ...currentRule, tables, roles }; - - if (isEditMode && currentRule.id) { - const updateId = currentRule.id; - delete data.id; - updateResource(updateId, data).then(response => { - if (!response) { - return; - } - addSuccessToast(`Rule updated`); - hide(); - }); - } else if (currentRule) { - createResource(data).then(response => { - if (!response) return; - addSuccessToast(t('Rule added')); - hide(); - }); - } - }; - - // * data loaders * - const loadTableOptions = useMemo( - () => - (input = '', page: number, pageSize: number) => { - const query = rison.encode({ - filter: input, - page, - page_size: pageSize, - }); - return SupersetClient.get({ - endpoint: `/api/v1/rowlevelsecurity/related/tables?q=${query}`, - }).then(response => { - const list = response.json.result.map( - (item: { value: number; text: string }) => ({ - label: item.text, - value: item.value, - }), - ); - return { data: list, totalCount: response.json.count }; - }); - }, - [], - ); - - const loadRoleOptions = useMemo( - () => - (input = '', page: number, pageSize: number) => { - const query = rison.encode({ - filter: input, - page, - page_size: pageSize, - }); - return SupersetClient.get({ - endpoint: `/api/v1/rowlevelsecurity/related/roles?q=${query}`, - }).then(response => { - const list = response.json.result.map( - (item: { value: number; text: string }) => ({ - label: item.text, - value: item.value, - }), - ); - return { data: list, totalCount: response.json.count }; - }); - }, - [], - ); - - // * state validators * - const validate = () => { - if ( - currentRule?.name && - currentRule?.clause && - currentRule.tables?.length - ) { - setDisableSave(false); - } else { - setDisableSave(true); - } - }; - - return ( - - {isEditMode ? ( - - ) : ( - - )} - {isEditMode ? t('Edit Rule') : t('Add Rule')} - - } - > - -
- - - onTextChange(target), - }} - css={noBottomMargin} - label={t('Rule Name')} - data-test="rule-name-test" - /> - - - -
- {t('Filter Type')}{' '} - -
-
-