diff --git a/superset-frontend/cypress-base/cypress/e2e/alerts_and_reports/alerts.test.ts b/superset-frontend/cypress-base/cypress/e2e/alerts_and_reports/alerts.test.ts index a695541cee..b677507a46 100644 --- a/superset-frontend/cypress-base/cypress/e2e/alerts_and_reports/alerts.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/alerts_and_reports/alerts.test.ts @@ -29,10 +29,9 @@ describe('Alert list view', () => { cy.getBySel('sort-header').eq(2).contains('Name'); cy.getBySel('sort-header').eq(3).contains('Schedule'); cy.getBySel('sort-header').eq(4).contains('Notification method'); - cy.getBySel('sort-header').eq(5).contains('Created by'); - cy.getBySel('sort-header').eq(6).contains('Owners'); - cy.getBySel('sort-header').eq(7).contains('Modified'); - cy.getBySel('sort-header').eq(8).contains('Active'); + cy.getBySel('sort-header').eq(5).contains('Owners'); + cy.getBySel('sort-header').eq(6).contains('Last modified'); + cy.getBySel('sort-header').eq(7).contains('Active'); // TODO Cypress won't recognize the Actions column // cy.getBySel('sort-header').eq(9).contains('Actions'); }); diff --git a/superset-frontend/cypress-base/cypress/e2e/alerts_and_reports/reports.test.ts b/superset-frontend/cypress-base/cypress/e2e/alerts_and_reports/reports.test.ts index e267d76f6f..a227fa03d7 100644 --- a/superset-frontend/cypress-base/cypress/e2e/alerts_and_reports/reports.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/alerts_and_reports/reports.test.ts @@ -29,10 +29,9 @@ describe('Report list view', () => { cy.getBySel('sort-header').eq(2).contains('Name'); cy.getBySel('sort-header').eq(3).contains('Schedule'); cy.getBySel('sort-header').eq(4).contains('Notification method'); - cy.getBySel('sort-header').eq(5).contains('Created by'); - cy.getBySel('sort-header').eq(6).contains('Owners'); - cy.getBySel('sort-header').eq(7).contains('Modified'); - cy.getBySel('sort-header').eq(8).contains('Active'); + cy.getBySel('sort-header').eq(5).contains('Owners'); + cy.getBySel('sort-header').eq(6).contains('Last modified'); + cy.getBySel('sort-header').eq(7).contains('Active'); // TODO Cypress won't recognize the Actions column // cy.getBySel('sort-header').eq(9).contains('Actions'); }); diff --git a/superset-frontend/cypress-base/cypress/e2e/chart_list/filter.test.ts b/superset-frontend/cypress-base/cypress/e2e/chart_list/filter.test.ts index acd11669be..00b09e2fb8 100644 --- a/superset-frontend/cypress-base/cypress/e2e/chart_list/filter.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/chart_list/filter.test.ts @@ -35,14 +35,14 @@ describe('Charts filters', () => { setFilter('Owner', 'admin user'); }); - it('should allow filtering by "Created by" correctly', () => { - setFilter('Created by', 'alpha user'); - setFilter('Created by', 'admin user'); + it('should allow filtering by "Modified by" correctly', () => { + setFilter('Modified by', 'alpha user'); + setFilter('Modified by', 'admin user'); }); - it('should allow filtering by "Chart type" correctly', () => { - setFilter('Chart type', 'Area Chart (legacy)'); - setFilter('Chart type', 'Bubble Chart'); + it('should allow filtering by "Type" correctly', () => { + setFilter('Type', 'Area Chart (legacy)'); + setFilter('Type', 'Bubble Chart'); }); it('should allow filtering by "Dataset" correctly', () => { @@ -51,7 +51,7 @@ describe('Charts filters', () => { }); it('should allow filtering by "Dashboards" correctly', () => { - setFilter('Dashboards', 'Unicode Test'); - setFilter('Dashboards', 'Tabbed Dashboard'); + setFilter('Dashboard', 'Unicode Test'); + setFilter('Dashboard', 'Tabbed Dashboard'); }); }); diff --git a/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts b/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts index 6664281abe..44f348edc5 100644 --- a/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts @@ -109,14 +109,12 @@ describe('Charts list', () => { it('should load rows in list mode', () => { cy.getBySel('listview-table').should('be.visible'); - cy.getBySel('sort-header').eq(1).contains('Chart'); - cy.getBySel('sort-header').eq(2).contains('Visualization type'); + cy.getBySel('sort-header').eq(1).contains('Name'); + cy.getBySel('sort-header').eq(2).contains('Type'); cy.getBySel('sort-header').eq(3).contains('Dataset'); - // cy.getBySel('sort-header').eq(4).contains('Dashboards added to'); - cy.getBySel('sort-header').eq(4).contains('Modified by'); + cy.getBySel('sort-header').eq(4).contains('Owners'); cy.getBySel('sort-header').eq(5).contains('Last modified'); - cy.getBySel('sort-header').eq(6).contains('Created by'); - cy.getBySel('sort-header').eq(7).contains('Actions'); + cy.getBySel('sort-header').eq(6).contains('Actions'); }); it('should sort correctly in list mode', () => { diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/filter.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard_list/filter.test.ts index 4654b3b5c2..854ea541c7 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/filter.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard_list/filter.test.ts @@ -35,9 +35,9 @@ describe('Dashboards filters', () => { setFilter('Owner', 'admin user'); }); - it('should allow filtering by "Created by" correctly', () => { - setFilter('Created by', 'alpha user'); - setFilter('Created by', 'admin user'); + it('should allow filtering by "Modified by" correctly', () => { + setFilter('Modified by', 'alpha user'); + setFilter('Modified by', 'admin user'); }); it('should allow filtering by "Status" correctly', () => { diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts index 9bc6eed224..7dfb7cd673 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts @@ -54,13 +54,11 @@ describe('Dashboards list', () => { it('should load rows in list mode', () => { cy.getBySel('listview-table').should('be.visible'); - cy.getBySel('sort-header').eq(1).contains('Title'); - cy.getBySel('sort-header').eq(2).contains('Modified by'); - cy.getBySel('sort-header').eq(3).contains('Status'); - cy.getBySel('sort-header').eq(4).contains('Modified'); - cy.getBySel('sort-header').eq(5).contains('Created by'); - cy.getBySel('sort-header').eq(6).contains('Owners'); - cy.getBySel('sort-header').eq(7).contains('Actions'); + cy.getBySel('sort-header').eq(1).contains('Name'); + cy.getBySel('sort-header').eq(2).contains('Status'); + cy.getBySel('sort-header').eq(3).contains('Owners'); + cy.getBySel('sort-header').eq(4).contains('Last modified'); + cy.getBySel('sort-header').eq(5).contains('Actions'); }); it('should sort correctly in list mode', () => { diff --git a/superset-frontend/src/components/AuditInfo/ModifiedInfo.test.tsx b/superset-frontend/src/components/AuditInfo/ModifiedInfo.test.tsx new file mode 100644 index 0000000000..af9d6913d8 --- /dev/null +++ b/superset-frontend/src/components/AuditInfo/ModifiedInfo.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; + +import { ModifiedInfo } from '.'; + +const TEST_DATE = '2023-11-20'; +const USER = { + id: 1, + first_name: 'Foo', + last_name: 'Bar', +}; + +test('should render a tooltip when user is provided', async () => { + render(); + + const dateElement = screen.getByTestId('audit-info-date'); + expect(dateElement).toBeInTheDocument(); + expect(screen.getByText(TEST_DATE)).toBeInTheDocument(); + expect(screen.queryByText('Modified by: Foo Bar')).not.toBeInTheDocument(); + userEvent.hover(dateElement); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(screen.getByText('Modified by: Foo Bar')).toBeInTheDocument(); +}); + +test('should render only the date if username is not provided', async () => { + render(); + + const dateElement = screen.getByTestId('audit-info-date'); + expect(dateElement).toBeInTheDocument(); + expect(screen.getByText(TEST_DATE)).toBeInTheDocument(); + userEvent.hover(dateElement); + await waitFor( + () => { + const tooltip = screen.queryByRole('tooltip'); + expect(tooltip).not.toBeInTheDocument(); + }, + { timeout: 1000 }, + ); +}); diff --git a/superset-frontend/src/components/AuditInfo/index.tsx b/superset-frontend/src/components/AuditInfo/index.tsx new file mode 100644 index 0000000000..24223a1554 --- /dev/null +++ b/superset-frontend/src/components/AuditInfo/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import Owner from 'src/types/Owner'; +import { Tooltip } from 'src/components/Tooltip'; +import getOwnerName from 'src/utils/getOwnerName'; +import { t } from '@superset-ui/core'; + +export type ModifiedInfoProps = { + user?: Owner; + date: string; +}; + +export const ModifiedInfo = ({ user, date }: ModifiedInfoProps) => { + const dateSpan = ( + + {date} + + ); + + if (user) { + const userName = getOwnerName(user); + const title = t('Modified by: %s', userName); + return ( + + {dateSpan} + + ); + } + return dateSpan; +}; diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx index 86b5c22777..751001297a 100644 --- a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx @@ -1114,7 +1114,7 @@ class DatasourceEditor extends React.PureComponent {
- + = ({
- {t('Annotation name')} + {t('Name')} *
= ({ const update_id = currentCssTemplate.id; delete currentCssTemplate.id; delete currentCssTemplate.created_by; + delete currentCssTemplate.changed_by; + delete currentCssTemplate.changed_on_delta_humanized; + updateResource(update_id, currentCssTemplate).then(response => { if (!response) { return; @@ -235,7 +238,7 @@ const CssTemplateModal: FunctionComponent = ({
- {t('CSS template name')} + {t('Name')} *
{ changed_on_delta_humanized: '', created_on_delta_humanized: '', created_by: { + id: 1, first_name: 'joe', last_name: 'smith', }, changed_by: { + id: 2, first_name: 'tom', last_name: 'brown', }, diff --git a/superset-frontend/src/pages/AlertReportList/index.tsx b/superset-frontend/src/pages/AlertReportList/index.tsx index b0cd0a4622..c6d14d186f 100644 --- a/superset-frontend/src/pages/AlertReportList/index.tsx +++ b/superset-frontend/src/pages/AlertReportList/index.tsx @@ -53,6 +53,8 @@ import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; import Owner from 'src/types/Owner'; import AlertReportModal from 'src/features/alerts/AlertReportModal'; import { AlertObject, AlertState } from 'src/features/alerts/types'; +import { ModifiedInfo } from 'src/components/AuditInfo'; +import { QueryObjectColumns } from 'src/views/CRUD/types'; const extensionsRegistry = getExtensionsRegistry(); @@ -303,18 +305,6 @@ function AlertList({ disableSortBy: true, size: 'xl', }, - { - Cell: ({ - row: { - original: { created_by }, - }, - }: any) => - created_by ? `${created_by.first_name} ${created_by.last_name}` : '', - Header: t('Created by'), - id: 'created_by', - disableSortBy: true, - size: 'xl', - }, { Cell: ({ row: { @@ -329,10 +319,13 @@ function AlertList({ { Cell: ({ row: { - original: { changed_on_delta_humanized: changedOn }, + original: { + changed_on_delta_humanized: changedOn, + changed_by: changedBy, + }, }, - }: any) => {changedOn}, - Header: t('Modified'), + }: any) => , + Header: t('Last modified'), accessor: 'changed_on_delta_humanized', size: 'xl', }, @@ -407,6 +400,10 @@ function AlertList({ disableSortBy: true, size: 'xl', }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [canDelete, canEdit, isReportEnabled, toggleActive], ); @@ -448,6 +445,13 @@ function AlertList({ const filters: Filters = useMemo( () => [ + { + Header: t('Name'), + key: 'search', + id: 'name', + input: 'search', + operator: FilterOperator.contains, + }, { Header: t('Owner'), key: 'owner', @@ -465,23 +469,6 @@ function AlertList({ ), paginate: true, }, - { - Header: t('Created by'), - key: 'created_by', - id: 'created_by', - input: 'select', - operator: FilterOperator.relationOneMany, - unfilteredLabel: 'All', - fetchSelects: createFetchRelated( - 'report', - 'created_by', - createErrorHandler(errMsg => - t('An error occurred while fetching created by values: %s', errMsg), - ), - user, - ), - paginate: true, - }, { Header: t('Status'), key: 'status', @@ -504,11 +491,24 @@ function AlertList({ ], }, { - Header: t('Search'), - key: 'search', - id: 'name', - input: 'search', - operator: FilterOperator.contains, + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', + input: 'select', + operator: FilterOperator.relationOneMany, + unfilteredLabel: t('All'), + fetchSelects: createFetchRelated( + 'report', + 'changed_by', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), + ), + user, + ), + paginate: true, }, ], [], diff --git a/superset-frontend/src/pages/AllEntities/index.tsx b/superset-frontend/src/pages/AllEntities/index.tsx index a1e2c52fe4..b94cab846d 100644 --- a/superset-frontend/src/pages/AllEntities/index.tsx +++ b/superset-frontend/src/pages/AllEntities/index.tsx @@ -35,6 +35,7 @@ import TagModal from 'src/features/tags/TagModal'; import withToasts, { useToasts } from 'src/components/MessageToasts/withToasts'; import { fetchObjectsByTagIds, fetchSingleTag } from 'src/features/tags/tags'; import Loading from 'src/components/Loading'; +import getOwnerName from 'src/utils/getOwnerName'; interface TaggedObject { id: number; @@ -132,7 +133,7 @@ function AllEntities() { const owner: Owner = { type: MetadataType.OWNER, - createdBy: `${tag?.created_by.first_name} ${tag?.created_by.last_name}`, + createdBy: getOwnerName(tag?.created_by), createdOn: tag?.created_on_delta_humanized || '', }; items.push(owner); @@ -140,7 +141,7 @@ function AllEntities() { const lastModified: LastModified = { type: MetadataType.LAST_MODIFIED, value: tag?.changed_on_delta_humanized || '', - modifiedBy: `${tag?.changed_by.first_name} ${tag?.changed_by.last_name}`, + modifiedBy: getOwnerName(tag?.changed_by), }; items.push(lastModified); diff --git a/superset-frontend/src/pages/AnnotationLayerList/index.tsx b/superset-frontend/src/pages/AnnotationLayerList/index.tsx index fc909538c0..fff5743b5a 100644 --- a/superset-frontend/src/pages/AnnotationLayerList/index.tsx +++ b/superset-frontend/src/pages/AnnotationLayerList/index.tsx @@ -21,7 +21,6 @@ import React, { useMemo, useState } from 'react'; import rison from 'rison'; import { t, SupersetClient } from '@superset-ui/core'; import { Link, useHistory } from 'react-router-dom'; -import moment from 'moment'; import { useListViewResource } from 'src/views/CRUD/hooks'; import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils'; import withToasts from 'src/components/MessageToasts/withToasts'; @@ -36,9 +35,10 @@ import DeleteModal from 'src/components/DeleteModal'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import AnnotationLayerModal from 'src/features/annotationLayers/AnnotationLayerModal'; import { AnnotationLayerObject } from 'src/features/annotationLayers/types'; +import { ModifiedInfo } from 'src/components/AuditInfo'; +import { QueryObjectColumns } from 'src/views/CRUD/types'; const PAGE_SIZE = 25; -const MOMENT_FORMAT = 'MMM DD, YYYY'; interface AnnotationLayersListProps { addDangerToast: (msg: string) => void; @@ -156,65 +156,16 @@ function AnnotationLayersList({ { Cell: ({ row: { - original: { changed_on: changedOn }, + original: { + changed_on_delta_humanized: changedOn, + changed_by: changedBy, + }, }, - }: any) => { - const date = new Date(changedOn); - const utc = new Date( - Date.UTC( - date.getFullYear(), - date.getMonth(), - date.getDate(), - date.getHours(), - date.getMinutes(), - date.getSeconds(), - date.getMilliseconds(), - ), - ); - - return moment(utc).format(MOMENT_FORMAT); - }, + }: any) => , Header: t('Last modified'), accessor: 'changed_on', size: 'xl', }, - { - Cell: ({ - row: { - original: { created_on: createdOn }, - }, - }: any) => { - const date = new Date(createdOn); - const utc = new Date( - Date.UTC( - date.getFullYear(), - date.getMonth(), - date.getDate(), - date.getHours(), - date.getMinutes(), - date.getSeconds(), - date.getMilliseconds(), - ), - ); - - return moment(utc).format(MOMENT_FORMAT); - }, - Header: t('Created on'), - accessor: 'created_on', - size: 'xl', - }, - { - accessor: 'created_by', - disableSortBy: true, - Header: t('Created by'), - Cell: ({ - row: { - original: { created_by: createdBy }, - }, - }: any) => - createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', - size: 'xl', - }, { Cell: ({ row: { original } }: any) => { const handleEdit = () => handleAnnotationLayerEdit(original); @@ -249,6 +200,10 @@ function AnnotationLayersList({ hidden: !canEdit && !canDelete, size: 'xl', }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [canDelete, canCreate], ); @@ -280,15 +235,22 @@ function AnnotationLayersList({ const filters: Filters = useMemo( () => [ { - Header: t('Created by'), - key: 'created_by', - id: 'created_by', + Header: t('Name'), + key: 'search', + id: 'name', + input: 'search', + operator: FilterOperator.contains, + }, + { + Header: t('Changed by'), + key: 'changed_by', + id: 'changed_by', input: 'select', operator: FilterOperator.relationOneMany, unfilteredLabel: t('All'), fetchSelects: createFetchRelated( 'annotation_layer', - 'created_by', + 'changed_by', createErrorHandler(errMsg => t( 'An error occurred while fetching dataset datasource values: %s', @@ -299,13 +261,6 @@ function AnnotationLayersList({ ), paginate: true, }, - { - Header: t('Search'), - key: 'search', - id: 'name', - input: 'search', - operator: FilterOperator.contains, - }, ], [], ); diff --git a/superset-frontend/src/pages/AnnotationList/index.tsx b/superset-frontend/src/pages/AnnotationList/index.tsx index 980a18ba72..e04b48080f 100644 --- a/superset-frontend/src/pages/AnnotationList/index.tsx +++ b/superset-frontend/src/pages/AnnotationList/index.tsx @@ -154,7 +154,7 @@ function AnnotationList({ () => [ { accessor: 'short_descr', - Header: t('Label'), + Header: t('Name'), }, { accessor: 'long_descr', diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index d13113158e..5ed967d7c1 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -29,7 +29,6 @@ import { import React, { useState, useMemo, useCallback } from 'react'; import rison from 'rison'; import { uniqBy } from 'lodash'; -import moment from 'moment'; import { useSelector } from 'react-redux'; import { createErrorHandler, @@ -69,11 +68,13 @@ import setupPlugins from 'src/setup/setupPlugins'; import InfoTooltip from 'src/components/InfoTooltip'; import CertifiedBadge from 'src/components/CertifiedBadge'; import { GenericLink } from 'src/components/GenericLink/GenericLink'; -import Owner from 'src/types/Owner'; import { loadTags } from 'src/components/Tags/utils'; +import FacePile from 'src/components/FacePile'; import ChartCard from 'src/features/charts/ChartCard'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { findPermission } from 'src/utils/findPermission'; +import { ModifiedInfo } from 'src/components/AuditInfo'; +import { QueryObjectColumns } from 'src/views/CRUD/types'; const FlexRowContainer = styled.div` align-items: center; @@ -245,10 +246,6 @@ function ChartList(props: ChartListProps) { }); setPreparingExport(true); }; - const changedByName = (lastSavedBy: Owner) => - lastSavedBy?.first_name - ? `${lastSavedBy?.first_name} ${lastSavedBy?.last_name}` - : null; function handleBulkChartDelete(chartsToDelete: Chart[]) { SupersetClient.delete({ @@ -366,7 +363,7 @@ function ChartList(props: ChartListProps) { )} ), - Header: t('Chart'), + Header: t('Name'), accessor: 'slice_name', }, { @@ -375,7 +372,7 @@ function ChartList(props: ChartListProps) { original: { viz_type: vizType }, }, }: any) => registry.get(vizType)?.name || vizType, - Header: t('Visualization type'), + Header: t('Type'), accessor: 'viz_type', size: 'xxl', }, @@ -438,44 +435,27 @@ function ChartList(props: ChartListProps) { { Cell: ({ row: { - original: { last_saved_by: lastSavedBy }, + original: { owners = [] }, }, - }: any) => <>{changedByName(lastSavedBy)}, - Header: t('Modified by'), - accessor: 'last_saved_by.first_name', + }: any) => , + Header: t('Owners'), + accessor: 'owners', + disableSortBy: true, size: 'xl', }, { Cell: ({ row: { - original: { last_saved_at: lastSavedAt }, + original: { + changed_on_delta_humanized: changedOn, + changed_by: changedBy, + }, }, - }: any) => ( - - {lastSavedAt ? moment.utc(lastSavedAt).fromNow() : null} - - ), + }: any) => , Header: t('Last modified'), accessor: 'last_saved_at', size: 'xl', }, - { - accessor: 'owners', - hidden: true, - disableSortBy: true, - }, - { - Cell: ({ - row: { - original: { created_by: createdBy }, - }, - }: any) => - createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', - Header: t('Created by'), - accessor: 'created_by', - disableSortBy: true, - size: 'xl', - }, { Cell: ({ row: { original } }: any) => { const handleDelete = () => @@ -563,6 +543,10 @@ function ChartList(props: ChartListProps) { disableSortBy: true, hidden: !canEdit && !canDelete, }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [ userId, @@ -597,58 +581,14 @@ function ChartList(props: ChartListProps) { const filters: Filters = useMemo(() => { const filters_list = [ { - Header: t('Search'), + Header: t('Name'), key: 'search', id: 'slice_name', input: 'search', operator: FilterOperator.chartAllText, }, { - Header: t('Owner'), - key: 'owner', - id: 'owners', - input: 'select', - operator: FilterOperator.relationManyMany, - unfilteredLabel: t('All'), - fetchSelects: createFetchRelated( - 'chart', - 'owners', - createErrorHandler(errMsg => - addDangerToast( - t( - 'An error occurred while fetching chart owners values: %s', - errMsg, - ), - ), - ), - props.user, - ), - paginate: true, - }, - { - Header: t('Created by'), - key: 'created_by', - id: 'created_by', - input: 'select', - operator: FilterOperator.relationOneMany, - unfilteredLabel: t('All'), - fetchSelects: createFetchRelated( - 'chart', - 'created_by', - createErrorHandler(errMsg => - addDangerToast( - t( - 'An error occurred while fetching chart created by values: %s', - errMsg, - ), - ), - ), - props.user, - ), - paginate: true, - }, - { - Header: t('Chart type'), + Header: t('Type'), key: 'viz_type', id: 'viz_type', input: 'select', @@ -683,8 +623,43 @@ function ChartList(props: ChartListProps) { fetchSelects: createFetchDatasets, paginate: true, }, + ...(isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && canReadTag + ? [ + { + Header: t('Tag'), + key: 'tags', + id: 'tags', + input: 'select', + operator: FilterOperator.chartTags, + unfilteredLabel: t('All'), + fetchSelects: loadTags, + }, + ] + : []), { - Header: t('Dashboards'), + Header: t('Owner'), + key: 'owner', + id: 'owners', + input: 'select', + operator: FilterOperator.relationManyMany, + unfilteredLabel: t('All'), + fetchSelects: createFetchRelated( + 'chart', + 'owners', + createErrorHandler(errMsg => + addDangerToast( + t( + 'An error occurred while fetching chart owners values: %s', + errMsg, + ), + ), + ), + props.user, + ), + paginate: true, + }, + { + Header: t('Dashboard'), key: 'dashboards', id: 'dashboards', input: 'select', @@ -707,18 +682,27 @@ function ChartList(props: ChartListProps) { { label: t('No'), value: false }, ], }, - ] as Filters; - if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && canReadTag) { - filters_list.push({ - Header: t('Tags'), - key: 'tags', - id: 'tags', + { + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', input: 'select', - operator: FilterOperator.chartTags, + operator: FilterOperator.relationOneMany, unfilteredLabel: t('All'), - fetchSelects: loadTags, - }); - } + fetchSelects: createFetchRelated( + 'chart', + 'changed_by', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), + ), + props.user, + ), + paginate: true, + }, + ] as Filters; return filters_list; }, [addDangerToast, favoritesFilter, props.user]); diff --git a/superset-frontend/src/pages/CssTemplateList/index.tsx b/superset-frontend/src/pages/CssTemplateList/index.tsx index f777f8e743..b77217b22f 100644 --- a/superset-frontend/src/pages/CssTemplateList/index.tsx +++ b/superset-frontend/src/pages/CssTemplateList/index.tsx @@ -21,13 +21,11 @@ import React, { useMemo, useState } from 'react'; import { t, SupersetClient } from '@superset-ui/core'; import rison from 'rison'; -import moment from 'moment'; import { useListViewResource } from 'src/views/CRUD/hooks'; -import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils'; +import { createErrorHandler, createFetchRelated } from 'src/views/CRUD/utils'; import withToasts from 'src/components/MessageToasts/withToasts'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import DeleteModal from 'src/components/DeleteModal'; -import { Tooltip } from 'src/components/Tooltip'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; import ListView, { @@ -37,6 +35,8 @@ import ListView, { } from 'src/components/ListView'; import CssTemplateModal from 'src/features/cssTemplates/CssTemplateModal'; import { TemplateObject } from 'src/features/cssTemplates/types'; +import { ModifiedInfo } from 'src/components/AuditInfo'; +import { QueryObjectColumns } from 'src/views/CRUD/types'; const PAGE_SIZE = 25; @@ -138,66 +138,12 @@ function CssTemplatesList({ changed_by: changedBy, }, }, - }: any) => { - let name = 'null'; - - if (changedBy) { - name = `${changedBy.first_name} ${changedBy.last_name}`; - } - - return ( - - {changedOn} - - ); - }, + }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', size: 'xl', disableSortBy: true, }, - { - Cell: ({ - row: { - original: { created_on: createdOn }, - }, - }: any) => { - const date = new Date(createdOn); - const utc = new Date( - Date.UTC( - date.getFullYear(), - date.getMonth(), - date.getDate(), - date.getHours(), - date.getMinutes(), - date.getSeconds(), - date.getMilliseconds(), - ), - ); - - return moment(utc).fromNow(); - }, - Header: t('Created on'), - accessor: 'created_on', - size: 'xl', - disableSortBy: true, - }, - { - accessor: 'created_by', - disableSortBy: true, - Header: t('Created by'), - Cell: ({ - row: { - original: { created_by: createdBy }, - }, - }: any) => - createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', - size: 'xl', - }, { Cell: ({ row: { original } }: any) => { const handleEdit = () => handleCssTemplateEdit(original); @@ -232,6 +178,10 @@ function CssTemplatesList({ hidden: !canEdit && !canDelete, size: 'xl', }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [canDelete, canCreate], ); @@ -270,15 +220,22 @@ function CssTemplatesList({ const filters: Filters = useMemo( () => [ { - Header: t('Created by'), - key: 'created_by', - id: 'created_by', + Header: t('Name'), + key: 'search', + id: 'template_name', + input: 'search', + operator: FilterOperator.contains, + }, + { + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', input: 'select', operator: FilterOperator.relationOneMany, unfilteredLabel: t('All'), fetchSelects: createFetchRelated( 'css_template', - 'created_by', + 'changed_by', createErrorHandler(errMsg => t( 'An error occurred while fetching dataset datasource values: %s', @@ -289,13 +246,6 @@ function CssTemplatesList({ ), paginate: true, }, - { - Header: t('Search'), - key: 'search', - id: 'template_name', - input: 'search', - operator: FilterOperator.contains, - }, ], [], ); diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index 6542d85129..e82b701859 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -57,13 +57,17 @@ import { Tooltip } from 'src/components/Tooltip'; import ImportModelsModal from 'src/components/ImportModal/index'; import Dashboard from 'src/dashboard/containers/Dashboard'; -import { Dashboard as CRUDDashboard } from 'src/views/CRUD/types'; +import { + Dashboard as CRUDDashboard, + QueryObjectColumns, +} from 'src/views/CRUD/types'; import CertifiedBadge from 'src/components/CertifiedBadge'; import { loadTags } from 'src/components/Tags/utils'; import DashboardCard from 'src/features/dashboards/DashboardCard'; import { DashboardStatus } from 'src/features/dashboards/types'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { findPermission } from 'src/utils/findPermission'; +import { ModifiedInfo } from 'src/components/AuditInfo'; const PAGE_SIZE = 25; const PASSWORDS_NEEDED_MESSAGE = t( @@ -108,11 +112,7 @@ const Actions = styled.div` `; function DashboardList(props: DashboardListProps) { - const { - addDangerToast, - addSuccessToast, - user: { userId }, - } = props; + const { addDangerToast, addSuccessToast, user } = props; const { roles } = useSelector( state => state.user, @@ -178,7 +178,7 @@ function DashboardList(props: DashboardListProps) { }; // TODO: Fix usage of localStorage keying on the user id - const userKey = dangerouslyGetItemDoNotUse(userId?.toString(), null); + const userKey = dangerouslyGetItemDoNotUse(user?.userId?.toString(), null); const canCreate = hasPerm('can_write'); const canEdit = hasPerm('can_write'); @@ -274,7 +274,7 @@ function DashboardList(props: DashboardListProps) { original: { id }, }, }: any) => - userId && ( + user?.userId && ( ), - Header: t('Title'), + Header: t('Name'), accessor: 'dashboard_title', }, + { + Cell: ({ + row: { + original: { status }, + }, + }: any) => + status === DashboardStatus.PUBLISHED ? t('Published') : t('Draft'), + Header: t('Status'), + accessor: 'published', + size: 'xl', + }, { Cell: ({ row: { @@ -338,49 +349,6 @@ function DashboardList(props: DashboardListProps) { disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM), }, - { - Cell: ({ - row: { - original: { changed_by_name: changedByName }, - }, - }: any) => <>{changedByName}, - Header: t('Modified by'), - accessor: 'changed_by.first_name', - size: 'xl', - }, - { - Cell: ({ - row: { - original: { status }, - }, - }: any) => - status === DashboardStatus.PUBLISHED ? t('Published') : t('Draft'), - Header: t('Status'), - accessor: 'published', - size: 'xl', - }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => {changedOn}, - Header: t('Modified'), - accessor: 'changed_on_delta_humanized', - size: 'xl', - }, - { - Cell: ({ - row: { - original: { created_by: createdBy }, - }, - }: any) => - createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', - Header: t('Created by'), - accessor: 'created_by', - disableSortBy: true, - size: 'xl', - }, { Cell: ({ row: { @@ -392,6 +360,19 @@ function DashboardList(props: DashboardListProps) { disableSortBy: true, size: 'xl', }, + { + Cell: ({ + row: { + original: { + changed_on_delta_humanized: changedOn, + changed_by: changedBy, + }, + }, + }: any) => , + Header: t('Last modified'), + accessor: 'changed_on_delta_humanized', + size: 'xl', + }, { Cell: ({ row: { original } }: any) => { const handleDelete = () => @@ -475,9 +456,13 @@ function DashboardList(props: DashboardListProps) { hidden: !canEdit && !canDelete && !canExport, disableSortBy: true, }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [ - userId, + user?.userId, canEdit, canDelete, canExport, @@ -509,12 +494,37 @@ function DashboardList(props: DashboardListProps) { const filters: Filters = useMemo(() => { const filters_list = [ { - Header: t('Search'), + Header: t('Name'), key: 'search', id: 'dashboard_title', input: 'search', operator: FilterOperator.titleOrSlug, }, + { + Header: t('Status'), + key: 'published', + id: 'published', + input: 'select', + operator: FilterOperator.equals, + unfilteredLabel: t('Any'), + selects: [ + { label: t('Published'), value: true }, + { label: t('Draft'), value: false }, + ], + }, + ...(isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && canReadTag + ? [ + { + Header: t('Tag'), + key: 'tags', + id: 'tags', + input: 'select', + operator: FilterOperator.dashboardTags, + unfilteredLabel: t('All'), + fetchSelects: loadTags, + }, + ] + : []), { Header: t('Owner'), key: 'owner', @@ -537,41 +547,7 @@ function DashboardList(props: DashboardListProps) { ), paginate: true, }, - { - Header: t('Created by'), - key: 'created_by', - id: 'created_by', - input: 'select', - operator: FilterOperator.relationOneMany, - unfilteredLabel: t('All'), - fetchSelects: createFetchRelated( - 'dashboard', - 'created_by', - createErrorHandler(errMsg => - addDangerToast( - t( - 'An error occurred while fetching dashboard created by values: %s', - errMsg, - ), - ), - ), - props.user, - ), - paginate: true, - }, - { - Header: t('Status'), - key: 'published', - id: 'published', - input: 'select', - operator: FilterOperator.equals, - unfilteredLabel: t('Any'), - selects: [ - { label: t('Published'), value: true }, - { label: t('Draft'), value: false }, - ], - }, - ...(userId ? [favoritesFilter] : []), + ...(user?.userId ? [favoritesFilter] : []), { Header: t('Certified'), key: 'certified', @@ -585,18 +561,27 @@ function DashboardList(props: DashboardListProps) { { label: t('No'), value: false }, ], }, - ] as Filters; - if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && canReadTag) { - filters_list.push({ - Header: t('Tags'), - key: 'tags', - id: 'tags', + { + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', input: 'select', - operator: FilterOperator.dashboardTags, + operator: FilterOperator.relationOneMany, unfilteredLabel: t('All'), - fetchSelects: loadTags, - }); - } + fetchSelects: createFetchRelated( + 'dashboard', + 'changed_by', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), + ), + user, + ), + paginate: true, + }, + ] as Filters; return filters_list; }, [addDangerToast, favoritesFilter, props.user]); @@ -632,7 +617,7 @@ function DashboardList(props: DashboardListProps) { ? userKey.thumbnails : isFeatureEnabled(FeatureFlag.THUMBNAILS) } - userId={userId} + userId={user?.userId} loading={loading} openDashboardEditModal={openDashboardEditModal} saveFavoriteStatus={saveFavoriteStatus} @@ -646,7 +631,7 @@ function DashboardList(props: DashboardListProps) { favoriteStatus, hasPerm, loading, - userId, + user?.userId, saveFavoriteStatus, userKey, ], @@ -743,7 +728,7 @@ function DashboardList(props: DashboardListProps) { addSuccessToast, addDangerToast, undefined, - userId, + user?.userId, ); setDashboardToDelete(null); }} diff --git a/superset-frontend/src/pages/DatabaseList/DatabaseList.test.jsx b/superset-frontend/src/pages/DatabaseList/DatabaseList.test.jsx index fd989b50d2..b1bfb245d3 100644 --- a/superset-frontend/src/pages/DatabaseList/DatabaseList.test.jsx +++ b/superset-frontend/src/pages/DatabaseList/DatabaseList.test.jsx @@ -218,7 +218,7 @@ describe('Admin DatabaseList', () => { await waitForComponentToPaint(wrapper); expect(fetchMock.lastCall()[0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/database/?q=(filters:!((col:expose_in_sqllab,opr:eq,value:!t),(col:allow_run_async,opr:eq,value:!f),(col:database_name,opr:ct,value:fooo)),order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, + `"http://localhost/api/v1/database/?q=(filters:!((col:database_name,opr:ct,value:fooo),(col:expose_in_sqllab,opr:eq,value:!t),(col:allow_run_async,opr:eq,value:!f)),order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, ); }); diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index d2308bd117..8c98392aca 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -32,7 +32,11 @@ import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers'; import Loading from 'src/components/Loading'; import { useListViewResource } from 'src/views/CRUD/hooks'; -import { createErrorHandler, uploadUserPerms } from 'src/views/CRUD/utils'; +import { + createErrorHandler, + createFetchRelated, + uploadUserPerms, +} from 'src/views/CRUD/utils'; import withToasts from 'src/components/MessageToasts/withToasts'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import DeleteModal from 'src/components/DeleteModal'; @@ -48,6 +52,8 @@ import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import type { MenuObjectProps } from 'src/types/bootstrapTypes'; import DatabaseModal from 'src/features/databases/DatabaseModal'; import { DatabaseObject } from 'src/features/databases/types'; +import { ModifiedInfo } from 'src/components/AuditInfo'; +import { QueryObjectColumns } from 'src/views/CRUD/types'; const extensionsRegistry = getExtensionsRegistry(); const DatabaseDeleteRelatedExtension = extensionsRegistry.get( @@ -67,6 +73,11 @@ interface DatabaseDeleteObject extends DatabaseObject { interface DatabaseListProps { addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; + user: { + userId: string | number; + firstName: string; + lastName: string; + }; } const IconCheck = styled(Icons.Check)` @@ -90,7 +101,11 @@ function BooleanDisplay({ value }: { value: Boolean }) { return value ? : ; } -function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { +function DatabaseList({ + addDangerToast, + addSuccessToast, + user, +}: DatabaseListProps) { const { state: { loading, @@ -105,7 +120,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { t('database'), addDangerToast, ); - const user = useSelector( + const fullUser = useSelector( state => state.user, ); const showDatabaseModal = getUrlParam(URL_PARAMS.showDatabaseModal); @@ -123,11 +138,11 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { null, ); const [allowUploads, setAllowUploads] = useState(false); - const isAdmin = isUserAdmin(user); + const isAdmin = isUserAdmin(fullUser); const showUploads = allowUploads || isAdmin; const [preparingExport, setPreparingExport] = useState(false); - const { roles } = user; + const { roles } = fullUser; const { CSV_EXTENSIONS, COLUMNAR_EXTENSIONS, @@ -313,7 +328,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { () => [ { accessor: 'database_name', - Header: t('Database'), + Header: t('Name'), }, { accessor: 'backend', @@ -380,23 +395,14 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { size: 'md', }, { - accessor: 'created_by', - disableSortBy: true, - Header: t('Created by'), Cell: ({ row: { - original: { created_by: createdBy }, + original: { + changed_by: changedBy, + changed_on_delta_humanized: changedOn, + }, }, - }: any) => - createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', - size: 'xl', - }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => changedOn, + }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', size: 'xl', @@ -470,12 +476,23 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { hidden: !canEdit && !canDelete, disableSortBy: true, }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [canDelete, canEdit, canExport], ); const filters: Filters = useMemo( () => [ + { + Header: t('Name'), + key: 'search', + id: 'database_name', + input: 'search', + operator: FilterOperator.contains, + }, { Header: t('Expose in SQL Lab'), key: 'expose_in_sql_lab', @@ -509,11 +526,24 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ], }, { - Header: t('Search'), - key: 'search', - id: 'database_name', - input: 'search', - operator: FilterOperator.contains, + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', + input: 'select', + operator: FilterOperator.relationOneMany, + unfilteredLabel: t('All'), + fetchSelects: createFetchRelated( + 'database', + 'changed_by', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), + ), + user, + ), + paginate: true, }, ], [], diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx index 916dd0615b..c316001bb4 100644 --- a/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx +++ b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx @@ -285,56 +285,41 @@ describe('RTL', () => { }); describe('Prevent unsafe URLs', () => { + const columnCount = 8; + const exploreUrlIndex = 1; + const getTdIndex = (rowNumber: number): number => + rowNumber * columnCount + exploreUrlIndex; + const mockedProps = {}; let wrapper: any; it('Check prevent unsafe is on renders relative links', async () => { - const tdColumnsNumber = 9; useSelectorMock.mockReturnValue(true); wrapper = await mountAndWait(mockedProps); const tdElements = wrapper.find(ListView).find('td'); - expect( - tdElements - .at(0 * tdColumnsNumber + 1) - .find('a') - .prop('href'), - ).toBe('/https://www.google.com?0'); - expect( - tdElements - .at(1 * tdColumnsNumber + 1) - .find('a') - .prop('href'), - ).toBe('/https://www.google.com?1'); - expect( - tdElements - .at(2 * tdColumnsNumber + 1) - .find('a') - .prop('href'), - ).toBe('/https://www.google.com?2'); + expect(tdElements.at(getTdIndex(0)).find('a').prop('href')).toBe( + '/https://www.google.com?0', + ); + expect(tdElements.at(getTdIndex(1)).find('a').prop('href')).toBe( + '/https://www.google.com?1', + ); + expect(tdElements.at(getTdIndex(2)).find('a').prop('href')).toBe( + '/https://www.google.com?2', + ); }); it('Check prevent unsafe is off renders absolute links', async () => { - const tdColumnsNumber = 9; useSelectorMock.mockReturnValue(false); wrapper = await mountAndWait(mockedProps); const tdElements = wrapper.find(ListView).find('td'); - expect( - tdElements - .at(0 * tdColumnsNumber + 1) - .find('a') - .prop('href'), - ).toBe('https://www.google.com?0'); - expect( - tdElements - .at(1 * tdColumnsNumber + 1) - .find('a') - .prop('href'), - ).toBe('https://www.google.com?1'); - expect( - tdElements - .at(2 * tdColumnsNumber + 1) - .find('a') - .prop('href'), - ).toBe('https://www.google.com?2'); + expect(tdElements.at(getTdIndex(0)).find('a').prop('href')).toBe( + 'https://www.google.com?0', + ); + expect(tdElements.at(getTdIndex(1)).find('a').prop('href')).toBe( + 'https://www.google.com?1', + ); + expect(tdElements.at(getTdIndex(2)).find('a').prop('href')).toBe( + 'https://www.google.com?2', + ); }); }); diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index d86d7a7b0f..8a39cb0463 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -70,6 +70,8 @@ import { } from 'src/features/datasets/constants'; import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal'; import { useSelector } from 'react-redux'; +import { ModifiedInfo } from 'src/components/AuditInfo'; +import { QueryObjectColumns } from 'src/views/CRUD/types'; const extensionsRegistry = getExtensionsRegistry(); const DatasetDeleteRelatedExtension = extensionsRegistry.get( @@ -380,26 +382,6 @@ const DatasetList: FunctionComponent = ({ accessor: 'schema', size: 'lg', }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => {changedOn}, - Header: t('Modified'), - accessor: 'changed_on_delta_humanized', - size: 'xl', - }, - { - Cell: ({ - row: { - original: { changed_by_name: changedByName }, - }, - }: any) => changedByName, - Header: t('Modified by'), - accessor: 'changed_by.first_name', - size: 'xl', - }, { accessor: 'database', disableSortBy: true, @@ -416,6 +398,19 @@ const DatasetList: FunctionComponent = ({ disableSortBy: true, size: 'lg', }, + { + Cell: ({ + row: { + original: { + changed_on_delta_humanized: changedOn, + changed_by: changedBy, + }, + }, + }: any) => , + Header: t('Last modified'), + accessor: 'changed_on_delta_humanized', + size: 'xl', + }, { accessor: 'sql', hidden: true, @@ -515,6 +510,10 @@ const DatasetList: FunctionComponent = ({ hidden: !canEdit && !canDelete && !canDuplicate, disableSortBy: true, }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [canEdit, canDelete, canExport, openDatasetEditModal, canDuplicate, user], ); @@ -522,31 +521,23 @@ const DatasetList: FunctionComponent = ({ const filterTypes: Filters = useMemo( () => [ { - Header: t('Search'), + Header: t('Name'), key: 'search', id: 'table_name', input: 'search', operator: FilterOperator.contains, }, { - Header: t('Owner'), - key: 'owner', - id: 'owners', + Header: t('Type'), + key: 'sql', + id: 'sql', input: 'select', - operator: FilterOperator.relationManyMany, + operator: FilterOperator.datasetIsNullOrEmpty, unfilteredLabel: 'All', - fetchSelects: createFetchRelated( - 'dataset', - 'owners', - createErrorHandler(errMsg => - t( - 'An error occurred while fetching dataset owner values: %s', - errMsg, - ), - ), - user, - ), - paginate: true, + selects: [ + { label: t('Virtual'), value: false }, + { label: t('Physical'), value: true }, + ], }, { Header: t('Database'), @@ -581,16 +572,24 @@ const DatasetList: FunctionComponent = ({ paginate: true, }, { - Header: t('Type'), - key: 'sql', - id: 'sql', + Header: t('Owner'), + key: 'owner', + id: 'owners', input: 'select', - operator: FilterOperator.datasetIsNullOrEmpty, + operator: FilterOperator.relationManyMany, unfilteredLabel: 'All', - selects: [ - { label: t('Virtual'), value: false }, - { label: t('Physical'), value: true }, - ], + fetchSelects: createFetchRelated( + 'dataset', + 'owners', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset owner values: %s', + errMsg, + ), + ), + user, + ), + paginate: true, }, { Header: t('Certified'), @@ -605,6 +604,26 @@ const DatasetList: FunctionComponent = ({ { label: t('No'), value: false }, ], }, + { + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', + input: 'select', + operator: FilterOperator.relationOneMany, + unfilteredLabel: t('All'), + fetchSelects: createFetchRelated( + 'dataset', + 'changed_by', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), + ), + user, + ), + paginate: true, + }, ], [user], ); diff --git a/superset-frontend/src/pages/QueryHistoryList/index.tsx b/superset-frontend/src/pages/QueryHistoryList/index.tsx index 77177188e0..94b646d9e4 100644 --- a/superset-frontend/src/pages/QueryHistoryList/index.tsx +++ b/superset-frontend/src/pages/QueryHistoryList/index.tsx @@ -53,6 +53,7 @@ import { QueryObject, QueryObjectColumns } from 'src/views/CRUD/types'; import Icons from 'src/components/Icons'; import QueryPreviewModal from 'src/features/queries/QueryPreviewModal'; import { addSuccessToast } from 'src/components/MessageToasts/actions'; +import getOwnerName from 'src/utils/getOwnerName'; const PAGE_SIZE = 25; const SQL_PREVIEW_MAX_LINES = 4; @@ -311,7 +312,7 @@ function QueryList({ addDangerToast }: QueryListProps) { row: { original: { user }, }, - }: any) => (user ? `${user.first_name} ${user.last_name}` : ''), + }: any) => getOwnerName(user), }, { accessor: QueryObjectColumns.user, diff --git a/superset-frontend/src/pages/RowLevelSecurityList/RowLevelSecurityList.test.tsx b/superset-frontend/src/pages/RowLevelSecurityList/RowLevelSecurityList.test.tsx index a4621ed10e..6721f73add 100644 --- a/superset-frontend/src/pages/RowLevelSecurityList/RowLevelSecurityList.test.tsx +++ b/superset-frontend/src/pages/RowLevelSecurityList/RowLevelSecurityList.test.tsx @@ -187,8 +187,8 @@ describe('RuleList RTL', () => { const searchFilters = screen.queryAllByTestId('filters-search'); expect(searchFilters).toHaveLength(2); - const typeFilter = await screen.findByTestId('filters-select'); - expect(typeFilter).toBeInTheDocument(); + const typeFilter = screen.queryAllByTestId('filters-select'); + expect(typeFilter).toHaveLength(2); }); it('renders correct list columns', async () => { @@ -201,7 +201,7 @@ describe('RuleList RTL', () => { 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 modifiedColumn = await within(table).findByText('Last modified'); const actionsColumn = await within(table).findByText('Actions'); expect(nameColumn).toBeInTheDocument(); diff --git a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx index 3c1e3b8aae..bef42284d0 100644 --- a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx +++ b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx @@ -33,7 +33,9 @@ import rison from 'rison'; import { useListViewResource } from 'src/views/CRUD/hooks'; import RowLevelSecurityModal from 'src/features/rls/RowLevelSecurityModal'; import { RLSObject } from 'src/features/rls/types'; -import { createErrorHandler } from 'src/views/CRUD/utils'; +import { createErrorHandler, createFetchRelated } from 'src/views/CRUD/utils'; +import { ModifiedInfo } from 'src/components/AuditInfo'; +import { QueryObjectColumns } from 'src/views/CRUD/types'; const Actions = styled.div` color: ${({ theme }) => theme.colors.grayscale.base}; @@ -43,7 +45,7 @@ interface RLSProps { addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; user: { - userId?: string | number; + userId: string | number; firstName: string; lastName: string; }; @@ -146,10 +148,13 @@ function RowLevelSecurityList(props: RLSProps) { { Cell: ({ row: { - original: { changed_on_delta_humanized: changedOn }, + original: { + changed_on_delta_humanized: changedOn, + changed_by: changedBy, + }, }, - }: any) => {changedOn}, - Header: t('Modified'), + }: any) => , + Header: t('Last modified'), accessor: 'changed_on_delta_humanized', size: 'xl', }, @@ -218,6 +223,10 @@ function RowLevelSecurityList(props: RLSProps) { hidden: !canEdit && !canWrite && !canExport, disableSortBy: true, }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [ user.userId, @@ -270,6 +279,26 @@ function RowLevelSecurityList(props: RLSProps) { input: 'search', operator: FilterOperator.startsWith, }, + { + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', + input: 'select', + operator: FilterOperator.relationOneMany, + unfilteredLabel: t('All'), + fetchSelects: createFetchRelated( + 'rowlevelsecurity', + 'changed_by', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), + ), + user, + ), + paginate: true, + }, ], [user], ); diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index 3ee62c2ce6..d48ffef8c9 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -18,20 +18,19 @@ */ import { - isFeatureEnabled, FeatureFlag, + isFeatureEnabled, styled, SupersetClient, t, } from '@superset-ui/core'; -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Link, useHistory } from 'react-router-dom'; import rison from 'rison'; -import moment from 'moment'; import { - createFetchRelated, - createFetchDistinct, createErrorHandler, + createFetchDistinct, + createFetchRelated, } from 'src/views/CRUD/utils'; import { useSelector } from 'react-redux'; import Popover from 'src/components/Popover'; @@ -39,11 +38,11 @@ import withToasts from 'src/components/MessageToasts/withToasts'; import { useListViewResource } from 'src/views/CRUD/hooks'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import handleResourceExport from 'src/utils/export'; -import SubMenu, { SubMenuProps, ButtonProps } from 'src/features/home/SubMenu'; +import SubMenu, { ButtonProps, SubMenuProps } from 'src/features/home/SubMenu'; import ListView, { - ListViewProps, - Filters, FilterOperator, + Filters, + ListViewProps, } from 'src/components/ListView'; import Loading from 'src/components/Loading'; import DeleteModal from 'src/components/DeleteModal'; @@ -51,15 +50,14 @@ import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; import { TagsList } from 'src/components/Tags'; import { Tooltip } from 'src/components/Tooltip'; import { commonMenuData } from 'src/features/home/commonMenuData'; -import { SavedQueryObject } from 'src/views/CRUD/types'; +import { QueryObjectColumns, SavedQueryObject } from 'src/views/CRUD/types'; import copyTextToClipboard from 'src/utils/copy'; import Tag from 'src/types/TagType'; import ImportModelsModal from 'src/components/ImportModal/index'; +import { ModifiedInfo } from 'src/components/AuditInfo'; +import { loadTags } from 'src/components/Tags/utils'; import Icons from 'src/components/Icons'; -import { - BootstrapUser, - UserWithPermissionsAndRoles, -} from 'src/types/bootstrapTypes'; +import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import SavedQueryPreviewModal from 'src/features/queries/SavedQueryPreviewModal'; import { findPermission } from 'src/utils/findPermission'; @@ -80,7 +78,11 @@ const CONFIRM_OVERWRITE_MESSAGE = t( interface SavedQueryListProps { addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; - user: BootstrapUser; + user: { + userId: string | number; + firstName: string; + lastName: string; + }; } const StyledTableLabel = styled.div` @@ -99,6 +101,7 @@ const StyledPopoverItem = styled.div` function SavedQueryList({ addDangerToast, addSuccessToast, + user, }: SavedQueryListProps) { const { state: { @@ -348,41 +351,6 @@ function SavedQueryList({ size: 'xl', disableSortBy: true, }, - { - Cell: ({ - row: { - original: { created_on: createdOn }, - }, - }: any) => { - const date = new Date(createdOn); - const utc = new Date( - Date.UTC( - date.getFullYear(), - date.getMonth(), - date.getDate(), - date.getHours(), - date.getMinutes(), - date.getSeconds(), - date.getMilliseconds(), - ), - ); - - return moment(utc).fromNow(); - }, - Header: t('Created on'), - accessor: 'created_on', - size: 'xl', - }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => changedOn, - Header: t('Modified'), - accessor: 'changed_on_delta_humanized', - size: 'xl', - }, { Cell: ({ row: { @@ -397,6 +365,19 @@ function SavedQueryList({ disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM), }, + { + Cell: ({ + row: { + original: { + changed_by: changedBy, + changed_on_delta_humanized: changedOn, + }, + }, + }: any) => , + Header: t('Last modified'), + accessor: 'changed_on_delta_humanized', + size: 'xl', + }, { Cell: ({ row: { original } }: any) => { const handlePreview = () => { @@ -452,12 +433,23 @@ function SavedQueryList({ id: 'actions', disableSortBy: true, }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [canDelete, canEdit, canExport, copyQueryLink, handleSavedQueryPreview], ); const filters: Filters = useMemo( () => [ + { + Header: t('Name'), + id: 'label', + key: 'search', + input: 'search', + operator: FilterOperator.allText, + }, { Header: t('Database'), key: 'database', @@ -497,28 +489,42 @@ function SavedQueryList({ ), paginate: true, }, - + ...((isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && canReadTag + ? [ + { + Header: t('Tag'), + id: 'tags', + key: 'tags', + input: 'select', + operator: FilterOperator.savedQueryTags, + fetchSelects: loadTags, + }, + ] + : []) as Filters), { - Header: t('Search'), - id: 'label', - key: 'search', - input: 'search', - operator: FilterOperator.allText, + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', + input: 'select', + operator: FilterOperator.relationOneMany, + unfilteredLabel: t('All'), + fetchSelects: createFetchRelated( + 'saved_query', + 'changed_by', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), + ), + user, + ), + paginate: true, }, ], [addDangerToast], ); - if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && canReadTag) { - filters.push({ - Header: t('Tags'), - id: 'tags', - key: 'tags', - input: 'search', - operator: FilterOperator.savedQueryTags, - }); - } - return ( <> diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx index a66d7c7b61..d395ce7cde 100644 --- a/superset-frontend/src/pages/Tags/index.tsx +++ b/superset-frontend/src/pages/Tags/index.tsx @@ -19,9 +19,9 @@ import React, { useMemo, useState } from 'react'; import { isFeatureEnabled, FeatureFlag, t } from '@superset-ui/core'; import { - createFetchRelated, - createErrorHandler, Actions, + createErrorHandler, + createFetchRelated, } from 'src/views/CRUD/utils'; import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; @@ -35,13 +35,13 @@ import { dangerouslyGetItemDoNotUse } from 'src/utils/localStorageHelpers'; import withToasts from 'src/components/MessageToasts/withToasts'; import Icons from 'src/components/Icons'; import { Tooltip } from 'src/components/Tooltip'; -import FacePile from 'src/components/FacePile'; import { Link } from 'react-router-dom'; import { deleteTags } from 'src/features/tags/tags'; import { Tag as AntdTag } from 'antd'; -import { Tag } from 'src/views/CRUD/types'; +import { QueryObjectColumns, Tag } from 'src/views/CRUD/types'; import TagModal from 'src/features/tags/TagModal'; import FaveStar from 'src/components/FaveStar'; +import { ModifiedInfo } from 'src/components/AuditInfo'; const PAGE_SIZE = 25; @@ -56,11 +56,8 @@ interface TagListProps { } function TagList(props: TagListProps) { - const { - addDangerToast, - addSuccessToast, - user: { userId }, - } = props; + const { addDangerToast, addSuccessToast, user } = props; + const { userId } = user; const { state: { @@ -162,24 +159,16 @@ function TagList(props: TagListProps) { { Cell: ({ row: { - original: { changed_on_delta_humanized: changedOn }, + original: { + changed_on_delta_humanized: changedOn, + changed_by: changedBy, + }, }, - }: any) => {changedOn}, - Header: t('Modified'), + }: any) => , + Header: t('Last modified'), accessor: 'changed_on_delta_humanized', size: 'xl', }, - { - Cell: ({ - row: { - original: { created_by: createdBy }, - }, - }: any) => (createdBy ? : ''), - Header: t('Created by'), - accessor: 'created_by', - disableSortBy: true, - size: 'xl', - }, { Cell: ({ row: { original } }: any) => { const handleEdit = () => handleTagEdit(original); @@ -238,6 +227,10 @@ function TagList(props: TagListProps) { hidden: !canDelete, disableSortBy: true, }, + { + accessor: QueryObjectColumns.changed_by, + hidden: true, + }, ], [userId, canDelete, refreshData, addSuccessToast, addDangerToast], ); @@ -245,32 +238,31 @@ function TagList(props: TagListProps) { const filters: Filters = useMemo(() => { const filters_list = [ { - Header: t('Created by'), - id: 'created_by', + Header: t('Name'), + id: 'name', + input: 'search', + operator: FilterOperator.contains, + }, + { + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', input: 'select', operator: FilterOperator.relationOneMany, unfilteredLabel: t('All'), fetchSelects: createFetchRelated( 'tag', - 'created_by', + 'changed_by', createErrorHandler(errMsg => - addDangerToast( - t( - 'An error occurred while fetching tag created by values: %s', - errMsg, - ), + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, ), ), - props.user, + user, ), paginate: true, }, - { - Header: t('Search'), - id: 'name', - input: 'search', - operator: FilterOperator.contains, - }, ] as Filters; return filters_list; }, [addDangerToast, props.user]); diff --git a/superset-frontend/src/utils/getOwnerName.test.ts b/superset-frontend/src/utils/getOwnerName.test.ts new file mode 100644 index 0000000000..a4a25e57b2 --- /dev/null +++ b/superset-frontend/src/utils/getOwnerName.test.ts @@ -0,0 +1,29 @@ +/** + * 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 getOwnerName from './getOwnerName'; + +test('render owner name correctly', () => { + expect(getOwnerName({ id: 1, first_name: 'Foo', last_name: 'Bar' })).toEqual( + 'Foo Bar', + ); +}); + +test('return empty string for undefined owner', () => { + expect(getOwnerName(undefined)).toEqual(''); +}); diff --git a/superset-frontend/src/utils/getOwnerName.ts b/superset-frontend/src/utils/getOwnerName.ts new file mode 100644 index 0000000000..2534c45f2c --- /dev/null +++ b/superset-frontend/src/utils/getOwnerName.ts @@ -0,0 +1,26 @@ +/** + * 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 Owner from 'src/types/Owner'; + +export default function getOwnerName(owner?: Owner): string { + if (!owner) { + return ''; + } + return `${owner.first_name} ${owner.last_name}`; +} diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index 5a53b57696..2fff111b47 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -112,6 +112,7 @@ export interface QueryObject { export enum QueryObjectColumns { id = 'id', changed_on = 'changed_on', + changed_by = 'changed_by', database = 'database', database_name = 'database.database_name', schema = 'schema', @@ -138,17 +139,11 @@ export type ImportResourceName = export interface Tag { changed_on_delta_humanized: string; - changed_by: { - first_name: string; - last_name: string; - }; + changed_by: Owner; created_on_delta_humanized: string; name: string; id: number; - created_by: { - first_name: string; - last_name: string; - }; + created_by: Owner; description: string; type: string; } diff --git a/superset/annotation_layers/api.py b/superset/annotation_layers/api.py index 886c151a68..5606e944ef 100644 --- a/superset/annotation_layers/api.py +++ b/superset/annotation_layers/api.py @@ -99,7 +99,7 @@ class AnnotationLayerRestApi(BaseSupersetModelRestApi): ] search_filters = {"name": [AnnotationLayerAllTextFilter]} - allowed_rel_fields = {"created_by"} + allowed_rel_fields = {"created_by", "changed_by"} apispec_parameter_schemas = { "get_delete_ids_schema": get_delete_ids_schema, diff --git a/superset/charts/api.py b/superset/charts/api.py index ea705f0aa9..191f09c66e 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -273,7 +273,7 @@ class ChartRestApi(BaseSupersetModelRestApi): "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } - allowed_rel_fields = {"owners", "created_by"} + allowed_rel_fields = {"owners", "created_by", "changed_by"} @expose("/", methods=("POST",)) @protect() diff --git a/superset/css_templates/api.py b/superset/css_templates/api.py index 25f4d50f30..ac222da66f 100644 --- a/superset/css_templates/api.py +++ b/superset/css_templates/api.py @@ -54,6 +54,10 @@ class CssTemplateRestApi(BaseSupersetModelRestApi): allow_browser_login = True show_columns = [ + "changed_on_delta_humanized", + "changed_by.first_name", + "changed_by.id", + "changed_by.last_name", "created_by.first_name", "created_by.id", "created_by.last_name", @@ -79,7 +83,7 @@ class CssTemplateRestApi(BaseSupersetModelRestApi): order_columns = ["template_name"] search_filters = {"template_name": [CssTemplateAllTextFilter]} - allowed_rel_fields = {"created_by"} + allowed_rel_fields = {"created_by", "changed_by"} apispec_parameter_schemas = { "get_delete_ids_schema": get_delete_ids_schema, diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index be773b83c3..cf75a644fb 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -261,7 +261,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): "roles": RelatedFieldFilter("name", FilterRelatedRoles), "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } - allowed_rel_fields = {"owners", "roles", "created_by"} + allowed_rel_fields = {"owners", "roles", "created_by", "changed_by"} openapi_spec_tag = "Dashboards" """ Override the name set for this collection of endpoints """ diff --git a/superset/databases/api.py b/superset/databases/api.py index df69d9ccd7..8de84a16af 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -111,6 +111,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi): include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.IMPORT, + RouteMethod.RELATED, "tables", "table_metadata", "table_extra_metadata", @@ -162,6 +163,8 @@ class DatabaseRestApi(BaseSupersetModelRestApi): "backend", "changed_on", "changed_on_delta_humanized", + "changed_by.first_name", + "changed_by.last_name", "created_by.first_name", "created_by.last_name", "database_name", @@ -194,7 +197,17 @@ class DatabaseRestApi(BaseSupersetModelRestApi): edit_columns = add_columns + search_columns = [ + "allow_file_upload", + "allow_dml", + "allow_run_async", + "created_by", + "changed_by", + "database_name", + "expose_in_sqllab", + ] search_filters = {"allow_file_upload": [DatabaseUploadEnabledFilter]} + allowed_rel_fields = {"changed_by", "created_by"} list_select_columns = list_columns + ["extra", "sqlalchemy_uri", "password"] order_columns = [ diff --git a/superset/datasets/api.py b/superset/datasets/api.py index e256ff99d6..bc4a42e58e 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -247,8 +247,17 @@ class DatasetRestApi(BaseSupersetModelRestApi): "sql": [DatasetIsNullOrEmptyFilter], "id": [DatasetCertifiedFilter], } - search_columns = ["id", "database", "owners", "schema", "sql", "table_name"] - allowed_rel_fields = {"database", "owners"} + search_columns = [ + "id", + "database", + "owners", + "schema", + "sql", + "table_name", + "created_by", + "changed_by", + ] + allowed_rel_fields = {"database", "owners", "created_by", "changed_by"} allowed_distinct_fields = {"schema"} apispec_parameter_schemas = { diff --git a/superset/queries/saved_queries/api.py b/superset/queries/saved_queries/api.py index 25ac520e45..ce283dd6d6 100644 --- a/superset/queries/saved_queries/api.py +++ b/superset/queries/saved_queries/api.py @@ -82,7 +82,11 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): base_filters = [["id", SavedQueryFilter, lambda: []]] show_columns = [ + "changed_on", "changed_on_delta_humanized", + "changed_by.first_name", + "changed_by.id", + "changed_by.last_name", "created_by.first_name", "created_by.id", "created_by.last_name", @@ -97,7 +101,11 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): "template_parameters", ] list_columns = [ + "changed_on", "changed_on_delta_humanized", + "changed_by.first_name", + "changed_by.id", + "changed_by.last_name", "created_on", "created_by.first_name", "created_by.id", @@ -140,7 +148,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): "last_run_delta_humanized", ] - search_columns = ["id", "database", "label", "schema", "created_by"] + search_columns = ["id", "database", "label", "schema", "created_by", "changed_by"] if is_feature_enabled("TAGGING_SYSTEM"): search_columns += ["tags"] search_filters = { @@ -161,7 +169,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): "database": "database_name", } base_related_field_filters = {"database": [["id", DatabaseFilter, lambda: []]]} - allowed_rel_fields = {"database"} + allowed_rel_fields = {"database", "changed_by", "created_by"} allowed_distinct_fields = {"schema"} def pre_add(self, item: SavedQuery) -> None: diff --git a/superset/reports/api.py b/superset/reports/api.py index ab4f80ae15..8238213fef 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -198,6 +198,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): search_columns = [ "name", "active", + "changed_by", "created_by", "owners", "type", @@ -207,7 +208,14 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): "chart_id", ] search_filters = {"name": [ReportScheduleAllTextFilter]} - allowed_rel_fields = {"owners", "chart", "dashboard", "database", "created_by"} + allowed_rel_fields = { + "owners", + "chart", + "dashboard", + "database", + "created_by", + "changed_by", + } base_related_field_filters = { "chart": [["id", ChartFilter, lambda: []]], diff --git a/superset/row_level_security/api.py b/superset/row_level_security/api.py index e7347f5280..fc505e724f 100644 --- a/superset/row_level_security/api.py +++ b/superset/row_level_security/api.py @@ -77,6 +77,9 @@ class RLSRestApi(BaseSupersetModelRestApi): "roles.name", "clause", "changed_on_delta_humanized", + "changed_by.first_name", + "changed_by.last_name", + "changed_by.id", "group_key", ] order_columns = [ @@ -115,6 +118,8 @@ class RLSRestApi(BaseSupersetModelRestApi): "roles", "group_key", "clause", + "created_by", + "changed_by", ) edit_columns = add_columns @@ -123,7 +128,7 @@ class RLSRestApi(BaseSupersetModelRestApi): add_model_schema = RLSPostSchema() edit_model_schema = RLSPutSchema() - allowed_rel_fields = {"tables", "roles"} + allowed_rel_fields = {"tables", "roles", "created_by", "changed_by"} base_related_field_filters = { "tables": [["id", DatasourceFilter, lambda: []]], "roles": [["id", BaseFilterRelatedRoles, lambda: []]], diff --git a/superset/row_level_security/schemas.py b/superset/row_level_security/schemas.py index 6c8249b875..f02767ec13 100644 --- a/superset/row_level_security/schemas.py +++ b/superset/row_level_security/schemas.py @@ -20,6 +20,7 @@ from marshmallow import fields, Schema from marshmallow.validate import Length, OneOf from superset.connectors.sqla.models import RowLevelSecurityFilter +from superset.dashboards.schemas import UserSchema from superset.utils.core import RowLevelSecurityFilterType id_description = "Unique if of rls filter" @@ -81,6 +82,7 @@ class RLSListSchema(Schema): ) group_key = fields.String(metadata={"description": "group_key_description"}) description = fields.String(metadata={"description": "description_description"}) + changed_by = fields.Nested(UserSchema(exclude=["username"])) class RLSShowSchema(Schema): diff --git a/superset/tags/api.py b/superset/tags/api.py index a3c95a5814..c0df921e3e 100644 --- a/superset/tags/api.py +++ b/superset/tags/api.py @@ -117,7 +117,7 @@ class TagRestApi(BaseSupersetModelRestApi): related_field_filters = { "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } - allowed_rel_fields = {"created_by"} + allowed_rel_fields = {"created_by", "changed_by"} add_model_schema = TagPostSchema() edit_model_schema = TagPutSchema() diff --git a/tests/integration_tests/css_templates/api_tests.py b/tests/integration_tests/css_templates/api_tests.py index b28cca955c..ceb46f553b 100644 --- a/tests/integration_tests/css_templates/api_tests.py +++ b/tests/integration_tests/css_templates/api_tests.py @@ -19,6 +19,8 @@ import json import pytest import prison +from datetime import datetime +from freezegun import freeze_time from sqlalchemy.sql import func import tests.integration_tests.test_app @@ -189,20 +191,27 @@ class TestCssTemplateApi(SupersetTestCase): """ CSS Template API: Test get CSS Template """ - css_template = ( - db.session.query(CssTemplate) - .filter(CssTemplate.template_name == "template_name1") - .one_or_none() - ) - self.login(username="admin") - uri = f"api/v1/css_template/{css_template.id}" - rv = self.get_assert_metric(uri, "get") + with freeze_time(datetime.now()): + css_template = ( + db.session.query(CssTemplate) + .filter(CssTemplate.template_name == "template_name1") + .one_or_none() + ) + self.login(username="admin") + uri = f"api/v1/css_template/{css_template.id}" + rv = self.get_assert_metric(uri, "get") assert rv.status_code == 200 expected_result = { "id": css_template.id, "template_name": "template_name1", "css": "css1", + "changed_by": { + "first_name": css_template.created_by.first_name, + "id": css_template.created_by.id, + "last_name": css_template.created_by.last_name, + }, + "changed_on_delta_humanized": "now", "created_by": { "first_name": css_template.created_by.first_name, "id": css_template.created_by.id, diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index 496012390e..0bc1f245a1 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -197,6 +197,7 @@ class TestDatabaseApi(SupersetTestCase): "allows_subquery", "allows_virtual_table_explore", "backend", + "changed_by", "changed_on", "changed_on_delta_humanized", "created_by", diff --git a/tests/integration_tests/queries/saved_queries/api_tests.py b/tests/integration_tests/queries/saved_queries/api_tests.py index 09929e4d23..c51c0dcbf0 100644 --- a/tests/integration_tests/queries/saved_queries/api_tests.py +++ b/tests/integration_tests/queries/saved_queries/api_tests.py @@ -17,6 +17,7 @@ # isort:skip_file """Unit tests for Superset""" import json +from datetime import datetime from io import BytesIO from typing import Optional from zipfile import is_zipfile, ZipFile @@ -24,6 +25,7 @@ from zipfile import is_zipfile, ZipFile import yaml import pytest import prison +from freezegun import freeze_time from sqlalchemy.sql import func, and_ import tests.integration_tests.test_app @@ -507,14 +509,17 @@ class TestSavedQueryApi(SupersetTestCase): db.session.query(SavedQuery).filter(SavedQuery.label == "label1").all()[0] ) self.login(username="admin") - uri = f"api/v1/saved_query/{saved_query.id}" - rv = self.get_assert_metric(uri, "get") - assert rv.status_code == 200 + with freeze_time(datetime.now()): + uri = f"api/v1/saved_query/{saved_query.id}" + rv = self.get_assert_metric(uri, "get") + assert rv.status_code == 200 expected_result = { "id": saved_query.id, "database": {"id": saved_query.database.id, "database_name": "examples"}, "description": "cool description", + "changed_by": None, + "changed_on_delta_humanized": "now", "created_by": { "first_name": saved_query.created_by.first_name, "id": saved_query.created_by.id, @@ -527,9 +532,8 @@ class TestSavedQueryApi(SupersetTestCase): "template_parameters": None, } data = json.loads(rv.data.decode("utf-8")) - self.assertIn("changed_on_delta_humanized", data["result"]) for key, value in data["result"].items(): - if key not in ("changed_on_delta_humanized",): + if key != "changed_on": assert value == expected_result[key] def test_get_saved_query_not_found(self):