From c9c31afd8d88d7f0c05831bcd8788a5629e2c1e0 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 18 Apr 2023 09:11:33 -0400 Subject: [PATCH 1/7] chore(slo): improve slo list summary alignment (#154932) --- .../observability/public/data/slo/common.ts | 16 ++++----- .../pages/slos/components/slo_sparkline.tsx | 20 ++++++++--- .../pages/slos/components/slo_summary.tsx | 35 ++++++++++++++----- .../kibana_react.storybook_decorator.tsx | 1 + 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/observability/public/data/slo/common.ts b/x-pack/plugins/observability/public/data/slo/common.ts index 3c0e1b7e49408..ae25d150f350b 100644 --- a/x-pack/plugins/observability/public/data/slo/common.ts +++ b/x-pack/plugins/observability/public/data/slo/common.ts @@ -35,8 +35,8 @@ export const buildHealthySummary = ( sliValue: 0.99872, errorBudget: { initial: 0.02, - consumed: 0.064, - remaining: 0.936, + consumed: 0.0642, + remaining: 0.93623, isEstimated: false, }, ...params, @@ -48,11 +48,11 @@ export const buildViolatedSummary = ( ): SLOWithSummaryResponse['summary'] => { return { status: 'VIOLATED', - sliValue: 0.97, + sliValue: 0.81232, errorBudget: { initial: 0.02, consumed: 1, - remaining: 0, + remaining: -3.1234, isEstimated: false, }, ...params, @@ -80,11 +80,11 @@ export const buildDegradingSummary = ( ): SLOWithSummaryResponse['summary'] => { return { status: 'DEGRADING', - sliValue: 0.97, + sliValue: 0.97982, errorBudget: { - initial: 0.02, - consumed: 0.88, - remaining: 0.12, + initial: 0.01, + consumed: 0.8822, + remaining: 0.1244, isEstimated: true, }, ...params, diff --git a/x-pack/plugins/observability/public/pages/slos/components/slo_sparkline.tsx b/x-pack/plugins/observability/public/pages/slos/components/slo_sparkline.tsx index 4f34ea594ddee..8b3586554abf8 100644 --- a/x-pack/plugins/observability/public/pages/slos/components/slo_sparkline.tsx +++ b/x-pack/plugins/observability/public/pages/slos/components/slo_sparkline.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { AreaSeries, Chart, Fit, LineSeries, ScaleType, Settings } from '@elastic/charts'; +import { AreaSeries, Axis, Chart, Fit, LineSeries, ScaleType, Settings } from '@elastic/charts'; import React from 'react'; import { EuiLoadingChart, useEuiTheme } from '@elastic/eui'; import { EUI_SPARKLINE_THEME_PARTIAL } from '@elastic/eui/dist/eui_charts_theme'; @@ -36,28 +36,38 @@ export function SloSparkline({ chart, data, id, isLoading, state }: Props) { const color = state === 'error' ? euiTheme.colors.danger : euiTheme.colors.success; const ChartComponent = chart === 'area' ? AreaSeries : LineSeries; + const LineAxisComponent = + chart === 'line' ? ( + + ) : null; if (isLoading) { return ; } return ( - + + {LineAxisComponent} - - - + + + + - - - + + + { if (setting === 'dateFormat') { From 5b1b15af7a9cd478652d996f5e1648ea5cfd4f43 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Tue, 18 Apr 2023 15:17:49 +0200 Subject: [PATCH 2/7] [ML] AIOps: Fix race condition where stale url state would reset search bar. (#154885) Fixes an issue there the global state `_g` and app state `_a` would get out of sync and overwrite each other. For example, a click on Refresh in the date picker (global state) could reset the search bar (app state) to empty. The issue was that in `x-pack/packages/ml/url_state/src/url_state.tsx` the `searchString` could become a stale value in `setUrlState`. This PR fixes it by using the approach already used in `usePageUrlState`: The `searchString` is passed on to be stored via `useRef` so that the `setUrlState` setter can always access the most recent value. --- .../ml/url_state/src/url_state.test.tsx | 118 +++++++++++++----- .../packages/ml/url_state/src/url_state.tsx | 15 ++- x-pack/plugins/aiops/public/hooks/use_data.ts | 52 ++++---- .../apps/aiops/explain_log_rate_spikes.ts | 50 +++++--- .../test/functional/apps/aiops/test_data.ts | 21 +++- x-pack/test/functional/apps/aiops/types.ts | 43 +++++-- .../aiops/explain_log_rate_spikes_page.ts | 32 ++++- 7 files changed, 236 insertions(+), 95 deletions(-) diff --git a/x-pack/packages/ml/url_state/src/url_state.test.tsx b/x-pack/packages/ml/url_state/src/url_state.test.tsx index 734c730dd91ba..033ecd77fadf4 100644 --- a/x-pack/packages/ml/url_state/src/url_state.test.tsx +++ b/x-pack/packages/ml/url_state/src/url_state.test.tsx @@ -5,29 +5,18 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { useEffect, type FC } from 'react'; import { render, act } from '@testing-library/react'; -import { parseUrlState, useUrlState, UrlStateProvider } from './url_state'; +import { MemoryRouter } from 'react-router-dom'; -const mockHistoryPush = jest.fn(); +import { parseUrlState, useUrlState, UrlStateProvider } from './url_state'; -jest.mock('react-router-dom', () => ({ - useHistory: () => ({ - push: mockHistoryPush, - }), - useLocation: () => ({ - search: - "?_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFieldName:action),query:(query_string:(analyze_wildcard:!t,query:'*')))&_g=(ml:(jobIds:!(dec-2)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2019-01-01T00:03:40.000Z',mode:absolute,to:'2019-08-30T11:55:07.000Z'))&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d", - }), -})); +const mockHistoryInitialState = + "?_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFieldName:action),query:(query_string:(analyze_wildcard:!t,query:'*')))&_g=(ml:(jobIds:!(dec-2)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2019-01-01T00:03:40.000Z',mode:absolute,to:'2019-08-30T11:55:07.000Z'))&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d"; describe('getUrlState', () => { test('properly decode url with _g and _a', () => { - expect( - parseUrlState( - "?_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFieldName:action),query:(query_string:(analyze_wildcard:!t,query:'*')))&_g=(ml:(jobIds:!(dec-2)),refreshInterval:(display:Off,pause:!t,value:0),time:(from:'2019-01-01T00:03:40.000Z',mode:absolute,to:'2019-08-30T11:55:07.000Z'))&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d" - ) - ).toEqual({ + expect(parseUrlState(mockHistoryInitialState)).toEqual({ _a: { mlExplorerFilter: {}, mlExplorerSwimlane: { @@ -46,7 +35,7 @@ describe('getUrlState', () => { }, refreshInterval: { display: 'Off', - pause: true, + pause: false, value: 0, }, time: { @@ -61,29 +50,96 @@ describe('getUrlState', () => { }); describe('useUrlState', () => { - beforeEach(() => { - mockHistoryPush.mockClear(); + it('pushes a properly encoded search string to history', () => { + const TestComponent: FC = () => { + const [appState, setAppState] = useUrlState('_a'); + + useEffect(() => { + setAppState(parseUrlState(mockHistoryInitialState)._a); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +
{JSON.stringify(appState?.query)}
+ + ); + }; + + const { getByText, getByTestId } = render( + + + + + + ); + + expect(getByTestId('appState').innerHTML).toBe( + '{"query_string":{"analyze_wildcard":true,"query":"*"}}' + ); + + act(() => { + getByText('ButtonText').click(); + }); + + expect(getByTestId('appState').innerHTML).toBe('"my-query"'); }); - test('pushes a properly encoded search string to history', () => { + it('updates both _g and _a state successfully', () => { const TestComponent: FC = () => { - const [, setUrlState] = useUrlState('_a'); - return ; + const [globalState, setGlobalState] = useUrlState('_g'); + const [appState, setAppState] = useUrlState('_a'); + + useEffect(() => { + setGlobalState({ time: 'initial time' }); + setAppState({ query: 'initial query' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + +
{globalState?.time}
+
{appState?.query}
+ + ); }; - const { getByText } = render( - - - + const { getByText, getByTestId } = render( + + + + + ); + expect(getByTestId('globalState').innerHTML).toBe('initial time'); + expect(getByTestId('appState').innerHTML).toBe('initial query'); + act(() => { - getByText('ButtonText').click(); + getByText('GlobalStateButton1').click(); }); - expect(mockHistoryPush).toHaveBeenCalledWith({ - search: - '_a=%28mlExplorerFilter%3A%28%29%2CmlExplorerSwimlane%3A%28viewByFieldName%3Aaction%29%2Cquery%3A%28%29%29&_g=%28ml%3A%28jobIds%3A%21%28dec-2%29%29%2CrefreshInterval%3A%28display%3AOff%2Cpause%3A%21f%2Cvalue%3A0%29%2Ctime%3A%28from%3A%272019-01-01T00%3A03%3A40.000Z%27%2Cmode%3Aabsolute%2Cto%3A%272019-08-30T11%3A55%3A07.000Z%27%29%29&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d', + expect(getByTestId('globalState').innerHTML).toBe('now-15m'); + expect(getByTestId('appState').innerHTML).toBe('initial query'); + + act(() => { + getByText('AppStateButton').click(); + }); + + expect(getByTestId('globalState').innerHTML).toBe('now-15m'); + expect(getByTestId('appState').innerHTML).toBe('the updated query'); + + act(() => { + getByText('GlobalStateButton2').click(); }); + + expect(getByTestId('globalState').innerHTML).toBe('now-5y'); + expect(getByTestId('appState').innerHTML).toBe('the updated query'); }); }); diff --git a/x-pack/packages/ml/url_state/src/url_state.tsx b/x-pack/packages/ml/url_state/src/url_state.tsx index d643a22bde6e4..bd62e1f61029a 100644 --- a/x-pack/packages/ml/url_state/src/url_state.tsx +++ b/x-pack/packages/ml/url_state/src/url_state.tsx @@ -94,6 +94,12 @@ export const UrlStateProvider: FC = ({ children }) => { const history = useHistory(); const { search: searchString } = useLocation(); + const searchStringRef = useRef(searchString); + + useEffect(() => { + searchStringRef.current = searchString; + }, [searchString]); + const setUrlState: SetUrlState = useCallback( ( accessor: Accessor, @@ -101,7 +107,8 @@ export const UrlStateProvider: FC = ({ children }) => { value?: any, replaceState?: boolean ) => { - const prevSearchString = searchString; + const prevSearchString = searchStringRef.current; + const urlState = parseUrlState(prevSearchString); const parsedQueryString = parse(prevSearchString, { sort: false }); @@ -142,6 +149,10 @@ export const UrlStateProvider: FC = ({ children }) => { if (oldLocationSearchString !== newLocationSearchString) { const newSearchString = stringify(parsedQueryString, { sort: false }); + // Another `setUrlState` call could happen before the updated + // `searchString` gets propagated via `useLocation` therefore + // we update the ref right away too. + searchStringRef.current = newSearchString; if (replaceState) { history.replace({ search: newSearchString }); } else { @@ -154,7 +165,7 @@ export const UrlStateProvider: FC = ({ children }) => { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [searchString] + [] ); return {children}; diff --git a/x-pack/plugins/aiops/public/hooks/use_data.ts b/x-pack/plugins/aiops/public/hooks/use_data.ts index 3546cead3edab..82db675a94c4f 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data.ts @@ -45,9 +45,6 @@ export const useData = ( } = useAiopsAppContext(); const [lastRefresh, setLastRefresh] = useState(0); - const [fieldStatsRequest, setFieldStatsRequest] = useState< - DocumentStatsSearchStrategyParams | undefined - >(); /** Prepare required params to pass to search strategy **/ const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => { @@ -91,12 +88,30 @@ export const useData = ( ]); const _timeBuckets = useTimeBuckets(); - const timefilter = useTimefilter({ timeRangeSelector: selectedDataView?.timeFieldName !== undefined, autoRefreshSelector: true, }); + const fieldStatsRequest: DocumentStatsSearchStrategyParams | undefined = useMemo(() => { + const timefilterActiveBounds = timefilter.getActiveBounds(); + if (timefilterActiveBounds !== undefined) { + _timeBuckets.setInterval('auto'); + _timeBuckets.setBounds(timefilterActiveBounds); + _timeBuckets.setBarTarget(barTarget); + return { + earliest: timefilterActiveBounds.min?.valueOf(), + latest: timefilterActiveBounds.max?.valueOf(), + intervalMs: _timeBuckets.getInterval()?.asMilliseconds(), + index: selectedDataView.getIndexPattern(), + searchQuery, + timeFieldName: selectedDataView.timeFieldName, + runtimeFieldMap: selectedDataView.getRuntimeMappings(), + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastRefresh, searchQuery]); + const overallStatsRequest = useMemo(() => { return fieldStatsRequest ? { @@ -125,25 +140,6 @@ export const useData = ( lastRefresh ); - function updateFieldStatsRequest() { - const timefilterActiveBounds = timefilter.getActiveBounds(); - if (timefilterActiveBounds !== undefined) { - _timeBuckets.setInterval('auto'); - _timeBuckets.setBounds(timefilterActiveBounds); - _timeBuckets.setBarTarget(barTarget); - setFieldStatsRequest({ - earliest: timefilterActiveBounds.min?.valueOf(), - latest: timefilterActiveBounds.max?.valueOf(), - intervalMs: _timeBuckets.getInterval()?.asMilliseconds(), - index: selectedDataView.getIndexPattern(), - searchQuery, - timeFieldName: selectedDataView.timeFieldName, - runtimeFieldMap: selectedDataView.getRuntimeMappings(), - }); - setLastRefresh(Date.now()); - } - } - useEffect(() => { const timefilterUpdateSubscription = merge( timefilter.getAutoRefreshFetch$(), @@ -156,13 +152,13 @@ export const useData = ( refreshInterval: timefilter.getRefreshInterval(), }); } - updateFieldStatsRequest(); + setLastRefresh(Date.now()); }); // This listens just for an initial update of the timefilter to be switched on. const timefilterEnabledSubscription = timefilter.getEnabledUpdated$().subscribe(() => { if (fieldStatsRequest === undefined) { - updateFieldStatsRequest(); + setLastRefresh(Date.now()); } }); @@ -173,12 +169,6 @@ export const useData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Ensure request is updated when search changes - useEffect(() => { - updateFieldStatsRequest(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchString, JSON.stringify(searchQuery)]); - return { documentStats, timefilter, diff --git a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts b/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts index d34169b05a408..92320dad62087 100644 --- a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts +++ b/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts @@ -10,7 +10,7 @@ import { orderBy } from 'lodash'; import expect from '@kbn/expect'; import type { FtrProviderContext } from '../../ftr_provider_context'; -import type { TestData } from './types'; +import { isTestDataExpectedWithSampleProbability, type TestData } from './types'; import { explainLogRateSpikesTestData } from './test_data'; export default function ({ getPageObject, getService }: FtrProviderContext) { @@ -43,9 +43,21 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await aiops.explainLogRateSpikesPage.assertTimeRangeSelectorSectionExists(); await ml.testExecution.logTestStep(`${testData.suiteTitle} loads data for full time range`); + if (testData.query) { + await aiops.explainLogRateSpikesPage.setQueryInput(testData.query); + } await aiops.explainLogRateSpikesPage.clickUseFullDataButton( testData.expected.totalDocCountFormatted ); + + if (isTestDataExpectedWithSampleProbability(testData.expected)) { + await aiops.explainLogRateSpikesPage.assertSamplingProbability( + testData.expected.sampleProbabilityFormatted + ); + } else { + await aiops.explainLogRateSpikesPage.assertSamplingProbabilityMissing(); + } + await headerPage.waitUntilLoadingHasFinished(); await ml.testExecution.logTestStep( @@ -147,21 +159,24 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await aiops.explainLogRateSpikesAnalysisGroupsTable.assertSpikeAnalysisTableExists(); - const analysisGroupsTable = - await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable(); - - expect(orderBy(analysisGroupsTable, 'group')).to.be.eql( - orderBy(testData.expected.analysisGroupsTable, 'group') - ); + if (!isTestDataExpectedWithSampleProbability(testData.expected)) { + const analysisGroupsTable = + await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable(); + expect(orderBy(analysisGroupsTable, 'group')).to.be.eql( + orderBy(testData.expected.analysisGroupsTable, 'group') + ); + } await ml.testExecution.logTestStep('expand table row'); await aiops.explainLogRateSpikesAnalysisGroupsTable.assertExpandRowButtonExists(); await aiops.explainLogRateSpikesAnalysisGroupsTable.expandRow(); - const analysisTable = await aiops.explainLogRateSpikesAnalysisTable.parseAnalysisTable(); - expect(orderBy(analysisTable, ['fieldName', 'fieldValue'])).to.be.eql( - orderBy(testData.expected.analysisTable, ['fieldName', 'fieldValue']) - ); + if (!isTestDataExpectedWithSampleProbability(testData.expected)) { + const analysisTable = await aiops.explainLogRateSpikesAnalysisTable.parseAnalysisTable(); + expect(orderBy(analysisTable, ['fieldName', 'fieldValue'])).to.be.eql( + orderBy(testData.expected.analysisTable, ['fieldName', 'fieldValue']) + ); + } // Assert the field selector that allows to costumize grouping await aiops.explainLogRateSpikesPage.assertFieldFilterPopoverButtonExists(false); @@ -182,11 +197,14 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { if (testData.fieldSelectorApplyAvailable) { await aiops.explainLogRateSpikesPage.clickFieldFilterApplyButton(); - const filteredAnalysisGroupsTable = - await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable(); - expect(orderBy(filteredAnalysisGroupsTable, 'group')).to.be.eql( - orderBy(testData.expected.filteredAnalysisGroupsTable, 'group') - ); + + if (!isTestDataExpectedWithSampleProbability(testData.expected)) { + const filteredAnalysisGroupsTable = + await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable(); + expect(orderBy(filteredAnalysisGroupsTable, 'group')).to.be.eql( + orderBy(testData.expected.filteredAnalysisGroupsTable, 'group') + ); + } } }); } diff --git a/x-pack/test/functional/apps/aiops/test_data.ts b/x-pack/test/functional/apps/aiops/test_data.ts index 1ccc441618bdb..b6d3293aeba81 100644 --- a/x-pack/test/functional/apps/aiops/test_data.ts +++ b/x-pack/test/functional/apps/aiops/test_data.ts @@ -19,6 +19,24 @@ export const farequoteDataViewTestData: TestData = { fieldSelectorApplyAvailable: false, expected: { totalDocCountFormatted: '86,374', + sampleProbabilityFormatted: '0.5', + fieldSelectorPopover: ['airline', 'custom_field.keyword'], + }, +}; + +export const farequoteDataViewTestDataWithQuery: TestData = { + suiteTitle: 'farequote with spike', + dataGenerator: 'farequote_with_spike', + isSavedSearch: false, + sourceIndexOrSavedSearch: 'ft_farequote', + brushDeviationTargetTimestamp: 1455033600000, + brushIntervalFactor: 1, + chartClickCoordinates: [0, 0], + fieldSelectorSearch: 'airline', + fieldSelectorApplyAvailable: false, + query: 'NOT airline:("SWR" OR "ACA" OR "AWE" OR "BAW" OR "JAL" OR "JBU" OR "JZA" OR "KLM")', + expected: { + totalDocCountFormatted: '48,799', analysisGroupsTable: [ { docCount: '297', @@ -34,7 +52,7 @@ export const farequoteDataViewTestData: TestData = { fieldName: 'airline', fieldValue: 'AAL', logRate: 'Chart type:bar chart', - pValue: '4.66e-11', + pValue: '1.18e-8', impact: 'High', }, ], @@ -105,5 +123,6 @@ export const artificialLogDataViewTestData: TestData = { export const explainLogRateSpikesTestData: TestData[] = [ farequoteDataViewTestData, + farequoteDataViewTestDataWithQuery, artificialLogDataViewTestData, ]; diff --git a/x-pack/test/functional/apps/aiops/types.ts b/x-pack/test/functional/apps/aiops/types.ts index 7a758aa4a65ff..01733a8e1a2af 100644 --- a/x-pack/test/functional/apps/aiops/types.ts +++ b/x-pack/test/functional/apps/aiops/types.ts @@ -5,6 +5,34 @@ * 2.0. */ +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; + +interface TestDataExpectedWithSampleProbability { + totalDocCountFormatted: string; + sampleProbabilityFormatted: string; + fieldSelectorPopover: string[]; +} + +export function isTestDataExpectedWithSampleProbability( + arg: unknown +): arg is TestDataExpectedWithSampleProbability { + return isPopulatedObject(arg, ['sampleProbabilityFormatted']); +} + +interface TestDataExpectedWithoutSampleProbability { + totalDocCountFormatted: string; + analysisGroupsTable: Array<{ group: string; docCount: string }>; + filteredAnalysisGroupsTable?: Array<{ group: string; docCount: string }>; + analysisTable: Array<{ + fieldName: string; + fieldValue: string; + logRate: string; + pValue: string; + impact: string; + }>; + fieldSelectorPopover: string[]; +} + export interface TestData { suiteTitle: string; dataGenerator: string; @@ -17,17 +45,6 @@ export interface TestData { chartClickCoordinates: [number, number]; fieldSelectorSearch: string; fieldSelectorApplyAvailable: boolean; - expected: { - totalDocCountFormatted: string; - analysisGroupsTable: Array<{ group: string; docCount: string }>; - filteredAnalysisGroupsTable?: Array<{ group: string; docCount: string }>; - analysisTable: Array<{ - fieldName: string; - fieldValue: string; - logRate: string; - pValue: string; - impact: string; - }>; - fieldSelectorPopover: string[]; - }; + query?: string; + expected: TestDataExpectedWithSampleProbability | TestDataExpectedWithoutSampleProbability; } diff --git a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts index 3a921a74ee359..3da9ed7c760b7 100644 --- a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts +++ b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts @@ -9,12 +9,16 @@ import expect from '@kbn/expect'; import type { FtrProviderContext } from '../../ftr_provider_context'; -export function ExplainLogRateSpikesPageProvider({ getService }: FtrProviderContext) { +export function ExplainLogRateSpikesPageProvider({ + getService, + getPageObject, +}: FtrProviderContext) { const browser = getService('browser'); const elasticChart = getService('elasticChart'); const ml = getService('ml'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const header = getPageObject('header'); return { async assertTimeRangeSelectorSectionExists() { @@ -31,6 +35,32 @@ export function ExplainLogRateSpikesPageProvider({ getService }: FtrProviderCont }); }, + async assertSamplingProbability(expectedFormattedSamplingProbability: string) { + await retry.tryForTime(5000, async () => { + const samplingProbability = await testSubjects.getVisibleText('aiopsSamplingProbability'); + expect(samplingProbability).to.eql( + expectedFormattedSamplingProbability, + `Expected total document count to be '${expectedFormattedSamplingProbability}' (got '${samplingProbability}')` + ); + }); + }, + + async setQueryInput(query: string) { + const aiopsQueryInput = await testSubjects.find('aiopsQueryInput'); + await aiopsQueryInput.type(query); + await aiopsQueryInput.pressKeys(browser.keys.ENTER); + await header.waitUntilLoadingHasFinished(); + const queryBarText = await aiopsQueryInput.getVisibleText(); + expect(queryBarText).to.eql( + query, + `Expected query bar text to be '${query}' (got '${queryBarText}')` + ); + }, + + async assertSamplingProbabilityMissing() { + await testSubjects.missingOrFail('aiopsSamplingProbability'); + }, + async clickUseFullDataButton(expectedFormattedTotalDocCount: string) { await retry.tryForTime(30 * 1000, async () => { await testSubjects.clickWhenNotDisabledWithoutRetry('mlDatePickerButtonUseFullData'); From ebe278490f43152e93f7bf300a1cf65878504891 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 18 Apr 2023 07:33:15 -0600 Subject: [PATCH 3/7] [reporting] show loading state when creating a reporting job (#154939) Screen Shot 2023-04-13 at 12 04 26 PM ### Steps to test * Load your favorite sample data set and open its dashboard * Click "Share" and then click "PDF Reports" * Open browser devtools and open network tab. Turn on network throttling to better see loading state * Click "Generate PDF". Notice how button now gives feedback its clicked and something is happening. Before, button would not show loading state and users are confused into thinking nothing is happening. --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../reporting_panel_content.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx index 9102802f5d79e..6ac0e374b3919 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx @@ -62,6 +62,7 @@ interface State { absoluteUrl: string; layoutId: string; objectType: string; + isCreatingReportJob: boolean; } class ReportingPanelContentUi extends Component { @@ -78,6 +79,7 @@ class ReportingPanelContentUi extends Component { absoluteUrl: this.getAbsoluteReportGenerationUrl(props), layoutId: '', objectType, + isCreatingReportJob: false, }; } @@ -227,12 +229,13 @@ class ReportingPanelContentUi extends Component { private renderGenerateReportButton = (isDisabled: boolean) => { return ( { this.props.getJobParams() ); + this.setState({ isCreatingReportJob: true }); + return this.props.apiClient .createReportingJob(this.props.reportType, decoratedJobParams) .then(() => { @@ -313,6 +318,9 @@ class ReportingPanelContentUi extends Component { if (this.props.onClose) { this.props.onClose(); } + if (this.mounted) { + this.setState({ isCreatingReportJob: false }); + } }) .catch((error) => { this.props.toasts.addError(error, { @@ -325,6 +333,9 @@ class ReportingPanelContentUi extends Component { ) as unknown as string, }); + if (this.mounted) { + this.setState({ isCreatingReportJob: false }); + } }); }; } From 55b9fd2353b487a3db3997d4313c564e3f3b9853 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 18 Apr 2023 15:39:29 +0200 Subject: [PATCH 4/7] [Dev docs] Added final section to HTTP versioning tutorial (#154901) ## Summary Adds the final section to the HTTP versioning tutorial about using the route versioning specification. --- dev_docs/tutorials/versioning_http_apis.mdx | 117 ++++++++++++++++++-- 1 file changed, 110 insertions(+), 7 deletions(-) diff --git a/dev_docs/tutorials/versioning_http_apis.mdx b/dev_docs/tutorials/versioning_http_apis.mdx index c8a3625fb4977..81bfed4f4dc4e 100644 --- a/dev_docs/tutorials/versioning_http_apis.mdx +++ b/dev_docs/tutorials/versioning_http_apis.mdx @@ -41,7 +41,7 @@ router.get( ); ``` -#### Why is this problemetic for versioning? +#### Why is this problematic for versioning? Whenever we perform a data migration the body of this endpoint will change for all clients. This prevents us from being able to maintain past interfaces and gracefully introduce new ones. @@ -119,7 +119,7 @@ router.post( } ); ``` -#### Why is this problemetic for versioning? +#### Why is this problematic for versioning? This HTTP API currently accepts all numbers and strings as input which allows for unexpected inputs like negative numbers or non-URL friendly characters. This may break future migrations or integrations that assume your data will always be within certain parameters. @@ -141,7 +141,7 @@ This HTTP API currently accepts all numbers and strings as input which allows fo Adding this validation we negate the risk of unexpected values. It is not necessary to use `@kbn/config-schema`, as long as your validation mechanism provides finer grained controls than "number" or "string". -In summary: think about the acceptable paramaters for every input your HTTP API expects. +In summary: think about the acceptable parameters for every input your HTTP API expects. ### 3. Keep interfaces as "narrow" as possible @@ -170,7 +170,7 @@ router.get( The above code follows guidelines from steps 1 and 2, but it allows clients to specify ANY string by which to sort. This is a far "wider" API than we need for this endpoint. -#### Why is this problemetic for versioning? +#### Why is this problematic for versioning? Without telemetry it is impossible to know what values clients might be passing through — and what type of sort behaviour they are expecting. @@ -207,9 +207,112 @@ router.get( The changes are: -1. New input validation accepts a known set of values. This makes our HTTP API far _narrower_ and specific to our use case. It does not matter that our `sortSchema` has the same values as our persistence schema, what matters is that we created a **translation layer** between our HTTP API and our internal schema. This faclitates easily versioning this endpoint. +1. New input validation accepts a known set of values. This makes our HTTP API far _narrower_ and specific to our use case. It does not matter that our `sortSchema` has the same values as our persistence schema, what matters is that we created a **translation layer** between our HTTP API and our internal schema. This facilitates easily versioning this endpoint. 2. **Bonus point**: we use the `escapeKuery` utility to defend against KQL injection attacks. -### 4. Use the versioned API spec +### 4. Adhere to the HTTP versioning specification + +#### Choosing the right version + +##### Public endpoints +Public endpoints include any endpoint that is intended for users to directly integrate with via HTTP. + +Choose a date string in the format `YYYY-MM-DD`. This date should be the date that a (group) of APIs was made available. + +##### Internal endpoints +Internal endpoints are all non-public endpoints (see definition above). + +If you need to maintain backwards-compatibility for an internal endpoint use a single, larger-than-zero number. Ex. `1`. + + +#### Use the versioned router + +Core exposes a versioned router that ensures your endpoint's behaviour and formatting all conforms to the versioning specification. + +```typescript + router.versioned. + .post({ + access: 'public', // This endpoint is intended for a public audience + path: '/api/my-app/foo/{id?}', + options: { timeout: { payload: 60000 } }, + }) + .addVersion( + { + version: '2023-01-01', // The public version of this API + validate: { + request: { + query: schema.object({ + name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), + }), + params: schema.object({ + id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), + }), + body: schema.object({ foo: schema.string() }), + }, + response: { + 200: { // In development environments, this validation will run against 200 responses + body: schema.object({ foo: schema.string() }), + }, + }, + }, + }, + async (ctx, req, res) => { + await ctx.fooService.create(req.body.foo, req.params.id, req.query.name); + return res.ok({ body: { foo: req.body.foo } }); + } + ) + // BREAKING CHANGE: { foo: string } => { fooString: string } in response body + .addVersion( + { + version: '2023-02-01', + validate: { + request: { + query: schema.object({ + name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), + }), + params: schema.object({ + id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), + }), + body: schema.object({ fooString: schema.string() }), + }, + response: { + 200: { + body: schema.object({ fooName: schema.string() }), + }, + }, + }, + }, + async (ctx, req, res) => { + await ctx.fooService.create(req.body.fooString, req.params.id, req.query.name); + return res.ok({ body: { fooName: req.body.fooString } }); + } + ) + // BREAKING CHANGES: Enforce min/max length on fooString + .addVersion( + { + version: '2023-03-01', + validate: { + request: { + query: schema.object({ + name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), + }), + params: schema.object({ + id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), + }), + body: schema.object({ fooString: schema.string({ minLength: 0, maxLength: 1000 }) }), + }, + response: { + 200: { + body: schema.object({ fooName: schema.string() }), + }, + }, + }, + }, + async (ctx, req, res) => { + await ctx.fooService.create(req.body.fooString, req.params.id, req.query.name); + return res.ok({ body: { fooName: req.body.fooString } }); + } +``` -_Under construction, check back here soon!_ \ No newline at end of file +#### Additional reading +For a more details on the versioning specification see [this document](https://docs.google.com/document/d/1YpF6hXIHZaHvwNaQAxWFzexUF1nbqACTtH2IfDu0ldA/edit?usp=sharing). \ No newline at end of file From b81a2705dfae1b65fef7ad18c0cb470ad83fbf6b Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 18 Apr 2023 15:54:11 +0200 Subject: [PATCH 5/7] [Synthetics] Add tooltip for viewer user for edit actions (#155134) --- .../common/components/permissions.tsx | 2 +- .../management/monitor_list_table/columns.tsx | 48 ++++++++++++++----- .../monitor_list_table/monitor_list.tsx | 3 -- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/permissions.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/permissions.tsx index 97a9ac3e62ce3..17580afbf2cf4 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/permissions.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/permissions.tsx @@ -100,7 +100,7 @@ const CANNOT_PERFORM_ACTION_FLEET = i18n.translate( } ); -const CANNOT_PERFORM_ACTION_SYNTHETICS = i18n.translate( +export const CANNOT_PERFORM_ACTION_SYNTHETICS = i18n.translate( 'xpack.synthetics.monitorManagement.noSyntheticsPermissions', { defaultMessage: 'You do not have sufficient permissions to perform this action.', diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx index dd56da1e7e563..074695f26c148 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx @@ -5,16 +5,20 @@ * 2.0. */ -import { EuiBasicTableColumn } from '@elastic/eui'; +import { EuiBasicTableColumn, EuiButtonIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useHistory } from 'react-router-dom'; import { FETCH_STATUS } from '@kbn/observability-plugin/public'; +import { useCanEditSynthetics } from '../../../../../../hooks/use_capabilities'; import { isStatusEnabled, toggleStatusAlert, } from '../../../../../../../common/runtime_types/monitor_management/alert_config'; -import { NoPermissionsTooltip } from '../../../common/components/permissions'; +import { + CANNOT_PERFORM_ACTION_SYNTHETICS, + NoPermissionsTooltip, +} from '../../../common/components/permissions'; import { TagsBadges } from '../../../common/components/tag_badges'; import { useMonitorAlertEnable } from '../../../../hooks/use_monitor_alert_enable'; import * as labels from './labels'; @@ -35,17 +39,16 @@ import { MonitorEnabled } from './monitor_enabled'; import { MonitorLocations } from './monitor_locations'; export function useMonitorListColumns({ - canEditSynthetics, loading, overviewStatus, setMonitorPendingDeletion, }: { - canEditSynthetics: boolean; loading: boolean; overviewStatus: OverviewStatusState | null; setMonitorPendingDeletion: (config: EncryptedSyntheticsSavedMonitor) => void; }): Array> { const history = useHistory(); + const canEditSynthetics = useCanEditSynthetics(); const { alertStatus, updateAlertEnabledState } = useMonitorAlertEnable(); const { canSaveIntegrations } = useFleetPermissions(); @@ -54,7 +57,7 @@ export function useMonitorListColumns({ return alertStatus(fields[ConfigKey.CONFIG_ID]) === FETCH_STATUS.LOADING; }; - return [ + const columns: Array> = [ { align: 'left' as const, field: ConfigKey.NAME as string, @@ -173,8 +176,8 @@ export function useMonitorListColumns({ ), description: labels.EDIT_LABEL, - icon: 'pencil', - type: 'icon', + icon: 'pencil' as const, + type: 'icon' as const, enabled: (fields) => canEditSynthetics && !isActionLoading(fields) && @@ -197,9 +200,9 @@ export function useMonitorListColumns({ ), description: labels.DELETE_LABEL, - icon: 'trash', - type: 'icon', - color: 'danger', + icon: 'trash' as const, + type: 'icon' as const, + color: 'danger' as const, enabled: (fields) => canEditSynthetics && !isActionLoading(fields) && @@ -216,8 +219,8 @@ export function useMonitorListColumns({ : labels.ENABLE_STATUS_ALERT, icon: (fields) => isStatusEnabled(fields[ConfigKey.ALERT_CONFIG]) ? 'bellSlash' : 'bell', - type: 'icon', - color: 'danger', + type: 'icon' as const, + color: 'danger' as const, enabled: (fields) => canEditSynthetics && !isActionLoading(fields), onClick: (fields) => { updateAlertEnabledState({ @@ -240,4 +243,25 @@ export function useMonitorListColumns({ ], }, ]; + + if (!canEditSynthetics) { + // replace last column with a tooltip + columns[columns.length - 1] = { + align: 'right' as const, + name: i18n.translate('xpack.synthetics.management.monitorList.actions', { + defaultMessage: 'Actions', + }), + render: () => ( + + + + ), + }; + } + + return columns; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx index 2ac8c0f6129d6..55f2d7e80ee1a 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx @@ -19,7 +19,6 @@ import { i18n } from '@kbn/i18n'; import { DeleteMonitor } from './delete_monitor'; import { IHttpSerializedFetchError } from '../../../../state/utils/http_error'; import { MonitorListPageState } from '../../../../state'; -import { useCanEditSynthetics } from '../../../../../../hooks/use_capabilities'; import { ConfigKey, EncryptedSyntheticsSavedMonitor, @@ -52,7 +51,6 @@ export const MonitorList = ({ }: Props) => { const { euiTheme } = useEuiTheme(); const isXl = useIsWithinMinBreakpoint('xxl'); - const canEditSynthetics = useCanEditSynthetics(); const [monitorPendingDeletion, setMonitorPendingDeletion] = useState(null); @@ -96,7 +94,6 @@ export const MonitorList = ({ }); const columns = useMonitorListColumns({ - canEditSynthetics, loading, overviewStatus, setMonitorPendingDeletion, From 0a38f85002d8f9096bcd35c073f0faebcff9617d Mon Sep 17 00:00:00 2001 From: Antonio Date: Tue, 18 Apr 2023 16:02:11 +0200 Subject: [PATCH 6/7] [Cases] Attaching files to cases (#154436) Fixes #151595 ## Summary In this PR we will be merging a feature branch into `main`. This feature branch is a collection of several different PRs with file functionality for cases. - https://github.com/elastic/kibana/pull/152941 - https://github.com/elastic/kibana/pull/153957 - https://github.com/elastic/kibana/pull/154432 - https://github.com/elastic/kibana/pull/153853 Most of the code was already reviewed so this will mainly be used for testing. - Files tab in the case detail view. - Attach files to a case. - View a list of all files attached to a case (with pagination). - Preview image files attached to a case. - Search for files attached to a case by file name. - Download files attached to a case. - Users are now able to see file activity in the case detail view. - Image files have a different icon and a clickable file name to preview. - Other files have a standard "document" icon and the name is not clickable. - The file can be downloaded by clicking the download icon. ## Release notes Support file attachments in Cases. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-optimizer/limits.yml | 2 +- .../file/file_upload/impl/src/file_upload.tsx | 2 +- .../cases/common/api/cases/comment/files.ts | 16 +- .../cases/common/constants/mime_types.ts | 8 +- x-pack/plugins/cases/common/types.ts | 1 + x-pack/plugins/cases/public/application.tsx | 21 +- .../client/attachment_framework/types.ts | 28 +- .../ui/get_all_cases_selector_modal.tsx | 6 +- .../cases/public/client/ui/get_cases.tsx | 6 +- .../public/client/ui/get_cases_context.tsx | 12 +- .../client/ui/get_create_case_flyout.tsx | 6 +- .../public/client/ui/get_recent_cases.tsx | 6 +- .../public/common/mock/test_providers.tsx | 54 +++- .../public/common/use_cases_toast.test.tsx | 20 ++ .../cases/public/common/use_cases_toast.tsx | 3 + .../cases/public/components/app/index.tsx | 10 +- .../components/case_action_bar/actions.tsx | 4 + .../case_view/case_view_page.test.tsx | 3 +- .../components/case_view/case_view_page.tsx | 2 + .../case_view/case_view_tabs.test.tsx | 64 ++++- .../components/case_view/case_view_tabs.tsx | 42 ++- .../components/case_view_files.test.tsx | 114 +++++++++ .../case_view/components/case_view_files.tsx | 104 ++++++++ .../components/case_view/translations.ts | 4 + .../public/components/cases_context/index.tsx | 54 +++- .../public/components/files/add_file.test.tsx | 241 ++++++++++++++++++ .../public/components/files/add_file.tsx | 141 ++++++++++ .../files/file_delete_button.test.tsx | 155 +++++++++++ .../components/files/file_delete_button.tsx | 62 +++++ .../files/file_download_button.test.tsx | 57 +++++ .../components/files/file_download_button.tsx | 46 ++++ .../components/files/file_name_link.test.tsx | 58 +++++ .../components/files/file_name_link.tsx | 44 ++++ .../components/files/file_preview.test.tsx | 38 +++ .../public/components/files/file_preview.tsx | 56 ++++ .../components/files/file_type.test.tsx | 187 ++++++++++++++ .../public/components/files/file_type.tsx | 106 ++++++++ .../components/files/files_table.test.tsx | 233 +++++++++++++++++ .../public/components/files/files_table.tsx | 83 ++++++ .../files/files_utility_bar.test.tsx | 42 +++ .../components/files/files_utility_bar.tsx | 36 +++ .../public/components/files/translations.tsx | 115 +++++++++ .../cases/public/components/files/types.ts | 12 + .../files/use_file_preview.test.tsx | 54 ++++ .../components/files/use_file_preview.tsx | 17 ++ .../files/use_files_table_columns.test.tsx | 73 ++++++ .../files/use_files_table_columns.tsx | 71 ++++++ .../public/components/files/utils.test.tsx | 97 +++++++ .../cases/public/components/files/utils.tsx | 61 +++++ .../lens/use_lens_open_visualization.tsx | 3 + .../components/property_actions/index.tsx | 24 +- .../user_actions/comment/comment.test.tsx | 195 +++++++++++++- .../comment/registered_attachments.tsx | 52 ++-- .../alert_property_actions.tsx | 6 +- .../property_actions.test.tsx | 2 + .../property_actions/property_actions.tsx | 4 +- ...ered_attachments_property_actions.test.tsx | 19 +- ...egistered_attachments_property_actions.tsx | 11 +- .../user_comment_property_actions.tsx | 5 + .../cases/public/containers/__mocks__/api.ts | 10 + .../cases/public/containers/api.test.tsx | 27 ++ x-pack/plugins/cases/public/containers/api.ts | 17 ++ .../cases/public/containers/constants.ts | 4 + .../plugins/cases/public/containers/mock.ts | 15 ++ .../cases/public/containers/translations.ts | 8 + .../use_delete_file_attachment.test.tsx | 120 +++++++++ .../containers/use_delete_file_attachment.tsx | 43 ++++ .../use_get_case_file_stats.test.tsx | 64 +++++ .../containers/use_get_case_file_stats.tsx | 57 +++++ .../containers/use_get_case_files.test.tsx | 70 +++++ .../public/containers/use_get_case_files.tsx | 61 +++++ x-pack/plugins/cases/public/files/index.ts | 3 + .../public/internal_attachments/index.ts | 15 ++ x-pack/plugins/cases/public/plugin.ts | 7 + x-pack/plugins/cases/public/types.ts | 4 +- .../common/limiter_checker/test_utils.ts | 2 +- x-pack/plugins/cases/tsconfig.json | 4 + .../cases_api_integration/common/lib/mock.ts | 2 +- .../public/attachments/external_reference.tsx | 13 +- .../public/attachments/persistable_state.tsx | 11 +- 80 files changed, 3440 insertions(+), 115 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx create mode 100644 x-pack/plugins/cases/public/components/files/add_file.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/add_file.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_delete_button.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_download_button.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_download_button.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_name_link.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_name_link.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_preview.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_preview.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_type.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_type.tsx create mode 100644 x-pack/plugins/cases/public/components/files/files_table.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/files_table.tsx create mode 100644 x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/files_utility_bar.tsx create mode 100644 x-pack/plugins/cases/public/components/files/translations.tsx create mode 100644 x-pack/plugins/cases/public/components/files/types.ts create mode 100644 x-pack/plugins/cases/public/components/files/use_file_preview.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/use_file_preview.tsx create mode 100644 x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx create mode 100644 x-pack/plugins/cases/public/components/files/utils.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/utils.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_delete_file_attachment.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_delete_file_attachment.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_get_case_file_stats.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_get_case_file_stats.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_get_case_files.tsx create mode 100644 x-pack/plugins/cases/public/internal_attachments/index.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 74d7df9394153..93d5507c53a1e 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -7,7 +7,7 @@ pageLoadAssetSize: banners: 17946 bfetch: 22837 canvas: 1066647 - cases: 144442 + cases: 170000 charts: 55000 cloud: 21076 cloudChat: 19894 diff --git a/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx b/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx index 498a4a93b5fe4..45e74312e1e55 100644 --- a/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx +++ b/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx @@ -19,7 +19,7 @@ import { context } from './context'; /** * An object representing an uploaded file */ -interface UploadedFile { +export interface UploadedFile { /** * The ID that was generated for the uploaded file */ diff --git a/x-pack/plugins/cases/common/api/cases/comment/files.ts b/x-pack/plugins/cases/common/api/cases/comment/files.ts index 66555b1a584d9..af42a7a779e55 100644 --- a/x-pack/plugins/cases/common/api/cases/comment/files.ts +++ b/x-pack/plugins/cases/common/api/cases/comment/files.ts @@ -9,15 +9,15 @@ import * as rt from 'io-ts'; import { MAX_DELETE_FILES } from '../../../constants'; import { limitedArraySchema, NonEmptyString } from '../../../schema'; +export const SingleFileAttachmentMetadataRt = rt.type({ + name: rt.string, + extension: rt.string, + mimeType: rt.string, + created: rt.string, +}); + export const FileAttachmentMetadataRt = rt.type({ - files: rt.array( - rt.type({ - name: rt.string, - extension: rt.string, - mimeType: rt.string, - createdAt: rt.string, - }) - ), + files: rt.array(SingleFileAttachmentMetadataRt), }); export type FileAttachmentMetadata = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/constants/mime_types.ts b/x-pack/plugins/cases/common/constants/mime_types.ts index 9f1f455513dab..c35e5ef674c81 100644 --- a/x-pack/plugins/cases/common/constants/mime_types.ts +++ b/x-pack/plugins/cases/common/constants/mime_types.ts @@ -8,7 +8,7 @@ /** * These were retrieved from https://www.iana.org/assignments/media-types/media-types.xhtml#image */ -const imageMimeTypes = [ +export const imageMimeTypes = [ 'image/aces', 'image/apng', 'image/avci', @@ -87,9 +87,9 @@ const imageMimeTypes = [ 'image/wmf', ]; -const textMimeTypes = ['text/plain', 'text/csv', 'text/json', 'application/json']; +export const textMimeTypes = ['text/plain', 'text/csv', 'text/json', 'application/json']; -const compressionMimeTypes = [ +export const compressionMimeTypes = [ 'application/zip', 'application/gzip', 'application/x-bzip', @@ -98,7 +98,7 @@ const compressionMimeTypes = [ 'application/x-tar', ]; -const pdfMimeTypes = ['application/pdf']; +export const pdfMimeTypes = ['application/pdf']; export const ALLOWED_MIME_TYPES = [ ...imageMimeTypes, diff --git a/x-pack/plugins/cases/common/types.ts b/x-pack/plugins/cases/common/types.ts index 3ff14b0905110..32d6b34b11c16 100644 --- a/x-pack/plugins/cases/common/types.ts +++ b/x-pack/plugins/cases/common/types.ts @@ -24,4 +24,5 @@ export type SnakeToCamelCase = T extends Record export enum CASE_VIEW_PAGE_TABS { ALERTS = 'alerts', ACTIVITY = 'activity', + FILES = 'files', } diff --git a/x-pack/plugins/cases/public/application.tsx b/x-pack/plugins/cases/public/application.tsx index bac423a9f8292..742f254472160 100644 --- a/x-pack/plugins/cases/public/application.tsx +++ b/x-pack/plugins/cases/public/application.tsx @@ -9,19 +9,21 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Router } from 'react-router-dom'; -import { I18nProvider } from '@kbn/i18n-react'; import { EuiErrorBoundary } from '@elastic/eui'; - +import { I18nProvider } from '@kbn/i18n-react'; +import { EuiThemeProvider as StyledComponentsThemeProvider } from '@kbn/kibana-react-plugin/common'; import { KibanaContextProvider, KibanaThemeProvider, useUiSetting$, } from '@kbn/kibana-react-plugin/public'; -import { EuiThemeProvider as StyledComponentsThemeProvider } from '@kbn/kibana-react-plugin/common'; -import type { RenderAppProps } from './types'; -import { CasesApp } from './components/app'; + +import type { ScopedFilesClient } from '@kbn/files-plugin/public'; import type { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry'; import type { PersistableStateAttachmentTypeRegistry } from './client/attachment_framework/persistable_state_registry'; +import type { RenderAppProps } from './types'; + +import { CasesApp } from './components/app'; export const renderApp = (deps: RenderAppProps) => { const { mountParams } = deps; @@ -37,10 +39,15 @@ export const renderApp = (deps: RenderAppProps) => { interface CasesAppWithContextProps { externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; + getFilesClient: (scope: string) => ScopedFilesClient; } const CasesAppWithContext: React.FC = React.memo( - ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry }) => { + ({ + externalReferenceAttachmentTypeRegistry, + persistableStateAttachmentTypeRegistry, + getFilesClient, + }) => { const [darkMode] = useUiSetting$('theme:darkMode'); return ( @@ -48,6 +55,7 @@ const CasesAppWithContext: React.FC = React.memo( ); @@ -78,6 +86,7 @@ export const App: React.FC<{ deps: RenderAppProps }> = ({ deps }) => { deps.externalReferenceAttachmentTypeRegistry } persistableStateAttachmentTypeRegistry={deps.persistableStateAttachmentTypeRegistry} + getFilesClient={pluginsStart.files.filesClientFactory.asScoped} /> diff --git a/x-pack/plugins/cases/public/client/attachment_framework/types.ts b/x-pack/plugins/cases/public/client/attachment_framework/types.ts index 414b8a0086654..95a453b9d0a12 100644 --- a/x-pack/plugins/cases/public/client/attachment_framework/types.ts +++ b/x-pack/plugins/cases/public/client/attachment_framework/types.ts @@ -13,19 +13,38 @@ import type { } from '../../../common/api'; import type { Case } from '../../containers/types'; -export interface AttachmentAction { +export enum AttachmentActionType { + BUTTON = 'button', + CUSTOM = 'custom', +} + +interface BaseAttachmentAction { + type: AttachmentActionType; + label: string; + isPrimary?: boolean; + disabled?: boolean; +} + +interface ButtonAttachmentAction extends BaseAttachmentAction { + type: AttachmentActionType.BUTTON; onClick: () => void; iconType: string; - label: string; color?: EuiButtonProps['color']; - isPrimary?: boolean; } +interface CustomAttachmentAction extends BaseAttachmentAction { + type: AttachmentActionType.CUSTOM; + render: () => JSX.Element; +} + +export type AttachmentAction = ButtonAttachmentAction | CustomAttachmentAction; + export interface AttachmentViewObject { timelineAvatar?: EuiCommentProps['timelineAvatar']; getActions?: (props: Props) => AttachmentAction[]; event?: EuiCommentProps['event']; children?: React.LazyExoticComponent>; + hideDefaultActions?: boolean; } export interface CommonAttachmentViewProps { @@ -46,8 +65,9 @@ export interface AttachmentType { id: string; icon: IconType; displayName: string; - getAttachmentViewObject: () => AttachmentViewObject; + getAttachmentViewObject: (props: Props) => AttachmentViewObject; getAttachmentRemovalObject?: (props: Props) => Pick, 'event'>; + hideDefaultActions?: boolean; } export type ExternalReferenceAttachmentType = AttachmentType; diff --git a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx index b0807b0509135..fc85e84639baa 100644 --- a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx @@ -14,7 +14,9 @@ import { CasesProvider } from '../../components/cases_context'; type GetAllCasesSelectorModalPropsInternal = AllCasesSelectorModalProps & CasesContextProps; export type GetAllCasesSelectorModalProps = Omit< GetAllCasesSelectorModalPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; const AllCasesSelectorModalLazy: React.FC = lazy( @@ -23,6 +25,7 @@ const AllCasesSelectorModalLazy: React.FC = lazy( export const getAllCasesSelectorModalLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, hiddenStatuses, @@ -33,6 +36,7 @@ export const getAllCasesSelectorModalLazy = ({ value={{ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, }} diff --git a/x-pack/plugins/cases/public/client/ui/get_cases.tsx b/x-pack/plugins/cases/public/client/ui/get_cases.tsx index 45c9f30b984d2..36556523fc3a3 100644 --- a/x-pack/plugins/cases/public/client/ui/get_cases.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_cases.tsx @@ -14,7 +14,9 @@ import { CasesProvider } from '../../components/cases_context'; type GetCasesPropsInternal = CasesProps & CasesContextProps; export type GetCasesProps = Omit< GetCasesPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; const CasesRoutesLazy: React.FC = lazy(() => import('../../components/app/routes')); @@ -22,6 +24,7 @@ const CasesRoutesLazy: React.FC = lazy(() => import('../../component export const getCasesLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, basePath, @@ -39,6 +42,7 @@ export const getCasesLazy = ({ value={{ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, basePath, diff --git a/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx b/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx index 77e6ca3c87e24..9db49ef9776ba 100644 --- a/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx @@ -13,7 +13,9 @@ import type { CasesContextProps } from '../../components/cases_context'; export type GetCasesContextPropsInternal = CasesContextProps; export type GetCasesContextProps = Omit< CasesContextProps, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; const CasesProviderLazy: React.FC<{ value: GetCasesContextPropsInternal }> = lazy( @@ -28,6 +30,7 @@ const CasesProviderLazyWrapper = ({ features, children, releasePhase, + getFilesClient, }: GetCasesContextPropsInternal & { children: ReactNode }) => { return ( }> @@ -39,6 +42,7 @@ const CasesProviderLazyWrapper = ({ permissions, features, releasePhase, + getFilesClient, }} > {children} @@ -52,9 +56,12 @@ CasesProviderLazyWrapper.displayName = 'CasesProviderLazyWrapper'; export const getCasesContextLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, }: Pick< GetCasesContextPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >): (() => React.FC) => { const CasesProviderLazyWrapperWithRegistry: React.FC = ({ children, @@ -64,6 +71,7 @@ export const getCasesContextLazy = ({ {...props} externalReferenceAttachmentTypeRegistry={externalReferenceAttachmentTypeRegistry} persistableStateAttachmentTypeRegistry={persistableStateAttachmentTypeRegistry} + getFilesClient={getFilesClient} > {children} diff --git a/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx index af932b53e1dde..e52a14033a614 100644 --- a/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx @@ -14,7 +14,9 @@ import { CasesProvider } from '../../components/cases_context'; type GetCreateCaseFlyoutPropsInternal = CreateCaseFlyoutProps & CasesContextProps; export type GetCreateCaseFlyoutProps = Omit< GetCreateCaseFlyoutPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; export const CreateCaseFlyoutLazy: React.FC = lazy( @@ -23,6 +25,7 @@ export const CreateCaseFlyoutLazy: React.FC = lazy( export const getCreateCaseFlyoutLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, features, @@ -35,6 +38,7 @@ export const getCreateCaseFlyoutLazy = ({ value={{ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, features, diff --git a/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx b/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx index a047c106246da..7c41cc3842bf7 100644 --- a/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx @@ -14,7 +14,9 @@ import type { RecentCasesProps } from '../../components/recent_cases'; type GetRecentCasesPropsInternal = RecentCasesProps & CasesContextProps; export type GetRecentCasesProps = Omit< GetRecentCasesPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; const RecentCasesLazy: React.FC = lazy( @@ -23,6 +25,7 @@ const RecentCasesLazy: React.FC = lazy( export const getRecentCasesLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, maxCasesToShow, @@ -31,6 +34,7 @@ export const getRecentCasesLazy = ({ value={{ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, }} diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 2a5a75bf7a789..f0b2e71231bb1 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -9,22 +9,30 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; + +import type { RenderOptions, RenderResult } from '@testing-library/react'; +import type { ILicense } from '@kbn/licensing-plugin/public'; +import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { ScopedFilesClient } from '@kbn/files-plugin/public'; + import { euiDarkVars } from '@kbn/ui-theme'; import { I18nProvider } from '@kbn/i18n-react'; -import { ThemeProvider } from 'styled-components'; +import { createMockFilesClient } from '@kbn/shared-ux-file-mocks'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import type { RenderOptions, RenderResult } from '@testing-library/react'; import { render as reactRender } from '@testing-library/react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import type { ILicense } from '@kbn/licensing-plugin/public'; -import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; +import { FilesContext } from '@kbn/shared-ux-file-context'; + +import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; import type { CasesFeatures, CasesPermissions } from '../../../common/ui/types'; -import { CasesProvider } from '../../components/cases_context'; -import { createStartServicesMock } from '../lib/kibana/kibana_react.mock'; import type { StartServices } from '../../types'; import type { ReleasePhase } from '../../components/types'; + +import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; +import { CasesProvider } from '../../components/cases_context'; +import { createStartServicesMock } from '../lib/kibana/kibana_react.mock'; import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry'; import { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry'; import { allCasesPermissions } from './permissions'; @@ -43,17 +51,35 @@ type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResul window.scrollTo = jest.fn(); +const mockGetFilesClient = () => { + const mockedFilesClient = createMockFilesClient() as unknown as DeeplyMockedKeys< + ScopedFilesClient + >; + + mockedFilesClient.getFileKind.mockImplementation(() => ({ + id: 'test', + maxSizeBytes: 10000, + http: {}, + })); + + return () => mockedFilesClient; +}; + +export const mockedTestProvidersOwner = [SECURITY_SOLUTION_OWNER]; + /** A utility for wrapping children in the providers required to run most tests */ const TestProvidersComponent: React.FC = ({ children, features, - owner = [SECURITY_SOLUTION_OWNER], + owner = mockedTestProvidersOwner, permissions = allCasesPermissions(), releasePhase = 'ga', externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(), persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry(), license, }) => { + const services = createStartServicesMock({ license }); + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -67,7 +93,7 @@ const TestProvidersComponent: React.FC = ({ }, }); - const services = createStartServicesMock({ license }); + const getFilesClient = mockGetFilesClient(); return ( @@ -82,9 +108,10 @@ const TestProvidersComponent: React.FC = ({ features, owner, permissions, + getFilesClient, }} > - {children} + {children} @@ -104,6 +131,7 @@ export interface AppMockRenderer { coreStart: StartServices; queryClient: QueryClient; AppWrapper: React.FC<{ children: React.ReactElement }>; + getFilesClient: () => ScopedFilesClient; } export const testQueryClient = new QueryClient({ @@ -125,7 +153,7 @@ export const testQueryClient = new QueryClient({ export const createAppMockRenderer = ({ features, - owner = [SECURITY_SOLUTION_OWNER], + owner = mockedTestProvidersOwner, permissions = allCasesPermissions(), releasePhase = 'ga', externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(), @@ -147,6 +175,8 @@ export const createAppMockRenderer = ({ }, }); + const getFilesClient = mockGetFilesClient(); + const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( @@ -161,6 +191,7 @@ export const createAppMockRenderer = ({ owner, permissions, releasePhase, + getFilesClient, }} > {children} @@ -188,6 +219,7 @@ export const createAppMockRenderer = ({ AppWrapper, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, }; }; diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx index 6c33c86d29d51..d6597e31362e7 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx @@ -25,6 +25,7 @@ const useKibanaMock = useKibana as jest.Mocked; describe('Use cases toast hook', () => { const successMock = jest.fn(); const errorMock = jest.fn(); + const dangerMock = jest.fn(); const getUrlForApp = jest.fn().mockReturnValue(`/app/cases/${mockCase.id}`); const navigateToUrl = jest.fn(); @@ -54,6 +55,7 @@ describe('Use cases toast hook', () => { return { addSuccess: successMock, addError: errorMock, + addDanger: dangerMock, }; }); @@ -352,4 +354,22 @@ describe('Use cases toast hook', () => { }); }); }); + + describe('showDangerToast', () => { + it('should show a danger toast', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + + result.current.showDangerToast('my danger toast'); + + expect(dangerMock).toHaveBeenCalledWith({ + className: 'eui-textBreakWord', + title: 'my danger toast', + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx index fd143345e2deb..26027905f8f0e 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.tsx @@ -169,6 +169,9 @@ export const useCasesToast = () => { showSuccessToast: (title: string) => { toasts.addSuccess({ title, className: 'eui-textBreakWord' }); }, + showDangerToast: (title: string) => { + toasts.addDanger({ title, className: 'eui-textBreakWord' }); + }, showInfoToast: (title: string, text?: string) => { toasts.addInfo({ title, diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx index 42ef9b658fea7..f53e7edf9356a 100644 --- a/x-pack/plugins/cases/public/components/app/index.tsx +++ b/x-pack/plugins/cases/public/components/app/index.tsx @@ -6,12 +6,15 @@ */ import React from 'react'; -import { APP_OWNER } from '../../../common/constants'; + +import type { ScopedFilesClient } from '@kbn/files-plugin/public'; + import type { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry'; import type { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry'; + +import { APP_OWNER } from '../../../common/constants'; import { getCasesLazy } from '../../client/ui/get_cases'; import { useApplicationCapabilities } from '../../common/lib/kibana'; - import { Wrapper } from '../wrappers'; import type { CasesRoutesProps } from './types'; @@ -20,11 +23,13 @@ export type CasesProps = CasesRoutesProps; interface CasesAppProps { externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; + getFilesClient: (scope: string) => ScopedFilesClient; } const CasesAppComponent: React.FC = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, }) => { const userCapabilities = useApplicationCapabilities(); @@ -33,6 +38,7 @@ const CasesAppComponent: React.FC = ({ {getCasesLazy({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner: [APP_OWNER], useFetchAlertData: () => [false, {}], permissions: userCapabilities.generalCases, diff --git a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx index 87cd1fc732a30..b80cd5c2dbe74 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx @@ -16,6 +16,7 @@ import type { Case } from '../../../common/ui/types'; import { useAllCasesNavigation } from '../../common/navigation'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesToast } from '../../common/use_cases_toast'; +import { AttachmentActionType } from '../../client/attachment_framework/types'; interface CaseViewActions { caseData: Case; @@ -40,6 +41,7 @@ const ActionsComponent: React.FC = ({ caseData, currentExternal const propertyActions = useMemo( () => [ { + type: AttachmentActionType.BUTTON as const, iconType: 'copyClipboard', label: i18n.COPY_ID_ACTION_LABEL, onClick: () => { @@ -50,6 +52,7 @@ const ActionsComponent: React.FC = ({ caseData, currentExternal ...(currentExternalIncident != null && !isEmpty(currentExternalIncident?.externalUrl) ? [ { + type: AttachmentActionType.BUTTON as const, iconType: 'popout', label: i18n.VIEW_INCIDENT(currentExternalIncident?.externalTitle ?? ''), onClick: () => window.open(currentExternalIncident?.externalUrl, '_blank'), @@ -59,6 +62,7 @@ const ActionsComponent: React.FC = ({ caseData, currentExternal ...(permissions.delete ? [ { + type: AttachmentActionType.BUTTON as const, iconType: 'trash', label: i18n.DELETE_CASE(), color: 'danger' as const, diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index bf348124e4616..f247945c7c700 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -493,8 +493,9 @@ describe('CaseViewPage', () => { it('renders tabs correctly', async () => { const result = appMockRenderer.render(); await act(async () => { - expect(result.getByTestId('case-view-tab-title-alerts')).toBeTruthy(); expect(result.getByTestId('case-view-tab-title-activity')).toBeTruthy(); + expect(result.getByTestId('case-view-tab-title-alerts')).toBeTruthy(); + expect(result.getByTestId('case-view-tab-title-files')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index a26793e501897..55245de4b22b2 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -18,6 +18,7 @@ import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; import { WhitePageWrapperNoBorder } from '../wrappers'; import { CaseViewActivity } from './components/case_view_activity'; import { CaseViewAlerts } from './components/case_view_alerts'; +import { CaseViewFiles } from './components/case_view_files'; import { CaseViewMetrics } from './metrics'; import type { CaseViewPageProps } from './types'; import { useRefreshCaseViewPage } from './use_on_refresh_case_view_page'; @@ -140,6 +141,7 @@ export const CaseViewPage = React.memo( {activeTabId === CASE_VIEW_PAGE_TABS.ALERTS && features.alerts.enabled && ( )} + {activeTabId === CASE_VIEW_PAGE_TABS.FILES && }
{timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null} diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx index a3da7d90267cf..bd532d95ba58b 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx @@ -8,23 +8,28 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; + import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; +import type { UseGetCase } from '../../containers/use_get_case'; +import type { CaseViewTabsProps } from './case_view_tabs'; + +import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import '../../common/mock/match_media'; +import { createAppMockRenderer } from '../../common/mock'; import { useCaseViewNavigation } from '../../common/navigation/hooks'; -import type { UseGetCase } from '../../containers/use_get_case'; import { useGetCase } from '../../containers/use_get_case'; import { CaseViewTabs } from './case_view_tabs'; import { caseData, defaultGetCase } from './mocks'; -import type { CaseViewTabsProps } from './case_view_tabs'; -import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; +import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats'; jest.mock('../../containers/use_get_case'); jest.mock('../../common/navigation/hooks'); jest.mock('../../common/hooks'); +jest.mock('../../containers/use_get_case_file_stats'); const useFetchCaseMock = useGetCase as jest.Mock; const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock; +const useGetCaseFileStatsMock = useGetCaseFileStats as jest.Mock; const mockGetCase = (props: Partial = {}) => { const data = { @@ -45,8 +50,10 @@ export const caseProps: CaseViewTabsProps = { describe('CaseViewTabs', () => { let appMockRenderer: AppMockRenderer; + const data = { total: 3 }; beforeEach(() => { + useGetCaseFileStatsMock.mockReturnValue({ data }); mockGetCase(); appMockRenderer = createAppMockRenderer(); @@ -62,6 +69,7 @@ describe('CaseViewTabs', () => { expect(await screen.findByTestId('case-view-tab-title-activity')).toBeInTheDocument(); expect(await screen.findByTestId('case-view-tab-title-alerts')).toBeInTheDocument(); + expect(await screen.findByTestId('case-view-tab-title-files')).toBeInTheDocument(); }); it('renders the activity tab by default', async () => { @@ -82,6 +90,40 @@ describe('CaseViewTabs', () => { ); }); + it('shows the files tab as active', async () => { + appMockRenderer.render(); + + expect(await screen.findByTestId('case-view-tab-title-files')).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('shows the files tab with the correct count and colour', async () => { + appMockRenderer.render(); + + const badge = await screen.findByTestId('case-view-files-stats-badge'); + + expect(badge.getAttribute('class')).toMatch(/accent/); + expect(badge).toHaveTextContent('3'); + }); + + it('do not show count on the files tab if the call isLoading', async () => { + useGetCaseFileStatsMock.mockReturnValue({ isLoading: true, data }); + + appMockRenderer.render(); + + expect(screen.queryByTestId('case-view-files-stats-badge')).not.toBeInTheDocument(); + }); + + it('the files tab count has a different colour if the tab is not active', async () => { + appMockRenderer.render(); + + expect( + (await screen.findByTestId('case-view-files-stats-badge')).getAttribute('class') + ).not.toMatch(/accent/); + }); + it('navigates to the activity tab when the activity tab is clicked', async () => { const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; appMockRenderer.render(); @@ -109,4 +151,18 @@ describe('CaseViewTabs', () => { }); }); }); + + it('navigates to the files tab when the files tab is clicked', async () => { + const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; + appMockRenderer.render(); + + userEvent.click(await screen.findByTestId('case-view-tab-title-files')); + + await waitFor(() => { + expect(navigateToCaseViewMock).toHaveBeenCalledWith({ + detailName: caseData.id, + tabId: CASE_VIEW_PAGE_TABS.FILES, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx index 746311051f147..630248bf79d52 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx @@ -5,20 +5,49 @@ * 2.0. */ -import { EuiBetaBadge, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; +import { EuiBetaBadge, EuiNotificationBadge, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useCaseViewNavigation } from '../../common/navigation'; import { useCasesContext } from '../cases_context/use_cases_context'; import { EXPERIMENTAL_DESC, EXPERIMENTAL_LABEL } from '../header_page/translations'; -import { ACTIVITY_TAB, ALERTS_TAB } from './translations'; +import { ACTIVITY_TAB, ALERTS_TAB, FILES_TAB } from './translations'; import type { Case } from '../../../common'; +import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats'; const ExperimentalBadge = styled(EuiBetaBadge)` margin-left: 5px; `; +const StyledNotificationBadge = styled(EuiNotificationBadge)` + margin-left: 5px; +`; + +const FilesTab = ({ + activeTab, + fileStatsData, + isLoading, +}: { + activeTab: string; + fileStatsData: { total: number } | undefined; + isLoading: boolean; +}) => ( + <> + {FILES_TAB} + {!isLoading && fileStatsData && ( + + {fileStatsData.total > 0 ? fileStatsData.total : 0} + + )} + +); + +FilesTab.displayName = 'FilesTab'; + export interface CaseViewTabsProps { caseData: Case; activeTab: CASE_VIEW_PAGE_TABS; @@ -27,6 +56,7 @@ export interface CaseViewTabsProps { export const CaseViewTabs = React.memo(({ caseData, activeTab }) => { const { features } = useCasesContext(); const { navigateToCaseView } = useCaseViewNavigation(); + const { data: fileStatsData, isLoading } = useGetCaseFileStats({ caseId: caseData.id }); const tabs = useMemo( () => [ @@ -56,8 +86,14 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab }, ] : []), + { + id: CASE_VIEW_PAGE_TABS.FILES, + name: ( + + ), + }, ], - [features.alerts.enabled, features.alerts.isExperimental] + [activeTab, features.alerts.enabled, features.alerts.isExperimental, fileStatsData, isLoading] ); const renderTabs = useCallback(() => { diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx new file mode 100644 index 0000000000000..dc5b937bd8781 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { Case } from '../../../../common'; +import type { AppMockRenderer } from '../../../common/mock'; + +import { createAppMockRenderer } from '../../../common/mock'; +import { alertCommentWithIndices, basicCase } from '../../../containers/mock'; +import { useGetCaseFiles } from '../../../containers/use_get_case_files'; +import { CaseViewFiles, DEFAULT_CASE_FILES_FILTERING_OPTIONS } from './case_view_files'; + +jest.mock('../../../containers/use_get_case_files'); + +const useGetCaseFilesMock = useGetCaseFiles as jest.Mock; + +const caseData: Case = { + ...basicCase, + comments: [...basicCase.comments, alertCommentWithIndices], +}; + +describe('Case View Page files tab', () => { + let appMockRender: AppMockRenderer; + + useGetCaseFilesMock.mockReturnValue({ + data: { files: [], total: 11 }, + isLoading: false, + }); + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the utility bar for the files table', async () => { + appMockRender.render(); + + expect((await screen.findAllByTestId('cases-files-add')).length).toBe(2); + expect(await screen.findByTestId('cases-files-search')).toBeInTheDocument(); + }); + + it('should render the files table', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument(); + }); + + it('clicking table pagination triggers calls to useGetCaseFiles', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('pagination-button-next')); + + await waitFor(() => + expect(useGetCaseFilesMock).toHaveBeenCalledWith({ + caseId: basicCase.id, + page: DEFAULT_CASE_FILES_FILTERING_OPTIONS.page + 1, + perPage: DEFAULT_CASE_FILES_FILTERING_OPTIONS.perPage, + }) + ); + }); + + it('changing perPage value triggers calls to useGetCaseFiles', async () => { + const targetPagination = 50; + + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('tablePaginationPopoverButton')); + + const pageSizeOption = screen.getByTestId('tablePagination-50-rows'); + + pageSizeOption.style.pointerEvents = 'all'; + + userEvent.click(pageSizeOption); + + await waitFor(() => + expect(useGetCaseFilesMock).toHaveBeenCalledWith({ + caseId: basicCase.id, + page: DEFAULT_CASE_FILES_FILTERING_OPTIONS.page, + perPage: targetPagination, + }) + ); + }); + + it('search by word triggers calls to useGetCaseFiles', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument(); + + await userEvent.type(screen.getByTestId('cases-files-search'), 'Foobar{enter}'); + + await waitFor(() => + expect(useGetCaseFilesMock).toHaveBeenCalledWith({ + caseId: basicCase.id, + page: DEFAULT_CASE_FILES_FILTERING_OPTIONS.page, + perPage: DEFAULT_CASE_FILES_FILTERING_OPTIONS.perPage, + searchTerm: 'Foobar', + }) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx new file mode 100644 index 0000000000000..54693acfa2390 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isEqual } from 'lodash/fp'; +import React, { useCallback, useMemo, useState } from 'react'; + +import type { Criteria } from '@elastic/eui'; +import type { FileJSON } from '@kbn/shared-ux-file-types'; + +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import type { Case } from '../../../../common/ui/types'; +import type { CaseFilesFilteringOptions } from '../../../containers/use_get_case_files'; + +import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; +import { useGetCaseFiles } from '../../../containers/use_get_case_files'; +import { FilesTable } from '../../files/files_table'; +import { CaseViewTabs } from '../case_view_tabs'; +import { FilesUtilityBar } from '../../files/files_utility_bar'; + +interface CaseViewFilesProps { + caseData: Case; +} + +export const DEFAULT_CASE_FILES_FILTERING_OPTIONS = { + page: 0, + perPage: 10, +}; + +export const CaseViewFiles = ({ caseData }: CaseViewFilesProps) => { + const [filteringOptions, setFilteringOptions] = useState( + DEFAULT_CASE_FILES_FILTERING_OPTIONS + ); + const { + data: caseFiles, + isLoading, + isPreviousData, + } = useGetCaseFiles({ + ...filteringOptions, + caseId: caseData.id, + }); + + const onTableChange = useCallback( + ({ page }: Criteria) => { + if (page && !isPreviousData) { + setFilteringOptions({ + ...filteringOptions, + page: page.index, + perPage: page.size, + }); + } + }, + [filteringOptions, isPreviousData] + ); + + const onSearchChange = useCallback( + (newSearch) => { + const trimSearch = newSearch.trim(); + if (!isEqual(trimSearch, filteringOptions.searchTerm)) { + setFilteringOptions({ + ...filteringOptions, + searchTerm: trimSearch, + }); + } + }, + [filteringOptions] + ); + + const pagination = useMemo( + () => ({ + pageIndex: filteringOptions.page, + pageSize: filteringOptions.perPage, + totalItemCount: caseFiles?.total ?? 0, + pageSizeOptions: [10, 25, 50], + showPerPageOptions: true, + }), + [filteringOptions.page, filteringOptions.perPage, caseFiles?.total] + ); + + return ( + + + + + + + + + + + + ); +}; + +CaseViewFiles.displayName = 'CaseViewFiles'; diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index d71c56fc97fca..8fc80c1a0aba3 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -165,6 +165,10 @@ export const ALERTS_TAB = i18n.translate('xpack.cases.caseView.tabs.alerts', { defaultMessage: 'Alerts', }); +export const FILES_TAB = i18n.translate('xpack.cases.caseView.tabs.files', { + defaultMessage: 'Files', +}); + export const ALERTS_EMPTY_DESCRIPTION = i18n.translate( 'xpack.cases.caseView.tabs.alerts.emptyDescription', { diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx index 4e31fffdd7701..dc7eac6381b4d 100644 --- a/x-pack/plugins/cases/public/components/cases_context/index.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx @@ -5,25 +5,34 @@ * 2.0. */ -import type { Dispatch } from 'react'; -import React, { useState, useEffect, useReducer } from 'react'; +import type { Dispatch, ReactNode } from 'react'; + import { merge } from 'lodash'; +import React, { useCallback, useEffect, useState, useReducer } from 'react'; import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; -import { DEFAULT_FEATURES } from '../../../common/constants'; -import { DEFAULT_BASE_PATH } from '../../common/navigation'; -import { useApplication } from './use_application'; + +import type { ScopedFilesClient } from '@kbn/files-plugin/public'; + +import { FilesContext } from '@kbn/shared-ux-file-context'; + import type { CasesContextStoreAction } from './cases_context_reducer'; -import { casesContextReducer, getInitialCasesContextState } from './cases_context_reducer'; import type { CasesFeaturesAllRequired, CasesFeatures, CasesPermissions, } from '../../containers/types'; -import { CasesGlobalComponents } from './cases_global_components'; import type { ReleasePhase } from '../types'; import type { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry'; import type { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry'; +import { CasesGlobalComponents } from './cases_global_components'; +import { DEFAULT_FEATURES } from '../../../common/constants'; +import { constructFileKindIdByOwner } from '../../../common/files'; +import { DEFAULT_BASE_PATH } from '../../common/navigation'; +import { useApplication } from './use_application'; +import { casesContextReducer, getInitialCasesContextState } from './cases_context_reducer'; +import { isRegisteredOwner } from '../../files'; + export type CasesContextValueDispatch = Dispatch; export interface CasesContextValue { @@ -50,6 +59,7 @@ export interface CasesContextProps basePath?: string; features?: CasesFeatures; releasePhase?: ReleasePhase; + getFilesClient: (scope: string) => ScopedFilesClient; } export const CasesContext = React.createContext(undefined); @@ -69,6 +79,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ basePath = DEFAULT_BASE_PATH, features = {}, releasePhase = 'ga', + getFilesClient, }, }) => { const { appId, appTitle } = useApplication(); @@ -114,10 +125,35 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ } }, [appTitle, appId]); + const applyFilesContext = useCallback( + (contextChildren: ReactNode) => { + if (owner.length === 0) { + return contextChildren; + } + + if (isRegisteredOwner(owner[0])) { + return ( + + {contextChildren} + + ); + } else { + throw new Error( + 'Invalid owner provided to cases context. See https://github.com/elastic/kibana/blob/main/x-pack/plugins/cases/README.md#casescontext-setup' + ); + } + }, + [getFilesClient, owner] + ); + return isCasesContextValue(value) ? ( - - {children} + {applyFilesContext( + <> + + {children} + + )} ) : null; }; diff --git a/x-pack/plugins/cases/public/components/files/add_file.test.tsx b/x-pack/plugins/cases/public/components/files/add_file.test.tsx new file mode 100644 index 0000000000000..911f8a4df538d --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/add_file.test.tsx @@ -0,0 +1,241 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import type { FileUploadProps } from '@kbn/shared-ux-file-upload'; + +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; + +import * as api from '../../containers/api'; +import { + buildCasesPermissions, + createAppMockRenderer, + mockedTestProvidersOwner, +} from '../../common/mock'; +import { AddFile } from './add_file'; +import { useToasts } from '../../common/lib/kibana'; + +import { useCreateAttachments } from '../../containers/use_create_attachments'; +import { basicCaseId, basicFileMock } from '../../containers/mock'; + +jest.mock('../../containers/api'); +jest.mock('../../containers/use_create_attachments'); +jest.mock('../../common/lib/kibana'); + +const useToastsMock = useToasts as jest.Mock; +const useCreateAttachmentsMock = useCreateAttachments as jest.Mock; + +const mockedExternalReferenceId = 'externalReferenceId'; +const validateMetadata = jest.fn(); +const mockFileUpload = jest + .fn() + .mockImplementation( + ({ + kind, + onDone, + onError, + meta, + }: Required>) => ( + <> + + + + + ) + ); + +jest.mock('@kbn/shared-ux-file-upload', () => { + const original = jest.requireActual('@kbn/shared-ux-file-upload'); + return { + ...original, + FileUpload: (props: unknown) => mockFileUpload(props), + }; +}); + +describe('AddFile', () => { + let appMockRender: AppMockRenderer; + + const successMock = jest.fn(); + const errorMock = jest.fn(); + + useToastsMock.mockImplementation(() => { + return { + addSuccess: successMock, + addError: errorMock, + }; + }); + + const createAttachmentsMock = jest.fn(); + + useCreateAttachmentsMock.mockReturnValue({ + isLoading: false, + createAttachments: createAttachmentsMock, + }); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-add')).toBeInTheDocument(); + }); + + it('AddFile is not rendered if user has no create permission', async () => { + appMockRender = createAppMockRenderer({ + permissions: buildCasesPermissions({ create: false }), + }); + + appMockRender.render(); + + expect(screen.queryByTestId('cases-files-add')).not.toBeInTheDocument(); + }); + + it('AddFile is not rendered if user has no update permission', async () => { + appMockRender = createAppMockRenderer({ + permissions: buildCasesPermissions({ update: false }), + }); + + appMockRender.render(); + + expect(screen.queryByTestId('cases-files-add')).not.toBeInTheDocument(); + }); + + it('clicking button renders modal', async () => { + appMockRender.render(); + + userEvent.click(await screen.findByTestId('cases-files-add')); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + }); + + it('createAttachments called with right parameters', async () => { + appMockRender.render(); + + userEvent.click(await screen.findByTestId('cases-files-add')); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('testOnDone')); + + await waitFor(() => + expect(createAttachmentsMock).toBeCalledWith({ + caseId: 'foobar', + caseOwner: mockedTestProvidersOwner[0], + data: [ + { + externalReferenceAttachmentTypeId: '.files', + externalReferenceId: mockedExternalReferenceId, + externalReferenceMetadata: { + files: [ + { + created: '2020-02-19T23:06:33.798Z', + extension: 'png', + mimeType: 'image/png', + name: 'my-super-cool-screenshot', + }, + ], + }, + externalReferenceStorage: { soType: 'file', type: 'savedObject' }, + type: 'externalReference', + }, + ], + throwOnError: true, + updateCase: expect.any(Function), + }) + ); + + await waitFor(() => + expect(successMock).toHaveBeenCalledWith({ + className: 'eui-textBreakWord', + title: `File ${basicFileMock.name} uploaded successfully`, + }) + ); + }); + + it('failed upload displays error toast', async () => { + appMockRender.render(); + + userEvent.click(await screen.findByTestId('cases-files-add')); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('testOnError')); + + expect(errorMock).toHaveBeenCalledWith( + { name: 'upload error name', message: 'upload error message' }, + { + title: 'Failed to upload file', + } + ); + }); + + it('correct metadata is passed to FileUpload component', async () => { + const caseId = 'foobar'; + + appMockRender.render(); + + userEvent.click(await screen.findByTestId('cases-files-add')); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('testMetadata')); + + await waitFor(() => + expect(validateMetadata).toHaveBeenCalledWith({ + caseIds: [caseId], + owner: [mockedTestProvidersOwner[0]], + }) + ); + }); + + it('deleteFileAttachments is called correctly if createAttachments fails', async () => { + const spyOnDeleteFileAttachments = jest.spyOn(api, 'deleteFileAttachments'); + + createAttachmentsMock.mockImplementation(() => { + throw new Error(); + }); + + appMockRender.render(); + + userEvent.click(await screen.findByTestId('cases-files-add')); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('testOnDone')); + + expect(spyOnDeleteFileAttachments).toHaveBeenCalledWith({ + caseId: basicCaseId, + fileIds: [mockedExternalReferenceId], + signal: expect.any(AbortSignal), + }); + + createAttachmentsMock.mockRestore(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/add_file.tsx b/x-pack/plugins/cases/public/components/files/add_file.tsx new file mode 100644 index 0000000000000..a3c9fba1188ea --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/add_file.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; + +import type { UploadedFile } from '@kbn/shared-ux-file-upload/src/file_upload'; + +import { FILE_SO_TYPE } from '@kbn/files-plugin/common'; +import { FileUpload } from '@kbn/shared-ux-file-upload'; + +import { constructFileKindIdByOwner } from '../../../common/files'; +import type { Owner } from '../../../common/constants/types'; + +import { CommentType, ExternalReferenceStorageType } from '../../../common'; +import { FILE_ATTACHMENT_TYPE } from '../../../common/api'; +import { useCasesToast } from '../../common/use_cases_toast'; +import { useCreateAttachments } from '../../containers/use_create_attachments'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import * as i18n from './translations'; +import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_page'; +import { deleteFileAttachments } from '../../containers/api'; + +interface AddFileProps { + caseId: string; +} + +const AddFileComponent: React.FC = ({ caseId }) => { + const { owner, permissions } = useCasesContext(); + const { showDangerToast, showErrorToast, showSuccessToast } = useCasesToast(); + const { isLoading, createAttachments } = useCreateAttachments(); + const refreshAttachmentsTable = useRefreshCaseViewPage(); + const [isModalVisible, setIsModalVisible] = useState(false); + + const closeModal = () => setIsModalVisible(false); + const showModal = () => setIsModalVisible(true); + + const onError = useCallback( + (error) => { + showErrorToast(error, { + title: i18n.FAILED_UPLOAD, + }); + }, + [showErrorToast] + ); + + const onUploadDone = useCallback( + async (chosenFiles: UploadedFile[]) => { + if (chosenFiles.length === 0) { + showDangerToast(i18n.FAILED_UPLOAD); + return; + } + + const file = chosenFiles[0]; + + try { + await createAttachments({ + caseId, + caseOwner: owner[0], + data: [ + { + type: CommentType.externalReference, + externalReferenceId: file.id, + externalReferenceStorage: { + type: ExternalReferenceStorageType.savedObject, + soType: FILE_SO_TYPE, + }, + externalReferenceAttachmentTypeId: FILE_ATTACHMENT_TYPE, + externalReferenceMetadata: { + files: [ + { + name: file.fileJSON.name, + extension: file.fileJSON.extension ?? '', + mimeType: file.fileJSON.mimeType ?? '', + created: file.fileJSON.created, + }, + ], + }, + }, + ], + updateCase: refreshAttachmentsTable, + throwOnError: true, + }); + + showSuccessToast(i18n.SUCCESSFUL_UPLOAD_FILE_NAME(file.fileJSON.name)); + } catch (error) { + // error toast is handled inside createAttachments + + // we need to delete the file if attachment creation failed + const abortCtrlRef = new AbortController(); + return deleteFileAttachments({ caseId, fileIds: [file.id], signal: abortCtrlRef.signal }); + } + + closeModal(); + }, + [caseId, createAttachments, owner, refreshAttachmentsTable, showDangerToast, showSuccessToast] + ); + + return permissions.create && permissions.update ? ( + + + {i18n.ADD_FILE} + + {isModalVisible && ( + + + {i18n.ADD_FILE} + + + + + + )} + + ) : null; +}; +AddFileComponent.displayName = 'AddFile'; + +export const AddFile = React.memo(AddFileComponent); diff --git a/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx b/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx new file mode 100644 index 0000000000000..38ed8a20eab40 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; + +import { buildCasesPermissions, createAppMockRenderer } from '../../common/mock'; +import { basicCaseId, basicFileMock } from '../../containers/mock'; +import { useDeleteFileAttachment } from '../../containers/use_delete_file_attachment'; +import { FileDeleteButton } from './file_delete_button'; + +jest.mock('../../containers/use_delete_file_attachment'); + +const useDeleteFileAttachmentMock = useDeleteFileAttachment as jest.Mock; + +describe('FileDeleteButton', () => { + let appMockRender: AppMockRenderer; + const mutate = jest.fn(); + + useDeleteFileAttachmentMock.mockReturnValue({ isLoading: false, mutate }); + + describe('isIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders delete button correctly', async () => { + appMockRender.render( + + ); + + expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument(); + + expect(useDeleteFileAttachmentMock).toBeCalledTimes(1); + }); + + it('clicking delete button opens the confirmation modal', async () => { + appMockRender.render( + + ); + + const deleteButton = await screen.findByTestId('cases-files-delete-button'); + + expect(deleteButton).toBeInTheDocument(); + + userEvent.click(deleteButton); + + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + }); + + it('clicking delete button in the confirmation modal calls deleteFileAttachment with proper params', async () => { + appMockRender.render( + + ); + + const deleteButton = await screen.findByTestId('cases-files-delete-button'); + + expect(deleteButton).toBeInTheDocument(); + + userEvent.click(deleteButton); + + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('confirmModalConfirmButton')); + + await waitFor(() => { + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith({ + caseId: basicCaseId, + fileId: basicFileMock.id, + }); + }); + }); + + it('delete button is not rendered if user has no delete permission', async () => { + appMockRender = createAppMockRenderer({ + permissions: buildCasesPermissions({ delete: false }), + }); + + appMockRender.render( + + ); + + expect(screen.queryByTestId('cases-files-delete-button')).not.toBeInTheDocument(); + }); + }); + + describe('not isIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders delete button correctly', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument(); + + expect(useDeleteFileAttachmentMock).toBeCalledTimes(1); + }); + + it('clicking delete button opens the confirmation modal', async () => { + appMockRender.render(); + + const deleteButton = await screen.findByTestId('cases-files-delete-button'); + + expect(deleteButton).toBeInTheDocument(); + + userEvent.click(deleteButton); + + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + }); + + it('clicking delete button in the confirmation modal calls deleteFileAttachment with proper params', async () => { + appMockRender.render(); + + const deleteButton = await screen.findByTestId('cases-files-delete-button'); + + expect(deleteButton).toBeInTheDocument(); + + userEvent.click(deleteButton); + + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('confirmModalConfirmButton')); + + await waitFor(() => { + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith({ + caseId: basicCaseId, + fileId: basicFileMock.id, + }); + }); + }); + + it('delete button is not rendered if user has no delete permission', async () => { + appMockRender = createAppMockRenderer({ + permissions: buildCasesPermissions({ delete: false }), + }); + + appMockRender.render(); + + expect(screen.queryByTestId('cases-files-delete-button')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_delete_button.tsx b/x-pack/plugins/cases/public/components/files/file_delete_button.tsx new file mode 100644 index 0000000000000..f344b942aa2c2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_delete_button.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; +import * as i18n from './translations'; +import { useDeleteFileAttachment } from '../../containers/use_delete_file_attachment'; +import { useDeletePropertyAction } from '../user_actions/property_actions/use_delete_property_action'; +import { DeleteAttachmentConfirmationModal } from '../user_actions/delete_attachment_confirmation_modal'; +import { useCasesContext } from '../cases_context/use_cases_context'; + +interface FileDeleteButtonProps { + caseId: string; + fileId: string; + isIcon?: boolean; +} + +const FileDeleteButtonComponent: React.FC = ({ caseId, fileId, isIcon }) => { + const { permissions } = useCasesContext(); + const { isLoading, mutate: deleteFileAttachment } = useDeleteFileAttachment(); + + const { showDeletionModal, onModalOpen, onConfirm, onCancel } = useDeletePropertyAction({ + onDelete: () => deleteFileAttachment({ caseId, fileId }), + }); + + const buttonProps = { + iconType: 'trash', + 'aria-label': i18n.DELETE_FILE, + color: 'danger' as const, + isDisabled: isLoading, + onClick: onModalOpen, + 'data-test-subj': 'cases-files-delete-button', + }; + + return permissions.delete ? ( + <> + {isIcon ? ( + + ) : ( + {i18n.DELETE_FILE} + )} + {showDeletionModal ? ( + + ) : null} + + ) : ( + <> + ); +}; +FileDeleteButtonComponent.displayName = 'FileDeleteButton'; + +export const FileDeleteButton = React.memo(FileDeleteButtonComponent); diff --git a/x-pack/plugins/cases/public/components/files/file_download_button.test.tsx b/x-pack/plugins/cases/public/components/files/file_download_button.test.tsx new file mode 100644 index 0000000000000..0c729900a9ea6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_download_button.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; +import { FileDownloadButton } from './file_download_button'; +import { basicFileMock } from '../../containers/mock'; +import { constructFileKindIdByOwner } from '../../../common/files'; + +describe('FileDownloadButton', () => { + let appMockRender: AppMockRenderer; + + describe('isIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders download button with correct href', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument(); + + expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1); + expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({ + fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]), + id: basicFileMock.id, + }); + }); + }); + + describe('not isIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders download button with correct href', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument(); + + expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1); + expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({ + fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]), + id: basicFileMock.id, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_download_button.tsx b/x-pack/plugins/cases/public/components/files/file_download_button.tsx new file mode 100644 index 0000000000000..856c7000ba9d5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_download_button.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; +import { useFilesContext } from '@kbn/shared-ux-file-context'; + +import type { Owner } from '../../../common/constants/types'; + +import { constructFileKindIdByOwner } from '../../../common/files'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import * as i18n from './translations'; + +interface FileDownloadButtonProps { + fileId: string; + isIcon?: boolean; +} + +const FileDownloadButtonComponent: React.FC = ({ fileId, isIcon }) => { + const { owner } = useCasesContext(); + const { client: filesClient } = useFilesContext(); + + const buttonProps = { + iconType: 'download', + 'aria-label': i18n.DOWNLOAD_FILE, + href: filesClient.getDownloadHref({ + fileKind: constructFileKindIdByOwner(owner[0] as Owner), + id: fileId, + }), + 'data-test-subj': 'cases-files-download-button', + }; + + return isIcon ? ( + + ) : ( + {i18n.DOWNLOAD_FILE} + ); +}; +FileDownloadButtonComponent.displayName = 'FileDownloadButton'; + +export const FileDownloadButton = React.memo(FileDownloadButtonComponent); diff --git a/x-pack/plugins/cases/public/components/files/file_name_link.test.tsx b/x-pack/plugins/cases/public/components/files/file_name_link.test.tsx new file mode 100644 index 0000000000000..39c62322dedeb --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_name_link.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import userEvent from '@testing-library/user-event'; +import { FileNameLink } from './file_name_link'; +import { basicFileMock } from '../../containers/mock'; + +describe('FileNameLink', () => { + let appMockRender: AppMockRenderer; + + const defaultProps = { + file: basicFileMock, + showPreview: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders clickable name if file is image', async () => { + appMockRender.render(); + + const nameLink = await screen.findByTestId('cases-files-name-link'); + + expect(nameLink).toBeInTheDocument(); + + userEvent.click(nameLink); + + expect(defaultProps.showPreview).toHaveBeenCalled(); + }); + + it('renders simple text name if file is not image', async () => { + appMockRender.render( + + ); + + const nameLink = await screen.findByTestId('cases-files-name-text'); + + expect(nameLink).toBeInTheDocument(); + + userEvent.click(nameLink); + + expect(defaultProps.showPreview).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_name_link.tsx b/x-pack/plugins/cases/public/components/files/file_name_link.tsx new file mode 100644 index 0000000000000..4c9aedc3ad85b --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_name_link.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiLink } from '@elastic/eui'; + +import type { FileJSON } from '@kbn/shared-ux-file-types'; +import * as i18n from './translations'; +import { isImage } from './utils'; + +interface FileNameLinkProps { + file: Pick; + showPreview: () => void; +} + +const FileNameLinkComponent: React.FC = ({ file, showPreview }) => { + let fileName = file.name; + + if (typeof file.extension !== 'undefined') { + fileName += `.${file.extension}`; + } + + if (isImage(file)) { + return ( + + {fileName} + + ); + } else { + return ( + + {fileName} + + ); + } +}; +FileNameLinkComponent.displayName = 'FileNameLink'; + +export const FileNameLink = React.memo(FileNameLinkComponent); diff --git a/x-pack/plugins/cases/public/components/files/file_preview.test.tsx b/x-pack/plugins/cases/public/components/files/file_preview.test.tsx new file mode 100644 index 0000000000000..b02df3a82228f --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_preview.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { screen, waitFor } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; + +import { constructFileKindIdByOwner } from '../../../common/files'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; +import { basicFileMock } from '../../containers/mock'; +import { FilePreview } from './file_preview'; + +describe('FilePreview', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('FilePreview rendered correctly', async () => { + appMockRender.render(); + + await waitFor(() => + expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({ + id: basicFileMock.id, + fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]), + }) + ); + + expect(await screen.findByTestId('cases-files-image-preview')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_preview.tsx b/x-pack/plugins/cases/public/components/files/file_preview.tsx new file mode 100644 index 0000000000000..1bb91c5b53ff7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_preview.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import styled from 'styled-components'; + +import type { FileJSON } from '@kbn/shared-ux-file-types'; + +import { EuiOverlayMask, EuiFocusTrap, EuiImage } from '@elastic/eui'; +import { useFilesContext } from '@kbn/shared-ux-file-context'; + +import type { Owner } from '../../../common/constants/types'; + +import { constructFileKindIdByOwner } from '../../../common/files'; +import { useCasesContext } from '../cases_context/use_cases_context'; + +interface FilePreviewProps { + closePreview: () => void; + selectedFile: Pick; +} + +const StyledOverlayMask = styled(EuiOverlayMask)` + padding-block-end: 0vh !important; + + img { + max-height: 85vh; + max-width: 85vw; + object-fit: contain; + } +`; + +export const FilePreview = ({ closePreview, selectedFile }: FilePreviewProps) => { + const { client: filesClient } = useFilesContext(); + const { owner } = useCasesContext(); + + return ( + + + + + + ); +}; + +FilePreview.displayName = 'FilePreview'; diff --git a/x-pack/plugins/cases/public/components/files/file_type.test.tsx b/x-pack/plugins/cases/public/components/files/file_type.test.tsx new file mode 100644 index 0000000000000..8d4fd4c0eabde --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_type.test.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { JsonValue } from '@kbn/utility-types'; + +import { screen } from '@testing-library/react'; + +import type { ExternalReferenceAttachmentViewProps } from '../../client/attachment_framework/types'; +import type { AppMockRenderer } from '../../common/mock'; + +import { AttachmentActionType } from '../../client/attachment_framework/types'; +import { FILE_ATTACHMENT_TYPE } from '../../../common/api'; +import { createAppMockRenderer } from '../../common/mock'; +import { basicCase, basicFileMock } from '../../containers/mock'; +import { getFileType } from './file_type'; +import userEvent from '@testing-library/user-event'; + +describe('getFileType', () => { + const fileType = getFileType(); + + it('invalid props return blank FileAttachmentViewObject', () => { + expect(fileType).toStrictEqual({ + id: FILE_ATTACHMENT_TYPE, + icon: 'document', + displayName: 'File Attachment Type', + getAttachmentViewObject: expect.any(Function), + }); + }); + + describe('getFileAttachmentViewObject', () => { + let appMockRender: AppMockRenderer; + + const attachmentViewProps = { + externalReferenceId: basicFileMock.id, + externalReferenceMetadata: { files: [basicFileMock] }, + caseData: { title: basicCase.title, id: basicCase.id }, + } as unknown as ExternalReferenceAttachmentViewProps; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('event renders a clickable name if the file is an image', async () => { + appMockRender = createAppMockRenderer(); + + // @ts-ignore + appMockRender.render(fileType.getAttachmentViewObject({ ...attachmentViewProps }).event); + + expect(await screen.findByText('my-super-cool-screenshot.png')).toBeInTheDocument(); + expect(screen.queryByTestId('cases-files-image-preview')).not.toBeInTheDocument(); + }); + + it('clicking the name rendered in event opens the file preview', async () => { + appMockRender = createAppMockRenderer(); + + // @ts-ignore + appMockRender.render(fileType.getAttachmentViewObject({ ...attachmentViewProps }).event); + + userEvent.click(await screen.findByText('my-super-cool-screenshot.png')); + expect(await screen.findByTestId('cases-files-image-preview')).toBeInTheDocument(); + }); + + it('getActions renders a download button', async () => { + appMockRender = createAppMockRenderer(); + + const attachmentViewObject = fileType.getAttachmentViewObject({ ...attachmentViewProps }); + + expect(attachmentViewObject).not.toBeUndefined(); + + // @ts-ignore + const actions = attachmentViewObject.getActions(); + + expect(actions.length).toBe(2); + expect(actions[0]).toStrictEqual({ + type: AttachmentActionType.CUSTOM, + isPrimary: false, + label: 'Download File', + render: expect.any(Function), + }); + + // @ts-ignore + appMockRender.render(actions[0].render()); + + expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument(); + }); + + it('getActions renders a delete button', async () => { + appMockRender = createAppMockRenderer(); + + const attachmentViewObject = fileType.getAttachmentViewObject({ ...attachmentViewProps }); + + expect(attachmentViewObject).not.toBeUndefined(); + + // @ts-ignore + const actions = attachmentViewObject.getActions(); + + expect(actions.length).toBe(2); + expect(actions[1]).toStrictEqual({ + type: AttachmentActionType.CUSTOM, + isPrimary: false, + label: 'Delete File', + render: expect.any(Function), + }); + + // @ts-ignore + appMockRender.render(actions[1].render()); + + expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument(); + }); + + it('clicking the delete button in actions opens deletion modal', async () => { + appMockRender = createAppMockRenderer(); + + const attachmentViewObject = fileType.getAttachmentViewObject({ ...attachmentViewProps }); + + expect(attachmentViewObject).not.toBeUndefined(); + + // @ts-ignore + const actions = attachmentViewObject.getActions(); + + expect(actions.length).toBe(2); + expect(actions[1]).toStrictEqual({ + type: AttachmentActionType.CUSTOM, + isPrimary: false, + label: 'Delete File', + render: expect.any(Function), + }); + + // @ts-ignore + appMockRender.render(actions[1].render()); + + const deleteButton = await screen.findByTestId('cases-files-delete-button'); + + expect(deleteButton).toBeInTheDocument(); + + userEvent.click(deleteButton); + + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + }); + + it('empty externalReferenceMetadata returns blank FileAttachmentViewObject', () => { + expect( + fileType.getAttachmentViewObject({ ...attachmentViewProps, externalReferenceMetadata: {} }) + ).toEqual({ + event: 'added an unknown file', + hideDefaultActions: true, + timelineAvatar: 'document', + type: 'regular', + getActions: expect.any(Function), + }); + }); + + it('timelineAvatar is image if file is an image', () => { + expect(fileType.getAttachmentViewObject(attachmentViewProps)).toEqual( + expect.objectContaining({ + timelineAvatar: 'image', + }) + ); + }); + + it('timelineAvatar is document if file is not an image', () => { + expect( + fileType.getAttachmentViewObject({ + ...attachmentViewProps, + externalReferenceMetadata: { + files: [{ ...basicFileMock, mimeType: 'text/csv' } as JsonValue], + }, + }) + ).toEqual( + expect.objectContaining({ + timelineAvatar: 'document', + }) + ); + }); + + it('default actions should be hidden', () => { + expect(fileType.getAttachmentViewObject(attachmentViewProps)).toEqual( + expect.objectContaining({ + hideDefaultActions: true, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_type.tsx b/x-pack/plugins/cases/public/components/files/file_type.tsx new file mode 100644 index 0000000000000..271bf3008e70e --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_type.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import type { + ExternalReferenceAttachmentType, + ExternalReferenceAttachmentViewProps, +} from '../../client/attachment_framework/types'; +import type { DownloadableFile } from './types'; + +import { AttachmentActionType } from '../../client/attachment_framework/types'; +import { FILE_ATTACHMENT_TYPE } from '../../../common/api'; +import { FileDownloadButton } from './file_download_button'; +import { FileNameLink } from './file_name_link'; +import { FilePreview } from './file_preview'; +import * as i18n from './translations'; +import { isImage, isValidFileExternalReferenceMetadata } from './utils'; +import { useFilePreview } from './use_file_preview'; +import { FileDeleteButton } from './file_delete_button'; + +interface FileAttachmentEventProps { + file: DownloadableFile; +} + +const FileAttachmentEvent = ({ file }: FileAttachmentEventProps) => { + const { isPreviewVisible, showPreview, closePreview } = useFilePreview(); + + return ( + <> + {i18n.ADDED} + + {isPreviewVisible && } + + ); +}; + +FileAttachmentEvent.displayName = 'FileAttachmentEvent'; + +function getFileDownloadButton(fileId: string) { + return ; +} + +function getFileDeleteButton(caseId: string, fileId: string) { + return ; +} + +const getFileAttachmentActions = ({ caseId, fileId }: { caseId: string; fileId: string }) => [ + { + type: AttachmentActionType.CUSTOM as const, + render: () => getFileDownloadButton(fileId), + label: i18n.DOWNLOAD_FILE, + isPrimary: false, + }, + { + type: AttachmentActionType.CUSTOM as const, + render: () => getFileDeleteButton(caseId, fileId), + label: i18n.DELETE_FILE, + isPrimary: false, + }, +]; + +const getFileAttachmentViewObject = (props: ExternalReferenceAttachmentViewProps) => { + const caseId = props.caseData.id; + const fileId = props.externalReferenceId; + + if (!isValidFileExternalReferenceMetadata(props.externalReferenceMetadata)) { + return { + type: 'regular', + event: i18n.ADDED_UNKNOWN_FILE, + timelineAvatar: 'document', + getActions: () => [ + { + type: AttachmentActionType.CUSTOM as const, + render: () => getFileDeleteButton(caseId, fileId), + label: i18n.DELETE_FILE, + isPrimary: false, + }, + ], + hideDefaultActions: true, + }; + } + + const fileMetadata = props.externalReferenceMetadata.files[0]; + const file = { + id: fileId, + ...fileMetadata, + }; + + return { + event: , + timelineAvatar: isImage(file) ? 'image' : 'document', + getActions: () => getFileAttachmentActions({ caseId, fileId }), + hideDefaultActions: true, + }; +}; + +export const getFileType = (): ExternalReferenceAttachmentType => ({ + id: FILE_ATTACHMENT_TYPE, + icon: 'document', + displayName: 'File Attachment Type', + getAttachmentViewObject: getFileAttachmentViewObject, +}); diff --git a/x-pack/plugins/cases/public/components/files/files_table.test.tsx b/x-pack/plugins/cases/public/components/files/files_table.test.tsx new file mode 100644 index 0000000000000..651f86e76b462 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/files_table.test.tsx @@ -0,0 +1,233 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, within } from '@testing-library/react'; + +import { basicFileMock } from '../../containers/mock'; +import type { AppMockRenderer } from '../../common/mock'; + +import { constructFileKindIdByOwner } from '../../../common/files'; +import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock'; +import { FilesTable } from './files_table'; +import userEvent from '@testing-library/user-event'; + +describe('FilesTable', () => { + const onTableChange = jest.fn(); + const defaultProps = { + caseId: 'foobar', + items: [basicFileMock], + pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 1 }, + isLoading: false, + onChange: onTableChange, + }; + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table-results-count')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-files-table-filename')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-files-table-filetype')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-files-table-date-added')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument(); + }); + + it('renders loading state', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table-loading')).toBeInTheDocument(); + }); + + it('renders empty table', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table-empty')).toBeInTheDocument(); + }); + + it('FileAdd in empty table is clickable', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table-empty')).toBeInTheDocument(); + + const addFileButton = await screen.findByTestId('cases-files-add'); + + expect(addFileButton).toBeInTheDocument(); + + userEvent.click(addFileButton); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + }); + + it('renders single result count properly', async () => { + const mockPagination = { pageIndex: 0, pageSize: 10, totalItemCount: 1 }; + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table-results-count')).toHaveTextContent( + `Showing ${defaultProps.items.length} file` + ); + }); + + it('non image rows dont open file preview', async () => { + const nonImageFileMock = { ...basicFileMock, mimeType: 'something/else' }; + + appMockRender.render(); + + userEvent.click( + await within(await screen.findByTestId('cases-files-table-filename')).findByTitle( + 'No preview available' + ) + ); + + expect(await screen.queryByTestId('cases-files-image-preview')).not.toBeInTheDocument(); + }); + + it('image rows open file preview', async () => { + appMockRender.render(); + + userEvent.click( + await screen.findByRole('button', { + name: `${basicFileMock.name}.${basicFileMock.extension}`, + }) + ); + + expect(await screen.findByTestId('cases-files-image-preview')).toBeInTheDocument(); + }); + + it('different mimeTypes are displayed correctly', async () => { + const mockPagination = { pageIndex: 0, pageSize: 10, totalItemCount: 7 }; + appMockRender.render( + + ); + + expect((await screen.findAllByText('Unknown')).length).toBe(4); + expect(await screen.findByText('Compressed')).toBeInTheDocument(); + expect(await screen.findByText('Text')).toBeInTheDocument(); + expect(await screen.findByText('Image')).toBeInTheDocument(); + }); + + it('download button renders correctly', async () => { + appMockRender.render(); + + expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1); + expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({ + fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]), + id: basicFileMock.id, + }); + + expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument(); + }); + + it('delete button renders correctly', async () => { + appMockRender.render(); + + expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1); + expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({ + fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]), + id: basicFileMock.id, + }); + + expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument(); + }); + + it('clicking delete button opens deletion modal', async () => { + appMockRender.render(); + + expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1); + expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({ + fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]), + id: basicFileMock.id, + }); + + const deleteButton = await screen.findByTestId('cases-files-delete-button'); + + expect(deleteButton).toBeInTheDocument(); + + userEvent.click(deleteButton); + + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + }); + + it('go to next page calls onTableChange with correct values', async () => { + const mockPagination = { pageIndex: 0, pageSize: 1, totalItemCount: 2 }; + + appMockRender.render( + + ); + + userEvent.click(await screen.findByTestId('pagination-button-next')); + + await waitFor(() => + expect(onTableChange).toHaveBeenCalledWith({ + page: { index: mockPagination.pageIndex + 1, size: mockPagination.pageSize }, + }) + ); + }); + + it('go to previous page calls onTableChange with correct values', async () => { + const mockPagination = { pageIndex: 1, pageSize: 1, totalItemCount: 2 }; + + appMockRender.render( + + ); + + userEvent.click(await screen.findByTestId('pagination-button-previous')); + + await waitFor(() => + expect(onTableChange).toHaveBeenCalledWith({ + page: { index: mockPagination.pageIndex - 1, size: mockPagination.pageSize }, + }) + ); + }); + + it('changing perPage calls onTableChange with correct values', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByTestId('tablePaginationPopoverButton')); + + const pageSizeOption = screen.getByTestId('tablePagination-50-rows'); + + pageSizeOption.style.pointerEvents = 'all'; + + userEvent.click(pageSizeOption); + + await waitFor(() => + expect(onTableChange).toHaveBeenCalledWith({ + page: { index: 0, size: 50 }, + }) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/files_table.tsx b/x-pack/plugins/cases/public/components/files/files_table.tsx new file mode 100644 index 0000000000000..6433d90a91d44 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/files_table.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState } from 'react'; + +import type { Pagination, EuiBasicTableProps } from '@elastic/eui'; +import type { FileJSON } from '@kbn/shared-ux-file-types'; + +import { EuiBasicTable, EuiLoadingContent, EuiSpacer, EuiText, EuiEmptyPrompt } from '@elastic/eui'; + +import * as i18n from './translations'; +import { useFilesTableColumns } from './use_files_table_columns'; +import { FilePreview } from './file_preview'; +import { AddFile } from './add_file'; +import { useFilePreview } from './use_file_preview'; + +const EmptyFilesTable = ({ caseId }: { caseId: string }) => ( + {i18n.NO_FILES}} + data-test-subj="cases-files-table-empty" + titleSize="xs" + actions={} + /> +); + +EmptyFilesTable.displayName = 'EmptyFilesTable'; + +interface FilesTableProps { + caseId: string; + isLoading: boolean; + items: FileJSON[]; + onChange: EuiBasicTableProps['onChange']; + pagination: Pagination; +} + +export const FilesTable = ({ caseId, items, pagination, onChange, isLoading }: FilesTableProps) => { + const { isPreviewVisible, showPreview, closePreview } = useFilePreview(); + + const [selectedFile, setSelectedFile] = useState(); + + const displayPreview = (file: FileJSON) => { + setSelectedFile(file); + showPreview(); + }; + + const columns = useFilesTableColumns({ caseId, showPreview: displayPreview }); + + return isLoading ? ( + <> + + + + ) : ( + <> + {pagination.totalItemCount > 0 && ( + <> + + + {i18n.SHOWING_FILES(items.length)} + + + )} + + } + /> + {isPreviewVisible && selectedFile !== undefined && ( + + )} + + ); +}; + +FilesTable.displayName = 'FilesTable'; diff --git a/x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx b/x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx new file mode 100644 index 0000000000000..bfac1998a857a --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import userEvent from '@testing-library/user-event'; +import { FilesUtilityBar } from './files_utility_bar'; + +const defaultProps = { + caseId: 'foobar', + onSearch: jest.fn(), +}; + +describe('FilesUtilityBar', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-add')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-files-search')).toBeInTheDocument(); + }); + + it('search text passed correctly to callback', async () => { + appMockRender.render(); + + await userEvent.type(screen.getByTestId('cases-files-search'), 'My search{enter}'); + expect(defaultProps.onSearch).toBeCalledWith('My search'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/files_utility_bar.tsx b/x-pack/plugins/cases/public/components/files/files_utility_bar.tsx new file mode 100644 index 0000000000000..71b1ef503fc63 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/files_utility_bar.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch } from '@elastic/eui'; +import { AddFile } from './add_file'; + +import * as i18n from './translations'; + +interface FilesUtilityBarProps { + caseId: string; + onSearch: (newSearch: string) => void; +} + +export const FilesUtilityBar = ({ caseId, onSearch }: FilesUtilityBarProps) => { + return ( + + + + + + + ); +}; + +FilesUtilityBar.displayName = 'FilesUtilityBar'; diff --git a/x-pack/plugins/cases/public/components/files/translations.tsx b/x-pack/plugins/cases/public/components/files/translations.tsx new file mode 100644 index 0000000000000..4023c5b18cea8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/translations.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ACTIONS = i18n.translate('xpack.cases.caseView.files.actions', { + defaultMessage: 'Actions', +}); + +export const ADD_FILE = i18n.translate('xpack.cases.caseView.files.addFile', { + defaultMessage: 'Add File', +}); + +export const CLOSE_MODAL = i18n.translate('xpack.cases.caseView.files.closeModal', { + defaultMessage: 'Close', +}); + +export const DATE_ADDED = i18n.translate('xpack.cases.caseView.files.dateAdded', { + defaultMessage: 'Date Added', +}); + +export const DELETE_FILE = i18n.translate('xpack.cases.caseView.files.deleteFile', { + defaultMessage: 'Delete File', +}); + +export const DOWNLOAD_FILE = i18n.translate('xpack.cases.caseView.files.downloadFile', { + defaultMessage: 'Download File', +}); + +export const FILES_TABLE = i18n.translate('xpack.cases.caseView.files.filesTable', { + defaultMessage: 'Files table', +}); + +export const NAME = i18n.translate('xpack.cases.caseView.files.name', { + defaultMessage: 'Name', +}); + +export const NO_FILES = i18n.translate('xpack.cases.caseView.files.noFilesAvailable', { + defaultMessage: 'No files available', +}); + +export const NO_PREVIEW = i18n.translate('xpack.cases.caseView.files.noPreviewAvailable', { + defaultMessage: 'No preview available', +}); + +export const RESULTS_COUNT = i18n.translate('xpack.cases.caseView.files.resultsCount', { + defaultMessage: 'Showing', +}); + +export const TYPE = i18n.translate('xpack.cases.caseView.files.type', { + defaultMessage: 'Type', +}); + +export const SEARCH_PLACEHOLDER = i18n.translate('xpack.cases.caseView.files.searchPlaceholder', { + defaultMessage: 'Search files', +}); + +export const FAILED_UPLOAD = i18n.translate('xpack.cases.caseView.files.failedUpload', { + defaultMessage: 'Failed to upload file', +}); + +export const UNKNOWN_MIME_TYPE = i18n.translate('xpack.cases.caseView.files.unknownMimeType', { + defaultMessage: 'Unknown', +}); + +export const IMAGE_MIME_TYPE = i18n.translate('xpack.cases.caseView.files.imageMimeType', { + defaultMessage: 'Image', +}); + +export const TEXT_MIME_TYPE = i18n.translate('xpack.cases.caseView.files.textMimeType', { + defaultMessage: 'Text', +}); + +export const COMPRESSED_MIME_TYPE = i18n.translate( + 'xpack.cases.caseView.files.compressedMimeType', + { + defaultMessage: 'Compressed', + } +); + +export const PDF_MIME_TYPE = i18n.translate('xpack.cases.caseView.files.pdfMimeType', { + defaultMessage: 'PDF', +}); + +export const SUCCESSFUL_UPLOAD_FILE_NAME = (fileName: string) => + i18n.translate('xpack.cases.caseView.files.successfulUploadFileName', { + defaultMessage: 'File {fileName} uploaded successfully', + values: { fileName }, + }); + +export const SHOWING_FILES = (totalFiles: number) => + i18n.translate('xpack.cases.caseView.files.showingFilesTitle', { + values: { totalFiles }, + defaultMessage: 'Showing {totalFiles} {totalFiles, plural, =1 {file} other {files}}', + }); + +export const ADDED = i18n.translate('xpack.cases.caseView.files.added', { + defaultMessage: 'added ', +}); + +export const ADDED_UNKNOWN_FILE = i18n.translate('xpack.cases.caseView.files.addedUnknownFile', { + defaultMessage: 'added an unknown file', +}); + +export const DELETE = i18n.translate('xpack.cases.caseView.files.delete', { + defaultMessage: 'Delete', +}); + +export const DELETE_FILE_TITLE = i18n.translate('xpack.cases.caseView.files.deleteThisFile', { + defaultMessage: 'Delete this file?', +}); diff --git a/x-pack/plugins/cases/public/components/files/types.ts b/x-pack/plugins/cases/public/components/files/types.ts new file mode 100644 index 0000000000000..a211b5ac4053d --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type * as rt from 'io-ts'; + +import type { SingleFileAttachmentMetadataRt } from '../../../common/api'; + +export type DownloadableFile = rt.TypeOf & { id: string }; diff --git a/x-pack/plugins/cases/public/components/files/use_file_preview.test.tsx b/x-pack/plugins/cases/public/components/files/use_file_preview.test.tsx new file mode 100644 index 0000000000000..49e18fb818cd9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/use_file_preview.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useFilePreview } from './use_file_preview'; + +describe('useFilePreview', () => { + it('isPreviewVisible is false by default', () => { + const { result } = renderHook(() => { + return useFilePreview(); + }); + + expect(result.current.isPreviewVisible).toBeFalsy(); + }); + + it('showPreview sets isPreviewVisible to true', () => { + const { result } = renderHook(() => { + return useFilePreview(); + }); + + expect(result.current.isPreviewVisible).toBeFalsy(); + + act(() => { + result.current.showPreview(); + }); + + expect(result.current.isPreviewVisible).toBeTruthy(); + }); + + it('closePreview sets isPreviewVisible to false', () => { + const { result } = renderHook(() => { + return useFilePreview(); + }); + + expect(result.current.isPreviewVisible).toBeFalsy(); + + act(() => { + result.current.showPreview(); + }); + + expect(result.current.isPreviewVisible).toBeTruthy(); + + act(() => { + result.current.closePreview(); + }); + + expect(result.current.isPreviewVisible).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/use_file_preview.tsx b/x-pack/plugins/cases/public/components/files/use_file_preview.tsx new file mode 100644 index 0000000000000..c802aa38fc688 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/use_file_preview.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState } from 'react'; + +export const useFilePreview = () => { + const [isPreviewVisible, setIsPreviewVisible] = useState(false); + + const closePreview = () => setIsPreviewVisible(false); + const showPreview = () => setIsPreviewVisible(true); + + return { isPreviewVisible, showPreview, closePreview }; +}; diff --git a/x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx b/x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx new file mode 100644 index 0000000000000..77070da0dbc57 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FilesTableColumnsProps } from './use_files_table_columns'; +import { useFilesTableColumns } from './use_files_table_columns'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { renderHook } from '@testing-library/react-hooks'; +import { basicCase } from '../../containers/mock'; + +describe('useFilesTableColumns', () => { + let appMockRender: AppMockRenderer; + + const useFilesTableColumnsProps: FilesTableColumnsProps = { + caseId: basicCase.id, + showPreview: () => {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('return all files table columns correctly', async () => { + const { result } = renderHook(() => useFilesTableColumns(useFilesTableColumnsProps), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "cases-files-table-filename", + "name": "Name", + "render": [Function], + "width": "60%", + }, + Object { + "data-test-subj": "cases-files-table-filetype", + "name": "Type", + "render": [Function], + }, + Object { + "data-test-subj": "cases-files-table-date-added", + "dataType": "date", + "field": "created", + "name": "Date Added", + }, + Object { + "actions": Array [ + Object { + "description": "Download File", + "isPrimary": true, + "name": "Download", + "render": [Function], + }, + Object { + "description": "Delete File", + "isPrimary": true, + "name": "Delete", + "render": [Function], + }, + ], + "name": "Actions", + "width": "120px", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx b/x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx new file mode 100644 index 0000000000000..80568189afb58 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { FileJSON } from '@kbn/shared-ux-file-types'; + +import * as i18n from './translations'; +import { parseMimeType } from './utils'; +import { FileNameLink } from './file_name_link'; +import { FileDownloadButton } from './file_download_button'; +import { FileDeleteButton } from './file_delete_button'; + +export interface FilesTableColumnsProps { + caseId: string; + showPreview: (file: FileJSON) => void; +} + +export const useFilesTableColumns = ({ + caseId, + showPreview, +}: FilesTableColumnsProps): Array> => { + return [ + { + name: i18n.NAME, + 'data-test-subj': 'cases-files-table-filename', + render: (file: FileJSON) => ( + showPreview(file)} /> + ), + width: '60%', + }, + { + name: i18n.TYPE, + 'data-test-subj': 'cases-files-table-filetype', + render: (attachment: FileJSON) => { + return {parseMimeType(attachment.mimeType)}; + }, + }, + { + name: i18n.DATE_ADDED, + field: 'created', + 'data-test-subj': 'cases-files-table-date-added', + dataType: 'date', + }, + { + name: i18n.ACTIONS, + width: '120px', + actions: [ + { + name: 'Download', + isPrimary: true, + description: i18n.DOWNLOAD_FILE, + render: (file: FileJSON) => , + }, + { + name: 'Delete', + isPrimary: true, + description: i18n.DELETE_FILE, + render: (file: FileJSON) => ( + + ), + }, + ], + }, + ]; +}; diff --git a/x-pack/plugins/cases/public/components/files/utils.test.tsx b/x-pack/plugins/cases/public/components/files/utils.test.tsx new file mode 100644 index 0000000000000..411492d1a2bab --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/utils.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { JsonValue } from '@kbn/utility-types'; + +import { + compressionMimeTypes, + imageMimeTypes, + pdfMimeTypes, + textMimeTypes, +} from '../../../common/constants/mime_types'; +import { basicFileMock } from '../../containers/mock'; +import { isImage, isValidFileExternalReferenceMetadata, parseMimeType } from './utils'; + +describe('isImage', () => { + it.each(imageMimeTypes)('should return true for image mime type: %s', (mimeType) => { + expect(isImage({ mimeType })).toBeTruthy(); + }); + + it.each(textMimeTypes)('should return false for text mime type: %s', (mimeType) => { + expect(isImage({ mimeType })).toBeFalsy(); + }); +}); + +describe('parseMimeType', () => { + it('should return Unknown for empty strings', () => { + expect(parseMimeType('')).toBe('Unknown'); + }); + + it('should return Unknown for undefined', () => { + expect(parseMimeType(undefined)).toBe('Unknown'); + }); + + it('should return Unknown for strings starting with forward slash', () => { + expect(parseMimeType('/start')).toBe('Unknown'); + }); + + it('should return Unknown for strings with no forward slash', () => { + expect(parseMimeType('no-slash')).toBe('Unknown'); + }); + + it('should return capitalize first letter for valid strings', () => { + expect(parseMimeType('foo/bar')).toBe('Foo'); + }); + + it.each(imageMimeTypes)('should return "Image" for image mime type: %s', (mimeType) => { + expect(parseMimeType(mimeType)).toBe('Image'); + }); + + it.each(textMimeTypes)('should return "Text" for text mime type: %s', (mimeType) => { + expect(parseMimeType(mimeType)).toBe('Text'); + }); + + it.each(compressionMimeTypes)( + 'should return "Compressed" for image mime type: %s', + (mimeType) => { + expect(parseMimeType(mimeType)).toBe('Compressed'); + } + ); + + it.each(pdfMimeTypes)('should return "Pdf" for text mime type: %s', (mimeType) => { + expect(parseMimeType(mimeType)).toBe('PDF'); + }); +}); + +describe('isValidFileExternalReferenceMetadata', () => { + it('should return false for empty objects', () => { + expect(isValidFileExternalReferenceMetadata({})).toBeFalsy(); + }); + + it('should return false if the files property is missing', () => { + expect(isValidFileExternalReferenceMetadata({ foo: 'bar' })).toBeFalsy(); + }); + + it('should return false if the files property is not an array', () => { + expect(isValidFileExternalReferenceMetadata({ files: 'bar' })).toBeFalsy(); + }); + + it('should return false if files is not an array of file metadata', () => { + expect(isValidFileExternalReferenceMetadata({ files: [3] })).toBeFalsy(); + }); + + it('should return false if files is not an array of file metadata 2', () => { + expect( + isValidFileExternalReferenceMetadata({ files: [{ name: 'foo', mimeType: 'bar' }] }) + ).toBeFalsy(); + }); + + it('should return true if the metadata is as expected', () => { + expect( + isValidFileExternalReferenceMetadata({ files: [basicFileMock as unknown as JsonValue] }) + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/utils.tsx b/x-pack/plugins/cases/public/components/files/utils.tsx new file mode 100644 index 0000000000000..b870c733eb10e --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/utils.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + CommentRequestExternalReferenceType, + FileAttachmentMetadata, +} from '../../../common/api'; + +import { + compressionMimeTypes, + imageMimeTypes, + textMimeTypes, + pdfMimeTypes, +} from '../../../common/constants/mime_types'; +import { FileAttachmentMetadataRt } from '../../../common/api'; +import * as i18n from './translations'; + +export const isImage = (file: { mimeType?: string }) => file.mimeType?.startsWith('image/'); + +export const parseMimeType = (mimeType: string | undefined) => { + if (typeof mimeType === 'undefined') { + return i18n.UNKNOWN_MIME_TYPE; + } + + if (imageMimeTypes.includes(mimeType)) { + return i18n.IMAGE_MIME_TYPE; + } + + if (textMimeTypes.includes(mimeType)) { + return i18n.TEXT_MIME_TYPE; + } + + if (compressionMimeTypes.includes(mimeType)) { + return i18n.COMPRESSED_MIME_TYPE; + } + + if (pdfMimeTypes.includes(mimeType)) { + return i18n.PDF_MIME_TYPE; + } + + const result = mimeType.split('/'); + + if (result.length <= 1 || result[0] === '') { + return i18n.UNKNOWN_MIME_TYPE; + } + + return result[0].charAt(0).toUpperCase() + result[0].slice(1); +}; + +export const isValidFileExternalReferenceMetadata = ( + externalReferenceMetadata: CommentRequestExternalReferenceType['externalReferenceMetadata'] +): externalReferenceMetadata is FileAttachmentMetadata => { + return ( + FileAttachmentMetadataRt.is(externalReferenceMetadata) && + externalReferenceMetadata?.files?.length >= 1 + ); +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_open_visualization.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_open_visualization.tsx index 1d6a5ec8ca11a..84dddd64ba61b 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_open_visualization.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_open_visualization.tsx @@ -9,6 +9,8 @@ import { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; + +import { AttachmentActionType } from '../../../../client/attachment_framework/types'; import { useKibana } from '../../../../common/lib/kibana'; import { parseCommentString, @@ -42,6 +44,7 @@ export const useLensOpenVisualization = ({ comment }: { comment: string }) => { actionConfig: !lensVisualization.length ? null : { + type: AttachmentActionType.BUTTON as const, iconType: 'lensApp', label: i18n.translate( 'xpack.cases.markdownEditor.plugins.lens.openVisualizationButtonLabel', diff --git a/x-pack/plugins/cases/public/components/property_actions/index.tsx b/x-pack/plugins/cases/public/components/property_actions/index.tsx index 4de52d551bf2f..833ace8333d2b 100644 --- a/x-pack/plugins/cases/public/components/property_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/property_actions/index.tsx @@ -9,6 +9,9 @@ import React, { useCallback, useState } from 'react'; import type { EuiButtonProps } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiPopover, EuiButtonIcon, EuiButtonEmpty } from '@elastic/eui'; +import type { AttachmentAction } from '../../client/attachment_framework/types'; + +import { AttachmentActionType } from '../../client/attachment_framework/types'; import * as i18n from './translations'; export interface PropertyActionButtonProps { @@ -45,7 +48,7 @@ const PropertyActionButton = React.memo( PropertyActionButton.displayName = 'PropertyActionButton'; export interface PropertyActionsProps { - propertyActions: PropertyActionButtonProps[]; + propertyActions: AttachmentAction[]; customDataTestSubj?: string; } @@ -93,14 +96,17 @@ export const PropertyActions = React.memo( {propertyActions.map((action, key) => ( - onClosePopover(action.onClick)} - customDataTestSubj={customDataTestSubj} - /> + {(action.type === AttachmentActionType.BUTTON && ( + onClosePopover(action.onClick)} + customDataTestSubj={customDataTestSubj} + /> + )) || + (action.type === AttachmentActionType.CUSTOM && action.render())} ))} diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx index db21c2f2100c6..4cf6c9844e948 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx @@ -36,6 +36,7 @@ import { useCaseViewNavigation, useCaseViewParams } from '../../../common/naviga import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry'; import { PersistableStateAttachmentTypeRegistry } from '../../../client/attachment_framework/persistable_state_registry'; import { userProfiles } from '../../../containers/user_profiles/api.mock'; +import { AttachmentActionType } from '../../../client/attachment_framework/types'; jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/navigation/hooks'); @@ -849,9 +850,27 @@ describe('createCommentUserActionBuilder', () => { const attachment = getExternalReferenceAttachment({ getActions: () => [ - { label: 'My primary button', isPrimary: true, iconType: 'danger', onClick }, - { label: 'My primary 2 button', isPrimary: true, iconType: 'danger', onClick }, - { label: 'My primary 3 button', isPrimary: true, iconType: 'danger', onClick }, + { + type: AttachmentActionType.BUTTON as const, + label: 'My primary button', + isPrimary: true, + iconType: 'danger', + onClick, + }, + { + type: AttachmentActionType.BUTTON as const, + label: 'My primary 2 button', + isPrimary: true, + iconType: 'danger', + onClick, + }, + { + type: AttachmentActionType.BUTTON as const, + label: 'My primary 3 button', + isPrimary: true, + iconType: 'danger', + onClick, + }, ], }); @@ -888,14 +907,75 @@ describe('createCommentUserActionBuilder', () => { expect(onClick).toHaveBeenCalledTimes(2); }); + it('shows correctly a custom action', async () => { + const onClick = jest.fn(); + + const attachment = getExternalReferenceAttachment({ + getActions: () => [ + { + type: AttachmentActionType.CUSTOM as const, + isPrimary: true, + label: 'Test button', + render: () => ( +