From bf25e16a8bb19351c93f5c47a4c927ee15f99f2b Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Thu, 27 Aug 2020 08:23:07 -0700 Subject: [PATCH 01/33] Skip creating SpacesClient when not needed in auth interceptor (#75706) Co-authored-by: Elastic Machine --- .../lib/request_interceptors/on_post_auth_interceptor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts index 772914bb53211..3d6084d37a384 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts @@ -38,13 +38,12 @@ export function initSpacesOnPostAuthRequestInterceptor({ const isRequestingSpaceRoot = path === '/' && spaceId !== DEFAULT_SPACE_ID; const isRequestingApplication = path.startsWith('/app'); - const spacesClient = await spacesService.scopedClient(request); - // if requesting the application root, then show the Space Selector UI to allow the user to choose which space // they wish to visit. This is done "onPostAuth" to allow the Saved Objects Client to use the request's auth credentials, // which is not available at the time of "onRequest". if (isRequestingKibanaRoot) { try { + const spacesClient = await spacesService.scopedClient(request); const spaces = await spacesClient.getAll(); if (spaces.length === 1) { @@ -77,6 +76,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ try { log.debug(`Verifying access to space "${spaceId}"`); + const spacesClient = await spacesService.scopedClient(request); space = await spacesClient.get(spaceId); } catch (error) { const wrappedError = wrapError(error); From 69a8d061299c10075d34d26bb0359a77778a8f70 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 27 Aug 2020 08:40:56 -0700 Subject: [PATCH 02/33] [Reporting/Download CSV] Get the file name from savedSearch data (#76031) * [Reporting/Download CSV] provide title even if panel \titles are hidden in the dashboard * add functional test * Update embeddable_panel.tsx * Update download_csv.ts --- .../public/lib/panel/embeddable_panel.tsx | 1 + .../panel_actions/get_csv_panel_action.tsx | 2 +- .../apps/dashboard/reporting/download_csv.ts | 46 +++++++++++++----- .../reporting/ecommerce_kibana/data.json.gz | Bin 4138 -> 4219 bytes 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index d8659680dceb9..ca5cb5ca4f0d5 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -211,6 +211,7 @@ export class EmbeddablePanel extends React.Component { const kibanaTimezone = this.core.uiSettings.get('dateFormat:tz'); const id = `search:${embeddable.getSavedSearch().id}`; - const filename = embeddable.getTitle(); + const filename = embeddable.getSavedSearch().title; const timezone = kibanaTimezone === 'Browser' ? moment.tz.guess() : kibanaTimezone; const fromTime = dateMath.parse(from); const toTime = dateMath.parse(to); diff --git a/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts index b39613b3dbd1b..5c41945cb88d8 100644 --- a/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts +++ b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts @@ -14,15 +14,27 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; const csvPath = path.resolve(REPO_ROOT, 'target/functional-tests/downloads/Ecommerce Data.csv'); +// checks every 100ms for the file to exist in the download dir +// just wait up to 5 seconds +const getDownload$ = (filePath: string) => { + return Rx.interval(100).pipe( + map(() => fs.existsSync(filePath)), + filter((value) => value === true), + first(), + timeout(5000) + ); +}; + export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const browser = getService('browser'); const dashboardPanelActions = getService('dashboardPanelActions'); const log = getService('log'); const testSubjects = getService('testSubjects'); + const find = getService('find'); const PageObjects = getPageObjects(['reporting', 'common', 'dashboard']); - describe('Reporting Download CSV', () => { + describe('Download CSV', () => { before('initialize tests', async () => { log.debug('ReportingPage:initTests'); await esArchiver.loadIfNeeded('reporting/ecommerce'); @@ -33,10 +45,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after('clean up archives and previous file download', async () => { await esArchiver.unload('reporting/ecommerce'); await esArchiver.unload('reporting/ecommerce_kibana'); + }); + + afterEach('remove download', () => { try { fs.unlinkSync(csvPath); } catch (e) { - // nothing to worry + // it might not have been there to begin with } }); @@ -50,19 +65,26 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('embeddablePanelAction-downloadCsvReport'); await testSubjects.existOrFail('csvDownloadStarted'); // validate toast panel - // check every 100ms for the file to exist in the download dir - // just wait up to 5 seconds - const success$ = Rx.interval(100).pipe( - map(() => fs.existsSync(csvPath)), - filter((value) => value === true), - first(), - timeout(5000) - ); - - const fileExists = await success$.toPromise(); + const fileExists = await getDownload$(csvPath).toPromise(); expect(fileExists).to.be(true); // no need to validate download contents, API Integration tests do that some different variations }); + + it('Gets the correct filename if panel titles are hidden', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard Hidden Panel Titles'); + const savedSearchPanel = await find.byCssSelector( + '[data-test-embeddable-id="94eab06f-60ac-4a85-b771-3a8ed475c9bb"]' + ); // panel title is hidden + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + + await testSubjects.existOrFail('embeddablePanelAction-downloadCsvReport'); + await testSubjects.click('embeddablePanelAction-downloadCsvReport'); + await testSubjects.existOrFail('csvDownloadStarted'); + + const fileExists = await getDownload$(csvPath).toPromise(); // file exists with proper name + expect(fileExists).to.be(true); + }); }); } diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json.gz b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/data.json.gz index 454d260a518cd39001004d026afd8dc00c5e01d6..d4dd21528a882aa45d262280bb0323f2b9b46bf3 100644 GIT binary patch delta 4208 zcmV-$5RdPwAp0PHABzYGp4~=Y0s~}WbYU)Pb8l_{?OjWcB7NSQ45BSr)9&B6%c3tTUPAP9_jVi<$mWDqCusv8$s zwBhymC-gSRkI1Pik|HH)TI#nZ0;apAQ?Kv5>YPIsIUl}%rYH?|I3|s*((og%(d2|Z z9HgA`A$)$0Enwf<}SUZdPj`Zx{PJ2@0nbK3omja0v5 zX{x3XN5yS_O;b@Hn?}bo`;OUeWW$g0c^Wb4lbD1aNnno08TCogoQo)9qoAi_4$tpl z##?oyIVy5g&Dusz7g=3XYXR+jx!lof{O9Pk*FIcY=$*CDUoLb_hefybc30QCn%=UU z&LxD-NI*t}#fRPE)AF)EkGEpxigwb3#Bbwdi;!@CGh$Rs$>NmEh%V=38sYik(NW83 z>z5FZkbyeJjFC9(E~NRTrB_*_Dxy9mfzO57c>Li`Bg7-J)9CIrdeq-(G|EAV1DJJ^`v1r6B_vg3pN;(=iAQzV>mD5KbaE_J-IE_;g$cK*NJUOa_=$ zsWHKS{{1)_y%~9rxWJNpmZN4BA!lk)kh1Ts;7q(ICRLnaI&w)YXv@IKiBouT^1^_G zeN+*ZiB-|%4Nql5ivzT82-S-0@ANHbtZ=aCel>*y; zqjL+A3S1gOmc<)x!EMP1)8Ot)bwDCIZh4d)F0r-T0l-nnXb8T!VyI!!_^?NlsOLrg z%HR$ns8?tdR`EhxZyZq$XBu*;>DEBQaDcob_A$a?+Q%MC<3;nQTMX}Iu&W=~tD>I{ z>3|mE`NeD78;Uj(<|?GK<2dqDk0q^th0)%krI*33zNJ@1KW*tntBVuT8^_cmi)C3u z9Q1qrAi}q^+gEX?A!Vxw{<7C#Z{8(dor|$GN|@^$9xTQ0r%fkAcPt(hczv3PrF(cJ z5v-C^6yV1s_CQ;Buwq`lQfXZ+>Q~6SU!Leqy3ueI1IS8379VC3!FjlYh}+zMT|m4N zS7B>Wze4_8RmNO@{vgF6qwMe!mke*(%#z*o?q9Mf&@z0mlGz2|?x(>j?)88?AW5={ z&_#W=ib?6^eDa3U{>-FV#PBK^c5crrhg;xXod6^8f(w3-@+&;jC079U!iVT?GamBO zx_1)glk!<@Mn~j6KQy}uxcRw%*~ta&YBv^$X5vMo5wE^y4{VR-AYPl}vQ%X}5Tr6=cI~x~f)*)Hg!h6L3q5Bi!u?@n1a7#kl5)A!d z>xY{y$#Z3fdc_qzj%GAn@LQ6B7X|4ktn$an*>$?=iuuXOX=-uSr`nL;GO1E4rwr-_ zOnC~>YpxAeJ3#6%Vf;zu!3pW{QumFr0}d(UY@-sXRu>g{6;hn2hR+MT)Q}E_0sOGn zgJ7~{>`A+u5Fjq_tDn4o;>thnG{goL;Bb(N>u*5Wm2F2r@6&+seXskNZ)QT-?~?vz zz9}waKQLj*K1z%yQOu@Hp!|8hYBCWwN~9;XiYT_bxAN^yc@y^6vO@L>AJyhQf03E+ zqPvscX_*GP#O~zdWavgX_Pds+TWx}_c!MhDuX^RpnQh4)>kP1eSvAusdz@oUkpP}l zoWmy%aY%w~ZU#vy`a(?K!0~{FMKCQhJLY4GiTDF36NlPqh{v=9#z9PdZcdVsgDlt| zW`FnLuWrDHA+Hlm?nPp}=4ra8_jR>{JY6+>(^Fkr*HzPWbwe`|vWO;m7C7FKF0}AK zo|HhU=leX1xF;Tes^a*H`O01_2tCACTY^c!)cIT*uHz6*we60n8ivsa!}UzHZ#sI% zX}cPBtT|jRGH`h=wI#XCRVjGlTpFF6%TB?iV-V~jtFKxJd#Zr}v1{9!+QuE?8@B0z zlh$&%*udqv)Gx?oyFJb2gh^BPG{?1E)z&myHFN-sZG)(Po{4NwO}pRsP%V>-4P2hd z_63B9mfZs>L8*TPTN+oXrdYaP?6SLI>@m*>)0l1sr< zg-vsoOMr)5)HYS$HZ9fgz_A>xbyN-a9b(zO)o<5tK8p=po=X!}s}wSj{l+92z5{h> z48d_apo}qp?A_V(l9gmE52UCzIEeBHim1s0yq2n4+sNwbh{tPWS?^DmQ|}VUFyq<9 zMB{ceJ(b7Zc@34)sL)a`gnlhGpn&q=M<9EZZP< zU)9>4>089m5JvU&L>5WvONBj}q&T2I6>QW7?;0$BI>a;Nco)W^T_Khw%EVeMdOemb zty~a-+cw17mSO2vQYUzj`NA&c!D?!PN8^CNAIQwr zNTlX}YEcfatAz8bgsf;@7YXYkVO=Dwi-dKNur3mAsYp=XroK-?MZ_mX2B>x6ur3_d zg~PgVxJ=IaZidzy$vJvQo$SUEGFy(VO#;V^6c;PguBaxv zLw6D<#~Re)Mjws|R=gpOS(*2gZZQyX!O_uwoN(H#n5r0)NuW3e!WR&nXq zaJ|v={0SM1NVe=`fgSm-wc3Xwd42<0&5rH%AvKaP=Hy0lP&Cu0ob*VWfb?vWzjzRT zOqcj?C3zlKIxp!aj$5hiKHwhOSjs!VE(tQWs?!wSncGsFhMq70s0h%J zumYAXBqSu_$t-t|B)MR%I6n1&^jRItVxC!k1L%GkZn?(A6Zrhhn=rBSBoIeT@{7vi z$j#>^^2i?o;JTH@B_))Qn3CjSZb^@4R-84g0&>Ezw@c(He!r;P1o-ko&GJrvb0n-E z?+aUegRp%P$XVSe#W3O_-OE}gz3M9J~neOH4omIBr8T-#2i)Th+&q7k*LTb8ai@%zI>p zB@5d9jAyN6L!jj}2(s5422m`3NZ)(Qwk+e`w6PqOHNI0HUP@8v=!!D3C2E*;Ms}MS znV~}_j@B93LK)eZl50)KVE%PJ_HU4n6?(pcphJ^4qcCNq-m{59C9;%*Dt}{GK=w#5 zH-DNNJ_=vBhUl@xMV58+xP|DEUB_v64Ws2CXT_q&CF8|HcWI00@sGcMe)7-HzW(R4 zpIuYP7~php6G3C{URDCI*lq?J7EQ(hK70?yp?D0X*q^ew`21i*gK(5TTFW=(5!jT( zC3o9AkYv+N-o&sWYmv&N+d16VV>ySQZWRgj{XC&|p?s+!M^F*Ln3t z+%hxbIK{%{2sXczoRkx80D_SIQmR&~y^DGBi6_J3fs6k`LuOOc^rohpP1Mr=Q%+Y{ z4p>_DU{X;W!@7gK%0n%uRR73tBb@A()^i6NRRV8+0}Ez_l-$98eHsKWLnJ!g%W#*T z_09`p>F-ggw}y@ejt4D#f#(be)i(4 z?|%K_|G)d_yH8#mzxe8!Vqz(T-E2@iF9H_VfNGHDAH_>0=EX87b3~k&*>qnPedNs5 z@Uxk}2KfBo1HmMJ3nz2p#?kypQ`y*yt@wK|;ndDs2~E?)<@Vz`L%W8pd5N3ScVlb5 znyt?2YOti)UfnB3=WSHm^z7v4sltZo;o0%uuj^9J2*#S>spc=pBto z_<)AH$vfdD+26))CH2WCgxRNfP=eFXmC@PBZy1oEMG2yRv*V{!+56__p>mL(eoYnc z^l!@W+piSw?D)&H3EfV=4hPByDOSSa>F+~j@9g+FYbwL&^z)E+`XU@QmE`Q?FUlUQ z^|7aX^C_QrJmgKv>CM*qK7s`uPM%@KOQEmFSNRm$y(m<|!M9(XoqR@{iYqxB@{_R*6l&7E%W-;KGJK?a zcKjFU8V-P8_zh6XQei_96#!xU$+HaL!Rb@U@-M?-YiB2ff7@{courgKXdVi1fN^&S z-f8w*_ zXJIZm=z08n%nycf{hWRt30(*!KMylN1;d%4$Qd~h_;~s!;B+W{gGGG~f4IiSLyi)H z5^$SO6Q2I9rEJweI6L_r$M^d~_{|mjg$KN1S`3%#?MizcP%Mp#a|&V--@^fX`M8)6 zBzXMykmLS?RN=GCc7aW>iI0See~tm5_w7^Z4mm`@pHS^%?MbF}_y=EptZbZ}d?_UR zM`haiBtmY?pN5KNq5?_D+yLSKfQ>v5Zn-%~4^RIOyy(BdJ}Li{aDNS($|eXAOjD@W z6`uCZ|B4}Eyz(QyhR3;9-iD5w+~JxE4Z+)_NP%ZX7rxHTN?OyeDEHJ~e<=wlxuv|_ zQgnpcEoB@0cA&I%tCf{)MSz{+?@D6p2#*tW@;};Dzg{4WR>xOj5|jclbyG%?ha}#> z978!YawTo^7r*3<<;N}(!Waz)$xKE7 zc_%3CQ90)j(twU46wW;udk>HK8y5Zg3z?mk(YD^da4J=6unSy2Eiaq<&A$K(o8ofr GbN~RTtxO#N delta 4126 zcmV+(5aI9pAgUmLABzYGwZV^G0s~}WbYU)Pb8l_{?Oj`sNdrzb*@EK#*hkuq8AON<8M%|j3*3#=c4AP9_jVi<#LGFWHhr*16r z(1xGKKcQb2`4Kr)MN*_hO-p^(WB^aMbn5netnW_|V(zSh5O-)mLd$q;8Dd#`|EYEF09vytle zEKSuk;;6WPt7$44V$LGXftM1Ov#dr%!#hxWESE1;?YsZ z>FSpdkC1^n!Hkh4>My1Fm8Dl(qb{N$C85uS+Isx)PAkG=veWACv<+Wn-5lg0>2rf;fYxATJF_ z*hd{vl~@;D(eUJUz)FNmDOvWo4IO*1Lj-YhxExSM4p<$hc43?=in9RP*(Hbjak8Y3 zmz}R&ws}*Ux!V#HfFDC-r1IkgLkwUuB0oBRn&B}^c%(SI1aI1P@ivPzW$~CK1D7Uj zG~jWj4!We&AWQ0v=wbohAx%;?ko1dLJi0N9pU3D&e@xY7y zwZR?6P_NK9s^f*W!6c>}&Mf3g)2)Gq;Q)C}>|>0hY=}LUCClbdw;0~5VAnse*F`@Y z(g7{R^NZKEHxz9o%ymd-CrRvQ9!ooaOQXF-ORs`me@m~6e%8{K3(`J*QI~EU0ygp6E(gQq} z2-e9dO7Ihsc%UsDteID@Ra)1G`W5o-mnVADZZulQ0J4^l#fP~>a1rh><~DbK7Z9(- zDQqL^SID31%2?>nA7waVlpS8;$nd7kEZxoS{*gtAmf?f7%q{@;FbmgluLtBINz-+N zF6y&&OiC~3lQ)$1=O)c!hS$lk3wvHW+yd|V1Q>}IT=1iepYTYRTm#rkU!uFsc*t+- z-bs{Cs&};+9h3X~((ESS=J#fQrw82iZY&hd#EZvcUVYDB*dEg$5s!r)ktBs*^_#h~ zmv&or$LgR9U;D04x7_t+qpx*4-QH!c+)MHIE#zn*UZiTC<@0;ZSj8GbYR~LBuIu== zXSu82Yks)^<voK=h_U-iYt2@&1t&iw;}^C4zqDo=Z}-~bGrJ9#m&iCYI)bE-cUR;sZ(pW44MYa zcnZ)P&IaopAa$5B{-pNeg!Fi+`&QKfN0f24QH9i~i<-PjDNa?x=Y?HrM1xTX|Ja*h zINdV#q}@#k5EuCAC$G4FijO-jv4Mp+3Nms229#adb_Dbx4H@6}`j7c$CY1d?8EzJv z;wtt76ISe_!gv}dY{mqtpBJkp7jdgXdeW$fa=Uw{*zT0KVSlYEWUus5ZQ=VDxd|`3 zJL$cSX`oB&PCiaYZj2MZZ+W`aCFqJbsB-?gSKgf2R_w9K0NZtcGo7)=1=bV`;Azb{ zeDV-SB;4j^kXE8E#PkiE1T-pxX_eUtA5%`mUqG2U)J{vhrX?^23H7--Nk$IxV1JnZ z-iP110Ut)ZPB6I_iSe4J>7G8+)gJP6)$mPEb!}Z&P1Ds4%|yr|n&esFcu%^}(gS%? z0;!(w^DN??c&Uni<16Q@da)o35MONtCIwUHb7{DaLp0U4d!}j_#t;nGGu5H#=sl5_lR%UrUy>i z$mMbaSLaf{AeY_lESFOzP2JNR*K$=`(`?ny0Wh`=qIxEOvOzW7;m|{kOfENYbtbzP zWYW-bCN-^4B`UeZ8tL^MAAG8Zh-x@pTg9ersgCUoyN0jzOs83u%MDzeOJhYY1yeOP z&3P^X9&%CFRDIjDRKo+uaO<1i~$Uyd+kaYAO)TIdo z$Ju}~!LWCK=kH6_lCdI?qQ>AL$|ESErZ4bXs%~u~tFI#-uaRYaI9pD=Pawlg<`)x< z+wtsFc3-#qCWL;w`(bNQ4g%>%5qg)*L0(==R3kam6Wzw2bd5ko^5HP;h)Bxx=c_Gp^nkp4ul(HOiNu;>tf&yeF?7>jm=SXL+#8?orkSn{-T zNe~9^TG#^+Huky_gljpT>J5=^J6+TBE&F94x>qO=0#&DJODy^Yuozc}MeFL=6^3## zxtObAS$LA+&w2J-ouDHX8LEc15$bnsh_xNVy39-3HH`fgy!XluGwMFK0e*6TPSSn); zkOoN@3-Ta2D4y$6O>m@5Kq|J$|9%LVF7e+rZAu3KqbQbH+7C`lg{mh^aT#d*Uj zA*T#`yF^~b@yjYrfUhppuI@BP!us*Pu*J6s+b1M?$ltZ8T%+OQ`GES2S9N+W-Fr0g z!s=Zc;aHP6lu-oUfF_Y?EBks95OnoJeBhO2ZX-?T!A6JKddS|;fK(F<#v-6 zae_|>-#2iwTi3^k7kP2$b8c(uEPCXIC5zYnoM)|KL!jj>4D-KM62^%jeeWIHvW$DP z#&T5E_)c?psYIotE6T`Ls9|1bWVe}-89HR*=ygW6R7N(T0f3soDk7O73TUBG>RGm&!$>Q(pD@pj)RD|D8pny?Wth}EWLSnIdyw^riCq2k_>0)?V zu=98t7*P@ZOeBJKPBu%rZC2^FSLK@GTWM_w>8~=Y@?3;uX4K5a=_B^`-Nq34C@9(m4`Y`rT&rM#yH)rtmg(B zbpmgH3kzn2l-%Gx4a1iq5}oelxGS%06os+$_c&r(kXZo5LPs+(vGnk32!tz~@V^d( zw-*Qv9tdrOUI)U(f$+r_FOI+a>5H$v`^}60`|gwPe)Zz`#aGvV6cZ~U>}G@Fc@eNY z18P88d=&47EsAAO=7=~kx9Pqt`pB8<;b*h>YhLFE9|$ISI9U)kjuuCn+QwdP#ovPo zXLjCNXqqLiwjVbc+BIy=D?F6G8(Z_$Y;{&&gC))G`kok_w^41=vy-1?3L9mIXUBiL zuA`nYPImoxKdSA2QnUL>#m+T;=p9c;^ngaY>3h*8+26))E%hlTgxP0!P{PyCmGRlh zZyAuFMG51xF`{rkna*&;VO%?C-ugd7#uN3d>_{*#f-A=!b0_CF&E79om50SEW zcKn>Rl~H{9dBi(?5slhPdUo;`We?W+xu<;dDW7;U;!Vm->C)qTlK~DCYSz5Vae7=be58AJ{1@mN1;8)-1SnOhun~z%fH3~#Sq|{v^r>X| zm(i%RvlGF8+er+aq?A2q9tm)Oad!mY+I*Vh=TR{zXYnl((kR`fePw5dZ({Rm1MZ&# zh2{hJ@Xsjckq;1R)MSTvxxW*?VjuL_ra9d0ho&L3>Y&AeQJNZ4w_lHCH$rbyB2fSih z4wvigN_!0`mPW-n1u=>5Q2<|lUQP%SJpNn6asN@O@L6uVz$VzlCqhMk#{kg#_NjD- z0;1qgsP?h;B-c9p2VZ`yY@D5ZDJ1(RW!Cv5LT=2TMv7*l5=q6}0O5avjXV%;xfx`K zr~eIJ^dDfKlz&ROzea6k6NCt+Db(u#ly;(c0R*iP=6yJU-O`eN~7A< z%v#Wab}~*MLGX){2lx;INYje-0U_@vUcFng?=^AJ7(|7J9?D`_EB%17(f3I#-pwm8 zR6`?I(l&p8vuLcoW04ZZXcVM#83E*-ptMKToI^+hI*L%Z@Dl7jJmG&R#cw{y>~)N; c_2Gq6sYZic;{3F_Y~eTm06Ls^>9uG80ER^n&j0`b From 3541e779c62a9f4f0d398a7e41316cc1f33018bb Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 27 Aug 2020 10:57:37 -0500 Subject: [PATCH 03/33] Index pattern class cleanup - remove _.apply and `any` instance (#76004) Index pattern class cleanup - remove _.apply and `any` instance --- ...ins-data-public.fieldlist._constructor_.md | 4 +- ...s-data-public.indexpattern.intervalname.md | 11 ++++ ...plugin-plugins-data-public.indexpattern.md | 3 + ...ugins-data-public.indexpattern.prepbody.md | 18 +++++- ...-data-public.indexpattern.sourcefilters.md | 11 ++++ ...n-plugins-data-public.indexpattern.type.md | 11 ++++ .../index_patterns/fields/field_list.ts | 2 +- .../index_patterns/index_pattern.test.ts | 13 ++++ .../index_patterns/index_pattern.ts | 63 +++++++++++-------- src/plugins/data/public/public.api.md | 23 +++++-- 10 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.intervalname.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.sourcefilters.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.type.md diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldlist._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldlist._constructor_.md index 3b60ac0f48edd..9f9613a5a68f7 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldlist._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldlist._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `FieldList` class Signature: ```typescript -constructor(indexPattern: IndexPattern, specs?: FieldSpec[], shortDotsEnable?: boolean, onNotification?: () => void); +constructor(indexPattern: IndexPattern, specs?: FieldSpec[], shortDotsEnable?: boolean, onNotification?: OnNotification); ``` ## Parameters @@ -19,5 +19,5 @@ constructor(indexPattern: IndexPattern, specs?: FieldSpec[], shortDotsEnable?: b | indexPattern | IndexPattern | | | specs | FieldSpec[] | | | shortDotsEnable | boolean | | -| onNotification | () => void | | +| onNotification | OnNotification | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.intervalname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.intervalname.md new file mode 100644 index 0000000000000..762b4a37bfd28 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.intervalname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [intervalName](./kibana-plugin-plugins-data-public.indexpattern.intervalname.md) + +## IndexPattern.intervalName property + +Signature: + +```typescript +intervalName: string | undefined; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md index 649f8ef077e3f..c15cb3358f689 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md @@ -27,9 +27,12 @@ export declare class IndexPattern implements IIndexPattern | [formatField](./kibana-plugin-plugins-data-public.indexpattern.formatfield.md) | | any | | | [formatHit](./kibana-plugin-plugins-data-public.indexpattern.formathit.md) | | any | | | [id](./kibana-plugin-plugins-data-public.indexpattern.id.md) | | string | | +| [intervalName](./kibana-plugin-plugins-data-public.indexpattern.intervalname.md) | | string | undefined | | | [metaFields](./kibana-plugin-plugins-data-public.indexpattern.metafields.md) | | string[] | | +| [sourceFilters](./kibana-plugin-plugins-data-public.indexpattern.sourcefilters.md) | | SourceFilter[] | | | [timeFieldName](./kibana-plugin-plugins-data-public.indexpattern.timefieldname.md) | | string | undefined | | | [title](./kibana-plugin-plugins-data-public.indexpattern.title.md) | | string | | +| [type](./kibana-plugin-plugins-data-public.indexpattern.type.md) | | string | undefined | | | [typeMeta](./kibana-plugin-plugins-data-public.indexpattern.typemeta.md) | | TypeMeta | | ## Methods diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.prepbody.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.prepbody.md index 5c9f017b571da..1d77b2a55860e 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.prepbody.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.prepbody.md @@ -8,12 +8,26 @@ ```typescript prepBody(): { - [key: string]: any; + title: string; + timeFieldName: string | undefined; + intervalName: string | undefined; + sourceFilters: string | undefined; + fields: string | undefined; + fieldFormatMap: string | undefined; + type: string | undefined; + typeMeta: string | undefined; }; ``` Returns: `{ - [key: string]: any; + title: string; + timeFieldName: string | undefined; + intervalName: string | undefined; + sourceFilters: string | undefined; + fields: string | undefined; + fieldFormatMap: string | undefined; + type: string | undefined; + typeMeta: string | undefined; }` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.sourcefilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.sourcefilters.md new file mode 100644 index 0000000000000..10ccf8e137627 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.sourcefilters.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [sourceFilters](./kibana-plugin-plugins-data-public.indexpattern.sourcefilters.md) + +## IndexPattern.sourceFilters property + +Signature: + +```typescript +sourceFilters?: SourceFilter[]; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.type.md new file mode 100644 index 0000000000000..7a10d058b9c65 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [type](./kibana-plugin-plugins-data-public.indexpattern.type.md) + +## IndexPattern.type property + +Signature: + +```typescript +type: string | undefined; +``` diff --git a/src/plugins/data/common/index_patterns/fields/field_list.ts b/src/plugins/data/common/index_patterns/fields/field_list.ts index 34bd69230a2e4..d2489a5d1f7e3 100644 --- a/src/plugins/data/common/index_patterns/fields/field_list.ts +++ b/src/plugins/data/common/index_patterns/fields/field_list.ts @@ -64,7 +64,7 @@ export class FieldList extends Array implements IIndexPattern indexPattern: IndexPattern, specs: FieldSpec[] = [], shortDotsEnable = false, - onNotification = () => {} + onNotification: OnNotification = () => {} ) { super(); this.indexPattern = indexPattern; diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts index 09b79cae4aac2..f7e1156170f03 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts @@ -43,8 +43,21 @@ jest.mock('../../field_mapping', () => { id: true, title: true, fieldFormatMap: { + _serialize: jest.fn().mockImplementation(() => {}), _deserialize: jest.fn().mockImplementation(() => []), }, + fields: { + _serialize: jest.fn().mockImplementation(() => {}), + _deserialize: jest.fn().mockImplementation((fields) => fields), + }, + sourceFilters: { + _serialize: jest.fn().mockImplementation(() => {}), + _deserialize: jest.fn().mockImplementation(() => undefined), + }, + typeMeta: { + _serialize: jest.fn().mockImplementation(() => {}), + _deserialize: jest.fn().mockImplementation(() => undefined), + }, })), }; }); diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts index e81ef1d6b2482..5d6ae61a77e00 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts @@ -56,14 +56,14 @@ interface IndexPatternDeps { } export class IndexPattern implements IIndexPattern { - [key: string]: any; - public id?: string; public title: string = ''; public fieldFormatMap: any; public typeMeta?: TypeMeta; public fields: IIndexPatternFieldList & { toSpec: () => FieldSpec[] }; public timeFieldName: string | undefined; + public intervalName: string | undefined; + public type: string | undefined; public formatHit: any; public formatField: any; public flattenHit: any; @@ -72,7 +72,7 @@ export class IndexPattern implements IIndexPattern { private version: string | undefined; private savedObjectsClient: SavedObjectsClientCommon; private patternCache: PatternCache; - private sourceFilters?: SourceFilter[]; + public sourceFilters?: SourceFilter[]; private originalBody: { [key: string]: any } = {}; public fieldsFetcher: any; // probably want to factor out any direct usage and change to private private shortDotsEnable: boolean = false; @@ -126,7 +126,7 @@ export class IndexPattern implements IIndexPattern { this.shortDotsEnable = shortDotsEnable; this.metaFields = metaFields; - this.fields = new FieldList(this, [], this.shortDotsEnable, this.onUnknownType); + this.fields = new FieldList(this, [], this.shortDotsEnable, this.onNotification); this.apiClient = apiClient; this.fieldsFetcher = createFieldsFetcher(this, apiClient, metaFields); @@ -226,10 +226,13 @@ export class IndexPattern implements IIndexPattern { response[name] = fieldMapping._deserialize(response[name]); }); - // give index pattern all of the values - const fieldList = this.fields; - _.assign(this, response); - this.fields = fieldList; + this.title = response.title; + this.timeFieldName = response.timeFieldName; + this.intervalName = response.intervalName; + this.sourceFilters = response.sourceFilters; + this.fieldFormatMap = response.fieldFormatMap; + this.type = response.type; + this.typeMeta = response.typeMeta; if (!this.title && this.id) { this.title = this.id; @@ -430,18 +433,16 @@ export class IndexPattern implements IIndexPattern { } prepBody() { - const body: { [key: string]: any } = {}; - - // serialize json fields - _.forOwn(this.mapping, (fieldMapping, fieldName) => { - if (!fieldName || this[fieldName] == null) return; - - body[fieldName] = fieldMapping._serialize - ? fieldMapping._serialize(this[fieldName]) - : this[fieldName]; - }); - - return body; + return { + title: this.title, + timeFieldName: this.timeFieldName, + intervalName: this.intervalName, + sourceFilters: this.mapping.sourceFilters._serialize!(this.sourceFilters), + fields: this.mapping.fields._serialize!(this.fields), + fieldFormatMap: this.mapping.fieldFormatMap._serialize!(this.fieldFormatMap), + type: this.type, + typeMeta: this.mapping.typeMeta._serialize!(this.mapping), + }; } getFormatterForField(field: IndexPatternField | IndexPatternField['spec']): FieldFormat { @@ -485,10 +486,14 @@ export class IndexPattern implements IIndexPattern { async save(saveAttempts: number = 0): Promise { if (!this.id) return; const body = this.prepBody(); - // What keys changed since they last pulled the index pattern - const originalChangedKeys = Object.keys(body).filter( - (key) => body[key] !== this.originalBody[key] - ); + + const originalChangedKeys: string[] = []; + Object.entries(body).forEach(([key, value]) => { + if (value !== this.originalBody[key]) { + originalChangedKeys.push(key); + } + }); + return this.savedObjectsClient .update(savedObjectType, this.id, body, { version: this.version }) .then((resp) => { @@ -519,8 +524,12 @@ export class IndexPattern implements IIndexPattern { // and ensure we ignore the key if the server response // is the same as the original response (since that is expected // if we made a change in that key) - const serverChangedKeys = Object.keys(updatedBody).filter((key) => { - return updatedBody[key] !== body[key] && this.originalBody[key] !== updatedBody[key]; + + const serverChangedKeys: string[] = []; + Object.entries(updatedBody).forEach(([key, value]) => { + if (value !== (body as any)[key] && value !== this.originalBody[key]) { + serverChangedKeys.push(key); + } }); let unresolvedCollision = false; @@ -545,7 +554,7 @@ export class IndexPattern implements IIndexPattern { // Set the updated response on this object serverChangedKeys.forEach((key) => { - this[key] = samePattern[key]; + (this as any)[key] = (samePattern as any)[key]; }); this.version = samePattern.version; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 261f16229460a..9a2a82e8ed206 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -604,7 +604,8 @@ export type FieldFormatsGetConfigFn = GetConfigFn; // @public (undocumented) export class FieldList extends Array implements IIndexPatternFieldList { // Warning: (ae-forgotten-export) The symbol "FieldSpec" needs to be exported by the entry point index.d.ts - constructor(indexPattern: IndexPattern, specs?: FieldSpec[], shortDotsEnable?: boolean, onNotification?: () => void); + // Warning: (ae-forgotten-export) The symbol "OnNotification" needs to be exported by the entry point index.d.ts + constructor(indexPattern: IndexPattern, specs?: FieldSpec[], shortDotsEnable?: boolean, onNotification?: OnNotification); // (undocumented) readonly add: (field: FieldSpec) => void; // (undocumented) @@ -946,8 +947,6 @@ export class IndexPattern implements IIndexPattern { // Warning: (ae-forgotten-export) The symbol "IndexPatternDeps" needs to be exported by the entry point index.d.ts constructor(id: string | undefined, { savedObjectsClient, apiClient, patternCache, fieldFormats, onNotification, onError, shortDotsEnable, metaFields, }: IndexPatternDeps); // (undocumented) - [key: string]: any; - // (undocumented) addScriptedField(name: string, script: string, fieldType: string | undefined, lang: string): Promise; // (undocumented) create(allowOverride?: boolean): Promise; @@ -1008,6 +1007,8 @@ export class IndexPattern implements IIndexPattern { // (undocumented) initFromSpec(spec: IndexPatternSpec): this; // (undocumented) + intervalName: string | undefined; + // (undocumented) isTimeBased(): boolean; // (undocumented) isTimeBasedWildcard(): boolean; @@ -1021,7 +1022,14 @@ export class IndexPattern implements IIndexPattern { popularizeField(fieldName: string, unit?: number): Promise; // (undocumented) prepBody(): { - [key: string]: any; + title: string; + timeFieldName: string | undefined; + intervalName: string | undefined; + sourceFilters: string | undefined; + fields: string | undefined; + fieldFormatMap: string | undefined; + type: string | undefined; + typeMeta: string | undefined; }; // (undocumented) refreshFields(): Promise; @@ -1029,6 +1037,10 @@ export class IndexPattern implements IIndexPattern { removeScriptedField(fieldName: string): Promise; // (undocumented) save(saveAttempts?: number): Promise; + // Warning: (ae-forgotten-export) The symbol "SourceFilter" needs to be exported by the entry point index.d.ts + // + // (undocumented) + sourceFilters?: SourceFilter[]; // (undocumented) timeFieldName: string | undefined; // (undocumented) @@ -1040,6 +1052,8 @@ export class IndexPattern implements IIndexPattern { // (undocumented) toString(): string; // (undocumented) + type: string | undefined; + // (undocumented) typeMeta?: IndexPatternTypeMeta; } @@ -1081,7 +1095,6 @@ export interface IndexPatternAttributes { // // @public (undocumented) export class IndexPatternField implements IFieldType { - // Warning: (ae-forgotten-export) The symbol "OnNotification" needs to be exported by the entry point index.d.ts constructor(indexPattern: IndexPattern, spec: FieldSpec, displayName: string, onNotification: OnNotification); // (undocumented) get aggregatable(): boolean; From d556c79481e7fa19b3644f8e925ea0236e237a44 Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Thu, 27 Aug 2020 12:03:53 -0400 Subject: [PATCH 04/33] Test user assignment to maps tests - 2 (#75890) and removing unused data from fullscreen maps.js --- x-pack/test/functional/apps/maps/add_layer_panel.js | 6 ++++++ .../test/functional/apps/maps/blended_vector_layer.js | 10 ++++++++++ x-pack/test/functional/apps/maps/full_screen_mode.js | 7 +++++-- x-pack/test/functional/apps/maps/layer_visibility.js | 3 +++ .../functional/apps/maps/saved_object_management.js | 9 +++++++++ x-pack/test/functional/apps/maps/vector_styling.js | 7 ++++++- 6 files changed, 39 insertions(+), 3 deletions(-) diff --git a/x-pack/test/functional/apps/maps/add_layer_panel.js b/x-pack/test/functional/apps/maps/add_layer_panel.js index 3902b616cf1ee..9eb560ed42c31 100644 --- a/x-pack/test/functional/apps/maps/add_layer_panel.js +++ b/x-pack/test/functional/apps/maps/add_layer_panel.js @@ -9,17 +9,23 @@ import expect from '@kbn/expect'; export default function ({ getService, getPageObjects }) { const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['maps']); + const security = getService('security'); describe('Add layer panel', () => { const LAYER_NAME = 'World Countries'; before(async () => { + await security.testUser.setRoles(['global_maps_all']); await PageObjects.maps.openNewMap(); await PageObjects.maps.clickAddLayer(); await PageObjects.maps.selectEMSBoundariesSource(); await PageObjects.maps.selectVectorLayer(LAYER_NAME); }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should show unsaved layer in layer TOC', async () => { const vectorLayerExists = await PageObjects.maps.doesLayerExist(LAYER_NAME); expect(vectorLayerExists).to.be(true); diff --git a/x-pack/test/functional/apps/maps/blended_vector_layer.js b/x-pack/test/functional/apps/maps/blended_vector_layer.js index 9658cb3729134..9793d4b8f03d3 100644 --- a/x-pack/test/functional/apps/maps/blended_vector_layer.js +++ b/x-pack/test/functional/apps/maps/blended_vector_layer.js @@ -9,12 +9,22 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const inspector = getService('inspector'); + const security = getService('security'); describe('blended vector layer', () => { before(async () => { + await security.testUser.setRoles(['test_logstash_reader', 'global_maps_all']); await PageObjects.maps.loadSavedMap('blended document example'); }); + afterEach(async () => { + await inspector.close(); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should request documents when zoomed to smaller regions showing less data', async () => { const hits = await PageObjects.maps.getHits(); expect(hits).to.equal('33'); diff --git a/x-pack/test/functional/apps/maps/full_screen_mode.js b/x-pack/test/functional/apps/maps/full_screen_mode.js index b4ea2b0baf255..a114826f564bb 100644 --- a/x-pack/test/functional/apps/maps/full_screen_mode.js +++ b/x-pack/test/functional/apps/maps/full_screen_mode.js @@ -9,13 +9,16 @@ import expect from '@kbn/expect'; export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['maps', 'common']); const retry = getService('retry'); - const esArchiver = getService('esArchiver'); + const security = getService('security'); describe('maps full screen mode', () => { before(async () => { - await esArchiver.loadIfNeeded('maps/data'); + await security.testUser.setRoles(['global_maps_all']); await PageObjects.maps.openNewMap(); }); + after(async () => { + await security.testUser.restoreDefaults(); + }); it('full screen button should exist', async () => { const exists = await PageObjects.maps.fullScreenModeMenuItemExists(); diff --git a/x-pack/test/functional/apps/maps/layer_visibility.js b/x-pack/test/functional/apps/maps/layer_visibility.js index 22cff6de416c1..dd9b93c995695 100644 --- a/x-pack/test/functional/apps/maps/layer_visibility.js +++ b/x-pack/test/functional/apps/maps/layer_visibility.js @@ -9,14 +9,17 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const inspector = getService('inspector'); + const security = getService('security'); describe('layer visibility', () => { before(async () => { + await security.testUser.setRoles(['test_logstash_reader', 'global_maps_all']); await PageObjects.maps.loadSavedMap('document example hidden'); }); afterEach(async () => { await inspector.close(); + await security.testUser.restoreDefaults(); }); it('should not make any requests when layer is hidden', async () => { diff --git a/x-pack/test/functional/apps/maps/saved_object_management.js b/x-pack/test/functional/apps/maps/saved_object_management.js index 810df8e995064..277a8a5651453 100644 --- a/x-pack/test/functional/apps/maps/saved_object_management.js +++ b/x-pack/test/functional/apps/maps/saved_object_management.js @@ -12,6 +12,7 @@ export default function ({ getPageObjects, getService }) { const filterBar = getService('filterBar'); const browser = getService('browser'); const inspector = getService('inspector'); + const security = getService('security'); describe('map saved object management', () => { const MAP_NAME_PREFIX = 'saved_object_management_test_'; @@ -20,8 +21,16 @@ export default function ({ getPageObjects, getService }) { describe('read', () => { before(async () => { + await security.testUser.setRoles([ + 'global_maps_all', + 'geoshape_data_reader', + 'test_logstash_reader', + ]); await PageObjects.maps.loadSavedMap('join example'); }); + after(async () => { + await security.testUser.restoreDefaults(); + }); it('should update global Kibana time to value stored with map', async () => { const timeConfig = await PageObjects.timePicker.getTimeConfig(); diff --git a/x-pack/test/functional/apps/maps/vector_styling.js b/x-pack/test/functional/apps/maps/vector_styling.js index 29c01918165cf..1def542982dd8 100644 --- a/x-pack/test/functional/apps/maps/vector_styling.js +++ b/x-pack/test/functional/apps/maps/vector_styling.js @@ -6,13 +6,18 @@ import expect from '@kbn/expect'; -export default function ({ getPageObjects }) { +export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['maps']); + const security = getService('security'); describe('vector styling', () => { before(async () => { + await security.testUser.setRoles(['test_logstash_reader', 'global_maps_all']); await PageObjects.maps.loadSavedMap('document example'); }); + after(async () => { + await security.testUser.restoreDefaults(); + }); describe('categorical styling', () => { before(async () => { From 51fd423689faa64fc08de43c379e4afdd1c4b711 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 27 Aug 2020 12:42:24 -0400 Subject: [PATCH 05/33] [Docs] Fix typo in docs for `server.xsrf.disableProtection` (#76102) --- docs/setup/settings.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index e1fb1802b2a21..4a931aabd3646 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -592,7 +592,7 @@ The `server.xsrf.whitelist` setting requires the following format: [cols="2*<"] |=== -| [[settings-xsrf-disableProtection]] `status.xsrf.disableProtection:` +| [[settings-xsrf-disableProtection]] `server.xsrf.disableProtection:` | Setting this to `true` will completely disable Cross-site request forgery protection in Kibana. This is not recommended. *Default: `false`* | `status.allowAnonymous:` From 12f4f6d74ac8986bf59ab6721fa06ab8c4c671e8 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 27 Aug 2020 10:53:16 -0600 Subject: [PATCH 06/33] [Maps] fix read only badge is no longer shown in nav for users with read-only permission (#76091) --- .../maps/public/routing/maps_router.js | 21 ++++++++++++++++++- .../maps/feature_controls/maps_security.ts | 3 +-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/maps/public/routing/maps_router.js b/x-pack/plugins/maps/public/routing/maps_router.js index 9b7900d032f5a..f0f5234e3f989 100644 --- a/x-pack/plugins/maps/public/routing/maps_router.js +++ b/x-pack/plugins/maps/public/routing/maps_router.js @@ -7,7 +7,14 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Router, Switch, Route, Redirect } from 'react-router-dom'; -import { getCoreI18n, getToasts, getEmbeddableService } from '../kibana_services'; +import { i18n } from '@kbn/i18n'; +import { + getCoreChrome, + getCoreI18n, + getMapsCapabilities, + getToasts, + getEmbeddableService, +} from '../kibana_services'; import { createKbnUrlStateStorage, withNotifyOnErrors, @@ -44,6 +51,18 @@ const App = ({ history, appBasePath, onAppLeave }) => { const { originatingApp } = stateTransfer?.getIncomingEditorState({ keysToRemoveAfterFetch: ['originatingApp'] }) || {}; + if (!getMapsCapabilities().save) { + getCoreChrome().setBadge({ + text: i18n.translate('xpack.maps.badge.readOnly.text', { + defaultMessage: 'Read only', + }), + tooltip: i18n.translate('xpack.maps.badge.readOnly.tooltip', { + defaultMessage: 'Unable to save maps', + }), + iconType: 'glasses', + }); + } + return ( diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts index f480f1f0ae24a..ae9b0f095fc44 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts @@ -181,8 +181,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await testSubjects.missingOrFail('checkboxSelectAll'); }); - // This behavior was removed when the Maps app was migrated to NP - it.skip(`shows read-only badge`, async () => { + it(`shows read-only badge`, async () => { await globalNav.badgeExistsOrFail('Read only'); }); From 2010ec6ac4ba518fd032afb044f6ea0b47e3f587 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 27 Aug 2020 18:22:53 +0100 Subject: [PATCH 07/33] fix(NA): keystore read path on serve (#75659) Co-authored-by: Elastic Machine --- src/cli/serve/serve.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 345156b2491a1..c08f3aec64335 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -149,7 +149,7 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { ); merge(extraCliOptions); - merge(readKeystore(get('path.data'))); + merge(readKeystore()); return rawConfig; } From ebfba81ba54b4b25f07a98fc3eefe7a7927a3e02 Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Thu, 27 Aug 2020 13:25:01 -0400 Subject: [PATCH 08/33] [Security Solution][Resolver] break/wrap for process detail (#76095) * [Security Solution][Resolver]break/wrap for process detail * add an enzyme test to check for the breakers --- .../public/resolver/mocks/resolver_tree.ts | 2 +- .../public/resolver/view/panel.test.tsx | 17 ++++++++-- .../view/panels/panel_content_utilities.tsx | 32 +++++++++++++++++++ .../resolver/view/panels/process_details.tsx | 16 +++++++--- .../view/panels/related_event_detail.tsx | 30 +---------------- 5 files changed, 60 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts index 7edf4f8071ed8..8bd5953e9cb41 100644 --- a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts @@ -177,7 +177,7 @@ export function mockTreeWithNoAncestorsAnd2Children({ const origin: ResolverEvent = mockEndpointEvent({ pid: 0, entityID: originID, - name: 'c', + name: 'c.ext', parentEntityId: 'none', timestamp: 0, }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx index 037337fb2f868..1add907ae933d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -81,7 +81,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an }; }) ).toYieldEqualTo({ - title: 'c', + title: 'c.ext', titleIcon: 'Running Process', detailEntries: [ ['process.executable', 'executable'], @@ -94,6 +94,19 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an ], }); }); + it('should have breaking opportunities (s) in node titles to allow wrapping', async () => { + await expect( + simulator().map(() => { + const titleWrapper = simulator().testSubject('resolver:node-detail:title'); + return { + wordBreaks: titleWrapper.find('wbr').length, + }; + }) + ).toYieldEqualTo({ + // The GeneratedText component adds 1 after the period and one at the end + wordBreaks: 2, + }); + }); }); const queryStringWithFirstChildSelected = urlSearch(resolverComponentInstanceID, { @@ -174,7 +187,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an .testSubject('resolver:node-list:node-link:title') .map((node) => node.text()); }) - ).toYieldEqualTo(['c', 'd', 'e']); + ).toYieldEqualTo(['c.ext', 'd', 'e']); }); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 5c7a4a476efba..19f0aa3fe1d67 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -43,6 +43,38 @@ const betaBadgeLabel = i18n.translate( } ); +/** + * A component that renders an element with breaking opportunities (``s) + * spliced into text children at word boundaries. + */ +export const GeneratedText = React.memo(function ({ children }) { + return <>{processedValue()}; + + function processedValue() { + return React.Children.map(children, (child) => { + if (typeof child === 'string') { + const valueSplitByWordBoundaries = child.split(/\b/); + + if (valueSplitByWordBoundaries.length < 2) { + return valueSplitByWordBoundaries[0]; + } + + return [ + valueSplitByWordBoundaries[0], + ...valueSplitByWordBoundaries + .splice(1) + .reduce(function (generatedTextMemo: Array, value, index) { + return [...generatedTextMemo, value, ]; + }, []), + ]; + } else { + return child; + } + }); + } +}); +GeneratedText.displayName = 'GeneratedText'; + /** * A component to keep time representations in blocks so they don't wrap * and look bad. diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx index 15711909c4c9b..e86e3e6baf4a4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx @@ -19,7 +19,7 @@ import { FormattedMessage } from 'react-intl'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import * as selectors from '../../store/selectors'; import * as event from '../../../../common/endpoint/models/event'; -import { formatDate, StyledBreadcrumbs } from './panel_content_utilities'; +import { formatDate, StyledBreadcrumbs, GeneratedText } from './panel_content_utilities'; import { processPath, processPid, @@ -39,6 +39,10 @@ const StyledDescriptionList = styled(EuiDescriptionList)` } `; +const StyledTitle = styled('h4')` + overflow-wrap: break-word; +`; + /** * A description list view of all the Metadata that goes with a particular process event, like: * Created, PID, User/Domain, etc. @@ -114,7 +118,7 @@ export const ProcessDetails = memo(function ProcessDetails({ .map((entry) => { return { ...entry, - description: String(entry.description), + description: {String(entry.description)}, }; }); @@ -163,13 +167,15 @@ export const ProcessDetails = memo(function ProcessDetails({ -

+ - {processName} -

+ + {processName} + +
diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx index da4cd3c9dacad..6aacf91c56178 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx @@ -10,7 +10,7 @@ import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from ' import styled from 'styled-components'; import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; -import { StyledBreadcrumbs, BoldCode, StyledTime } from './panel_content_utilities'; +import { StyledBreadcrumbs, BoldCode, StyledTime, GeneratedText } from './panel_content_utilities'; import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; @@ -57,34 +57,6 @@ const TitleHr = memo(() => { }); TitleHr.displayName = 'TitleHR'; -const GeneratedText = React.memo(function ({ children }) { - return <>{processedValue()}; - - function processedValue() { - return React.Children.map(children, (child) => { - if (typeof child === 'string') { - const valueSplitByWordBoundaries = child.split(/\b/); - - if (valueSplitByWordBoundaries.length < 2) { - return valueSplitByWordBoundaries[0]; - } - - return [ - valueSplitByWordBoundaries[0], - ...valueSplitByWordBoundaries - .splice(1) - .reduce(function (generatedTextMemo: Array, value, index) { - return [...generatedTextMemo, value, ]; - }, []), - ]; - } else { - return child; - } - }); - } -}); -GeneratedText.displayName = 'GeneratedText'; - /** * Take description list entries and prepare them for display by * seeding with `` tags. From 165752b05f2e2e847c3204a762a44c430f58ebac Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 27 Aug 2020 10:39:44 -0700 Subject: [PATCH 09/33] [DOCS] Add monitoring.ui.logs.index (#75830) --- docs/settings/monitoring-settings.asciidoc | 43 ++++++++++++---------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/settings/monitoring-settings.asciidoc b/docs/settings/monitoring-settings.asciidoc index 5b8fa0725d96b..d538519eefcc4 100644 --- a/docs/settings/monitoring-settings.asciidoc +++ b/docs/settings/monitoring-settings.asciidoc @@ -5,12 +5,12 @@ Monitoring settings ++++ -By default, the Monitoring application is enabled, but data collection -is disabled. When you first start {kib} monitoring, you are prompted to -enable data collection. If you are using {stack-security-features}, you must be -signed in as a user with the `cluster:manage` privilege to enable -data collection. The built-in `superuser` role has this privilege and the -built-in `elastic` user has this role. +By default, *{stack-monitor-app}* is enabled, but data collection is disabled. +When you first start {kib} monitoring, you are prompted to enable data +collection. If you are using {stack-security-features}, you must be signed in as +a user with the `cluster:manage` privilege to enable data collection. The +built-in `superuser` role has this privilege and the built-in `elastic` user has +this role. You can adjust how monitoring data is collected from {kib} and displayed in {kib} by configuring settings in the @@ -49,7 +49,7 @@ For more information, see in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} monitoring cluster. + + - Every other request performed by the Stack Monitoring UI to the monitoring {es} + Every other request performed by *{stack-monitor-app}* to the monitoring {es} cluster uses the authenticated user's credentials, which must be the same on both the {es} monitoring cluster and the {es} production cluster. + + @@ -60,7 +60,7 @@ For more information, see in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} monitoring cluster. + + - Every other request performed by the Stack Monitoring UI to the monitoring {es} + Every other request performed by *{stack-monitor-app}* to the monitoring {es} cluster uses the authenticated user's credentials, which must be the same on both the {es} monitoring cluster and the {es} production cluster. + + @@ -83,7 +83,7 @@ These settings control how data is collected from {kib}. |=== | `monitoring.kibana.collection.enabled` | Set to `true` (default) to enable data collection from the {kib} NodeJS server - for {kib} Dashboards to be featured in the Monitoring. + for {kib} dashboards to be featured in *{stack-monitor-app}*. | `monitoring.kibana.collection.interval` | Specifies the number of milliseconds to wait in between data sampling on the @@ -96,16 +96,26 @@ These settings control how data is collected from {kib}. [[monitoring-ui-settings]] ==== Monitoring UI settings -These settings adjust how the {kib} Monitoring page displays monitoring data. +These settings adjust how *{stack-monitor-app}* displays monitoring data. However, the defaults work best in most circumstances. For more information about configuring {kib}, see -{kibana-ref}/settings.html[Setting Kibana Server Properties]. +{kibana-ref}/settings.html[Setting {kib} server properties]. [cols="2*<"] |=== | `monitoring.ui.elasticsearch.logFetchCount` - | Specifies the number of log entries to display in the Monitoring UI. Defaults to - `10`. The maximum value is `50`. + | Specifies the number of log entries to display in *{stack-monitor-app}*. + Defaults to `10`. The maximum value is `50`. + +| `monitoring.ui.enabled` + | Set to `false` to hide *{stack-monitor-app}*. The monitoring back-end + continues to run as an agent for sending {kib} stats to the monitoring + cluster. Defaults to `true`. + +| `monitoring.ui.logs.index` + | Specifies the name of the indices that are shown on the + <> page in *{stack-monitor-app}*. The default value + is `filebeat-*`. | `monitoring.ui.max_bucket_size` | Specifies the number of term buckets to return out of the overall terms list when @@ -120,18 +130,13 @@ about configuring {kib}, see `monitoring.ui.collection.interval` in `elasticsearch.yml`, use the same value in this setting. -| `monitoring.ui.enabled` - | Set to `false` to hide the Monitoring UI in {kib}. The monitoring back-end - continues to run as an agent for sending {kib} stats to the monitoring - cluster. Defaults to `true`. - |=== [float] [[monitoring-ui-cgroup-settings]] ===== Monitoring UI container settings -The Monitoring UI exposes the Cgroup statistics that we collect for you to make +*{stack-monitor-app}* exposes the Cgroup statistics that we collect for you to make better decisions about your container performance, rather than guessing based on the overall machine performance. If you are not running your applications in a container, then Cgroup statistics are not useful. From c31acce6499932fd39babcb4a984f58260df4239 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 27 Aug 2020 13:54:09 -0400 Subject: [PATCH 10/33] Fix more broken usages of `bulkCreate` (#76005) --- .../tutorial/saved_objects_installer.js | 9 +++++--- .../tutorial/saved_objects_installer.test.js | 21 +++++++++++++++++++ .../models/data_recognizer/data_recognizer.ts | 7 ++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/plugins/home/public/application/components/tutorial/saved_objects_installer.js b/src/plugins/home/public/application/components/tutorial/saved_objects_installer.js index 790c6d9c2574e..dd63827c38c5d 100644 --- a/src/plugins/home/public/application/components/tutorial/saved_objects_installer.js +++ b/src/plugins/home/public/application/components/tutorial/saved_objects_installer.js @@ -62,9 +62,12 @@ class SavedObjectsInstallerUi extends React.Component { let resp; try { - resp = await this.props.bulkCreate(this.props.savedObjects, { - overwrite: this.state.overwrite, - }); + // Filter out the saved object version field, if present, to avoid inadvertently triggering optimistic concurrency control. + const objectsToCreate = this.props.savedObjects.map( + // eslint-disable-next-line no-unused-vars + ({ version, ...savedObject }) => savedObject + ); + resp = await this.props.bulkCreate(objectsToCreate, { overwrite: this.state.overwrite }); } catch (error) { if (!this._isMounted) { return; diff --git a/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js b/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js index 6cc02184fbc16..e7b7d8ed1d7fd 100644 --- a/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js +++ b/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js @@ -79,4 +79,25 @@ describe('bulkCreate', () => { expect(component).toMatchSnapshot(); }); + + test('should filter out saved object version before calling bulkCreate', async () => { + const bulkCreateMock = jest.fn().mockResolvedValue({ + savedObjects: [savedObject], + }); + const component = mountWithIntl( + + ); + + findTestSubject(component, 'loadSavedObjects').simulate('click'); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(bulkCreateMock).toHaveBeenCalledWith([savedObject], expect.any(Object)); + }); }); diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index cc42a545c11e2..206baacd98322 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -20,6 +20,7 @@ import { getAuthorizationHeader } from '../../lib/request_authorization'; import { MlInfoResponse } from '../../../common/types/ml_server_info'; import { KibanaObjects, + KibanaObjectConfig, ModuleDataFeed, ModuleJob, Module, @@ -100,7 +101,7 @@ interface ObjectExistResponse { id: string; type: string; exists: boolean; - savedObject?: any; + savedObject?: { id: string; type: string; attributes: KibanaObjectConfig }; } interface SaveResults { @@ -678,14 +679,14 @@ export class DataRecognizer { let results = { saved_objects: [] as any[] }; const filteredSavedObjects = objectExistResults .filter((o) => o.exists === false) - .map((o) => o.savedObject); + .map((o) => o.savedObject!); if (filteredSavedObjects.length) { results = await this.savedObjectsClient.bulkCreate( // Add an empty migrationVersion attribute to each saved object to ensure // it is automatically migrated to the 7.0+ format with a references attribute. filteredSavedObjects.map((doc) => ({ ...doc, - migrationVersion: doc.migrationVersion || {}, + migrationVersion: {}, })) ); } From 1bd8f4127531a5b0426416b4ee264f1af6166a1f Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Thu, 27 Aug 2020 13:50:15 -0500 Subject: [PATCH 11/33] [ML] Add indicator if there are stopped partitions in categorization job wizard (#75709) --- .../common/results_loader/results_loader.ts | 4 + .../category_stopped_partitions.tsx | 137 ++++++++++++++++++ .../metric_selection_summary.tsx | 2 + 3 files changed, 143 insertions(+) create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/category_stopped_partitions.tsx diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts index 110b031cd1dc0..2b250b9622286 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts @@ -126,6 +126,10 @@ export class ResultsLoader { this._results$.next(this._results); } + public get results$() { + return this._results$; + } + public subscribeToResults(func: ResultsSubscriber) { return this._results$.subscribe(func); } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/category_stopped_partitions.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/category_stopped_partitions.tsx new file mode 100644 index 0000000000000..5e28a2f7c6975 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/category_stopped_partitions.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState, useMemo, useCallback } from 'react'; +import { EuiBasicTable, EuiCallOut, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { from } from 'rxjs'; +import { switchMap, takeWhile, tap } from 'rxjs/operators'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../../common/job_creator'; +import { ml } from '../../../../../../../services/ml_api_service'; +import { extractErrorProperties } from '../../../../../../../../../common/util/errors'; + +const NUMBER_OF_PREVIEW = 5; +export const CategoryStoppedPartitions: FC = () => { + const { jobCreator: jc, resultsLoader } = useContext(JobCreatorContext); + const jobCreator = jc as CategorizationJobCreator; + const [tableRow, setTableRow] = useState>([]); + const [stoppedPartitionsError, setStoppedPartitionsError] = useState(); + + const columns = useMemo( + () => [ + { + field: 'partitionName', + name: i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.stoppedPartitionsPreviewColumnName', + { + defaultMessage: 'Stopped partition names', + } + ), + render: (partition: any) => ( + + {partition} + + ), + }, + ], + [] + ); + + const loadCategoryStoppedPartitions = useCallback(async () => { + try { + const { jobs } = await ml.results.getCategoryStoppedPartitions([jobCreator.jobId]); + + if ( + !Array.isArray(jobs) && // if jobs is object of jobId: [partitions] + Array.isArray(jobs[jobCreator.jobId]) && + jobs[jobCreator.jobId].length > 0 + ) { + return jobs[jobCreator.jobId]; + } + } catch (e) { + const error = extractErrorProperties(e); + // might get 404 because job has not been created yet and that's ok + if (error.statusCode !== 404) { + setStoppedPartitionsError(error.message); + } + } + }, [jobCreator.jobId]); + + useEffect(() => { + // only need to run this check if jobCreator.perPartitionStopOnWarn is turned on + if (jobCreator.perPartitionCategorization && jobCreator.perPartitionStopOnWarn) { + // subscribe to result updates + const resultsSubscription = resultsLoader.results$ + .pipe( + switchMap(() => { + return from(loadCategoryStoppedPartitions()); + }), + tap((results) => { + if (Array.isArray(results)) { + setTableRow( + results.slice(0, NUMBER_OF_PREVIEW).map((partitionName) => ({ + partitionName, + })) + ); + } + }), + takeWhile((results) => { + return !results || (Array.isArray(results) && results.length <= NUMBER_OF_PREVIEW); + }) + ) + .subscribe(); + return () => resultsSubscription.unsubscribe(); + } + }, []); + + return ( + <> + {stoppedPartitionsError && ( + <> + + + } + /> + + )} + {Array.isArray(tableRow) && tableRow.length > 0 && ( + <> + +
+ +
+ + + } + /> + + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx index 768d8c394fb8f..9f66fb95b53a8 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx @@ -11,6 +11,7 @@ import { Results, Anomaly } from '../../../../../common/results_loader'; import { LineChartPoint } from '../../../../../common/chart_loader'; import { EventRateChart } from '../../../charts/event_rate_chart'; import { TopCategories } from './top_categories'; +import { CategoryStoppedPartitions } from './category_stopped_partitions'; const DTR_IDX = 0; @@ -73,6 +74,7 @@ export const CategorizationDetectorsSummary: FC = () => { fadeChart={jobIsRunning} /> + ); }; From a7b0f7a102f4785d6c9cefbbdc07c1049087dfe0 Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 27 Aug 2020 12:03:20 -0700 Subject: [PATCH 12/33] [Enterprise Search] Add reusable FlashMessages helper (#75901) * Set up basic shared FlashMessages & FlashMessagesLogic * Add top-level FlashMessagesProvider and history listener - This ensures that: - Our FlashMessagesLogic is a global state that persists throughout the entire app and only unmounts when the app itself does (allowing for persistent messages if needed) - history.listen enables the same behavior as previously, where flash messages would be cleared between page views * Set up queued messages that appear on page nav/load * [AS] Add FlashMessages component to Engines Overview + add Kea/Redux context/state to mountWithContext (in order for tests to pass) * Fix missing type exports, replace previous IFlashMessagesProps * [WS] Remove flashMessages state in OverviewLogic - in favor of either connecting it or using FlashMessagesLogic directly in the future * PR feedback: DRY out EUI callout color type def * PR Feedback: make flashMessages method names more explicit * PR Feedback: Shorter FlashMessagesLogic type names * PR feedback: Typing Co-authored-by: Byron Hulcher Co-authored-by: Byron Hulcher --- .../__mocks__/mount_with_context.mock.tsx | 9 +- .../engine_overview/engine_overview.tsx | 2 + .../public/applications/index.tsx | 2 + .../flash_messages/flash_messages.test.tsx | 64 +++++++++ .../shared/flash_messages/flash_messages.tsx | 43 ++++++ .../flash_messages_logic.test.ts | 136 ++++++++++++++++++ .../flash_messages/flash_messages_logic.ts | 87 +++++++++++ .../flash_messages_provider.test.tsx | 46 ++++++ .../flash_messages_provider.tsx | 30 ++++ .../shared/flash_messages/index.ts | 14 ++ .../public/applications/shared/types.ts | 9 +- .../overview/__mocks__/overview_logic.mock.ts | 1 - .../views/overview/overview_logic.test.ts | 9 -- .../views/overview/overview_logic.ts | 11 +- 14 files changed, 434 insertions(+), 29 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx index 9f8fda856eed6..826e0482acef7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -8,6 +8,10 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { mount, ReactWrapper } from 'enzyme'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; +import { getContext, resetContext } from 'kea'; + import { I18nProvider } from '@kbn/i18n/react'; import { KibanaContext } from '../'; import { mockKibanaContext } from './kibana_context.mock'; @@ -24,11 +28,14 @@ import { mockLicenseContext } from './license_context.mock'; * const wrapper = mountWithContext(, { config: { host: 'someOverride' } }); */ export const mountWithContext = (children: React.ReactNode, context?: object) => { + resetContext({ createStore: true }); + const store = getContext().store as Store; + return mount( - {children} + {children} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 74bcd9aeafb28..c3b47b2b585bd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -16,6 +16,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { FlashMessages } from '../../../shared/flash_messages'; import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing'; import { KibanaContext, IKibanaContext } from '../../../index'; @@ -88,6 +89,7 @@ export const EngineOverview: React.FC = () => { +

diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 60e4cedf413f2..a54295548004a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -22,6 +22,7 @@ import { } from 'src/core/public'; import { ClientConfigType, ClientData, PluginsSetup } from '../plugin'; import { LicenseProvider } from './shared/licensing'; +import { FlashMessagesProvider } from './shared/flash_messages'; import { HttpProvider } from './shared/http'; import { IExternalUrl } from './shared/enterprise_search_url'; import { IInitialAppData } from '../../common/types'; @@ -69,6 +70,7 @@ export const renderApp = ( + diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.test.tsx new file mode 100644 index 0000000000000..59bb7ee5b9625 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../__mocks__/kea.mock'; + +import { useValues } from 'kea'; +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiCallOut } from '@elastic/eui'; + +import { FlashMessages } from './'; + +describe('FlashMessages', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not render if no messages exist', () => { + (useValues as jest.Mock).mockImplementationOnce(() => ({ messages: [] })); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders an array of flash messages & types', () => { + const mockMessages = [ + { type: 'success', message: 'Hello world!!' }, + { + type: 'error', + message: 'Whoa nelly!', + description:
Something went wrong
, + }, + { type: 'info', message: 'Everything is fine, nothing is ruined' }, + { type: 'warning', message: 'Uh oh' }, + { type: 'info', message: 'Testing multiples of same type' }, + ]; + (useValues as jest.Mock).mockImplementationOnce(() => ({ messages: mockMessages })); + + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(5); + expect(wrapper.find(EuiCallOut).first().prop('color')).toEqual('success'); + expect(wrapper.find('[data-test-subj="error"]')).toHaveLength(1); + expect(wrapper.find(EuiCallOut).last().prop('iconType')).toEqual('iInCircle'); + }); + + it('renders any children', () => { + (useValues as jest.Mock).mockImplementationOnce(() => ({ messages: [{ type: 'success' }] })); + + const wrapper = shallow( + + + + ); + + expect(wrapper.find('[data-test-subj="testing"]').text()).toContain('Some action'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.tsx new file mode 100644 index 0000000000000..5a909a287795c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { useValues } from 'kea'; +import { EuiCallOut, EuiCallOutProps, EuiSpacer } from '@elastic/eui'; + +import { FlashMessagesLogic, IFlashMessagesValues } from './flash_messages_logic'; + +const FLASH_MESSAGE_TYPES = { + success: { color: 'success' as EuiCallOutProps['color'], icon: 'check' }, + info: { color: 'primary' as EuiCallOutProps['color'], icon: 'iInCircle' }, + warning: { color: 'warning' as EuiCallOutProps['color'], icon: 'alert' }, + error: { color: 'danger' as EuiCallOutProps['color'], icon: 'cross' }, +}; + +export const FlashMessages: React.FC = ({ children }) => { + const { messages } = useValues(FlashMessagesLogic) as IFlashMessagesValues; + + // If we have no messages to display, do not render the element at all + if (!messages.length) return null; + + return ( +
+ {messages.map(({ type, message, description }, index) => ( + + + {description} + + + + ))} + {children} +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts new file mode 100644 index 0000000000000..136912847baa9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resetContext } from 'kea'; + +import { FlashMessagesLogic, IFlashMessage } from './flash_messages_logic'; + +describe('FlashMessagesLogic', () => { + const DEFAULT_VALUES = { + messages: [], + queuedMessages: [], + historyListener: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + resetContext({}); + }); + + it('has expected default values', () => { + FlashMessagesLogic.mount(); + expect(FlashMessagesLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('setFlashMessages()', () => { + it('sets an array of messages', () => { + const messages: IFlashMessage[] = [ + { type: 'success', message: 'Hello world!!' }, + { type: 'error', message: 'Whoa nelly!', description: 'Uh oh' }, + { type: 'info', message: 'Everything is fine, nothing is ruined' }, + ]; + + FlashMessagesLogic.mount(); + FlashMessagesLogic.actions.setFlashMessages(messages); + + expect(FlashMessagesLogic.values.messages).toEqual(messages); + }); + + it('automatically converts to an array if a single message obj is passed in', () => { + const message = { type: 'success', message: 'I turn into an array!' } as IFlashMessage; + + FlashMessagesLogic.mount(); + FlashMessagesLogic.actions.setFlashMessages(message); + + expect(FlashMessagesLogic.values.messages).toEqual([message]); + }); + }); + + describe('clearFlashMessages()', () => { + it('sets messages back to an empty array', () => { + FlashMessagesLogic.mount(); + FlashMessagesLogic.actions.setFlashMessages('test' as any); + FlashMessagesLogic.actions.clearFlashMessages(); + + expect(FlashMessagesLogic.values.messages).toEqual([]); + }); + }); + + describe('setQueuedMessages()', () => { + it('sets an array of messages', () => { + const queuedMessage: IFlashMessage = { type: 'error', message: 'You deleted a thing' }; + + FlashMessagesLogic.mount(); + FlashMessagesLogic.actions.setQueuedMessages(queuedMessage); + + expect(FlashMessagesLogic.values.queuedMessages).toEqual([queuedMessage]); + }); + }); + + describe('clearQueuedMessages()', () => { + it('sets queued messages back to an empty array', () => { + FlashMessagesLogic.mount(); + FlashMessagesLogic.actions.setQueuedMessages('test' as any); + FlashMessagesLogic.actions.clearQueuedMessages(); + + expect(FlashMessagesLogic.values.queuedMessages).toEqual([]); + }); + }); + + describe('history listener logic', () => { + describe('setHistoryListener()', () => { + it('sets the historyListener value', () => { + FlashMessagesLogic.mount(); + FlashMessagesLogic.actions.setHistoryListener('test' as any); + + expect(FlashMessagesLogic.values.historyListener).toEqual('test'); + }); + }); + + describe('listenToHistory()', () => { + it('listens for history changes and clears messages on change', () => { + FlashMessagesLogic.mount(); + FlashMessagesLogic.actions.setQueuedMessages(['queuedMessages'] as any); + jest.spyOn(FlashMessagesLogic.actions, 'clearFlashMessages'); + jest.spyOn(FlashMessagesLogic.actions, 'setFlashMessages'); + jest.spyOn(FlashMessagesLogic.actions, 'clearQueuedMessages'); + jest.spyOn(FlashMessagesLogic.actions, 'setHistoryListener'); + + const mockListener = jest.fn(() => jest.fn()); + const history = { listen: mockListener } as any; + FlashMessagesLogic.actions.listenToHistory(history); + + expect(mockListener).toHaveBeenCalled(); + expect(FlashMessagesLogic.actions.setHistoryListener).toHaveBeenCalled(); + + const mockHistoryChange = (mockListener.mock.calls[0] as any)[0]; + mockHistoryChange(); + expect(FlashMessagesLogic.actions.clearFlashMessages).toHaveBeenCalled(); + expect(FlashMessagesLogic.actions.setFlashMessages).toHaveBeenCalledWith([ + 'queuedMessages', + ]); + expect(FlashMessagesLogic.actions.clearQueuedMessages).toHaveBeenCalled(); + }); + }); + + describe('beforeUnmount', () => { + it('removes history listener on unmount', () => { + const mockUnlistener = jest.fn(); + const unmount = FlashMessagesLogic.mount(); + + FlashMessagesLogic.actions.setHistoryListener(mockUnlistener); + unmount(); + + expect(mockUnlistener).toHaveBeenCalled(); + }); + + it('does not crash if no listener exists', () => { + const unmount = FlashMessagesLogic.mount(); + unmount(); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts new file mode 100644 index 0000000000000..96c7817832c52 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { kea } from 'kea'; +import { ReactNode } from 'react'; +import { History } from 'history'; + +import { IKeaLogic, TKeaReducers, IKeaParams } from '../types'; + +export interface IFlashMessage { + type: 'success' | 'info' | 'warning' | 'error'; + message: ReactNode; + description?: ReactNode; +} + +export interface IFlashMessagesValues { + messages: IFlashMessage[]; + queuedMessages: IFlashMessage[]; + historyListener: Function | null; +} +export interface IFlashMessagesActions { + setFlashMessages(messages: IFlashMessage | IFlashMessage[]): void; + clearFlashMessages(): void; + setQueuedMessages(messages: IFlashMessage | IFlashMessage[]): void; + clearQueuedMessages(): void; + listenToHistory(history: History): void; + setHistoryListener(historyListener: Function): void; +} + +const convertToArray = (messages: IFlashMessage | IFlashMessage[]) => + !Array.isArray(messages) ? [messages] : messages; + +export const FlashMessagesLogic = kea({ + actions: (): IFlashMessagesActions => ({ + setFlashMessages: (messages) => ({ messages: convertToArray(messages) }), + clearFlashMessages: () => null, + setQueuedMessages: (messages) => ({ messages: convertToArray(messages) }), + clearQueuedMessages: () => null, + listenToHistory: (history) => history, + setHistoryListener: (historyListener) => ({ historyListener }), + }), + reducers: (): TKeaReducers => ({ + messages: [ + [], + { + setFlashMessages: (_, { messages }) => messages, + clearFlashMessages: () => [], + }, + ], + queuedMessages: [ + [], + { + setQueuedMessages: (_, { messages }) => messages, + clearQueuedMessages: () => [], + }, + ], + historyListener: [ + null, + { + setHistoryListener: (_, { historyListener }) => historyListener, + }, + ], + }), + listeners: ({ values, actions }): Partial => ({ + listenToHistory: (history) => { + // On React Router navigation, clear previous flash messages and load any queued messages + const unlisten = history.listen(() => { + actions.clearFlashMessages(); + actions.setFlashMessages(values.queuedMessages); + actions.clearQueuedMessages(); + }); + actions.setHistoryListener(unlisten); + }, + }), + events: ({ values }) => ({ + beforeUnmount: () => { + const { historyListener: removeHistoryListener } = values; + if (removeHistoryListener) removeHistoryListener(); + }, + }), +} as IKeaParams) as IKeaLogic< + IFlashMessagesValues, + IFlashMessagesActions +>; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.tsx new file mode 100644 index 0000000000000..bcd7abd6d7ce2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../__mocks__/shallow_usecontext.mock'; +import '../../__mocks__/kea.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { useValues, useActions } from 'kea'; + +import { mockHistory } from '../../__mocks__'; + +import { FlashMessagesProvider } from './'; + +describe('FlashMessagesProvider', () => { + const props = { history: mockHistory as any }; + const listenToHistory = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useActions as jest.Mock).mockImplementationOnce(() => ({ listenToHistory })); + }); + + it('does not render', () => { + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('listens to history on mount', () => { + shallow(); + + expect(listenToHistory).toHaveBeenCalledWith(mockHistory); + }); + + it('does not add another history listener if one already exists', () => { + (useValues as jest.Mock).mockImplementationOnce(() => ({ historyListener: 'exists' as any })); + + shallow(); + + expect(listenToHistory).not.toHaveBeenCalledWith(props); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx new file mode 100644 index 0000000000000..584124468a91f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; +import { useValues, useActions } from 'kea'; +import { History } from 'history'; + +import { + FlashMessagesLogic, + IFlashMessagesValues, + IFlashMessagesActions, +} from './flash_messages_logic'; + +interface IFlashMessagesProviderProps { + history: History; +} + +export const FlashMessagesProvider: React.FC = ({ history }) => { + const { historyListener } = useValues(FlashMessagesLogic) as IFlashMessagesValues; + const { listenToHistory } = useActions(FlashMessagesLogic) as IFlashMessagesActions; + + useEffect(() => { + if (!historyListener) listenToHistory(history); + }, []); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts new file mode 100644 index 0000000000000..74e233ad6b320 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { FlashMessages } from './flash_messages'; +export { + FlashMessagesLogic, + IFlashMessage, + IFlashMessagesValues, + IFlashMessagesActions, +} from './flash_messages_logic'; +export { FlashMessagesProvider } from './flash_messages_provider'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts index a8e08323c5e3b..561016d36921d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -4,14 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export interface IFlashMessagesProps { - info?: string[]; - warning?: string[]; - error?: string[]; - success?: string[]; - isWrapped?: boolean; - children?: React.ReactNode; -} +export { IFlashMessage } from './flash_messages'; export interface IKeaLogic { mount(): Function; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/overview_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/overview_logic.mock.ts index 5588c4fc53b67..05715c648e5dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/overview_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/overview_logic.mock.ts @@ -22,7 +22,6 @@ export const mockLogicValues = { personalSourcesCount: 0, sourcesCount: 0, dataLoading: true, - flashMessages: {}, } as IOverviewValues; export const mockLogicActions = { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.test.ts index 3fbf0e60b5b49..61108d7cb1f2f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.test.ts @@ -76,15 +76,6 @@ describe('OverviewLogic', () => { }); }); - describe('setFlashMessages', () => { - it('will set `flashMessages`', () => { - const flashMessages = { error: ['error'] }; - OverviewLogic.actions.setFlashMessages(flashMessages); - - expect(OverviewLogic.values.flashMessages).toEqual(flashMessages); - }); - }); - describe('initializeOverview', () => { it('calls API and sets values', async () => { const setServerDataSpy = jest.spyOn(OverviewLogic.actions, 'setServerData'); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts index 057bce1b4056c..6606e5b55cb33 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts @@ -8,7 +8,7 @@ import { kea } from 'kea'; import { HttpLogic } from '../../../shared/http'; import { IAccount, IOrganization } from '../../types'; -import { IFlashMessagesProps, IKeaLogic, TKeaReducers, IKeaParams } from '../../../shared/types'; +import { IKeaLogic, TKeaReducers, IKeaParams } from '../../../shared/types'; import { IFeedActivity } from './recent_activity'; @@ -30,19 +30,16 @@ export interface IOverviewServerData { export interface IOverviewActions { setServerData(serverData: IOverviewServerData): void; - setFlashMessages(flashMessages: IFlashMessagesProps): void; initializeOverview(): void; } export interface IOverviewValues extends IOverviewServerData { dataLoading: boolean; - flashMessages: IFlashMessagesProps; } export const OverviewLogic = kea({ actions: (): IOverviewActions => ({ setServerData: (serverData) => serverData, - setFlashMessages: (flashMessages) => ({ flashMessages }), initializeOverview: () => null, }), reducers: (): TKeaReducers => ({ @@ -70,12 +67,6 @@ export const OverviewLogic = kea({ setServerData: (_, { canCreateInvitations }) => canCreateInvitations, }, ], - flashMessages: [ - {}, - { - setFlashMessages: (_, { flashMessages }) => flashMessages, - }, - ], hasUsers: [ false, { From e2e9d96df674e7cdbcaa94f33f9ca4098c0c9cc8 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Thu, 27 Aug 2020 12:15:47 -0700 Subject: [PATCH 13/33] accessibility test for Painless lab (#75688) * accessibility test for painless lab * skipped a test due to aria-violation * skipped tests due to aria-violation and added datatestsubj * removed the unwanted import * incorporate review comments * feedback incorporated * review comments incorporated * removed unwanted expect --- .../components/output_pane/output_pane.tsx | 1 + .../public/application/constants.tsx | 3 + .../test/accessibility/apps/painless_lab.ts | 65 +++++++++++++++++++ x-pack/test/accessibility/config.ts | 1 + x-pack/test/functional/config.js | 4 ++ 5 files changed, 74 insertions(+) create mode 100644 x-pack/test/accessibility/apps/painless_lab.ts diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx index e6a97bb02f738..ce597b27cc2a3 100644 --- a/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx @@ -50,6 +50,7 @@ export const OutputPane: FunctionComponent = ({ isLoading, response }) => {defaultLabel} @@ -41,6 +42,7 @@ export const painlessContextOptions = [ { value: 'filter', inputDisplay: filterLabel, + 'data-test-subj': 'filterButtonDropdown', dropdownDisplay: ( <> {filterLabel} @@ -57,6 +59,7 @@ export const painlessContextOptions = [ { value: 'score', inputDisplay: scoreLabel, + 'data-test-subj': 'scoreButtonDropdown', dropdownDisplay: ( <> {scoreLabel} diff --git a/x-pack/test/accessibility/apps/painless_lab.ts b/x-pack/test/accessibility/apps/painless_lab.ts new file mode 100644 index 0000000000000..0ec8285f50ec8 --- /dev/null +++ b/x-pack/test/accessibility/apps/painless_lab.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['common', 'security']); + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const a11y = getService('a11y'); + + describe('Accessibility Painless Lab Editor', () => { + before(async () => { + await PageObjects.common.navigateToApp('painlessLab'); + }); + + it('renders the page without a11y errors', async () => { + await PageObjects.common.navigateToApp('painlessLab'); + await a11y.testAppSnapshot(); + }); + + it('click on the output button', async () => { + const painlessTabsOutput = await find.byCssSelector( + '[data-test-subj="painlessTabs"] #output' + ); + await painlessTabsOutput.click(); + await a11y.testAppSnapshot(); + }); + + it('click on the parameters button', async () => { + const painlessTabsParameters = await find.byCssSelector( + '[data-test-subj="painlessTabs"] #parameters' + ); + await painlessTabsParameters.click(); + await a11y.testAppSnapshot(); + }); + + // github.com/elastic/kibana/issues/75876 + it.skip('click on the context button', async () => { + const painlessTabsContext = await find.byCssSelector( + '[data-test-subj="painlessTabs"] #context' + ); + await painlessTabsContext.click(); + await a11y.testAppSnapshot(); + }); + + it.skip('click on the Basic button', async () => { + await testSubjects.click('basicButtonDropdown'); + await a11y.testAppSnapshot(); + }); + + it.skip('click on the Filter button', async () => { + await testSubjects.click('filterButtonDropdown'); + await a11y.testAppSnapshot(); + }); + + it.skip('click on the Score button', async () => { + await testSubjects.click('scoreButtonDropdown'); + await a11y.testAppSnapshot(); + }); + }); +} diff --git a/x-pack/test/accessibility/config.ts b/x-pack/test/accessibility/config.ts index 7f4543d014def..0a95805754314 100644 --- a/x-pack/test/accessibility/config.ts +++ b/x-pack/test/accessibility/config.ts @@ -20,6 +20,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/grok_debugger'), require.resolve('./apps/search_profiler'), require.resolve('./apps/uptime'), + require.resolve('./apps/painless_lab'), ], pageObjects, services, diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index cdc6292ba808a..16e2cd1559fce 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -128,6 +128,10 @@ export default async function ({ readConfigFile }) { pathname: '/app/dev_tools', hash: '/searchprofiler', }, + painlessLab: { + pathname: '/app/dev_tools', + hash: '/painless_lab', + }, spaceSelector: { pathname: '/', }, From ca94923900f9ce424f08f4d32bea53b40bc0e552 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 27 Aug 2020 14:34:28 -0500 Subject: [PATCH 14/33] [Enterprise Search] Migrate util and components from ent-search (#76051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate useDidUpdateEffect hook Migrates https://github.com/elastic/ent-search/blob/master/app/javascript/shared/utils/useDidUpdateEffect.ts with test added. * Migrate TruncateContent component * Migrate TableHeader component * Remove unused deps * Fix test name * Remove custom type in favor of DependencyList * Add stylesheet for truncated content * Actually import stylesheet 🤦🏼‍♂️ * Replace legacy mixin Co-authored-by: Elastic Machine --- .../applications/shared/table_header/index.ts | 7 +++ .../shared/table_header/table_header.test.tsx | 29 ++++++++++++ .../shared/table_header/table_header.tsx | 23 +++++++++ .../applications/shared/truncate/index.ts | 8 ++++ .../shared/truncate/truncate.test.tsx | 47 +++++++++++++++++++ .../applications/shared/truncate/truncate.ts | 13 +++++ .../shared/truncate/truncated_content.scss | 35 ++++++++++++++ .../shared/truncate/truncated_content.tsx | 35 ++++++++++++++ .../shared/use_did_update_effect/index.ts | 7 +++ .../use_did_update_effect.test.tsx | 33 +++++++++++++ .../use_did_update_effect.tsx | 23 +++++++++ 11 files changed, 260 insertions(+) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/table_header/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/table_header/table_header.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/table_header/table_header.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/truncate/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncate.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncate.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncated_content.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncated_content.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/table_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/table_header/index.ts new file mode 100644 index 0000000000000..34ce070fcde46 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/table_header/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TableHeader } from './table_header'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/table_header/table_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/table_header/table_header.test.tsx new file mode 100644 index 0000000000000..70e2ac7ac6f0d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/table_header/table_header.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiTableHeader, EuiTableHeaderCell } from '@elastic/eui'; + +import { TableHeader } from './table_header'; + +const headerItems = ['foo', 'bar', 'baz']; + +describe('TableHeader', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTableHeader)).toHaveLength(1); + expect(wrapper.find(EuiTableHeaderCell)).toHaveLength(3); + }); + + it('renders extra cell', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTableHeader)).toHaveLength(1); + expect(wrapper.find(EuiTableHeaderCell)).toHaveLength(4); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/table_header/table_header.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/table_header/table_header.tsx new file mode 100644 index 0000000000000..e7f9617fdcd91 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/table_header/table_header.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiTableHeader, EuiTableHeaderCell } from '@elastic/eui'; + +interface ITableHeaderProps { + headerItems: string[]; + extraCell?: boolean; +} + +export const TableHeader: React.FC = ({ headerItems, extraCell }) => ( + + {headerItems.map((item, i) => ( + {item} + ))} + {extraCell && } + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/truncate/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/index.ts new file mode 100644 index 0000000000000..d3ee618e92b5b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { truncate, truncateBeginning } from './truncate'; +export { TruncatedContent } from './truncated_content'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncate.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncate.test.tsx new file mode 100644 index 0000000000000..aa8427cd822be --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncate.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { TruncatedContent } from './'; + +const content = 'foobarbaz'; + +describe('TruncatedContent', () => { + it('renders with no truncation', () => { + const wrapper = shallow(); + + expect(wrapper.find('span.truncated-content')).toHaveLength(0); + expect(wrapper.text()).toEqual('foo'); + }); + + it('renders with truncation at the end', () => { + const wrapper = shallow(); + const element = wrapper.find('span.truncated-content'); + + expect(element).toHaveLength(1); + expect(element.prop('title')).toEqual(content); + expect(wrapper.text()).toEqual('foob…'); + expect(wrapper.find('span.truncated-content__tooltip')).toHaveLength(0); + }); + + it('renders with truncation at the beginning', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('span.truncated-content')).toHaveLength(1); + expect(wrapper.text()).toEqual('…rbaz'); + }); + + it('renders with inline tooltip', () => { + const wrapper = shallow(); + + expect(wrapper.find('span.truncated-content').prop('title')).toEqual(''); + expect(wrapper.find('span.truncated-content__tooltip')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncate.ts b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncate.ts new file mode 100644 index 0000000000000..36094e3abe258 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncate.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function truncate(text: string, length: number) { + return `${text.substring(0, length)}…`; +} + +export function truncateBeginning(text: string, length: number) { + return `…${text.substring(text.length - length)}`; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncated_content.scss b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncated_content.scss new file mode 100644 index 0000000000000..701834acfed9d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncated_content.scss @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +.truncated-content { + position: relative; + z-index: 2; + display: inline-block; + white-space: nowrap; + + &__tooltip { + position: absolute; + top: 50%; + transform: translateY(-50%); + left: -3px; + margin-top: -1px; + background: $euiColorEmptyShade; + border-radius: 2px; + width: calc(100% + 4px); + height: calc(100% + 4px); + padding: 0 2px; + display: none; + align-items: center; + box-shadow: 0 1px 3px rgba(black, 0.1); + border: 1px solid $euiBorderColor; + width: auto; + white-space: nowrap; + + .truncated-content:hover & { + display: flex; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncated_content.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncated_content.tsx new file mode 100644 index 0000000000000..7785f75b71d34 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/truncate/truncated_content.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { truncate, truncateBeginning } from './'; + +import './truncated_content.scss'; + +interface ITruncatedContentProps { + content: string; + length: number; + beginning?: boolean; + tooltipType?: 'inline' | 'title'; +} + +export const TruncatedContent: React.FC = ({ + content, + length, + beginning = false, + tooltipType = 'inline', +}) => { + if (content.length <= length) return <>{content}; + + const inline = tooltipType === 'inline'; + return ( + + {beginning ? truncateBeginning(content, length) : truncate(content, length)} + {inline && {content}} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/index.ts new file mode 100644 index 0000000000000..05c60ebced088 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { useDidUpdateEffect } from './use_did_update_effect'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.test.tsx new file mode 100644 index 0000000000000..e3d2ffb44f01e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { mount } from 'enzyme'; + +import { EuiLink } from '@elastic/eui'; + +import { useDidUpdateEffect } from './use_did_update_effect'; + +const fn = jest.fn(); + +const TestHook = ({ value }: { value: number }) => { + const [inputValue, setValue] = useState(value); + useDidUpdateEffect(fn, [inputValue]); + return setValue(2)} />; +}; + +const wrapper = mount(); + +describe('useDidUpdateEffect', () => { + it('should not fire function when value unchanged', () => { + expect(fn).not.toHaveBeenCalled(); + }); + + it('should fire function when value changed', () => { + wrapper.find(EuiLink).simulate('click'); + expect(fn).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.tsx new file mode 100644 index 0000000000000..4c3e10fc84b84 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/use_did_update_effect/use_did_update_effect.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Sometimes we don't want to fire the initial useEffect call. + * This custom Hook only fires after the intial render has completed. + */ +import { useEffect, useRef, DependencyList } from 'react'; + +export const useDidUpdateEffect = (fn: Function, inputs: DependencyList) => { + const didMountRef = useRef(false); + + useEffect(() => { + if (didMountRef.current) { + fn(); + } else { + didMountRef.current = true; + } + }, inputs); +}; From 31507f82b6b4e6751fc694ec61b3b0cab3cbd83c Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Thu, 27 Aug 2020 15:43:52 -0400 Subject: [PATCH 15/33] [Resolver] Fix useSelector usage (#76129) In some cases we have selectors returning thunks. The thunks need to be called inside `useSelector` in order for a rerender to be reliably triggered. `useSelector` triggers a re-render if its return value changes. By calling the thunk inside of the selector passed to `useSelector`, we will trigger re-renders when needed. --- .../resolver/view/panels/process_details.tsx | 6 ++++-- .../view/panels/related_event_detail.tsx | 7 +++---- .../public/resolver/view/process_event_dot.tsx | 18 +++++++++++------- .../view/resolver_without_providers.tsx | 11 +++++++---- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx index e86e3e6baf4a4..1ec56b8aa169a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx @@ -31,7 +31,7 @@ import { import { CubeForProcess } from './cube_for_process'; import { ResolverEvent } from '../../../../common/endpoint/types'; import { useResolverTheme } from '../assets'; -import { CrumbInfo } from '../../types'; +import { CrumbInfo, ResolverState } from '../../types'; const StyledDescriptionList = styled(EuiDescriptionList)` &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { @@ -56,7 +56,9 @@ export const ProcessDetails = memo(function ProcessDetails({ }) { const processName = event.eventName(processEvent); const entityId = event.entityId(processEvent); - const isProcessTerminated = useSelector(selectors.isProcessTerminated)(entityId); + const isProcessTerminated = useSelector((state: ResolverState) => + selectors.isProcessTerminated(state)(entityId) + ); const processInfoEntry: EuiDescriptionListProps['listItems'] = useMemo(() => { const eventTime = event.eventTimestamp(processEvent); const dateTime = eventTime === undefined ? null : formatDate(eventTime); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx index 6aacf91c56178..dfafbae9c9a16 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx @@ -16,7 +16,7 @@ import { ResolverEvent } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { useResolverDispatch } from '../use_resolver_dispatch'; import { PanelContentError } from './panel_content_error'; -import { CrumbInfo } from '../../types'; +import { CrumbInfo, ResolverState } from '../../types'; // Adding some styles to prevent horizontal scrollbars, per request from UX review const StyledDescriptionList = memo(styled(EuiDescriptionList)` @@ -126,9 +126,8 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({ relatedEventCategory = naString, sections, formattedDate, - ] = useSelector(selectors.relatedEventDisplayInfoByEntityAndSelfId)( - processEntityId, - relatedEventId + ] = useSelector((state: ResolverState) => + selectors.relatedEventDisplayInfoByEntityAndSelfId(state)(processEntityId, relatedEventId) ); const waitCrumbs = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 2bb104801866f..baa8ce1fcdd86 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -12,7 +12,7 @@ import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; -import { Vector2, Matrix3 } from '../types'; +import { Vector2, Matrix3, ResolverState } from '../types'; import { SymbolIds, useResolverTheme, calculateResolverFontSize } from './assets'; import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; @@ -118,7 +118,9 @@ const UnstyledProcessEventDot = React.memo( // NB: this component should be taking nodeID as a `string` instead of handling this logic here throw new Error('Tried to render a node with no ID'); } - const relatedEventStats = useSelector(selectors.relatedEventsStats)(nodeID); + const relatedEventStats = useSelector((state: ResolverState) => + selectors.relatedEventsStats(state)(nodeID) + ); // define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID. // this is used to link nodes via aria attributes @@ -126,11 +128,13 @@ const UnstyledProcessEventDot = React.memo( htmlIDPrefix, ]); - const ariaLevel: number | null = useSelector(selectors.ariaLevel)(nodeID); + const ariaLevel: number | null = useSelector((state: ResolverState) => + selectors.ariaLevel(state)(nodeID) + ); // the node ID to 'flowto' - const ariaFlowtoNodeID: string | null = useSelector(selectors.ariaFlowtoNodeID)(timeAtRender)( - nodeID + const ariaFlowtoNodeID: string | null = useSelector((state: ResolverState) => + selectors.ariaFlowtoNodeID(state)(timeAtRender)(nodeID) ); const isShowingEventActions = xScale > 0.8; @@ -290,8 +294,8 @@ const UnstyledProcessEventDot = React.memo( ? subMenuAssets.initialMenuStatus : relatedEventOptions; - const grandTotal: number | null = useSelector(selectors.relatedEventTotalForProcess)( - event as ResolverEvent + const grandTotal: number | null = useSelector((state: ResolverState) => + selectors.relatedEventTotalForProcess(state)(event as ResolverEvent) ); /* eslint-disable jsx-a11y/click-events-have-key-events */ diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index 32faeec043f2d..aa845e7283ebe 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -21,7 +21,7 @@ import { useStateSyncingActions } from './use_state_syncing_actions'; import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; import { SideEffectContext } from './side_effect_context'; -import { ResolverProps } from '../types'; +import { ResolverProps, ResolverState } from '../types'; /** * The highest level connected Resolver component. Needs a `Provider` in its ancestry to work. @@ -46,9 +46,12 @@ export const ResolverWithoutProviders = React.memo( // use this for the entire render in order to keep things in sync const timeAtRender = timestamp(); - const { processNodePositions, connectingEdgeLineSegments } = useSelector( - selectors.visibleNodesAndEdgeLines - )(timeAtRender); + const { + processNodePositions, + connectingEdgeLineSegments, + } = useSelector((state: ResolverState) => + selectors.visibleNodesAndEdgeLines(state)(timeAtRender) + ); const terminatedProcesses = useSelector(selectors.terminatedProcesses); const { projectionMatrix, ref: cameraRef, onMouseDown } = useCamera(); From 50193eaabb3993fa662efc83caa5712ef11ae64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Thu, 27 Aug 2020 16:00:03 -0400 Subject: [PATCH 16/33] Fix alerts unable to create / update when the name has trailing whitepace(s) (#76079) * Trim alert name in API key name * Add API integration tests --- .../alerts/server/alerts_client.test.ts | 128 +++++++++++++++++- x-pack/plugins/alerts/server/alerts_client.ts | 4 +- .../tests/alerting/create.ts | 39 ++++++ .../tests/alerting/update.ts | 51 +++++++ 4 files changed, 219 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/alerts/server/alerts_client.test.ts b/x-pack/plugins/alerts/server/alerts_client.test.ts index d994269366ae6..f4aef62657abc 100644 --- a/x-pack/plugins/alerts/server/alerts_client.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client.test.ts @@ -652,6 +652,70 @@ describe('create()', () => { expect(taskManager.schedule).toHaveBeenCalledTimes(0); }); + test('should trim alert name when creating API key', async () => { + const data = getMockData({ name: ' my alert name ' }); + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: 'action', + attributes: { + actions: [], + actionTypeId: 'test', + }, + references: [], + }, + ], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: false, + name: ' my alert name ', + alertTypeId: '123', + schedule: { interval: 10000 }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + + await alertsClient.create({ data }); + expect(alertsClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: 123/my alert name'); + }); + test('should validate params', async () => { const data = getMockData(); alertTypeRegistry.get.mockReturnValue({ @@ -2896,9 +2960,13 @@ describe('update()', () => { type: 'alert', attributes: { enabled: true, + tags: ['foo'], alertTypeId: 'myType', + schedule: { interval: '10s' }, consumer: 'myApp', scheduledTaskId: 'task-123', + params: {}, + throttle: null, actions: [ { group: 'default', @@ -2927,7 +2995,7 @@ describe('update()', () => { unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingDecryptedAlert); alertTypeRegistry.get.mockReturnValue({ - id: '123', + id: 'myType', name: 'Test', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', @@ -3489,6 +3557,64 @@ describe('update()', () => { ); }); + it('should trim alert name in the API key name', async () => { + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: 'action', + attributes: { + actions: [], + actionTypeId: 'test', + }, + references: [], + }, + ], + }); + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: false, + name: ' my alert name ', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + scheduledTaskId: 'task-123', + apiKey: null, + }, + updated_at: new Date().toISOString(), + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + await alertsClient.update({ + id: '1', + data: { + ...existingAlert.attributes, + name: ' my alert name ', + }, + }); + + expect(alertsClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: myType/my alert name'); + }); + it('swallows error when invalidate API key throws', async () => { alertsClientParams.invalidateAPIKey.mockRejectedValueOnce(new Error('Fail')); unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ diff --git a/x-pack/plugins/alerts/server/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client.ts index 80e021fc5cb6e..74aef644d58ca 100644 --- a/x-pack/plugins/alerts/server/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { omit, isEqual, map, uniq, pick, truncate } from 'lodash'; +import { omit, isEqual, map, uniq, pick, truncate, trim } from 'lodash'; import { i18n } from '@kbn/i18n'; import { Logger, @@ -940,7 +940,7 @@ export class AlertsClient { } private generateAPIKeyName(alertTypeId: string, alertName: string) { - return truncate(`Alerting: ${alertTypeId}/${alertName}`, { length: 256 }); + return truncate(`Alerting: ${alertTypeId}/${trim(alertName)}`, { length: 256 }); } } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts index 8d7b9dec58cf1..983f87405a1a6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts @@ -347,6 +347,45 @@ export default function createAlertTests({ getService }: FtrProviderContext) { } }); + it('should handle create alert request appropriately when alert name has leading and trailing whitespaces', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + name: ' leading and trailing whitespace ', + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.name).to.eql(' leading and trailing whitespace '); + objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it('should handle create alert request appropriately when alert type is unregistered', async () => { const response = await supertestWithoutAuth .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts index ab3a92d0b3f70..48269cc1c4498 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -505,6 +505,57 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { } }); + it('should handle update alert request appropriately when alert name has leading and trailing whitespaces', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const updatedData = { + name: ' leading and trailing whitespace ', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + actions: [], + throttle: '1m', + }; + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(updatedData); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'update', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.name).to.eql(' leading and trailing whitespace '); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it(`shouldn't update alert from another space`, async () => { const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) From 905c76242d650dce2f9288c43ba91c8cbd5184e0 Mon Sep 17 00:00:00 2001 From: Chris Cressman Date: Thu, 27 Aug 2020 16:11:18 -0400 Subject: [PATCH 17/33] Fixes App Search documentation links (#76133) Two links to App Search docs are pointing to outdated versions. Update the URLs. --- .../app_search/components/setup_guide/setup_guide.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index fa55289e73e0b..204f355c7a31a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -19,8 +19,8 @@ export const SetupGuide: React.FC = () => ( Date: Thu, 27 Aug 2020 14:42:21 -0600 Subject: [PATCH 18/33] [Security_Solution][Resolver] Resolver loading and error state (#75600) --- .../data_access_layer/mocks/emptify_mock.ts | 88 +++++++++ .../data_access_layer/mocks/pausify_mock.ts | 124 +++++++++++++ .../resolver/test_utilities/extend_jest.ts | 12 +- .../resolver/view/clickthrough.test.tsx | 1 + .../view/resolver_loading_state.test.tsx | 167 ++++++++++++++++++ 5 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts new file mode 100644 index 0000000000000..43282848dcf9a --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../../../common/endpoint/types'; +import { mockTreeWithNoProcessEvents } from '../../mocks/resolver_tree'; +import { DataAccessLayer } from '../../types'; + +type EmptiableRequests = 'relatedEvents' | 'resolverTree' | 'entities' | 'indexPatterns'; + +interface Metadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * A record of entityIDs to be used in tests assertions. + */ + entityIDs: T; +} + +/** + * A simple mock dataAccessLayer that allows you to control whether a request comes back with data or empty. + */ +export function emptifyMock( + { + metadata, + dataAccessLayer, + }: { + dataAccessLayer: DataAccessLayer; + metadata: Metadata; + }, + dataShouldBeEmpty: EmptiableRequests[] +): { + dataAccessLayer: DataAccessLayer; + metadata: Metadata; +} { + return { + metadata, + dataAccessLayer: { + /** + * Fetch related events for an entity ID + */ + async relatedEvents(...args): Promise { + return dataShouldBeEmpty.includes('relatedEvents') + ? Promise.resolve({ + entityID: args[0], + events: [], + nextEvent: null, + }) + : dataAccessLayer.relatedEvents(...args); + }, + + /** + * Fetch a ResolverTree for a entityID + */ + async resolverTree(...args): Promise { + return dataShouldBeEmpty.includes('resolverTree') + ? Promise.resolve(mockTreeWithNoProcessEvents()) + : dataAccessLayer.resolverTree(...args); + }, + + /** + * Get an array of index patterns that contain events. + */ + indexPatterns(...args): string[] { + return dataShouldBeEmpty.includes('indexPatterns') + ? [] + : dataAccessLayer.indexPatterns(...args); + }, + + /** + * Get entities matching a document. + */ + async entities(...args): Promise { + return dataShouldBeEmpty.includes('entities') + ? Promise.resolve([]) + : dataAccessLayer.entities(...args); + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts new file mode 100644 index 0000000000000..baddcdfd0cd84 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../../../common/endpoint/types'; +import { DataAccessLayer } from '../../types'; + +type PausableRequests = 'relatedEvents' | 'resolverTree' | 'entities'; + +interface Metadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * A record of entityIDs to be used in tests assertions. + */ + entityIDs: T; +} + +/** + * A simple mock dataAccessLayer that allows you to manually pause and resume a request. + */ +export function pausifyMock({ + metadata, + dataAccessLayer, +}: { + dataAccessLayer: DataAccessLayer; + metadata: Metadata; +}): { + dataAccessLayer: DataAccessLayer; + metadata: Metadata; + pause: (pausableRequests: PausableRequests[]) => void; + resume: (pausableRequests: PausableRequests[]) => void; +} { + let relatedEventsPromise = Promise.resolve(); + let resolverTreePromise = Promise.resolve(); + let entitiesPromise = Promise.resolve(); + + let relatedEventsResolver: (() => void) | null; + let resolverTreeResolver: (() => void) | null; + let entitiesResolver: (() => void) | null; + + return { + metadata, + pause: (pausableRequests: PausableRequests[]) => { + const pauseRelatedEventsRequest = pausableRequests.includes('relatedEvents'); + const pauseResolverTreeRequest = pausableRequests.includes('resolverTree'); + const pauseEntitiesRequest = pausableRequests.includes('entities'); + + if (pauseRelatedEventsRequest && !relatedEventsResolver) { + relatedEventsPromise = new Promise((resolve) => { + relatedEventsResolver = resolve; + }); + } + if (pauseResolverTreeRequest && !resolverTreeResolver) { + resolverTreePromise = new Promise((resolve) => { + resolverTreeResolver = resolve; + }); + } + if (pauseEntitiesRequest && !entitiesResolver) { + entitiesPromise = new Promise((resolve) => { + entitiesResolver = resolve; + }); + } + }, + resume: (pausableRequests: PausableRequests[]) => { + const resumeEntitiesRequest = pausableRequests.includes('entities'); + const resumeResolverTreeRequest = pausableRequests.includes('resolverTree'); + const resumeRelatedEventsRequest = pausableRequests.includes('relatedEvents'); + + if (resumeEntitiesRequest && entitiesResolver) { + entitiesResolver(); + entitiesResolver = null; + } + if (resumeResolverTreeRequest && resolverTreeResolver) { + resolverTreeResolver(); + resolverTreeResolver = null; + } + if (resumeRelatedEventsRequest && relatedEventsResolver) { + relatedEventsResolver(); + relatedEventsResolver = null; + } + }, + dataAccessLayer: { + /** + * Fetch related events for an entity ID + */ + async relatedEvents(...args): Promise { + await relatedEventsPromise; + return dataAccessLayer.relatedEvents(...args); + }, + + /** + * Fetch a ResolverTree for a entityID + */ + async resolverTree(...args): Promise { + await resolverTreePromise; + return dataAccessLayer.resolverTree(...args); + }, + + /** + * Get an array of index patterns that contain events. + */ + indexPatterns(...args): string[] { + return dataAccessLayer.indexPatterns(...args); + }, + + /** + * Get entities matching a document. + */ + async entities(...args): Promise { + await entitiesPromise; + return dataAccessLayer.entities(...args); + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts index df8f32d15a7ab..aa04221361de0 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts @@ -44,7 +44,7 @@ expect.extend({ const received: T[] = []; // Set to true if the test passes. - let pass: boolean = false; + let lastCheckPassed: boolean = false; // Async iterate over the iterable for await (const next of receivedIterable) { @@ -52,15 +52,17 @@ expect.extend({ received.push(next); // Use deep equals to compare the value to the expected value if (this.equals(next, expected)) { - // If the value is equal, break - pass = true; + lastCheckPassed = true; + } else if (lastCheckPassed) { + // the previous check passed but this one didn't + lastCheckPassed = false; break; } } // Use `pass` as set in the above loop (or initialized to `false`) // See https://jestjs.io/docs/en/expect#custom-matchers-api and https://jestjs.io/docs/en/expect#thisutils - const message = pass + const message = lastCheckPassed ? () => `${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\n` + `Expected: not ${this.utils.printExpected(expected)}\n${ @@ -84,7 +86,7 @@ expect.extend({ ) .join(`\n\n`)}`; - return { message, pass }; + return { message, pass: lastCheckPassed }; }, /** * A custom matcher that takes an async generator and compares each value it yields to an expected value. diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx index 358fcd17b998a..1e5ac093cac77 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -242,6 +242,7 @@ describe('Resolver, when analyzing a tree that has two related events for the or ); if (button) { button.simulate('click'); + button.simulate('click'); // The first click opened the menu, this second click closes it } }); it('should close the submenu', async () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx new file mode 100644 index 0000000000000..c357ee18acfeb --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Simulator } from '../test_utilities/simulator'; +import { pausifyMock } from '../data_access_layer/mocks/pausify_mock'; +import { emptifyMock } from '../data_access_layer/mocks/emptify_mock'; +import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children'; +import '../test_utilities/extend_jest'; + +describe('Resolver: data loading and resolution states', () => { + let simulator: Simulator; + const resolverComponentInstanceID = 'resolver-loading-resolution-states'; + + describe('When entities data is being requested', () => { + beforeEach(() => { + const { + metadata: { databaseDocumentID }, + dataAccessLayer, + pause, + } = pausifyMock(noAncestorsTwoChildren()); + pause(['entities']); + simulator = new Simulator({ + dataAccessLayer, + databaseDocumentID, + resolverComponentInstanceID, + }); + }); + + it('should display a loading state', async () => { + await expect( + simulator.map(() => ({ + resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, + resolverGraphError: simulator.testSubject('resolver:graph:error').length, + resolverGraph: simulator.testSubject('resolver:graph').length, + })) + ).toYieldEqualTo({ + resolverGraphLoading: 1, + resolverGraphError: 0, + resolverGraph: 0, + }); + }); + }); + + describe('When resolver tree data is being requested', () => { + beforeEach(() => { + const { + metadata: { databaseDocumentID }, + dataAccessLayer, + pause, + } = pausifyMock(noAncestorsTwoChildren()); + pause(['resolverTree']); + simulator = new Simulator({ + dataAccessLayer, + databaseDocumentID, + resolverComponentInstanceID, + }); + }); + + it('should display a loading state', async () => { + await expect( + simulator.map(() => ({ + resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, + resolverGraphError: simulator.testSubject('resolver:graph:error').length, + resolverGraph: simulator.testSubject('resolver:graph').length, + })) + ).toYieldEqualTo({ + resolverGraphLoading: 1, + resolverGraphError: 0, + resolverGraph: 0, + }); + }); + }); + + describe("When the entities request doesn't return any data", () => { + beforeEach(() => { + const { + metadata: { databaseDocumentID }, + dataAccessLayer, + } = emptifyMock(noAncestorsTwoChildren(), ['entities']); + + simulator = new Simulator({ + dataAccessLayer, + databaseDocumentID, + resolverComponentInstanceID, + }); + }); + + it('should display an error', async () => { + await expect( + simulator.map(() => ({ + resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, + resolverGraphError: simulator.testSubject('resolver:graph:error').length, + resolverGraph: simulator.testSubject('resolver:graph').length, + })) + ).toYieldEqualTo({ + resolverGraphLoading: 0, + resolverGraphError: 1, + resolverGraph: 0, + }); + }); + }); + + describe("When the resolver tree request doesn't return any data", () => { + beforeEach(() => { + const { + metadata: { databaseDocumentID }, + dataAccessLayer, + } = emptifyMock(noAncestorsTwoChildren(), ['resolverTree']); + + simulator = new Simulator({ + dataAccessLayer, + databaseDocumentID, + resolverComponentInstanceID, + }); + }); + + it('should display a resolver graph with 0 nodes', async () => { + await expect( + simulator.map(() => ({ + resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, + resolverGraphError: simulator.testSubject('resolver:graph:error').length, + resolverGraph: simulator.testSubject('resolver:graph').length, + resolverGraphNodes: simulator.testSubject('resolver:node').length, + })) + ).toYieldEqualTo({ + resolverGraphLoading: 0, + resolverGraphError: 0, + resolverGraph: 1, + resolverGraphNodes: 0, + }); + }); + }); + + describe('When all resolver data requests successfully resolve', () => { + beforeEach(async () => { + const { + metadata: { databaseDocumentID }, + dataAccessLayer, + } = noAncestorsTwoChildren(); + + simulator = new Simulator({ + dataAccessLayer, + databaseDocumentID, + resolverComponentInstanceID, + }); + }); + + it('should display the resolver graph with 3 nodes', async () => { + await expect( + simulator.map(() => ({ + resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, + resolverGraphError: simulator.testSubject('resolver:graph:error').length, + resolverGraph: simulator.testSubject('resolver:graph').length, + resolverGraphNodes: simulator.testSubject('resolver:node').length, + })) + ).toYieldEqualTo({ + resolverGraphLoading: 0, + resolverGraphError: 0, + resolverGraph: 1, + resolverGraphNodes: 3, + }); + }); + }); +}); From 89ae03221b99c2cf06aa007b3f055e6f0cd43537 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 27 Aug 2020 14:19:41 -0700 Subject: [PATCH 19/33] [docs/getting-started] link to yarn v1 specifically (#76169) Co-authored-by: spalger --- docs/developer/getting-started/index.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index 10e603a8da8bb..9b334a55c4203 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -30,7 +30,7 @@ you can switch to the correct version when using nvm by running: nvm use ---- -Install the latest version of https://yarnpkg.com[yarn]. +Install the latest version of https://classic.yarnpkg.com/en/docs/install[yarn v1]. Bootstrap {kib} and install all the dependencies: From 64311d306f88f649677a26b1f9a50c2f39b1a2aa Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 27 Aug 2020 14:56:48 -0700 Subject: [PATCH 20/33] [plugin-helpers] improve 3rd party KP plugin support (#75019) Co-authored-by: Tyler Smalley Co-authored-by: spalger --- .../external-plugin-functional-tests.asciidoc | 4 +- packages/kbn-dev-utils/package.json | 18 +- packages/kbn-dev-utils/src/babel.ts | 59 +++++ packages/kbn-dev-utils/src/index.ts | 3 + .../src/parse_kibana_platform_plugin.ts | 59 +++++ packages/kbn-dev-utils/src/run/flags.ts | 4 +- .../kbn-dev-utils/src/serializers/index.ts | 1 + .../src/serializers/replace_serializer.ts | 36 +++ ...simple_kibana_platform_plugin_discovery.ts | 54 +---- .../src/streams.ts | 53 ++-- .../src/optimizer/kibana_platform_plugins.ts | 2 +- .../kbn-plugin-generator/src/plugin_types.ts | 60 +++++ .../src/render_template.ts | 25 +- .../template/README.md.ejs | 11 + .../template/package.json.ejs | 2 + packages/kbn-plugin-helpers/package.json | 35 +-- .../build_context.ts} | 20 +- packages/kbn-plugin-helpers/src/cli.ts | 123 ++++++---- packages/kbn-plugin-helpers/src/config.ts | 83 +++++++ .../src/{lib/run.ts => find_kibana_json.ts} | 26 +- .../src/{tasks/start => }/index.ts | 2 +- .../src/integration_tests/build.test.ts | 123 ++++++++++ .../commander_action.test.js.snap | 43 ---- .../src/lib/commander_action.test.js | 87 ------- .../src/lib/commander_action.ts | 36 --- .../kbn-plugin-helpers/src/lib/config_file.ts | 69 ------ .../lib/enable_collecting_unknown_options.ts | 30 --- .../kbn-plugin-helpers/src/lib/pipeline.ts | 23 -- .../src/lib/plugin_config.ts | 74 ------ packages/kbn-plugin-helpers/src/lib/utils.ts | 42 ---- .../kbn-plugin-helpers/src/lib/win_cmd.ts | 24 -- ...task.ts => load_kibana_platform_plugin.ts} | 41 ++-- .../tasks.ts => resolve_kibana_version.ts} | 33 +-- .../src/tasks/build/README.md | 19 -- .../src/tasks/build/build_task.ts | 63 ----- .../src/tasks/build/create_build.ts | 179 -------------- .../src/tasks/build/git_info.ts | 46 ---- .../src/tasks/build/index.ts | 20 -- .../build_action_test_plugin/package.json | 16 -- .../translations/es.json | 4 - .../create_build_test_plugin/index.js | 20 -- .../create_build_test_plugin/package.json | 16 -- .../translations/es.json | 4 - .../create_package_test_plugin/index.js | 20 -- .../create_package_test_plugin/package.json | 16 -- .../translations/es.json | 4 - .../__snapshots__/build_action.test.js.snap | 3 - .../integration_tests/build_action.test.js | 117 --------- .../integration_tests/create_build.test.js | 87 ------- .../integration_tests/create_package.test.js | 48 ---- .../src/tasks/build/rewrite_package_json.ts | 54 ----- .../src/{lib/docs.ts => tasks/clean.ts} | 26 +- .../create_package.ts => create_archive.ts} | 48 ++-- .../src/{lib => tasks}/index.ts | 10 +- .../kbn-plugin-helpers/src/tasks/optimize.ts | 53 ++++ .../src/tasks/start/README.md | 6 - .../src/tasks/start/start_task.ts | 51 ---- .../src/tasks/test/mocha/README.md | 45 ---- .../src/tasks/test/mocha/index.ts | 20 -- .../src/tasks/write_server_files.ts | 101 ++++++++ .../src/tasks/yarn_install.ts | 40 ++++ packages/kbn-plugin-helpers/tsconfig.json | 5 +- .../index.js => scripts/plugin_helpers.js | 3 +- src/setup_node_env/prebuilt_dev_only_entry.js | 1 + .../plugins/kbn_tp_run_pipeline/package.json | 2 +- .../kbn_tp_custom_visualizations/package.json | 2 +- x-pack/.kibana-plugin-helpers.json | 35 --- x-pack/gulpfile.js | 2 - x-pack/package.json | 4 +- x-pack/scripts/api_debug.js | 2 +- x-pack/scripts/functional_test_runner.js | 2 +- x-pack/scripts/functional_tests.js | 2 +- x-pack/scripts/functional_tests_server.js | 2 +- x-pack/scripts/jest.js | 2 +- x-pack/tasks/build.ts | 68 +++++- x-pack/tasks/dev.ts | 14 -- .../common/config.ts | 8 +- .../spaces_api_integration/common/config.ts | 6 +- yarn.lock | 226 +++++++++++++----- 79 files changed, 1146 insertions(+), 1681 deletions(-) create mode 100644 packages/kbn-dev-utils/src/babel.ts create mode 100644 packages/kbn-dev-utils/src/parse_kibana_platform_plugin.ts create mode 100644 packages/kbn-dev-utils/src/serializers/replace_serializer.ts rename packages/{kbn-plugin-generator => kbn-dev-utils}/src/streams.ts (62%) create mode 100644 packages/kbn-plugin-generator/src/plugin_types.ts rename packages/kbn-plugin-helpers/{bin/plugin-helpers.js => src/build_context.ts} (73%) mode change 100755 => 100644 create mode 100644 packages/kbn-plugin-helpers/src/config.ts rename packages/kbn-plugin-helpers/src/{lib/run.ts => find_kibana_json.ts} (65%) rename packages/kbn-plugin-helpers/src/{tasks/start => }/index.ts (96%) create mode 100644 packages/kbn-plugin-helpers/src/integration_tests/build.test.ts delete mode 100644 packages/kbn-plugin-helpers/src/lib/__snapshots__/commander_action.test.js.snap delete mode 100644 packages/kbn-plugin-helpers/src/lib/commander_action.test.js delete mode 100644 packages/kbn-plugin-helpers/src/lib/commander_action.ts delete mode 100644 packages/kbn-plugin-helpers/src/lib/config_file.ts delete mode 100644 packages/kbn-plugin-helpers/src/lib/enable_collecting_unknown_options.ts delete mode 100644 packages/kbn-plugin-helpers/src/lib/pipeline.ts delete mode 100644 packages/kbn-plugin-helpers/src/lib/plugin_config.ts delete mode 100644 packages/kbn-plugin-helpers/src/lib/utils.ts delete mode 100644 packages/kbn-plugin-helpers/src/lib/win_cmd.ts rename packages/kbn-plugin-helpers/src/{tasks/test/mocha/test_mocha_task.ts => load_kibana_platform_plugin.ts} (52%) rename packages/kbn-plugin-helpers/src/{lib/tasks.ts => resolve_kibana_version.ts} (58%) delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/README.md delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/build_task.ts delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/create_build.ts delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/git_info.ts delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/index.ts delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__fixtures__/build_action_test_plugin/package.json delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__fixtures__/build_action_test_plugin/translations/es.json delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__fixtures__/create_build_test_plugin/index.js delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__fixtures__/create_build_test_plugin/package.json delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__fixtures__/create_build_test_plugin/translations/es.json delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__fixtures__/create_package_test_plugin/index.js delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__fixtures__/create_package_test_plugin/package.json delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__fixtures__/create_package_test_plugin/translations/es.json delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__snapshots__/build_action.test.js.snap delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/build_action.test.js delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/create_build.test.js delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/integration_tests/create_package.test.js delete mode 100644 packages/kbn-plugin-helpers/src/tasks/build/rewrite_package_json.ts rename packages/kbn-plugin-helpers/src/{lib/docs.ts => tasks/clean.ts} (62%) rename packages/kbn-plugin-helpers/src/tasks/{build/create_package.ts => create_archive.ts} (53%) rename packages/kbn-plugin-helpers/src/{lib => tasks}/index.ts (83%) create mode 100644 packages/kbn-plugin-helpers/src/tasks/optimize.ts delete mode 100644 packages/kbn-plugin-helpers/src/tasks/start/README.md delete mode 100644 packages/kbn-plugin-helpers/src/tasks/start/start_task.ts delete mode 100644 packages/kbn-plugin-helpers/src/tasks/test/mocha/README.md delete mode 100644 packages/kbn-plugin-helpers/src/tasks/test/mocha/index.ts create mode 100644 packages/kbn-plugin-helpers/src/tasks/write_server_files.ts create mode 100644 packages/kbn-plugin-helpers/src/tasks/yarn_install.ts rename packages/kbn-plugin-helpers/src/tasks/build/integration_tests/__fixtures__/build_action_test_plugin/index.js => scripts/plugin_helpers.js (88%) delete mode 100644 x-pack/.kibana-plugin-helpers.json delete mode 100644 x-pack/tasks/dev.ts diff --git a/docs/developer/plugin/external-plugin-functional-tests.asciidoc b/docs/developer/plugin/external-plugin-functional-tests.asciidoc index 706bf6af8ed9b..7e5b5b79d06e9 100644 --- a/docs/developer/plugin/external-plugin-functional-tests.asciidoc +++ b/docs/developer/plugin/external-plugin-functional-tests.asciidoc @@ -13,7 +13,7 @@ To get started copy and paste this example to `test/functional/config.js`: ["source","js"] ----------- import { resolve } from 'path'; -import { resolveKibanaPath } from '@kbn/plugin-helpers'; +import { REPO_ROOT } from '@kbn/dev-utils'; import { MyServiceProvider } from './services/my_service'; import { MyAppPageProvider } from './services/my_app_page'; @@ -24,7 +24,7 @@ export default async function ({ readConfigFile }) { // read the {kib} config file so that we can utilize some of // its services and PageObjects - const kibanaConfig = await readConfigFile(resolveKibanaPath('test/functional/config.js')); + const kibanaConfig = await readConfigFile(resolve(REPO_ROOT, 'test/functional/config.js')); return { // list paths to the files that contain your plugins tests diff --git a/packages/kbn-dev-utils/package.json b/packages/kbn-dev-utils/package.json index 768a67794517f..4f6f995f38f31 100644 --- a/packages/kbn-dev-utils/package.json +++ b/packages/kbn-dev-utils/package.json @@ -1,32 +1,38 @@ { "name": "@kbn/dev-utils", - "main": "./target/index.js", "version": "1.0.0", - "license": "Apache-2.0", "private": true, + "license": "Apache-2.0", + "main": "./target/index.js", "scripts": { "build": "tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" }, "dependencies": { + "@babel/core": "^7.11.1", "axios": "^0.19.0", "chalk": "^4.1.0", + "cheerio": "0.22.0", "dedent": "^0.7.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", "getopts": "^2.2.5", + "globby": "^8.0.1", "load-json-file": "^6.2.0", - "normalize-path": "^3.0.0", + "markdown-it": "^10.0.0", "moment": "^2.24.0", + "normalize-path": "^3.0.0", "rxjs": "^6.5.5", "strip-ansi": "^6.0.0", "tree-kill": "^1.2.2", - "tslib": "^2.0.0" + "vinyl": "^2.2.0" }, "devDependencies": { - "typescript": "4.0.2", + "@kbn/babel-preset": "1.0.0", "@kbn/expect": "1.0.0", - "chance": "1.0.18" + "@types/vinyl": "^2.0.4", + "chance": "1.0.18", + "typescript": "4.0.2" } } diff --git a/packages/kbn-dev-utils/src/babel.ts b/packages/kbn-dev-utils/src/babel.ts new file mode 100644 index 0000000000000..e48fe81d0232c --- /dev/null +++ b/packages/kbn-dev-utils/src/babel.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import File from 'vinyl'; +import * as Babel from '@babel/core'; + +const transformedFiles = new WeakSet(); + +/** + * Returns a promise that resolves when the file has been + * mutated so the contents of the file are tranformed with + * babel, include inline sourcemaps, and the filename has + * been updated to use .js. + * + * If the file was previously transformed with this function + * the promise will just resolve immediately. + */ +export async function transformFileWithBabel(file: File) { + if (!(file.contents instanceof Buffer)) { + throw new Error('file must be buffered'); + } + + if (transformedFiles.has(file)) { + return; + } + + const source = file.contents.toString('utf8'); + const result = await Babel.transformAsync(source, { + babelrc: false, + configFile: false, + sourceMaps: 'inline', + filename: file.path, + presets: [require.resolve('@kbn/babel-preset/node_preset')], + }); + + if (!result || typeof result.code !== 'string') { + throw new Error('babel transformation failed without an error...'); + } + + file.contents = Buffer.from(result.code); + file.extname = '.js'; + transformedFiles.add(file); +} diff --git a/packages/kbn-dev-utils/src/index.ts b/packages/kbn-dev-utils/src/index.ts index 798746d159f60..2871fe2ffcf4a 100644 --- a/packages/kbn-dev-utils/src/index.ts +++ b/packages/kbn-dev-utils/src/index.ts @@ -41,3 +41,6 @@ export * from './stdio'; export * from './ci_stats_reporter'; export * from './plugin_list'; export * from './simple_kibana_platform_plugin_discovery'; +export * from './streams'; +export * from './babel'; +export * from './parse_kibana_platform_plugin'; diff --git a/packages/kbn-dev-utils/src/parse_kibana_platform_plugin.ts b/packages/kbn-dev-utils/src/parse_kibana_platform_plugin.ts new file mode 100644 index 0000000000000..83d8c2684d7ca --- /dev/null +++ b/packages/kbn-dev-utils/src/parse_kibana_platform_plugin.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; +import loadJsonFile from 'load-json-file'; + +export interface KibanaPlatformPlugin { + readonly directory: string; + readonly manifestPath: string; + readonly manifest: { + id: string; + ui: boolean; + server: boolean; + [key: string]: unknown; + }; +} + +export function parseKibanaPlatformPlugin(manifestPath: string): KibanaPlatformPlugin { + if (!Path.isAbsolute(manifestPath)) { + throw new TypeError('expected new platform manifest path to be absolute'); + } + + const manifest = loadJsonFile.sync(manifestPath); + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + throw new TypeError('expected new platform plugin manifest to be a JSON encoded object'); + } + + if (typeof manifest.id !== 'string') { + throw new TypeError('expected new platform plugin manifest to have a string id'); + } + + return { + directory: Path.dirname(manifestPath), + manifestPath, + manifest: { + ...manifest, + + ui: !!manifest.ui, + server: !!manifest.server, + id: manifest.id, + }, + }; +} diff --git a/packages/kbn-dev-utils/src/run/flags.ts b/packages/kbn-dev-utils/src/run/flags.ts index 12642bceca15a..54758b4a7dbf8 100644 --- a/packages/kbn-dev-utils/src/run/flags.ts +++ b/packages/kbn-dev-utils/src/run/flags.ts @@ -52,8 +52,8 @@ export function mergeFlagOptions(global: FlagOptions = {}, local: FlagOptions = boolean: [...(global.boolean || []), ...(local.boolean || [])], string: [...(global.string || []), ...(local.string || [])], default: { - ...global.alias, - ...local.alias, + ...global.default, + ...local.default, }, help: local.help, diff --git a/packages/kbn-dev-utils/src/serializers/index.ts b/packages/kbn-dev-utils/src/serializers/index.ts index e645a3be3fe5d..6e0ac0b8be029 100644 --- a/packages/kbn-dev-utils/src/serializers/index.ts +++ b/packages/kbn-dev-utils/src/serializers/index.ts @@ -21,3 +21,4 @@ export * from './absolute_path_serializer'; export * from './strip_ansi_serializer'; export * from './recursive_serializer'; export * from './any_instance_serizlizer'; +export * from './replace_serializer'; diff --git a/packages/kbn-dev-utils/src/serializers/replace_serializer.ts b/packages/kbn-dev-utils/src/serializers/replace_serializer.ts new file mode 100644 index 0000000000000..06096c4bee3a2 --- /dev/null +++ b/packages/kbn-dev-utils/src/serializers/replace_serializer.ts @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createRecursiveSerializer } from './recursive_serializer'; + +type Replacer = (substring: string, ...args: any[]) => string; + +export function createReplaceSerializer( + toReplace: string | RegExp, + replaceWith: string | Replacer +) { + return createRecursiveSerializer( + typeof toReplace === 'string' + ? (v: any) => typeof v === 'string' && v.includes(toReplace) + : (v: any) => typeof v === 'string' && toReplace.test(v), + typeof replaceWith === 'string' + ? (v: string) => v.replace(toReplace, replaceWith) + : (v: string) => v.replace(toReplace, replaceWith) + ); +} diff --git a/packages/kbn-dev-utils/src/simple_kibana_platform_plugin_discovery.ts b/packages/kbn-dev-utils/src/simple_kibana_platform_plugin_discovery.ts index c7155b2b3c51b..c56d63edb9ac4 100644 --- a/packages/kbn-dev-utils/src/simple_kibana_platform_plugin_discovery.ts +++ b/packages/kbn-dev-utils/src/simple_kibana_platform_plugin_discovery.ts @@ -20,67 +20,37 @@ import Path from 'path'; import globby from 'globby'; -import loadJsonFile from 'load-json-file'; -export interface KibanaPlatformPlugin { - readonly directory: string; - readonly manifestPath: string; - readonly manifest: { - id: string; - [key: string]: unknown; - }; -} +import { parseKibanaPlatformPlugin } from './parse_kibana_platform_plugin'; /** * Helper to find the new platform plugins. */ -export function simpleKibanaPlatformPluginDiscovery(scanDirs: string[], paths: string[]) { +export function simpleKibanaPlatformPluginDiscovery(scanDirs: string[], pluginPaths: string[]) { const patterns = Array.from( new Set([ // find kibana.json files up to 5 levels within the scan dir ...scanDirs.reduce( (acc: string[], dir) => [ ...acc, - `${dir}/*/kibana.json`, - `${dir}/*/*/kibana.json`, - `${dir}/*/*/*/kibana.json`, - `${dir}/*/*/*/*/kibana.json`, - `${dir}/*/*/*/*/*/kibana.json`, + Path.resolve(dir, '*/kibana.json'), + Path.resolve(dir, '*/*/kibana.json'), + Path.resolve(dir, '*/*/*/kibana.json'), + Path.resolve(dir, '*/*/*/*/kibana.json'), + Path.resolve(dir, '*/*/*/*/*/kibana.json'), ], [] ), - ...paths.map((path) => `${path}/kibana.json`), + ...pluginPaths.map((path) => Path.resolve(path, `kibana.json`)), ]) ); const manifestPaths = globby.sync(patterns, { absolute: true }).map((path) => - // absolute paths returned from globby are using normalize or something so the path separators are `/` even on windows, Path.resolve solves this + // absolute paths returned from globby are using normalize or + // something so the path separators are `/` even on windows, + // Path.resolve solves this Path.resolve(path) ); - return manifestPaths.map( - (manifestPath): KibanaPlatformPlugin => { - if (!Path.isAbsolute(manifestPath)) { - throw new TypeError('expected new platform manifest path to be absolute'); - } - - const manifest = loadJsonFile.sync(manifestPath); - if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { - throw new TypeError('expected new platform plugin manifest to be a JSON encoded object'); - } - - if (typeof manifest.id !== 'string') { - throw new TypeError('expected new platform plugin manifest to have a string id'); - } - - return { - directory: Path.dirname(manifestPath), - manifestPath, - manifest: { - ...manifest, - id: manifest.id, - }, - }; - } - ); + return manifestPaths.map(parseKibanaPlatformPlugin); } diff --git a/packages/kbn-plugin-generator/src/streams.ts b/packages/kbn-dev-utils/src/streams.ts similarity index 62% rename from packages/kbn-plugin-generator/src/streams.ts rename to packages/kbn-dev-utils/src/streams.ts index 976008e879dd3..6a868f648e78d 100644 --- a/packages/kbn-plugin-generator/src/streams.ts +++ b/packages/kbn-dev-utils/src/streams.ts @@ -20,7 +20,6 @@ import { Transform } from 'stream'; import File from 'vinyl'; -import { Minimatch } from 'minimatch'; interface BufferedFile extends File { contents: Buffer; @@ -33,41 +32,31 @@ interface BufferedFile extends File { * mutate the file, replace it with another file (return a new File * object), or drop it from the stream (return null) */ -export const tapFileStream = ( +export const transformFileStream = ( fn: (file: BufferedFile) => File | void | null | Promise ) => new Transform({ objectMode: true, - transform(file: BufferedFile, _, cb) { - Promise.resolve(file) - .then(fn) - .then( - (result) => { - // drop the file when null is returned - if (result === null) { - cb(); - } else { - cb(undefined, result || file); - } - }, - (error) => cb(error) - ); - }, - }); + transform(file: File, _, cb) { + Promise.resolve() + .then(async () => { + if (file.isDirectory()) { + return cb(undefined, file); + } -export const excludeFiles = (globs: string[]) => { - const patterns = globs.map( - (g) => - new Minimatch(g, { - matchBase: true, - }) - ); + if (!(file.contents instanceof Buffer)) { + throw new Error('files must be buffered to use transformFileStream()'); + } - return tapFileStream((file) => { - const path = file.relative.replace(/\.ejs$/, ''); - const exclude = patterns.some((p) => p.match(path)); - if (exclude) { - return null; - } + const result = await fn(file as BufferedFile); + + if (result === null) { + // explicitly drop file if null is returned + cb(); + } else { + cb(undefined, result || file); + } + }) + .catch(cb); + }, }); -}; diff --git a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts index a848d779dc9a2..8a3379211927b 100644 --- a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts +++ b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts @@ -50,7 +50,7 @@ export function findKibanaPlatformPlugins(scanDirs: string[], paths: string[]) { directory, manifestPath, id: manifest.id, - isUiPlugin: !!manifest.ui, + isUiPlugin: manifest.ui, extraPublicDirs: extraPublicDirs || [], }; } diff --git a/packages/kbn-plugin-generator/src/plugin_types.ts b/packages/kbn-plugin-generator/src/plugin_types.ts new file mode 100644 index 0000000000000..ae5201f4e8dbb --- /dev/null +++ b/packages/kbn-plugin-generator/src/plugin_types.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; + +import { REPO_ROOT } from '@kbn/dev-utils'; + +export interface PluginType { + thirdParty: boolean; + installDir: string; +} + +export const PLUGIN_TYPE_OPTIONS: Array<{ name: string; value: PluginType }> = [ + { + name: 'Installable plugin', + value: { thirdParty: true, installDir: Path.resolve(REPO_ROOT, 'plugins') }, + }, + { + name: 'Kibana Example', + value: { thirdParty: false, installDir: Path.resolve(REPO_ROOT, 'examples') }, + }, + { + name: 'Kibana OSS', + value: { thirdParty: false, installDir: Path.resolve(REPO_ROOT, 'src/plugins') }, + }, + { + name: 'Kibana OSS Functional Testing', + value: { + thirdParty: false, + installDir: Path.resolve(REPO_ROOT, 'test/plugin_functional/plugins'), + }, + }, + { + name: 'X-Pack', + value: { thirdParty: false, installDir: Path.resolve(REPO_ROOT, 'x-pack/plugins') }, + }, + { + name: 'X-Pack Functional Testing', + value: { + thirdParty: false, + installDir: Path.resolve(REPO_ROOT, 'x-pack/test/plugin_functional/plugins'), + }, + }, +]; diff --git a/packages/kbn-plugin-generator/src/render_template.ts b/packages/kbn-plugin-generator/src/render_template.ts index 18bdcf1be1a6b..894088c119651 100644 --- a/packages/kbn-plugin-generator/src/render_template.ts +++ b/packages/kbn-plugin-generator/src/render_template.ts @@ -23,15 +23,32 @@ import { promisify } from 'util'; import vfs from 'vinyl-fs'; import prettier from 'prettier'; -import { REPO_ROOT } from '@kbn/dev-utils'; +import { REPO_ROOT, transformFileStream } from '@kbn/dev-utils'; import ejs from 'ejs'; +import { Minimatch } from 'minimatch'; import { snakeCase, camelCase, upperCamelCase } from './casing'; -import { excludeFiles, tapFileStream } from './streams'; import { Answers } from './ask_questions'; const asyncPipeline = promisify(pipeline); +const excludeFiles = (globs: string[]) => { + const patterns = globs.map( + (g) => + new Minimatch(g, { + matchBase: true, + }) + ); + + return transformFileStream((file) => { + const path = file.relative.replace(/\.ejs$/, ''); + const exclude = patterns.some((p) => p.match(path)); + if (exclude) { + return null; + } + }); +}; + /** * Stream all the files from the template directory, ignoring * certain files based on the answers, process the .ejs templates @@ -82,7 +99,7 @@ export async function renderTemplates({ ), // render .ejs templates and rename to not use .ejs extension - tapFileStream((file) => { + transformFileStream((file) => { if (file.extname !== '.ejs') { return; } @@ -108,7 +125,7 @@ export async function renderTemplates({ }), // format each file with prettier - tapFileStream((file) => { + transformFileStream((file) => { if (!file.extname) { return; } diff --git a/packages/kbn-plugin-generator/template/README.md.ejs b/packages/kbn-plugin-generator/template/README.md.ejs index 5f30bf0463305..2cd19c904263e 100755 --- a/packages/kbn-plugin-generator/template/README.md.ejs +++ b/packages/kbn-plugin-generator/template/README.md.ejs @@ -7,3 +7,14 @@ A Kibana plugin ## Development See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment. + +<% if (thirdPartyPlugin) { %> +## Scripts +
+
yarn kbn bootstrap
+
Execute this to install node_modules and setup the dependencies in your plugin and in Kibana
+ +
yarn plugin-helpers build
+
Execute this to create a distributable version of this plugin that can be installed in Kibana
+
+<% } %> diff --git a/packages/kbn-plugin-generator/template/package.json.ejs b/packages/kbn-plugin-generator/template/package.json.ejs index cbd59894ca47c..ab234b1df2bc5 100644 --- a/packages/kbn-plugin-generator/template/package.json.ejs +++ b/packages/kbn-plugin-generator/template/package.json.ejs @@ -3,6 +3,8 @@ "version": "0.0.0", "private": true, "scripts": { + "build": "yarn plugin-helpers build", + "plugin-helpers": "node ../../scripts/plugin_helpers", "kbn": "node ../../scripts/kbn" } } diff --git a/packages/kbn-plugin-helpers/package.json b/packages/kbn-plugin-helpers/package.json index ba39528a1f809..129c58a4b4174 100644 --- a/packages/kbn-plugin-helpers/package.json +++ b/packages/kbn-plugin-helpers/package.json @@ -1,43 +1,32 @@ { "name": "@kbn/plugin-helpers", - "version": "9.0.2", + "version": "1.0.0", "private": true, "description": "Just some helpers for kibana plugin devs.", "license": "Apache-2.0", - "main": "target/lib/index.js", - "scripts": { - "kbn:bootstrap": "tsc" - }, + "main": "target/index.js", "bin": { "plugin-helpers": "bin/plugin-helpers.js" }, + "scripts": { + "kbn:bootstrap": "rm -rf target && tsc", + "kbn:watch": "tsc --watch" + }, "dependencies": { - "@babel/core": "^7.11.1", - "argv-split": "^2.0.1", - "commander": "^3.0.0", + "@kbn/dev-utils": "1.0.0", + "@kbn/optimizer": "1.0.0", "del": "^5.1.0", "execa": "^4.0.2", - "globby": "^8.0.1", - "gulp-babel": "^8.0.0", - "gulp-rename": "1.4.0", - "gulp-zip": "5.0.1", + "gulp-zip": "^5.0.2", "inquirer": "^1.2.2", - "minimatch": "^3.0.4", - "through2": "^2.0.3", - "through2-map": "^3.0.0", - "vinyl": "^2.2.0", + "load-json-file": "^6.2.0", "vinyl-fs": "^3.0.3" }, "devDependencies": { - "@types/gulp-rename": "^0.0.33", + "@types/decompress": "^4.2.3", "@types/gulp-zip": "^4.0.1", "@types/inquirer": "^6.5.0", - "@types/through2": "^2.0.35", - "@types/through2-map": "^3.0.0", - "@types/vinyl": "^2.0.4", + "decompress": "^4.2.1", "typescript": "4.0.2" - }, - "peerDependencies": { - "@kbn/babel-preset": "1.0.0" } } diff --git a/packages/kbn-plugin-helpers/bin/plugin-helpers.js b/packages/kbn-plugin-helpers/src/build_context.ts old mode 100755 new mode 100644 similarity index 73% rename from packages/kbn-plugin-helpers/bin/plugin-helpers.js rename to packages/kbn-plugin-helpers/src/build_context.ts index 175ff1019fa2d..62300d5a34e49 --- a/packages/kbn-plugin-helpers/bin/plugin-helpers.js +++ b/packages/kbn-plugin-helpers/src/build_context.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -19,10 +17,16 @@ * under the License. */ -const nodeMajorVersion = parseFloat(process.version.replace(/^v(\d+)\..+/, '$1')); -if (nodeMajorVersion < 6) { - console.error('FATAL: kibana-plugin-helpers requires node 6+'); - process.exit(1); -} +import { ToolingLog } from '@kbn/dev-utils'; -require('../target/cli'); +import { Plugin } from './load_kibana_platform_plugin'; +import { Config } from './config'; + +export interface BuildContext { + log: ToolingLog; + plugin: Plugin; + config: Config; + sourceDir: string; + buildDir: string; + kibanaVersion: string; +} diff --git a/packages/kbn-plugin-helpers/src/cli.ts b/packages/kbn-plugin-helpers/src/cli.ts index 18ddc62cba8a6..21b6559f63650 100644 --- a/packages/kbn-plugin-helpers/src/cli.ts +++ b/packages/kbn-plugin-helpers/src/cli.ts @@ -17,59 +17,86 @@ * under the License. */ -import Fs from 'fs'; import Path from 'path'; -import program from 'commander'; +import { RunWithCommands, createFlagError, createFailError } from '@kbn/dev-utils'; -import { createCommanderAction } from './lib/commander_action'; -import { docs } from './lib/docs'; -import { enableCollectingUnknownOptions } from './lib/enable_collecting_unknown_options'; +import { findKibanaJson } from './find_kibana_json'; +import { loadKibanaPlatformPlugin } from './load_kibana_platform_plugin'; +import * as Tasks from './tasks'; +import { BuildContext } from './build_context'; +import { resolveKibanaVersion } from './resolve_kibana_version'; +import { loadConfig } from './config'; -const pkg = JSON.parse(Fs.readFileSync(Path.resolve(__dirname, '../package.json'), 'utf8')); -program.version(pkg.version); +export function runCli() { + new RunWithCommands({ + description: 'Some helper tasks for plugin-authors', + }) + .command({ + name: 'build', + description: ` + Copies files from the source into a zip archive that can be distributed for + installation into production Kibana installs. The archive includes the non- + development npm dependencies and builds itself using raw files in the source + directory so make sure they are clean/up to date. The resulting archive can + be found at: -enableCollectingUnknownOptions( - program - .command('start') - .description('Start kibana and have it include this plugin') - .on('--help', docs('start')) - .action( - createCommanderAction('start', (command) => ({ - flags: command.unknownOptions, - })) - ) -); + build/{plugin.id}-{kibanaVersion}.zip -program - .command('build [files...]') - .description('Build a distributable archive') - .on('--help', docs('build')) - .option('--skip-archive', "Don't create the zip file, leave the build path alone") - .option( - '-d, --build-destination ', - 'Target path for the build output, absolute or relative to the plugin root' - ) - .option('-b, --build-version ', 'Version for the build output') - .option('-k, --kibana-version ', 'Kibana version for the build output') - .action( - createCommanderAction('build', (command, files) => ({ - buildDestination: command.buildDestination, - buildVersion: command.buildVersion, - kibanaVersion: command.kibanaVersion, - skipArchive: Boolean(command.skipArchive), - files, - })) - ); + `, + flags: { + boolean: ['skip-archive'], + string: ['kibana-version'], + alias: { + k: 'kibana-version', + }, + help: ` + --skip-archive Don't create the zip file, just create the build/kibana directory + --kibana-version, -v Kibana version that the + `, + }, + async run({ log, flags }) { + const versionFlag = flags['kibana-version']; + if (versionFlag !== undefined && typeof versionFlag !== 'string') { + throw createFlagError('expected a single --kibana-version flag'); + } -program - .command('test:mocha [files...]') - .description('Run the server tests using mocha') - .on('--help', docs('test/mocha')) - .action( - createCommanderAction('testMocha', (command, files) => ({ - files, - })) - ); + const skipArchive = flags['skip-archive']; + if (skipArchive !== undefined && typeof skipArchive !== 'boolean') { + throw createFlagError('expected a single --skip-archive flag'); + } -program.parse(process.argv); + const pluginDir = await findKibanaJson(process.cwd()); + if (!pluginDir) { + throw createFailError( + `Unable to find Kibana Platform plugin in [${process.cwd()}] or any of its parent directories. Has it been migrated properly? Does it have a kibana.json file?` + ); + } + + const plugin = loadKibanaPlatformPlugin(pluginDir); + const config = await loadConfig(log, plugin); + const kibanaVersion = await resolveKibanaVersion(versionFlag, plugin); + const sourceDir = plugin.directory; + const buildDir = Path.resolve(plugin.directory, 'build/kibana', plugin.manifest.id); + + const context: BuildContext = { + log, + plugin, + config, + sourceDir, + buildDir, + kibanaVersion, + }; + + await Tasks.initTargets(context); + await Tasks.optimize(context); + await Tasks.writeServerFiles(context); + await Tasks.yarnInstall(context); + + if (skipArchive !== true) { + await Tasks.createArchive(context); + } + }, + }) + .execute(); +} diff --git a/packages/kbn-plugin-helpers/src/config.ts b/packages/kbn-plugin-helpers/src/config.ts new file mode 100644 index 0000000000000..bd5ad8ab6acc7 --- /dev/null +++ b/packages/kbn-plugin-helpers/src/config.ts @@ -0,0 +1,83 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; + +import loadJsonFile from 'load-json-file'; + +import { ToolingLog } from '@kbn/dev-utils'; +import { Plugin } from './load_kibana_platform_plugin'; + +export interface Config { + skipInstallDependencies: boolean; + serverSourcePatterns?: string[]; +} + +const isArrayOfStrings = (v: any): v is string[] => + Array.isArray(v) && v.every((p) => typeof p === 'string'); + +export async function loadConfig(log: ToolingLog, plugin: Plugin): Promise { + try { + const path = Path.resolve(plugin.directory, '.kibana-plugin-helpers.json'); + const file = await loadJsonFile(path); + + if (!(typeof file === 'object' && file && !Array.isArray(file))) { + throw new TypeError(`expected config at [${path}] to be an object`); + } + + const { + skipInstallDependencies = false, + buildSourcePatterns, + serverSourcePatterns, + ...rest + } = file; + + if (typeof skipInstallDependencies !== 'boolean') { + throw new TypeError(`expected [skipInstallDependencies] at [${path}] to be a boolean`); + } + + if (buildSourcePatterns) { + log.warning( + `DEPRECATED: rename [buildSourcePatterns] to [serverSourcePatterns] in [${path}]` + ); + } + const ssp = buildSourcePatterns || serverSourcePatterns; + if (ssp !== undefined && !isArrayOfStrings(ssp)) { + throw new TypeError(`expected [serverSourcePatterns] at [${path}] to be an array of strings`); + } + + if (Object.keys(rest).length) { + throw new TypeError(`unexpected key in [${path}]: ${Object.keys(rest).join(', ')}`); + } + + log.info(`Loaded config file from [${path}]`); + return { + skipInstallDependencies, + serverSourcePatterns: ssp, + }; + } catch (error) { + if (error.code === 'ENOENT') { + return { + skipInstallDependencies: false, + }; + } + + throw error; + } +} diff --git a/packages/kbn-plugin-helpers/src/lib/run.ts b/packages/kbn-plugin-helpers/src/find_kibana_json.ts similarity index 65% rename from packages/kbn-plugin-helpers/src/lib/run.ts rename to packages/kbn-plugin-helpers/src/find_kibana_json.ts index 2b1a2a63c1074..9340309056830 100644 --- a/packages/kbn-plugin-helpers/src/lib/run.ts +++ b/packages/kbn-plugin-helpers/src/find_kibana_json.ts @@ -17,21 +17,21 @@ * under the License. */ -import { pluginConfig, PluginConfig } from './plugin_config'; -import { tasks, Tasks } from './tasks'; +import Path from 'path'; +import Fs from 'fs'; +import { promisify } from 'util'; -export interface TaskContext { - plugin: PluginConfig; - run: typeof run; - options?: any; -} +const existsAsync = promisify(Fs.exists); + +export async function findKibanaJson(directory: string): Promise { + if (await existsAsync(Path.resolve(directory, 'kibana.json'))) { + return directory; + } -export function run(name: keyof Tasks, options?: any) { - const action = tasks[name]; - if (!action) { - throw new Error('Invalid task: "' + name + '"'); + const parent = Path.dirname(directory); + if (parent === directory) { + return undefined; } - const plugin = pluginConfig(); - return action({ plugin, run, options }); + return findKibanaJson(parent); } diff --git a/packages/kbn-plugin-helpers/src/tasks/start/index.ts b/packages/kbn-plugin-helpers/src/index.ts similarity index 96% rename from packages/kbn-plugin-helpers/src/tasks/start/index.ts rename to packages/kbn-plugin-helpers/src/index.ts index cf34bdbadf416..a05bc698bde17 100644 --- a/packages/kbn-plugin-helpers/src/tasks/start/index.ts +++ b/packages/kbn-plugin-helpers/src/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from './start_task'; +export * from './cli'; diff --git a/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts b/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts new file mode 100644 index 0000000000000..62f83cd672f3d --- /dev/null +++ b/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts @@ -0,0 +1,123 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; +import Fs from 'fs'; + +import execa from 'execa'; +import { createStripAnsiSerializer, REPO_ROOT, createReplaceSerializer } from '@kbn/dev-utils'; +import decompress from 'decompress'; +import del from 'del'; +import globby from 'globby'; +import loadJsonFile from 'load-json-file'; + +const PLUGIN_DIR = Path.resolve(REPO_ROOT, 'plugins/foo_test_plugin'); +const PLUGIN_BUILD_DIR = Path.resolve(PLUGIN_DIR, 'build'); +const PLUGIN_ARCHIVE = Path.resolve(PLUGIN_BUILD_DIR, `fooTestPlugin-7.5.0.zip`); +const TMP_DIR = Path.resolve(__dirname, '__tmp__'); + +expect.addSnapshotSerializer(createReplaceSerializer(/[\d\.]+ sec/g, '

+ } + description={ + + {' '} + + + } + fullWidth + titleSize="xs" + > + { + setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); + }} + label={freezeLabel} + aria-label={freezeLabel} + /> + + + errors={errors} + phaseData={phaseData} + phase={frozenProperty} + isShowingErrors={isShowingErrors} + setPhaseData={setPhaseData} + /> + + ) : null} + + ); + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx index 22f0114d16afe..106e3b9139a9b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/eui'; import { HotPhase as HotPhaseInterface, Phases } from '../../../services/policies/types'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; +import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; import { LearnMoreLink, @@ -112,9 +112,8 @@ const maxAgeUnits = [ }), }, ]; -const hotProperty = propertyof('hot'); -const phaseProperty = (propertyName: keyof HotPhaseInterface) => - propertyof(propertyName); +const hotProperty: keyof Phases = 'hot'; +const phaseProperty = (propertyName: keyof HotPhaseInterface) => propertyName; interface Props { errors?: PhaseValidationErrors; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts index 8d1ace5950497..d59f2ff6413fd 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts @@ -7,4 +7,5 @@ export { HotPhase } from './hot_phase'; export { WarmPhase } from './warm_phase'; export { ColdPhase } from './cold_phase'; +export { FrozenPhase } from './frozen_phase'; export { DeletePhase } from './delete_phase'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx index f7b8c60a5c71f..2733d01ac222d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx @@ -30,7 +30,7 @@ import { } from '../components'; import { Phases, WarmPhase as WarmPhaseInterface } from '../../../services/policies/types'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; +import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; const shrinkLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { defaultMessage: 'Shrink index', @@ -47,9 +47,8 @@ const forcemergeLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.force defaultMessage: 'Force merge data', }); -const warmProperty = propertyof('warm'); -const phaseProperty = (propertyName: keyof WarmPhaseInterface) => - propertyof(propertyName); +const warmProperty: keyof Phases = 'warm'; +const phaseProperty = (propertyName: keyof WarmPhaseInterface) => propertyName; interface Props { setPhaseData: (key: keyof WarmPhaseInterface & string, value: boolean | string) => void; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts index 6cc43042ed4ff..7fa82a004b872 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts @@ -152,9 +152,9 @@ export const validateColdPhase = (phase: ColdPhase): PhaseValidationErrors { + const phase = { ...frozenPhaseInitialization }; + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + + if (phaseSerialized.min_age) { + const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); + phase.selectedMinimumAge = minAge; + phase.selectedMinimumAgeUnits = minAgeUnits; + } + + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + if (actions.allocate) { + const allocate = actions.allocate; + if (allocate.require) { + Object.entries(allocate.require).forEach((entry) => { + phase.selectedNodeAttrs = entry.join(':'); + }); + if (allocate.number_of_replicas) { + phase.selectedReplicaCount = allocate.number_of_replicas.toString(); + } + } + } + + if (actions.freeze) { + phase.freezeEnabled = true; + } + + if (actions.set_priority) { + phase.phaseIndexPriority = actions.set_priority.priority + ? actions.set_priority.priority.toString() + : ''; + } + } + + return phase; +}; + +export const frozenPhaseToES = ( + phase: FrozenPhase, + originalPhase?: SerializedFrozenPhase +): SerializedFrozenPhase => { + if (!originalPhase) { + originalPhase = { ...serializedPhaseInitialization }; + } + + const esPhase = { ...originalPhase }; + + if (isNumber(phase.selectedMinimumAge)) { + esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; + } + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.selectedNodeAttrs) { + const [name, value] = phase.selectedNodeAttrs.split(':'); + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.require = { + [name]: value, + }; + } else { + if (esPhase.actions.allocate) { + // @ts-expect-error + delete esPhase.actions.allocate.require; + } + } + + if (isNumber(phase.selectedReplicaCount)) { + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.number_of_replicas = parseInt(phase.selectedReplicaCount, 10); + } else { + if (esPhase.actions.allocate) { + // @ts-expect-error + delete esPhase.actions.allocate.number_of_replicas; + } + } + + if ( + esPhase.actions.allocate && + !esPhase.actions.allocate.require && + !isNumber(esPhase.actions.allocate.number_of_replicas) && + isEmpty(esPhase.actions.allocate.include) && + isEmpty(esPhase.actions.allocate.exclude) + ) { + // remove allocate action if it does not define require or number of nodes + // and both include and exclude are empty objects (ES will fail to parse if we don't) + delete esPhase.actions.allocate; + } + + if (phase.freezeEnabled) { + esPhase.actions.freeze = {}; + } else { + delete esPhase.actions.freeze; + } + + if (isNumber(phase.phaseIndexPriority)) { + esPhase.actions.set_priority = { + priority: parseInt(phase.phaseIndexPriority, 10), + }; + } else { + delete esPhase.actions.set_priority; + } + + return esPhase; +}; + +export const validateFrozenPhase = (phase: FrozenPhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // index priority is optional, but if it's set, it needs to be a positive number + if (phase.phaseIndexPriority) { + if (!isNumber(phase.phaseIndexPriority)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.phaseIndexPriority, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + } + + // min age needs to be a positive number + if (!isNumber(phase.selectedMinimumAge)) { + phaseErrors.selectedMinimumAge = [numberRequiredMessage]; + } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { + phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; + } + + return { ...phaseErrors }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts index 3953521df1817..807a6fe8ec395 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts @@ -9,6 +9,7 @@ import { defaultNewDeletePhase, defaultNewHotPhase, defaultNewWarmPhase, + defaultNewFrozenPhase, serializedPhaseInitialization, } from '../../constants'; @@ -17,6 +18,7 @@ import { Policy, PolicyFromES, SerializedPolicy } from './types'; import { hotPhaseFromES, hotPhaseToES } from './hot_phase'; import { warmPhaseFromES, warmPhaseToES } from './warm_phase'; import { coldPhaseFromES, coldPhaseToES } from './cold_phase'; +import { frozenPhaseFromES, frozenPhaseToES } from './frozen_phase'; import { deletePhaseFromES, deletePhaseToES } from './delete_phase'; export const splitSizeAndUnits = (field: string): { size: string; units: string } => { @@ -53,6 +55,7 @@ export const initializeNewPolicy = (newPolicyName: string = ''): Policy => { hot: { ...defaultNewHotPhase }, warm: { ...defaultNewWarmPhase }, cold: { ...defaultNewColdPhase }, + frozen: { ...defaultNewFrozenPhase }, delete: { ...defaultNewDeletePhase }, }, }; @@ -70,6 +73,7 @@ export const deserializePolicy = (policy: PolicyFromES): Policy => { hot: hotPhaseFromES(phases.hot), warm: warmPhaseFromES(phases.warm), cold: coldPhaseFromES(phases.cold), + frozen: frozenPhaseFromES(phases.frozen), delete: deletePhaseFromES(phases.delete), }, }; @@ -94,6 +98,13 @@ export const serializePolicy = ( serializedPolicy.phases.cold = coldPhaseToES(policy.phases.cold, originalEsPolicy.phases.cold); } + if (policy.phases.frozen.phaseEnabled) { + serializedPolicy.phases.frozen = frozenPhaseToES( + policy.phases.frozen, + originalEsPolicy.phases.frozen + ); + } + if (policy.phases.delete.phaseEnabled) { serializedPolicy.phases.delete = deletePhaseToES( policy.phases.delete, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts index 545488be2cd5e..6fdbc4babd3f3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts @@ -9,7 +9,17 @@ import { validateHotPhase } from './hot_phase'; import { validateWarmPhase } from './warm_phase'; import { validateColdPhase } from './cold_phase'; import { validateDeletePhase } from './delete_phase'; -import { ColdPhase, DeletePhase, HotPhase, Phase, Policy, PolicyFromES, WarmPhase } from './types'; +import { validateFrozenPhase } from './frozen_phase'; + +import { + ColdPhase, + DeletePhase, + FrozenPhase, + HotPhase, + Policy, + PolicyFromES, + WarmPhase, +} from './types'; export const propertyof = (propertyName: keyof T & string) => propertyName; @@ -100,7 +110,7 @@ export const policyNameAlreadyUsedErrorMessage = i18n.translate( defaultMessage: 'That policy name is already used.', } ); -export type PhaseValidationErrors = { +export type PhaseValidationErrors = { [P in keyof Partial]: string[]; }; @@ -108,6 +118,7 @@ export interface ValidationErrors { hot: PhaseValidationErrors; warm: PhaseValidationErrors; cold: PhaseValidationErrors; + frozen: PhaseValidationErrors; delete: PhaseValidationErrors; policyName: string[]; } @@ -148,12 +159,14 @@ export const validatePolicy = ( const hotPhaseErrors = validateHotPhase(policy.phases.hot); const warmPhaseErrors = validateWarmPhase(policy.phases.warm); const coldPhaseErrors = validateColdPhase(policy.phases.cold); + const frozenPhaseErrors = validateFrozenPhase(policy.phases.frozen); const deletePhaseErrors = validateDeletePhase(policy.phases.delete); const isValid = policyNameErrors.length === 0 && Object.keys(hotPhaseErrors).length === 0 && Object.keys(warmPhaseErrors).length === 0 && Object.keys(coldPhaseErrors).length === 0 && + Object.keys(frozenPhaseErrors).length === 0 && Object.keys(deletePhaseErrors).length === 0; return [ isValid, @@ -162,6 +175,7 @@ export const validatePolicy = ( hot: hotPhaseErrors, warm: warmPhaseErrors, cold: coldPhaseErrors, + frozen: frozenPhaseErrors, delete: deletePhaseErrors, }, ]; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts index 2e2ed5b38bb87..3d4c73cf4a82c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts @@ -13,6 +13,7 @@ export interface Phases { hot?: SerializedHotPhase; warm?: SerializedWarmPhase; cold?: SerializedColdPhase; + frozen?: SerializedFrozenPhase; delete?: SerializedDeletePhase; } @@ -68,6 +69,16 @@ export interface SerializedColdPhase extends SerializedPhase { }; } +export interface SerializedFrozenPhase extends SerializedPhase { + actions: { + freeze?: {}; + allocate?: AllocateAction; + set_priority?: { + priority: number | null; + }; + }; +} + export interface SerializedDeletePhase extends SerializedPhase { actions: { wait_for_snapshot?: { @@ -94,47 +105,66 @@ export interface Policy { hot: HotPhase; warm: WarmPhase; cold: ColdPhase; + frozen: FrozenPhase; delete: DeletePhase; }; } -export interface Phase { +export interface CommonPhaseSettings { phaseEnabled: boolean; } -export interface HotPhase extends Phase { + +export interface PhaseWithMinAge { + selectedMinimumAge: string; + selectedMinimumAgeUnits: string; +} + +export interface PhaseWithAllocationAction { + selectedNodeAttrs: string; + selectedReplicaCount: string; +} + +export interface PhaseWithIndexPriority { + phaseIndexPriority: string; +} + +export interface HotPhase extends CommonPhaseSettings, PhaseWithIndexPriority { rolloverEnabled: boolean; selectedMaxSizeStored: string; selectedMaxSizeStoredUnits: string; selectedMaxDocuments: string; selectedMaxAge: string; selectedMaxAgeUnits: string; - phaseIndexPriority: string; } -export interface WarmPhase extends Phase { +export interface WarmPhase + extends CommonPhaseSettings, + PhaseWithMinAge, + PhaseWithAllocationAction, + PhaseWithIndexPriority { warmPhaseOnRollover: boolean; - selectedMinimumAge: string; - selectedMinimumAgeUnits: string; - selectedNodeAttrs: string; - selectedReplicaCount: string; shrinkEnabled: boolean; selectedPrimaryShardCount: string; forceMergeEnabled: boolean; selectedForceMergeSegments: string; - phaseIndexPriority: string; } -export interface ColdPhase extends Phase { - selectedMinimumAge: string; - selectedMinimumAgeUnits: string; - selectedNodeAttrs: string; - selectedReplicaCount: string; +export interface ColdPhase + extends CommonPhaseSettings, + PhaseWithMinAge, + PhaseWithAllocationAction, + PhaseWithIndexPriority { freezeEnabled: boolean; - phaseIndexPriority: string; } -export interface DeletePhase extends Phase { - selectedMinimumAge: string; - selectedMinimumAgeUnits: string; +export interface FrozenPhase + extends CommonPhaseSettings, + PhaseWithMinAge, + PhaseWithAllocationAction, + PhaseWithIndexPriority { + freezeEnabled: boolean; +} + +export interface DeletePhase extends CommonPhaseSettings, PhaseWithMinAge { waitForSnapshotPolicy: string; } diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js index a1eac5264bb6a..8d01f4a4c200e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js @@ -176,6 +176,12 @@ export const ilmFilterExtension = (indices) => { defaultMessage: 'Warm', }), }, + { + value: 'frozen', + view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.frozenLabel', { + defaultMessage: 'Frozen', + }), + }, { value: 'cold', view: i18n.translate('xpack.indexLifecycleMgmt.indexMgmtFilter.coldLabel', { diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts index 2d02802119e47..9b51164fd4c28 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -104,6 +104,23 @@ const coldPhaseSchema = schema.maybe( }) ); +const frozenPhaseSchema = schema.maybe( + schema.object({ + min_age: minAgeSchema, + actions: schema.object({ + set_priority: setPrioritySchema, + unfollow: unfollowSchema, + allocate: allocateSchema, + freeze: schema.maybe(schema.object({})), // Freeze has no options + searchable_snapshot: schema.maybe( + schema.object({ + snapshot_repository: schema.string(), + }) + ), + }), + }) +); + const deletePhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, @@ -129,6 +146,7 @@ const bodySchema = schema.object({ hot: hotPhaseSchema, warm: warmPhaseSchema, cold: coldPhaseSchema, + frozen: frozenPhaseSchema, delete: deletePhaseSchema, }), }); From 647f397c50a74cf72c268a432e01311745a5b303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 31 Aug 2020 10:46:29 +0100 Subject: [PATCH 33/33] [APM] Chart units don't update when toggling the chart legends (#74931) * changing unit when legend is disabled * changing unit when legend is disabled * show individual units in the tooltip * addressing PR comment * increasing duration threshold * change formatter based on available legends * addressing PR comment Co-authored-by: Elastic Machine --- .../shared/charts/CustomPlot/index.js | 5 + .../TransactionCharts/BrowserLineChart.tsx | 14 +- .../TransactionLineChart/index.tsx | 16 +- .../charts/TransactionCharts/helper.test.ts | 69 +++++ .../charts/TransactionCharts/helper.tsx | 35 +++ .../shared/charts/TransactionCharts/index.tsx | 277 ++++++------------ .../charts/TransactionCharts/ml_header.tsx | 96 ++++++ .../TransactionCharts/use_formatter.test.tsx | 109 +++++++ .../charts/TransactionCharts/use_formatter.ts | 30 ++ .../formatters/__test__/duration.test.ts | 7 +- .../apm/public/utils/formatters/duration.ts | 4 +- 11 files changed, 457 insertions(+), 205 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js index 7e74961e57ea1..41925d651e361 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js @@ -79,6 +79,10 @@ export class InnerCustomPlot extends PureComponent { return i === _i ? !disabledValue : !!disabledValue; }); + if (typeof this.props.onToggleLegend === 'function') { + this.props.onToggleLegend(nextSeriesEnabledState); + } + return { seriesEnabledState: nextSeriesEnabledState, }; @@ -235,6 +239,7 @@ InnerCustomPlot.propTypes = { }) ), noHits: PropTypes.bool, + onToggleLegend: PropTypes.func, }; InnerCustomPlot.defaultProps = { diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx index 0e19c63775d31..40caf35155918 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx @@ -4,17 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; import { EuiTitle } from '@elastic/eui'; -import { TransactionLineChart } from './TransactionLineChart'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useAvgDurationByBrowser } from '../../../../hooks/useAvgDurationByBrowser'; +import { getDurationFormatter } from '../../../../utils/formatters'; import { - getMaxY, getResponseTimeTickFormatter, getResponseTimeTooltipFormatter, -} from '.'; -import { getDurationFormatter } from '../../../../utils/formatters'; -import { useAvgDurationByBrowser } from '../../../../hooks/useAvgDurationByBrowser'; + getMaxY, +} from './helper'; +import { TransactionLineChart } from './TransactionLineChart'; export function BrowserLineChart() { const { data } = useAvgDurationByBrowser(); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx index eaad883d2f9f6..07b7f01194d5c 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx @@ -5,22 +5,13 @@ */ import React, { useCallback } from 'react'; -import { - Coordinate, - RectCoordinate, -} from '../../../../../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../../../../../typings/timeseries'; import { useChartsSync } from '../../../../../hooks/useChartsSync'; // @ts-ignore import CustomPlot from '../../CustomPlot'; interface Props { - series: Array<{ - color: string; - title: React.ReactNode; - titleShort?: React.ReactNode; - data: Array; - type: string; - }>; + series: TimeSeries[]; truncateLegends?: boolean; tickFormatY: (y: number) => React.ReactNode; formatTooltipValue: (c: Coordinate) => React.ReactNode; @@ -28,6 +19,7 @@ interface Props { height?: number; stacked?: boolean; onHover?: () => void; + onToggleLegend?: (disabledSeriesState: boolean[]) => void; } function TransactionLineChart(props: Props) { @@ -40,6 +32,7 @@ function TransactionLineChart(props: Props) { truncateLegends, stacked = false, onHover, + onToggleLegend, } = props; const syncedChartsProps = useChartsSync(); @@ -66,6 +59,7 @@ function TransactionLineChart(props: Props) { height={height} truncateLegends={truncateLegends} {...(stacked ? { stackBy: 'y' } : {})} + onToggleLegend={onToggleLegend} /> ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts new file mode 100644 index 0000000000000..a476892fa4a3f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getResponseTimeTickFormatter, + getResponseTimeTooltipFormatter, + getMaxY, +} from './helper'; +import { + getDurationFormatter, + toMicroseconds, +} from '../../../../utils/formatters'; +import { TimeSeries } from '../../../../../typings/timeseries'; + +describe('transaction chart helper', () => { + describe('getResponseTimeTickFormatter', () => { + it('formattes time tick in minutes', () => { + const formatter = getDurationFormatter(toMicroseconds(11, 'minutes')); + const timeTickFormatter = getResponseTimeTickFormatter(formatter); + expect(timeTickFormatter(toMicroseconds(60, 'seconds'))).toEqual( + '1.0 min' + ); + }); + it('formattes time tick in seconds', () => { + const formatter = getDurationFormatter(toMicroseconds(11, 'seconds')); + const timeTickFormatter = getResponseTimeTickFormatter(formatter); + expect(timeTickFormatter(toMicroseconds(6, 'seconds'))).toEqual('6.0 s'); + }); + }); + describe('getResponseTimeTooltipFormatter', () => { + const formatter = getDurationFormatter(toMicroseconds(11, 'minutes')); + const tooltipFormatter = getResponseTimeTooltipFormatter(formatter); + it("doesn't format invalid y coordinate", () => { + expect(tooltipFormatter({ x: 1, y: undefined })).toEqual('N/A'); + expect(tooltipFormatter({ x: 1, y: null })).toEqual('N/A'); + }); + it('formattes tooltip in minutes', () => { + expect( + tooltipFormatter({ x: 1, y: toMicroseconds(60, 'seconds') }) + ).toEqual('1.0 min'); + }); + }); + describe('getMaxY', () => { + it('returns zero when empty time series', () => { + expect(getMaxY([])).toEqual(0); + }); + it('returns zero for invalid y coordinate', () => { + const timeSeries = ([ + { data: [{ x: 1 }, { x: 2 }, { x: 3, y: -1 }] }, + ] as unknown) as TimeSeries[]; + expect(getMaxY(timeSeries)).toEqual(0); + }); + it('returns the max y coordinate', () => { + const timeSeries = ([ + { + data: [ + { x: 1, y: 10 }, + { x: 2, y: 5 }, + { x: 3, y: 1 }, + ], + }, + ] as unknown) as TimeSeries[]; + expect(getMaxY(timeSeries)).toEqual(10); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx new file mode 100644 index 0000000000000..f11a33f932553 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { flatten } from 'lodash'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; +import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; +import { TimeFormatter } from '../../../../utils/formatters'; + +export function getResponseTimeTickFormatter(formatter: TimeFormatter) { + return (t: number) => { + return formatter(t).formatted; + }; +} + +export function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { + return (coordinate: Coordinate) => { + return isValidCoordinateValue(coordinate.y) + ? formatter(coordinate.y).formatted + : NOT_AVAILABLE_LABEL; + }; +} + +export function getMaxY(timeSeries: TimeSeries[]) { + const coordinates = flatten( + timeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) + ); + + const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); + + return Math.max(...numbers, 0); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx index 1f80dbf5f4d95..d11925dc0303d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -8,38 +8,34 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, - EuiIconTip, EuiPanel, - EuiText, - EuiTitle, EuiSpacer, + EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Location } from 'history'; -import React, { Component } from 'react'; -import { isEmpty, flatten } from 'lodash'; -import styled from 'styled-components'; +import React from 'react'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; -import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; -import { ITransactionChartData } from '../../../../selectors/chartSelectors'; -import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { - tpmUnit, - TimeFormatter, - getDurationFormatter, - asDecimal, -} from '../../../../utils/formatters'; -import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink'; + TRANSACTION_PAGE_LOAD, + TRANSACTION_REQUEST, + TRANSACTION_ROUTE_CHANGE, +} from '../../../../../common/transaction_types'; +import { Coordinate } from '../../../../../typings/timeseries'; import { LicenseContext } from '../../../../context/LicenseContext'; -import { TransactionLineChart } from './TransactionLineChart'; +import { IUrlParams } from '../../../../context/UrlParamsContext/types'; +import { ITransactionChartData } from '../../../../selectors/chartSelectors'; +import { asDecimal, tpmUnit } from '../../../../utils/formatters'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; import { BrowserLineChart } from './BrowserLineChart'; import { DurationByCountryMap } from './DurationByCountryMap'; import { - TRANSACTION_PAGE_LOAD, - TRANSACTION_ROUTE_CHANGE, - TRANSACTION_REQUEST, -} from '../../../../../common/transaction_types'; + getResponseTimeTickFormatter, + getResponseTimeTooltipFormatter, +} from './helper'; +import { MLHeader } from './ml_header'; +import { TransactionLineChart } from './TransactionLineChart'; +import { useFormatter } from './use_formatter'; interface TransactionChartProps { charts: ITransactionChartData; @@ -47,181 +43,96 @@ interface TransactionChartProps { urlParams: IUrlParams; } -const ShiftedIconWrapper = styled.span` - padding-right: 5px; - position: relative; - top: -1px; - display: inline-block; -`; - -const ShiftedEuiText = styled(EuiText)` - position: relative; - top: 5px; -`; - -export function getResponseTimeTickFormatter(formatter: TimeFormatter) { - return (t: number) => formatter(t).formatted; -} - -export function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { - return (p: Coordinate) => { - return isValidCoordinateValue(p.y) - ? formatter(p.y).formatted - : NOT_AVAILABLE_LABEL; - }; -} - -export function getMaxY(responseTimeSeries: TimeSeries[]) { - const coordinates = flatten( - responseTimeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) - ); - - const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); - - return Math.max(...numbers, 0); -} - -export class TransactionCharts extends Component { - public getTPMFormatter = (t: number) => { - const { urlParams } = this.props; +export function TransactionCharts({ + charts, + location, + urlParams, +}: TransactionChartProps) { + const getTPMFormatter = (t: number) => { const unit = tpmUnit(urlParams.transactionType); return `${asDecimal(t)} ${unit}`; }; - public getTPMTooltipFormatter = (p: Coordinate) => { + const getTPMTooltipFormatter = (p: Coordinate) => { return isValidCoordinateValue(p.y) - ? this.getTPMFormatter(p.y) + ? getTPMFormatter(p.y) : NOT_AVAILABLE_LABEL; }; - public renderMLHeader(hasValidMlLicense: boolean | undefined) { - const { mlJobId } = this.props.charts; - - if (!hasValidMlLicense || !mlJobId) { - return null; - } - - const { serviceName, kuery, transactionType } = this.props.urlParams; - if (!serviceName) { - return null; - } + const { transactionType } = urlParams; - const hasKuery = !isEmpty(kuery); - const icon = hasKuery ? ( - - ) : ( - - ); - - return ( - - - {icon} - - {i18n.translate( - 'xpack.apm.metrics.transactionChart.machineLearningLabel', - { - defaultMessage: 'Machine learning:', - } - )}{' '} - - - View Job - - - - ); - } + const { responseTimeSeries, tpmSeries } = charts; - public render() { - const { charts, urlParams } = this.props; - const { responseTimeSeries, tpmSeries } = charts; - const { transactionType } = urlParams; - const maxY = getMaxY(responseTimeSeries); - const formatter = getDurationFormatter(maxY); + const { formatter, setDisabledSeriesState } = useFormatter( + responseTimeSeries + ); - return ( - <> - - - - - - - - {responseTimeLabel(transactionType)} - - - - {(license) => - this.renderMLHeader(license?.getFeature('ml').isAvailable) - } - - - + + + + + + + + {responseTimeLabel(transactionType)} + + + + {(license) => ( + )} - /> - - - - - - - - - {tpmLabel(transactionType)} - - - - - - - {transactionType === TRANSACTION_PAGE_LOAD && ( - <> - - - - - - - - - - - - - - - )} - - ); - } + + + + + + + + + + + + {tpmLabel(transactionType)} + + + + + + + {transactionType === TRANSACTION_PAGE_LOAD && ( + <> + + + + + + + + + + + + + + + )} + + ); } function tpmLabel(type?: string) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx new file mode 100644 index 0000000000000..f829b5841efa9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIconTip } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import React from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink'; + +interface Props { + hasValidMlLicense?: boolean; + mlJobId?: string; +} + +const ShiftedIconWrapper = styled.span` + padding-right: 5px; + position: relative; + top: -1px; + display: inline-block; +`; + +const ShiftedEuiText = styled(EuiText)` + position: relative; + top: 5px; +`; + +export function MLHeader({ hasValidMlLicense, mlJobId }: Props) { + const { urlParams } = useUrlParams(); + + if (!hasValidMlLicense || !mlJobId) { + return null; + } + + const { serviceName, kuery, transactionType } = urlParams; + if (!serviceName) { + return null; + } + + const hasKuery = !isEmpty(kuery); + const icon = hasKuery ? ( +