diff --git a/.eslintrc.js b/.eslintrc.js index 5b97e5e0d2..8a0ee2141f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -91,6 +91,7 @@ module.exports = { 'sonarjs/no-identical-functions': 'error', 'sonarjs/prefer-immediate-return': 'error', 'sonarjs/no-small-switch': 'error', + 'sonarjs/no-nested-template-literals': 'off', 'no-console': 'error', 'import/no-duplicates': 'error', 'prefer-destructuring': 'error', diff --git a/jest.config.cjs b/jest.config.cjs index 6f25d3af4b..e2b2decde8 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -13,6 +13,7 @@ module.exports = { '\\.scss\\?inline$': '/redisinsight/__mocks__/scssRaw.js', 'uiSrc/slices/store$': '/redisinsight/ui/src/utils/test-store.ts', 'uiSrc/(.*)': '/redisinsight/ui/src/$1', + 'apiSrc/(.*)': '/redisinsight/api/src/$1', '@redislabsdev/redis-ui-components': '@redis-ui/components', '@redislabsdev/redis-ui-styles': '@redis-ui/styles', '@redislabsdev/redis-ui-icons': '@redis-ui/icons', diff --git a/package.json b/package.json index 3fef125e97..d487da21e8 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.2", "@electron/rebuild": "^3.7.1", + "@faker-js/faker": "^9.9.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@svgr/webpack": "^8.1.0", "@teamsupercell/typings-for-css-modules-loader": "^2.4.0", @@ -180,6 +181,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-sonarjs": "^2.0.4", "file-loader": "^6.0.0", + "fishery": "^2.3.1", "google-auth-library": "^9.0.0", "googleapis": "^125.0.0", "html-webpack-plugin": "^5.6.0", diff --git a/redisinsight/api/data/vector-collections/bikes b/redisinsight/api/data/vector-collections/bikes new file mode 100644 index 0000000000..d6d962dc8e --- /dev/null +++ b/redisinsight/api/data/vector-collections/bikes @@ -0,0 +1,111 @@ +HSET bikes:10000 model 'Saturn' brand 'BikeShind' price 3837 type 'Enduro bikes' material 'alloy' weight 7.2 description 'This bike fills the space between a pure XC race bike, and a trail bike. It is light, with shorter travel (115mm rear and 120mm front), and quick handling. It has a lightweight frame and all-carbon fork, with cables routed internally. That said, we feel this bike is a fantastic option for the rider seeking the versatility that this highly adjustable bike provides.' description_embeddings "p\xaa\xcb\xbc\x91$\x1a<#\x07\xa8\xbc\x8b\xf6\xd0;\xfc\xce\x96<\x0b\x07\x95=i\x85\x1f\xbd\xb0\xc3\x89\xbb\xbd\xef\xa0=\xd4aL;X\xfb\xd4<6\xd8n=\xfb\x94y=i\x16\x03\xbc\x95\xde\x9a=\"\xe8\x96\xbdT\x91p=\xfd\xc2d\xbc\x91-Y\xbc\\wf\xbd\x87\xac\xcc;a\xd5\x18\xbdn\rH=\xe8\xdf\xb4\xbd\x12)p\xbd\x98\x85\x92\xbcj\x00\x8d;,#\x0f<\na\xdb\xbc\xee\xa3?=\x7f\xad\xf3\xbb]\xa3\xdd\xbc\x0f\x1f\x81=}\x81\xa5\xbd]\xbf\xba< \x93\x97\xbaV\"\xfb{<\xd8\xd4\xd3\xbcv\x88\xb3;\xc1\x19\xd9;\x16\xbd\x95=@\xcb\xcf<\xe8\xeez\xbc\xfbRZ\xbd\xae*);\x8e\xb05;h\xaaW\xbc\x98\\\\=\r7\xd4<\x0c\xd2\xb0<\xa9?\x8b<\x05\x1eo\xbd\xbd{\xc1\xbc\x15fm\xbd\x00\xe3\xae<\xe2+\x98<{\x07A:\xd9\x8a[\xb3\xd5<\xd6W\xbd\xba\"\x9c\xa5\xbc\xed\x00M=\"\xc3\x89:\x93w\x14\xc4\xb6\xb9UM\x8a;)Q\x8e<\xd1F(\xbcz\x9e\x03=\xb4\xe2\xf2\xbb\x94\x99$<~\x11>\xbd\xe7i\x15\xbd\x8a\x01\xd6<\xd4\xe9\x9f<`\xbf6\xbc\x08\xd4\xab\xbb\xf8\x0c\x89\xbd@\x9d\t=(\xe5\x0e\xbc6\xd3\x00\xbd\xa8\x82\xa9<{\xd8\xfc\xbc\x81S\x89<\x87\x1f\x17=\x17:\xd0;\xce\xe6]=\xb5\x95\x82\xbb\xf50+\xbcm\xe9n\xbdIn.\xbd\xb4\x95\x01;p\x89i=,\x0b}\xbc\xde\xae\xce\xba\x01\"\x8b<\xb1\xd2(\xbd\xe8\xb6\x1d=\x1ak\xd4\xbc\xb8\x1e\xd4;_\xd0\xde;\xf8\x98\xc4\xbaz\\\xec<\x8c\xd6K<\x94\x84\x17=\x7fv\x91\xbd\xcb\xe7n\xbdP<\xb4\xbd\xf8\xc5\xbd<\x00\x0fF=\x85z\x15<\xc54\"<\xd8\x7fh\xbc\xde\xff\x01\xbca\xf4\xa8\xbc\x94]\xb0\xbc\xe8\xda\x80=?\xa9\x17\xbb\xff4\xc4\xbb\x8a\xf7\xb2=^\xd00\xbd\xa7\xad3\xbd\xf1\xf9\x84:\x1b\xbc%\xbd(\xe84\xbd/\x01\x9b<\xf716=r\x86\xa3=}n\x0c=\x05\xa8\xfd\xb8\xd5\x84C<\xf9\\\xba<\xfe\xb3\xa8\xba\xa9\x82\xaa=\xf6{\x06;\x9eXk=\x86F]=\x18\xa7\xed\xbb\xc1\xef?\xbdXi\xb9:w\x95\x8b\xbc\x0eD\xb3\xbc\xa8\re<\xd7G\xb6&\xbdj\xbc\x9b\xbc5\x02\xa6H;@\xa5.=\x12\xf3=\xbd\x97\xc7D;\xc3U\x8b\xbb\xf0\x04\xdb9!\xeb\xae\xbb\xbc$\x0b\x1e\xbc\"\xe5c=4T\x1c\xbd_\xc9\xec\xbcG8\x85<+\xa2\x91\xbcu\x12\x84\xbd\x99\xce\x02=D\x16R<\xeaS ;\xae\x9e\x1c=\xa1:\x01\xbd2\xc15\xbc\x88u\x8d\xbd\xfd\x85i\xbd\xcb\x00\n\xbdb\x81u\xbc\xd2\x16M\xbcN\xd8\x9e<\xf0\x94\xf2\xbcn\x80\xea\xbcF\xe9\xb0\xbcL9h\xbd\x88;\"\xbd\x8bf%<\xe5\xd6 <\x820!\xbd\n@\x93<\x14R\x91\xbd\xed\xb3j;\xe7\x9d\x8e:\x9c\xee\x07<-T\xd8<\xb2\x96B\xbdb\x14\xa5\xbb\xd0\xd2P\x97\xbc\xaf~\xa4\xb9\x9a\x0b\x1a\xbd\x06\x9c\xd9\xbcX#\"\xbdD\x07\x02=:;0\xbc\xa7v\xaa\xbc\xafE\x97\xbd\xf8\x14W<\xcf\xdd#<\xee\xab\x9a\xbd\xbaw\x17\xbcE\x08 \xbdp\x13\x9d\xbbG,(\xbd\xe6\xdad;\x19\x9f\xa8<%*\x84=\xe2(\r\xbc\x16\xf4\xcb\xbc\xe5\x06,<\x89\xbc^\xbc\x96|\xb0\xba\x1b\x1a\xa7W\x92\xbc\xf8\x99M\xbc4\xfb\xe7\xbc_\x02.<(\xb9&\xbdJ\x97\x8b=\xc6Il\xbc!x\xfb\xbc\"\xc9\xe8<\xabrw<\xf4Ls<\xf0\xc8#=*Tx=M\xdc\xce\xbc\xf3\xca\n=\xc2\xfc\xa5<\x96d\x81<\x86\xd9\"\xbd\xebh\xc8\xbc\xc8\\\x0f=\xc5\x90@\xbd\x07\x1fB<\x91\xc2\xa9\xba\xb75\xba;\xc2*\xc7\xbc\x95\x19\"\xbd\xcf\x0b`=\xdf\xeeP\xbc\xba\x83\x03=\xe8\x01\xe1\xbb\xabdn=#\xe4\xef\xbb\xc1\x94\xb8;\xa5]J\xbd\xcc.\xbf9\x18c\x0b\xbd\x8f\xa6\x06>E\x171\xbcr\x0bz\xbb\xc0;\xe3\xbbio\xc8\xba\x05q\x9b<\n-G\xbb\xdel\xf4\xbc\xe7\x91\xae<\xa0\xc9\x08\xbc\x9cAu\xbc0\xe0\x11;\x00\xb0i\xbc\xf9\tm\xbbF\xb0\xc2\xbd\x90\xfbd\xbc\xe3w\xc9<\x1a\xe1\xd0\xbbD\x0bC=\xe1.f<\xefY\x9a\xbc\x18\xe9\xbd\xbcBbp=C\xfb\x9d\xbd\x84\"\xd8<0\xee?\xbb\xc1\x9e\xad\xbc\x93\xa3`=\xb3D1<&\x17\xaa\xbc\xd7\xa3,\xbb\xc0\x07\xdc<\x8a\xa5\xd5<\xd67\xa8<\x1a\x80d\xbc\xa8\x0b\\\xbc\x8d\x00(\xbc2\x143=\x1f\x07\x08\xbdee/=\x8at\x00=\xab\x04\xaf<\xcblP<63v=\x15\x9c>\xbc\xe9<\x89\xbdli\xd3\xbcmY\xae<\rl\x83<\xb1\xcf\x9a\xbbjn\x93\xbd\t\xe7H=\xe2Cr=\xf2Ai\xbc:\x1a\xc5\xbc\xb7\xc07\xbd\xeb\xa2\x92=Iij;\xa2\x8fp< \x95\x03=\xbd\x14\xa8=\xb5\x12\x92<\x0e\xb4\x9f=\x81\xbe\x1b=\xbaJ\xdc<(\x81\xb0\xbc;\x7f\x9b=\xc3\x8bs\xbb\x03\x81\xa6\xbdp\xc9\x03\xbc\xc1\xdf\n\xbcx\x7f8\xbc{\xc6\xdd\xbcb1\xc8\xbd\xdd&\xe0\xbc\x0c\xea\x8f<;]\xc8<\xe5K\x1d=\xf5\xdc\x83\x8d;\xc2\xd18\xbd\x91\xf4k\xbd\xec\xa4\x17\xbd\x02\xcb8\xbc\xe2bf\xbd\x17\x08\x12=\x17#`;3\xdd\x9d=XF\x89<>\x14\\=\xbb\"@\xbdXH\xf5;\xa4O\xc3<\\\xfbM=\xf9K6\xbd\xb9\xcb\x1f\xbd\xccdK\xbbHi\xd4<;\x04\xea9\x1e\x93\x06\xba\"\xba/\xbc\xa3\xce\xe3<\xc1r[\xbc~\xb2\x7f\xbc\x18\xd3\xb8<\x9d\xab`\xbc\xa0y\x14=\xc7+\xae<\xef|@=\xff\xa9\x9f\xbd2\x08 8\"\x11W;mI6<\xd8\x19\x1f<\x94\xf4\xae<8\x03,;r\x8dm\xbcu\xact\xbc%\xfc\xed\xbb0\xdb\x0b\xbd4o\xf3\xbbw/h=\xa2\x08\xd1\xbc\x96\xcc\x93;mZ\xdc\xbc\\!?\xbb\xd6\x18s;\xfa\xe8\xad<\xd4.\x98=\x1e\xae\xb2<\x844\x9d:\xe55\x8a;\x1a\xfb\x15\xbd\x8a\xd4\x0f\xbcX\xa5Y\xbd\x021\x18=WX\n\xbd;\xb4!\xbd\xe4r\xb6\xbb\xc7\x1a\x7f\xbc\xf4C:\xbd\xed\x9d\xa0<\xbd\xcdH\xbc\xaa\x15\x84\xbc\xf7$L=V\xc32\xbdos\xcb\xbc>\xfc\xac\xbd\x15\x8eX\xbd}\xe1\xfd\xbc\x1b\xc2\xba\xbcsH\xf9\xbb9\x8c\xb6<\x1f\xd5&\xbd\x0b<\x83\xbc\xfd\xf5\xd0\xbc1l\x95\xbd\xda\x7f\xef\xbcU\xa0\x1f<\x14y?\xa0\xbd\x7f\x96\xc1;#\xd9\x8a\xbb\xc6\xfb\xa3<\x88\x80\xbc<\x9d[\n\xbd\xc1\x01\xd8\xbc\xd8\x8eK<\xcdM\xa2\xbc\xe4T\x90\xbd^4\x9c\xbd\xcf\x14P\xbdd\x18M;\xd5\xa8{=,/\x00:T\x92\xb3=\xe1\x13\xe2\xbbm\xdd\xb3<\xb8\x1a\x82\xbd>o\x8d\xbd\xca\xa8)=\xdfD\xf7\xbb[o\r;1\x9dx\xbc\x08\x1c\xb6\xbc\x036f;\x8d:\x93\xbd8=\xc7\xbc\x9b\xaf\xfe<\x1e<:=N\x03\x8b\xbc\t \x83\xbbZ\x89e=\xf7B?<\x1c^\xcc<\x8b\x9do<\x1bE4\xbaf\x0b\r\xbdJp\xa5\xbbT\x12\x88\xbd\xea\xa6\xe6\xb8\xf2\xb5\xdd<\x83\x7f\x11\xbc\x7f\x05V\xb0\xbc\xdb\xb52=\xe5\xc3m=\xf7\x9c2\xbc\xce\xb4\x10=\x06rG\xbd>c\x0f=\xcd\xc5\xf9\xbc" +HSET bikes:10002 model 'Tethys' brand '7th Generation' price 2961 type 'Road bikes' material 'alloy' weight 7.4 description 'The bike has a lightweight form factor, making it easier for seniors to use. At this price point, you get a Shimano 105 hydraulic groupset with a RS510 crank set. The wheels have had a slight upgrade for 2022, so you\'re now getting DT Swiss R470 rims with the Formula hubs. All in all it\'s an impressive package for the price, making it very competitive.' description_embeddings "j\xa1\xa5\xbcM\x18\xa1<\xfc\x7f\x9a\xbcV\xb6\xcd3\xe90\xbd\xea\x7f\x97\xbc\xc9\xcdm+\x0c\xbd\xd7\x10\x9f-?=\xda\xd5\xad\xbcE\tB<\x9cR\x13\xbdo\x8e\x0b\xbc\x1e\x86\xb5<\xd98e\xbd\xe9\xed]=lZ\xc7\xbc\x0c\x8d\xd5\xbc\x1c\"\x90\xbd~/\xbf\xbc\xe0U\x87=\xa8\x9d\xb0\xbb\x08`B=F\x19\x02<\xbcYD=\x00Oe\xb9\x10^\xe9<\xbec\x08\xbd\x89U*\xbcr\xd4\x01=\xb1s\xde\xbbB@\xad9T5\xa8=\xafz_\xbc\xaem\xd8\xbd\xady\x96\xbdm\xe2\xa9<\xa51\xaf\xbc\x06\x9a\x93\xbc\xfe5m\xbc;\xdf\xce;\xa8\xdd\xc7\xbd\xdc\x06\x8f=\x9d\xf2(\xbd\xbdf\xfd\xb9Y\x04\x13\xbd\xea\xbcx=,\\\xe59\x1d\x80\x13\xbc~|\xf3\xbc\xe3\xc1\xf1\xbc\xb9>\x97\xbcLL\xa9=\xc6H\x00\xbdq\xd4\xe3\xbcR~\xb2<\xa0Q[<\xb3\xff\xe2;\xb1OP\xbc,9^\xbc+\x1c+=~\xbb\xdf<3R\r\xbd\xcc4\xab<\x99\xa5\x80<\xa1,\x96\xbb\xb49 <\xff\x9d\x98\xbc\xe1=\xdd<\x84\xa45\xbd\x96\x1e/\xbd\xd8\xca:;G\xc3n<\t\xd5v<\x81\x9b\x89\xbd\x84\x1bK\xbbq\xe6\xca<\xfa_\x17=\xf5<\xe6\xc4\x85\xfe;\x1a\xcd(;lH\x1e\xbc{\xfd\t\xbd\x89\x0c\x89\xbdgw\x1d=\xee\xd2^\xbc\x1c8\x85\xbb\xecn4=l\xc3&\xbd\x96\xc2\xbf:}uv;.O\x00=?\xa2\x91<\x1e\xad\n\xbb\x1f\xa5\xbe\xbc\xf9\xc9\x16=\x83\x14\x16\xbb\x85\x89\x84\xbd\x13\xadH\xbc\rL\x97\xbc,p\x94<\x93$\x8b\xbc\xc4i\xb8<\xd5@\x16\xbd\x1cC\xdc<\xb6\xcf\xdf<\x1fMU<\xe6\xef\xc0\xb9Q\x85\xef<$\x85t=\x86\x97\x91\xbc\xcc\x1d\xfd<\xe1]%\xbd?\x92t:\x8f\x1bJ\xbd.\x0cz=-\xbd\x7f\xbdMZ&=\x93?`=kTh=\xbdW1=\xed\x9b\xbd\xbb\x0b\x81\x80<\xe5\x11X\xbd\x0f5C=\x04\xe0&<\x1fn\x1b\xbdU\xf4\x03\xbd;L\x93\xbdD\xa7Q=1\x1dz\xbc\xa8\xd5%= \xafU\xbc\xff\x1a\"=sND\xbd\x9dh.\xbd\x86\tI\xbd/\x88\xef;\xe6\x06\xb2\xbd\xc8\x1c\xa0n\x91\xbc\xcb)U\xbd\xbc\x85N\xbd\xdb\xc8\x9e<\xb0e?\xbd\x9d\xcc\xce<\xe4)4=\xc0\x15;=\x02\x8b\xa2;\xa8\xa9N=Kj<\xbdT\xe2\x8c\xbd\x19\xff\x19=\x93\xcc\xfa:J7\x97<^\xab9\xbd\xd09k;P\x11\xe5<[\xa0e\xbc~Yu<1j,<\x05]\t9E\xe5\xc3\xbc[p\x0c:\x9aSd\xbc\xb0D\\\xbc\x12\x8d0\xbb\xfe}Q<\x7f\x80\n<" +HSET bikes:10003 model 'Enterprise' brand 'nHill' price 1244 type 'Kids bikes' material 'alloy' weight 13.0 description 'Easy, intuitive, and very lightweight, these bikes are carefully designed to make bike riding as natural as possible. A set of powerful Shimano hydraulic disc brakes provide ample stopping ability. It’s for the rider who wants both efficiency and capability.' description_embeddings "q^\xb1;`\x88$=\xe3\x08\xeb\xbc6\xc4\x15<<\x99T\xbc\x00\xba\x90<\xbc\x9ai=M\xe3\x05<\x1a\xa5\x94\xbc\xf5\xf29\xbd\x1c\"\xf7=\xe1\"\xc2;\xdb2\xe1\xbc\x07\x18\x9c<\xact\x19\xbb,\r\x03<\x0f\x04C\xbc\x08\x9d\xff:\xb8\xf3\xac\xbc1B\xca\xbc!\xcf\xb8:\xb6\x01\xc9<\xbc\x83\n\xbdpP\x06<\xfe\xeb?\x1d=\xa7\xa9\t=p\xbb\x9a<\xe3Jb=\xa3\x0e\" \xea\xe6\xbb\xff\x8e\xed<\xde\xc8\xe5<\x0c~\xfe=\xa1\xe0\x949\xf0\xd3j;\xd8\"\x07=\x91\x7f\x1a\xbb\xf7z\x00\xbc\x11\x12\xaa\xbc\x13c\x9b\xbcm\x8d\xb9<\x03\x88\xf1\xbcwJ\x84=\xbdr\xdb\xbc\xfe\xd2l;.\x03\xd1\xbc\xf0\xe4\x89<\xb8\xea\x95\xbaL\x02\xe5\xbdSvZ\xbdj\xb1\x05=\x006\x85\xbc\xc5\xab\xd8\xbc#.\x9c=\xd6~\x7f=\x01yc<\xe5\xa2\x7f;\xf4\x9cD\xbd\x16\xe4\t\xbc\xd9yq\xbd\xc9\xa4>=}#\x9c\xd9;\x1d\xfb\x07\xbd\xf4\x8a\x8a\xbc\x8aE\xac\xbb+\x89\xaa\xbc\xf3\xab\xb8<\xafv#=\x17\x98\xe7<\xc9\x0c\xea\xbc\x98\xe0\xc5<[M1=0\xd1~:\xaf\xf9\xaf;\xc1\xd2\x80\xbc\xe2~\x08:\x0f\xc0\x9a<\xc0Y\x16\xfaJ\xbc\xf2t\xbf<\x9e\x02I=\xbdU8<;\"\xaf*\xc2<\x8b\x1d\x06=\x04\xbb[=\xb7\xb6.\xbc\xad\xda(<\xc2\x00\xa2:\xdaY\r<*\xc7#\xbdd\xda\x86<\x8fz\xdc\xbb#:\xc0\xbbKrb\xff\xbc\xd9\x84\xf5\xbcO\\\xd6\xbc\x9b\x9c&<\x83L\x93<\x7f=;=\xe3\xc0\x8c<\x02\x87d\xbc\xc7\x9c@\xbd\x1e\x8c\x07\xbd\xd2\x87\x86\xbc\x90P\xcf<\xef}\x8d\xbb\x10\xa3~\xbb,|P\xbd\xb1\xd6\xa3\xbb\x16\"\r\xbdf\n\xbd<\x1e\xde\xa8<\x80\x04\x03=\xcf\"\x87\xbd\"\xd9\x1e\xbc6\xf5\xae=#\xde\x9d<\xe2!\x19\xbcY*4<\x04\x03\xe6<\xf5\x95\xdc\xbc\xed\x8f\x03=\xde\xbe^\xbc\xc7\x12\x86\xbcP\xc4\x88<\xeb\xab\x04=\x9e\xd3\x95;\x8aF\x94=V{8<\x81\xdf1\xbc\xe4}\x1f\xbd\xe0\xce\x0b=\x19\xddq\xbb[yE\xbd~\xb5\xaf9B\xff{=r\xb6\xae\xbd\x85\x17\xe5=FXJ=\xc3\x86\x02\xbdc\xd9m<\xd5J\x14=\xcd5\x17\xb9\xc356\xbd[\xc3#=c\xe0\xc5=\xfbKM\xbd \xe8\xa2\xbb$\xd9\xac=T\x05B\xbb\xbfz\xe9\xbcj_y\xbc[\x07w\xbc\x80\xea\x14\xbd\x01\xde5=k\xfb\xd5\xbb\\#\xd0\xbc\xff\xf7R=\xab\x91j=\xbaT\xc5\xbcWy\x1b\xbdF\xc4\x04\xbd\x13\xd4\x0f\xbd6M,\xbd^\"G<\xa3S\x08\xbd{h\x0f=\xfaeu\xbd\xe8\x8cH;\x06\xd0\xe3\xbb:6\x05=\xa3\xf6g\xbd\x03\xd9\x7f\xbcv\xb8\x9d\xbb\x1e\xe9\xa5I=\x129\xce;\x03\xa3\x0b\xbd\xb6\xf6\xfc<\x81\x06\xdf<\xb8\xa1\x04\xbc+E\x11=\xfeh\xe4<%N\x11<\xc0l\x17=\x8e\xc2\x14=\x06#\xb3:\x0c\xcc\xe8<\x8f\xce\x1b<\xc8\xbb;\xbc\xb4\x16\x91<\x14\xea\xa6\xcb\x96\xbc\xdb\xd3x=8O4\xbc\xe2\xa8\xa8\xbd\xd3\xac \xbc\xb2\xa4\x8e<\xb5\xa3o\xbc\xa7yn\xbd\xf8\x1a\x9c=\xd4\x8b\xa5<*\xf7\x90<\xa8v;=\xa4\xebM\xbc\xc8v\xcc\xbc\x9d\x80\x9b\xbd:\x95\x89\xbc\xdc\r\xe6<6\xc8\x1a\xbc\xef\xda\t<\xc2\x19\x1a\xbc\xdd\xe2X\xbd\xf4\xcd\xcf\xba\x93n\n;osN\xbc\x8bU^<\xbb\x16\x19\xbdU\xc6d\xbd\x94}oK\xed\xba\xba\x01\xf0\xdd\xd5,=g\x14\xc1<\x04Gz&<;\x9dP\x06=\xdf)\xf4\xbc\x1f\x96\x0b=\x92\xbd\xf5<\xe0j\x83<\xa6Z\xb4\xbc\x03vC=F\xc4\xbe\xbcp\x83/\xbd\xd7\x82\x98\xbdc\xb0\xc7<\x13\xb5\xcc<\xbd\xec\xa9\xbc\xec\xc5m\xbd\x8f$\x88<\xb2\xb2M=\x97\x07\xea\xbc-FH\xbd\x8e\xa50\xba\x07\x8a%=\xed\x93\x94\xbc\xa3\x97\x1f=\x0cs\xe7\xbcf\xc2\xab<\x9b\xe3O\xbdm\xbf\xf4\xbb\x88uA\xbc:B\xf3\xbc\xe3\xeb\x96=\xe8\xbc.\xbd\xebA\xdf\xbc\xa5\xfb\x80\xbd*n[=\x8b\xc7\xb8<\x9a\xe9\x92;x}Y={\xa6h<\xce\xed\x87;\xcf\x17\xb5<\xd4\x050=O\xb4\x15\xbcz\x0fL\xbd2\x04\x9a\xbc\xa7\x86\x8c\xbc\xdbW\xa0\xbd\xd8&\n=\x9b\x82\xb9<\xf5\xf0\xb2\xbc<\xb4\x19<\x8bRM=~\x89\"\xbd\x86\x80,=\x1dG\xcf\xbcs_F\xbd\xad\x00\xec<\x16d\x16\xbd\x99\xe9\x0f<\xf6\xe8\x93\xbc\x89C\x0e\xbd\xc2\x11\x9e<\x81\xc7[\xbd\xd7\xc4E\xbc\xdd\x06\xe5\xbc\xdd\x90w=\xcb\x05\xde9\xc80W\xbd\x93\x18\"=s\"\xb8=\x10\xc5\xa5\xbc\xec\x18\x80\xbd\x1f\xd3\x00\xbe\xb8\xfc\x86<&\xc8\x11\xbd\x7f@\x13=\x17\xa8M\xbb\xd8\xdd>\xbb\x7f\x8dQ=;\x91\xda\xbc\xff;W\xbb\"\xd1{\xbbf\x1f\x07=\x0c7$\xbd\xb5\xa4z<(5E\xbd\xb8\x89\x87=\x11\xb0\xd3<\xd6\xdd\x03=\xa4\x0b\x97\xbc\xaaL9<3\xd9,\xbc\xdb\x1ak\xbd\xd8\x00\x84<\xc9\xf5\xab\xbdmm\xdf\xbc3\n\x99<\x04\xaeA\xbc\xe8\xde\xf5<\tX\x15\xbd%eC\xbc/K\"\xbd\xe0\x03\x8e\xa7t=\r\x9c\x1d=\xef\x8e\xed;\xc5;U\xbd\x0cR\xfc9w*-=\xfdk\xb6\xbd,\xe3\x90\xbc\xbc\xa4^=\x1e9q\xbdoY\x80=]\xf6\x14\xbd!_1\xbc\x0c\xd3\xb4<\xf6\xd6e=7e\x9e\xbb\x06\xf6\x1f\xbd\x82\ra\xbd\x0b\xb07:&\x9c\x1d\xbc\xb4\xe2\xff=0\xa0*\xbd\xa9\xbe\xa0\xbb\xb6G\x8a=\xbc\n%\xbc\xae\x1d(\xbc\x1d\x8a\x11<\"\xd27\xbd\x12wz=x\xb1\xfc\xbc5\x80\xa0\xbc\xe2\xa4u\xbc\xfdb,\xbch\xf4*=\x86\xfb3\xbdSY\xab\xbcm\xae\xe9K<\xe1\x17+=J3\x16<\x0c\x9fZ\xbd/\xe8\x8b<\x89\xf2\x02\xbd\xe3\x8f\x85\xbcE\xb8l;\xe0\xaf\x01\xbd\xf6\xdf\x81\xbc\x1e\x08\xed= 5@<\x18d\x01<\xac\xbd\x80\xbc\xff\x04[\xbd\xb2\xbaG=m\x0fT\xbc\xdd`\xa4<\xa9\x82\x08\xbd9\xa8\x12\xbd\xd9\xaf\xe6\xbd\xdc36=\x1ay\x12\xbd\x88\xc2;<\x00\xfa\x0e\xbd\xc7\xe7Z\xbdy\xf0\xe8;\xd1\x7f\xca\xb9Q\xc1\x87\xbco*3\xbc$\xc9^<-\x1f\xfb<\x05Ue\xbc\t\x9br<\xe7\x0e\xb3\xbc&\xe8\xb1=\xfaf\x99\xba\x04S\xfa\xbb\x0b6\xec<\xd2=\x0b=\xbaja<\xe0\x95,<\xba\x8b\xa6\xbd\x91\x1e-\xbd\xe08s\xbbx\x02Z=\xf6\xac\xfd\xbb2~\x8a\xbc\x15b&\xbd}N\xb3\xbb\xf3w\xfd\xba\x97\x18\x0e\xbd8L\xca\xbd\x93\xb8T\xbc\xd6\xa1\"<|5P\xbcA\xfb+=IF\x0b=4\xc4\x0e=\xd1m\xab;\x0e\xc3f=m\x9c)\xbc\xc9\x93\x11\xbdr\xd7i;\x03\x89\x08\xbaQi\xa3<\"v5\xbd\x89\x06S\xbc2C\x05=\x1b\xdd\xbf8\xce\x1ar\xbdi\xcc\xfa\xbc\x96\xec\xe2<\\\xf6\xfe\xbc\xd1S\xf8\xbc\xdf\xc4 \xbc\xae\x8c$\xbc\x9f\x1a\x91\xbd\xa5\x89\xa9\xbc\xaa\xb5\x1c\xbc\n\xe7\x10\xbdhr\xfe<:\xe0\xd6\xbc\xdb;\x84\xbd\xae\x8e\xb2<\x84v\xae=\x15\x9aj\xbc\xc0\xb5\x9f\xbaT\xa0\xa6\xbbX\xac\x9b\xbdR\xfd%=\x17\xeeT=DK\x96\xbd\x1e~\x87\xbb\xb4\xb9\x1c\xbc5\xb1\xa4<\x85w\xa5=\xf7\xc9G\xbb\x92p\xf8\xbc\xa45\x1a=\xe1<}\xbcH\xcb\\\xbc\x1e\x9e\x10=\x93\x154=7/\x8a\xbc2FS=\tN\x11\xba7s\x02=0\x0e2r%\xbc*A\x10=\x866.<\x02\xd7\xf1\xbcxI>\xbc\xe1\x8f|\xbd\x8a\xdf\x06=\x0bP\xe4<#\x9b:<=\xed \xbcY3s9a\x85\xf0:\xfe\x0f\xd0<\xd6\xf7|\xbd\x1e\x8e\xa0\xbc\xef\xed\xbc:\xaa\xbe\xfe\xbb\n\x13\xe8;\x86\xba\xd5\xbc\xa5>\xb1<\xa6e\x92<\x8b\x04\xac<\xe6\x86\x9c<\x11\xf4\x7f\xbcYM\x97;}\xea\xa4<\x01\x1b\x11=NB\xb4\xbah?\x1e=-\x84\xc0;\x9e\xfe\x03\xbc,\xb3T\xbc\xd1*K\xbd\xf3\xe3\xa2\xbb/G%=\xd3\xdd\xd6\xbb\x05,\xec\xbc.~\x82<\x85\xdf\xad<\x88X\xad<\x1e\x97m\xbc8\xfa\xd1\xc8\xbc\xa3\xa5\xbb<\x1d\xdaI;\x03j(=/\x0e\xbf\xbc\x17\x07\xfb\xbcY\xbd:\xbd\x93\xd1\x1b<\xe6\xd6\x11\xbc\xaf\x13\xbb\xbcA\xe6\x1f\xbd\x9e\xbf\xf5\x05\xb5=@\x0e^=\xcaqp\xbc\n|\xa5<\xa8\xf0\x9d\xbb\xc7A\xa2\xbc\xbe:\x82=\xed\xd2#<\x16h*<4\x88(\xbc8\x94\x16\xbcD\x04T\xbdd\xb2Z\xba\xf1%\xae<\x015\x1a\xbd\xb8\xd9<=,\"\xea<\xc5]\x06=\xbc-E=7W\xa5<\n\x83r\xbb\x81\xf5O\xbdV?\x16\xbc1\x8a\x0f\xbcEl\xa1<\xcf;\xa9\xbd\t\xfd\x8d\xbc\xce\x9a%\xbc\xed%\xb6\xbc\xc0c5\xbb\xe4\x927=\xd3\xa0\x1f\xbd\x9e\x015<\xebs\xc6\xbd\xc5\xda\xf6\xbbEZ\xdb\xbc\xb3\x02F\xbd\xffr\xda\xbb\xa4\xf9\xf5\xbbuXA=\xe7q\xdf<\x01\x15[=\x88\x06u<\xae\x0e.<\x84D\x1a=\x82\"\x1f\xbd,?L\xbc\xa3\xcf\xbb\xbc\x8c\xa8\xb6\xbb\xbd^$\xbd" +HSET bikes:10006 model 'Quaoar' brand 'ScramBikes' price 3689 type 'Commuter bikes' material 'carbon' weight 12.8 description 'This bike is a great option for anyone who just wants a bike to get about on The Shimano gear system effectively does away with an external cassette, so is super low maintenance in terms of wear and tear. This is the bike for the rider who wants trail manners with low fuss ownership.' description_embeddings "\xd03\xd1;\x1a\xc8\xee<2L\xc9\xbc5\xc5\x03=\xed[\x90<&|\xd4<\xe2\xc0\x03\xbd\x84%\\\xbdU\x00\x89=G\x9b\xda<\xee\xc6\x83<\xddJ\x00=\xb7,\"=\x8e9\x99;\xd6\xe1\xba=t\xab>\xbc9+\x88\xbcPO8\xbf\xbc\xa1\xf3N\xbd\xdeC\xa8\xbc\xd2t\x81\xbcz\xc8\xff<\x01\xb0\x83<\x87\x87\x8f\xbc\xaeEx\xbd\xaf_g;\xdcSa\xbd\n\x95[<]Qh<\xf3\xf5Q<%q=\xbd/`\x909\x0b\x91\xa8=p\xb5[\xbaV\xb5=:;\xb5\xc1\xbc\x03\xd9I<6\xd4\xff<\x81\x7f><\x0e\xaf\x0c<\xc4LM\xbd\xefl\n\xbd\x83\x851\xbd\xfb/\x8c\xbc\xc8]\xcf\xbb\x1a\xd5\xe0\xbd\xe9\x98M\xbdZ}0\xbd\x82\x0b\xf3\xbc*\x19*\xbd\xb55%<|\xf3\xda\xbc1\xb8\x06=\xde4F=/\xdeq\xbc0}\xfd\xbc\xffw\xfd;S\xbc\xbb\xbc\xe9\xf4\xf0\xbc?\xd5\x0f\xbd\xbaQ\x11=;\xfc\xb2=kA\x8c9\x8d\xcf\x83\xbbgd\t\xbc\x92\n\xe2<\xcb\xba\xa6\xbc\xbc\xe3\xaf=\xd7u\xbe<\x8ad&=^,\x9f\xbc\xa9\xe8\x02=k]\x03\xbd\x03\xe4\xc2<|ja<\xdd\x14$\xbc\x0e\xd8\x17<\xcc\x19\xe8\xba\xfa\xd5F=\xf1\rY\xbd\xa0\xb53\xbdD\xb1\xd9\xbd\x8e#:=g\xebn\xbb\xa3\xa2A\xbd\xc5\x97\xa2\xbds\x0c\xdb\xbc\x82\x0e\x0e\xbd4\xc7&<\xfa$\xcc9*W\xf0\xbc\xf9\x1a7=\x95\xe6\x18\xbaP\x06\xbb\xbca\x82m<=\xfd\x04\xbc\xf5\x86\xd7\xbcjs\xc1\xbb\xe8r\xd9\xba\xb2\x0f\x92;\xd7O^\xbcJK\x99\xbc\x80#+=\x15\x90\x94\xbd\r\xfa\x82\xbd\xea\xe0\xcb<\x85\xdef;tT\xe4\xde\x94\xbd\x98\x87\x05\xbd\xee\xcc\xe0\xbbs\xc4Z\xbdf\t-=\xaa\x18~\xbd \xf1G\xbd\xa2\xc69=\x1aB\xaf\xbc\xe3\x85\x89\xbd(\x82\xb1=\xd7\xf7\x88=Et\x92\xbc\xaa\x83\xad<8\x1a\x83<\xf6+\x80\xbd\xd0`\x8c\xbb\x8dS$=Vr\x96\xbd~q\x0f;\xc3l\xec\xbcJO\xff<\xda\xb3\x91<\xb6$\x02\xbb\xb0\xd5\x02\xbc\xa2\xc3\t=\xf9\xca\x7f\xbc\xda\x18\x81\xbb\x8a\x80\xb2<\xc6\x04\xfa<\x0f\xa7J<\xdb\x96\xf7;g\x8d\xd68y\xe8\x98<\xbf6K=Z\xbet\xbc\xb9\xdbI\xbd\xeaj*<\xf3\x17\xdf<7 \xd8:\x98\x13\xd5\xbc\xa2w\x02=\x1d\xf0$=,\x05\x16<\xa4(\xa5\xbc)2Z\xbd\xa9\xbb@=\x15\xfb\xef\xbb\xb9j\xb6\xbc\xa8K\xa8;\x1d \xc3\xbcZ\x9f\x1c\xbc\x8bJ\xea\xbca\xef\x13\xbd\xcct\xa3=\xa9\xc8\x10=\xe3A\xfb<4\xf3\xb3;\xb6Jh=\x97\x10\x7f\xbc\x91g\xbc\xbc\x0cIt\xbc\xd89\x8d<\xbb\xbf\xe5\xb9\xb4%\xbe<\xb9{\x82\xbc\xf4w\xed\xbb\x8c\xcf*\xbd\xbf\x85\x07=e4-\xbd!\xb2\x9d\xbd%\xdbn\xbd\xc5\xc6~\xbd\x17.D=##\x81\xbc\xf0[\x97\x18\xce=7^\x88\xbbs\xb5\x8a\xbc\x87gd\xbd|\xd5\x9f\xbcFp\x15\xbc\x8c<\x14\xaa<\xba}M\xbca\xd0\x89=\xbdrB\xbaY\x9bK\xbc\x84a\xf6\xba\xba\x1d\xc2<}%\xa2\xbc\xe7a~\xbd7=\x9d\xbd\xae.\xf2;]!\x06\xbd\x03\xe6\x95;\x1b\xb6\x83=\xd8\x14\xa8=\x16\x81i\xbd:\xc6\x03<\x11\xa4\xcb\xbbg30\xbd\xa2\xe0\x14\xbbH\xe2\x91\x90;{6\xeb<\x01\xb1\t\xbd\xc4c\x18<\xa3\xd7\xb5\xbb{\xc3\x85\xbc\xba\xcd\t=\xaf\x0f\x0b<\x1f\xe3.j6\xbdeK\x88\xbb\xc4\x14\x8e\xbc\x95\xfcK;\xaf\xd0\xd5\xbci\x9e\x9a;\x88a\x06\xbdl\x18\x95\xbb\xf8-\xd8\xbc\xfb\xef\xa3:\xcc\x81F:\xef\x98\xb2<\x8b\x08\xb3\xbb@8P\xbd\xf72;==\x00[\xbc\x7f\x99.\xbc\x9b\xa7\x97\xbc\xae\x87\x1f=\xba\xa2\xc7\xbc\x02#Z\xbd\x92\xcf\x90\xc0G\xbdu\x93\x82\xbd\xa1C\xcf<\xf46\x96=@\x16\x8c\xbc{\xff\xe2\xbc\x83\x19\x9a;\xef9\x18\xbdv\x9f~\xbb\xe90\x1f\xbdb\x12\x05\xbde\xdbh=\xea\xb3\xd6\xbc\rn\xf5<\xf3\xbf\x90\xbd\xad\x9c\x16\xbd|T\x0e\xbc\x84\xe6B<\x9b\xc1\x99<\xb1\xd4?\xbd\xd6\x1b\x99;Z\xb9a=*x\x08\xbd9\xd8\xe7<\xb7\xc6\x12<\xf1y\xcd<\xf8cb\xbdi\xc5\x80\xbd\xf9\xdeP\xbdm\xf3d=\x99\x14\x1d\xbd\x9dr\"\xbd\xd1\\\xb9\xbd\xce;@\xbc\xaft\x98<\xffQ\x8e\xbct\xbd\xd9;\x18\xcc\xec\xbc\xa0k\xa6\xbb\xbf\xdbt=\x98\t\xa6<0\xf4\xd8<\r\xed*=\xb9)A=\x9f\xe3@;V\x8a\x17\xbd\x88{\xff\xba\x89\x92\x83<\nJU\xbd\xfdRD;\xf9\x9c\xa4=\x8b0\xa2\xbd\xd6X\xff<\x8f\xbf\x86;0Gc\xbc\xabR\x1e\xbd\x8d\x13\x84=f\x96\xe4;L\xcb\xce\xbc\xf8/J\xbd_\x99\xe9\xbc\xdeE\x10\xbdP\xe6\xcd=\xbe\xf46;\xd2\x0e,\xbcI\xb8\\=\x01e\xbd;\x7ff\x9c<\xfc\xef\x1c<:\x18\x08\xbd\rM\xb6<\xba\xd2\x15\xbd\xa6=\x85\xbc\x04\xe2\xaf\xba\xa7\xf2d<\x96\x9d\xb6;FK\xb5\xbd\xe2\x0e\xf1\xbc\x98:\xe4<\xb9)\x8b\xbc1/\x01=H\x86:=\xe1?k=\x03\xcb{9D\xfa\xa1\xbcI\x82|\xbc\xaa\x81U=\xfcq\x86\xbc\xb3\x96L\xbd\xa6\x8a.\xbcK\r\xd0<\xaf}&\xbd\xce\x94B\xbd\xbb\x96\xd1\xbd\x87\xfc\x16\x15=|\xdb\xd1\xbbV\xd3\x85\xbc\x1b\x9b\xf0\xb8\x1bB5\xbc`( \xbd\xf9\xa7`\xbd\xcby\xdf\xbcA~\x8c=ak\xae<_\x87\xbc<\xbf,\xc6<\\\x7f\xef<\x08\x8b\x9a\xbci\x1a\n\xbd\xfa2\xc5\xbc\xe9\xe1\xba\xbb\xc5/\x1f\xbcw)\x10=\x13\x16i;E\xfb9<\xc7\x98S\xbd\xa5\x97\xd7\xbc\xed\x07\x99<\xdej6<\xaf\x94\x84=\x92\xe9\x93<\xe0h\xd2\xbcf\xc7\xd0\xbc?\x9b\xd0<\x10\xf9\xb1\xbc\x1bVo=\xfd\xcbk<4\xf1\xa3=4/\x9d\xbbOV4\xbdB9\x9d\xbc C\x84\xbd\xa1\xae\x92\xbd\xde\x94K=\xc8[o<\xa7\x12=<\x18\xf8Y=\xe5\x17F<\xd4\xca\xde\xbb\x08\xd3\xb1<-\x0e\x93\xbdR\xd2G;p.\x06=\xe4\xd5l\xbb\x82T\x18=\xba5B\xbci6\xa7\xbc\x12r\xe0<\xb92J=\xd5rg\xbd\x8c\x0c\x14\xbd!T\x1d=\xa2T:\xbd\xbb\xc1Z=\x1d\xb8\x85\xbc\x04\xaa\";=,(\xbc\x91\xff7=\xc5\x97\xaf\xbc\xb0\xb3\xb9\x84\x1e<\x04|\x19\xbb\xd753\xbd8\xb3\x00=@|\x98\xbc\xabC4<\xa4^/\xbb\xd8\x01\xc9\xbd\x1c\xcb\x1f\xbda\xdc\x02=U\x17\xd3\xbc\x93\xfaz\xbd\xfc\xea\x85=\xbcH\x07>On\xf7<\xf9\xe9\xbd<\x83\xa4\xcd\xbcpB\xdf;\x014\xb0\xbdGp\xe2<\xfe\"\xbe<\xba?\xcb\xbc\xa7\x02_<+\x1c\x97\xbc\xa9\xb4\xcd\xbb\xb8\xe5U\xbd\xc8\xa4n-D\xbd\xad\xf8\x0e\xbc\x08`\x01<\xc6\x08o=\x0b>\xb4=z\xa7\xa1\xbb\xb99\x03=\x9f\xd7F\xbd\xcdUR\xbd|J\xc0<\x9aZ=;\xc7P\xfb<\xf5\x9b\x97\xbcFI\x98\xbc\x16|g;j\xea\xa7\xbdZ\x9b\xa0\xba/\xd9\xc5\xbb\xd0Z\xe8<\xdf!\xd3\xbcH3M\xbc\xa8\xf2\xb5<\xe5\x87p<\x03l`<\xb0$\x1d\xbd\xa8\x8b\t=\x87+P\xbcH\xb55=m\xaa!=XR\x02=\xf9\x99\x01\xbd3\xbd\xf3;\x07\xc4\x83=f[9\x11$\xbd\xcd\xbd\x8c<\xca3\xa8=\x8dJM\xbd\xe7y\xf8;)\x87\x7f\xbbj7x\xbd\xf2\x97\x17=O\x94\xc3<\xe1\xee\x97\xbd\x8e\xa38\xba\x1c\xa8\x18\xbd\xba\x08~=\x1c\x0e\xa9=\xe3\xc2\t\xbc\xd3\xc1\xc8\xbc\x9a\xa9c=y\x8c\x1b<\x8eR\t\xbcv\xa3\x18=\xf4\x16#=\xce\xf5\x1e<\r\x91\x9d=\xc3\xc6r\xbc_\x13M<\x81\x96V=\x90\xe0\xf7\xbc\xcd\xe8\xc9\xbc\xe1Y*=|\"\x11=\xb6\xa6\xda:Ws\xce:\x99k\x91:\x0c\x17\x00=\xcc\x92&=\xca6\x96<\x87\xc7\xd1;\xa4j\x8f=\x8b\x0f\xc4;\x0ff*\xbd1/H<\xa41\xd2\xbc\xf0\x87\xb3;\x87\x0b\x17\xbc#\x85P\xbd\xba\xfe\x94=\x15\x93\xd0;*1f=M\xffK<\xb5\\\xca\xbbsV\xae;\xf8\xf9\xb3<<\xe0\xde\xbbA\xd6\x05\xf4\xbe<\r\xaf\x01\xba\xb8\xd7\tt\"\xbd\x8c~\xc8=m\x0c\x05\xbc\xd7\x00+=\x81\xfdS=\xdep\xe6<\xfc.\xb4\xbcv)2\xbd7\x15\xbc\xbb\x1a#\x8e<\x9eF#=YT\xa6<5\x02|\xbda\xc3w\xbc\x99\xc0\xb4\xbb2\xd5 =\xb0\xbe\xc6=\xe7%\x81\xbd`~\xca\xbc\x87\x90)\xbd2\x8f\x81=J4\x00<\x07\x9e%=ar\xb7<\xbb2\x07=\x94_b\xbd\xe6i\x04=q\xddB\xbc\xd9\x19\xca\xbc@z\x9f\xbcI?S\xbd\x8b\x9f`;\xab\xf0\xc8\xbc\xdes\x0f=\x13.\xd8\xbc\xd6K\x1c<\xafN\xe0\xbc\xfe\xc9\xab\xbc\xdd\x01\x12\xb9\xb4\xd5T<\x08\xff\x8f\xbc\x0865\xbc\xac\xd2\xc0;+\x7f\xcd;\xff94\xbd\xdc\xcb\r=\x89NX<9\x157\xbc\xb3\x01K\xbd\x8e\xa9\x97=f\xee\xa6<%\xe2\x0f=\xca\xf4\xff\xbch\x82Q\xbc\x8d?5\xbd9 \x18;\xd6\x99\xc5=\xe3q\x91\xbb\x01\x93\t=z\xf0\xd8<0p\x89\xbbl\x16\\\xbc\xd1h;\xbcid#=f1U;eK\x9d=_\xdf\xb7\xbc\x9b\xa3\xcb\xbc\xb6}\xc38]\xf0\x8d\xbd\xff\xee\x89\xba\xcb\xd3\x10\xbd\x91t\x0f\xbb\x95$\xfe\xbcD`\x90\xbc\x1d\xe4?\xbb\xc7$n=5\x07\x12\xbc.k\xad<\xd8\x0b\xdd\xbc\xbe\xf2\x12\xbdo\x0e\x15\xbdum,\xbc\xfd,$\xbdF+B\xbd\x9a\xde\xa0:\xee+\r=\xca\x8a\x96;[\xe7\x84\xbd\xee\xed]=\x16<1\xbc\xc4I1\xbdk\xa8\xa7\xbc\xd7\xcc\x19\xbdi\xeb\xed<\xcax\x00\xbd\x05\x82\xe8;y\xbd\x06\xbdkF\xe1;58&<\xfaL2=\xbb;\xc5\xbc\x04\xd3\xe4\xbc\x06\xb2>\xbdV\xf7;=\xe5\x00N\xbd6\x83\xe3;\xb8\xe1\x05\xbd\xfe\xec\xf7<\x07\xfcs\xbd\xd4\xd0T=_\xe19=\xbay\x17=\x9d\xa8\x83\xbcWp\xe6<\x99E\x8c\xbc\x83\xf1\xab\xbc=\xe5m=bZ\xcd=I/\xec;s\x80\x9c\xb9un\x0c=\xa1\xee\x1b=\xc8~\xe4\xbc\x99\x95\xcf\xbc\xe5\x8c\xc5<~\x1as\xbdA\xea\x8f<\x85\xb2\xc0\xbd\xaaG\x08\xbd\xcdq\xbd;\xf0\xab\xce\xbc\x82xD<\x0bk\x81=[\x99\xae\xbc*\x89\x04=U\xdd\xe2\xbd&\xbb\xe4<\x00\t\xe5\xbcP\xcf\xc1\xbd\\\xdb\x08\xbc\xf1Z\xde:\x0c\"\xf3<\x8ch\x0f=\x81\x9d>=\x97\x1bb\xbc\xd2\xfd\xc8<\xd6\x93\xb4<\x9ajf\xbd\xe9\x17\xae\xbc\xfd\tt\xbc\x15\xdf9=\x89\xc2=\xbd" +HSET bikes:10009 model 'Mars' brand 'BikeShind' price 4580 type 'Kids bikes' material 'alloy' weight 13.3 description 'The innovative braking system on this bike has been a game changer in the kids’ bike world. The Plush saddle softens over time with use. The included Seatpost, however, is easily adjustable and adds to this bike’s fantastic rating, as do the hydraulic disc brakes from Tektro. All in all it\'s an impressive package for the price, making it very competitive.' description_embeddings "\xc3h\xbf;\x94\xf04\xbcF|\x06\xbd\x94\x93\xc2\xbbb\xd7_\xbdo\x8a=\xbbQ\x15.\xbd\xe5\x8c$\xbd\xcau\x94=\xc9w\x16=\xd0>\x04\xbb\xab\xdc\xba\xbcE\xac\xd4<\xcc\x9f\xbd<\xab)M=6\xabA\xbd\x8cvg=\xf9\x9c\x86\xbdO\xfc\x1a\xbd\xe6g\x1d\xbd\xe0\xd4\"=\x8fX\x13\xbd\x91\xb4\x02=\xf6y\x99\xbck\xe6\xfa\xbc\x13\xd2P\xbc?l\xda<\x00y\x9e\xbbI\xc0\xd8\xbb\\ux=f\x92\xb6\xba\xaf\x15\xff\xbc\x9b\x1bI=\x82\xecR=F\x13@\xbd\xe1\xdb*\xbc4\xa1\x1f=\xed\x98G\xbc\x8eE5\xbd!\x8e\xb5;\xcd\xd0\xc3<\x1cxH\xbc\x82R\xd9;o\x9c\xc6:PG\xff\xbbCY>\xbd\x8c\"\xf4<\xb7B\xbc\xbc\x9d\x9f\xe6\xbc\x1dY\xf1\xbb-\x91\xd2;W\xcc\xa8\xbcn\x8f\xf6\xbc\x18\xd0\x1b<\xc8~\xa7\xbc\xb7\x00k\xbc\xe9\xbe9<*\xd9\xea\xbc\xd8\x1a\xb9\xbc\xc6\"\xcc=@\xfe\xd2\xbc\x88O\x07=\x000,;j\xa4\xe0<\x00\\\x84;5\xe6\x06\xbd\xfc\\\x95<\xd4i/\xbb\x86s\xce\xbc\xf7[\xc1\xba-[\xa4\xbb\x1a\x7f8\xbc%1\x94\xbd\xa4\xc2\xa9<*\xca\x8a\xbc\x95\"\xa0\xbc\"E*\xbd\xd7(!\xbd\x95$y=\xba\xb1\xeb<\x0e\xee\x0f\xbd\xa1\xaa\xf0\xbc\x008\x1a\xbd\xd9\x03l9<\x8e\x0f\xbd\xf1\x1e\xab\xbc\xf4U\xea<\x9f\xf8C\xbcB\xc4pe9\xb5<\xb7\xf4\xe7<\xfe\x85\x8e\xbc\x01\"\x80\xbc\xba\xad)<\xcb\xc42=!\x08\xa1<\x1d\x0e\n\xbc\x0c\x08\x88\xbd\x02\x1c\xda\xbc\x8d\x1a\xfd::\xd8I<\xfbu\x10=\xab\xf5C:\x8a(P=\xcd\t\xb0\xbb\x9ad\x82\xbd\x15p\x07=t\xad\x15=\xdc$\xc1\xba\xb8\x0fB<\x12\x95#\xbb*\xbb.\xbd\xd1\x1b\x08\xbd\x82\xcb\xc6\xbc\xf7|\xbb\xbc\x9ep\x01=#\xf7\xd3\xbc\x1c\xea\xa9<\x9f\xcb\x15=\xd4\xb1\t\xbd\x83\x1f\x80\xbc\x1c\x8c\xb9<:\xd4\xd3\xbc\xf9\xea0\xbd\xbd\xb4\x98\xbd\xae}X\xbakd\x81=\x7fE\xb7\xbcV\x87\xe3<\x85f\xbf\xbc\xf1}\x0e=\xe2\xcc\xb7\xba\xc8Z\x8c\xbc\xef\xe6\x08;\x8eR\xa2<\x7f\xa1*\xbb\x80Z\xe9<\x1a\xfau\xbc\x16\x9b\x80\xbct\x01\xc5\xbc]5\x07<\x0c}\xe2<\\5s\xbd\xb0\x9f\\=\xa3\x0bh\xbb\xb0\xcd\x12\xbb\xa3\xba/\xbd\t\xb4\xbe;\x06\x86\x16=BQU;\xc67\xb7<=j\x10=mFv\xbc\nR\x83\xbc\x95hb=\x9939\xbb\xc9\x87%\xbd\x8d\xd2\x17\xbbl\xd4\x05=\xbb^\xff\xbcE\x83P=0\xfe[\xbc\x05l\x03<\xc4\x86\xb6\xbc\x86\x17\xf8;\xab\xfc\x02\xbc\xa9\x83,=\xa2\x03\n=\xf1_\x84\xbd\t\xfb\x8d<\xb3]\xc0<\x86hJ=\x99M\xa3:\xbd~\x1a=\xf2\x9f&=q+\x8e\xbd\x9bY\x98=\xc8\xe2\x86<\x1aq\x84=p6\xeb\xb9\x1a\xc3\x8b<\x19\xbd\x82;\xc6\xd4\xaf<\x93\xfb\xd1\xba\xc9_g\xbdL\x99\xa4\xbdH$\xb2\xbaog\x8d\xbc\xd6\xa0\x19=\x00_S\xbdE\xfe\xe7\xbc\xda;`=\x9c\xf8\x03\xbd\xfe\xd7\xed\xbc\\.E=\x9a\x1fi=rd\xab<\x7f\x07b<\x0bH\xbc<\xa3`\xda\xbd\xb7\xccy\xbc\x8e\xd8\x82;y\x8d9\xbd\x99\x95w;\xb5\xf0\xd2\xbcaz\x85\xbd\xb6`\xd0<\xca0\r\xbcA\xc6\xa3;\xf2\xfbP=FSR\xbc?\xb2\"=*\xd2\xda;dK\xf5\xbc\xd1\xd17\xbb\xd7\x98]=Xc\xfc<\xe4\x1b9;\\\x99\xef\xbc\x02vo\xbc\xf4(\"=\x02\x98\xa5\"\x84\xbcK(I;\xdb3\x81=\x86\xb1\xdc\xbc\xe5l\\<\xdf\xe1\x05\xbd*\x8d\x91;gx\x08<\xc1\xdc\xe0;w-\xf4\xbc\x85,\xcc\xc1=\xe1\xb7\xd8\x08\xbd\xd5\x97\x08\xbd4\xbc|=?\xc4\xbd;\xfcCa= m*\xbd\x8b\x98\x80\xbc\x01\xcfb\xbb\"/\xa9\xbc\xe5\xbeV<@G1=\r\xd9\xb8\xbc\xa2M\xbe<\xa5\x8c7<\xff\"\xea<\xa2\x1d\xd3\xbbO\xa4\xb7<\x04\x18d\xbd\xae\xcc\x06=\xf9I\xfe;\xaas\x17\xbd&e\x1a\xbd\x9d6Z=RB\x94=\xb6\x12\xbb\xa8g=7\x8b&=\xb7\xb1\xdd\xbd),\xf1\xbbm\x94.\xbc\x95\xe0\xb1<\xa8R\x0c\xbd\x8f)\xf7\xbcHc\xbf\xbc\xea\xd7\xae;\xc8*\xf6<\xc9\xe3n=\xbe\xe1\xca:\x13y\x82=\"\xdc\t\xbd\x9dep\xbd.\xb2\xca<\x01^a\xbc\xfa\xf6\x12=5\xf4A=1\x1d\x82<$Y\x86\xbd5\xf9\xed\xcd\xa7\xbcy\x9b@\xbc\xc0\xfd9=\x830\x92<\x02\xa2\xf8\xbb\xb2\x8f\x01\xbd\x16\xa6T\xbd\x83\xe8~\xbcY[\xbd=\xa7\x9an\xbc\"\xf8\x92\xbc\xe2Mg=S\x9ar\xbc\xeeL/\xbc\xe1\x11`=g\x99\x97:\x1c\x14-\xbc\xd7\x88\xf9\xbbi\xa6\xdc;\xcb{\x02\xbd\xd08\x97\xbc^cA\xbb\xde\xc1\x9b\xbb\xc3\x88\"\xbdIp\xa8\xba=\xec\xdc\xbd\xb2\xcc\x9a\xbdl\xc0 =J\"M<5C\xac;\xe6\xbeg\xbdLJ\x9f\xbc\xbaA\x13=\xf1\x16\xe4\xbap\x92[<\xdd\xbf\xc6\x00\xa7\xb8\xbb\x7fP\xf0<\xb7[\x08<\xb8\xeb\xc4<\x0c\x19\x06\xbd\x9c\xcd\x99=8\x1e\x08=b\xe8\x9d\xbc\x80\xad\x95\xbc8\x1eD\xbd\xda\x9c7\xbd\x02\xa8 \xbc\xf9\xd8x<\xab\x8f&=\xa0h\x10< c\xc1<\xefQ\xfb<\xa4\x990\xbdm\xd4#=\x03]\xda\xbc\x15\xef\xbc\xbbi\xf5\x9c\xbd=\xd8\x17\xbd/\x15b\xbd\xf4\xbc\xf6;\x01\x8d\xe4<\x0f\xe2\xfd\xbb\x04\xf8N\xbd\xdc\xde\xba<\xc5\xbb\xf4<\"\xf14;\xc1\xb5\xe8\xbcv\xaa\"=\x1fJ\x03\xba[\xa0\xdc\xbc\x10\xfa\x8d\xbd\x98\xae\xc9<\x9b#\x91<\xec0\x91<\xab[L\xbd\xddS\xa8<\x8d\xeao<\xebk\x81\xbd0\xd2A\xbd\x82M\x18=\x84L\\=5\x8fz\xbc\xac\x015=\xd6\xd6\xe1\xbc\xc1Q\x9f\xbcm\x93H\xbd\xb5\x03\"\xbc\xbe&\x83\xbc\xc6?\xb9\xbc\xa0\xb4\xad=\xa4\x0bt:\x03\x1b\x8c\xb7e\xd03\xbd\x80\x03[=N\xd61=p\x85\xa9\xbc\xd2~\x8d=\x11\x9aP\xbds\x99\x80=j\xd0H\xbc\xe0\xde\xff\xbcQ\xc3\xd5<\xd9r\xad=\xea\xbcm<\xc4\x00\xea\xbc1[U\xbd\x11\xd6P\xbd\x98\x85\xd3\xbct\xdc\xd3=\xbb@G\xbd-4\x06\xbd8\xcaa=\x1dF[<\xb7d\x87;\r\xa0\x89;\x7f?\x04\xbd/\xc5\x0f=\x85^\x1e\xbdv\x0b\x1f\xbc\x80\xd4\xc1\xbc\xb3\xa9\x0e=R@\x05;^\xbd3=\xffh\x8b\xbc\x12}\xa4;6\x86a=\xc8\xa6/\xbc\r\x03R\xbb\x90\xc2\xcd\xbc\x0c\x14\xcf\xbbWM\x8c\xbcV7\x8d:\ng\xb2\xbb\xddH\xc2\xbcu\x0e\xea9K`4=r\x9dp\xbd\xe6\xacY\xbck\xba<=\xbd\x88\x1b\xbc\xf1]=<*5\xff\xbb\x8c\x88\x97\xbc$\xfb\x16=p\xcb\xa4<\xec:\xa9\xbd\xbe5\x1a=X\x91;\xbc\x7f\xb1\xd9\xbc\xf62,=\x8dp\xa8<\xea\x06\xab\xbc\x91\xc5\x93=*5\x81<\x01\xb8i\t\xca\xb9\x11\xbbq\xd2\x92\xbc^N\xd6\xbbVxN\x92\xbd\x98\xa2f<\x8a\x08\xb9;\xbfP\x1d\xbc\xc6\xea/\xbc\xe18w\xbc\xd7\xe2\x0f;S\xb7a:g\xc56\xbd\x95uT\xbd\xde\x18q\xbc\xca\x8c\xe0\xbc\x9a\xb0\x92\xbc\x88[r\xbb\xe9\xc0\xbd\xba\xf7\\M\xbd\xdb`s\xbdC\xb5\x15\xbd\xb5\nK=\xd9\x12\xef<\xe2.\x88<\xf6\xed\xa6\xbb6l\x9e;6\xd0\x01=O\"#\xbci\x1a3=\xd3:\x15=\x06\x8c\x1b=\xdf\xc9d\xbc\x96\xe7>:JD_\xbd\xb4XN\xbd\xcc\xb7\x84\xbdO5i<\xcb\xfb\x9f=\xd9\xc3\x96\xbb\x0ez\xc8<=?5;~\x07\xbc:\xb8\xc1*=\x9bo\xbc<\x04$\xea<\xca\r\x8a:\xe0<\x8e=j\x85Y\xbd\xd25\x01=J\n\xb3<\x07\xd0i<\xab-\xa3;\xe3\x92\x85=&\xa9$\xbdA\xca\xa2;6\xeb\xe2<\xf9+p=\xf3\x0c\x92<\xf4\xba)\xbbw+\xf8\xbc\xd3\xf7\xeb\xbc\xf6Lr<5\xba\x82:32\x07=g\r\xf9,\xbd&\xccW=\xc3\"6\xbc}\t\x8f;\xe3\xb7\xe3\xbbRc\x0f\xbdQ%\x95\xbd\xc3%\x87\xbc\x1dK\x83=~\xae\x0f\xbcZ|E=\x1a\"\xa9\xbb\xd4\xda\xa1<\t\xc3\x10\xbd\xb5\x9b\xd0\xbc o5=X@\xce: \x9f\xce<\x1e\x9al<;\xa3B\xbc\x80N\xce<\x90\xcf\x92\xbd\xaa\x19\x02=\x8f\x90,\xbc\xdb\xaeD;\xd1ot\xbc\x15\xd6g\xba2\xb8:\xbc\x9aZ\x83=~\x91\x18\xbdL5n<\xb3\xf6\x91\xb1\xbbw$\x0f\xbd\xe1U\x07\xbc2L\xba<\xed\xa9\x85\xbb\xc7\xb5\xfe\xbcK(\xdf8]\xca\xe3\xbc\xe2d9\xbdX<[\xbd\xa2\x90J\xbd\x82\xa0\xd8<3\xb9K\xbd~\xaeV<\xdb\xda\x0e\xbd\x19\xbe\x07=\xc9\xc1\xbd\xbb\xe7\xed|;&\x06\x07\xbdx\xe5\xf2\xbc\xd4\xbc5\xbdZe =&\x19\x12\xbd\x0c\x02\xd4\xbc_\x9d\x07\xbd\x11\x12\x93<\xea\xf0S\xbcI(;=5\x16\x85=m\x8b\x81=\x17\xbf\xb0\xba\xe9\xee\x10=\x15\xff\xe6\xbc\xea\xb3\\:\xd9}s=d\xa6\x00\xbbZL\xae\xbbo-\xeb\xbc2*+\xbc\x1d\xe6&\xbd\xb3\x90\xad\xbc\xef\xe4\x1d<\"Z\xe1\xbc\x0005=IT\x05\xbcr\xb3\x02\xbc;\xa2(=\x7f\xb1\x00=4\x8a\x8f:9g}\xbd\x17B\xc0:,\x87\x02\xbd\xb4\xa1\xcf<\xdbW\xa1\xbd\x11\x8e\x08\xbc#\x07\xdc\xbb\xe9d1\xbd\xb6~\xef\xbb\xc1S\xe5<\xe4$\xe0\xbch\xd7k<\xaf7\x00\xbe\xecb,=\x00\x84p\xbd`wb\xbd\xac\xe9\xcd9E\xd5\x08=W\xf6Q<\xd7Q*=\x00\xfbx=#\xcc|\xe0\xf3<\xbf9\xb3<\x1a\xc2)<\xdd\xa4\xa5;\t\xba\xe9\xbc\xf6|\xaf\xbc\xed\xb5\xe6\xbb\xca\xe6\x99\xba\x0c\xe4\xa5<\xbc\xb3*\xbd\xdc\\M\xbdP\x89*\xbd\xdf+c\xbc\xbbz;\xbb\xef\x8a\xe0w\xbd\xcd1C\xbc\xe1e\xea\xbc\x05\x87\x8d;\xd00];l\xe5\x8a\xbc}\n\xbf<\xed\x04\xc2\xbd\xce5\xaf;\x94\xf9\x0e=J\xfb\xde\xbc\xa9\x87\x13\xbb\xd4\xd8\xe0<\x90\x8d\xb9<\xcd\"\xd1\xbcL\x1e\x88=/*\xa4\xbd\xcdg\xe7<\x84>\x90<\xc6\x7f\x1c:\xf7\x11\x17=nQ\x8b<\x81\xa1C\xbc\xc1\x0f+\xbd\xac\x1e\x11<\xe6\x1a\x88<\x98\xea\x9a<)\xf1\xe0\xbc%\x92\x8c<\x94c\xb2;\xca\rI=e\xc5H\xbb\x18\xae.=\xc87\x00=!\xfbT<\xe1|~\xd8\x9c<\xdb\xbc\x1b\xbd\\e\x82\xbd0\xe8\xff\xbc\x98[\x00\xbd\xbd]R\xbdR\xbd\xf0\xbc\x97|I\xbd.\x95\xdd\xba\x97*\xb0\xbdE=7\xbcmM\xb6_=/D\x1d\xbal#`\xbd\x15\x80\x13=\xfey\x16=\xd9\x92;8\xba]U\xbb\xd9\xad\xf2:\xcf\x15\xa2>\xbc\xca\xf2z\xab\xbd]\x8c\x13\xbdl\xf7d=_\x8aw\xbd\x01\xc7D\xbc\x95b+=\x80\xc5\xa1=p\x95\xf4\xbbo\x8d\x11;\x97EK\xbc,\tU<\x10\xe7\r\xbd\x88\xcd\xd9<4\xc9\xb0<\xb6n\x08=/\xd4\xf2\xbb\xbc\xc1\x89=B2\xb8<\xcf\xd2o\xbd\x1f\xf9)<\xc4J\x89=S\xb7\x8f\xbd\x1cL\"gX\xbcdh\xa4<-\xc7\xff<\xec\xe2\x84\xbc\x85\xa7\x85\xbd\xb9O\xfc;\x9bN\x81=\x02\x05\xf7\xbcJ)\t=\xe1\xfe\xbc\xbd\x9dm\xcb\xbb(\xb6=\xbd\xe5\xd0\x86\xbaV\x97\x1c<\xad\xdd\x10\xbdb\x89e=h6\\\xbd1\x8d\x12\\\xa7;axH\xbdm\xba\xf9;oo\x1c\xbd\xc6\t\x1d\xbd(\xb1\x11=\xd2V<\xbd)\xc2\x1f\xbd\xe9\x02\xa8<\x11\x89\xa9=f\xb4\xd4\xbcK\x1c\x18\xc3\x1a\xbc\xf3@\x9a=I\xa7\xac\xbd\x84_\x15<:\x95\x8b\xbc/\xd4@=\xa3\x11\xa6<\xee1\xab\xba\x95G\xc3\xbc\xb9\x94\x96=b*\xc4\x01=\x86\xce3\xbd\xbaK\n\xbd\xbf&\xfa\xbcG\xaf\xcf\xbc(V\x15=#O\xcb<\x80\xfdG<\x1f\x00W=<$\x1c\xbc\x12\xbd\x94<\xc1\xb2\x02\xbd\xddt\xb1\xb9n\x9d\x9a\xbc\xbf\xc3\xd1=\xbdx\xb9\x93=\xba\x0e\xb79J{\xb0=\x0fB\xb1<\x8bj\x9e<\x96\x10@\xbd\xdb\xc1T\xbc#\xdc\x1b:\x0b\xa6c\xbc\xcf\xef$\xbd\xc8O\x18\xbd\x13j\xad\xbc\xbcU\xc3;]UN\r=\xf73\xaf<\xcc\xf2\xeb=\x14Z\x8c;Jzy=\xab\xd4\x87\xbd8\xc01=\x8c\x10\xa6\xbd\xc4\xeb+\xbc\xc9<\x00\xbd)\x93\xa2<]\x12!\xbd-\xce\x16\xbd\xc9k\x16<\xf8\xffl={\x8c\n\xbd\xfd@\x8f\xbd\x00\xca\xe0=\xce\xe0\xa7\xbcwu\x13\xbd#\xe3\xc6<\x15\"\xef<\x8b\x1a\x8f\xbc\xab=6<\x08\xc8E<\xb2\xc2\x0c\xbc\x1f\xfa5\xbd\xbc3[\xbbjM\xce=\xe6\x9a\x92\xbc\x97]\x8a=\xf2\x88\xbe;S\x92\xc3\xbb\x92d\x96\xbc\xaa\xc3\x01>-G\xa2\xbcd\xfa\r\xbdh\x8d5\xd6\xd9\xbb?\xd47=\t%\xa6:C&\x86\xbc\x88\x1d\xf2\xbc\x0f\x00f\xbd\xc8\xf7\x01\xbcR\xbc\x0c=3\xa3\xe2<\xf8r\x16\xbd\x97\x9a\xfc\xbbM\x0e\xf0\xbd4\xc3d\xbdX$\xb3\xbcrC\xed\xbc|\xea\tw<\x7f\xbaw\xb7~;&\xd2%\xbd\xc7Mr;-\xe9c=\xfeP\xb8<6\x1b\xc7<\xc8\xd6\xa3=\x0e&\x1a\xfc<\xf3\xd72\xbdp/\xa1;\x87\xe3\xe6\xbdY4\x01\xbe\xd7\xc6\xfa\xbaq\xb8I=\xa6Z\xa6<\x9b\x96\r=\x0b\tY;\xdb\xadP=/K\xaf\xbc\x8d\xce><\x0f\x83^\xbdt\x13\xc4\xbc\xc7\xc8\x85=EE\xd5<\x80\x94\x9d\xba\x17F0\xbd6wj=.\xbeo=\xf0\x860\xbdoq6\xbd\xdfC\xf9;\xef\xd3\x11=\xae\x13$\xbd\x9d\xa7q\xbdi\x82H\xbd\xb9(W<\x14\xfeI\xbda\xd3\xcf<\x13\x0eX<\xfc\xe77\xbc\x8d\x03U=\x8f\xbe\xe4<\x88\xc7p\xbd\xe3\x82\xac=\xab\xdc\xd9=W:\x89<\xf0\x16\x12\xbc{\xf2\x9b\xbc\xb7\xcc\xb9\xbd\x16\xbb\xa1h\x1c\xbbD\xa6\x8c\xbd\x16\xbd\xa7\xbc\xc3\xd0F=\x82\xa5\x1c=\xd4\x1a+=\xb6}\x80\xbc\xbck\n<\x02\x84n<\x86\xbe}<\xad\x85\xbdZe\xa9<\x08!\x80\xbbOBn<\xc3\xd3jZ: ;\x0f\xbc\xc5\xfc1\xbcM\xf1\x9c\xbc\xd3&b;\x90\xeb(=\x14ET<\x9e0~\xbc\x0e\xf9\x0e\xbd$\xc6b\xbd\x1a\x97\r\xba\xaa\xa7\xe3=\xf1OJ\xbc\xe5\x9c\x9d\xbc\x1d\xb7\xa5\xbb\xe1\xb4\xcd\xbc\x9a\xe4\x0b\xbd\xfe\xf7\xff\xbc\\c\x9e;D7\xf3=\xff\xac\x85<\xf8\xd3\xb6\xbc\x85A\xc8<\xbe{\xb4;\x1f\xf1\x9f\xbc\xbb\xce\x90<^&\xe9;\xd5\x9d`=\x8e(!\xbdr\x1e\x02=B\x17\x97\xbdW1i:\xee\x9d\xa7\xeb\x03=\xec\xc8\xf3\xbc\xbf\x911<\xb5\xb3n\xbc;i)=D\xf54=\xb0\x9cb\xbc\xd8\"\xa0};\xbd\xc0v:\xa4\xa6\x92\xbc,K\x19\xbd\x8bW\x90;%\t\xa1\xbd\xb0\xea>\xbb\x1e\x8d\x10\xbc\xdaG\x06\xbdj\x82\x91\xbb\"nI\xbdT\xf2a\xbc\xd2V\xc0<._\x1a\xbd<\xa1p=s\xc2\xf3\xbc\xf0~B\xbd\x8e\xa4\x99\xbd<\xe1\xf4\xbc;\x13\x88=o\xb2`\xbca\xd4F=6\x9cv;Zq\x83=\xd4\xef\xb6\xbb^\x7f\xc2\xbc\xe6E\xee\xbc\xd1\x88\xc1:-\xe7\xe4<\xb6\x81\r\xbc\xba\xe2\x08=\xff\xc9\x17=\xce\xc7\x98\xbd\xfaC\x1e<\xbaS\xdc;\x99\xb5\xbe\xbc\x1e\x13\x9b<3H\xe2;\xfeA\xb6<:(P\xbd\x7f:\xf6;t\x13\xa8<\xc3\x87,\xbb\xf0\xe8<\xbd&\xa9\xad<\xac5;<\x92\xf9\xf5<|\x84&\xbdq\xdc0\xbc\xbbq\x14;\"ZJ\xbbU\x08\x16\xbc\xabY\x96<\x08f@\xbd\xd1\x99N<\x86\x1c\x94\xbd\x17J\x11\xbcDc)\xbb\xd3\xd7\xc3=n\xb2\x0b\xbcU\r[=\x1e\xa2\x03;X\xf6\n\xbc\xa5fE<\xc0\x8eM<\xac;\xfb<\xac\xf3\xc1:\x0f\xac\xaa\xba\x9a\x9e\x85\xbb\x07=\xd5\xbc\xcb\xe0V;HsK\xbct*M<\xec\x9f%=U\xa1\x1e\xbc\xdfhh\xbd\xa3s\xdc;\"\xff\x14=\xe3T\x9a\xbc\"\x98\x95\xbc\xf1!\xd7\xbb\xe3\x1e\xf9\xbc\x18u\xc8\xbb\xcaL\x9c\xbd\xb2\xf7\x9d\xbc\xb1<\x8d=n#j=\x1d\xb7M=\x07]\"\xbc\xb3}I<\xb1\x9cs\xbb6\xd4\x98<0*T\xbcz1\x84\xbc\xf6\xb3\x85<\xea\xd1[;\xaa\x80c=\xce\xa4\x90\xbc\x11X\x17\xbd\xfd*\x8e=\x00\xfcj\xbdx)\x99\xbd\xee\xeb)\xbd\xaa\x9eY\xbd\x96\xf4\xcd<\xd3\x02\x82\xbb\xdbV\x92<\xb6\xff\xc7\xbcB\x00\x13\xbd\xc6>\x13=\x91\x14\x8f=\x1f\x8d\x1b\xbcC\x87x=@\xda\x03\xbc\xbc\x8b{\xbc\xf0\xc7M=C\x1f\xc1<\n}b\xbc1\x1d\xfe\xbb\xd06\x84=\xef\x93M\xbc\xd5\xa3g<\x86\xd9\x9f;\x95\x99\xaa;&[$\xbb\xd1\x11\x10=6\x9e5\xbd\xf6&\x19\xbd-\xac/=A\x05J\xbc!H\xbe<\xc7\xca\xfe:8fj\xbc\r\xb1\"=SzZ\xbc\xe6\x9b\xdd\xbdB\x82\xf9\xbc/\xa1\xb7\xbab\xf8{\xbc\x03z&=\xe0}/\xbd\xdao\x8a\xbc\x0c\xe9\x05=\x93>]< F^\t\xebm\x90<\x80\x11\xe8\xbb[\x95\xe9<\x7f\xd5\x1d\xb7\xe1\tS\xbc\xad\x9b$=\x96\xb7\x86\xbc\xa3K\xb7\xbc\x95N>=\xeb\xbbo\xbc[\xca\xca\xbb\xb56C\xe5:\xf74A\xbb\xd8\xed\xbc\xbc\x8a\x12n<\xa3\xb4\x1f\xbd\x186l\xbb\x0c\x1bU\x026\xbc\xa5\x82^\xbd.\xd0\x8b=\xf4d\xb9\xbc\xd3E\x88\xbb.\xc2V\xbd\x04z)\xbd\x8a\x1b\xfc\xbc.<\x92;\xce1/\xbc\xd0\xbc\x10=\xb6\xd2\"\xbd\xce\xff\xeb<\xbc+\x03=\x8c\x95*;m9\xb2\xbbmi\x92<&\xd6\x99\xbc\x15:\xf2\xbc\x7f\xe6\xa4\xbc\x998L=\xa7~\x07=8\x85)\xbb\r\xf6\t\xbdc\xfa\xc6\xbc8\x82\xac\xbc\xf2R\xaf\xbdy\xf1h\xbc\x83H\x84<\x85\x15\x82\xbc\x16n\x13=\x04n\x01=\xa5zZ<\xe2.f\xbc\x0c\x9b\x83=Q\xca\xfd;S\x9f\x9e\xbcQ\x8c\xc6\xbb\x16\x0f\xc1\xbc\xd3ao\xbd\n\x80\x89=u\x1f\x9e<\x94Md<\xf9e\x99\xbc\x0e&v0\xbdMzC<\xceyB=\xc1\xb0\x90\xbb\xeb\xde\x01=+\xecQ\xbc8\x95\xe2;\xda6c\xbd8\xe4\x05\xbcB\xfa\xeeXV\xbd\x7f\xa2[=\xe7\x14\xcd\xbc^\xe5V\xbc3\x94\xec\xbc\xde^^=:%\xe2;R\xc8\x8a\xbc\r\xab5\xbd\xd9\xfbj\xbc^\xdb\xcb\xbcS\xd5\xcb=\x0fK/\xbd\x85m\x0e\xbd\xdf\x1e\x93<\x1bU3;C\xfb4=\xa4G\x93<\x1bs\x8f\xbd-\x15\x91:P\xd2\xdc\xbc\x17e\xae\xbc\xacCB\xbd2e}\xbb6\xe7\x05=,5\xac\xbd\xbe\x9e\x0c\xbdR\x88\x17=e\x9d\xf6\xbc0\"*<;\x14\xb1<\xaf@\xee<\x80\\\x1d\xbd\xdf2\xfb<9\x8f\xce\xbdI\xd4\x82\xb9\xee\x8c\xb3<\x1b\x8c\xd5\xbc\x04\r\xa8=\xcf\xae\x03\xbcv1*\xbd\x98\xd8\xee\xbam\xbeI=\x08\x07\x0c=,\x8f4=i\x11\x88\xbd\xaf\x1d\x03=\x8a!\xa7;\x07\xe8D=\xc11\x13\xbds9w<\xde\xdb1=\x93\xd9\xf7;6\xe0\xdc<\xf4+H=\xee|\xf5<\xdb]\xdc\xbcd\xd2\xf6\xbc\xccX\x1e\xbd?r\xe0\xbc\xec\x06#<#\x820\xbd\x9bB\xb3<\xc5\x8b\x87=?\xbf*=&\xae\x97\xe9\xb5\xb9\xa6~\x88\xbd\xb6\x7fT\xbc\xf8\xec\x8ay\x91\xbc1T\xea\xbc6x\xda\xbc\xb9\xfdD=\xb8E\x8a\xbbao\xdd6\xd8\xbc\xc0i\x19\xbb\x02u\x83=&b_\xbc}\xc7\x91=\xf0\xcb\xd8\xbb\xf9\x19\xb2<|\xd0~\xbd\x0b\x07\x86\xbc\n}\xe2\xbcNE\xe3<\x14j\xb7:jA\xbf:\xc2}\xd8\xbcv\x93A\xbd\x19t\x15\xbc\xc1\x88\xbcM\xbbieC\xbc\x12\xea\xea\xbc^\x91\x04\xbd\xd1qs|\x12=\x8c\x80\x01\xbc\r\x92E\xbc\x93\x0e6\xbd\xc7pz:r\xaaL\xbd_\xf7S\xba\x9d\x00\xd3\xbb\x97\x06\xb7\xbc2\x83\"\xbd\x1d\xe1\xae\xbc\xb4\xc7\xcc\xbc7\x98\x9f\xbdu~\x8a\xbdrX\xfe\xbc\xf0!\xa1;\xad\x02\xad\xbc\xa9\xae\xa7\xbc\x1d>?\xbd\x1d\xc1\xf9<\x19I\x81\xbc\x86m\xd2<\xe1#L:x\xa4U\xbd\x92\xe8\x86\xbc\xbc\xc0\xf4\xbc\xa5\x16\xd3\xbc\xbcP\x1e\xbd\\%\xb1:\x02p\x12\xbc\x06y\xeb\xbc\x84\xa5\x0b\xba9\x13$\xbd-\xd5?\xbd\x0b=\xa6=/`\xfc\xbc\xa9m\x06=3&!\xbb\xad\x07\x18=\xefm\xbb<\x95l\xa5\xbcx\xa7\xaf<\x15\x91\xbe<\xcb\xea\xb5<\xcd\xe9)\xbd\xdd\xce\x88\xbc\xba\x1d\x05\xbd\xd7\xe0-\xbc\xeb\x9b\x94\xbdV\x01\xa8\xbczv@\xbd\xd3c)\xbb\xe4\x11_\xbd\x9e\xfcq\xbd\x03\xcc\x03=#@\x0e<\x99\xdcB\xbd\x808\xd9\xbc\x81m\xb6\xbc\x85z\xab;\xc9\xc4\x19\xbdi:\xd4\xbb\x07\xcd\x06=%\xfc9<\x92\xa9\xe6<+=\xdc\xbb\xd8\xfa\xcb;kW\x1c\xbc\xde\xc6\xe2:\x06\x9a\x13=\x1dh\xef<\xde\x86l\xbd\xe9\xf9\xae\xbc\xc3\xae\xdd<\x87%\x9d\xbd\x00h\x10\xbd\xc4\xeb\xae\xfc\xeb\xbc\xf4\xe58\xbdZ\x8d>\xbd\x0b\x9b\xaf\xba\x89\xb0\xa7<\xd6\xea\n=\xdb\xec\x8f\xbb\x13<\x0c=)\xf9\xaf;\xa5\xcbJ\xbdtE\xeb;\xf6\x14\xdd<\xc7\x9d\xa2\xbcC#L<1\x9e*\xbd\x1a\xb9[\xbd\x92n#\xbd\xed`\x98\xbctSZ\xbd?\xf4\x11=\xe7\xe6g\xbdTX\xb7\xbb\xe7\xb5X=Vq \xbd\xdcI\xee\xbc\xa2\xe0\x13=\x19\xe4#\xbc@\xb6B\xbd\xef\xad\x8d\xbdN\x8c\xf5\xbb\xb2\\S=/s\x01\xbd8\xa2%=\xef\x90\x05\xbc\x11\xfd\xb6<\x87\x9aH\xbc\x03VZ\xbcpz\x8f\xbc\xcbm.=`\x8f1\xbb\xcex&;\xf6\xf6\x0e\xba?\xe8\x88\xbc\xca\xe7\r\xbdw\x9c\xf2;\x01M\x1f=\x10\x86\"\xbd\r\x90\x1a=G\xdd\xe5\xbcM\xd8v\xbbJ\xa6F\xbd\xa7+\x16;\x9e`\xe2<\xb6|\xd8\xbc\xed\xad\xde<4\xd6%=P\x94\x92;x\xdd\x97\xbc\x0fa\x86=%\x89\xe6\xbb\x9c\xd4/\xbd$\x16\xb5\xbc\xae\xb2\x11=\x0f\x9a\r\xbd\x96Pa=L\xad\xaa;\xa8\x94g\xbc\x10\x87\xb3\xbc\xf1\xb1\x90\xb8b\x7f#\xbb\x06\xcap<\xe5\x0b\x04=\xbc\xcf=\xbc\x97F.=\x07\x1b&=il\x1e=\x9bi\xa0\xbb\xaea\xdb<\xae_\x14=!\xddB\xbd\x8c\xe8\x85=N5\x8a=`\xc0\x98\xbb\xbe\xbff<\x16n\x8c<\xcb\xac*=\x9dO\xbc;\x18\xf67\xbd\xdaz\x18\xbd\xd0z%\xbc!\xe4\xc7\xbc\xc43\xcd;\x91@\xb6\xbd\xa8$\x8c\xbd3\xbb\xbd=BR\xb9<\x94\xcd\x11\xbd\x83I;\xbdhA\x05\xbdgm\x8d<\xbf\xfec;\x90yk=+\x1b(\xbd\xf0\xabG<\x9fL\x08\xbd\x82\xad\x99=@\x07\xd1;\x89\x11@<\xacYl\xbd+>\xbc\xbc\xb3\x07p\xbc\x15w\x15=>\x1e\xd3\xbc\x9a\xe3\xf3\xbc\x8a\x9bi<\x99{-=v\x9e\x00<>\x14\x11\xbd\x9b\xf9*<\xae\x86\x92=uSV\xbcg\xc0\xf9\xbbYE\"=\xab\xd3\x1b=\xd7r\xa8;x\xe2U=_\xe8\xd7;=\xfe\xf2<\x14\xce\xd7\xbc\xfe\xff\xa2=\xc9\"\xdc\xbc\xd4\xc00\xbdF\x80h\xbc\xea\xda\x10\xbdjX0\xbc\xad}K\xbd\xd5\x12\xed\xbds\x1e\xda<\x81\xc2\\=\xe5\x9d:=8>*=\x9fT\xa0;\xfdK\x9a\xbc\xbc\xd2\x95\xbce\x97\x97<0\x15\x8b\xbd[\xdd\xe8\xbcm\xec\xf0<\x0b\xc6V=uH\x80\xa3\xdd\xc2\xbb?B.<&a\"\xbc\xd0\xb94\xbd%\xc0\x04\xbc\xdcz\x89=\x8d\x8d\x9f\xbd\x8a\x12\x8b\xbb\xfa-j\xbd\xab\xf3Y=\xbcW\x17\xbc9,C=i|\x07\xbd\xa14\xdf\xbb\xfb\xacT;\x8e\x9dJ\xbcnQ\x14q\x9f\xbcy \xc8\xae\xbb\r\xc8k<:\xb1\xc0<\x9d\x0c\x14=\xa8x\x19\xbd\xb9\x99\xeb\xbbEy\xd0\xec\xa3<\x8d\xf5\x07\xbdw\xc4+<\x86\xb5\x9c=\x19\xe1\x01=\"^\xe1\xbc.\\#<$~\x7f\xbcX\x9b#\xbdv\xe1\x14=\xd4\x97\xd3:\xda\xb3\x97;\x00%\x99<9h\xfa\xbc\xaf\x94\x91<\xa8\xac\x81\xbb=\x06S=\rm\x90\xa2<\x85,\x9b<\x99\xda\xd6\xbc\x02\xebt\xbd\xde\xa5\xad\xbd\xd7\x91#=\xbc~\xfa\xbb\xffKM\xbc\x98\xc9\x1b\xbck\xcbv\xbcJ\x1b\x87\xbc\xce\x7f?=\xeb\x1c\xd0\xbb\xc0\x8d\x19\xbd\x03\xf0\xda<\xe9\x9dV\xbd\x14\x0f\xad\xbcj\x89|\xbc\x04\x13@\xbd\n\x9f\x11\xbd\xd6\xb6\xb6\xbcV\xbf\xce\xbcZ=\xed\xbc\x07k\xc1\xbcbp\x83\xbc\xbe\xc9\x93\xbc\x14\x1c\x9d\xbd\x1e\xe0\xeb;\xfc\x93R\xbd\xac#\xb5<\x8bJD;\xb6<\xbf\xbc\xc6Z=\xbd\x85\xeb^;v\x04_\xbcRY!=!\rT\xbb\xabMM;r &\xbcd\xdaj<\xb5i\xa6\xbcYc\xa3\xbd\xd8\xc4\xb9\xbd\x02Z%\xbd\xf2\xcf\xa6\xbc\x9d\xeb\x08=3=\xf8\xba>\x82\x95=\xefV\x0b\xbdVQ\x13<\x15^\x16\xbdk\xac\xbc\xbdv\xf3Q=Q{\x9e;\xcd\xbbn\xbc\xfaYz\xbcP\x94\xb9:\x1d8\xf1\xbb\xb4\xb3\x1c\xbd\x07\xd4X\xbc5I\n<\xc9\xaat<\x9e\xe3\xdd=W5\xc0=%C\x868\x1e\x12-=\xd2\x19E=N\x97\xc3:M\xb1w=uh\xb2\xbd\xb4>,=\x0b\x1c\x16\xbc" +HSET bikes:10017 model 'Sol' brand '7th Generation' price 2083 type 'Road bikes' material 'carbon' weight 8.9 description 'The women-specific, race-ready frameset has received significant upgrades for 2022 and now has a stiffer front end thanks to the upgraded SL fork. The hydraulic disc brakes provide powerful and modulated braking even in wet conditions, whilst the 3x8 drivetrain offers a huge choice of gears. Put it all together and you get a bike that helps redefine what can be done for this price.' description_embeddings "\xcc\xd9\x1a=\xbc\xf8\xde;\xc3\xdb\"\xbd\xe0\x15y\xbc\xff\xc6\x1c\xdc\xf1\xc4\xbc\x10H\xc0\xbc\x98\x07\x1b<\xc0T#\xbbM\x81+<;\xef\xc2:U\xd4\xd7<\xf2\xf7q\xbc\x83\xdd\xb7\xbcR\xc1\x85<2\xe9\xf0=N\xb2(<\xab\x90K\xbc\xab\xff,=agm\xbc\xe7\x93\x8a\xbc?;\x06\xee\x91<\xfb\x1c\x1c\xbd\xfd\xe5\xb8;P\xd4b\xbd\x85t\xa3\xbc\nQ\xc6;\x0cr\x90\xbcS\xfdI=\x86\x1f\x1e=f2\xd1<&%\x0c\xbd\x1c\x87\xf3;h\x19e]<\xc8\xb9>=\x9aS\x1a\xbb\x8a\xad\xb7;t\xaa\xb0<\x95\x0e\xef\xbb\x894s\xbd\xf9x\xa0\xbdV \x11<\xd2u5=\xc0\xe5d<\x8b\t\x06\xbc\"\xfd\xd3;\xa8\xcf\x01\xbcaKa\xbd\xd9\xf1\xe2\xbc\xa8E3=`MV=\xcc\xa3\xc9\xbc\x8f4%=\xa4?c\xbc0\x8e\"\xbd\xa6\xdb\xeb\xbc\xd94\xa9wl=\xaa\x0f\xc7=\xf2\x16\x99\xbcw\xbf4\xbcbV\xf4\xbc\x88\x8c\x08\xbd[%\xb2<\xa8..=\xea\x9e\xad\xbd~f~\xbc\xb7Fk\xbdX\xba\t=\xfb\xb7\x19=if1;r@}\xbd\x8b\x10\xd1<\xc5\xcf5\xbc\xe7\xdbd<\xaa\xee!\xb4\xa3\xbc\x1d\xae\xe5=8\xd1\x14\xba\x1eg\xf4;\xc1\x83\xc5\xbb\x8a\x84\x00\xbd\x00|\x8a\xbc\xc3KY=\x84\x85G<\x15\xf32\xbdP]\xa5\xbd~T\xd5\x13\xbc~\xcb\xd8;\x01\x91\xe2\xbcF\x8ao\xba\x17!\x0b\xbdB4\x0b<\xe5\xc9\x90\xbd\xdc\x84\xd7<\x9b\xe6\xb5\xbct\x8c\xb0<]\xeb\xc3<\xce\xa4\xd6\xbc\x02\xd0\xa2\xbd\x12\x94\x85=s2\x97=\"\xac\x92\xbc\x98\x88\x0c;\xe9~:=\xae\x8b\xf8\xbc#\xda\x80<\x7f\x16\x85=\xe1\xc4q\xbd\xbe\xba\r;\x80+\x7f<<:\r\xe8\xbbo$\xca<\xe5q&=\xda22\xbc!\x19n<\x8fO\x9b\xbc\x0c\xc1\xe9<\xfe\x83^\xbb(\xe5\x95\xbc\x8a\x84\xf6\xba\xed \xea<\xe3\xae\xea<\xf8\xba\x0e\xbd\xcf\xd0\xb5\xbc\x8e\xdf\xdd\xbcB\x911=\xe14\xc5\xbb\xa7\xe3\xb0\xbcCqr\xbcyT\x86\xbc|\xa4P\xab\xcd\xbc\x9e\xf8-\xbd\x8f\xb6\x87=\x84#A\xbd\xc9K\xffQ%h\xef<\x06\xcdI\xbd\xaeK\xd8\xba\x0f{\x0c\xbc\x92\xdf==\xed]\x9a\xbc\xe5G\x11\xbb\xb9\x9c\x94=%\x1a\x9a<,\xe74\xbb\xed/<=\xf2r\x03=~\x08\"\xbd\xba\xdb\xe6<\x8c[\x16\xbd\x91\"\x00\xbb&;!\xbd1\x9b\x04\xbd\x0c7\xe6\xbd\xa7\x8e\xca<\t\xd4u\xbdMv\x05=H|\x91;\x03\x05\x9e<\xce\xc6\xb9;v5z<\xcbX\x04=\xf3E(;\x07\xf7{=\xb1\xb7\xa1\xbc\xde\x15\xc7\xbc\xdf\x90P<\x9b\xf4\n=\xcd\r\xc5<\xa9\"\x95\xbc\xb5\xbcv=X7\xdf\xbc\xc5\x16\x8d<[\x8be\xbc(\xad\x1b=\xbf\nS<\xe1I\x1f;v\xe3\xdb;\xf8++\xbd\xc0\x03i\xbc\x1b:P<\xc2.\xab\xbc\xfd\xab0\xbd(\x91E\xbc]c\xfc\xbc\x94ny\xbc\x11D\xb8\xbbl\x93\xe7\xba\xdb<\xe4\xbb\xe8V\xf7\xbb\xd7\xb3\xb6<\x1e\xf8\xc3\xbd\xe6`\x97\xbd\xdc|\x8c=M\x8de:w\x01\xac=\xb5\n\xfe<6V\x9c=\xa0\x18\xe4<\xbar3\xba\xe1\xea\xd8\xbc=\xae\x1f\xbd\xe6\x1b\x8c\xba\xff\xcf\x01\x1c\xbd\xd6\x1b\x0f=b\xe6\x16\xbd\x05\xe6\xe3\xbc\x1c\xad\xcc\xbc:\xe6\xb7=rS\xaf\xbdT\xf71;\x18G\x1f\xbdJ\xd8\x0f\xbdW\xcd\x02=\xda\x80\x8e\xbdo\x9bD\xbd\x89\xe0\x86<\x96[\x9f\xbc\xb2\xd8\xe1<\xdf\xbc1\xbdT\xf7\xcf\xbb\xa8\xfa4\xbc\xaa\xd7\x8e\xbcG\xb2M<\x82{\x80\xbc\xc9\xb37\xbd\xfe\xadE\xbc\xfe(0<\x1e\x0b\xd5\xbc\xa9s\x93\xbc\xc0\x10\x11=\x94\xef\xd7\xba\xc0e\x1c:35=\xbd\x9a\x01K\xbb\xb6\"\xd6<$~e=$\xf9_\xbc\xaf\x1a:\xbd\xcd\xeb&=\xb2zq\xbd\x15\xfe\xff\xbc2TG\xbd/\x7f\x02\xbe\x8eZ\xd4\xbc \x00\xf5;,\x95Y\xbco\x06\xfc\xbc\x9f\xcb\x89\xbc+c\"=4\x89\xd0=$\x1c>B\xbb\xf9\xc4\x14<\x17\xc0F\xbcI\x0c\x9c<\xd1Z\xa5<\xb6\xdb4\xbd\xe6OT<0x]=C\xc0\xab<\xaa\x8e/\xbdF\x89\xce\xbb\xb5\x84w\xbd\x03\x1e\xed\xbd_\xc7\x8c\xbdL\x9fA=\x12\xe0\xa1\xbc\x9b\x9a\xff\x17\x9a{\xfe\xbc\xf8\x19\x97=cV\xd9<\xba\x19\x90\xbb\x8d\xd3\xf9\xba5w\xc4<\xa6\x92$=\xd2\xcbz<\x91\xaa;\xbcY\xc2&=\xdcDr<%\xc8J\xbc\xd7m\x8f:\xdc\xc2\"=\xe9\xe37\xbc\x0c\xf4\x8b;>\xe1\xe8\xbc\x99\xf5\x03\xbdK\xa1\x87;e\x86\x80\xbdV\x1a\x9a\xbd\xab\xee\x00=\x99K\x19=\xad]\x92\xbc*\xca\xc7\xbc\xb6\x81\x9f\xbd0\xe4\xc0\xbb3\xe3\x1f\xbd\x19\xe8]<`\x13\xcd9,u9=\"\xf9\x04\xbc)\x82Q\xbc\xa7,O\xbd&\xd2\x19\xbd\xcb\xb4)=\x8a\x82\x8d<\xe1\x9f\r=\xd4]<\xbd/\xe9c\xbc\xc8H\x8f<\xc4\xa8\xf4\xbc-\xd8-\xbd\x8b7\xa3\xbaK\x9d$;\xf54_\xbd$*\xa48cT\xe3=\xdc/];0|\x01= \xb7J\xbcuj\xea\xbb\xe8\xc0(\xbd\xd2\x1f\r=\x15\xbf\n=\xee\xa0\xda\xbc/wD\xbd\x86oF\xbdBj\x1a\xbc/\x1a\n<\xecH\x9d\xdb\xbc\xa3\x91\x00;)i\x86\xbd\xe6\t\xd5<\xec\xc3\xad:\xb8v)\xbd\x0ez\xcc<\x8c\xc7\r\xbd\xb1XV=\xe1\xa6\xf7:3sk\xbc\x93t\x8c\xbd\xb7\x86\x98<\xef\x84\xda<\x80\xa39\xbd\xafd\xd6<\xf1\x02\xdb\x1c\xbd\xce8\x88=\x90\x99\x1f\xbc\xca*\x17;XU\xb2;\x93\xb8\xd5\xbbl\x02\"\xbd\xffma=aY*<\xf76\x80\xbc4g\xb2;\x06x\xbd\xbb\xa5\xab\x9e:f\n\x91<\xc9 \x9f\xbc\xe6p\x86\xbb\xad\xa5\xb8\xbc[!u;\xac\xae\xd1\xbc\xe3\x9e5\xbb\xea\x08\xa6=\xa16\xac\xbd\x11\xca\x80\xbbV\x17\xa4;UE?\xbd\xc2\xa8\x11<\xc2\xc9\xf3<\x0cm\x87\xbbA\xb8\x05=\x90A>=\xc6\x1bk\xbd\x85\x0e\x98\xbb\xc8\t<= \x1b\xa5\xbc\x96x\xa9<\xeb4\xee<\x80\xc9 <\x04\xfc\x82\xbd\x7f\x87j<^\xff\x01=\xee\xfd\xb3\xbb\xb65\x1e\xbc\xe4t\x0c=\xb5\xf2\x829\xc3\xfbu=^\x8f\xb7\xbc\xb4\xe7\xaf;x\x10\x16\x93<\x1d.\xc2<1WJ\x8d\xc6<\x13\xf2\xe6\xbc\xa4\x92\xfc\xeb\x11\xbc\rY\x8f\xbda\x8a\xd7\xbc\x81\xf1\xcf:^\xe0\x07\xbd\xd4\r\x98<\xcd-_\xbd`\x15\xd5\xa3h\xa0\xba\xcb\xe6-=\x9a\x87\x9a\xbc\x82j\xbe\xbcp\xce\xb9\xbd\xb7\nm=[0\xcb\xbck\xcek<\x07wr\xbd\xc5\x92\x88<\xbc\xc4#\xbb8\x04\x8d;\xb9\xa5\"2\x81<\x98l\x7f=\xc2\xb1\xd6<\x0eYj\xbd\xc3\x9a <\x08\xfc\xb4\xbc6\xe7M=\xe3\x9f\x84\xbd\xe2K\x10\xbd\x1f\x98\xe6\xbb\x92{%\xbd\xba\x16\x1f\xbd\xf6&J=5\x13\x13=\x1cB&<\xea\xab\x9a\xabM\xbdM\xd6\x83\xbd\x8f\xb5/<,\xb4\x99<-\xfb-<\xd3\xc8\x0f\xbd\xb6\xab\xa0\xbdRp\x8d\xbc\x87~V\xbc\xebW\x18\xbc\\K\"\xbc\x99\xe9!=\x9cG\x03\xbc\x16\x11h\xbc\x9f_\x8b\xba\"B\xd9\xbb\xd6\xd12<\x909*:\xe0\x89\xfe<\xeeI\x1e\xbd\xd0\x98\xc2\xbf\xa1\x81;\xdea\xf7<\x90^\xa0\xbb\"Fk<[x\x7f\xbd\x80\n\x8f=\xd4\xaa^=6\xbc\x02\xbdY@\x8e\xbc\xafe\x86\xbb#\x00\x03\xbd\x89e\xc0\xbc\xc2\xbfA<\xd4|Z\xbd\x858\x12=m\xb0\x8f=/e+\xbdR\xac\\\xbd\x85^\x0f\xbe\x16\xa3\x18\xbd[\xae\xfb\xbc\xee2l\xba\xfd:\x96\xbb\x15\x95$<\x07`\xf7<2\x18\xd8\xbcjh\x14\xb9@\xb3`\xbceu\x0b\xbd`\x97*=\xc9\xcf\x8f:\xeb\xa9v\xbb\x83i\xe9\xbc\xa0U[\xbdrEo=\x1c\x81<\xbc\xecw\x08\xbc\xad]\xd2<\x81\xba\x81<\x8a<\xc9\xbc\xce\x80L=y\xe5N\xbd\x8dq\x1d<\xda\x85\x9e<\xab\xd0\xc4\xbcQp\xea;\x83vp9\x8f\x1d\x83\xbb\x91\xe6C\xbcm\xab\xb4\xbb\xd1h\xba\xbc\xf4\x1c\x99\xbd\xce\xc7^\xbd#dI\xbb:\xc2\xb9\xbc\xd5\x94\x98=\x02\xb8\x17=\xd2\x07\x93@\xbd\xb8\x8b\x01<\xaf\xdd\xb8=\xd4Dq\xba\xb3\x83\x1d\xbdZwk=oc\x93\xbc\xcc\xf5\xb9\xbbt\xcd\xb3\xbcF\xe0\xf5\xbb6\x9b\xf5\xbbx \x7f\xbc\xc8\x8e\x99<\xcc\x04/\xbd.q`\xbd]\xa1>=7c\xfa\xbbT+\x8e\xbc;\xe9\x13\xbcJ;(:\x17\xf1\xd6<*\xd2\xb1<\x9c\xd2v\xbc^\xe7\x87:I\x97)\xbd\xd8q\x83<\xf6\xac?=g.\x14=\tTA\xbd\x08\x0b\x8f\xbc\xcc\xa4\x13=d\x1aM\xbd9\x04.\xbd\xddP\x81=\x8e\xc1\xe0:\x95\x16!\xbd0\xd9\xe0\xbc\x83O$\xbcdmW\xbdm\xc2f\xbc\xf4C\xc7=\xd8\x1b\xc5\xbc\xb4\xc3\x12=\x08\x8a\x12\xbd^\x9c\xa3<\xec\xbe\xa9\xbc\xc6E\xbd@\x05\xdb<\xb5\x99U\xbd\xe2\x89\x85<\xaeP\xa7\xbb\xa1\xfd>=\xd9\xce\xb0\xbc\xcf\x1b\x06\xbd\xcc\xbb0<>\xe83\xbd\xcayj\xbd\x0e\r\x8a<\x1d.\x1d\xbc\xeed\xe2\xbb\xc3J\xe6\xbb\x1a\xeaq\xbc\xc8>\x1f\xbd\xd3N\xc3\xbcoM\xca<\xdd\xfa7\xbd\x96\xdd\x03\xbd\xf3V\xc0\xbc\xdaA\xca\xbd\xc8\xa3\xeb\x84=\xf21\x10\xbd\xbf\xce:\xbd\xe6\xb4?\xbd#\xbft=\xbdE\x8a\xbcmb\x1b;\xee\xd76\xbd(\xdb\xb8;g1\xb1\xbc(:s=\xba&\x9c=\x04\xbc\x89=\x05S\xfc\xbbd\xe7\xe2&\xc9=\x02\xcc;<\x0e\xe7i\xbc>TD=\xe59%\xbd\x98=C={\x81\x86\xbc}\xa7\x86\xff\x13\xbd^\xf0mn\xbcsbx=\xb76\xb7\xbc\x06{>\xbdIX\xa5\xbd8f\x8c\xbb\xd9\xc9\xac\x1e>\xbd\x95\xb3\xa2\xbc\xe3\x84\x97\xbb,\x13F\xbc\x83#\x01\xbdx\xbb\x19=\x8cA$\xbd\xe9\xfb5\xbc\xf4\xd38=(!\xdc\xbc\x08\xc00\xbd3\xff3=\x89\xb4\x9c\xbb\x19\xee\xc6\xbc\xa9\xab\xb0\xbbI\xea\x88<\x8a\\\xa5\xbd\xb6\xa2\xd7;k\x063\xbc4\xac\x0c\xbd`\x9cl\xe6\x0f\xbd0C\x9c<\xba\xc3\x04\xbd\x14\xd0\t=Mxh\xbc\xee\x14\xd6\xbb\xbds\r\xbb\xd0/U<\xbe\t\xc3;p\x1eO\xbb\xaf\x95\xbf\xbbw\xf6O\xbb\xa2L\x06=\xd4w\x82\xbb6\x19\xa39\xdc<=\x8b\x00\xbc`\x05\xaf\xbd\x120\xdf<\x00Z\xaa\xbae\xc1\xa7\xbd\x9d\x98\xa3\xbc\xac\x96\x96=\x8d\x16!\xbd\xbc\x14z=\xe2/\xb3\xbc.c\x84\xbbN<\xcce\xe4\xbb\xcb\x0b\xa1<\xfd\x11\xc5\xbd\xdc\xd8\xa8<\x00\x95S0:\xcf\xa5\x01\xbd\xa2_\x80<\xda\xed\x8b<\rS\xc7=I\x96\xe0;+%%=\xf6!Q=$\xda\x13\xb9b\x18\xa3<\n\xb5\xaf=i\x9e\x946\x80\xa5\xf9\x86<#\xcf\xec\xbc\xb37\xf2\xbcE\xdf/\xbd@\xe9\x85\xbd\xa2^\x8a<\xd8a\xe9<\x07~6\xbb\xb9\xbd*=.P\x0c=\xbeU\r=\x7f,\xb8<\xa1\x97\xac\xbc\xd5\x90?<]\x94\xbd\xbc\xc4\x97=\xbc\xe7\xb82=Uk\xd2\xbc\x15\x1aM\xbdV}v;\xf7\xc2\xf7;9\x80\xe0\xbb\x03Q\x0f=\x19B\xe3\xbc\xce\xfe\xdf\xbc\xc9\xe0d=\xffD<=@\t3\xbc\xe4~~w\x96\x05;\x01\x88\xae<(F\xbb\xbc\x06qr;ii\xdc:6\x7f/=\x19\x9b@<\xd5M\x9f\xbc\x1c\x9c\x90\xbd*\x122\xbcK\xc8\xb9<#\x0e\xaa;\xc8TF=\xd1qH<=I\x04=\xd7\xab\xa2<7N\xb3\xbd\xe2-9\xbb<8\x9e<\x04\xa1\\=0k\xe8<\xcc\xdd\xcb<@\x08\x9a\xba\xb0fB\xbd\xa2gf;\xd4:j\xbd\xe8\x0f\xb1\xbc\x0b\x8e.=\xe1\x91\x91;E+0<\xcb\xf8E\x89\xd7=\x9a\xcd\x10\xbd\x8d\xe5\x97<5\x97\x18\xbc\x81q\xd9\xbc\xb0t\xfd\xbb}\xfc\xb7=-k\xa1\xbd\xac\x0c\xba\xbb\xc48S\xbd\x80\xd3\x12=:\xd2\x97\xbc\xd5\xbf\x86=:\xf1Q\xbc\xf5\x06\xc7<\xfd^M\xbb\x8f\x91<<\xabA?=\x93\xd66=\x01\xf1><\x9d\x07}<\x14A\x11<\xd0\xeb#<\n\x1a\xea;\xa5E\xac<\x8b\x1e\x9c\xbd\xc8\xb6D\xbc\xd50\x8e;\xf5\xbf\xd1\xbc\x15\x89\x16\xbd\x07\xfe\x11=z\x14f=\xb9\xa0\x8c<\xf6FE\xbd\x04\xcf)\xbc\x93\xce\x07=&p\"<\xa9\xc9R\xbdK\x88W<\xa6\x17\xee\xbaD\x0c\x97\xbdyI|\xbd\xe2=(\xbd\xe3\x1d\x80=\xe1mx=\x08\xec\x15<\x92\xdf@\xbc\x8cmE;^)m\xbb\"eY\xbd\n01\xbc\x0ce\x9b\xba\xbf@\x8e;\xab\xa7\xa1;1\xd1\x00=2\xffU\xbc\xd30\x0e;\xab\xeb\xba\xbc\xe1f\xeb\xbc)\xa84<.\x95\x1f=\xf5\xee\x04<#\xba\xcd\xbb\x15\xc5\xa2\xbd\xdf\xe9\x1a<\xf5\xca\xe7<\xb5\xd9\x18\xba\xb7I\x84\t\xbet\x1f\xb9N\xdf8\xbc/B\xab\xfd\xdb\xbc\x8a\xe6o;u\x1a\xf6<\xb1\xf4\xcb<\x07J\x96\xbd\xfa\xbce\xbdA\xaey\xbd-\xb5\x80;\x83\xce\x10=y~\x1a=\x96\"\x03<-eP\xbc-\xb7\xda\xbc\xb9\xe4*\xbd\x0e\xce\x8e\xbc\x12R\x03=\x93\xfc\x00<\xe5\x16\x93\xbc\x96\xdax=\x0b\xdfR\xbd\xfbdL\xbd\x83\x94\x12=.\xe4\x18\xbd\x17_\xa2\xbc\xe2\xd5$\xbc02;=\xbe\xb9\x12\xbcB\xdb\xe7\xbb\x12\xfe\x1b\xbdh9f;\xa9\x1c\x14=f\xfb#<\xbex\xb6<\x1e\x98\x05\xbd^\x93\xe1<\x1b9Y=\x8e\xe5\xbb\xbbM\xa6*\xbdM\x8a\xb3<\xcc\xa8;\xbcf\xab\x8b<\x93.\x07=\xec\xa8\xd5\xbb\x18\xcd\xa4\xbc\x8a\x02\x06\xbd\xa9\x90\n=m/\xdc3\x82\xbcw]\r\xbd\xf7\x87f=\x82\x10*\xbd\xd9iW<[)M2\x1f=\xa7\x86\x1b\xbc\x1fO\x19\xbdbQM=d\xd7W<\xd3bt<\xecM\xe6\xbcc\x97 \xbb\x7f\x11\x94\xbb.4\x80\xbc\xa9\xd4-\xbc\xd9;\xc1\xbc\x10\x005\xbc\xce\xd7$;\xa7Cy<\xcfM\xe5<\x92\xe5\xf5<\x14@z\xbc\x1d\x02|\xbcW\x98\x1f9\xee\xc3\x02>5\xa1\x92;\x8c\xbc\"=\xc1&\xfd\xbaX\x0b\xe2<&>\xff;\x97$Z=\xcf\xc6\x14\xba@\r\"\xbd|{\x9f\xbc\x1e\xd3\x8e\xbc\x1b\xd8}\xbd\xfa\xf4\x81\xbc\xb5\xf8\x15F<\xc2I\x16=\x12BD\xbcv7\xfe<\x1e7\xa9\xbcY\xa7\x94=2J9\xbc~H\x19\xbcF\x08\xde\xbcd?g\xbc4Hb\x1d=;\xd0\x87\xbd>\xba.\xbd9z9\xbcu\xb0P\x0c\xbd\x8f\xc9\x8c<+\xa5\xa6=\x91\x19<\xbd\x9b \"==\xd9W<\x05A(=\xfa\x14\x9c\xbc\xed%v\xbc\xfb\x9a\xa1<\xfd\xbc\xc9< {V<\xf1EC=\xc0j\xb0\xbc\x19+A<0y\x12=\xf7yZ=\"a\xb9\xb7\xbb^\x98\xc0)\xbd\x02\xd7k\xbd\xddeP;\x1f\xf5\x87\xbd\x14\xa71=(<\xc8;l\xae\x12=\x00\xba\x13<:\x11\xe9\xbc\xdc\xc0\xe4\xbb\xea\r\x9d\xbb\xf4\x08G<\x8e\x04\xb2\xbcig\xd3=\x9a^\xe3\xbb@:\xe6\x8e\xac\xaf\xbd\xc1/\x80\xbc\x8f\xae\xa9\xbcb\x98\x1f=\xe7@\xa2;\xe7bW=\xd5\x0b\xea<(D\x95<\xea\xa7H\xbdo\x08*=+\x8d\x14\xbc\x14\xe0\x11\xbd\xf6C\xc2\xbb\xda\xc8~\xbd\xf9J\xa0<<\x10\xb9\xbc\xc6&\xf8<\x9e\xd9\xd5\xbc\xaf\x01\"<\x061\xdf\xbcd\x1c\x8d\xbchO\x9e\xba\x8f\xebn;a\xac\x91\xbc\x1e\xc9\xf1\xbb\x0cmC;\xc9\x82V=2\x86@<%\xdf\xc7<\x9cH\x18=e\xfb\t=]\x88d\xbc6\xec\xd2\xbc\xb2\xa2\xa9;\x8c\xe3\x8f<\xe4\xf1|\xbd\xd0\xa3\x05\xbd\xd5i\xea\xbc\xc3\x89\xbf\xbd ;\x05\xcf\x95<\x9e\x04g\xbc!\x87\x8b<\x1e\x87\x1f=\\\xfc0\xbb\x97\x848\xbd\xf3\xeaM=\x91f\xfc\xbc\xb5v0\xbd\xcd3E\xbd\xdf\xea\xab\xba\x8d\xe6\x88;\xf6\x13=\xbd\xe1\xcb\x7f\xbdd\xb3\xe7<.p\xee<\xda2\xc0\xbc\xaa\xaf\x13\xbd\xaa\x0ep<\xcb\xa8\x90=\xe4\xe3\xce\xbcM\x8f\xcb<\xd2\x8d;\xbd~\xde?\xbb\xc2\x9fp\xbd\xcd\xd5\xdf\xbb\xcc\x97\xf7\xbb\x03m\xed\xbch\xfd\x99=\\[N\xbc*G\xb1\xb9F\xa2\xf8\xbcW\x00\"=\x98)\x00=\xc1\x82%:\xe4\xb3\x90=ig\xf0<\"\x89\x1a\xbd\x9a\xdb\xb6\xd4O<9\x89\x9e;\xef\xd8\x84\xbdP\xca\x94\xbcO\x8b\x84\xbb\xfb;\xa7\xbd\xcd\xf8l=\xfc\xcd\x92\xbb\xa7\xeb\xa6<\xb8\x19\xc6\xbcO\xd8\xe7;L\x0b\xd7\x89<=l#\xbdu\x1c\x14\xbd\x99\xd7\xb0\xbc\xb1\xb9\xf7<\x18\xc6\xf2\xbc\x89-\xe5\xbc\xf4\xc2@\xbd)bB<\x971\xda9I\xbbH\xbd\n\xd4\x19\xbd#\x8bR\xbc`\xc3\x97:\x99_\xb2\xbc\xb3N\x94=\x81\xa7\n=\xe8\xb3\x00=\xef\xbf\x92;\r\xc3\xa8=C\xc5$\xbd\x9c}/\xbdd\x9cp<\xdd\x1d\x84\xbbb\xc9\x1b\xbcx\x1b)=Q\xa5\x91<\xc4\xce\xec\xbc\xaa9&<\n\x11\xdc\xb2\xbc\xd9\xc5%<}\xc6]\xba\x83|O=)\xffX<\xde!6\xbb\xc6\x9c\xf0\xbaw\xaeF\xbd\x87U\xd7<\xe2~\x1d\x97;(W\xd2\xbc\x98\x0fn\xbd\xde\x84{=+r\xe5|\xc3\xba48\x97\xbd\xf7\x87J\xbc]?\x8b<0\x14\xcc:\xee\xf9\xbb\xd75\xbc\xaf\t\x81\xbb\x15\xdaC\x14=i\xfa<\xbc\xa8\x1a\xe7<\xcb\xf7\xe7\xbc\xdd\x19\x96<\xf6\xde\x8a\xbdl\xfa\xa4\xbc\xfc\xb1]\xbc\xe4)\x06=\xeeq\x96<6\xe3g=\xdc\x852\xbd\x18\xe3)\xbc\xf3\xfc\xfd\xbb@f\xfd:ky\xaf\xbd{n7_\xbd\xac\xc7\xe4\xbb!\xad\x15\xbd#uJ=\x95\x0f\x00\xbd\xac\x10T\xbcC\x8b\xfb<\xf3J\xdb\xbc\xdfop\xbcg\xc9%<\x1c]\x92\xbc\x95\xabI\xbc\t\xa5S\xbc\xb1\xdd\xb2<\x0c\tD=8\x85\xc4<\x8d\x08\x11\xbd*\xa9\xb8;u\xba\x00=\xcb\xf2\xf0\xbct\x8b\x0b\xbc.\xe5\x8e<\xbb\";=3N}\xbdSN\x16=\x8a@\x0c\xbdG^\x94\xbb\x94yZ\xbd\xb5q\xdc;\x04\xb1\xfa\xbb\x8b\x07]\xbdE\xdf\x88=\x1cZ9\xbcR4c;Qqt\xbdL\x06\xfe\xd01<0\xe5\x1d\xbd\xf9\x11\xf5\xbb\x92\xe7\xbe\xbc\xad\xd8s\xbc*\xff\xc3;%H\x08\xbd\x8eX~\xbc\xf0\xca\x8b=\xae\xc1\xa5\x9e\xbc\nC\x11;\x9f\xd6\xd5\xbc?\x9c\x99\xbcw\x06\n\xbc\xcdD\x12=R\xb3\xbe;z{\xb4\xbc$\xff\x82<\xecA\x88=\x96\xcc\xb9\xbb\xce\xa8X\xbd\x1d\xbb\xcf\xbc\xe1\xe8\xac\xbb\xb8\x1e\xaa\xbcbc\x1c\xbd\x87Uy\xbdDd\x9e=\x02\x9f\x8e=\xbfS\xe3<\x8f\x95\xc3\xbb\xaeO<=|X(\xbc\xcc\xa6\x0f\xbcE\xfe\xc9;BI&\xbc\xf7\x89\xc7\xbc\xffwI\x9a\xf9\xba\xe0\xdez=|\xf8\xae\xba+E\x11\xbcT\xdfG\xbd\xc1\xa83<\xadK%\xbc\xfdL*=\x8e\xaaz\xbc\xffM\xd2\xbav\x08\xbd\xbc\xfb\x0br\xbds\xc9\xe2<\xeb\x8c#=g\xd1\xa7\xbdEN|\xbb\xd1\x08u\xbdq\xe1\xa2=\xcfN\xc4\xbc\x19\x19\x89=mB\x16<\xa6\nw=\x8b()\xbd\xaey\xb9\xbc7\x9e\xe5\xbc\xa5\xdd+=\x98\x81f\xbdF\x8f\x1f\xbd\xd1R\x0b;\xdbd[\xbc3L\x14<\x9f\xfc \xbd\xeb\xdeo;\xc9\xb9\x11=S\x1d\x91\xbcv\xe1\xd5\xbc\x9a\x98h\xbc\x8f\x8b\xff\xb9\x80\xa2\x0f=;Q#=\x82\xdc\x1c<\xf1-R\xbd\xfbN6=\xb5) =\xf3oB<\xaa\xfaD\xbd\x0e\xaf\xc6=2\xeb\xb2;\xbb\t\xe5\xbc@\xb8\xdc\xbb\xc5#\xe9\xbckXW\xbdQ#j<\x0bp\xa7=\x19\xf0\xf4\xbaY\xf8\xe3<\xf7\x80\xa3\xbc\xe7\x86\xe6<\x1a\xbd\x85\xbc\xfaA)\xbcY\x1c4=\xec\x7fm;k8\x04;/N\xf7<\x04\xa7I\xba\x16d\xfb\xb9+s\x99\xbd\xc3\xf3\xde<\xe0&L\xbc\xcfN\xca\xbcEay\xbc\xfc\xce\xef<\xd6\xf8\xee\xbb\x96^|=\"\xc9\xd1\xbc,\x1f\x06=\xa6\xd6\n=1\xcd%\xbd\xce\x17\x91\xbcm\xf5j\xbd\x00\x92\xb2\xbc\xbe\xf5@\xbd\xc7\x9cc<\x05-A\xbc\xc6\x06\x95\xbc\xd8\x16\xf3\xbc\xd4\xf2\xbf<|\xf9\x8a\xbc\xc6\x1b~\xbd\x12\xb9\"\xbd*=J\xbd0]\x08=\t\x0c\xa2\xbc\xc1\x16O<\xb0\nG\xbd\\\x91\x97<\xe7\x08\x1e;o\xefZ<\xb5\x18\x84;\xc3(\x8a\xbd\xd4\x15\xe8\xbb\x9d\xc1\xea;\x01\xef\xcf\xbci\xbc\xb9\xbc\x86\x82\x18\xbd\xc0\x9b\x86\x85=\xa9\x97\x83\xbd\xac1j\xbd\xbd\x96&\xbd\x83\xf7\xe2<\xc1;\x81\xbc\xf7\xbd\xcf8im\xbd\xd62\xec<\x1e\xb6\x9a<=0\\=\xbd\x8e\xc4\xbb\n\xa56=q\x19\xf5<\xe1Zv\xbd\r\x0e~=n\\7:}\xa8W=\x1a\xfc\xea\xbb,q\xea\xbat\xa13\xbcq\x12\x00;\x17\xf6\xb6;<\x1d\x14\xbd\"\xf0\xbe\xbd9t\xe6\xbc\xfe\x00\x0e\xbcIKA=\x96\xb7\x02\xbd\xa98\x1b\xbdB\x06f=\xb6\x8b\x96\xbc@P\xda\xbc\xd7\xc8\x93=|\xd2\x8e=Q1\x15:\xe4\x85\x01=-\xa2\xf9\x9e2\xbd\xc8}\xb4\xbb\x11hC\xbdEc3\xbd\xd2\x10\xc7\xbc\xdd\"\xdb\xbdHVM<\xa3\xe4-<\x05\xc2\xd2\x92:uH\xca:\x97\xe25\xbd\xb2Pp=yBu\xbc\xb3\xc6\xd8<\xa1\xc6\x92<\xeb\xc7\xa0\xbc\x0cy\xb8\xbb\xac\x1a\x8e=\"\x10\x11>sr\x16\xbd\xf1\xc5\x89<\x0f\xd2\x86\xbb\xbb\xba\xe0\xbc\x1d\xd7E\xbc\xfe]\xc9=\xc5A\x9c\xbd\x17\xa2\x9f\xbc\x1b;\x1f\xbdx\xdb\x8c=#\xbc}\xb8\xcd\xd6h=\x89E@\xbd\xf4B\x88\xbc?\xfbQ<\xa1\xf8\xe3\xbc\xa8\x85\x02<5^\"=[+{\xbc\xe1\xdd\xce<\x90\x06\xad;\xe3\x9b;\xbc$\xdb\xd3<\xa4\x86\xca<\xd7@D\xbd9\x86\x95<\x81|\\\xba\xad\xc5-\xbdD\x81\x11\xbd]\x04\xf7\xa3\xbc\x0fX\xd2\xba\x15\xa6\x81=\xe9\xdbP<`\x84\xae<\xf6\x95,=5\x8b\xf6\xbc\xa4\xaa\xad;\xdc\xae\xdc;\xcee\x14=\"r\xa9<\xfd\xf4/\xbdM\xd9-\xbc\xf7\xde\x1f<\x8c\x1f\xc1\xbbi\xd3p\xbd\xab~\x0b\xbd\xfc\xd7m\xbc\xd0\xdd8\xbd\xd5\xec\x01\xbb\x1a4%=\xfe\xe5\x9fpj<\x83%\x07=\xed\x17\xf5;\x008O\xbc+}8\xbc\x9c\xdaM=S\xc7\xbc;S\x1d\x11\xbdS\x14 =_a\xbd\xbc\xf8\r\x83\xf1\xc1<\xf6\xfd9=\xc8@\x00\xbd.\x81\n\xbd\x94\x14\xa6\xbd\xfa\xef\xfa<\x85\xbeb;f\xf5\xda=\x91z\x9c\xbcuq =\xad\xbe\x8b\xbd)]\xa9;e\xd5\n;\xe2\xd6\xb8;\xd7\xa8:\xbd\xe8\xb5\xff\xbc\x81\x86\xf7\xbc(E~\xbb\x16\x16!0\x9f<\xd5\r\xd6\xbb?\x18\xab<\xe5\x19\x80\xbd\x0cN\x83\xbcUx;\xbd\x8bC\x91\xbdLB#\xbd\x7f\x11\xc5\xbc\xf0~\x94\xbb\tu\xb9\xbcw\xb1\x02\xbd\x15\x9aJ\xbb\x81FL\xbcCg\x90\xbd\x12\xb1\xfa<\xb0\xeb^\xbd\x7f\xa8\x99<\xc9\x8cR\xbd\x92\x90\r\xbc:Y\xef\xbc\x0e\xe4\xa1\xbb\xc5\x81\x80\xbc\xbar\xeb<\xdf\x93\x0b\xbc\\t\x0e\xbcB\xe6\x8f\xbaS&d<\xa4*x\xbc\x0c\xef\x9a\xbd\xc5\xa1\xe4\xbd\xb7\xf9\\\xbc\xb6m\xe8\xbc\xda !<\x9d\xebm<\x80\xe5\x93=\xec\xeeP\xbd\xa8\x97\x12=\x06\x95\xd3\xbc0\x14\x85\xbd\xfe\xd2\x1b=#\xb0K<\xc0S*\xbc\xbb_-\xbd\x8dl\x84\xbci0\xcc:P\xf0\xbf\xbc\x8f\xc5\xd7\xbc\xcb\x90\x0e<\x81-b=e\x8b\xd3;?\xc0(<\x82eJ=\xd0c\xbc\xbb\x04]|=\x1f2\xd5\xbb\xa5U\xeb\xbd\x04\xe2c;Z\x85x\xbc\x85T\x8f\xbcV\"\xeb<\xadP\t=\xe3:\xc1<&\xad\xdd\xbc\xd8\x81\xcb=%\x03_86\x1c\x18\xbc\xd1;\xd9;aY\xde\xbc\x1a\xc3x\xbbi\xca\xa7\xbc\x16\xf1\x89\xbc\xeeb\xb3\xbc5Xc\xbd\x1c\x00\x83<\xad<\x85=\xe4\xda~\xbd>&\xda\x94\xac\xbb\xc9\xc5\x8c\xbch^\xa9\xbc\xef\xc02\xbdt\xdc\xd2\xbcm\x0fm<*\x9b\xdf:.C\xb9\xbcv\x03\x15\xbd\xcf\xbcr<\x14QtcZ\xbc\xfb\xf1+\xbd\x992\xba\xbc\xd5\x9f\x88Z<\x0c\xb2\xf4\xbbe\xab\xd6;`\xd3A\xbd\xfcv\x84=8=\xb3\xbd#<\xb4:tN\x19<(A\x0c\xbd\x85\x94\xcc;\x82(\xbf\xbc\xb2#X\xbd\xaf\x91o<\xf9 \\\xbcfVu=\x9f\x1e\x88\xbd^\xaf\x18<\xacR\x84\xbd\xec\xe6\x0b;\xcf\xdb3=JO?=\x98\x19~\xbb\xf1/\xd6<=hl=}\xfc{\xbc=\xeb\x08=s\x01\x03\xbb\xe33Z\xba8\xd1\xf8<\xa5\xc54=\xe6\xa6\x7f\xb9a\x86\xec\xfb\xac\xbd\xff\xb6O\xbd\x1f\xb7\x92\xbc\xc1|n=R\xb3\xf1;\xb9\x9c\x06=#\xa1\xba\xbc\xbb\x1a\xa7\xbc5\xccA=m\xcb\x92\xbd\x998o<\xb0\xc3\x91<&\x10\xfb\xbc\x0f\xdem=\x8aW1<;5>=\x8f\xf2\xfb\xbcG)\x18;\xcd\x86\xdd;\xaf\xa6Q=\xd7$\n\xbdY\x911\xbc\xfb\x8fZ<-\x95\x92<8\x12s\xbd\xf0\xfbI=\xda\xed\"=\xca\xfa\xa0\xbc\x8a\xae\x06<\xd2\xf2\x91=\xc2\xfdM\xbc\x96\xb3\x1e\xbd\xdc\xfd\x17\xbd\xc2\xf8D\xbd\x1e\x0c\xff\xbc\xc5\xe9\xfe;\x90\x9d\x1d<\xa0\xab\xef\xbcA2\x83=Ak\xbe<\x97k\x1c\xbdk\x1f\xd4\xbc[\x1b\x10\xbd\x15\x1a(\xbd\xd4\xb2\x8e\xbd\xd7\xe6?\xbc\x0b\xbd\xf2\xbcG\x067=8&\x05\xbb\xff\x88\xff\xa5\x04\xbc\xb3G\xce\xbd\xed Z=}\xa2\xc2\xff\x8f\xbdRL\xea:\xd4eG\x9c\xbdq\xc3T=\xd9\xbc\xac=n\xcb\xe9\xbcM\xf3\xcc;\x0c{\xb2;\xe3\xf18\xbd\xb5|\x95\xba(\xc5L=+D\x83\xbdO\xdd\x1a\xbd\x0b\x10\xd8\xbc\xcb\x97\x98=o{\xe7\xbc\xbf\xd2\x16=\x9b\nq;\x13\xb4X=\xaf\xa6=;*9s<\xfe}\r=\xdf\xb1\xb5<\x08\xb9\x14\xbd\x90\xe9.=\x8d\xf8\x96\xbc\x05s\x08:\xa0O:=\xa4\xeeI=y1R\xbd\xa1\x0fV\xbc\xe4sC=\xa29i\xbc\xa9\xd4\x12\xbdw0\x98\xbbg\xbf7<\xac6S=\xce[|\xbcJ\xa0\x13\xbd\x12 V=W\xbb\x01=\x9a\xb0\x0c\xbda\xa6\xbf:\xa37\x1e\xbc\xe6\xa3\x1a\xbd=\xa6o\xbdT\x9b\x1f\xbd\xf7\xe4\xa4=\x10\xe4R=l\x83\xfa\xba\x13V\x0e=\xe8\xafR<\x94\x15\x06<\xb4\x8a\r\xbd\xfd\xd6=\xbd\x94\x94=\xbc\xc0\xa65:\xdai\xdc<\x982\x91<\xc9\xe4\x90;U\xeb\x02\xbd\xd7\xce\xea;|6_\xbd\xf7\x7fH\xbd\xe0\x92\xe4\xbc\xc6}\xb9\xbc\xafm\x95=P\xdc\x98<\xc7?\xf8\xbc:\x9d\x10\xbd4Y\x1c\xbd\x87\t\xa6=$\xdb\x9a=F2\xc7\xbc\x9d\x01y9^;\x85\x85\x8b<\x93\xfdX=\xff\xf3\xbd\xbc\xdb$R\xbc\x18\x9d\x1c=\xf7i\x19\xbd\x0f\"M;\xcc\x7f\xb1<6\x17\xed<0s\xbd<\x82\x0b3=\xf7\xa5%\x9dE=(W\xe1\xbc\r8\x12\xbd\x1d\xb9E=\x9d\xcd5<\xe5\x01\x81\xbd\xea@\xee;;zy=u \x81;\x14?`<\xa0\xf9c<\x1b3\x14;\x81Z\x15\xbd\xd6\xfe\xe2<$\x94x\xbcd\xd9\xda\xbc\xdad\x15<\xe0Nr\xbc!\xda\x97\xbc\xf4\x02\xb2\xbc~\x7f\x05\xbd4\xb4\xcf\xbc\x1a>\xe9\xbc#\x141\xbc\xb2~\x1c\xbd\xd7\x8f\xe3\xbbz\xf4\x99=m\x9f\xe5\xbc~\x05.<}\xde\x1d;\xe4\xd9\x82<=\xb2\x00=\xb52\t\xbd\xfa\xc5]<\xd9x\xe6<\x80\xcd\x81\xbb\x9c\xc2\x8a=\x1f\xa2\n<\xdd|\r\xbd\xc2\xab\x17\xbd\xb3\xbd\xce<\x04\xff\xaf\xbc\x89\x8d\x0b\xbd\x03d\xd7\xbc~@\xe6<\xd2\xe1\xb59\x86\xf7\xe2<$\x99\x07=\x82\x05!\xbc\n\xb4\x07<\xfaA\xb0\xbc\xd9\xa7\xb2<1(j<\xf3\xd9\x08\xbd\xf14q\xbcBM\xef<\x8e9\xd1\xbc\x7f\xcf\xb8\xbd\xa9-\x81<\x8a\xf0\x05\xbdm\xebB\xbc\xee\x16\x81=\xe5\x8d\x00>\xbe\xf8\xa7<\xa4![<\x14\xf89\xbd\xd5a\x15\xbd\x82\x8d&=\xba\xf5o\xd0\xc5\xbc\xb0.)=:,\x83<\x99q\xcc\xbc\x18\xdf\xa4=\x84\xd8~=\x01\r\xef;\xb6A\xef\xba\xbc\x83q=\x93q\xee\xbd\x88\xf4\xc8<\"\xbd\x89;\xc0z\xd4\xbc\x81v\x9d\xbc\xe5\xf9y\xbc\x8b\xcah\xbdE\xbb\x06=\xe7\x88\x8a\xbc{#\xc7<\x8eeF=\xaa\x90\xbe<\xb1\x91%=T-\xf3<\xcc\xd2\xaa\xbc\"\x04\xe7:\x7f\xe1\xa4=pO+<\xa8\xcdQ<7a{\xbdqDY\xbc\x8bk\"=\xd9\xe7?<)\xfa\x02\xbc\xb9\x0f\xb5\xca\x85\xbc{\x9e\xa9<\xf4\x85\xe9<\x15\xb8\xcc<\x0e\xf1\xa1\xbcG%\x0f\xbc\xec\xc5\xb0\xbc\x00[|\xbcr\xaek\xbd\x9a\xd9\x1b\xbd\xe8=Q;1\xfa\xdc:_c\x07=\x18\xac\xb5\xbc\xc5\xfa\xf0\xbcn1\xfe\xbc[k\x02\xbdy]2\xe4\xb1\x86\xbc\x9e`r<^\x04\xb5\xba\x9a\xf8\x94=\x878\x11\xbd\x93\x17\xf4;\x1fp\xd4<\x89m\x9b\xbc?\x8c\xa6=\x1b\xb6M\xbd\r\x02\xcd:\xa6\x02#\xbc_\x12\xd1\xbc{\t#=G\xcc\xf7\xbc1p\xcc<\xe9^.\xbdHL\xa8\xbc\x80j\x01=H\xe0{\xbc\x90\xba\"\xbc\xc3\xa0/=i\xb8B\xbc\xbf\xc9\xb2<\x81\x8b\xcb<\x01\x7f\xa9<^\xf0\xf7\xbb9\x9d\xb9\xbc\"\xafq\xbb\xb5\xc1C=\xa3\xf4\x9c<\xc0 \x1e\xbd\x8f\x076\xbd\x96\xb0\x9b\xbb`jf<#\x91\xc4;\xb3Q\x98\xbd\xa0\xf1\xc6\xbc\xce\xcb\x9e=\xe1\xf6\xbe\xb8\xe1 r\xbd\xef\x8fT\xbc\xd2\xa7U:\x8f\x92h;\x952\x19;?\x8c\x83<:\xa0M\xbd\xbc\xfeY=\x8b\x19\x13\xbc\x9b\xc4\xb7=\xa4\x0c\xbc;Fv\x0c=\xf4\xa2\x0e\xba\xb3=y\xbcd\xf0Y\xbd$0%=qRg\xbb\x90Q\xb8\xbc\xffd\x93\xbc\x0cN\x12=W\xea\xe1<\x13\x91#\xbdK\xf3g=/.\x93=+Z\xb4:h:s\xbc\xcdf\x89<\xbb-\x03=\xef\xe35\xbc\rk`=\xdd\xcf\x93\xbc\xbbWI\xbc\xb0\xf2\xe7;\xceFc=\xe13\x9b\xbc\x8d\xf3\xc2\xbcu~\xc6;dpf\xbd\xcaYG\xbdG\xa0\x8c\xbb\xdb\xcby\xbd\x87\x9d\xdb<\x805\xa5\xba\xd3aj=f\xa7\xc0<\xffr\xc4\xbc\x8fe\r\xbd\xc0~\x81\xbc\x15\x1a\x04=\xb6h\x10\xbd\xc9H\xbe\xbc\xa9\x02\xba<\xa8*\x00=N\xe8\xb5<[\"\xa8;=\xba\xf12r<~bu=\xbb\x0fH<4\xf5r=\xedbS=\x97\xb9\x1e\xbd\x0b\xd0Z:\xeb\xdf\x19<\xcap1=U\xd6\xed\xbaL\xc1P\xbd\x83\xad\xce\xbc\x8b\xb8H\xbb]\\\xaa<\xd3\xc2.\xbd\xca\\\xf1\xbcY\x87 \xbc\xd1\xfec\xbd\xb7\xc4\x83\xbcJ\xd3np^\xbc\x1f\x13\x18\xbcI\xd2\xf7\xbc\x8b\xed)=\x92\xc1\xb1\xbcB\xfe\xc7\xbcCf*=\x83\xdb\x80=\x8b\x9ch\xbb\x0b%d;D\xa4\xc3\xbb\xb5\r\x9e<\x80\xd1\x9a;-\xfbL<\xe9A\x0c=\x85\xa8\x8f<^\xe1k=\xff\xf6\x05\xbd\xa4\xd0\xaa<\xa1\xda\xf9\xbc\xd5\xf9\x06;w\x1e\xac=@\xc7\xf7\xbc,\xf2\x13\xbd\x94J\x8e\xbd\x14p\x06<\x1c\xbcE\xbc\x8f\x1c\xdb=\x10\x8f\x0c\xbdUx@=\xd4tf\xbd\tN\x9a<\x86\xfd\xcd\xbb>\x06\n\xba?%\x18\xbd\x98@\x19\xbd\x1c\xf3R\xbcvG\x13\xbc^\x9e\xc8<\xc9\x8e\xb7=\xfeG\xe8\xb9\"\n\"=q\x9a\xa5\xbcH\r\x87\xbdw\xf1J<\\\x04\xe2\xbc~k\x96<\"\x1b\xae<\xa2\x97\x9f<\xb2\x8eQ\xbd\xb7+\xdd<\xfa\xa0P<\xf7\x11\xae\xbd\x0ba\x92\xbcnc\xa6\xbb\x1b\xdd\x1f=_\xf7%=\x9f\x15m\xbcL\x9e}\xbd\xb5\xe1e:J\xcbs\xbdR\xdb\xcc\xbcJ\x1c\x02<[\xf7\x05=\x82\x0c\xe2\xbc\xf8\xc2\xe1\xbb\n\xe2\xbc=x\x16\x87\xb9\xc3\xb7v\xbbCa\xe6\xbb\x14\x8fX<\xa2\"\xc4\xd0:g \xab<\xe5\x01\xc8\xbc\x9dK\x1b\xc4:;\\\x17\xa0<\x9d\xcd\x8e\xbcJ\xe4\x9a\xbcW&_\xbd\xcc6\x88<\x131\x82=\x1e\xd2\x10\xbc\x87A:\xbc h\x0e;K{\x90\xbbU-\x04;EV%<\x96\x93]\xbc\xf3\xcfz=\x12\xd5\xc4\xbcs\x86\t=\xa0\x03S\xbd\x8dH/\xbd\xba(\x0f;C\xcd\x95<\xf0\x97\x8d\xb9\xe2j\xa8\xbdW\xbf\x8b=\xa6D\x1c\xbd\xb4\xbc\n\xbc\x9d\x8fc\xbd\x8bG\x90\xbb\xd7\xb03\xf72=v\x10\xab\xbdL/\x13\xbd\x17\x80f\xbd\xca-\xd7; (\x0c=\xaa\xa6\x02\xbc\xf3\xbd\x05\xbc\x7fT\xb1<8\x88O\xba\xa2J\xb2\xbchf\xae<\x12\xa7\x01\xbd\x1c\xe8\x16\xbc\xb9\xc5V=\xc1\xf4\x86;\xd0+\xba<\xdb\xd2m=\x06\x86\xce<\xba\xc9]\xbc]_\x11\xbc\x89\x0b\x15= b\x8a<\x96!P\xbd\x05+?\xbcIv\x9c=p\x85\xcc\xbd\x00\xf8\xa6<\x1fu\x91\xbc\x00\xee\xa1\xbc\xfa@\xf5\xbc`\xeb\xa0=\x05\xef\t\xbbHJ\xc3\xbcG\x1f\x1e\xbd\xa5\xaf@\xbc\x9c\xb8\xa8\xbc\xc1\x14\xc6=o\xbd!;\xf6\x03\xea\xbc\xaffr=>\x9d\xb3\xbc\x0e4\xa9\x84\xbdom\xa4\xbc\xed(2\xbc+(\x0f\xbd\xf2\xa9m<\x0b\xaf3\xbd\x0f\xc7.\xbd\x9b\x127=\"\xab\xbf\xbc\xf4\x02\x88\xbd\x12\xb8^=V\xc9\x94=\xf3\xbf\xc9\xbc{\xae4\xbb2\r\x80<\xc9z\x88\xbd8\xf0\xdc<\x19\xc2\xf1<3{\x86\xbd?\xe9\x9a<{\xeb\x18\xbd\xf6\x13\x00=\t,#\xbdid\x17=\x95\xc0X\xbd\xcf\xd0\x8b\xbd\xa6\xdd=\xbd)G\x89\xbdZ\xa9B=\xa3J\xc6\xbbHVy\xaa<\x80\xf7\xfe\xbb tQ= \xaf\xf6,\xb1<\xba\xf5f=\x94\xf3r=\xba\x9c\x04\xbd*Y\x84<\xa5\xcb\xc7\xbc!\x11\xd0<\xbe\xc4\x85=\x04\tW:Q\xb4\x90=\xf8\xaa0=\x02@\x83\xbdP`\"<\xc0^\x13\xbc\x80\x1d\xfc<99#;M\xd8$<\xf6)\x95\xbbr\x86n\xbdm\xa1\xa8<\xa1<\x86\xbc\tp\x12=j\xa2\xe9\xbc\xec\x11\xa1\xbd\xfb\xa5\xe9\xbcb\xee:;H6\xb8;\x88\x00s\xbd\xe9\xf8\x9a=qU\x9d=\x89\x15\xfa<\x90.\x08=MI\xd4\xbcN\x9d(\xbc\"Q\xa7\xbd\x02\x96\x01\xbcK\x9f\xe6<26H\xbd\xd4\x87\xdd;\xc5\xa95\xbbWY\xbb\xbb)8\xf4<\x0bk\x0c\xbce}M\xbb?\x93\x91<\x18~\xe1<\x8a:\xd7\xbc$1P=N\xa3\xcb\xbc\x91g\xcf;M\x9cV\xbd\xa1\xcey<\xd5\xdb\xed<\xdeJ\x83\xba\xe4x\x08\xbc\xd4\xfb`=\xc3\xb9!\xbdkv\x0e=\x8eK\x16\xbd\x0c*\xca<\xa2\x80\xa1\xbc\xcc\x92\x8e\xbd\xf0\xae\x84<#Q\x1a=\xe1\x02\xb1\x0b=\x01|>\xbd\x9c\xb2,=b\x15\xc4\xbc\xda\x06<=\xbaU:\xbc" +HSET bikes:10032 model 'Enceladus' brand 'Velorim' price 3421 type 'Kids bikes' material 'alloy' weight 11.8 description 'These bikes pretty easy to ride while also being lightweight enough and quite durable. At this price point, you get a Shimano 105 hydraulic groupset with a RS510 crank set. The wheels have had a slight upgrade for 2022, so you\'re now getting DT Swiss R470 rims with the Formula hubs. If you\'re after a budget option, this is one of the best bikes you could get.' description_embeddings "\xd2&\xa8\xbc\xb0\xfd\xbe<\xf6nt\xbcQ\x00\x01=\x8aB\x00\xbd&\x86P=:\xdbm;E\x17\"\xbd\x7f1G=\x17\":=j\xd1 =\rV\x03;IG\x13=\x8a\x16U;\xa7u\x88=\x8f\x15\xd5\xbc_\xb9U=\x878\xc6\xbc\x9f\xf6\x1a=\xaa\x88\x86\xbdK\xbbC:\xe0\xb5\x0c\xbd\xa4\x11G= \x82e\xbd{H\x9d\xbd\xbc\xabc<\xc6\xa1o=\x87\x19\xee\xbb\x9a\xa7%\xbd\n\xc6\xe8=\xed\xc4>\xbd\xa2\xa8?\xbd6$\xe7<\xd1\x0c\x13\xbc\xbc#y\xbaa \x9b<\x9e3\x1b<\x8a\x8bD\xbc\x06v\x16\xbd\xd5\xba.\xbc\xde\xae\x9c=\x96\xf4\r\xbc-\xe5_=\xb0\xcc\xbe,\x90\xbcy\xdd\x93\xbd]\xd8><\xb1\xcf\xdd<\xf1Q\xdf\xbc\xc8\xe4e<\xbb\xc7\x86\xbd(\xee4<+\xf1\xbb;\x07\x19\xa0<$D\x93<*J/=\xbb\xe1\x0e<);\x04\xbb\xc7NR\xbd\x1aF\x8e\xbd\x96\x80&<\xac \x0f=\xfdI\x12\x05=X!c\xbcgV\x9b\xbcI\xd0Z:\x81Vt\xbd\x80\xe6 \xbdz\xb7\r\xbd\xa83\xd5<\x16|\xe6=\x9d\x01\x14=#u\xc3\xbc\xbe;\x9f\xde\xbc.|\xd4\xbcTEI\xbc~.x=\x0b\x07/\xbd\xbb\x07nV=\xe3\xd4\xa7<\xfdNA=\x14,7\xbcN\xee\xe4<:h\xe5\xbcv\xb7\xa5\xbc\x8b\xb6\x15=\x11\xcd\"\xbc\xcd\x93&\xbb\x7fjP\xbdcE\xb7=Q\xaa\xa6=t\xf5a\xbc\xaf\x8d\xa6\xbb!\x96\x88\xbc\\b\xaf\xbd\x067\xb0<^\x97)=0=!\xbdp\t\r<\xcc\x92\xb0\xbd\xf8s==\xb4\xa6\xb7<$\xe9T\xbc\xcd\x8d\xcf\xbbZ\xb4C=\xf9\x9bY;\x1d(%=\xc5\xecJ\xbc\xfbIA=\x89\xf4\xc4<\x00\x0f\xcc:\xca\xbeI\xbc\x86\x87T\xbb&\x1f\xf7\x8dH\x82;\xee\x8d\xb4\xbc\xcbj\xfd\xbc\xb8\x92]\xbdrm\xc5\xbd\xaa\xb8\x04==\x11\xf6<\x10\xe6\x02;\x9cJ\xf4\xfd>\xbd\x14\xea\x89<\xa5\xb4\x0f=\xd9K4=!j\xcc<\x96K><$\xd3\xdf<\x1a\xb8\x06<\xcaO!=\r\xb6\xdf9\xcd\xb2\x80=\xa63h=V\xcd\xa0\xbc\xcd +\xbd\xf4\xdf}<\xca\x7f\x1e<\xde?\x17\xbd\x9e;\x87<\xa5\x8a\x16\xce\xfb<7\xfc\xa7;\xae\xc1\x87<\xae\xf5 =5V/\xbds\x11\xc8;v\xfe&<\x1d\x15\xb6\xbc\xd7\xbe\n=r\xa2K\xbd\x0c\xe5E\xbd\xc1h(=\xef\x8b\x0c\xbdI\xa3l\xbd \x07\x11<:+\xf0<\xd3\xbb\x10\xe1\x1e=\xed\x83\xa1\xbdiH\xf4\xbc\x18*\x08;wRy=a\n\xf0<\x12\x12L:/\xc9\xad\xbcB\xed\xe2<\xc9=\x81;LI\xb6\xbbB\xdc\x1e\xbbEym<6\xc3\"\xbd6\x1a\xf7\xbc\x94\x03Q<\xd1H$=G1U\xbcDs0\xbd#\xe0\x96\xbc\xc6\xdb\x12<\xaa\xc5\xdf<\xc3y\x03=\x9c\xc52=\xe6\x08\r=/\x9e\x0b\xbdq\xa9\xf2\xbcJ\xc9\x16=n\xf0\x89\xbb\x00\xc14=i\xd6\x98:\xdas\xfd=\xc5j\x0e\xbclU\xc2\xbc\xd5\x7f\x1d\xbc\x0b\xd3?\xbd\xb4u\xe1\xbd\xdbGi=\xd1QU<\xc9\xd27;)/\xc1<\xc8\xb8T\xbd\xab\xba*=R\x1c \xbc\xddn\xc2\xbc:\xac\x03=\xffpP=F\x1e}=^\xcaa<5\x92\x18=\xd7\xc2q\xbd[\xd4B\xbd>\x89\x9f;\x8cx(\xbd\xdb\xbf\x16=K\x91\x80\xbd\xa3\x9f \xbdf\x12`;?\xc9\x00=\x0f\n\x12\xbd\xda\xec\xd7:\xd0\x02?\xbc_/\xb8\xbc\x12\xf7\x02\xbdN\xe3\xbb\xbc\x10\xffh\xbdr\xccV\xbd\xae\x8f\x9f=\xe4\xd9\xa8<[6R==\xc2\x9e:\x07-G=\xdc\xf4[=\xa2NR<\xf2\x8a=\xbc,\xbe\xde<\x92\xc3K\xef\xff<\xc8{\x0f\xbd\x8d\xec\xc3<\xba\x11\xb0<\t*\xde<\x12\x0b3\xbdEB\xb9<\xdb;\xef9\xc2\xfd\x98\xbc\xfe\xd1k=x\t\xf3\xbb\x82n\x89\xbc\xb7\xd3\xeb\xbch2\xfb\xbc\r!\xad\xbc\xfd\xf0\x1d=\xccW\xfe\xbcG\x9d]\xbd\x9b\xb0\xca\xbd\xbc\xb0\x82\xbd\xe4\xc2\x02\xbdh7\x86;#\x9dl\xbbq\x16\xa4\xbcH8\xa9<\xb0\xb8-\xbd\xf1pw\xba}\xb9\x9c\xbd\"\xeaE=i\xa5\xab\xbd\x03#\x15=\xdc\x9b\x03=\x88\xf4]\xbd\"\x0f\xb8\xbd\\&\xa6\xbc\xde\xf0\x07<\x81\xa2\xe5<*)\x1a=\xcc\x80\xfa<\xb2\x06\x05;\xc0\x89\x0c=\x7f\xe78<\xfbO\x98<\x04\x80a=\xaf8y=k\x1d\xb4;\xadn\xe7=%\xcd\x109\x07`2\xbd\x96\xf1\xc0\xbc\xdd\xd2\x1b\xbd\xd5^\x81\xbd\xe6I\x03=\x03\xa5\\=\xc8\x82`<\x9c\xf0-=\\\xe7\xf5;\xe8\xb5\x1e=\xa9\xdb\xa3;\xb2\xf7|<\xbc\xa9\xab;\xceY\x12\xbcz@V\xbc+\x81*=\\f\xcb\xbcfa(\xbd\xf4\xad\x18<\xde\xe4\xed;\x14\xc2\xf5<\x92\xeb\xa0\xbc3\xafG\xba\xa0@\xb6\xb8\xf6\xf3\xa4;\xda\xf7\xfe\x07\xbdU\xae\x98\xbd*\xe3\xf6<\xf9_\xb8;\xe0)H=\x7fb#:\x13\"A\xbd<\xf3\xbc\xbc*\xa3i\xbd=\x19\xbc\xbb\x8f9i9\xbb\x8c\xcc;,\x96\x0c\xbc1\x84\x12=\x938\xf9<6p\x1b=\xb70\x93\xbb\xf7\x88\x08<\t\xdaN\xbd\xe1\xff\x04\xbd\xf9\xdeq<\xc5\xfb/\xbd\xa5\xa7\xe4;\xf6\"b<\x05\xdc-;\xda\xa1\xc1\xbb\x92\x81 =A\xd3>\xbc\xc5\r\xd6<\xb6n\x1b\xbbxw\xad\xbdC\xd2\xe1\xbc\xd07*\xbdL\xe2\xa2\xe5;\xf3\xf5\x84:\x8b\x92|;\x1e\xf2\xdb< \x06\x85\xbb.\xd6]\xb9\x8dMW\xbbTh-\xbd\xbb\xfb\x86=Y\x89N\xbc\xf6\xf2\x9b\xbb\x8b\x03\x14\xbd\x1b\xa1W<\x80\x10K=\xc9\x1b\xdb=Fc<<\xec\x95\x9d\xbc\xf5n\x91<\xd4\x9c\xb3<\x98\xda\xd3<\x9e\x0f\xc0\xbc5\x08&\xbdQ\xfc\xc9\xbc3y\x81\xbcv?h\xbcJf\x9a\xbb]_\xb2\xbc\xbf\xceG\xbdb\xbc\x91\xbd\x8a\xcfW<\xb6\x81\xc1\xb2\xf2\xc4;\x9e\x82\x05=\x87\x8d\x18\xbb\xe2~9<#\xb9Y\xbd\x91\xb3j=\xb1\xb3h=s\xd9\xfd\xbc\xa6u\xc9\xbc\x1b\xc7t\xbc\x82\x0b\x06\xbdJv\xac\xbc\xd2U\xb9;\xbcy#=K\xa4\xde:\xf4\x88\xb1<\xba!\x03=\xf5\n^\xbcn?\xba<\"\xfa\x978\xcb\x19\x1f=-\xc4I\xbdH\x8a\xc3\xbc\xa1\xcek\xbd\xaeH\xc5:\xa5\xcf\xb6<\xb7\xdcs=\xee\xa5\x88\xbc\x17\x9d\xba\xbd%\xb97\xbc1z\xc2<^K\xd3;8\x1a\xcc\xbc\xf4\x97e\xbdq\x88>\xbd\x18-\x12\xbd\xf3\xd16:@8\xd7;\x0cvB<\x13\x12\x84<\xcb<\xcc\xa1\xa0\x02=\xdd\xb3\xfa<\xc2h\xcc\xbc\xc2\x08:\xbdy\x04a<\x9c\xcd\x15\xbd\xb2\x8e+\xbb7\x93\xe1\xbb\xf6\xbf\xe4\xbc\\\x0f\x0f=\xcf\xf3]\xbd\xbe\xfc\xae=\x90A\xa2=\x03z\xc8<>\x98l=\xc9_&\xbdAh\"=\x08\x15\x01=.\x98#\xbd*\xee1\xbcW%3=c\"K\xbd1\xcb4\xbd\xf7\x86\x81=\xee\xc5\x82;\xf3\xb5\xf6\xbc\x18\xbf\xcf\xbc\xb1X\x17\xbc\xc5\xefL\xbd\xa8xn\xbc\xe9\xc6\xaa=\x0eA\xa9\xbc$\xf4\x0b=\xfe\x07\x1e\xbd\xe5\x80\xe9<\xda\xea\xc5\xbc\xcf\x8f\xcf\xbcE\x82)=\xbc\x842=h5>=@_\xe98G\x83\xdc\xbck\xa8\x13<\x84s?\xbd\xa2\x83d=\xdd\xaf\\\xbd\xbf\xf2\xc4<\x9a\xf3e\xbdYtW\xbbxW\xb6\xbc\x90]\xd5=\xda{\xd6\xbc\x96\xab\xab\xbc\x07U\xc5\xbb\x1ek*\xbd\x19\x05\x1b\xbd\"F\xc7\xbc\xff\xea\x9eG\xbd5\x05\x14=8 \xb8@\xbd\\\xbaM;\x9b\x19\xe7<\xf1\xf1\x08\xbd\x7f\xfe\x13=}\xc3_\xbd\x9d\xef\xc5\x1e\x1a<\x9e\xec\x91\xbd\x97.\xdc\xbc\x8b\x1a\t\xbdJo\x01=a\xe9\xd0\xbcR(N\xbd\xb1o\xd3\xbbB\xa0\x8b\xbc\x1bl\xd5;\xbc\x13\x12=ys\x8f\xc7\xbd\xe7*F<=7\xfa\xbc`\x81y\xbc[\x02<\x05\x1c\xbc\xba\xe3d\x13\xbc \xba#\xbc\x93}\x01=\x13\xeb\x1c\xbd\x13X3<\xc2\xa8q\xbdG\xa0g\xbcu\xf1\x11\xbc\xced\xe0<\xf4-.\xbd\xf4w[\xbd\x16\xdae=\x1c\x98^=R{\xc6\xbc0\x17\x08\xbd\xba|\x82<\x0f~\xb9;\x9a(\x81<\xef\xbe\x06=7\xddB\xbd\xc5\xf8A<\x97`)\xbc\xb9\x94\x9c\x82\xbb\x8cf<=8\xe0\x1c=\xe4\xd6\xb4\xbb])^<\xab\xea%=!`\x91\xb9H\x82\\<|$-;\xb5\t\xbf<@\xc34\xbd#N\xe4\xbce\xf2d;\x06\xb8a<\xf2\x16\n\xbbUIi\xbd\x12\x02\x1a\xbcp\xe4\xda;D\xbd\x04=\x98d\x05=3Z\x00=\x05\x95\xdd<\\\xe9\xbf\xbcY\x8a\n<\xaevK=\xe3\xdaC<\x9c\xdd\x01=\xda\xf6X\xbc\x1e\xc1\n>\xe5\xdc\xa2\xba\x9f\x02\x81\xbc\xd3\x04\xce:\xa14\x00\xbdj\x07\xb6\xbd\x12\x8a\x04=\xe3H\x8b;\x8d3\x1d94\xb0\x03=\xc7pH\xbdm\xf2I\xbb*c\x90 P\xbd`l\xbb<\xbe\xbb\x89<\x13\x9b\x03\xbbu;/<\xbe\xb0k=.\xaa\x9a<\n\xe70\xbd\t]\x02=X\xabI=\x9f\x825\xbd\xe6\r+\xbd,\x14\x82\xbdk_\x96<\xe6\xbc\xc0\xbb\x1d\x13\x95=\xa6;\xf3\xbb\xde3\xe5<\xe4@e\xbd\xd3U\x00\xbd\xad\x9c\x15\xbdo\xe4\xb0\xbb\x9f\xd0\xae\xbd\xe2\xcdB<\xc26\x9b\xba\x8ee\xdd=\xf4F\xe0\x96=t\x90\x86<\xb4I\x04\xbd\xec H=`\xb5\xc9\xbc}\xab\x8e;\x0c\x9a\xe7\xbc\xa9>49\xea\x95\x10\xbc\x15V\x9d\xbc\xf70\xee;\x95\xce\x8a=\x86\xf2\x0e\xbd\x0b\\\x1d\xbdh\x1c3\xbc\xee\xd7\x0b\xbc\xa1\x8a\x11<}1\x7f\xbdy,\xf4\xa8<9E\"=\x90:9=\xef\xb3\xa4<\xd6x\xe3\xbcw\x00\x84=\xc9G\x02<\xd4\xc4\xd4\xbbt\x142<\x90Vc\xbc|\x84x\xbc\xe1\xe7\x80\xbc3\x88\xcc<\xe6rO<\x13\xaeC\xbdW\xf2\x0b\xbc}\xc4\t\xbd\x9d\xdc\x13\xbd\xb4\xb0\x8b=\xab\xf9\xde<\xd4\x917=\x8e\xa9A;\\\xa5\x93=U\x0c\xa0<3\x17\xce;z{\x90<;G\xb7\xbb\xbc\x8ab<\x9fE\x02\xbd\x85e\xcb<\x07\x10\x17\xbdHx\x96\xbc\x18\xf7&\xbd\x17\xba#;\xd2\xe5\x92\xb91.W=\xde\xbdg\xbcgMZ\xbb\x1e\xe4\x05\xbb\x16\x080\xbd\x0f\xf8f;f|\x15=c\x19|\xbc\x94\xc8\x07\xbd\x1a\xad0\xbd\x03\x0bO\xbd\xd9&,\xbdd\xe5\x84<7\xf3U\xbb\xda\x0c\xff;\xd9z\x9b\xbb\x91\x0c*<\x08>U<\xa6\x1a6<\xa9>,\xbcnL\xcbhR;\x14E\xb3=\xfb\xb5\"\xbc\x08Ca=\xfbe\x82\xbc\x96\x97\xb6;\xcd\xeb\x81\xbd\xa9qZ=C\x86\xca;\x81+\x1f\xbc<)>\xbd<\x1eu\xbc\xbc\t(\xbd\xf0`\xd6=\xa4@\xaa\xbc\x08\xeb\xc4\xbc\xd7\x91N<\xf7~\xc4\xbb\xef<5=C\xfe0<\xdd6Q\xbd,G\x0c<\xea\x04!\xbd\xe0\x1b\x99\xba\xc5\x94\x13\xbdR\x00\x9f<\x9d\x07]@\x0b=;\x18e<\x919\x08\xbc7\xdc\x87\xbdRm\xfd\xbaj\x0f\xc8<\xf3\xbbB}\xa7\xbdD\xeb\x85<\xbb\xdb\x1d=\x12?X\xbd\xed\x8fB\xbb\x8e\xe7\x00\xbda\x11\x82=s\x01d=\xbd{(\xbcS\xb4\x02\xbd]\x8a?<\x7f\xad_\xbb$(\x10;\xda\xa5\x11=\xd0p\xec<\x18\xb7\xf3<\xd7\x9b\xb5<\x91X\x03=\xf2\x05\x8b<\x8a\xdd\xc0<\x99\x9cI=\xfeB\xaa\xbd\xe1u\x99\xbb\x7f2\"=\xf4\xa0\x04=\xab\xdaM\xbc\xef\xa8D=.C%=\xcb\x80\x8e<\xad\xc8\xfb\xbb5\xd3\x0b\xbd\xfc\x9e2=\xed\xab\x82<\x04\":\xbc\xea\xf6\xe6;\x85\xb3\x96\xbc\xf1\xe4\xb5\xbc@\x8c~\xbdE\xf2\x07\xbc\x00\x8d\x91=f\\^=\xaf\xea\xd3<\xc4\xc0\xa5:\x8563\xf9b\xbc\xd8\xf3\xb1;\nye=TnP\xbc\xc3\x9dY=\xc9\x914\xbc0\x05\t=1\xae\x81\xbd~\xdfq\xbcJ2\x8b\xbb\x1a\x18/=\xf9\xd6\x80;C\xbe\xb8\xbc)>\x01\xbc\x02g{\xbbY+\x07<\xd3$\xc5\xbc\x91\xb4X<\xc3k\xf6\xbaI4\x83=rH\n=?\xd6:\xbd\x88\nd\xbb\xf4*\xd2\xbb\xaf\xff\x0f=P\x14S\xbd\rl\xd2;\xe9up\xbc\x86\xf8>\xbd\x16,\xef<\x91\x17\xc7\xbc\xbb\n\xa4\xbc\x8b\x8c\xaa\xbc`\x13\x15\xbd|\xfb\x10\xbdH<%=>\x01\x81\xbd+y\xac\xbc\xbf\xae\x15=\x1c%e=\xe1\x9d\xfc:\x05\xd6\x8d<\xf0\xa8d;\xa9\x12\x9e;%p^\xbd\xbe\n\x04=-jA\xbcqG\\=\xbc9\xed;^\x95\xd6\xa5\xbb\xb3rd\xbd4\xe1\xf9\xbb`v\xb0\xbc\xa4\xd0\x86\xbcVd)<\x81\xce \xbdK\xac_\xbd\x0eS\xde=\xde\x99=\xbb\xda\xee\xcd\xbc\xe5B9\xbd\xb9U\x02\xbd\xb7)\xe8;3N{;\xcc5B=\xaf\xd1c\xbdZ\xe3<\xbdj\xa9\xa2\xbd`\xb2y=\xfe\xb4\"\xbc\xa1x\xc0<.\xdd.\xbd\x05:\x99\xbd\xfb\x12C\xbc\xa1\x8eM\xbb|\xd2\xcb\xbc\x7f\xe0\xbd\xbc\x16\xb12=p\x11\xd8<\x9aP\x95\xbc\xfb\x87\xeb\xbbg\xe7\x0e\xbd\xe0\xd5\x8d=\xfe\xf6)\xbc\x02:?\xbcH\xf5\x04=cIb<\x8d\xee\x01=/\xa9\x07=\xceoN\xbd\x14B\r\xbd<\xd7\xf7\xbc29\x19=\x7f\xe6\x17\xbd\x92\xfa\xdf\xbb\xa7u\x81\xbdp\xf8\x00\xbc\xd5v\xb3<(\xe0\x9f\xbd\"\xc5\xc7\xbd\xb2S\x85\xbc\x1c\x0f\x1b=\xf8\xd4\x1d\xbd\xe9\\s=>\x02\x16\xbd\x83;&\xbd\xf1\x01\xf7<:\x08\xad<1\x8b2=\x85\x17\xfd\xbaS\x19\xa4<\xb4\x9c\x84<\xa0v\xb0\xbc7Q\xc09\x15=\x02Vu\xbd\x9a\xf9\xaf;\x9b\xcf>\xbdD\xe0\x98\xbb\xc5\xa4\x9f\xbc\x01}\xb1\xbc\xab=\x05\xbc\xf1$\x05=\x84\xfd\xbb\xbc\xd4{\x89<,\xc8\xa5\xbd\x01\x9d\xcf<\xecnf\xbd}\xd5\xa2\xbd\x8c2\x05<\x8a\x15b<\xf0\xd4{<+&}<\xd5&h=\xc4\xae\xbe\xba9\x06\xd0;p\r\xf2\xba\xbc\x04\xc0+\xbd\xc9w\x83\xbb]\xafC\xbdL\xb7Q\xbd{\x0c\xee;\xe1p[<\xba\xab\x9e<\xc9\xfa\x87;(\x85\xdf\xbb$\"q\xbd\xe2\xbf%\xbd\xc1\x95t=\xe7\xd91=<\xaa\xc9<\xd0\x82@<3x==\x15P3=\xf7\x8d\xba;\xe8N\t\xbd\x99\xcc\x1b=\xf5\xfb 9\x9a\xe8\x00;\xd1\x88p\xbc\xde\xfc\x83;\xa2\xec\x15\xbd}\xf5\xb6:UE^\xbc\x82\x18\x80;\xedQ\x0c\xbc\xf8N\xce\xbc\x17\x91;\xbd\xf9}d\xbc\x00\xd9\xd1\xbb\xf20c\xbc7`\x1a\xbd\x85\xa94\xe12;Jm8=\xc1\xc0s\xbc\xf2\xce\xbe:\xcdT\x9a<\xa7\x92~\xbb|\x88\xa0\xbd\x9e\x97\xe9;X\xb3\x91\xbc\xbd\x07\x8d<\xb9\xc5\xae\xba\xa6(\x97<\xbe\x030=\xce3\x89;\xa9p\x91\xbc\xec{B\xbd\xbc\xaf7=S]\x18\xbdJ`\x04\xbd\x84\r@; n\xbd\xbc]\xca\x9a\xbb\x81\xbc\xe7\xbcH\xdb\xda\xbc\xcd#\x8d=\xc90\xb0<\xb5D\x06=\x0e?\xfa<\xe5gX=\x86\xe2I\xbc\xc6\xc8\xa9\xbc=\xb8\x9a;\x1a\xe0\xae\xbc\x9f\x88\xe5\xbb`}\x8c<\xcc\x1f\x1f\xbc\xbe^\x8d\xbcm\x91-\xbd\xcdY\xc4<\x92\x95\xcb\xbc\xe9\x9a\xc8\xbd\"\x95\x01\xbda\xea\x17\xbd\x00\xbb\xc2<\x05\x0f\xf1\xba6\xf8\xad=\n\xb9\x93<\xe6\xd7\x12\xbbM4\xfc<\x0b@\n\x1e\xbbi\xa3\xc2\xba\xa7lD\xe3G\xb0;\xd5q\x84\xbc\x0c\xdc\x93<\xfc\x06\xdd<\x18Ql\xbc)3\x87\xbc\x01\xf6\xae\xbc\xec\xd2Q=\x92\xe74\xbd;d\x1d;g\xaa\x89\xbd\x0f4P<\xde\xf6\x94;\xb1\x19\xfd\xbcQ\xfcT=\x1e\x0be\xbd\x9b\xae\x17=\xa4g\x10\xbb\x89R\x01\xbd0\xc6\x81\xbd\xbey\x82<\xbe\xfd\x1f=,=\x9c\xbc{f\xa7<\x8a\xf6\x94<\xbbv\x94\xbc\x8a\xaa\xe2;\x92Z\xe6<\xf2\xba\x88\xbcC\xf5\x0f\xbd\xaa\xf4\xfe\xbc^P\x16\xbc.=\x1a\xbd\t\xc4B=/.\x9b\xbc\x86\x92\x15\xbb\x85\xec&\xbd/X\xae<\xf7\x8aA;K\x99\xa7\xbc\xa8\x9a8=\xc6\xa6\xc3\xbcL\xe5)=\xf2\xe6\xb0\xbc\xc93)\xba\xafz8<\x1a\xbf\x0c=\x96J\x8e=\x15^*\xbd\xb8\xfd\xa1\x13a\xbc\x180*=>\xb2\x93<\x17\xfd\xca<\x93\xaa \xbd\xb1$\x92;\x7fBM<\xd5\x85Y\xbb\nH\xd0\xbc? \xd7<\xcf\xaa5\xbdd&.\xbdE\xb2\x8e=p\x01V=\xd5d\x04;\x18}\x89\xbc7\x18x<\xcd\x8cf<\x882\xc5<\x1e0B=\xf0\xe87\xbd\xb7\xab\x19<\xc8JA\xbcK\xa94\xbb\x1c\xc6\x8c\xba\xa8N\xd4<\x01\xa7\x97\xbd\xdd\xc3\x04\xbd\x94P\x00\xba\xff\x88M:$\xd4\x13<\x8a\x19\xec\xbb\\\xf5\xee\x08\x96=\x96.\xd6;t\xe1\x82\xbd\xb8\xeb\xab=\xaa\x89\x81=\xb1\xa0\x93\xbc\xfc]\x9b\xbc\xf0\x00\x8f9\xf2\xc1\xaf\xbd\x16Rn<\x81\xbf\xdb<$\xe2Y\xbdh\x0eR9\xdf\xdd\x7f\xbdN]\xda<(6\xd9<\xe3~\xac;\xb2\xfa\x0c\xbd\x00\xcc\x16=\xfcO\x06\xbd\xaa\x8e\xa3<\xc0\t\xe8\xbc\xdc{x=\xdd>\x0e<\x14\x1b\x89\xbb\xf8\xb2\xac;\x99\xf9\xcb;QBe\xf4\x8d<\x8f\xe7%\xbdu\xba*\xbd\x0cA\xa1<\xabg\xf1<\x85\xc0K\xbaz]Y\xbd\xf6\xb2#;\xec\xc1\xf9<\xaf\xd3\xd9<\x80\xb3\r=\xe0\x05\xca<\x00\xc6\"=\xfb\xd11\xbc\x10#\xca\xbc{FC=\xd0\xa2y\xbbv\xcf\xf0<+&\x00\xbd\x1a2\xfc=;\xb9\x9e<#\xe34\xbc\xfaC\xbf\xbb\"$\x07\xbdx\xfb\xbd\xbda\xf8]=Em\xd9\xbb\xfb\xca[\xbd\xf2\xfcD=\xfbpe\xbd[\x8c\xb1\xbb\x1d\xae\x83;\xc2P\x86\xbd\x08\xe2-=\x01mn;\xd4\xd1*=\xc4\x1f6<\xe1\xb4\x1e=\"7l\xbd\xa0\x1e\xe6\xbc\xea\xc9\x85\xbd\xbe~d<;\xb7S=\x834\xe5\xbc+\xab*\xbc\xcc\xa5\xb7;AY\xb5\xbc3F\xab;\xde}\xb5W\xf4\xbc\xd2\xd58:-\x91\x96\xee\xbc_H\xe7\xbc\xa8x\x1b\xbd\xfbB\x0b\xbd\xa5z\x82<\xc0]\xe1\xbc\xbc\xc2\xa3<{W\xfd<\x7fkQ=/\xfb\xe1<`w\xa9=\xa2\x92\xe6\x01\xbc\xfc\x0e\x87\xba\xe2A\x95<\xc1\xcd\xec\xbc\xc4\x9bi\xbdM2\xb7<\xc3\xee{\xbc)\x0f\xb9<4\xe7\xb1\xbc\x96\x01\xb2\xbcW7\xa3;+M\xc5<\xd32\x03\xbcf\x13<\xbcvp\x92=\xba\x91~;\xff\xb5\x9d\xbc\xe4J$=\xdd\xb1\xe1<5IF=\xd5Q\xcb\xbb\x10\r\x07=a|\xdb\xbcQS\xa2\xbc\xc7\xc4\x8d=\x15\xfe-\xbd\xde\xe8\x9a<\x87b\x1a\xbd\xe5\xc2\x96\xbc\xacnq<\xed\xd4@\xbd\x17X\x1c\xbd\x7f\x1e\xad<\xdf\x10\x95\xbc_\\\xfb\xbc\xb9@\xa7<\x92\xd8s\xbd)V\xc7\xbdk5\x93\xbc\xdc}\xd9\xbcOs\xb2\xd2\xbc\x88\"{\xbd$\xb3\x1a\xbd\x88\x0ey=\xcd\xb6\xf0:\x80\x1eY<\xc9\x86\xc6<\x17\x14\x8a\xbc;\xd1v=]Ud\xbcn\xe5\x10<\x01\x07\x12\xbb)\xe0\x8c\xbbS\x0e^;0^O=|n\xd9\xbb\xa0\x8c@\xbd\xcf\xd8\xd6\xb7t\x02~\xbc\x8f?.=?DT\xbd\xc2\xf1*=\x13\xb4\x9d\xbc_\x16\x0b=\xdfaf<\xb5\x12\xdb<\xd3\x9d\x8e\xbcHn\x19\xbd\xa7\xd1_=\x88*\xd3;a\xbfN=\xa2\xec\x1c\xbdM\x02\xb5\xbb\xca\xd5\x01\xbcF4$=\x99\x8f\xee\xba:\xb2j<\xb8\xf4\x00=\xbfC/=A\xcf\xb4\xbb\x8c\xa1%\xbc\xee%\x08\xb8\x8b\xbdf\xca\x88=6\xf7\x12=\xe5\xae\xfe\xbc\x9c\xb8\xa1<\xb8\x1eT;\xa3\xdc\x8a<\xa5\x8a`<7R0<\xcaI\x06=;6(<\xd3\x0b\x99\xbc\x1eD\xac<\xe7\xa9{<\xb8\xe2\xe7\xbc\xab\xa3w=\x85Z\xd4\xbc\x1c\x08\xf2;\xa1\xfeb<\xe3FN\xbd\xe3\xf1\xa3\xbd\xde,\x17=\xcd\xab3=xD\x16\xbd\xba\xf0Q;\x8c\xe9\xcf\xbcG-\x85\xbc\x87\xee\xd2\xbc\x05<.<\x1aB\xeb\xbb\xd0\x9cU=;\xe6;=0\x1e\xa7<-1\xa8\xbc\xd5\xb3&;##\x02\xbb\x9d\x0f:={\xccc=r3F\xbd2\xf0\xe4<\xedi\x9a<\xfdi\x0c=\x82\x9fN\xbd\"\xdc:\xbda\x18\x8d\xbd\x08D$=\xc9\xd0\x10\xbc\xa78\x99\xbd*\"\xb7<\xaax!\xbc\x80>\x13O$\xbd5\xd2\xa4;<\xa8\xb7<\x9f`\xcb\xbc\x87\xbb8=\xf5\xb0\xc2\xbc\x02\x1b\xd5\xbc,\x9b`\xbd\xabHI\xbc\xa7\x1dK=\xb0\x1b\xdf\xbc\x852y=p\xcb\xad<\xe9u\x18=\xe3A\x86=\xce\x88K\xbd\xe6\xa2\xde\xbc2\xeaL\xbc>\x16#=?T\xf8\xbc\xa1\xb5\t\xbd\xbe\x18\x93=\x8e?Q=\xf4\x0e\x1b;\xe7\x83\xac\xbc\x9d\xcbY=\xae\x91\x11\xbb\x1d!\xf1<`\xe2\x16=\xce\x13G\xbd\xd7\x00\xc5;\x02\x8f\x07=;\x1f\x14=\xf5Vm<\xd0^\xba=\xe2\xa7\x9b\xbd\x8fE:;\xec,\x9d\xbbd\x05Z\xbc\x1e\x83p\xbbY\xea\xff<\x9fF\xf9<\xd8be\xbc\xfdO\xcf\xbb\xcb\xc7\x80<\x87\x85\x1a=(\x83\xe0\xbc\x82\t\x12\xbd\xd5*7\xbd^[\x05<\xf7B\xaa\xbb\xe4\x89-=\xde<\\<\xe8\"w\xbdw\xb2\x81=$\xed\x19=\x17\x920\xbd\x85K6=\x80\x014=\x90\x89\x86\xbc \x98N\xbc\xd0\x0e\xe3\xbc\xe3\xd0E\xbd\xa1_\x80<\x89YN\xbcp3M\xbdC\xae[<{\xea\x9b\xbd\x07\xd8\x05=ud\t=N\xcc[\xbc_\xa8\x1b\xbd\x97\xc2:;\x84\x95:\xbd\x9c5_=\xfd@^\xbd8\xf5\xead\x15= zy8_\xd7\x04\xbc\x822[\xbc\n\xa4\r\xbc\"(O<\\\xf0\xa5\xbc\xd0n\x0c\xbd\x99\x98\xdb<\xa8=\xe4\xbc\xe5\x0c\x01\xbcN\x81B;c\x16\x91\xbc*<(=\xe3Q\x00=\x0f\xce\xee<\xca\x88\xa6\xbd\x8d{%\xbd\xf7j\x9c\xbb*]\xaa\xbd\xe5 j;s\x8f\xbf\xbb\xe1{(=_Q_=5(O=\x1c\xe8^<1\xdd\x0e<)!\x04\xbdp\t\xf3=\xd2\xbe\xa9\xbc\xb2\x14\xd9\xbcL\xd6\xf3\xbcvh*\xbd\xcd\xe9f\xbdj\xbc\xa1;{\xf6\xe2<\xfd\x83\xd1<(\xcd\xb1;\x87Y2\xbc.\x94\xa0\xbd&\x192\xbdw\xf3\x86=l\xbe\x0c=(\x9b\xf8<\x92\x92\xa1<3\xe9:=\xd1:\xfb<\x93\x13\xfe\xba\xebd\xd5\xbc\xb9\xc1@=\x13\xa6\x05<\xc4\xa4\x07\xbc\x86\xb4\x05\xbct^^;\x8e\xb6!\xbd\x98G~\xbc\xbdg\x8d\xbc\x81.@\xbc<\xb7\xb2\xbb\xdf\xee\r\xbd\x84Ee\xbd\x02\xb6\xa0\xbc-r\x02=\xabT\xa4\xbc\xa3\xdd\x81\xbcY \xd4\xbc\xddL\xb5\xbc\xea\xe8T\xbdr\x93\x849\x1a\xf7\x0e=SL\x1f={\xff\x96<\xd4\x88\xab;\xb3\r\x95\xbc\x83\xf7\x80\xbcuO\xba\xbc{@\xda<\"\xb7\xaf\x87<\xc7\xf2\x97\xbc\xb2\x1a\x8b\xbc\xb6\x0b\x05=\xb4\x9c\x0e\xbcJ\x19\x1c\xbb\x97\xd42<#\xcc\x14\xbd\xa3a\x9c\xbc\x98V\x19\xbd\xb3\x9a\xed<\xf9y\x95=B\xfb\xf8\xb9\xd5\x9c=<\x98\xaf\x82:\x06;\xc9\xbc\x05\xc7\xc0\xba\xf1\x80\x17=\xe5\x02R\xbcr\xd3S=\xf4\xde;\xbd\xd4-/=\n9b\xbd\xe0\x8e\xe0\xbc\x00\x0e\x8b\xbb\"xY\x05\xbd|g\xee;\xc5\x9a\xd9\xbc\x0c\xf9\x13\xbcA1\xc4\xbc\xd924\xbda\x00\xa5=\xd4i#=\x9f\xe2(=\xa7\x92\xa7\xe9\xbcvu\x94\xbb\x02I\xab\xbc\x97/4=y\xfa\xba<\x1b\xa0^\xbbV\xe4\xd4<\x8cqZ<&C\xca\xbc\x1b\xf7\xb2\xbcxC\x18=\xfdC\x9e\xbc\xec\xbc\xe1\xbc\xb7\xff\x14=2\xd8\x03<\xfe\x1f\x06=e\x05\xec;\xe8/\x83\xbd\xd7\xd5\\\xbd\xe1\xec\x8a\xbc\x92\xda\x14==\x99#\xbb&\x08\x89=C\xd6m<\xc9\xcf\x03=\x10\x81\xed;Y*6=\x91\x81\xf3<\xc8\xc2\x0c=\xb8\xaa\x0b=\x84f\xe5=i\xef\x1a<\xe3\x8b\x86\xbbix\n\xbd2\x90C\xbd\x10\xc3\x02\xbd\xc1\xacC=lQ\x04\xbcY}\xbe5\x0f\xbc\x02\xfa\xa5\xbcV\x00\x1c\xbcC\xba\xbc<\xbf\x89L\xbcu\\f<1\xb2\x88=\x89i.;6r\xb3<\x89\x0b\x0c=lb\x06<\x11\xcbr\xbd$\xf5T\xbd\x14\xef\x15=\xf2I\"\xbd\x8aY\x9f\xbc\\ \x85\xbc\xb9\xbcF\xbc\xc3R\x01\xbd\xa5cY=\xb3\x9e\x8a=\xe3\x8fI\xbd" +HSET bikes:10044 model 'Europa' brand 'Tots' price 3391 type 'Commuter bikes' material 'alloy' weight 8.1 description 'This bike is the perfect commuting companion for anyone just looking to get the job done At this price point, you get a Shimano 105 hydraulic groupset with a RS510 crank set. The wheels have had a slight upgrade for 2022, so you\'re now getting DT Swiss R470 rims with the Formula hubs. Put it all together and you get a bike that helps redefine what can be done for this price.' description_embeddings "\xe7\xd4\x9d\xbcd\xe1\x8d\xbb\xbbi#\xbcf{\x06=Mk\n\xbdh,\x9d<\xe2\xd2\x1f\xbc\xca\x0b\xd0\xbc\xfc\xfds=M5\xfa<\xf6\xd8\xe4<\x14\xfa[=J\xdf];\x8b*\x11\xbc\xb4=t\xbd\xf9t[\xbd\x1a\xae\xf8<\x01\x9b>Y;\x9f\xb62=\x1d5_;\xa1!\xab8\x9c\xa5o=8\x99m\xba\xc2\x83\x0e\xbdM\xc3\x9d\xbc\xae\x1f\x8d<\x0e\xe1\xbf\xbc\xbb\xc4N=\x9d\xde\x84\xbc \xfc\xbb\xbc\x94/\xcb\xbb\xa3S\x0c\xf2\xbc\x89\xfa\xbc\xbd\x10\xb9\xb5\xf9\xbb{\xbfb\xbd\xfe\x9e\x1b=\xe6\x80C\xbb\x99R\x01<\xf8/\x8f<\x0b\x1e8=\xa3c%\xbbtb-=\t<\xba\xbc\xd0\x14~=\xe5kO9\xf7\xa5\"<~\xf4\xfd\xba(k\xa6\xbc\xa1f\x1e=\x82\x7f\xac;\x0f\x1e2\xbc\xdb^\t\xce8<9e*=!\x1b\x13\x0b\xd28\xbcU\xc1\xdc\xbc\xdc\xdb\x11\xbc\xbe\x97\x1d\xbd$\xa4\x9d\xbd\xb8\xb63=\x88IY<7><\xba\xc3\xa1D=!n&\xbd@\x96]<\xf6TB<\xda\x83\x12=[gB<\xec}\x0e<\xa5pF\xba\x81\x195=He\xab<\xfa\x0bg\xbd\xb8[9\xbd\x81\xe5\x15\xbc\xa0\xe1\xde\x90\"=\xdd+\xad<\xca\xfc\x9a<\x95)^\xbc<\xdd\xa5=\xb8z\xf4;r\xdb\x11;k\x9a\xab\xb9v\xe7\xc9;X\x8b&\xbcm\xa3\xe2:-\xd4\x10\xbd\x14%\xdd\xbc\x19\xdb\x8a\xbd\xa0\xb2!\xbd\\*\x90\xbb\xebG\x1c\xbd\xe2\xf2\xd0;\x9c\xd7I\xbd}x\xab<\x8bC\xc4;\x11 ]\xbc\xfbP@=\xc4\xcbF\xbdv-w\xbb\xa5\xb2E=\xd1 F\xbd\xb6\xb3\xa0\xbc.\xe6\xf7\xbd\x07\xdau=\x8a\x04\x05\xbc\xa7o\xb1:\xb9\x19\x8a\xbd\xd09`<\x11:\x02\xe5;\xf3\xf5\x84:\x8b\x92|;\x1e\xf2\xdb< \x06\x85\xbb.\xd6]\xb9\x8dMW\xbbTh-\xbd\xbb\xfb\x86=Y\x89N\xbc\xf6\xf2\x9b\xbb\x8b\x03\x14\xbd\x1b\xa1W<\x80\x10K=\xc9\x1b\xdb=Fc<<\xec\x95\x9d\xbc\xf5n\x91<\xd4\x9c\xb3<\x98\xda\xd3<\x9e\x0f\xc0\xbc5\x08&\xbdQ\xfc\xc9\xbc3y\x81\xbcv?h\xbcJf\x9a\xbb]_\xb2\xbc\xbf\xceG\xbdb\xbc\x91\xbd\x8a\xcfW<\xb6\x81\xc1\xb2\xf2\xc4;\x9e\x82\x05=\x87\x8d\x18\xbb\xe2~9<#\xb9Y\xbd\x91\xb3j=\xb1\xb3h=s\xd9\xfd\xbc\xa6u\xc9\xbc\x1b\xc7t\xbc\x82\x0b\x06\xbdJv\xac\xbc\xd2U\xb9;\xbcy#=K\xa4\xde:\xf4\x88\xb1<\xba!\x03=\xf5\n^\xbcn?\xba<\"\xfa\x978\xcb\x19\x1f=-\xc4I\xbdH\x8a\xc3\xbc\xa1\xcek\xbd\xaeH\xc5:\xa5\xcf\xb6<\xb7\xdcs=\xee\xa5\x88\xbc\x17\x9d\xba\xbd%\xb97\xbc1z\xc2<^K\xd3;8\x1a\xcc\xbc\xf4\x97e\xbdq\x88>\xbd\x18-\x12\xbd\xf3\xd16:@8\xd7;\x0cvB<\x13\x12\x84<\xcb<\xcc\xa1\xa0\x02=\xdd\xb3\xfa<\xc2h\xcc\xbc\xc2\x08:\xbdy\x04a<\x9c\xcd\x15\xbd\xb2\x8e+\xbb7\x93\xe1\xbb\xf6\xbf\xe4\xbc\\\x0f\x0f=\xcf\xf3]\xbd\xbe\xfc\xae=\x90A\xa2=\x03z\xc8<>\x98l=\xc9_&\xbdAh\"=\x08\x15\x01=.\x98#\xbd*\xee1\xbcW%3=c\"K\xbd1\xcb4\xbd\xf7\x86\x81=\xee\xc5\x82;\xf3\xb5\xf6\xbc\x18\xbf\xcf\xbc\xb1X\x17\xbc\xc5\xefL\xbd\xa8xn\xbc\xe9\xc6\xaa=\x0eA\xa9\xbc$\xf4\x0b=\xfe\x07\x1e\xbd\xe5\x80\xe9<\xda\xea\xc5\xbc\xcf\x8f\xcf\xbcE\x82)=\xbc\x842=h5>=@_\xe98G\x83\xdc\xbck\xa8\x13<\x84s?\xbd\xa2\x83d=\xdd\xaf\\\xbd\xbf\xf2\xc4<\x9a\xf3e\xbdYtW\x80\x90=\x9da\xb5\xba3\x94F\xbd\"\xda\xe0;L\xe9w=\xd7a\x94<\xb4\xee\\=\xdfN\xc3\xbct\xe2==N\x0c\x87\xbc\xcce\xbe\xbc\x04\xc99\xbdxH\xab<\xe5\xba\xe7\xbc\x0c(@<\xadf\x0f\xbd\x1d22\xbd\x06 4\xbc\xa3G==4\x95W\xbb\xdd&G\xbc\xa1\xd2\xd5=/\xaf\x97\xbbF\xca\xd5\xbc\x9awO=RC\xcd<\x10\xe2\r=\x0f4\xff\xbcql\x16=\xea\x87\x0c\xbd\x86\xde\xaf\xbcL\x868<\xdb/\x92=\x0ev\x01=\x81\x16b<\x82\x7f\xea<\x08k-<>}\xe1\xbc\x90OP=\x18\x06\xa6<\xc5\x17\xb0\xbc\xe2\xdc\x19\xbbT\x82\xb3\xbb\x97\x12\xf4\xbcB\x1f\xd4\xbc/\x11\xa1<\x93U|X\\\xbc>\xa8\x81=O\x8a\x14\xbc\xcc\xd6\x82X\x9b\xbd\xda\\\xad<\xce\x80\xa7\xbd,r\xa7<\x18`\x0b;E\xd6\x10\xbc\x0b\xbe\x14\xbc\x1f2\x8c\xbc\x99\xe3\x94=a\x08\x80=\xffo\x10;\xe8c)=\x8f\x01\"\xbd\xf3J\x95\xbc\xc7\x85H<\xea,K\xbc~<%\xbd\x9b\x96\x14\xbd%\xddn=|[\x97\xbc$j\xf2<\xeb\xd5+\xbd&\xfa\x85\xdd\x14\xbdJ\xc6\xdf<\x84\x05\x91=\rs\x8c;\xd6\xd8V<\x84\x9dD\xbc\xc5\xadp\xbc\xd2Q0<\x9a4\x18<\xea_M=2\xac\xe5\xba\xe6\x18\x9a\xbcF\xd3\xc4<6{|\xd3\xbcK\x02\x06\xbc\x81\xda\x1f\xbdS\x0e?\xbc\x8e\x94l\xbd>\xcd+=\xfd\xf0\x98;zn\x13\xbdg\xa1\x0f=\xe8\\\x8b\xbd{\xef\xf7\xbc\x81\xc7\x1a\xbd85\x87\xbdE\xb6\xaf\xbd\x94\xae\x01<\xb3\xb6O;\x8b\x94R\xbc\xde\x1c\xef\xbc\x97\xdcE\xbd%\x0c\xb6\xbc\xe5\xa2\x1e\xbd\x89%\xd4<$\x13\x0e=\x046\x8e=\x03l*\xbd\x13f\xb3<\xdb\x88\x16\xbd\x12\x90y\xbd\xe7\x92\x84=\x86\xca\x93\xbcq\x1eE\xdd;=\x1fD]\xbd8\x13\xb9\xbc%3\xd5<\xe1n\xd4;\xa3\xeb\x89\xbd\t\x9d\x9a\xbbe\xee>=[\xa9\x1a:\xfc\x85\x1b<\xbe`~\xfa\x9a\xbc9\xc3>\xbb\x81\xaf\xc9\xbd\xb1\xe1\x9a:\x89\x7f\x9a\xd8<\xe9\x1e\x80<\xae\xcc\xb7\xbc|\x85\x9c\xbb\x9f\x81\x90;\xde\xae\xc5;\xf5C\x14=}\xe2\x83\xbb\x11\xb4l\xbd\xfd\x1f\xf0\xbc\x94\xb5\xbc;\xfb\xb8Y;I\x1e6=OB\xd4\xba\xb6\x188=P\x8b\t:\xa8\xe4\x17\xbd\x93!7=\x89\xd8\xa2=\x16Pn\xbc\x8e\xd7\x83Y\x90\xbb\xe7R\xe1<0\xaa\x81\xbb\x83\xff\x15\xbb8\x08H=\xca;\x00\xbb|\x946=\x10\x1e&=U\x8b\\<\x1d\x1a\x87=1\xeb\xb1<\xb7z\xd3\xbcj ?<\x0c\x9da\xb9&p\xd5\xbc\xb5\xd2W\xbd\xb9\xb8\xf3\xbb\xfbE\x90<\x89\xc2\xe4<\xcc\xe9\xce=\n\xf5\x1b\xbc\x9cC\xf5<\x87\xa8\xa8<\xd8\xe9\xc9<\x9b\xd3\xc0\xbc\x96M2\xbcQH\xc2\xbc\x9aq\xa0\xbc\xd6\xbc\x82\xbda\xdcF\xbdJ\x18w\xbbk0\xbc\xbb\xb7u\x05=\x96\x9c\xaa\xbc\x8c\x92\xd9\xbcB*&\xbd\xbd\x9dZ\xbdU\xebn;}\xc9\r\xbd\xd9n\x90\xbc]r\x13=\x92\xfb\x02\xbdyV\xfd=\x87\xe9\x1d\xbd\xf5\xc2s\xbc\xa0\xf2\xf5;=ca=9d\xf6\xbbs\x9e\x8f\xbb\xf5}\xb8<%v\xdc\xbcQ\xad\xad=VR\x10\xbdq(0<\x07\x18\xd3\xbcv\x82\xb9\xbbH5\x92=W\xa9\xad\xbcY\x00\xed<\xb2\x1d\x15\xbdWO\xa6:\x19 O<\xb5\x8e\xbe;\xc2t\xd2\xbc\x00;\x86=?g\x19\xbc\x9e\"i;\x1fP\xcb<\xda/\xb6<\r,\xdb\xbcpE\xdf;\x18\xf8_\xbb\xedc\x1e=~\t\xf8;\x91\xdc:\xbdI=(\xbd&5\xa6\xbb\x89\x93$\xbb\x16K\xad\xbcGy\xa9\xbd}\xec\xec\xbcd\x1c\x91=\xc6\x86v\xb8\x00\xc9 \xbd\r\x8a\xe3\xbaz\xcd\xcd\xbc\xc0v\x01=\x9c!\x7f\xbb\xf1\xe1\xb1<\xe9\xf1o\xbd\x02J\xc9<\xa5\xfd\x88\xbc\xbe\xec\x85=\x90\xb5\x9b\xbbq\xaf\x14=\x01w#\xbd2\x03\xfa\xbbI\xa1k\xbdOFS=\x8b2\x18\xbci\x8c\x05\xbd\nx.\xbcO\xb4\x07=\xe8\xa3\x0b=\xcc\x16S\xbd\xe2\x00\x0e=W\xec\x94=\xbe\"g\xbc\x83t)\xbcBF\xd2<\xb0\x04\xbd<\xa0\xa2\x9a;\x05x\xac=\xa3F\x81\xbc\xdf\x11n<\xa8t\x11\xbbn\xdb\x82=\x9eX\xc1\xbc\xfc\xde\x11\xbd4}\xad:w\xdbO\xbd\x05\x86%\xbd\x0c\xf6\xf5\xbb\xfaG\xa7\xbd\x8b\xd4\xe3;\x04h\x9d\xbbF\x156=\x95\x9b>=3\xe2C;\xfbL\xdd\xbc\xcb\xc0|\xbc\xa9\xec\x05=\x9c\"\xa0\xbdVc\xbc\xbclC\xc7;[\xd3\x04=\xd5B\xe5<\xef@\x11<[\xdf\x0c=\xc3#\xd4;=\xd57\xbc4\x1a\xa9\xbc\xbb\xaa(=\xbd\x0c\xa3\xbc\x12\x80?\xbc\x16\xec{\xbc\"\xe25;\x15*\x06<\xc0Y\xc1\xbc?PR=)\xdbB\xbc\xe5\xa3\x02=c\xe2\xec<\xffu\xd6\xbc\x8fL\x88<\xa5\"\xa1=b<\xf6=\x929\xe6\xbc\xc4E\xfc:\x88\xd4\xed\xbc\xef\x07\x1e\xbdC\xa3\xd7\xbc+\xfc\xdc=7\xe9M\xbd\xbb\xea\x89\xbc:\x8b\x1d\xbd\x009n=\x9e)\xda\xbb:\x11_=]\x85\xea\xbcd\x03\xac\xbc\x84\x80\xaa<\xc1/\xcb\xbc\x19\xc35\xbc\x83r\x01=\xbbO5\xbd\xfe\x04\x04=]f\x04=`[\xce\xbaEH\xcc<_%x;\x1c\xb5\x7f\xbd\x14\x1a\xf2;\x8e\xff\xb1\xbb\xd6\xf3/\xbd}\x08\x08\xbdR\x10\xdc<\xd8yN=(\xda\xcc;\xdc,\r\xbdD_~\xbd\xf4\xed><\x80#\x05=g\x05\x99\xbcQ}\x97<\xc5y\xc8;\x14\xc3\x1d\xbdy\x9e\xae\xbd\xaff\xae\xbc7\xf9\xc1=\xbc\x89$=\xb7\xfa\x92:\xc5@\x85\xbd\xa7\xc3Z\xbc\xcdEm\xbd!d\\\xbc`\xd8\xe6<\x0e\xa2\x04\xbc\x04\x14\xa4\xb9\xdf\xf6\x00=\xc8\xb6\xb4<\xe64\x89;\xc5~\x8a\xbd\xc7\xaf8=\xe6o\x1e\xbd4\xea\n\xbd%\xd8p\xbbI\xb1\x9c\xbd\x84\xe6\x02=\xc4\xb9\xf0\xbcZ\xcd\x97\xf7u9\x8d\xb3\xfeH<\xc4\x1b\x01\xbd\xdc\x10)<\xddow<\x1a\x86!=\xa4\t\xb5<\xa1\x7fZ\xbc\x9fB{\xb9\x15\x8be;\xbc\x14\xfb;\x1cS\xba;\x9b\x0c\xb5:\x0b\x8d]\xbc\x9d\xb8\x1e:\t\x9d\n\xbd\x1e\xaf.=8\xf2\xaa\xbc\x02\xaa\x0f;\xb8\xd0H=4\xbb\x9b=\x9e\x97\xc4\xbb\xa0\x87\xa1;m\x13]\xbcB)\xb7<\n\xa5\x13\xbbk\\\xa7<\xef\xef\xa9<\xba\x86\x1c=h`\x83=\xfb\xf1g;\xb9w\x1d=\xf7p}\xbd\xe0JC:\x89\xfb\x88=\xf2\xa15\xbd^\x1d\xb5\xbc\xd2\x10\x8d\xbd\xde\"\x81<\xe1\xcb\xfe\xbbI\x98\xd9=N\xe9\xac\xbc\xc2HE=@0t\xbd\xf5\xd2)<\xb9\xbd\x8c\xbch\x06f\x86\x8b=\x0ep\xdf\xbc\xcf\xce\xf8\xbd\x07u\xcc\xbc\x9a\xaf\x01=\xd4\xe0\x02\xbdX@\xce\xbc\xa6f\xb8\xbc\x97\xe4\x94< {P<\x13\t\x18=\xb8\xcb\x00=,Z\x83\xbc\xe7uS;{\x95\xe8\xbb\xf6\xb8R<&\xba\x83;\x1bE\x04\xbdB6\x95\xbc\xe3q\xc8<\xf2\x8f%\xbcm\"\xa3\xbdO\"\xa0<\x89Q\x9a\xbc\xb4PP\xbc\x17{{=&\xa0\xee=n\\\xf5<\x1d~l<\x180?\xbd\t\xd1\xd2\xbc\xdam*=\xc4@);\x08E\n=\xae\xf8\xdc\xbc\xfbpW\xbd\xf2\xb5\xb1\xbc\xc6\x91\xa9\xbab\xc6\x96<\x9e-\x99=\xe4@\x1b;\xe6u\xbf\xbb\xedd};4y\x1c\xbcB\xe6\x1f<\xb7\x0f\xe9\xbc\xe2c\xc8\xbd\x96\x15\t\xbc\x9bh\x0e\xbd;\xc7f=U\xa4K\xbd)\x1c\x9d\xbc\xddC(=I\x17\t<\x98\xc1\x8d\xbc\xea)\xb3=\\)Z=\xd8e\xe4\xbbz\x9e)\xbc\xe3PM=Q9\xdb\xbd\x1a\x19&<.z|\xc2\x08\xbd\x17\x85\x01<\xce\x97%\xbd\xe32%\xbd:\xa9\x93<\xd8j\x00\xbd\x08\x83\xdd=B\x13\xad\xbc\x83\xd39<\x026!<\x90c\x97=<\x85\x06\xbd\n\x08\x06;L.\xe4<\xad\t\xa2\xbc\x9d\"\xa7=\x07\xc2\x88\xbd\x15\x8a\xfc;\xa6{^\xbc\xb0y\x08\xbd\xc1\x1a#=\xd8^\x0f\xbd\x14k\xa4<8\x15#\xbdW\xc8\xb2\xbc2 \xdd<,\xca\x87\xbc\n\xc2U\xbc\xd7\xd8\n=\xe3\xed\x85\xbb\xe0Z\xf0\xa5\x16\xbd\xa4tI\xbcc\xff\xe8<\xa4[\xe5\xbc?\x9d\xd0\xbc\xa6\xce\xe0<>\xd8\x08=\xdd\xf2\xe1<\xad\x9e\x05<\xf0\x07\xb2\x86\xbdCi.\xbd\xe1\x87\xd8=\x8dg\x81=\xd3)[\xbba\xd2\x9f\x0f\xdb<\t_O\xbd?{~<\x1c v=bQ\"\xbc\xc2\xf5\x16\xbd\xd6w\x8b<\xa9\xa6\\<\xd6\xd1\xde<\xb5\xce\x1a\xbd\xa7jx;xq\x13=i\xa3%=\x02\xff\xac\xbc\xd4\xbag\xbcS\x8e?\xbc\xe8\xf3\x98\xbc\xd4\x89\xcb\xbdxt\xf4\xbc4\x00\x81=\x00\x1fN=\x19%e;%\x19\x86<\xc9\xa8~<\xdb\x1a\xff\xbc\"\xedU\xbd\x1a\xef\x94\xbcmO\xbf\xbc\x9c\x0es<\x1b\xd8\xde;\xa4\xc1s<>\x1f\r\xbcE\x933\xbc\xa97\x91<\xe5\x04\x89\xbdu\x12R\xbd\xee\xbb\xed\xbc\xa0B\xf0\xbc3\x05\x18=\x84\xcd[\x95\xbd\x9d\xf4\xbfD\x9b\xbc(\xc6w=\x1f\x95\n\xbc\xb8\x07\x07\xbbnr(\xbckh%\xbb\xf0\x1c7<\xf5\xc6,\xbddk\x07<\xf4\xf4\xe6\xbb\xb3\x8d\x9f<\r\xd0M=7\x9dI=\x89@\x82\xbb9s\xc7<\x00\xa8\x91<\x8a*\x06\xbd\xca\xc0\xba<\xf7C\xca;\xeb\x9f\xa1\xbbk\xb1$\xbc" +HSET bikes:10050 model 'Enceladus' brand 'Nord' price 2298 type 'Kids mountain bikes' material 'full-carbon' weight 14.0 description 'This bike gives kids aged six years and older a durable and uberlight mountain bike for their first experience on tracks and easy cruising through forests and fields. The carefully crafted 50-34 tooth chainset and 11-32 tooth cassette give an easy-on-the-legs bottom gear for climbing, and the high-quality Vittoria Zaffiro tires give balance and grip.It includes a low-step frame , our memory foam seat, bump-resistant shocks and conveniently placed thumb throttle. This is the bike for the rider who wants trail manners with low fuss ownership.' description_embeddings "FY\xf4\xbcf\x87[=\xa8\xb6\n\xbdr\xce\r=\xbd\xb7\xb1\xbc\xfc$\xe3\xc04=B\x8a\x01=\xda9\x9b<\xd5\xd0;\xbdm\x00\xff<\xea\t1\x98<\xec6\x80\xbc\xe1\\5=\xf97\xc7\xbc\xad3r<^\x9dj\xbd\x10\xa9\x16\xbd\r~0=\x84\x98N\xbd\xa2Q\x0f\xbb\x15]\xcc\xbb\x1cu\xc5\xbb\x18\xb7\x1e\xbd\x01\xf8_\xd1\x05\xbaS\xc6\xde\xbcVS<=(I\xcb<\x1f5\x85<\x1e\xe4\xfb;j>\xe7\xbd\xf1\xf6\xea<\x8a\"\xb9<\xfc!\x8e\xbd\x913\xe3\xbc\x9coh=\xd9\x8cw\xbc\x0f\xba\x02=u\x81\xd0\xbc\xac\x01\xd9q\x0ct=\xfb\x85\x9c<)\xe4 \xbd\xd7\xdb\x1e\xbc\xaa\x12*\xbc8+\x92\xbd\xd8\xb6J=\x12\x8cu\xb9\x1b\x7fD\xbd;t\xc1=\xda\x95\x08\xbc\xb4\xc8J\xbd\x88\x1d\x0b\xbb\x1f\xf7\x80\xbc%Xn=Z\x9d\x88\xbc\xc5;\x0f\xbd32h9\x8b\x08\xc3<5;\xb9=\x8cp\x14\xb9[\x1d\x1f=\xf9 \x1a=\xd4\xa9m=\xd0\xb6\xd9:\xcc\xbb\x1f8\xfc<\xc1\x05\x0f\xbf<7y\xbb\xbc\n\x8c\xcc\xbc\xf0\x0c\x1f\xbdB\xf6\xa0=\xb7\x93n\xbd\xe2;\xa6<\x1eMO\xbd6\xb1K<\xa4k\x8e\xbc=\x8a\x07=\x14\x08\x03\xbd\xb2/\x03\xbd\xb9\xf3\x81=cK\x05<\xa0\x99L\xbd|\xd4\n=\x87\xa1\xd0;\xec\xd0\xc2\xbb\xbe\x1bu=\"\xec\xe4:\x8c\xccH=\xc3\x0b\x0e=\x98\xee\x80\xbd\xd0\xe4!<\x0b\xd3\xc8\xbb\"\x121=\xbc\x9f&\xbd\\\x8bw\xbc\xaaF\x91<\xf0\x15q\xbc\x92\x84g=\x9f\xe4\x17\xbd\xea\x1b\x02\xba>\x14\xc4\xbc\xf3\x80\x90\xbc\xbb\x0fc\xbd\x1a\x14\x14=\x94\\\x95\xbc\xe8R\xa7\xbd\x87=\x05=\x0b\x00\x80=\xc0\xdfN<5\x0c\x8b;\x96\xbf\xc3\xbch\xd1\xec\xbc0\x10\x1c\xbd\xba\xaf\xec=\xd1{\x08=\xd0\xf4\x19\xbd\xf4\xd2\xa0\xbc\xc2\x16\x8e\xbd\x12d\x7f=G\xc2\xce<\xf8\xd6j=\xfc2\x10<\xd7\xc7\x1e=\x1b\x80o\xbd\x8d\xb9\xe6\xbagE\x04\xbc\xe1#\x00<*#Z\xbc\xa0-j\xbdUs\xc7\xbb\x0c\x9f\xc7\xbaA/+<\xd1e\x98\xbc\x92n\xbf\xbc>\xe0}=XZ\xb0;V\xed)\xbd\xdc\xd92:\x91JB\xbd\xb4\xdb\xe6<\xc6I><\x8d\x8d*<\xb3\xef\xeb\xbcj\xd2;=\xfa\xa6\xcd\xbdT\xee\x81\xbdC\xb6q<`\xe5\xed<\xe4m\x0b<\x97\x06+\xbd\x13\xd1h\xbdU\xd5F=\x18\x1e\x8b\xbc;\x90\x02<\x19\xa6\xbe<\x98\xfa\xf0<=\xcd\xef\xbcx|[\xbc\x99\x8c\xb1=K\"\x82\xbb\xa0S\xe4\xbbgn\x96\xbb}9\n<\xf9L^\xbc\xabv\x1b=\xf1\xda\x90;\xd4\x8a\n\xbc0\xe8/\xbd\x9d\xe6\x8a=\xfcl\xb6\xbc\xcfG6\xbcc7\xb3\xbcu\"\x8c\xbc\x9fT)\xbdQt\x85\xc2\x00<\xb8\xb0\xc4\xa9;\xfb\xa0\xb8\xbc,$j\xbc\x93\xba[\xbd\x1d|\xbd\xbd\xb6\xc0\x15=\xa4\x9c\xe5<\x84\xdb\x95;\xaa\xf5\x08=\xa0^_\xbdd\x1f~<\x0b\x86\xd1<\x0c\xaa\x18=\x84\xd4\x98<\x03\x01\x90:\xce\xbb\x9b\xbc\xbc\x90@=\xa7\xf4\xf2\xbb\xbc\x15\x92\xbd\xe8\xd4\xbc\xbc\xd5\x0c\x86\xbc(\xcc\xa8<5\xdc\x82;u\x0b?=\xdd\x17\xf2\xbc5\xc1\x05=\x90\x028=\xeaE\xdc;_\x98b\xbc\xba\xd1\x8a\xbcN\x85W=\xcdq\x1d\xbb\xb8\xb93=\x81\x1e\xb7\xbc0\x96D;Q8\x1b\xbd\x16\xd6E=+\xbfh\xbdx\x03U<\x92v!=z#>=\xb7\r\xe7<\xaeE\x8f\xbd\xcf^\x83<\x99\x07\xe0\xbc\x15\xa8\xc9<\xed\xfaW\xb9\xa2\xbd\xae\x1c!\xbd\xd0\x83\xcf\xbc\x85\xedT<\x0b\"\xf9\xbc`\xc0\xa3\xee<\xa0\x0b\x85\xbc" +HSET bikes:10052 model 'Ganymede' brand 'Breakout' price 4938 type 'Enduro bikes' material 'full-carbon' weight 16.5 description 'This bike fills the space between a pure XC race bike, and a trail bike. It is light, with shorter travel (115mm rear and 120mm front), and quick handling. With a slick-shifting Claris gears from Shimano’s, this is a bike which doesn’t break the bank and delivers craved performance. Put it all together and you get a bike that helps redefine what can be done for this price.' description_embeddings "K?4\xbc\x8bb\xc4<\xaci\x94\xbc\xa6\x8a\x87\xf3\xa79J\xc2Y=E\xa1\x92\xbb\xbc\xbd\x0c<\xfc\xd8\xa4\xbc\xa5\xe25<\x99\xba\x15<|>r\xbbe<\x16\xbd#\x9c\xdf\xbb,]N=-4\xe5\xbc\xbf\xad\xce<\x114\xd6<\xf9n}<\xa5\xcf\xbc<\xdd\x8d\"<\xf0?\x04\xbd6\x1b,<=\xba\xce:^\xe8b\xbc)\x9e\xa5;\x1b\x85%=\xf5c\xc2\xbb\x94\x0f\x98<\xe6\xc2\x11\xbdp\x0b\x91\xbd\xef\x1e\xb9<\xb9\xdc\xdf\xbc\xd6\xd8\x01\xbd\xef\xa8\x8a\xbb\x8b6|\xb9j;\x82<\xed\x1ag=\xbe\xec\xe69Nch\tL\xe6\x88\xbbn&\xb3\xbc\xbcc\xab<]\xb9\xc1\xbai\xe3\xa1<\x87:\xcf<\xcb\xf7\x07\xbc\"\xc7\xe9\xbc\xe7\x90\xa4;\x19\x98\t\xbd\xfa\x0f\xc8<\xee\xfa*=\xdame\xbcsL|=\xfd\xe9\x84<\x12\x83\x94<\xd9\x04\x84\xbd\xbf\x98m\xbd8!\x8f\xbc\xfaZ\n=d?\xe7\xbb9$\xbb\xbao\x0c\xb1\xbcY~%\xbd!f\xcb<\xc9\xdf\xef\xbb\xa5\xbc\xa9\xbb\xfe\x9d!=\x98\n\xa6\xbc\xf6\xc7\x1b=\xb8\xc1\x0c<\xbfm\xbc\xbcX\xb7\x9a\xbd\xc2\xe0\x8c\xbd\x8d\xd7\xb4\xbdyj\xc8\xa9=X\xec\xa8:\x92\n`;j\xd4\x8e\xbb\xd4\xec<\xbcyX#=\xfa\x8a\x18:\xb7\xca\x16=6cy=\xc6M\x81;\x9a\x11/\xbd\x9dZ5\xbc{y<\xbc\xa6\xbbz\xbd\x07\xd0C\xbdp\x07\xbd\x13=\t\xd2\x92=\xbc\x80*\xbd\xed2\x96\xba\xbf\xcfr<\xe1\xe0\x8f=\xfb\xe0*\xbdx\xd6!\xbd\xb1^\xb0\xbb\xb3\xed\r=\n\xa4L;\xf8\xc8\"\xbcz`\x9c\xbc~\x18\xda:k\x94[\xbc\x9cx\xa3;\x87\x18\n\xbc\xees\xa7\xbcJ\xd7\xc4<_\xfcd`V\xbd[%\x19\xbc\xf6\xdd\x84=&\x0bi\xbd\x86\xd9n<\x8f4y<\xfdX\xcd<\xcd*\xd0\xbbx\x1e\x93\xbc\x85\xb4^=\xa3\"\x8d<\xe2u,\xbb\x97\x13\x8b\xbc\t\x0bK\xbc\xa9\xed\xcc\xbbo\x88\x8a\xbd\xd6\xc7.=S\x8e)\xbd\xe1\x02\x11\xbd^{\xbb\xbaJ\x03\x05\xbc\xcf=M\xbclB\xf8<\x81\xe5\x04\xbdu\xdaX<\xe0\xc5\xfb<\x104F\xbc\x06 ]\xbc\xd9\x02I\xbd\xe2E\xf2\xbcm\xb7U\xbc3\na<%\x94\xf6\xbcJ\xb4\xf4<\xf1\xb3g\xbd3\x86\xaf<\xe1\x94\xfe\xbc\xb2\xe0\xea\xbc~|-\xbdY\x18\xee\xbc\x83O\xcf<$\x89\xd7\xbc\xecl\xc8<\xdar\x89\xbd\xe7\xe9\xfb;\xa1\xec%\xbb\xbav\x06;\xd7\x84\xa4<\xf7\x13h\xbd\x87\x1d)\xbcI\x99A=\xa3\xb9\xa7\xbcH\xb3\x92\xbd\xdf\xb1H\xbdh\xe1\x91\xbb.\x00\xbb\xbcHa\xb3=>\xb4\x94;\xa2\x9b\xb5=\xacG\x97<\x1f\xdd<=\xde\xbc\xf7\xbc4\x15\x96\xbd^\x0b\x19=\xc0P`<\x8a\xea\xdf\xbcs\x18\xa1\xbcQ\x1d\x7f\xbc\xa7\x97\xb5\xbc\x89<\x18\xbdx\xf1:\xbc\xf8\xb1\x10<\x01\x9cN=\x9c\x9f\xa9;+g\xd7\xbc\xaao\x8a\xbdo\xf3\xa9;\x01-\xbd\xbc\x8d9\x9c\xbc\xb5\xad\x16<\x1d8\x1f=\x0b7f\xbd\xb6R\x15\xbbr\xb6\x8c\xbdn\xf6\xee\xbb\x0b\n\x10\xbd\x07\x05\xaa\xbd<\xb3\x8d\xb9\xfd\xa5~=Gl\x05=$\x1cD=\x0fG\x95=|\xed\x88\xbc\x80\x92#=9\x15\x9a<\x11\xb2W\xbdk.\xfd<\x0cSR\xbc\xb2\x83 =p\xda\x8e\xbc" +HSET bikes:10053 model 'Io' brand 'Peaknetic' price 665 type 'Kids mountain bikes' material 'aluminium' weight 13.5 description 'This bike gives kids aged six years and older a durable and uberlight mountain bike for their first experience on tracks and easy cruising through forests and fields. It has a lightweight frame and all-carbon fork, with cables routed internally. If you\'re after a budget option, this is one of the best bikes you could get.' description_embeddings "\xdcV)\xbd&\xe5r=\x93J_\xbdf(\xd6\xba\xb4\xe7B\xc8.<\r\x80\x07=\xbb\x01\x05\xbc>{^=dz#\xbc/\x8c\xe7\xbbwU =\xf2\xb9\xa9<\"\x1b\xe6<+\xbf_;\x8a\xda.=\xf7\x1f%\xbd\xa2\xda\x9a\xbcZ\xd1Y\xbc_\xad\xd2;w\xddO<\xbaq\xc8<\x9cNf=C7\x1e;\xfe\xd2\xb8<.LY\xbc\xa9\xd7\x0b=\x9b?&\xbbH\xcd\xb6\x03\xd2;U\xedC:\x01\xf18\xbd\xfbS{==\x02\xd2\xbc\xaeY\x1b=#\x14\xce\xb6\xcf\xbb~\xcb\x92<\xefi\x96;\xd5\xdeG=\xaa\x84\x02;@\xb2\xeb\xbc\xbd,\xab\xbd\xb7$\x94\xbc\x19=\xc7\xbc[L\x1c\xbd*\x9a!\xbd\x7f=0\xbbW\nM=\x97$\x84\x82Y;V\x88E=V/\xc2\xad\xbbmk\x8a;\xd1\xf0\x8b\xbbG{7\xbcMT\xe4;\xd8\x1cw=\xa0\xed\xf1=\xc0\xd4\xa3<6\x80\x9c=A\x87:\xbd\xca\xa4c<\xe7/\xdd<#\x8a\x06\xbd\xf7\x98^\xbc\x06h\xcf\xbc\x08^\x1f=\xc2\x8d\x95\xbc\xfa6\x03<\xd5\xc6h\xbd:\x82\xc6<\xe8<\xf0\xbb\xf6\x91\xb9;\xd2dH\xbc\xfe\x1ep9\xb9\xceL=-\x14\xdc;h\x8b\xca;\x89x<\xbc\n\xaci\xbc\xdf\xcb\x85;<\xfa\x98\xbc@%\xbe\xbd1<1\xbc\xcd\xadz;\x1b;\xaf\xbc%<\xb8\x9a#==\xb28<\x0e\xf8\xde\xbc\xe0\x06\xa0;jo\xf3\xbbd\xad\xe0<\x17\xc6\x08=\xe5N\xa0\xbc\xa2\xf1\x0c<``\x9a\xbc\x8e9\n=Bi\x19\xbc\xc7\x11\xd5\xbb\n\x064;o\xd8\x80\xbc\xe7\xbaD=i\x8d\\\xbd\xda;;=\xc6UK\xbd\"\x05\xb4<\x9a\xc9\xe1\xbb\x86\xd0\xd4<\xff\x8f\x07\xbd\x99\xd8\xf8\xbcQ\x03q=\xc9c\t<\xd8\x93B\xbd\xbaYV=\xba+e<\x9a\x1c\xc9\xbc\xe5\xa56=M\x84\xa3:\xc7f\xa2=X\x8f\xf6<\n:U\xbd(\x84\xeb;\x9f\x9d\xa9\xbc\x98\xfbX=Ef#\xbd\x8e\xe9\\\xd7\xcd<\xe32\xf5<\xbd[\xd1;\xcf\x91\xba\xbc\xae\xce\xd0\xbc\xa5\xd3}\xbdCm\x81\xbc29\x13=\xe4\xa4\"\xbd\x9ay_\xbd\x11\xd9F\xbd`.O=\x12\xc3\x1a=:\xa5]=V\x8c\x13=\xf1\xda==\xaa\xd7q\xbc/\x9d\x83\xbcJ\xd56\xbd\xc4\xc1j=N\xb2\x1f\xbb\xae\x07\xa1;\rY\xfb<6\x99\x04\xbd\x82\x8d\x85\xbd\xfa\xc2J\xbc\xdb\xbb\x86\xbd\t\xda\x06=\x85\xd4\xed;m\"C=\x83W\x08=V\x91\xbc=\x8c\xc5\xdb;}\xd0\x8f<\x16`\x19=}\xcc\xc3<\xe5p\x0b=\x81\x12\xdf\xbc\x1c\xed\xf3;\x18\x8d\xfa\xbc" +HSET bikes:10054 model 'Salacia' brand 'Ergonom' price 4471 type 'Kids bikes' material 'carbon' weight 7.0 description 'For shy or agressive riders, paved or dirt trails, this bike boasts kid-friendly geometry and strong quality parts at a minimal price point. With a slick-shifting Claris gears from Shimano’s, this is a bike which doesn’t break the bank and delivers craved performance. Put it all together and you get a bike that helps redefine what can be done for this price.' description_embeddings "\xe6fz<\x89\x17\t=\x8f\x9f\x91\xbcS\"\xf6L\xbd8OD\xbd\xc6\x0f\xf48\x85\x81\x91=\xf2~H\xbc\xfd\xfd\xaa\xbd\x89\xbb\xfd\xbdr\xf7-\xbd\xac\x9f\x8c\xbc.\xcf\xa3<^\x9a\xa2\xbc\x94Is\xbc\xfb\xc3)=\xb8^1\xbd\xd9\xf3\r\xbcg\x14#\xbc\xfa\xa0\xdb<\xffnY\xbdl\x00\xb8\xbb\x9e;\t\xbd\"\xbb\xcd<|\"\x91;,`\xcb<\xfa\xa8Y\xbd\xa0\xef\x90<\xa6v=\xbc\xa4\x863\xbd6p\xa5<\x17\x7f\x0b\xbd\x99\xfd\x12\xbdMY\xc2Z<\x1a\x95\x9c\xbd\xae\x13\xb5\xbc\xa1\xcb8\xbc\xcc\xd30=9\xa6*\xbd\x88##\xbd-\xa6\xad<\xfeS\x0e=\x1d\xd2Z\xbc%d\r<\xa8\x8a\x01\xbdJ\xe6\xca=SPg<\xad\xdd\x1c;5<:=\x1e\x80/=\x90\x9d;<\xcc\xdd\r=\x9a[\x9c\xbc\xae\x1d\x03\xbc\xddp\xe2\xbc\xee\xff0=\xb9\x14\xfa\xbc\xdem\x81\xbd\xcd\x13\xd3\xbc\x01\xd6\xc8<\xb80\xff\xbc\xeei.\xbdK^\xe6\xbd\x14\x1e\x93\xbc\xe8\xb2:=I[\x90\xbc\xda\x10\x87=Y\x1b!=\xad\x1d\x1d\xbb\x04=4\xf8\xa6\xbc\xe3\xcc@<\xd0\x92\xb2<\xb12\x1d\xbcJ\xf2\x81<\xedy\xbf\xbc\xa8\"\xe1\xbdp\xe5\xb7\xbb\xfb>\xb6\xbck\x15\xeb\xbcQ\x1c+=\xab\xf4\x99\xbb\xad\x11\x89<\x1e8\xac=\xf3\x17\xf5\xbbz\x17T\tv/p\xbc\x16KF\xbcW\xec-=C\n\x87=\xab\xa3Y<\x848\xe9<\xe8\x97\x07\xbdt\\\xd6\xbc\xd6\xe7!\xbc\"^\x18\xbd\x0c\x95\xbb<]h\x00=\r3\xf6\xbaH\x9fV=\xe9L\x0e\xbcK\xaf\x8d\xf1\xfc\xba]F\x1a\xbd\xa8\xbd\xda<\x18\x8d\x91<:\xa4\xa8<563y\x95;U\xfd\x17<\xf0l\x84<,\xf0K=<\xcc\x04=Bh\x06=E\x9e\xae=y{\x13\xbc\xb1\x85\x84\xbc\x8dK>\xbc\xcb!\xe1\xbc\x9d\x8c\x04\xbd2\xcc\x8d<\x04\x05\x84;[\x1b>;8\xaf\xf9<&\xf6\xc5<\x9e\xa0\x18< @\\\x12\xbdff\x0f\xbd\x845I=\xd8G\\\xbdP\x06\xfd<\x0f\x88\x1d=[\xee/<\x00\\4\xbd]\xe0\x12\xbbP\x9b7:ue\xa8\xbc\x80\x12\xec~\xbd\xfd\xd6K\xbd\xa8\x1b\xdf<\xe5\xbbD=\xf3(\x9d;Y\xe5\xf1<\x8a\xb9T<\x7f\xad\x14={\x17q<\x93\xb4\x04=9S \xbbAe\x08\xbcY\xcf\xa6<\xba\xcf(=\xf9\x86\xa9\xbc\xccT3\xbd\x13v\x02<\xfb\xc0o<\"\x96\x0f=(\xe3l\xbc\x1fs\x8b\xba\x89\x18\xb0\xbc\x05L\xf9\xbbOv\xc4M\xbd1\xac\x81\xbcg\xde\x03=\xc8\n/\xbd\xde\xd2\xd1<\x9e\xd1\\\xbd\x1d\xccJ\xbcX\xae\x0c\xbd9/e=k\xed\"\xbd\xf2\xe7\x1f\xbc\r\xfb^=_1b=\xad\xd6K:\xacg\x03=\xa5H\x95\xbc\xa8:D<=h\xa3=V\xf9\xc6\xb9\x04kn<\x92\"X=7\xd4\xd9\xbc\x1b\x00\x03=\xa5\x9e\x10\xbc\xec\xdb\xf2<4n\x10\xbd\x977.\xbc\x7f\xd8\xa4\xbb\xc7\xba\xd4\xbc\xaf\xe3{<}f\xe9\xbc\x1b/\xba\xbb\x84$O<\xe0\x96c\xbdg\xe5\x06\xbd\xf9\xb3@=pdw\xbd\x86R\xa1\xbcT{8==\xc7\xb4=^\xb0\x06\xbbP\xe6\xdb:\xe5\x95\xa6\xbb\x02(^\xbb\x0c\x03\x1e\xbd$\x1a\xd1<\xac\xfa\xea<\xd7\xce2=\xfeK\xcc\xbb~\x8d7\xbcT\x11\n\xbd\xabN\xda\xba\xf2\xb7\xc3\xbc\xf1\x0e\x01\xbd(\x9f\xf0\xbc>3\xc0\xbcH%\x1d=\x8d\xdd\x08\xbd\x05\xf0\xd9\xba\xe2\x95\xb3\xbcd\xc1F\xbd\xfa/\xd1;\xc8\x81k=N\x84\xcd<\xf5\x1d\x18<\x04c\x1f=,\x05\xe3\xbc\xd9\xd6\x80<\x88\x0cb=N\xf1\xa9<\x92\xf3\xad\xbc\xe1\x99\xa9\xbb\x94\"\x9a:\xa4\xa2J\xbcQ\xb8\x93\xbc>z\x879%\x9e\x92=\x17\xebM\xbbE\xfe~\xbb\xba\x7f\x8a\xbc\xddM\x00\xbdg\xc2\xdc\xbb;\x15\x88<`\xfa\x9f;\xd6\xb9E\xbc \xf5\x10\xbd/\x86\xbc\xe1\x96a\xbc\x8bu\xf8<;\xaf\xc7;\x8c\x0e\xc0<4#\xa4\xbc\n\x12\x9d=\\t\x96<\xe7\xc0\x14\xbd\x8a\xa9\"\xbd\xbfC\xd3;[%\x85\xbc\n\x86\xc4\xbc\xe8\xbf#=\x8d\xa5\x8a<\x1eM\xed\xbb\xcd\x0e\xd3<\xbd\x18\xf4<\xbb_\x1e\xbd\xa08\xc1<\xdc\x12\x92:\xa3\xfc\xa9<\x1dg\x8d\xbda\xfdz\xbc&\xea\x91\xbd4U\xa2;\xc4\xdf\xe7;\x0cy\x8d<\xa6\xe8\xe6\xbc7\xedE\xbc\xf5;\xce<\x1d\xce9<]\x8cT\xbc\xd1\x94j==\xfa\xf5\xbcL\xdb\xd9;\xf3C\x9e\xbd\xe6\xa4\t=1\xee\xa9<\xe2\xbc\xa5\xbc|\xc6B\xbdN\xa4\xc1<\xfd5\xed\xba\xf2\x87\x04\xbd\x063\x16\xbd\r\xe1O\xbb\xe9\xae\x10=i\x01\x08\xbd\xc44\xc5<\xff%\x1c\xbd\xfc\xaa\xac\xbc\xb6\xc2\xe0\xbc\xd15\x94\xbb8\xc8\x97\xbcf\xc3\xc6\xbcc\x14\x9e=\xa4\x80\x8b\xbcBfz\xbc`\xef?\xbd\xd2H\xd7<\x93\xfa\xbd<\x82\x1aw\xbbR^o=\xc3p\xc8<\xaa\xa8\xe4;\xb7\x17\x9a+\xbd\xe0m\xa5:$-\x1e\xbdJ+\xb5\xbc\xfb\x7fU;\xbe\xe4T;\xb2\xb2\r=\x87\xb1;\xbd\xfb\x07\x9d\xbc\x1a\x99\x8a\xbc6\x8c\x0b=,\xdf =\xb5\x8e\xb1<\xc0\xeb\xdb<%\xff9=\x0e\xbc\x02=9B\xde\xbc\xb8\xc4\x15\xbc\xba\x184\xc6\x00\xbc\x8a\x0f\xb6\xbc\xeb\x85\x80=w1\x18=~7\x82=\xd0\x99q\xbc\xf1f5=:\x96!\xbd\x1fH \xbd\x10\xeb@<\xd8\r\xc5\xb2<{\x90\x88\xbd\x9c\x7f&<&N\x88=$\x18\x19<\xf2\xe0\xd3<\xd3\x8c5=\xb2\x9c\x08\xbd\xa2U\x94\xbd\x15\xa2\xf8<\xae3\x10\xbcQs\x04\xbcQH\xf7\xb9\xb2\xd9\x86\xbc#\xe1^=\x91\xee \xa9o\"a=\xa25\xc5<]5\xec\xba\x99\xb4\xfa\xbct\x93\xab=\xc2-:\xbdSz?=]q\xba\xbb\x9f\x10%=;\xe8N<\x13\xce\xe3<\xady\xb3\xbc(\xff\xd9\xbcl+\xdf<\x05\x8e\x8c=\xfc\xfb\xb5;\xcd\xff\xdd\xbb\x84I\xba\xbb{\\\xdc\xbc\xc1\xdd\xfd<\x87\x93\xf58\xf3\xf4\x13=\x8d\xb5==\rc\xb7\xbc\xfb\xc9.\xbd\xdblk7R\xdd\xf4\xbcJaf\xbd\x83\xbd\xd5\xbc\xe8\x87V;\xc0Ph\xbd^F\xa1=~\xc5(\xbc\xb9\xedE=J\xfcD<\x8d\xaa\xa3\xbc\x1a\x1e\xd4\xbab\xab\xc4<\xde\x0eH\xbc\xbe\r\xf8;\xedsH==\x0bX=\xd8\xc0\xd4<\x1e\xa5_=\x0c*\x16=5\x19\x1d\xbc%h\x85\xbd\xe9n\xbd;5\x8cQ\xbc\x06|\xe5<\xde\xdb\xad;y\xabB\xbc\\\xf5u\xbb\xc1\x13\xf8\xbc\x05\x8e\xa6<4_\xb2=$:\x87\xbd\x91J\xf4;\x1bL\xd2\xbc\x99\xb2\x16=\x01\xa04\xbb\xcc\xa5-=dt+=\x9d6a=\xea\xc9J\xbd4\xd2\xa7<+d\xd9\xbbU\xcf\x8d<\xb3%:\xbdj\xef\x88\xbd\xfa\xa0\xfe<\xb8\xa9\xad;\xa9\x1a\x9c9A\xd2\xb4\xbc\x1b\x02f9\x19Z\xcc\xbc\xa0\x155\xbbG\x97\xfb<\xac\xd5\xab;\xe9\xd2\x05\xbd\xa5kU\x95\xbc\xe3g.\xbc\x01\xe38=s\x96\xc6=\x9c\xac\x87\xbdb,\x10=N\xf6z\xbd\x08\x82\\\xbcR\xb9\xb5\xbc\xc5\x1a\xe7:\x8b\r\x1d\xbdV\xa1\xa8=\xe2\xe7H\xbcob\xe3;\xae\xd9e<\xd1\xa5\xc7\xbc?\x99\xf4\xbc\xb0\"\xc6\xbc\x8f\xdf\x18\xbc\xfb\xe2-\xbdmG\xa5=\xb3W\xb4<\x18\xed\x8a<\xf2\x11\x8c=\xf8\x19V\xbc\xdc\x1f\xe8\x83\x07=\xc7\xe8\xd6\xbc\xb6\rO=q\xab\x85=3\xb09<\x8f%\x12<\xb1}P\xbc\xda>\xac<\x1dG\xd8;\x03\x89\xdc\xba\xb5\xf9\xc1\xbc/\xff\n\xbdjm%\xbdy\x0b\xee<\xc2\x8c\x1a\xbdA\x98\x9d\xba\xaf\x03e=\r\x16[\xbd\x99\xc8\xa0<\xdb\xa9I\xbb\x8ew#\xbd\xc3\xb9O<%\xd4\x82\xbc\xeb\x8c\xbe<\xffl\xf4\xbc5?\xee<\xf1J\xe2<\x9c]\x8e<\xfb(\x0e\xbd(3\x13=\xec\x08#<\xd7\"\xbe\xbb\xc5\n\x83=\x83\x07\x0b==\x04p\r\xbc\xba\xcaC=\xa2\xbd\xae\xbc\xe1\xdd\x01<\x14s\x18=\x9ck\xdc<\xf0\x88\"\xbdN\x19\xcc\xbc\t\xdd\x19=\xc2\xcd\xa8\xbb\x9e-h\xbb\xd2\xc9\x94\xbc\xbf\xf9o;\xdeM\xe7P\tv\x1b\xf7\xbb-5\xfd\xbc\xab,^<\xb9V8=\xf6KL<\xe4\"3=\x9b\xab\xca\xbc\xc1\xce\xa4\xbc\xc5aV<\xaf7\xf4\xbcU\xd3\x19<\xacN\xe2<\x9b8\xd2\xbb\xf1\x8cm=\x1a\xb3\x85\xbc\xdbM\xd9<-\xf3\x1a\xbd\xf7\x89\x90;\xb6\xc1\x87\xb9V\xb3\xf0<\x8a\xbd\xaf<\xcf7\x9d\xbcC\x8a\xb2\xbc1\xcb\x04\xbd\xf6\x80X*<\xddX\x9d\xbc\x8e\x83\x93\xbd\x9c\x04\x96{\x9e\xbcqc\xea\xbb\xd8\x9d\x8b\xbc7\xa5\xbd<\xecr\xb4\xbc\x89c\xa9\xbc\xbb:\xb2;q\xf6\xb1\xba\xbd7\xdf<\x87\x98\x1d=\xa6\x07w;?\xcf[<\xb1\x82\x81\xbbb\x82\x1e\xbd\xaa\x03\xc1\xbd\xc4\x8bZ=\xdcho\xbc+\x82\x9c\xbc\x0c\x96\xb4\xbb\xa5\xff\x82<\xa6k\xb9;\xc0\x0e>=\xd3{\xb1\xbc\xed\xab\xd9;\x8a\x1a\x95<([\xb7\xbc#c\xa3\xbc\xc7\xc6.\xbd\x82\xd9\xac\xbc\x10-h\xbc\x9aZ\x1e\xbc\xdd7P\xbc4\xb9\x19\xbc\xe47\xcb\xbcf\xd1J;\xcb\xf4\x08\xbd\tQz\xbd:H%\xbd\xde\xfe\x1a\xbd`z\xef<\x0f\x8c\xed\xbc\xcd%\xd8\xb9/\x0eW\xbd\xb3\xbbu<1B\x08\xbcz\xbc\x00=\x85\x81_\xbb\xc7b \xbd\x88rq\xbc\xf25\xfc<{\xff\xd9\xbc\xba\xfcz\xbd\x9cB\x19\xbd{\xbfw\xbb4\x19\x84\xbcU\xb7\x05=\xc5\x1c\x84=4w\xa4=\xb9\xa5\x05\xbc\xf7\x1d\x06=\x8aU\x17\xbdj\xd6\x81\xbc\xe0\xe7\xb1\x8a\xbbg\xcc)\xbc4\xd9\xfd\xbb\xc3^L\xbdz>7\xbd1\x01W\xbd\x13\xe8\xbc<\xabC\r\xbd\x9fI]\xbd\x83^a\xbd\xff\xad1=\xb3\xd2\xcc;#OO\xbd\xd7\x0c\x1d:(\xd1\x9a:\xdf\xa9\x1f<\x8e*0=V\xc3\x1f<\xeab\x9c<\xacg\xe6_!=[\xb7\xe5\xbc\xa0\xca\xfd\xbb3y\x0b=S~I\xbc\xec\x02E=z*\x88;\x81\xbaU=\xc3\xd8\x1c\xbd\xc2\xdb4\xbc\x0b\x11\t\xbb\x8dR\xab<\x0e\xe9\x0b\xbc\xf8\xce`\xbd\xb8\x95>\xbd1\xde\x16\xbd\xc6h\xf5\xbcl^\\=\xa6 \x14\xbc\x95\xbfU\xbd\xae\xafM=\x9d\xfb\x1b\xbd\xd1\xb1,\xbc\xee\xef\xe1\xbaX\x1d\x11=j\x95\xeb\xbc\x04\x8d\xdb=*\x88\xb5\xbd\xed=\x9d\xbcH\r\x13\xbd&\xef\x8d\xbc\x0f-?=\xf6\xf4\x92<\xbe(\x98\xbd\xf0\x1f\xd0<\xe6_\xe1<\xf0\"\xb2;\x7f5z;?\xed\t=\xab\xfa\x04\xbd\x8b\xe9\xb3<|\xfc\x84<\x0c>\xa8\xbb\xb4\xb7\x91<9{e=4x3<0\xadG\xbdw\r+=.\xeb\x95\xbb\xf6\xd9@\xbd\xa0\x8c]\xbc\xfd\xd9\x8c=\xca\xc93\xbdkl\x98=\xc4\xdc_\xbd\x85\xaf)<\x8b\xa1\x13\xbd\x96V}=\x96\xd8\xeb;\x7fV\xa0Q\x8c<\xa2\xf5w\xbc\xea\xcca<\xb0\xf1\xd2;\x7f\xa9\xa0;\x91e(:9\x13\x92\xbd\xba\xcb\xcc<\xc3\xa4\x11=@\x92\xcf\xbc\xa5\x89r;\xf5\xcdO\xbd\x8d\xec\x8b;n\xe4\x02=)\x02\xe7<&\x18]\t\x00\x18\xb4\xbc_\"\x89\xbd\xf4\xbb\xd1<\xebL\"\xbd\x91\xa9\x19:\x81fa<\xa8}\xb9\xbc\x1a\x16\xa1<|9\xf59\xb1Y\xa9;yz\\\xbdB\xdc\x90\xbd\xdf5\x01\xbd\xceR\x8a<\x91\xc6\xd8:U\x82\x8c<\xa2\xad0:o\x97S\xbcR2\x1a\xba\x12;\x9e<\xbe\xd1r\xbb\x86t\xbf=)2\xc9;\xea\xb9\n\xbd\xe2\x98\xde=4\xc2\xa4\xbcTHA\xbd$I\xa1\xbc\x1c\xaa\x99\xbb\xa2\xbaU\xbd5v0;\xf9\xf8\xb6<;\xff\xc3<<\xee\x18=\x17\x08\xc1!q\xbd\x91qp\xbb.bd\xbd\xdd\xc2\x0f\xbd\x0f\xb4\xa0=\x9d\xf9^=5@\x12\xbdv\xdd\xf6<\xd5\x97\x80;\xe3\xbaT=\xc2\x08V=\xf1\xfb\xe2:\xe3e\x03=\xb2\xd0&=\xcb\x90\x84\xbc\x0b\xd7n<\x95Z\xe1;\xd5O\x07\x81q\xbc\xd2\xceE\xbc\x9cR\xe0\xbc\xb6}\x0f\xbdU\xe6\xe3\xbcd`\x7f;\x84,;\xbcL1\xbf\xbc\xe9)\xda:\x84\t!=\x17\x0c\x08\xbc\n\xd3\"\xbd\n\xb5\x82;\x8bj\xf5\xbc\x15a\xf7\xbc\x03\x8b\x1e\xbcfv\xb5\xbc\xab\x86\x18<\xbf^\xfc<\xc6\x04`;6\x0c\x9e\xba\xb7\xd0\x06>\xf95\xb9\xbc+\xc0\x16\xbd,B\xc5<\xce\xaa\xcf\x1e\x89<\x1273\xbde\"s;\x14B\xcf<\x8d\x8cQ\xbc\x1c\x94\xa7\xbb\xcb\xf4B\xbd\x854B=^S5\xbd\x15\x19\x98<\xf22\t\xbb\xcb\xc6\xa4<9x\xf7;F\x96*\xbd\x9b\xe6\x8f\xbc\xdd\x84m=;)\xaf\xbc8\xe8p\xbd\xae\xc4\x8d\xbdyx\x14<`Va\xbd>\x95\xc4;L\x9a\xfe\xbbj^\x1a\xbdmdS=\x86g&\xbd\xca\xf7\xd1\xbc\x9b\xafA\xbd`{\x03=\xa5\xdau\xbd]_H<\xb3}\x11\xbd\x80a\r;\"\xada\xbdI\xa9\"=B\x80\xb8\xbd\x87D\xb5<\xa7\xd3\xcf\xbc\xd8k\x97\xbde\x9b\xdd\xbbbb,\xbdYu\xaa\xbb^\xd6\x1d=\x17\xab\xbd\xbc\xbeXP=Q\x0b\xd3\xbc\xeb\xb0\x8c\xbc%!{\xbb{q\xa8;Y?\x1b=\x03\xd5\xb2;30\xa2\xbcyr\x08\xbd\xe2\xb0\x86<\xa3\xa8\xb2\xbc9~\x8d\x05\xbdM\t\xc3<\xb7G\xc5\xbctn(\xbdH\x9a\xb1\xbd\xde\r9=mk\xbb;\xb0\xf0R\xbc\xc5\x81_=\xb6s\xbd=\xea\xf6i\xbb\xd0\xd5\xb7<\xa2t\xda\xbc;\xe80\xbdj\xa3N\xbc\xfe\xba\x10=\xef\n\x88\xbb\xbe\xb48=\xf6\xa3\xdd<{\xaf\xef\xb4\xbc\xda\x1f\xc2\xbd\x14\x86\xa2=\xfd\x92f<\x82\x944=\x11\x8d\x0f<\xc8\x89b=\x11\xa1\xea\xbc\x11M\xe7\xbc\xf5\x8e)\xbc\xa3>\xd7<6\x08\xfe\xbci\x942\xbdm3\xd7<\xa9)\x7f\xbc`a\x10=*\xe0)<\xc9\xe6\xe59R\xfd8=\x84)\x90\xbbA\x11\xb9\xbcb\xfd\x88<\xeap\x83\xbd4J\xde<\x13\x9a\xcd<@i1<\xf5\xf5\x85\xbd\xe8\xd9\xa9\xbc\xab\xf4\\\xbbK\xe9_N\x90\xbd=\xd5s\xbb/\xe9:\xbd\xd4}2:{\xdf\x07;\r\x86T=<\xeb\xac\xbd\xe6\x8c\xdb\xba|Qf=\xde#\x07=\xdca\x0b<\x1c$\x80=\xad\x1f\xdb\xbc\xb1\xfe\xbf\xbc(R4\xbbl\xf8\x00\xbdd$\xc1<\x9f\xe9\x9e\xbdt\x12\xaa<\x8e\xe6o\xbc\x8eB\x7f=i5\x99=\xe5\xb6\x89=\xe1\x98P<\xb9\xdbO=Y\xc0\xdd:\xc6\xb6\xb7=m\xaeB\xbc\xf3L\x86\xbc\x0bzz\xbc\x9f\xff\xa4\xbc{X\xa7\xbc\xbba\x08=[\t\x03\xbd\x99C\xc1<\xeb\xdf\x1f\xbd\\k\xc8<\xc3\xd5\x81<\x9aP\xff<~)A\xbcL\xaf\xf0\xbb\xbb\x054\xbc;\x1c\xa5\xbd \x07\x99=f^\xfc\xbc\xaf\xd1\xff\xbc\x12\x04Q\xbd]\x7f\x04:\x90\xd6R\xba\xf7?Q\xbc9\xfd\xbe\r\x1c\xbd\x7fQ9\xbc\x93\xc7\xa8=\xc1\xa31\xbc\xaf\xb0\x15\xbd`\x86\x15\xbd\x87\x95K<\x80.W\xbdm\xa4Q<\xf9\xacD\xbd\xd5\xac\x15\xbc\xf5=6=.\xbbG=\x19K%<\xa5\x835\xbd\xac\xd7m\xbd\x83\xac\xb7\xbbA\t\x8e\xbb\"`\xe5<\xd3\xdd\xdc\xbc\x9d(\x9d<\xe7zM\xbd( \n=\xde\xc5\"\xbct\xd3\x1d\xbc\xa5Wa\xbd\xa5\xdf\x1d\xbce)\xcc\xbc`\x8a#=\xedX\x93\xbc\xdeN\x81\xbd\xbe\xdcA=O\x83\r<\x8a\xafs\xbb\xa5\x0f\xa1\xbbN|\xd1;\xfc|\xb5=/ \xce;\x1b\x93\xc4\xbc\x89\x07\x02=c`\x1a=\xf42\x1a=\xf6oy<\xf8B\x05:v0\xf8\xbc*\xba\x11\xbdO\x03S=\r:{\xbb\n\xb8\x16\xbd\xae\xdd,=\xfd\xd9\x07=Lz#\xbdH\xdbS\xbdP/\xa2\xbd\xa8=\x8e:\xb1\xae\r=S\x9f\x06\xbd\x8fw\x87=\xe8\xc1L=\x048\x98\xbc\x9bLN<<\x19@:\xbf\x85\\\xbd7|\xa3\xbc\xd0\x9b\xce=\xa8\x07]\xbc\xfa\x16c\xbcT\xdc\x19\xbc\xda\"\xc0\xbb\x89\"\xf1<\xf3\x8b[\xbc\xf9\x10\x1c\xbd\x9as\xb2\xbbJ\x16\xae\xbb{\xa6\xfb\xbc\xd0x\x9b\xbd5\x99@\xbd \xc6\x199\x9a\x1c\x17\xbd\xcc\xcc\xba<\x9b3`\xbdo\x92\x06\xbd\x05\x9f\x06=\x04\xbd\x10\xbd\x19\xdb\x83\xbd\x98\xe1\x85=\xcc\xa6J=|\xe0\xcd\xbc\xb0\xed\xa1\xba\xfa\x9c\xd6<\xb3T\xb5\xbd\n\xcb\xf0<.=\x06=U\xa9\x8b\xbd\x89\xccK\xbc\xf6F\x12\xbc\x0f\xd2\r=\xf9\xc7z\x96\xf9\xbc\x1bq_\xbd\"\x01E\xbcb\xcf\x99=\x96\x93\xde:<\xd8\x8e\xbc\\\xa5\xb7\xbbi\x85Y={\xfd\x0c\xbd\xc9T\xb3<\xd9V\xac=\xb7n2\xbc\x81N\x10=!\xd3X<\x91\xc5\xcd\xbc\x1fV\x95\xbd\xb01C\xbd\x0b^\xbf;\xa8B\x15\xbd=\xe5\x04\xbd\x0cm?\xbc4\x00s\xbb\x07\xbcD[^=]\xe2\x87\xba0#\x90\xbc\xb5a\xe4;kl\xe2:\n\xf4\x83;\xcc\x94\xcd\xbc\x06\xa9\xbc\xbd\xea\xa3):\"-(\xbc\x1b\n:\xbd\x91\xf7\x9c\xd28\xccI\x05=Y\xc3\xb1<7\xdbz<\xdc\x93z;\xef \xeb\xbc\x18\x06\x9e;\xd1vr<\xf1\xaez<~\xb7^\xbct\x8aB\xbc\xe1\x1a\x9b\xbb%\xa9\x7f:\xeb\xebo=\x15\xb4]\xbb}i+\xbdv\x98\"\xbb\xa6\x17\x9e\xbc\x95\n\xab<\xf4{\x14\xbbP\xf3\xe9\xe8n\xbc{V\xe6;\xc1k\xd0<~\xd8\x06=$e\xd3\xbc\xdc5\x89<\r\xa4t=\x7f[;\xb9\xfbp\xdd<\x81$_\xc9\x85\xbd\x18(\xad:\xd2\t0\xbd\xe5\xf6\x9c\xbcL\xb7\xa7\xbc(\xaf\xd1<|4,=\x8de\xc4\xbc\xba\"\x88<\xa5\x88\n=9\xd0J\xbc\xd8\xa9E=\xd6\x0cP\xbcU\x94\x8a;w\\J=\x7fH\xaa:\xda\x1c\xc1\xbc}\xd7m\xbb\x0c\x85\xfc<\xd7\xcd\xa6\xbcU\r\xbb;\x89\xa1\x1c\xbc2}\xa9;\xbdTf\xbb\xb2z\xe8<\xf2w\xef\xbcw\xa9\x9a\xbc\xc0\xd6\xf1\xbb\x1e\xc3\xbf\xbbS\x8e\xc9\xf3\xbc{\xa2\t=\xaf\x162=\x9fS\xd8<\x99\xc6!=s\xd0\xa6\xbc\x1bf\x96\xbc\xf2;\xcb\xbb\xe0\xa7s\xbc|k\"<\x9f\x9a\x9a=\xf9\xeb\n\xbcv\x86\x93=\xa6\xda(\xbb\x880\x04=\x8f?\x96\xbd\xc8\xabj\xbc\xb6a\xe4\xbc\x8a\xec\xec<\xc9\x12{<\x183\xc0;(\xe5\xd2\xbcG\x91>\xbd\xc3\x98\xef;\x9eM\x8a\xbc\xab\x18m<\xb4a5o\xc9\xbb\xc2\xf0\x04\xbdR\xa8\x00\xbd\x11\\\xba<+<\xc0<\xbc^9<\xd9T\x9a\x96\xf4\xbb2\xad\xe0\xbbU\xea\xb6;\\`\xbb\xbcq\xcd0\xbd}5\xb8=\xd5\xbf\xc0\xbb\x89e\xf0\xbc.\xcc1=\xc9\x183\xbc\xc5\xa4\xe0\xbb\x11\x90\x11\xbdY\x91#=\x89\x96\x0c\xbd\x17\xbc*\xbd1\xc8F:\xafl\x0e\xbdn[\xcb\xbb\xb5lH=I\x8a)\xbd-\xd9\xb7<\xc4\xc0$=4\xad\x88\xbc\x12\x97\xa1\xbcO\x16\x0c\xbd\x152,\xbbLo]\xbd\xe9\xe5\xe3\xba\xaa<\xaf\xbb\xc4\xce\xdd\xbc\x8f\xbe*\xbd*\xb0\x9a\xbc\xb6\xfc\xf3\xbc\xa9k\x99\xbd[\xd5g\xbd4=\x12\xbcc\xd2\"\xbaD\xea\xa8\xbc\xb8)\xd5\xbc\xef$\x7f\xbd\x03\xdb\xef<\xa4\x12\xa1\xbc+\xeb\x13=\xe0\xac\x81\xbc9\x11s\xbd&\xe2\x8b\xbcp\xc6\x08=\xafF\xf2\xbc\x95\x83\x90\xbd-v\x10\xbd\xe7\x8d\x90\xbc\xd2\x97\x02\xbd\x97/^=\x99\xbbF=\xec>\xaa=I\"\r\xbb\xbd+1=\x89\xbd}\xbc\xd3\x91\x8e\xbd+\x84W<\xaeV\xbc\xbbP\xc8\xe0\xff\xc2;\x16~(\xbd\xb0\x1c6=\x01[\"=\xd5\x84\x89=\x07 \x1f\xbd~\xfd2=\x82\x99\x1a\xbd\xa2\x95]\xbdU\xb5\xe6\xbcz\xe2.<\xbf\x8e\xfb\xbbQ\xd2\x8a\xbd\x9a\xe1X=&\xcb\x8a<\xe7F\xc6\xbc\xea\xb8\x8d\xbc\x84d\x01=Ug\xa0<\tE\xb0\xffC\xbb`B=\xbd2\xdf\xa6\xbc\x9c\x8b\xf1:\x98\xd9\xd0\xbcx\xfbd\xbdlh+<@\xd9U=N\x05\x12\xbd\xe5f\xcc\xbc\xb0\xc5\xc6\xbc9\xad~\xbd{\xadZ;\xa3hZ\xbc\r\x97\xff<}\x8e\xab\xbd\x05\xb7/\xbbq\xd13\xbd\x83\x8f\xaf\xbc-<\xc7<\xcd\xeb?\xbc\x00{\xd0\xa8\x02=P\xea\x13=\xd2~(=\x9a\x9cb<\x94\x9a\x15\xbc$e\xc5<\xc9L\xc3;\x9fV\xce;l\xfb\xc5\xbc=\x9e\xfa<\xf6Z\xdf\xbc\xf6p\x0b\xbd\x9bP\xf4\xbc{R9\xbc.\xad\xfb:\xc9\x10\xcb<>6\x12\xbd\x854\xa4\xbdY\xfe(=.\xc7\xc7\xbc\xb4\xe1X\xbd\x87\x07\xe7=\xa3CJ\xbc\xf4N\x02\xbd\xefa,\xbd\xe3\xf4\xb7\xeb<]\x0fN\xbcV\x94\xe2<\x9e\x8c\x9f=Xf\xe8\xba\x8c\xa9\x90=r\x18D\xbc\x88,\xa9<\x9bI\xb3\xbb\x83\"\xdd<.]\x12=/\xa6\xcd;\x14\x98\x1f=\t\xea\x12\xbb\xe6\x8a\xdc:\xda\xa1\xd2\xbc\xac\xe7\x88\xbc\xbay\xc9\xbd=\xd3\x8a\xbdeT\xbe\xbc\xf1\x13\x86\xbb\xe8\xd1B=5\xcbt<\x0e\x13&=\x8dr\xb5;\x14\x90\xb3\xbc,o\xe4<\x19$;=.\xea\xa7;\x8e\x11\xb2<\x9a\x9ec=e\xbd&\xbde\x048\xbdj\x13o\xbdNz@;\xa7\x1e\x18<\x0e\xa2\x17;\x97\x02:=\x8cF\x85\xcd\xbb\x12\xb0\xc7\xbc\x961\x06\xbcK+\x82\xbdT\x08.\xbd\xd4\x9d\x8e\xbaD\xbc2\xbd\x07g\xcf\x8a]=\xf4\xc0O\xbc#\x8f\xb9\xbb+\xf2\x0b;.\x8c\xb1=\xc9\xbf \xbd)W<=\\&\t=J\xceF\xbc\xc6\xa8\x80\xbcy\xe3\xb5=\xdfC\x82\xbd\xac_:\xbc\x83F\xb8;1\xb05\xbd\xae\xdc-\xbd\t \xd0\xbcA\x9a\x8d\xbc\xbbD\xfe\xbc[O\x0e\xbd\xac\x8b\xe4\xbc\x8bw\x15<(\xd1\xcf;\x95WV=n\xda\xf3<#\xf2o\xbcZ\r\x06^<{C\x0c=\xd01\xc3\xbc+n\x07=\xfa\x82\xc0=y,\xd8:\xc1\xb1.<\xa1G\x1d\xbd+A\\\xbc\xbc\xe3\xd3;.\x9eO<\x0b3c:,P\"\xbd\xf2\x8aR\xbd\xf0\x0eP\xbd\\\x88\xc8\xbcu\x04 =\x85\xe8\x90<5\x19\x80\xbdj\x0b9\xbc\x80\x9b\xa3<>\r\x8b\xbd`F\xe5\xbch\xa0?=-r\xed<\x87\xfd=;\xc8\xa7\xda\xbc\x03\xaf\xd6\xbd\xbbR\xc1\xbd\xeb\xceB\xbd\xf8\xd4\x87\xbcS\xbe\xfa\xbb\x93U\x17\xbd\xc2c\xa9;\xf9[Z=\x16#\xfe:yus\xbd\xdb\x86;<\xdc\rH\xbdz\xf8\x1e\xbd\x08\x08\x05\xbd\xee\xc77\xbc@\x8c\x8f=\x7f\xb0\"=\xfa\xba\\\xbc\xbe\x10\x16\xbd\xfc5B<\x99\xef\x15<\n\xa2^=\xe1\x17\x12\xbd\x97\xe8\x0c=$\x144\xbd\x9f\x89\xab;\x18\xbc\x12=\xa9o\x81;\xbb\x1a$=}n\x07\xbd\x1c\x97U=\x9c\xbe_\xbc\xb5\xa5\x13<\x06d\xfe\xbc\xfc\x10\xc0<\x01\xc3/=\x15v\x01\xbd\x01;N<\xc8\x16\x0b\xbcm\xa2\x97\xbcE\x03\x93\xba\xe9\x84[=\xc6(p\xbc\x14.\x17\xbd\x91\xbd\xac\xbc\xae|\x85\xbc*\xfdC\xbd\xfb\x1d{=!\x02\xda\xbcy\xeef\xbb\xba\x9aG\xbb\x84\x7f\\<\x19\xe9*\xbbf\x0f\xa8\xbb\xb6\xe6\x0e=\x9b\x8e\xbd\xbc\xa6 \n=\xfe\xfc\x9b\xbb\xd1f\xe1\xbb\xfcA\x1b\xbd\x9f\xa8\xb1\xc4\x8d\xbdh\xa4\xde\xbbL\xb7\x1b\xbc\xfcVR=G\xf3&\xbd\x01\xb9\"\xbd\xee\xe9#\xbd\xe95\x07=\xa3\xaf\xff\xbbw\x15\xab\xbcBS\xa9\xbc|\x02\xdc\xbb\xd9\xb0\xc2\xbcB\t\x85=2\xfc.=\xf3\x8a\xde\xbcE\xeeW;)e9\xbc\xa8N\xe8\xbbQr\xab\xbc\xe2\xaf\x88=7\xb14\xbd\xf5\xab,\xbd\xc0\xe8\x07=\x93\xe4\xc4\xbb\x96\xc6b\xbd\xa8\x95/=HH\xc3\xbd\xea\x12\x01\xbd\xf3\r\xb7\xbc\x05t\x04\xbb\xa6\xc5\x08<\x9aE\xa1\xbd(4y:\x8a7\xa3\xa9\xbc\x8b`B\xbd\xbds\xcf\xbc8\t\x1a\xbd\x0fh\x84=k4\xed\xbbnP\xf0\xbb\x11\xe0\"=\x8c\x84\xcf<\xa3\x13\xb7\xbb6\x01\x13\xbb\xa3\xa9\xc2;\x8f=\x06=\x02\t\xd3\xbc\xa2\x80\x93\xbc\xdd_\"\xbd\x06\x82\xdc\xbb\x08 \x89=0T\x80\xbd_\xf5\xe2\xbc\x8ea\x81<1\x85\xa8;\xe1\xbaP\xbcFL\x14=DM\x8a\xbc1\xe6\xc8<\x8a\xf6\r=g\"\xee\xbd\x85|\xc9\xbb\xbc#!=\\\xbfR\xbd\xa5\x8c\x1c\xbc(\xc8\xf2\xbc),\x9b\xbc\x97$\x00\xbd\xbeUU=\xf3\xcbV<\x9b#\x86<\x8c\x06\xf3\xbc\xf4d\xee\xb5]\xbd}\xd0/\xbd\x07\xab,\xbd\xcc\xdbl=\x9c\xb90\xbd\xc2\x04\x87\xbd\x88Hw\xbc\x8d\x17\xb1<\x07\xea,\xbd[\x86\x1b<\xc6\xbd)=\x981L\xbd\x93f\xb8=1\x85*;\x04\x81\x82\xbd\x9d!\xbf=]\xef\x9a= g\xb1\xbc\xa6$u\xbbE\xe5\xbe\xbc\xa0r[\xbd\xe6q+=\x90\x87\x93\xbb\xc3\xa9\xe3\xbc\xd9\xd6\x81\xbc\"p\xa0\xbdQ\xcfy=\xd8\x96G=\x8cO\xbd\xbb\x01\x0b6;Mq\xab<\\D\\\xbc\x07\xd1\xfe\x88;7\x1f\x8b\xbc\"\t\t=\xa2\xc3v\xbd\xb3D\xb6\xbc\xbdS\x95\xbd:aX\xbc\xf0X\x8a=\xacN\x19=>\x1a\x01=\xf9\xfd\xb2<\xa7\xc9\x00=\xe2w\x19=f\xf2R;&~\xf9\xbcA\x7f\x88\xbc\xccv\x1d\xbc\xc2x,;i\x11C=\x9e\xb8y<\x15hb\xbd\xf5J\x0b<>\x12 \xbd\xd55\x94\xbd\x97\xf9\r\xbdY\x88\x01\xbd\x10\x0e\n=5\x11\xa0\xbc\xf1\xf4S<\x9b\xa8\xfe\xbcn\x930\xbd\xc0U\x16=\x0b\xa5p=w\xbd\xb7\xbb\xb1\to=a\xc0\x1f\xbc\x90[,\xbd\xa8(\xeb<\xe9\xe1\xc3<\xb1\x9b\xc6\xbck]\x13=\xb2r\x0f=\xd1K\xd7\xbb\xadr@=M\xbeF=%Z\x97\xd7\xd68<\x7f6\xa5\xbc\x08\x05\x19; \xb5G\xbd\xb4)y\xbd\xd7\xbdH={\xd6\x03=\x96\xed\x87\xbcd\xe9\x8c=\xdfA\x8d\xbdf0(<\xc2\x85\xa0;6T\xda<\x1ae\xe4<\x05\xa5\x88\xbcTdf\xb9{\xae\xe9;\xf8\xd0\x14\x8aH=\x8e\x1bu<\xc2\x80\x82<\xf6\x1a;<+dS=\xf6>y:\x9d\x91\xa2< n\xe3\xad:\xf9\xe4\n\xbd\x15*\xf3\xbc\xe3\x130\xbdCW\xa1;\xd1Y7=\x97\xa9R\xbcy\x81\xa0\xbb\xdd\xf0\x83\xbc_I\xd2:\xc2\xc5\x99;\x12\xb85<\x9b,\x92=\xe6\xe3\x19\xbdS\x02\x8b\xbd\xfe}\x9a\xbd\\\xd4d;\xbd`m\x19=_H\xb6<\xa4\xdd\x1e\xbd\xc3}i\xbc\xb9\x0b\xb1\xbd\xaci\x00=0\xe03\xbcG7?1\xa2;6?\xab;\xa4\xbb\x03\xbca\xd3?<\x83\xdd\x00\xbd\xdc\x8c\xc3;q\xf4#\xbd\xc0\xff\x1d<$\xbe\x81=\xbd0\xd0\xbd)}\x03\xbd\xd7\xa1J;\x9b\xe9\x15\xbd\x1e\xbf\xf6\xb9\xed\x9b\x01=\xf9\xf6\xa3\xbba\x02\xa9<\"\"%=t\x00\x97\xbd\x94`\x88\xbb\xe4:\xad<\xfb\x1a\xc7\xbc\xa4\xab\xba\xbb\xef\xdc\x8c\xbb\x99\xed\xea<\xa4\xd1\xbf\xbb\xa3\xc1\x05=\xb5\xe8\x08\xbci\xdfk=)\n\x04=\x01o\xcb\xba9\xdb\x9f\xbc\xe9\xd0o;#\x85\x1a={\x05\xa7<\t]\x04\xbd\x8e\x90I<\xf8\x80\xbf\xbc\"c\x8d<\x17\xf8)\xbc\xd6p\x11<\xdf\x87\"=$\xbc\xd2;{\x03\"\xbd,v#\xd2\xbc\xe2\xe6b\xbd\x84\xc6\xe3<\x84\\\x1a;8\xecK\xbb\x9c\xbc\xc5\xbc!\xef.\xbda\xaf7=\xee8T=\x11\xb7\xf3\xbb\xf5\xae~=1\x01o\xbc\xba,\x96\xbc\x89\xdf\xdc<\xcb\xf2\x0e=\xbdN\xbf\xbc$_O<\xfeyi=k\xeaB\xbc\xad\xa9\x86<\r\xd0\x16=\xbc\xb2,<\x1f\xfbo<\xea\x90\xd2<\xac\x17^\xbd\xdf[\xa3\xbc\xeb\x80\xc9\xce;_\x8c&\xbc\x8d\x9e.\xbd\xadV\xb4<\x18\xa9\x99={0G\xbd\x87Y\x04\xbdI\xae:=\xb4\x0c\x8e=\xc5\xdc@<\x15\x13\x91<2u\xa9\xbceb\x0e\xbd\x8d\xf5*\xbd*\x07\x05=\xbf^\x9c<\xb6\x9d~:\x94\xa8\xfb\xbb\xd9\xa2\r=\xf2\xd8\x9f<\xa4\xe1\xd6\xbc\x10\x1c\x1c=\xd6\x9b%=2\x073\xbdVzZ\xbd/\xc2p\xbd\x0f\x16\x1d=kA-\xbc\xddL\xab=\"\xb3g\xbc\x86\xeeC=\x9d\x805\xbdx4\x90\xbc\xbb\xe8S\xbc\xbb\xbc\x86\xbbGK\x9b\xbd \xa3\xc8\xbblR@\xbc[\xe0\xbe\xee\xbcqb\x98=\xd2\xa1\xc2;\x9c\x8f\x1c\xba\xf8\xca\xb9<\xa3\xda\x96<\x8d\x93i\xbc\xdc\x85@\xbc\x9a\n\x18\xbd\x8e\xcd\xa9\xbc\xda\x82Z\xbd|f\xf3\xbcUX\x03<`\xd5U\xbd\xe6\x8a\x1e<\xbd \xee\xbc\xda\xef);\xd6/}\xbcK6\xfd\xbc\xe4\x82*=*\x96\x04\xbdb\x91\xe9\xbbY@{=\xf0B\xc5\xbc\x9e\x86Q\xbbW\xb6\xce\xbd\r\x93b=l\x9dj\xbc\xc5M\xad\xbb&\xd5V\xbds\xc6o<\x1c\xac\x9c\xbcr\x9a\xe3A=O\xee\xe1<\xb1$\x17:?`Z=\xf4}\x83\xbc\x11\xb5\x1c\xbd\xe7\x01\xae;Nj:;\x05\xadG=b\xaa\xd6\xbb\xc4\x13(=O\x84+=h\xc4U\xbco\xb9\x19=AG\xac;_s\xb2\xbc\xe9\xc3T\xbc\xf7\x9e\x98=0=\x86<\xa5j\x93\xbc\xef#\xd8<\x83\xc9\x87\xbd:\xaa\xe7;\xc0\xbcV<\xb6\x01u=\xeePs\xbc\xb32\x82<(\\\xcd\xbc\x1b\xd6n\xbdR\xfcc\xbc+\x1c,<\x82c\xe0\xbb\x95\x10\xd0\xbb\xa8\xdc\xe9\xbc\x02If\xbd%\xabc\xbc\xd9\x89\x18=9\x0bY;\xbe\xac\xb5\xbby\xbd7;P\xdb\xbd\xbb\x9f\x18?<\xf0\x94@;\xe6\xfd$:\xe1O\xe9<\xfb\x85\x94<~\xeb\x1d<.\x99\xff\xbc\xd1\x88\x1b;\x8cL\xa5<\xa0B\x94<\xc5\xaf\xa1\xbcUs\xb2<\x83\xf0\xb9\xbc\xf5\t\x95\xbc\xa9\xf2\x08:\x81\xbe\x16=,\xfar=R\xc9\xca\xbc-2z=LP\xa4\xbc\xcbq\xf0\xbc/\x00\xd4(t;\x01\x06\x83<\x89~5\xbd\xe2\x93\xa1<\x10\x15\x08\xbd\x88\xc9O<[~3=\x18\x0bF\xbc\x9f\xd0!=Ch\xe5\xbc\xae\xbe\xa0\xb7\x029\x12;\xcca5\xb9$\xc6\x0f=F\xaa\xa8\xbbH\xefa\xbb)\"g<\xf0\xab\xb2\xbb\xe4\xf1\x08\xbd\x84\x02U\xbcI\x1f@;3\xaf\x02\xbd\xfb\xe4\x8d<\xc0N\x88<\xe42\x12\xbd\xb7\xbdF\xbb\x7f\xb4\xe4\xbb_\xfd\xa6\xbc\xe5Fd\xbd\x1a\x0b\x15=W\xc6\xa5;\x8fM*\xbbfBq\xbc\xceR\xd4=\xd0\xd5\xbc\xbcR\x91\"=\xf1\x83\x1e\xbd\xf9\xac\x12\xbc7\x0b\x15\xbd\xcc\x80<=\n\xed\t=\xbc{=\xbc\xb7kp\xbd\xb8{\xab\xbcrUO\xbd\xd7\x85^=\xac\xa3\x03\xbd\xd8\x95\x8b\xbb\xac5\x87\xbc\x0eD\x1c\xbc\xe5\x94^=\xa1\x80s<\xa3\x126\xbd?:\x13\x14\x81=\xf6\x1b\xf1:\xf5[d=\xf3d\xd9;3\xb5]<\xd3[\xa2\xbd\xf3\xfe\x8e\xbc\x19\x81\x9f\xbc^\xcc\x9e<\x1bI\x8c\xbc\xce<\x8a\xbc\x13\xaa\x92\xbb\xb1\xdc\x17\xbd\x0b\xa8\xee\xbb\xdc\"\x02\xbdS\x14y<\x93g\x1b<\xb5{\x91\xbc\xb2@@\xbb\x98\xf8\t\xbd\xf3\xb3\x01=\x99\x0c\x14\xbdf$x\xbd,5\xf7\xbcA2\t<\xe2\xec\xcb<1\xec\xb5<\xd7\xf9k<\x85\xa8\xb9;&1\x8d;\x95\x7f\x97<\xce\xbdi<\"\xf9\x90=\xa0\xac\x1b=\x96\xca\xa3\xbc\x7f\xdf\xf9=\x95\xaao\xbc\xf1\x0b_;\xaa\xd4\xce\xbc\x84\x00 \xbd\xd4h\xb4\xbd\xeb\xbc\x1b=`\x89J\xbc\x12K\xa3r=\xb3s\xad\xbc\x04|2\xbd\xa0s\x91;\xee\x04<\xbdX\xe28\xbdN\x97\x01=`?\xe4\xbcu\x0ch\xbc\xaa\xc0\xfc;\xc2N\xc0\xbca\x022\xbck\xb6\xff<\xf3\x80\x94=\xc8\x95\xf3<\x9a\xad\xd1\xbc\xbc\xa5\x9b\xbcy\x9bs=a\xc8X\xbd]\xa7Z<=Km\xbd\xf3p\xbe<\xf2\xbd\xaa:{\xce\x9c=\xe0\xcb\x95\xbd\xf6sr\xbb,\xe9\x87=>\xb1\x11=o\xbd\x9a;\xab\x1f\x13\xbc\xe1\xee\x17b\x81=\x88N\xd4\xb8\xaa\x89#\xbd\x90\x95\xac\xbc\xb1\x8a\x1e=\xf3\xf4\xf6<\xb2\t\xa5=\xcc \x18\xbd\xc5\xe2\xaf=o\x95\xf7\xbc\xceD\xaa<3\x07k\xbd/\x9a\xc2<\xe5\xc5~\xbd\x19\x96\x05\xbd\x07\xf0\xa4\xbb\xbc2\x0f=\xad\xb4I\xbdJ\x01\xa4=\x10v\xbc<\x8c\"\x86<0W\xa1\xbcz\xa7L\xbdb\x03\xee\xbcO\xb6\"\xbb+\x91\xf8<\x80a\xb8\xbcR\xec\"=\x9f<)\xbd\xe9\xdf\xd8\xbapy\x1e\xbd!\x0b$=\xe9&\xd0\xbc\xf7\x03;=vuZ;sW\xc0\xbbXr\xec\xbc\xf1aK;\x98\xaa\xc3\xbcE\xf1\\=\xf9*\x89=\xdc\xa7\xb8\xbd\xd36\xbf\xbb\xf3|\x91;c\xa8\xa4\xbb\xd9\x1cL\xbd>\x8f\xcb\xbcI\xdb\xb6=\xday\xa8\xbb\xe9\x0c\xaf\xbc8\xef\x18=%\x05o\xbaB%.\xbc\xe5\x1b\x96<\xd2\x9b\xdb<8\xf1u\xbd\xc3H1\xbd\xe4\x80]=\xe1@\x01\xbc{~\xbd\xba\x89\x84\xe9<5\xadw;\x93\x0f \xbd\x85/\x11\xbd8\xc7.\xbc\xab+)\xbdR\xaf\xc3\xbc\xcd\x9dV\xbc\x97s\x8c\xbdbgd\xbd1r\x0f\xbd\xb3\xdc\x9a\xbc\x1eA\x94\xbd\x113_\xbd\xcf\xda5\xbc~u\x8f\xbbik\x99\xbc\xb6\x94\x12\xbd\xa8\x83I\xbd\xd0\x9d\xad<\x06\xa2\x8c\xbc\x86\x04\x02=\x07\xe3\xd6\xbc\x93|\x03\xbd\xe3\xb5\x85\xbb\x8c\xff\x04=I\xdb\xef\xbci\xed\xf7\xbc\x1as8\xbd\xbf\x7f\xd4\xbc\xda\xb5\xa9;\xad\xca<=Y\x13\xcb\x9f\xa5=\xd1\xfc\x96=\x80<\xcd\x03\xba\xbcW\x99\x11\xbc\x00\xd1\xd3\xbc]M(\xbbY?\x12\xbd\xcb{\x9a<\xc4\x9d \xbd;V\xaf9\xe9\xd3^<\xa7\xf2\x81;\xb7\xad\x85\xbdJ3$\xbch\xe1\x8c\xbc\xc8\x1e\xa4\xc7@<\xf8\x9f\xdc\xb9\xff\x97\x85\xbc\xe8T\xbf\xbc :?<\xe9\xbbR\xbd-\xec;=\xbc4$=\x94\xf39=\x9b<\x17<\xc8\x9a\xac\xbdty\x85<~%0=M\xa0\x9f\xbd*\x0c\x9b\xbc\xf0\x19~=\x98\xda\xfd\xbc\xa8\x17}=\xac[\x19\xbd\x9d\xec\x9b<\x97\xb1Q\xbd\xbf\x8e\x82=[\x8b\xac<\x02\x12\xa4;\x80\x15/\xbd\xed\xdb\x07\xdd\x83\xbc\x02\xe2\x94=%,\xa5\xbc\xfbA\xa6\xbc\xe3\xe1\xe1\xbc\xd0q\x85;\xc8\xbb\xec=\x9e\xef\x01<\x91P\x11=\xa3\x81\x8a< \x05/=H4\xc8<\xaf\xc2\x93=\xf4\x94S<\xf2Tl\xbc\x81\xf0c\xbd\x80>\x81\xbc\x83\xa6\x9e;a\x81O\xb9K=5\x9d\xbc:\xdf\xf3\xac\xbd\xd6\x8e*\xbd\x8c\x0e\x9b\xbc\xcb\xd8\x1f\xbdN\x83>\xbd\xd9.r\xbc\x0f^\xfe<\xd4>?=\x0cv\xb5<\x96\xde\xc7\xbc\xf9:!=\r0\x12\xbd\xba\xfa\xca\xbc\x1f\xe8(=\x94\xfa#\xbd+\x978\xbc\x1aO)\xbd\x10.\xbf<\xca]\x19\xbd\x8ci-\xbdY~\x1e\xbd\xd7S\x8a\xbda\xc2\xf8;\xae;m\xbd\xce\xc8 \xbb\x7f|\xa4<\x98\xfb\xb3\xbc\x9bz\x08=\xf4\xfcV=XO\xb7\xe3\xbc\xb5\x83\xa6\xba\x95\xa7\x1b\xbal\x8bV\xbc\x8b8F\xbc\xafOu\xbc\xc8F\xb5\xbb5o\x9c\xa2<\xcc8\xf4<\xc6l\x95=\xd4\xf1.;d\x86\\c \xbd\xd0;\x0f\xbc\n\xc9\x9b\xbd\x97\xeeb=\x0b\xdf\xaf<\x084k=\x9e\xc3g;<\xa3\x90;EZ\x9b\xbd\xda\xfc\x1c<\xf2c\xb1<\xa4\x92\x17\xbdT\x8d\x14\xbd\xc8:\xfe\xbc\x8b\xbc]\xbb\x07\x06\xb1\xbc\x9d_\x93\xbc\x18]\xe7\xbcq\xa9\xf0;1#\x85=\xd6\x87\x8f\xbcZ\x8fA\xbd\xb3%\xc1\xbb\x93d\xe2\xbcs\x8f\x16=\xd7H#=C\xc1\x9e\xba\x1e\x9f\x86\xbb\xfb\xac\x18=\xcf4\x1e=\x15\xcc&=\xe3\xb6\xf5\xbaJ\xad{=\x0809=\x935h\xba\xfa\x16\xdf\xbcR\xed\xab;,\xb3\x08\xbd\x8c\x19\x989U\xbec=\xe2\xea\xec\xbb\xb9\xear<\xb7\xcb\x1d\xbc\x18-M=\x1b\xd3\x1b\xbc\x84\x02P\xbc\xe6\\Q=\x1dIc=\x14#\xbd<\xcd\xa0\xc0;\xbd\x1c\xcb\xbc\xd1=\x91\xbd@\"\x17\xbd\x1d\x81%=\xfa\xdd\xe0\xbc\r[p\xbc\xa2F8\xbd\xfat1\xbdz\xe1\xa3\xbd\xc33\x8a<#\x06\xa8\xbb\x05\xf7:=\xc9\x10\xc6;H\x90Z\xbd\xa5\xd04\xbb\xd1\x96\x87\xbd\x9dOx\xbd\x97N&<\xcb\xba\x9d\xbc\x08\xa0\r\xbd\x02Qn\xbcy_\x1f\xbc{\x15>\xbd\x13\x93\x9e\xbce7\x0c\xbd:Q\x8e\xbc/6\t\xbd0`\x8c\xbb\xbcRB<\x95{\x87\xba\xb1~l\xbc\x05\xa9\x1c<\xe2\xbe\x968z\x16\xab;`\xef4=\xc6\xf1\xc8<\x02\x03\xff\xbd\xf0\xcb\xe2\r =Dn)\xbd\x17qR<\xf8\xb1V=\xd3\xbd\x92\xbd\x8a\xbf\xfc<\xcc\xa2\x1d\xbc\x1b\x8d\x0e=57\x83\xbc\xb4\xe2\xb6\xbc\xcf\x07\xe7<[I\xc6<\x1d\xcb\x04\xbdQ\x9a\x82\xbd\x06(\xa1\xbd\x92?*\xbd\xc86\xc5\xbc\x12}\x9a<\xf3\xeb5\xbd\"\x83\x87\xbc\xfc\x85(=\xc9\r(\xbd\xe6\x17\xf2\xbb\x8d\xcfj<\x99\xef\x07=\xbf\x03\xce\x02\xbd\xb7\xc8j;\xb6O#=\xc8\xd6h\xbcw\xa0\x9b\xbc\xe3\xe4\x18=i\x04\x8f\xbd\xf9\xfc\x0f\xbb\x08\x8aI\xbd\xca\x86\x81=p\nY<\x98\xf9\xf5:\xbfvR\xbbq\xfe\xbe;\xec\x98V;\xfb\xef\x8a\xbb\x1a\xb18\xbd\xe8\xabW<\xfad\xb6\xbb\xb1\xbc\xd6\xbcq\x97\xba< \x8f4\xbc\xf1\x084=\xae\x8f\x9e\xbd\x886\xe8\xbb\xf3\xaet<\x05\xf7\xae=5\x89\x02=\t\xa7\x08\xbc\"\x97R\xbb\"\x9b\x0e\xbdY\xf2\x93\xbd\xc2\xba\x8b:\x81\xe7\x8a=~6\xb3:9z\x0c=$b\x15;\xb0\xf7\xe4\xbc\x0f:.=%r\xbe\xbb\x08\x0e\x06=\x98\xc6\x95<\x89\x87\xe9\xbc\xcc\xbd\x14<\xd79\xb1\xbdE\xfdC\xbcqZ\xc8\xbc\xa0\x90\x81\xbd\xcfBJ\xbc\x18\xa4\x99\xbc\xaa)\xd5;\xbe\xc1;\xbd\x981i\xbe\xbcY\x9e\xb1\xbd<1\x06=\xbe\x82\x8a<\x9d\x81\x1c<\x15.7=\xec\xe1\xc0=\xed\xf6!\xbcx\xd0T=3\xf9:=d\x91\xc59ADJ=i\x7f\x8e\xbd\xb9\xb1\x99<\xbe\xe4\x98\xbc" +HSET bikes:10068 model 'Pluto' brand 'Tots' price 3877 type 'eBikes' material 'alloy' weight 9.2 description 'A city eBike that could double as a short-haul commuter. The Shimano gear system effectively does away with an external cassette, so is super low maintenance in terms of wear and tear. Put it all together and you get a bike that helps redefine what can be done for this price.' description_embeddings "n\x8b<;\x8bS\xd3<\x1b\xc9\xa7\xbc\xaa\xe9L=\xbf\x08\xf1<\xef\xed\n=\xe2\x17\x02;g\xcd\x9c\xbd]\xe1W=a\xa3\xb5<\x18\xd7\xa0<\xabW\xd3<\xee]I=\xfa\xe0\x01;\xf2\x80\x91=\xc7P\xb2<\x17M\xff\xbc\\\xcc\xcf\xbb\x8b\r\xf3\xbcn\x8f<\xbdt=.\xbd\xacp\x8a=\x9f\xd6z=O\xc9!\xbcN\xc7q;%\xea\xc8d6\xbd{{y\xbd\x14\xb8\xfd\xbc\xd8\xe5\xb8:\x18e\xcf=\x98\x94\xa9\xbc\xd2\xeb\x99\xbb\x0b\x07{=\xbd\x99\xb3\xba:o\x1d<\xf0\x19;\xbb\x1a\xfd\x0f;\x90L\xd6<\x941.\xbd\xdc\xfc\xa0\xbc\xea\xdaq\xbb\x8a\xaa-\xbc\x02\x85\x0c=\xf7q~\xbd\x85j\x8b\xbd\xf0\x17p<\xd8\xf5\xb0<@\xee\x96:\x88\xd1,=\xab\x8e\xa7<\x83\x8c\x17\xbc\xde\x1f\x84\xbbv\xc5\xe0\xbd\x1d\x91u<\xa6`4=\xbb\x92\x80\xbd}\x93\x19=H\xd6G\xbd\x88\x1b\x9c<\x90<\x80\xb7S\t\xa2\xaeW\xbb$c\xb9;\xce\xf6+\xbd\xfb\xaeS;m\xd8a<\xb0T\x0c=q+\x19<\x13\x8d\x97\xbd\xf5\x07K\xfe\xcb<\x14\xd0\xff\xbc\xe2N\xe2<.O=;:c=\xbe\x03:=\xae\n\xc5<\xe3\x0e\x99;\x8b\x10\x07\xbd\x08\x92\x9c<\x81\xbc~=\xd99\xe89~\x7f\x10=R\xc8\xc3<\x90\xe2\x97\xbdL\x83\x13<\x83\xbb\xaf\xbc1\x03\xe0\xbc@\xba\x15\xbd\x01\x9c<\xbd\xa5\xf9\x07=\x98\xad\xb5\xbdg\x96\xd8\xb9\x92\x95\x9a;\xce\xa8X=q|\xad\xbc\x1b\x0c\xb3\xbd\xdb\x92\xeb\xbc\x91\x0c]\xb9\x07\xdcJ<\x8dK\x9c\xbd\"\xc5\xbd=\x12\xd4\";\x14\x94\x07=D&\x00=R\xfd\xef\xbcw\xd3\xe4\xbc\xf5\xa6b\xbdH\xa8\x86\xbc\xc1\xefR=\xb7\xde\x1f\xbcpr<<\xbd\xea-\xbdE\xf0\x94\xbb\x85\xc24=\xc7\x1dd<\xb6\xd8\xe7<\xc4\xd4E\xbd>\xb7-\xbd\n\xeaO\xbd\x15\xb56=A\xce\x13<\xed\x18)=Z\x1d\xa3\xbbAd!<\t\xd5\x99\xbdB\xaa\x88;\xd8\x8a\x91;\x9fR\x16\xba1\x10\xad\xbc\"\xb1\xd0\xbbe\xca\xda\xbc\xd0u\xff\xbc\x99h\x1b<\x1f\x8c=\xbcR\xfcD<\x8e\xc2\x99\xbcV`\x82\xbb\xaf\x06^\xbb.\xac\n<\xc8\xb7d\xbc\xed@\x8f\xbd\x00\xc3\xa9\xbc9\xcb\x97\xbc\xdd\x81\xbb\xbc\xd0!\x1e\xbdz\xb9:;\x97\xa7\x9f\xbb8\x92d<\x96t\xa5\xbcD\x04\x96;\xbb\xe5\xae\xbd\x95\\q\xbdc]\xaf=\xbc\x82\xbd<7\x15\x87\xbbO\x15\xf1<\xe9\xe7\x95=\x0c\xe78=M\x93\x97<\xf2\xc4\xd9\xbb\xad\xe5?<\x1a\xee\x80<@\xcbG\xbc\x15\x83@\xbcP~\xc8\xbc\xb72\xeb\xbc\xf7\x9e\x9f\xbb=T\x1f\xbd\x00kK\xbc\xf4\x91\x1d=\xa6\xb8\x19\xbd\xc2\xfed\xbd(\xecs\xbb\x0f\x0ci\x80\xbd\n\x86\x1d<\x91\xbcI\xbd\x11\x84\"=?\xeem;\xbf\xca\xb1<\x8dr\xd6\xbc\x9b\x99<=&\x90\xc8;\x8b\xc5b<`\xc5\xea*s\xfa<^J\xb6<\x92`\xfa\xbc\xfc\x14\x07<\xc2s\xe2<\x148\x9b\xbd\xe6K\xd2\xbd\xe3n\x83<\x85\xc7\xa3\xbc\xbb\xaaX\xbd\xce\x9b\xd1\xbc,\xcb\xe0;`\x92\xdb\xb8\xa764=2\xdf\x8f[;}8\xa3;\xca\x13\x1f\xbd\xc3)/\xbd\x808E\xbc\xa4.\x1e\xbcq\xef#\xbd\xfeo\"\xbd\xbd\xa7\x04\xbcI&0\xbc\xf1\x94;\xbdt\x04E\xbd\xf0\x87\xeb;\xaf\xbdF\xbcUSo\xbd^\xd9P\xbd\x1795\xbd\xb9\x00\xf8\xbc\x0cY\xd9\xbcp*+<0\x91\xde<022=\xf5\xd8\xa8\xbb\xb2\xaa\x05<\xb1v\xfd\xbcs\x9d\xcd\xbbu\xc7;\xbc.\xacJ<\xe7y\t=\xb0\xeeV\xbd\xb3\xe9\xba\xbc\xa7\x90\xc1<\xd4\x02e\xbd\xa9\xde\xf1:\xf9\x93\xf69>\xbf\x80\xbchl,\xbdM#\xb2<;\x04\x1a=q\xe5\xc1\xbb\x99\xd6\x86<>\xf1\xb5\xbc\xed\xde\xb8;T\xcd\x07<\xb6\x93\xbd=\xfc\xe7&<\x1f[\x94\xbc\x18\x93\xf2\xbc[\xf6\x18=\xb7\xc2<9u\xb93\xbd?\x0f\xcf<\xba\xbd\x1d=\x1f=O<\xfa\x98\xa1<\xb4$g\xbd\xad\x94\xb7<\xbe\xe1\x84\xbc\xc1w\xbf\xbc\xdcd\x14=\x14\x0b\xbe\xbd\xaan\xc9\xbc\xd7\x0f\x1e;\xca?\xe0\xbc\x1e\xaf\xac\xbc\x14jM<.w\xdd<;\xbc\x87\xbd\xb94$=\x18aX<\x91F\xf3<#\xbbek\xb6;i\x8ep\xbc\x84\xf7)\xbd+\xd0?=%\x0c\xef:\xa1\xbf\xc9\xbce\xe8\x00;\xca\x13\xfe\xb9\",\x04\xbd\xe8\xdc\x8e\x0e\xf7\xbb\xb3W.=\xa5\xa0!=ZbG=\xfa7\x0b=\x13:\xa1=`\x14\x17=}\x181\xbcJ?\x8c\xbdK\xd4H\xbdt\xff\xff\xbc\x9a\xdf\x95<\x85\xcf\x89\xb5\xe3\xbcx&b=B^\x08\xbbn\xb0\xf0<\x9bdv\xbdD\x1b\xaf=xC\x93=2\xed\x18\xbd\xfe\x8e\xae<\xff\x8b\x10<+\x9f\x1e\xb8<\xc9\x03<\x17FO=p`Z\xbd<\xe0B<\x7f\xfdI\xbd@\xa3[\xbc\xac\xe5\x18=\xd6!\x13=w\x03 \xbc\"\xda\x10=\x03\rM={\x1f\x81\xbd\xad:\x0e;.4\x8a\xbb\xd0\xa7\xc6q\x06\xbc\xc9\xbd\xd4\x9c\xbc\xc1\x85C<\xae\x9ct\xba\x80&\x9d:\xf8\x8eI=\x84\x0f\x19\xbdP\xb1(=s\xc0\x19\xbd.\xc9\x96;\xb1L!=[\xd5\x18\xbd\xf0\xfd`\xbds#\x11\xbcTg\x9b\xbcq\xab\x82<\xb1\x90\xab<\x94\xb2\xa6;juZ\xbbA\xeb\x8d\xbc\xb3\xd4K=\xdc\xc1N\xbc\xd3D\x1f\xbd@JJ\xbd\x92e\xc9=\x95]\x81\xbdW\xd5B=(\x06>\xbd]oJ<\xaf\xde\xea\xbb0\x85v<\x17p\xc6\xbc\xb3\xaf\x93\xbd\xf4\x1c==\x1aJ\x1d=\rvL\xbbb\xe8H;\x15RO;E_^<`j\xb1=\xc8\xfb\xe5:\xb2W\xfa<7(m=L5\xbf\xbc\xf5\xe8\xbc\xbcQ\x0e\\\xbbw\xd6\"\xbbb\xfa8\xbdvNZ\xbb-\xb4\xeb<\x9b\x80c\xbcE\xfa0=\x8d\xa4S\xbb\xf0?\xc2\xbbW\xfc4\xbd\xbb)\x0f=\x83\xac\xfc\xbb\xe7*\n\xbd\xb5R\x82<$\xd2\x85=\x86\xa2\\<\xc3\xce\x90\xbbR\x15\n\xbd\x8c`\x94:\x80\"\x93\xbd\xd6_#\xbb$n\x06\xbc\xde(\xa5<\xfab\x95\xba\xdc[<=]ZA<\x01\x89n\xbbi\x80\x8a=\xc6\xae\x14=\xe1E\x0b\xbc\xa0;\xc3\xbaG\x93_\xbd\xc5\x92\xe5<\xa7\xae\xb5;\x1c\x1dY=\xda\x91@<\xa5x*=\x90#\x96\xbd\xae\xaeh<\xbf$\xe1<8\xd0\xfc;\xa1Z\xfa\xbc\x92\x9a\x17\xbd\xa9\xb9\t\xbc\x87U\x18<\n\xba+\xbc\xf7\xee\x06<\x85\x82\xd7\xbc\xeb\x06R=>{\x0c\xbb\x9a\xbe\n\xbd\xaeo\xce<\xbc\xbb!\xbd\x9a\xb6\xcc<\xe9m8<\xf8T\x04=\\)\xdf\xbc\xfa\xe1\x83<\x19\x0c\xf0<\"Z =\xa9\xcb\xe5\x12\x13\x9b\x91\x1c3\xbd\xe0\x14\x1a\xbdA\x94\xa9=\x98\xc6c\x1d=kC\rg\xbcqw^\xbd\x8c\x18\t=\x9d76\x0f\x11\xbd\xe1\xcd\x96=\xb6\xf1\xaa9T\x86)=\xf3\"\xe5<\xf8\x98-\xbd\x9f\xa2\x17\xbdS\xc6(<\xd9\xef\"\xbc\xe39$\xbd\xbd7\xec;`\xdc\x10\xbd\xa1t\xf4\xbcT6==2\xd8c\xbb\xe4\x11\xb1\xbb&\xf9==J\x96\xf1\xbc\xe0\xa4x\xbc\xfb\x0c\xb3\xbc\xb6\x8a=\xbd\x05SH\xbd%\x01\xff\xbc\x8d,\xfd\xba\xc8\x972\xbdi\x17\r\xbd\x95%\x04\xbd\x95\xcf\x91\xbc\xb4L\x8f\xbd!\x1e\x01\xbd\xd9\x9b\x97\xbc\xa6\xed\xa4:a\xe4\x97\xbc\x9e)\x13\xbd\xb2+C\xbd\xf7pD<\xac\x06\xd7\xbc,\x8c\xa6<\xc6\x9d\xa7\xbc\x1b \xda\xbb\xc8\xb0\x1e\xbc\x15\xbd\x17<\xfb\xd6\xed\xbc%\xa5\xab\xbdF\xda\xac\xbdZ%\xf2\xbc\x8f\xca\xd0\xbb\xd2\xa6\x18=\xa7\xb0\xe4<\x96\x89\x9b=\xde\x12\x1d\xbd\x08T]=\x11\x81&\xbdp-\xc3\xbd\xbdQ0\xbak\x18\x14\xbc\t\xbc\xb1<\x85\xf7\xa1\xbcv\x1a\x0c\xbc\xa1\xa5\x97\xbc\xe3\x9e?\xbc(\x18.\xbd\xba\xf7\xe9\xbb\x897\xcd\xbc[\xd9\x8d<\x88\xd9\x0b=\xc9\xfd\n=(\x8e\xe4\xba~\x9a!=\xe0\xb0\x0f\xbc\"\xf4\x16<\xf4XC\xbd\x15@\xd7<\xd4\x15D\xbd2\x99l9\x9e\xd3\x9e<\xd2\xfa\x93\xbb\x96\x07\x89\xbc0K\x9c<\xfd\xba\xdd\xbc\xb2\xc0\xec<-\xc7\x8e\xbc-\r\xe1\xbbl\xbc2\xbd\x8ekM\xbd1-6=\xa7\x97\x88<\x99\xf4$\xbd\xd2^3=\x90\x1f\xb7=\xac\xfb7\xbb\xcd\xa8#=K\xd6\x94=K\x14\xea\xbc\xed\x93\x99=\xddL\xd6\xbd\x0f{a<\x82r\xd7<" +HSET bikes:10072 model 'Makemake' brand 'Eva' price 1459 type 'Road bikes' material 'alloy' weight 7.3 description 'The women-specific, race-ready frameset has received significant upgrades for 2022 and now has a stiffer front end thanks to the upgraded SL fork. The Plush saddle softens over time with use. The included Seatpost, however, is easily adjustable and adds to this bike’s fantastic rating, as do the hydraulic disc brakes from Tektro. It comes fully assembled (no convoluted instructions!) and includes a sturdy helmet at no cost.' description_embeddings "*6c<\xbc\xae\x06\xbd\xfd\xfa\x01\xbd\x08l7\xbc\xbf ]\xbd\x82\x89K\xbb\xca\xb2\x06\xbd\xb1\xfa9\xbd\xa3\x8dp=LDS<\"7\x1e\xbbj\xdf,\xbc\xd4]\xe2<=\xa8O<\x00\xc3S=\xf6Z\x8a\xbcJ\x91!=\xd2\xbd\r\xbd\xa3\xdf\x82\xbc\xf0\xa7f\xbdJa\x9d\xba&\x82\xdd;D6d<\xcal\x03\xbd0%\x85\xbc\xdf\x7f\xc6;\xd9\xecX<\x10\xf8\xe0\xbc\x82\x13\x05\xb9g\x0c\x8b=\xec\x8ap<\xabn\xfe\xbc2\x8d\x85<\xef\xf7\x04=0+\xcc\xbc\xf6Q\xca;\xeae/=O\x02\x16\xb9\xe0\x9f\xc0\xbc\x97&M<\xe7\x04\x8f=\x90M\x98\xbb\xd0\x9d\xe0\xbc\xe08\xf8\xd5\x88\xb7;\xc9\x11\xf8\xab[>\xbd\xafOM\xbc\x04\xddO\xbd\xf1\x87#=a\xa0R\xbcT\x8e\xdc<\n\xf5c<\xed`@\xbdX\xced=\x82\x8f\x08\xbdu\x08\x84<\xb1@V;\x94\xcdq\xbb>\x13N=\x85\x1b\xb4\xbc\x1e\xb2\xaf<\xb0[]\xbd\x19\xd1\xcb(\xbc\x19\xcez\xbd>X\x8f\xbba\x04\"=-\xfc:<\x98O\x12\xbdC\x8a\xa0\xbc\xda_`=Vl$\xbd\xd0\xfc\xa2\xba\x1d\xb6\x12\xbd$\xdd\x1a\xbd\xe2\xe4^;\xe3\x8bM<@\xefZ\xbd\xbd\x1b&\xbd\xb0\xaa*=\xe7x\x88\xbc\xc0\xec\"<9\x8f=\xbc2\x97\xff\xbbd#\xac<%\xcb\xcd\xbb\xcd\x1c\xdf\xbc\xf8\xbb\x97<\xfb],=WOT^\xd1A\xbd\xda\xd5\xb9\xbd\x821\xd5\xbc\x95\xf3\xfa\xbb{\x8a\x8b;\x97\xdb;\x91\xbd\x8e\x97\xa6<\x17\xc9\x99<\x9ar\xc0\xbc\x8d\x9e0\xbc\xae\xfc\x8d=0\xedO\xbd\xa0H\xf3\xbb\xf6\x04#\xbdog\xc8<\xcfF\x9f\xbc\x9c}C\xbd\xaa\xd0\x8f\xbdL\x1b6\xbd<\xef\xb9\xbbWN\xf9\xbb\xce\xa8\x8d\xbc\x07\xa5\r=\xf6\x88\x04\xbdRj\x97\xbct\x83\x84<\x15\x18\x13\xbcI\xd6\xea\xbcjA\xc7\xe7\xcd\xbc>\x94\x9a=\x84b\xa4\xba\xf9\xa3\x97\xbc0\\\xa6\xbd\x1e\x0eV=N\xb3\xb5\xbb\xe6\xfc\x0b=C\xf28\xbd\xe2\xf4_\xbdgu&<\xe8-2\xbd\xc7\x7f\x02\xba\x06\xcd\xd5\xbc\xda/\x94<\xf0E\x0e<\xa8\xda\x8f\xbd\x19g\x18\xbc\xd8l\x1d=\xd4\xe2\n\xbd\xdd\xb4g\xbd\x0b\nD=\xd9\xb4Q=E\x11#;\x95\xd3\xc5=\xc3qI=x\xbd\x8f\xbd\xee\x7f2\xbc\xda\xcaD\xbd-\x9az=\xea\xa4\x03\x84<\xaf\x08\xae\xbc\xe0\xfcG<\x81\xaeK\xbc%vP=)A\xe6<\xf75\x8a\xbc\x9b\xb1\xd4;\x05\x0f\xfc\xbck\xf1\xb5<\xfco\xf1<\xd82\xf6<$k>\xbd\xfd&#<\x1c\x9d\xca<\xcc#\x96\xbcS\xd2^\xbd\xde*\x83=\xe3[\x85< `\xfa\xbb\x9d\x83\xda\xbc\x8d\x1c\x18\xbdH\xb8\x8a\xbd\xf3\xb1\xc2\xbb\x1d\xe0\x96=\x85O\n\xbd\x84\x92\xb1\xbb\x7f2\x86\xbbY,\xc3<[k%\xbd\xa7\xde\xe7\xbcFim=\xf2\xf3.=\xb3*\x0e;2\x90\x92<\x98-\\<\x17\x95s\xbd\xa1\xe3\x1e\xbd\xfaBU=\x92\xce\xe4\xbc\xc0\xa8\xa8\xbb\xe8\xd8\x01\xbd<\x9bu\xbc\x86\x9e\xe1\xbb\tQ\x08=\x04\xa0F;\xbb\xfaN\xbcsD\xaa<\x91\xe3\x1c\xbds0\x80\xbci\xc8\xce\xbc\xebU.\xbd\xdd}\x17<\x13C\x91\xbc\xa4\x91u\xbdc\x96\xaf\xbb\xf5\xaa\xed\xbb\xb8\x07!;\x1d\x7f\x19\xbd\xe6\xe5[\xbdA\xff{\xbcbd<\xbdWb\xc7;q\xc1%=w\xbcT\xbc\xc8e\xe7\xbcM$\x15<\xab-\xa2\xbc\"\xb7a=\xc6K;\xbb\xb3L\x05\xbdVPa\xbc\xf8\xf1\x91=@\x9d\xa8\xbcL\xb3\\\xbdl\x84\x92\xbd\xc53\xed\xbb\xed\x95\x04\xbd\x00\xaf\xee<\x81\xb4B=u\xed\x9b=\xe2\x06\xd6\xbaHz\x1f=H\xf4\xd1\xbc\xeb\x9e\xa3\xbd9\x14\xb5<{0\x9f\x1b=\xe7\xb8\xcf\xbc\xc1\\\x1a=\xf8{\xab\xbc\xf6\x06g\xbd\xbd\xd3M\xbc\x1ej\xff\xbc\xbe\xfc1:\x9ck\x85;\xe6\x10\xf2<(p.=\x98M\xf4=\xca\x12X\xbc\xc5\x00\xf4<\x81M_=\xb1\x04 \xbd\x8d\xe1b=,\xb7\xea\xbc\xa6^:=\x1d\x16\n\xbc" +HSET bikes:10074 model 'Titania' brand 'Classic wheels' price 842 type 'Road bikes' material 'carbon' weight 15.3 description 'The women-specific, race-ready frameset has received significant upgrades for 2022 and now has a stiffer front end thanks to the upgraded SL fork. The Shimano gear system effectively does away with an external cassette, so is super low maintenance in terms of wear and tear. This is the bike for the rider who wants trail manners with low fuss ownership.' description_embeddings "\xd4\xcf\x0e=\x9e\xe5\xdd\xba\x94c\r\xbd\x00\xf7\xf2<\xdcJ\xea<7\x02\x00=\xfb\xb7\xf7\xbc\xc3|6\xbd\xba\xd6\"=\x82K\xce<\x97\xc5\x9e<$\xc7\n=\"\xa6\x1e=\xd7\x80\x1f\xbc+\xbd\xd2=\xec\x8bN<4\x9aJ\xbc\xa8G\xce<\x9fo\x97<\xd8\x0e\xbe\xbc(\x15\xa99\x93p\x02\xbd\xf0\x8c\xa3<\xb5=\x15\xbd\x13LW=\xf4\x1d\xc8l\x8f\xbc\xd3\xb2\r\xbc\x90\xc8\xa0\xbd|R\x12\xbc0A\x88\x81\xd0<&y\xa7;\xb0\x91M\xbc\xd2\xc2\x82\xbd\xc9\xe4\xa9<\xc8\xf0;=\x8b\x862=\xcbE\xa5\xbbE\x8c\xdb\xbb\x80\x80\xc2;\xd0\xa1\xeb\xbcgX\x19=\xce\xf6\xd3<)\xc4\x84=\xb2(\x1a\xbd$.m=\x9d\xb7\xf4\xbc_\xe9X\xbd\x18\xa1(\xbc\x18\x97\xb5<\xf7\xeei;v\x80\x91\xbd\xa2\x97\x80=D\xf8\x93<\x00vk\xbc\xbd2\x1a\xbdjg\xe2;\xf8\xbcsE6\xbdq\\\xa1;\xa3G\xcd=\xad\xbd\x01\xbd\x13$\x9b\xbc4\x97\xb3<9\x1f$\xbc\xbf\xd5\xd2<|\xa1\x8e\xbc\xfe\x95\xe9;h\xa5\x90\xbb\x84\x12\xa1\xbc\x1cG\t\xbb\",N9\xbf\x8f{=\x96\xb3i=\x13\xdb)<\xb2c\xb2\xbc\x8e\xf8O=\x05\xb4\xe9;}\x1ab<6\xf8&\xbd$\xad\x93<\xef\\%=)X;\xbd\xa3(\x90<}\x03\xc5=doa;\x14\x8d\n<\xe0\xab\x90\xbc\x06\x11\x0b\xbd\xff\x9f\xb0\xbcF\x85\xb1<\xd4t\xf4\xbcn\xcf\x88<\xdf<\x80=\x02\x8c\xd4<\x08\x17F\xe3\x06=Wu\xf5\xba\xa4\x91\x12=\xa1\x10\xa9=\x88)\x85=*f\xe0\xbc\xe1\"\x7f=;\x91\x8eD\xbd\x14\xab\xd5<\xf8\x91\x17\xbc\x9aG\xd0\xbbb\xbc\xc4\xbcW\xe6\x7f=!\x16\xc3\xbc\x1c\r\x92]\x10;S\xa2\xbf<\xcf\xb7\xdc<4|\x19\xbd\x9a5\xda;\xdd0\xfb\xbc\r\xc4\x03=\x98\x80{\xbc\x0bz`=\xfe.\x10\xbd\xc1\xc2!j\xaa\xbcb\xeb\x9e=Zd\\\xbc7O\xf3<\xb8i\x82\xbc\xfe)f\xbc\x9c\x85\x8b\xbc\t\x06\xd6<7\x821\xbd\xee\x98\xba\xbc\xa3=\x8c\xbd\xee\xd4]\xbaE<\x9e;\x97\xac\x81\xbb\\\xb6c=\x06\x14\x83=`\xc3.\xbd\xbf P\r\xb5\x02;D\xdf\xb4<\x9c\xfb|=_\xc1\x01\xbc\x9d 5=/#\xc8\xba\x86\xb1\x9e<\"\x16\x86;\x88l\xc7\xbc\xf6\x81P<\x8c5.\xbd\x85\xc9\xad\xbc>\x8e;<\x88\x18\xb6\xba|*G=\x04?p\xbd\xec\x9c\xa5\xbc\xc6\xa80\xbd.6\xed<\x94\xf5\x82=\n\x95\xc5;\xfe\xc4S=\xac,\x13=W\x80\r=\xca/l\xbc0\xf9M<\x14.\xb2;\xd9\x02\xb4\xbb\xa5\x1c\x1b=\x12\xd7\x00;\xf5\xa3\xf0\xbb\xc6\xfe\x88=b\xe8\xcc<\t*O:K\xf7\xe6\xbc\xee]\xc8\xb9\xc6\xdc\x1e=\x17Q\x94\xbd\xaa\x8f\xe5;\xb0\xe9\x99=\\\x0c\x00\xbd\x1eZQ=\x0f\xb3\xd8\xbc\x1e\x0b\x89\xbcG\xad$<\x90\xa0j=OF\x02;\xbd\xf9f\x05<\xd0\xddA\xbd\xff>d=\xa5\xdc\xa4;R\x81L=\xa0!\x86\xbba\xa0*\xbdK\xc6\x97<\xedC\x1c<\xf2\xf2}\xbcO\xf8\x85\xbc\x04\xa0\xad=\xe7:\x13\xba\xe2\xe7\x1f<\xbc\xbd\x84=l\x06#=\x1cx9\xbc\x0f\xc3\x9b\xbd\x88KR\xd7F\xbd\x95-7;2\x9a\xc1-\xbd\x02\x19\x90\xbd\xf7\xb6\x80;\xd9\xe6|a\xbb\xa1\x8a\n\xbd\xb0\xdf)\xbb\xde)\x02=\x90\xb8\x91\xb9\xa5+\t\xba(\xc0\xdb\xbc(\xfcQ\xbd\x04\xa4?:X\x90\x81=\xe2\xf1\xa0\xbbzO\xdd\xbc\x92\xe3\x1f=\xb0\x01\x9e\xba[\xf6\x84:\x97J\x87=\x9d0\xf2<\x93\xa9\"\xbc\xb6\x85\xd8;u\x95\xad\xbc6\xfdZ\xbd\x01S\x8a\xbcG\x9e\xa7<\x17\t\xaa\xbc\x89!U\xbc\xa86\xb4<\tG\x9b\xbd\xa9\x1d\xfb\xbc=\x0fH=L\x1b\xc9<(WX<\x89w\xae\xbc\xc5\x9b\xc8=\xd52^=@\xf5\x95<\xffba\xbcm\xa3\xc7<\xc0z\xb3\xbb#\xf5f\xbd*\x1e\xa2\xbb7\xa5\xb0<\x198\x9a\xbc\r\xc5N\xbd\xa9\x9b\xea<\xad\x0b\x8e\xbc`\xce\x83\xbd\xf2\x83\x05=0\xf8-<\x93F\x1f\xbd\x80b?=\xeaG\x99\xbc\x82a\x04\xbd?U?=\x15\r%\xbcF\x1d\x84\xbb\x1b\x04e=i?\x13\xbd}\x87C=\xd1\xa0\x0c\xbd\xc1\\\xcc\xbc\xc5),<\x18,)\xbdGy\xad\xbb\xb2\xb4i\xbc\x0c\xbfi=\x89\x82]<\x8dMG\xbd\xb2\xa0$=.\x82\xa2=\x1cm\xdc\xbc\x1d\x8c\x9d\xbd\x10\xc3\xf5\xbd\x15\xed\xd0<\x80\xf7\x8c\xbc\x00[\x07=\x84>\xb2<~\xde\xa2\xbcm3\x16=h\xf3\x03\xbd\xfe!\xc3\xbc\x8d\x17\xa0;\xa1\xc00<3d\x94\xbc\xe6\xf2\xbf\xbb\xaa\x811\xbds\xcd\xcc<\xf9\xd3\x17=\x02\x1e =M\t\x8d\xbc}\xecs<\x9a\x06\x94=\xbe\xc9\xf3<\xcd\xd2?\xbcf\xec\x1a\xbd\xbdw\xfa\xbc\x98`\xdc\xbc\xfd\x83\x08>J\x0eT\xbd\"\xa7\x9e\xbc\xa0\x10u=\x80\xbc\xa0\xbc\x8eX\xae;\xe3\x13\x90:6\x0b-\xbd\x92\xc0\xd1<\xff\xab\x9f\xbc\xa9\x88\x90\xbc\xfd=\xa2\xba\xad\xcc\xc5\xbb\"\xee\xe8<+\xb5\x8a\xbd\x0f:L\xbd\xb9\xe3\x11\xbbxeq\xbd\xd7H\xc2=\"\x02\xa8\xbd\x8a\n\x94<+D\xfa\xbc\x94\xa2|;\xd4\xe9\x85=\xa5\xba\x0e\xbd\xcc\x9f=\xbd\xbd Q=@/I\xbco:\xb6\xbc\xe7\x17\xb7<%\xdfI=\xcd\x15x=\xcc\x1c<\xbc\x83w\xa5\xbaCb\xc1<\x1dG\xb1\xbc\xb8Q\x03\xbd\xd9$c<$\xe9\x12\xbc\x17JS\xbc\x9e\xb1\x9c\xbc\x83}@;W\xcfS\xbb\xa9E]\xbd\x8d=\xec<9T]\xbc9\xda\n\xbd\xed\x9f_\xbd\x11\xce\\\xbd\xba\xc4\x90\x8f\xbc\x0bB\x0f\xbdo\x8ab<\x0cX(=\xd7\x9c\x1d\xbdZF\xf4\xba\x80\x9c\xb0\xbdC\xc65=_Cm\xbd\x0f\xff\xab\xbd\xd4z\x05<\xf5\xbe\xc7<7\xc8\xaa\xbb\x87t\x94\xbc\xd3\x07\x88;\xa7\xd8\xb7\xbcd\xaf\x18\xbd\xbcQj\xbb\xe6\xcb\xc5\xbc\xd7R\xaf~=\x9f2+\xbc\xae2\xea<\x1d\xc3\xa3=\xd2\xbaC<\xd0\x9b\xdf\xbc\xe0\"Y\xbc;c\xda\xbc\x91f\xdf\xbc-\xf1\n=my\xd9\xbc\xbfL\xa5\xbc\x9b\xccs=2\xddm=]*\xe3\xbc\xc0\x91\xba\xbci\xe0\x83\xbd\xce\xd4\xd6;a\x9fX\xbd\xa6\x99\x1d<.0\x12\xbd\xb3\x10\xa2<\xda\xf7\xf1<\x17\x9bO\xbd\x8b\xc7{\xbc\xd9(\x7f=a\xaf\xa2\xbd%X\xbf8E\x95\t\xbdhcQ=\xa51Z=\xcds\x99:\xa5+y\xbc\xaa,b=8G\x9a;nz\xf7\xbcj\xde-=\xceH\xed<&\x8d\x0e=\r=6=A\xb6\x0e\xbc[\xc7\x03=\xc7N\xa8<\xd5B\x8f\xbb\"\xd1m\xbd\x86\xe3\xaf;/\x7fe<\xbe5\xe0;\xf67\xc4\xbc\"\xe0\xf7\xbbX\xa5G=@b\xca\xbcT\x13\x9b\xbc\x06\xb3k<\xc6\x81\x7f=\x9e\xec\x98<^U\x02\xbd\r75\xbc\xb9\xdb\xf6:1Q\x13\xbc\xb0\xfd\xd4\xbc?\xb1\x82\xbd\xc6?\x8c=\xf5\xc6Q={\x13\x06=\x92\x84\xc1<+\x7fY=D\xb6\xd7\xbb\x1b\x8c\x9b\xbc?\x7f\x82\xbb\x15Q@\xbd\xe00\x01\xbc\x98\x9a\xb4;\xedS\xb5\xbc\x12\x91\x90\xbcu+H;\x9epS\xbd\xf4\xb2]<\x83\x17\xad;B\x9c[=\x14\x9aF=a\xefK<{\x89I<\x8d4Q=\n,\"=:\x1fA<\xaf2Z<\xdd\x94o<->5=\xed\xa5\x8f\xbd\"NF\xbd\xab_\"\xbd\xff\xb6B\xbc#y\x15=G\x12^\xbc\xfd\xb3h=\x03\x91\xe3;\x1b\x8b8<\xa9\xcf\xba<%\xc2$=\x94]0\xbbu=\xbc\xbc\x16;\x8e=\x19\xe2\x80\xbd\xe7\xdc6=_gm\xbcw\x19\x05;[\x87\x10\xbc0b>=\xa4\xb1/\xbd\xf13%=4`q=\xd2\xa0\xb2=\"\xe4\xb9:\xa09]< \x99\x91\xbc\xb0\xbdk8\x8b<\x06=F\x1aH:[\xac\x1f=\xef\x1fA=\x00\xca\x82\xbc\xc5\xda\x02\xbcX\x17s\xbb\xef\xda\x01=\xa6\xfca\xbd\x86\xcc\x84\xbda\xf0\xa8<\xa6\x107\xbd\xb8yw\xbdSRC=[\xe99=\xab\xed\xc0<~\xea;=O%\x1f<%\xac\x10\xbd\xffSZ\xbd\xd8\xcc\xf8\xbby&\xd8;\xd8\x0b\x05=\xfb\xc9\x13<\xb2\x115<|2e\xbc\x81&\x07\xbd\x81*`=\xb2U\x86=\x7f\x99g\xbdJ\xcag\xbc\xa3k$\xbd\x84\xaae=\xb3\xd1\xaa\xba\xe5\xb6\x9c=[}Z<\x18\xfc\x8c=\x8bp3\xbd\xf4\xb5\x8a\xbb\x04\x81\x85<\xfc\nQ=\xd5_\x89\xbdx\xb6\x14\xbdt6\x12\xbd\xb6d\x8c\xa7<\x1fh\x1f<\xb2\x90\x8e\xbd\x0cuF=\xbdt\xa3<\x9b\xfd$;\xaf\xae\xa9\xbc\xf2!\x11\xba\x8ej\xd6\xbc\xe7N \xbc\"\xd5\xd3;u\xb5\xa5\xbc\x1b\xe4X=\xb9\xfe\xec\xad<\xecwx\xbc`\xab\x9b\xbd\xa7*J\x92\xde;\xe2&\xa4\xbc\x92n\x89\xbc\x93\xc9E\xb9\x1fS\x1a=\x02(r<\x05\xc0\xca\xbc\xd7\xe7G\xbd:\t[\xbd\xd9\xd72\xbc>\xfc\x19=*\xd4\xf3<\xbc\xf2n\xbd\x9d?\x90\xbc7-\x1ed\xb5\xbb\xa0\x1c\x1a\xbdc\xc1C=\xa81\xee\xbc\xfe=\x80\xbc8\xday\xbc$\x98\x08<\xa5g\x95<\xe2\xa1\x05<\xa10\x03=\x03\xed\x9c\xbcR\x89\x99\xbc\x94s!=\xac\x9e\x81\xba\n\xaf\xc8\xbd\xfdF\x82=vfB=F\x90\x03\xbd\x11)1\xbd\xb4\x8a\xe0<>\x9b\xf5\xbbS\x87\xd8:\xfd\xf9\xc6<\x14\x17`\xbd$N$<\x8d\x9d\xda\xbb\x9f\x91J=k\xecY<\xb2\x1a\x17=COb\xbd9\xc5B\xbc\x01\x01;\xbc\xb2\xf4-\xbc\x8dg\xa1<\xc4\x16\x87\xbc\x05\xb02=a\xeb\xcc<\xe1\xe2\x10=`\x1e\x92;\x06\x1f\xc6\xbb\xca\xddj=}\xb2/\xbd)9Q<\xab)\x12=`j\x95=t\x97\x06=C\xdfH=\xd5_\x11=\xf0k\x8a\xbbKyL\xbc\xfd\xe9)=y\xed\x06\xbd\x94\xc5\x8f\xbc\x8a\x19\xe5<\x94v\x18\xbc\xb1Q\xa5\xbd$s\x9e\xbc\xeb\x02\x99\xbd\xad\x81D\xbc\x1c?\x10<9\x84\xb4\x1c\xc1\xaa;\x94d}\xbc(\xd7\x02<\x9b\xf5Y\xbdB\x01\xae\xbd\xfa\x16\x07=H]\xd9<\x07\x96\xc7\xbb\xff\x00.=G\xebu\xbd\xb20Z<\x00\x81\xac\xb9~=\xe7<\xd5^\x81<\x99[4\xbc\xf0\x9f\x01\xbc\xe2\xc9\xfe<\xfd\xf9\xc4\xba\xd8qi\xbd\xe3\x00\xc8\xbc\x13vJ\xbc\xba|\xab_\x1d<\x10\x9f\xac;rW|\xbc\xd6\x93\xe5<\x1cpi\x90\xbd\xe3FT\xbc\xfd:\xb0;\xdc\xc0\xfc\xb9\xa6,>=\xa8\xf4D=\xadB(;w\x99\x1e=_\x8d\x1b=\xba\xd2@\xbc$B\xa6\xbc\xd4\x13\x9a\xbdt\xef\xf6<+\xb9P<\xa8n\x9f<\xea\x1e?\xbd|\xca\x99=\x88\xdaZ<\xb0\xee\x81:!Uw<\xc4\x08o:\xce\x07A\xbcv\xf4\n1\xa2;6?\xab;\xa4\xbb\x03\xbca\xd3?<\x83\xdd\x00\xbd\xdc\x8c\xc3;q\xf4#\xbd\xc0\xff\x1d<$\xbe\x81=\xbd0\xd0\xbd)}\x03\xbd\xd7\xa1J;\x9b\xe9\x15\xbd\x1e\xbf\xf6\xb9\xed\x9b\x01=\xf9\xf6\xa3\xbba\x02\xa9<\"\"%=t\x00\x97\xbd\x94`\x88\xbb\xe4:\xad<\xfb\x1a\xc7\xbc\xa4\xab\xba\xbb\xef\xdc\x8c\xbb\x99\xed\xea<\xa4\xd1\xbf\xbb\xa3\xc1\x05=\xb5\xe8\x08\xbci\xdfk=)\n\x04=\x01o\xcb\xba9\xdb\x9f\xbc\xe9\xd0o;#\x85\x1a={\x05\xa7<\t]\x04\xbd\x8e\x90I<\xf8\x80\xbf\xbc\"c\x8d<\x17\xf8)\xbc\xd6p\x11<\xdf\x87\"=$\xbc\xd2;{\x03\"\xbd,v#\xd2\xbc\xe2\xe6b\xbd\x84\xc6\xe3<\x84\\\x1a;8\xecK\xbb\x9c\xbc\xc5\xbc!\xef.\xbda\xaf7=\xee8T=\x11\xb7\xf3\xbb\xf5\xae~=1\x01o\xbc\xba,\x96\xbc\x89\xdf\xdc<\xcb\xf2\x0e=\xbdN\xbf\xbc$_O<\xfeyi=k\xeaB\xbc\xad\xa9\x86<\r\xd0\x16=\xbc\xb2,<\x1f\xfbo<\xea\x90\xd2<\xac\x17^\xbd\xdf[\xa3\xbc\xeb\x80\xc9\xce;_\x8c&\xbc\x8d\x9e.\xbd\xadV\xb4<\x18\xa9\x99={0G\xbd\x87Y\x04\xbdI\xae:=\xb4\x0c\x8e=\xc5\xdc@<\x15\x13\x91<2u\xa9\xbceb\x0e\xbd\x8d\xf5*\xbd*\x07\x05=\xbf^\x9c<\xb6\x9d~:\x94\xa8\xfb\xbb\xd9\xa2\r=\xf2\xd8\x9f<\xa4\xe1\xd6\xbc\x10\x1c\x1c=\xd6\x9b%=2\x073\xbdVzZ\xbd/\xc2p\xbd\x0f\x16\x1d=kA-\xbc\xddL\xab=\"\xb3g\xbc\x86\xeeC=\x9d\x805\xbdx4\x90\xbc\xbb\xe8S\xbc\xbb\xbc\x86\xbbGK\x9b\xbd \xa3\xc8\xbblR@\xbc[\xe0\xbe\xee\xbcqb\x98=\xd2\xa1\xc2;\x9c\x8f\x1c\xba\xf8\xca\xb9<\xa3\xda\x96<\x8d\x93i\xbc\xdc\x85@\xbc\x9a\n\x18\xbd\x8e\xcd\xa9\xbc\xda\x82Z\xbd|f\xf3\xbcUX\x03<`\xd5U\xbd\xe6\x8a\x1e<\xbd \xee\xbc\xda\xef);\xd6/}\xbcK6\xfd\xbc\xe4\x82*=*\x96\x04\xbdb\x91\xe9\xbbY@{=\xf0B\xc5\xbc\x9e\x86Q\xbbW\xb6\xce\xbd\r\x93b=l\x9dj\xbc\xc5M\xad\xbb&\xd5V\xbds\xc6o<\x1c\xac\x9c\xbcr\x9a\xe3\x10\xfb<\x0bF\r\xbd\xec\xc4\x03\xbdW\x89\x9f\xbc\x06R <\xc7!\xf1<]8c;S/!=\xd4u\n\xbd{\xb3\xfc\xbc\x88\xf8^:\xf5\x14\x12=\x8c\xff\x03\xbc\xa4c\xf8<\xff\xe6\x13<\xd37\x9f\xbcL|\xaa\xbc\xadw\xa7=0\xc9\x18\xbc\xd2\xa2\xf4\xbc\x1f7 \xbdl7D;\xa0\xad\x87\xbd,)\xe8\xbb\xbb\xb6|<\xd6\xd6\x97;\x0f\xf3\xd7<\xdbF\xda<\r\x96\x8e\xbd\x80-{\xbdB\xec(=*I\x15<\xec\xe28\xbb\x86,\x8b=\xc1y\x9a=\x8bQ\x9e<\x91\xf5/\xbc\x8f\xf8\xc3\xbb\xd1\xd9\xa5\xbd&\xc9G\xbc)cp\xbc\x95<\x85=\xdeZ\xc2\xbc\xb8~\x9f\xbc\xc3\xff\xc0\xbcJg\x808$\xd5\r\xbd\xb3\xec\xe7=\x9am\"\xbdF\xc8\xe0\xbc\x7f\xa4\x0f=(\x0f\x0c=\x15\xad\x19\xbc\x9e\xd9>\xbd\xac\xf5>=\\\xed\x06\xbc>XG\xbd\x10\xc5*=&\x83,=\x9d<\xa4\xbbEs\x96<\x85\x05\xd5;]\xb0\x10=\x11x`;6-\xff\xbc\x92\t\xa4\xbd=d\xbc\xbb\xc9\xb5)\xbc\x8c\x92\xa6\xbcp\x8b\xb0\xbc\xf8\xcb\x85\xbb\r\xbfd=\x00\xfc\x81\xbc\x15\xfc\xb5\xbc\xba!\x80\xbc\xa8\xea\xa6=5\x83\x03=I\xe9\xa4\xbcf\x0bC:\x92\xe8/\xbdz\x80\x9a\xbc\xc4\x9d \xbd\xb9\xcc\x0f\xbd\xce^\xa5=\x9b\\U=\xac\x98,=\xbdSn<\xed\x14\x0f=\xbe8_\xbcA\xcc\xe3\xbc\x8a0d;@\xe6)\xbdZ\xeb\xde;\xe7\xad\xf0<.\xf4O\xbd\r\xa5\xd2\xbc\xd8\xec\x8f\xbc\"\xac\xd0<\x05\x1a\x9b\xbc\xc1\xe9\x84\xbd59\t;g\xcf\xa4\xbd\x042\"\xbb\xbe>\xc5\xbb\x9d\x00\xb7;\xf5$\x02;\xeb\x1d\x9b\xbcq\\[=\xcdY\xc5=\xf4\xe0J\xbc\x8d\xd9\x93=\x11\xba\x83\xbccj\x94<\xb5\xfeY=\x9b\xde\r\xbcd\xe1\n\xbd\xb3{\xd7\xbc\"\xb59=%\x93e:\x13\xa2\xea\xbb\xf2s?\xbd\xab\x9f@\xbb\x07\xb4\xe0;\xb5UA=@\x0ep\xbcD\xcb\xc7:\n\xed\xc5<\x1d\x1d\xee\xbb\xb4=\xd2;\x8a\xd9\xac\xbc\x8a$1<\x91\xf7\xb0\xbc\xbd\xa8\xd5\xbd\xf9\xb9\xb5\xbaCY+\xbd\xda\xea\xdd\xbc\xef@\x0c=1+\xa9\xbc\xf6\x90\x14<\xf4\x88\x91=K(\x8e\xba\x7ftF\t\x9c\x82\t\xbd\"\x13\xa6;\x18\xcfr=\xbd\x82Y=\xf9}\x93\xbc:LP=\xd3\xe3+\xbcj^9\xbcz\xabA\xbc5\xa7z\xbcP\xd9!=\xcem\x0c=\xd3\xd4\xda\xb9\x90\xe2Q=\xeb\xa0\x9e\xbc\xfb\x7f\xa7<\x12O\x1b\xbd\xdf\x9ap\xbc\x1d\x18\x90\xbc\xa5t\xa0<\x88\x03\xcb\xbc\x1cJ\xd8<\xf0n\xea;r\xd3\xee\xbc\x1b\xda\xff\xb9\xf8\x9a\x9b\xbc\xe3\xa21=J\xf0\x0f=\x95\x1aC=\x1e\xd1\x80<\xcfQv<\x94\xb4\x1e\xbd\xf3K\xc8=|\xebk\xbb^M(\xbde\xd3\xd6;-\xfa\x7f\xbd\xe5\x19T\xbd\xe7\xce\xc5\xbc\x95\xb3\xff<]\x9e\x8av\xbb<\xb0\x16\x03<\xc7U\xc0\xbc\xa8b\xe5<\x9ew\xd8\xbc\xf8\xc9\x8a=O\x0b\x8c\xbd1|S\xbd\x954\xaa\xbd\x06\xf1p=\xc5\xe0,\xbc\xd8\xb6\x8c\xbd=u\x86\xbd\x08\xe5b\xbc\xf5P0\xbdF\xfd\xe8;\xf6\x9d&\xbc\x1do+\xbd\xed\xb86=\x0bh\xa9\xbc\x1b\x86\xb6\xbc\xe0\xae1<\x03f\xec;tW\t\xbdz\xc8[\xbcE\xfc\x03<\x950\xef<\xacM\x0f\xbd\xfd-\x89<\r\xa7\xa0\xbd}\xe6\xa1\xba[#\x07=Yl\x1c\xbc\x16Fl\xbc\xbd8\xb9\xbc\x104\"\xbcK\\C\xbdx\xf9\x08=\xaf\\\xa5\xbb\xe80k\xbb\x0c7.=\xeb\x18x=\x82\xa7P\xbc\x19\x1d\xbb\xbcW\xf8\xed\xbcY\xd6#\xbdm\xb8\xe8\xbc\xe6@\xc9\xbb\x15+\x1a\xbd\xd0\xe1\xe9<\n\x837\xbd{A\x94\x17\xbd1f\x0c=\x0e\x0c\x89=\xbd5\x14\xbd\xf9l\xb1<\xbc\xd9\x0e<\x19\x10)\xbd\x8cn{\xbc\x1a\xf0\x0e=F\xaa\x90\xbd\x12\xcb\x8c\xbc\x08\x87F\xbd\xce\xb43=\x162\xd9<\x92\xb1\x02\xbdVS\xc6\xbc\xf8d\x9d=\xef\xe4\x19\xbcl\x13\xa2\xbbM\xc1<8\x97[R\xba,\xb2\xc6\xbaA\x85\x18=a<\xb97n\xf8n\xbc^)\x16=\xa5Q\xb6\xbb<\xb4M\xbdo,\x97:\xdc\xd6\xc9\x8d\xbc\xfa\xa6\xb3=D\x0cF<\x93A\xc1\xba=\x9e8\xbd\xce\xb0q=\x80B\xb0\xba\x16\xfd\xe0<\x96u\xba;f\xda\x1c\xbdmbM\xbd\x84cp\xbcm\xa48=?\xb3\x88\xb7\x1acc\xbb\xd4\xedX<\xbb`\xa4%\xbd\xbe\xb4\xa5\xbdR\xe4\x9a;\xf3\xdeQ\xbd\x84\xcdK;>\xb1P=?]\x9f=R#\x0c\xbd\x9e\x9cwL\xbc\x03 `\xbc=V2=\x87\xe2s;a\xf8\xef<\xc5\xb8?\xbd\xd7`\x9e;k^\xbf<\xf7\xe4r\xbd3\x19\xd9\xbbL\xe6\x8b<\xbc\xde\xb7<\xcfm\x06=6\xc3\xba=>1\xee;$a*=6\xacI=w\xab\x82\xbd\xe3\xf3\x85<\xbe\xe1\x03\xbd\x17\xef\x00=\xda\xf5\xc7\xbc" +HSET bikes:10082 model 'Millenium-falcon' brand 'Pedal pals' price 2385 type 'Road bikes' material 'full-carbon' weight 15.7 description 'This is our entry-level road bike – but it not a basic machine At this price point, you get a Shimano 105 hydraulic groupset with a RS510 crank set. The wheels have had a slight upgrade for 2022, so you\'re now getting DT Swiss R470 rims with the Formula hubs. If you\'re after a budget option, this is one of the best bikes you could get.' description_embeddings "\x93f\xd1\xbcO\xcfT<#\xbe\x89\xbc\x8a\xfb\x0b<\xac\x92\x86\xbc\xeb\x1c\xb6<\xa1\xf1\x04\xbd\xb9\xb4\xbd\xbcv\x18\x08=\xd3\xc3\xf2<\xe8|\xce<\xc2\xc8\x88;\x16\x83\x0f=\x10\x88C;6\xbe\xb6=\x93\xfa\xe4\xbcl\xae\x90=\x96\xe6Y\xbd\xf6\xbc\"=%\x1a\x8a\xbd@\x187\xbb\x8a\xd0\xdf\xbc\xd4D\x0e=\xbf\x19\x82\xbd\xb65y\xbdh60=H\x08M:\xc42-\xbb4\xe4W\xbdy\x9ab\xbd\xf3\xc1L\xbd3\x08<\xbd6\xa87\xbcl/o\xbb\x10MK\xbd \xec\x88<\x8f\xe5:=dX\x04\xbcm\xb8\x9c\xbc\xd0\x8e\x04;Z\xfd\xde\xbc\x8c\xc2\x08\xbd\xdb\xf9\x80\xbd\x99\x03M<\x10\x95\xe5=\xd52\xb3<\xda\"\xdb\xbb\xbb\x07\x1b=\xa4\xef|\xbc\xaa\xe7\x80\xbbq\x8dN\xbc\xd3*+;\xb4\x9bL=\x1f\x9b\x17\xbdUW\x83<\x92PU\xbd`\xc1\xcf:\xd1\xc9\xfa;\x8d\x1b\x02\xbd\x01\xfec=\xaa\xb51\xbd5}\xa0<\xdd\x0c\x13\xba\xe3\xd9\x80<\x05/R<\x87\xd0W=\xc7[\x85\xbcx\xbdg\x19\xbd\xee\xe3\x9d=\xc5\x9bc\xbc3\x98z\xbcH\x19\x95\xbc\xc5S\xe3\xbc\xdc\xbb\x98\xbc\x07\x90\xc0=H\x1c\x9e\xbcpD\xcd\xbc\x04H\xb8<\xaa\"\xc3;\xe4\x9dM\xbc:\xabf:\xa8ft;<\xbd\xc4<\x07\xda\xb4\xbc\xebC\x13\x01\xbc\xdb\x8a7:\xa0c\xa1\xbd+\x1f\xa4\xbb\x19\"\xcb\xbcV{\x03\xbc.8\x9e;\x17\xaf\xf0\xbcm\x07+=f\xe0\x13;\xb6\"\xaf<\xf8]A\xbb\xad\x10J8\x8d\x1f\xbc<\x0e\xb7\xc8\xbc\xbd\x96\xcf<\x02\x035=\xac/p=\x8eOW=\x1e\x02>=\xeb\xaae;\xe7\xae=;@\xdfh\xbc0\x9e\x9f=O\x7f\x85\xbcKs\n\xbdM\x04\xea\x9e\xd4\xbc\\ow=\xd3M:<)=\x97\xbc\n\xf0\x88\xbbc\xd6\xf2=\xd5\xc1z\xbc\x81\xf0\xb2\xbb\xcaDQ\xbc\x15\xbe\xd5\xbd\xb9\xd2\xe8<\x14]=<\xf6\x0c\xdb\xbc\xc5\x17\xf4:S\xf3%\xbdXk1=\xc8\x98\xb8<\xe5r\x8f\xbc\\\xb1\x0c=\xf5\x13\xf7;\xbf\x1ft=\x1b\xf8\xa7<&s\x8e\xbc\xa5\xeb\xc7;XJG=5\xf3\x05\xbd\xa8\x19\xf2<\xe0\xf2\r\xbd\xc3\x16\xff=\x0e\xe5+<\xc1(\xf3\xbc\xce9u\xbc/\xdcC\xbd\xc6\xc5\xc5\xbd\xe0;:=\xc5\xc0S<\xc2\xbe\x87\xbcR\xb3i=`\xb6\x89\xbdV\x80\xdf<\x08\"Q\xca;\xf2\xd2\n\xbc/\x9d\x9c\xbd\xcf\x9cm:\xa2l\x91\xbc\x1a\x9e =\xa9&\x84=d\xf3\xa9;\xa4\xbc\x12\xbd\xdc\xe8\xed\xbbi\x1a\xc2\xbcK\xfa>\xbd#J\xb8<\x0cZ\xbc\xb5d\x830\xbd?\xed\xd8=t\xea\xa5\xbc\xc2\x8f\xa2\xbc\xa6\x1f\xc2\xbc\xec\x11\x1c\xbc\xb2]\xae=\xdd?\x16\xbceU\xbe\xbc\xa9w\xb0\xbc\xd95\x80<\x1d\xd0\xe1=\xc0\x02\xff;f\xe8\x10=\xb8\xb2\xc0<\xac\x89+=\xe9\x05\t=\x1c\x15\x84=\"\xc6\xe4<&?o\xbc\x8d\xf7Q\xbd\x84\xae\xc0\xbc\xce\xff\xbd;I\x95\x85\xb8\x0b\xbd\xefg\xaf\xbd\x1eY\xa1<\xd8\x92\x1d;\x9b\x97\x00;/\x16#=\x95\xdf\x06=Ux\x04=\x1c\xce#\xbdyl\xae=D\xbb?\xbd\xd9\xd7s;}3_=\x9cM\xaf<\x8f\xb5\xa9<\xa4\xd6\x11\xbd\xc0\t\xd1\xbc\xef\xb9\xd8<\x98\xed\xe4\xbc\x1b\x8f\xb2\xbc\x87\xdf\x9b\xbc\xad\xf1\xc5;\xf0~6\xbd\xff\xc5P\xbd5\xbf\xca\xbc*[\x9f;u(\x13\xbdr\xf1\x12\xbd\x9aG\xb1\xbc\xba\xe8\x84\xbd\xabJ\xae:\xfeH\xaf\xbaG\xe4\x00\xbd\x12G@=!\xdb\x0c=\xec9\xda;U\xd3/=Zz\x80\xbd\xe7\x1b\xa1<}\xb0s\xbc\x12k\x95\xbb\x9f\xb7\x8b<\x85\xfc\x90\xbb2\xa9\x00\xbd\x9cT\x94\xbc\r\x10\n<\n%h<\xf3\xa6\t;\xa8.\xe0\xbb\x1fU\xc5\xbc\x10p\x15\xbb\x9e/\x05=\xee/\x8f\xbcQJ\xca\xbch\xe3\xd0\xbc:\"\xd6=E\xbcH\xbd\xb69\x8d<2\x99/\xbd\r\xdar<5FL\xbd\x96\xe5\x00=\x94<\xcf\xbc\x91aG\xbd\x85y\x84=\xec\x0b\xc7\xbb\xbe\xd8d\xbc\xc5E4\xbdH\x03l\xbc\xe6:\xb6\xbc\x86\x0c\x1e\xbdt\x9c\x91\xbcc\xfa\xc7\xbc\xb1\xba\x15\xbd\x1a.\xee\xbc\x86\x07\xd1:\x9aC\xd1\xbc\xd4\xc5\xf2\xbb\x8f\x07]<\xf2[Bo\x94=\x03NS\xbc\xf4\xbcz<\x89\xf3\xcd\t{\xbc\xbc\xaf\x8a=a\xd5<;[K\xc0\xbc\xbd\xe8\x1c;\x14\xd7\x80\xbd\n\xa2\x90\xbc\xbd\xef\xa0\xbb\x89S\xde;A\xd8\xf6\xbc\xaaC\x87\xbc8\xe8t<\x14!/;\x18\x8e\xc4\xbb\x92\xf2\x0b\xbd\x9f\x12\x1a=\xe0\x18\x99<\xf2\xa15=\x06\x81\xdc<\xcf\xef\xa8\xbb\xe2\x92\xd8\xbb\xff\x9bh\xbc\xc2\x17\x92\xbc\xe7\xf9\x9d\xbdM\xec\xf6<\x07\xf3\xc6\xbb\xb3\x1a\x98\xbd\xa0U\x94=\xb4\x1e:\xbd\xe6\xe6\x07\xbdh\x9b\x9e\xbc\xb7\x15$\xbdm\xfb\x82=\xff\xee\xe3\xbc\x0e\x81\x88\xbc\xa2\xd7\xff\xbc\xb3\xb5\x1d<\xfe\xaf\xd9=\xf3L\x1a\xbcx\x84\xa0<\x8f\r\xf2;\xa37+\xba\"\xa4\xdb<\xf3T\xb2=%\xb6\x85<\xb7h\x1e\xbdC\xafM\xbd\x83\xa6\x1e<#,\xc5;FF\x89<\xff\xe8\x00=Yc\xd8\xbb\x85\xac\xd5=\x93d\xf1:\x06\x00Z\xbd\xd4\x05=\xbc\xa5r\x9a<\xf5\x83\xf0\xbc^\xd64\xbc3\x99\x7f<$Ga\xbd\x1bX\xbc=J\xd2\x8d\xbd\xae\xc5\x04=\xe2\xe7\x9d\xbc\xb5\xde*\xbb\x02\x17\x0f\xbd\n\xc7\xf1\xbc\x11\xbe\xed<\xc7\xe8\xb2:\xa2L\n\xbcN\xe9#<\xa9!\xa0<\x1b~\xaa\xbc\xe98\xdd\xbc\\+\x18=\x9em\xdd\xbc\xa4\xd8\xc6\xbd\x1e\xa9\xf7<\xb1\xcc\x05<\x00Z\xb6<&3\x02\xbc\x1a|\x98\xbdL\x05=<+O\xd6<\xbe\x1f\xea\xbb\x0cvB=Y@]=\xef\x9d\x85<\x1d\x90d=\xce\xa6\xd6\xbc\xc3\x9e\xca\xbc\xe3\xcc\xe29E|\xf9\xbcY\x854\xbc\xae\xc5\x9e=\xcdwN\xbc\xd1\xc4\xe9\xbcW\x97\xa0\xbb`_d=\xf9\x8c9\xbd\xe8\xf6\xd6\xbb\xfbvm\xbc\xeb\x80\x02<\xd2}\x06\xbc\xea#L=\xb3|\xa8\xbc\xfe\x06\xc9\xbcs\x919=7\x8f\xab\xbbA\xe5\x9d\xbb2rx;\x7fR\xe7\xbb\xf2\xdav=\xa0B\x85=Qb\x19;\x05\xfd\xb4:\xb0\xb3\xabd\xbc\xfb\xe6\"\xbc\xde\xc8\x0c\xbd\xba\x8e\x86\xbd\x80\x1d\xd1\xb9\xcf\xc9\xbe4\xbd\x94\xe0\xd0\xbc\xb0w9=\x9a\x80\x8a\xb9\xc7\xa2(\xbd\xf4\xe6\r\x9ep3\xbc\x03\xda\xb0=\xd6\xe5\xfb\xbbC\x10\x82\xbd\x87wH=\xf9\xb1{==\xc1\xc0;+BF<\xc5\xd2\x04\xbc\xf8\xd3o\xbd?\x1cg\xbd\xd1[%\xbds65\xbdG\"\xcf<\x1c\xe8\x16\xbdys\xe4\xbb9$5=8\xdb!\xbd\x05}a\xbd|\xaf\xbc<\xa7\x19;\xbcT\x03J\xbd\xa9~Q\xbd\xa2m\xbc\xbb\x97\xff:=\xe2\xce\x19\xbd\x16\x1b\xbe\xda\xc9O\xbd\x18\xe9\x10\xbb\xce\xcb\x98<\xd3\xdd]=` \xa3\xbc\x90\xa1\xb7<%LQ=\xa1$\x98\xbc\x1d5\xa5=\xa6\xd4S\xbd\x10\xab\r\xb9\xbf\xd1\xba\xbbc*\"\xbc\x87\xa1\t=\xba\x1f\xee\xbc\xc3\x94\xdb\xb1\t\xbc\x11\xd1\x1c\xbc\x02\x98I<\xd0\xa4\xb4\xbd\xe2G\xd8\xbc\xbd\xfd\x85=\xf0\xc6\x90<\xc4\xbaK\xbd\xda\x87\xe3\xbc\x10\x99+\xbc\x0b.\xce<\x91\xaa\x19\xbb\xfe\xf2\x81<\xf2\xd2V\xbd\x13\xdf\x19=\x81\xfe\xa8\xbc\x8fi\xa4=^67<\xad\xa7\t=\x98(Y\xbbf\xff\x9b\xbb\xba]B\xbd>\x81\x18=Z\x9e\\\xe9\x06<\xc3\xefv=3\xda\x85<\xa1\xe9\\=>\xf3*=v\xe8\x0b\xbd\"\xb7\xb6;\x17C\x06<\x9a\xa5(=\xa0\x9e.<_\xb7O\xbd\xbd\xa4\xb9\xbc\xa2\xf6\xa4\xbb\xa4\x809\xb3\xd2;\x9cx\"\xdf;Q;p=c:s=\xff\x19\xdb<\xf2\xe9\xd4;KT\x94;[h\x939\xf86\xbe;9\x8b_<}5-=\t9\x96\xbc;\x17\xa4<\xcbE\x92<\xc97\x17\xbd\xf1Y\xd4\xbc\xb9\xed\x8f\xbd]\x0b\x9e\x19k\xbbXUU<\xe8\xb5=;\xb6\xee\n\xbd\xa3J?\xbd\xbf\x9b\xc3\xbd\xd2\xee\x83;\xcdM\x98\xbc\xdf\xd0\x06;\xcd7\xf4;P\xbc_=\xff\x19J\xbdAPY=`\xa9H\xbd\xa3U\xa0\xbd\xc5pu=s\xf2\x81\xb5,u\xc8\xbc\x91Xx\xbc`JH\xbc\x13\x0bO;\x8c\x10\x0e\xbc\xc4X>\xbc\x99\xc5\xc4;\\\xfes=\x06B\x0b\xbc\x98\xcc\n\xbc;\xa1\x1f=\xe8[\xb0;\xc4\xd2\x83=\xcd1\xd9;\"!\xfb:\xec\xd1B\xbd8\xb1h=\x92,\"\xbd\x13\x03\x1e<\x03\xf2J2=\xd1\xea\x0e\xbd\x1b\xa9\xbe\xbc\xdaH\x82=h\xad\xc9={!\xc3\xbc\xae\xfc\xb4<\xbaQ\x0b=\xd3x\xe3<\xd9\xd8\xca=^?\xed\xbd\xb2\x8b\xf9<\\\x0b{\xbc" +HSET bikes:10086 model 'Neptune' brand 'Ergonom' price 1875 type 'Kids mountain bikes' material 'alloy' weight 7.3 description 'Kids want to ride with as little weight as possible. Especially on an incline! At this price point, you get a Shimano 105 hydraulic groupset with a RS510 crank set. The wheels have had a slight upgrade for 2022, so you\'re now getting DT Swiss R470 rims with the Formula hubs. It’s for the rider who wants both efficiency and capability.' description_embeddings "\x1b\x80-\xbd\xd0\xf5\xd2\xbc\x85u(\xbd\x1d~\xda\xb4=\xcc\x91(=M\x85\x81=\x91x\x80\xbc\x84\xdcm;An\x83\xbb\xb7@\xb8=z\xa7\x00\xbd\xbeR\x98<\x1c\x0f\xa4\xbd\xd2<\x8d=\xfa0\x9a\xbd\x86Z#<*\xb0\xbe\xbc\xaf\xb9\xc5<\xac}h\xbch\xf8.\xbd\x8c\xfew\x08\xe0\xe5\xbc\x18\xcb\x1d\xbd\xca\xf89=\xda\xb5\x8b\xd4\x97<\xe9\n\xb2\xbaam{\xbc\x9e\xdc\xdf\xbc\xd6c\xca<\n\xd7\xba\xbc\xe5\xd1V\xbc\xcb9\x9a;\xd5\x8a\xdc\xbb>Z\xde\xbb]\xfbM=\xe9\x01\x86<\xa0\xe0;<\x03\x8d\xfe<@6\x83\xbc\x17\xe5\xde<\x82Y\xc7<\xd9\x7f\x8a;\xfaD.\xbd\x85`\x94<\xcd\xb9\xb0\xbc\x97\xa3\xf2\xbb\xb1\x92\xb2<\xa3H\xa8\xbb\\\x12\xed\xbc\xeeG\xa3<\xf1\xc2\xe2<\x1dt\x92\xbd-\xf6\x91\xbdp@l=p\xd9\x9c\xbc\x88\x83\x17=\x12|\xb2;\xde\x87\xba:\xbc\x07\xa0\xb8\xef\xeb\x93\xbcTg\r>B\xda\x89\xbb\xd3\xf2\xb5<\xf9\x16\x97;\xc4\xd6\x86\xbc6\xc2x\xbb\xe6\x14\x82=\xf2w\xe1;\xe3\xad\xd2\xbc!\xe0\x86\xbd\xe8E\x8c\xbd>m7;\x93Q\x93<@\xab\xbe<\x00\xe7\x0b\xbd\xd3a\xf7;_|\x07;\x14\xf5\x9b:\xe8]\x8f\xbc\x0eM\x07=\xb9\xcd\xdf<\xf0t\xc1\xbaYb)\xbd\xe0\x8be\xbdU\xdc\x83\xbdA\xf2\x04\xbd\x8b\x841\xaa\xbdq\xdd+<\xfc\x19\xc1\xbd\x804&\xbc\x80%i=\xdbo\x10=\xf7\x8d\x8c=h\x9a\x08\xbc\xcd$E;\xbe\xf4\x1d<\xac5I\xbb\x9a\xe4B\xbc\\>\xfd\xbb\xcd\x05\xee;\x9em\x91;i&S=l\xba\x0e\xbd\xa7\tK<\xcd\xa1\x96=\x03s7\xbd\x95\xc4\xa4\xbd\xe5w6\xbd\xaa\xbfo\xbc\xb8\x15|<\x8f[j\xbc\r\x90\x82\xbc\x16\xf7n\xbd\xd3\xfa7\xbbN\x0e\x04=\x92,\x9c=\xb0\xd6\xb8\xbb\xe1\xe7w=\x0e\x8b\xb4\xbcN\x897\xbc6\"7=\xc0\n\x9e\xa7;<]>=\xe1T\xc2;\x12XM=\x82\xb6#<\x1b\xf3\xfd\xbb\xc8\xb63\xbdo\x95@\xbd\x99\x87&=\x94\x18\xa9\xbc\xbc-\x0c8B\x9b\xc6\xbc\x8e\xb7#\xd7:\r\xb0\xcf<\x0e\xed`<\x9a\xaf\t\xbc\xcd\xc1M=\x90\x85D\xbd\xa9C\xb1\xbd\x85\x0c\x11\xbd\x1d\x19v=gi\xa4\x13\x9e;\xa9\x14\xcb\xbb|\\\xb9\xba\xac\xa7\xee<\x1c\xd2\x1c\xbd\x94?%=\x0c\x9fM\xbc\x9a/Z=\x9fc\xbf\xbc\xec\xed\xe0\xbc\xd6j\xcd;\xad \x02=N\x8f\x07\xbd\x12\x92\x04\xbc\x92\xe6\xf1\xbc\xed\xa5G=\xbc\xf6]\xbd\xd6R\x8b\xbb\x7f\xa0\x0c=W\x95\x8d<=\xe2\xa0\xfd\xbaV)\xbe\xbb\xda\xc6\x80=\xfal\xdc<\xe5_\xb6\xbb\x89\xdb\x0b\xbc\xfe\xc9\x05\xbd\x1a\xa7\xee\xbc\xe9NI\xbb\xa1B4\xbd\xd1Z\x99\xbb\xee{==7y\xa8\xba2O\x03\xbd\x84ix\xbc?\xb8\xe3=\xdb\"\x9d<\x9217=\xd6g\xdd\xbbp~\xc7<\x12T\x95\xbb\xf2r\x87=j\x16\x92\xbc\x82P\xcf\xbcd\xa2\x8d\xbdw\x0cM\xbd\xbe\xfe\x07\xbd:\xb8t<\xe5\xfb\x01<\xab\rW=+x\x1c=\x1bR\xb3;\x8e\"\xd6\xbcPC\x1b\xbd\xa77T=!\xb1\xa9\xbc\xfd\xfeG=\x8b\xb9\x84\xbd\xe1xP\xbdw\xbb\x18\xbd\x1d\xe0\x06\xbdWK{\xbd\xaa\xc0\x1e=\xf2x\xba\xbc\xdc\x81\x03=\xdc\xc1Q=m\xac\x86\xbc\xf4\x02\xac\xbc\xe2?\x0f=\xc6\xd8\xb1;\xday#\xbc\xbb>C\xbd\xc3\xb5\xb8:P\xdc\x7f;\xc9H\x03=\x18\x7f\x80<\x80\x1cH=\x81(\x1e=\xf1\x17\x0f=\xd1J\\:=\xbf\xdf\xbc\xab\xe2(:\x05\xb3,=c\x152\xbc\xca\xd1\x97\xbc\xf8\xe7-\xbd\x91\x85\x0b\xbd\xdbi\xc5:\xf6\xa4]<\xe3Z\xa7\xbb\x8do\xa7\xbc\x8b\x90\xdb<\xd1-^\xbcH\x92G<|\x93\x06=\x96g3\xbc\x04\xb5\x9b<\xc4\xd0\xe9\xbc\x05\x1a\x9c\xbd\xdaB\x14\xbcQ\xcdN\xbcm\x11\x95<\xefy?<\xe2Z\xeb\xbc\xdeU\xe4\xbb[?\x89<\x96:\xdes\x92F\xbci\x9c\xd1<\x9cb\xa3\xbcpv-\xbd[\xed\";\x16\xc6\x11=\x00A\x1a\x9a\x1e=<\xeb\x10\xe9\xbc*WK\xbd]G\xa4\xbb\n\xa3\x00:\x87\xb1g\xbd.\x81<=7:\x83\xbc\xd9\xe9D=n0n<\xde\x8f\x1e\xbcY\x9d\xbe\xbcw\xa9\x82=\xf8\xae\x01>)\xdb3\xbdmf\x96<\x9f\xd4U\xbcB;B\xbd\x00P\x99:\xb2-\xd1=f!\xa8\xbd1\x1a\xa9\xbc\xa2:\x1d\xbd\xc8\xae\x81=\xd1\x05\xc1<\xc2\x05M=\xd8\xbd \xbd\xf3\x8f\xf6\xbb\"\"E\xbbF$\xf2\xbcb\xa01;\x9c-5=\xbd\xdf\xa5<4\x0e\x18=\xa4I\xe4;0v%\xf3\xbd\xa9G`\xbd}\xf4\xbc\xbc\r\xfc\xc3\xbd\xf8\xd8\x12=u\xc0\xb2\xbb\x15\x06\xc2\xba\xda\xfc0;\xf1?f;\xfd\x94\xbe:\xbc\x8f\xac\xfa<\x9a\xa1|\xbc\x07s\xe9<\x1c\xf5\xcc\xbd\x182\x12=/\xddr<*L\x8c\xbc\x1b\xb3q\t\xe5\xab\xd7\xbcZ\xb9\t\xbd\t\x072=\xf82\xb3=\x98\xec4=~b!=\xa1;\"<\x03\xe7\xac\xbc\x0cV\x92;(\x01\x01\xbd\x9ez\xbd:\xb4o\x82<\xfanK\xbb\x1f\x01\xba;\x05\xd6\x17\xbd\xce2\xa0\xbb8\x92e<\xe0(\x1c\xbd\xe2M\x03<\xd5\xc1\x8b=u\xe4N<\xe6g\x11=\x88\x07\xfb\xbc)\x1e\xb4<\x03\xe9\x8f\xbc\xcb\xa3\x81\xbd\xdeQ\x19=\xfc\x190\xbd[E\xb2\xbd\xf9\xefW=\xe5\x16e<<5\x00\xbc}\x88\xc1\xbc\xc7\x02%\xbc\xc4.U\x92\xbdMA\xdc<\x86\xcbd\xbd\xcf\xc7`;\xe2>\x1c\xbc\xe2\xbb\xc5\xbb\xadx\x10\xbd\xa4\x06 \xbc\xc7/7;\xb0\x14\x19=g\xcd\xed\xbbxq5\xbb\xfe\x82\x85;\xa4\xaet\xbd\xc1\xed\x96\xbchO\xfc;\x19B\xd7\xbcP\x02\x1b\xbdM`0\xbb\xbc\xd0\xf6\xa7\xbb\x84\xa0\x8a<\xaa\x979\xbd\x97\xe8v=\x89\xf3\xb7<1\x9f\xf2<\xb3R\xfc\xbb\x86\x19\x9d<\xcd!\xc0<\xa4\xc9\x00=\xe7\x0b\"=\xadi\x1f\xbd\xbe\xd6\x1b=\xae\x1b(<\x12\xff\xea<\xbe\xd3=\xbd\x0c\x9b\xf9\xbbYi\x97\xbbb?\xe3\xbci\xad<=\x90w\xba;\x01zX=\xd4.\x80\xbd0\xea\xee\xbc_\x881\xbc\x01\x14\x98\xbc\xb9(\x99\xbcK\xc0L\xbc\xee 3\xda}\xba\xcep\x0b\xbc1`\xa8\xbdG\x9e\x80=\xb1\xf4W\xbb+Ef=d#\x8e;\xa8\xe4\xec:\x8a\xfaC<\xfd\x89\x13\xbd\xbelF\xbd>\xdf\x19\xbdjS>\xbd\xbbv\x0f=7\xb3\x82\xbc\xb3=\x93&=\xd4\xdb{=\x85\x8eE=w\xa4\xd2\xbb\x15\x18\xb2=\xc6RK\xbd\xe6N\x95\xbc\x1a\x18\xe8!\xbd\xc3d==\x9c_\x1e=\x80\xe2N\xbd\x00\xc5+\xbc\x9f\x95\xef<\x08o:\xbc\xd0{Y\xbd\xe4d\xaf;\xba\xe7\xb5\xb4|\xf3<\xf6\"\xbf\x83=\xaf?\x0f\xbd \xbb\xdf<\xbd\xca\xb1\xbc\x1f\xe51=\xd9K\xd6\xbb\x04\xd2\x82\xbc\xc3\xa8$\xbb\xa4\xc6\x87<\x9f\xaa\x00\xbb\xe5\xa2\xea<\xc9\x9cn\xbc\xfc\x13\r\xbc\xef \x0c\xbd\x02l\n<\x04\x0b =^\xe5C\xbd\x8f\xdc@=\x92\x0b\xbe;\x14\xcaD\xbb)\xc4A\xbdTk\xe3;\x8fi\x0f=Q\x8d\xa4;\xfa\x1b\x88<\x93\x92\x08=-7.\xbc\xa9\xb0\xb5\xbc\x7f\x9ds=\x18\xf1::\xe6\xf9\x10\xbdA\x13\x1c\xba\xa3j\x11=\x89\xca*\xbd\xa7mZ=.<\xc4\xbbx\r<<@\xe8\x9a\xbc\xbf\xc7<<\xb7K)\xbc{\xf9$=\xab\x9f\"=;\xdb\x96\xbdO~K<)\xca\xf6<\x9f\x958=\x9f\x1f\xf7;e\xd0\xeb<\x1e\xc0!=\x1e\x1c\x8d\xbd\xd5\x0f\x83=\xdb\x18(<\xc1i\x89=\x99:@;\xb9y\x8a\x03i=\xaad\xd3<\x90\x9cJ<\x19\xf2\x89\xbc\x90\x9b}\xbc\xe0\x02\x9f\xbbM\r\x9e\xbc\xc9\x1aq<\x19I!=%\xd1\xe79!*\xc0=F\xb3\xaa\xbcP\xdd\x16=7>a\xb9\xe24:=\xc3P\xdd\xbc\xf3\xfd\xf9\xbb\xa2s\xb5\xbc\xfe\xc8\xda\xbc\xd6\x91\x97\xbd\xba{P<~\x99\xfd\xbcy\x8b\xbd;\xa6\x8aM<\x96\x9cg<_8\xb78\xad\x1a\xb7\xbc\xa8\xc7B\xbdg\x02\x08;\x18\xef\xb5\xbc\x01\\\xb4\xbc\x83\xde\xc8\xbd\xb6\xe4\x82<\xed\xebL\xbc\x14\xb6\x0b<\xd6\xc8\x8d=\xbb[\xed\xbcE\xc4j<}d\x0f\xbd\"\xae\x0f;\xc2nF;\x01]\x15\xb9=9\xee\xbc\xdc\x0b\xcf\xbc\xbdB\xb7\x0f\xbd\xe9\x15\xc1=H\xbc\xc9<\xc4qQ\xbc\xc9b\xc9\xbc\x83;\x0b\xbdj\xfd\xa2<\xef\xbc]\xbc?m\xd4<\xd5\xfd\xed\xbc\xfe\xaf1=\xa1V\x0c\xbd\x1a`\x81=od4\xbc}\xc1\xe8<\xdc\x00\xbb\xbc)#\x0b;\x0b\x97\xc8\xbcx\x9f\x0f=.J\t\xbc\xc1\xaeE\xbdzm5;\x19/1=L\x0c\x17;\x1b-\x04\xbd\xc8\x7f7<\xfba\xbe=\xef\xf9\xfb;\x16\xa3\x12;\xb2C\x96<\xc3\xfd0=ga.\xbd|\xe6#;\xd6\xa0\xc3;\xc0?C;EGG\xbcS<\xb3\xbb\x90\x07\xdb;\xb6\x92\xd5;D\xb1\xb8\xbd\xeaP?;\xcc\xb5\xcf<\xe1Kw9;\xdb\x8a<\xbd\x1d\xc5\xbd\xbe\x99\x9d<\x18]m<3\xbb\xd4:\xcc\x05f\tGU(\xbc\x08\x91\x81\xbasq\x1a<\x80$\x97=\xb7\xda7=%69=wn\xbe<\xd3\\c\xbc\x98\xc9\xb7<\xfc\x06\xc0\xbc\xb9\x8c\xc1<\xf7\x7fE;t\x8c\x1a;J\x96o\xbb\xb94E\xbd!\x86\xdc;\xbd\xda\x89<\x9e\x8a7\xbd\x98/\xb0;\nF\x7f=U3\xf1:s\xd8%<\xb6W\x1f=\x13\xef\x009-\xee\xc4<\x18\x14\x97\x8c\xbc\x1f\x07\x01=\xb8\x14\x95;\xd6\xdc\x18\xbd\x9c(\x00=\xdf(\xc7\xbc\x8c\xcd\xbd\xbb\xda\x88\xc8<\x8d\xf8\x93<\xf67\xa0\xbc\xb6\xe2y\xbc\rT\xa5<\xf7\xc1\xc8<\xf9\x1d\\\xbd\xa8\x88\xb7\xbcy\xcd\x8b<\x032\x17=O\xcd\xbe<\xff\xa5\x81\xbd+\x12h\xbciYW\xbb\xaf\xb6 =\x0f\x02!\xbd\x19rV<\"<\x85=w\xfc\xe8<6\x08\xbc\xbc\xde\x14\"=\xb8a\xa8\xbc!\xde\x96\xbdv\x8c3=R\xbf}:Lv\x92\xba\xee\xba@<\xcb\x11\xb7\xbc\xbd\x12\xf3_=\xb6\"\x18\xbdA\xb3j\xbd\x8d\xe8\x85<*\xa52\xbc\x85\xf2\t=\xb6\xf2b=\x1dg\x86<\x1e=\x94\xbd\xc4\xe6\x99<\xd8%\x0f<\xe2\xf1\xe4;%\xb2\xc1;\x81\xdb\x83=\xf6\xb4\xaf<$>W\xbc\n\x1bq\xbcrf\xec\xbc\xf2@\x01<\xb7\xe4e=\x95bF=\xc4\x11\xeb<\xd1A\x18<`h\xa6\xbc\x10\xcf\xdd<\xd3\xd1|<\xe75\xd2<\x18\xe3:=\xf5D\xc0\xbc\x8a\xb4\x08<\xea\x82\xd5\x13<\xbb\xac+\xa9\xbc\xf9\xb3\x98\xbc\xa3\t_\xbd;\xb7\x99<+b$\xbdd\x9e\x86<[:\x18=\xbc\x95\x99<-\x84\x9f\xbd\xbf\x93c\xbc`h\xcf=Q_\x8c<\x1du\xc6<\x00\x05\xa3\xbb;\x8d\xf8<\x06C\xf4\xbb\x9eP\x9f=\xc6.\xf8\xbb\xeb.M;\xce@!\xbd\xb8o5\xbd\x0cN\xf7\xbcxh\x1a\xbcEJ\xc7<\x12/\x86<\x1c\x83\x809R?\x8b=\x1c\x1c\xb0;\x8e\xa1m8\x1dq\xd6<\x99\xeaS=\x94\xf3\xf9\xbbU\xc2\x99\xba\xfc\xf4\t=\xc7\x9c\xe2<\xac\xa8\x87\xbd\xc9q\x00\xbb\xa3#\x95=\xad\xa4m\xbd\xf6\x1c\xc0=\xe2\"\xd2\xbb\x80\xd6\x14\xbcT\x81\x90\xbc\xab\xbd\xc5=\xbf\xbc\x80<\xd9}\xec\xbb8\x11/\xbd\xc6\xa73\xbc\xfb\x1eq\xbc\xf5\xa7\xf2=\x01\xc0\x07\xbdi\x8a\xf0\xbc\x80-\x17=\xa2\xa5Q\xbc\xfe\xd0\x98<\x1b\x00\xca\xbb\xd7\x19\xab<\x04\x18\xfe=\xda\x06\xf8;\x0f\xbcR=\x07Q9;\x91l\x1b\xbc}\x19\x97=+h\x89\xbd\xfa\xa7\x9b=\xef\xb3\xd1\xba\x9eiZ\xbb19\xa0\xbc\xf2\xd8\xea<\xf0\xc8+\xbd\x11\xd7\x16=o\xdcj=\x175\x90=\xdb\x0b\xdd\xbc\t\x951<6\xad\xda\xbc\xa8,\xd5<\xa6h\x1d=@\xfa\xac:\xd1\xbd\x1c=\t\xfd\xe4\xbc\xcd\xdd!\xbc]Wn=4n\x96\xbcHl\xb2\xbc\xcf{\x01<64\xd4\xbc\xa2U\xd0\xbc\x99\x1c\x92\xbd\xe4\xceF=\xe6>\xf39K\xf0\x85\xbc_\xbae\xbc\xe7\x8b\x8e<9\x8c\x9a\xbb:\xe41=(_\x0b\xbdNag<\x18,\xdf<&J\xcb\xbcY00\xbc\x9f\x99Z\xbdW\xc9\xa0\xbc\x89i~\xbc\x9c3\x03\xbc\x94\x05\xe8\xbc\xb7H\xef;\xad\xfa\xfd\xbc\xe7HU;l\x93\x93\xbc\xe2B\x95\xbd&5t\xbd\x1d\xa7f\xbd=\x9a\x8f\xbb\"\xd5E\xbd\xa2\xd5\x80\xbc]\xb2\x14=\x03;\xd8\xbcr\r\x8b\xbdiwy\xbd_9s<\xc7<\xdc\xbc*\xc4%=V\x88l=\x9e\"\xac=N\xf90\xbcd\xee\x00=\x01\xab\x8b\xbc\x06\xc0\x07\xbd\x87\x8e\x16=>\xce\xde;\x1c\t\xf0<\xfe\xc0#\xbc1\xacM<\x15\x01i\xbc\xab\x1b&\xbc\xa3\\\x1b\xbaT\xd1z\xbc\xb5\xe6\x07=<0?=G\x91\x93<\xf7z\xaa<\x9ch\x0e=\xfc}\xd6;\xbe,\x9b\xbc:\xa7\x17<\xd8&t\xbc\xfanm;\xd4\x884\xbd\xc8\xd1\xbe<\x1eI\xc9\xbc\x94d\x0c\xbd;)\xc0\xbb\x99\xdci=\xd1-4\xbdij\xba:uf@\xbd)G\x8b\xbcNU\"\xbd+\xab\x82\xbdv\x0e$\xbc\x18V8<\x95\xe4\xc0;\x8f\x9ao=R\x17l=k\x03\x19\xbc\x95\xdb\xa8<%\xd2\x11=b@I\xbd\xee\x88\x89=\rE\x01\xbd0\xe8\x9e<\xab!\xb1<" +HSET bikes:10092 model 'Polydeuces' brand 'Peaknetic' price 3725 type 'Kids bikes' material 'alloy' weight 15.9 description 'For shy or agressive riders, paved or dirt trails, this bike boasts kid-friendly geometry and strong quality parts at a minimal price point. The Plush saddle softens over time with use. The included Seatpost, however, is easily adjustable and adds to this bike’s fantastic rating, as do the hydraulic disc brakes from Tektro. It comes fully assembled (no convoluted instructions!) and includes a sturdy helmet at no cost.' description_embeddings "\xc8*\xe6;\x0f#\xf9\xbc/\x8b\xd7\xbc\xd1$\x0b<\x81`\x90\xbd\xf8\xf1\xda\xbb]~\x06\xbd1\x04(\xbdvk\xc2=\xa7\x1d\x99<\xa4\x9f\xa9:\x86B\x1b\xbc\x11Q\t=o\x99\x02=\xb0T6=$\xee\x90\xbcA\x05)=Ovq\xbd(\xf8C\xbd@^h\xbd\xa8\xfb\xa3<\xf2\xb7\x9f\xbb\xd6\xcb\xb4;t\xcb:\xbcZ*.\xbd\xef|y;\xe3\xee\xba<\x90\xd6~\xbc\xab\x88\xa9\xbc\xf6\x10f==\xac\x17\xbc\x80\xc6\x02\xbd,\x14-=tH\x84=\x08i.\xbd\xed\x85F\xbc2\xf5/=\x90/\xe0\xbb\x83\x04v\xbc\"\xf1b;\x94\n\x16<\xe8\xcb\xb1\xbb>\xeav\xbcx\xacS<\x8a\xd8\xca\xb9*\xfe$\xbd]\xa0R=\xd6$\xff\xbaa\x8d\xf6\xbc\x1fuM<\xea\xe5b\xbcK\xc1\xb9\xbcK\x90\x0c\xbd\xab$\x83\xbc\x89g\x1b\xbc\x8d\xd4\xc6;#\x8f\xde;\x03Z\xd3\xbc\xa0\xa5\x88\xbc0\xa1\xd8=K\xa0\x8a\xbciqP=\x1f\xd7,\xbct$D=\x06]\xd1<\xc2\x06\xf1\xbco4\xb8\xb5\\j\xbc\xd1\x8d\x00=/\xb1l\xbc\xca\xeb7\xbd\xe3<\xa3:\xc2\xac\r=\x0cz\xb3;{\xeb\xa7\xbc\xac\x07\x8d\xbd\xb4\xe6\x19\xbds\xc5\x9f\xbbJ|\xbe;\x919\xe4<\xd4L\x81<\x9e\xd9C=\xaat\xfa;I\xd7\x81\xbd9\x92&=Cp\x01=P\xa9\xc9\xbc\xea\xd20\xbc7\xa9\x05\xbdS\x8a\xc6\xbc\xaa\x91\xe2\xbc-\x8b\t\xbdnIV\xbd\xa5\x16\x01=\xaa\xe9\xd9\xbc\xeag\xb1\xba\xfaeL=r\xcc\xcc\xbc\x9f8r\xbc\xc7\xf6\x12=\xd5\x85\x96\xbc\x0b\x88V\xbd_&\xad\xbd}3\r\xbc\xb2\x981==\xea\x95\xbc\x8a\xf2\xa7<\x8e8\x98\xbc\x93\xa6\xea<\x16\xf2\x07<\x1cP\xe4\xbc9i\x1e\xbc7g\xf9<\x8a\xaf\x8f\xbb\xa9\xd2\xf1;;\x8d\x12\xbd\xd1\x95\x9d\xbb\x12\xc8\xf0;\x7fE\x06\xbc\xadaj=\xbb\x18e\xbdY\xcc\x81=\x1a\\\x08\xbd{\xda\xbb;\\/S\xbd\x16\x19\xee<\xfb\x854<\xc2h\xc2\xbc\xc3\xf7\xc5<\x86\x1e\xe1<=\x8a\xdc\xbc\xe2l\x8e\xbcX\x8d\x90=\xa5z\xc3\xbc\x9aM>\xbd_>\xa5\xba\xeb`\x84B{\xbc`\x1c\"=3\xaeU=\xd8\xcb\xf5;-&\x02=\x9a\x80\r<\nG\x9c\xbd\xe6;\xa1\xba\x1a\"\xb0\xbb`\xee}\xbd~q`\xbb\xf2\x96>\xbd\xa6\x85\x16\xbd;=I=\x03.m\xbc4b\xca\xbc\xd0`\\=\xa1\x9a\"\xbb\xca@\xff<\xbf\x0c\xaa;\xb3\x9c\x08=\xa1\rk\xbc\x17\x93\xa0\xbc\xfak\xa3\xbbKg\x10<\x1d\xb7\x8f<\x02\xd3C\xbd{\x91\x0c\xbd\"\xf8\x0f\xbc\x93h\xb8\xbc\t8\xca:\x7f6\xb9\xbd+G\n\xbd\xee|\xa8=*V<&\xbd\xa01\xeb\xbc\xf9(\x16<~c\x07\xbc\x1e\xd1\r<\x9bI\xe3;w\xff\x14=X\xdf\x14\xbbH#\x8a\xbd\xee\xba>=\x9b\x0fd\xbd\x13jW\xbd\xafQ\xaf\xbc0\xf9\xe3\xbd\x999,=\x7f(\xd0\xbb\x1fG\x17\xbc\xb9\xdf\x88;\x86=\x85\xba\xda\xfa\xb5<\xcd\x1a!=.\t>=\xc0=\x00\xbb!!\x82\xbd.\xab\x06\xbcz;\xf1<_L\xa0:\n\xe3K\xbd.\x9cZ\xbb\xbdVw=\xcf,%\xbd\xd2&\xc4<\xe8\x0c.\xbd\x8d;\xf0\xbc\xa2\xc0P<\x9dr\x1d\xbc\xfa\x027\xbd\xd2\x86\x0c\xbd\xea\x1c\x89<\xc6\xfag\xbc\xfe\x9e&<\x03\x07\xe7\xbb?d\x01;\xb23\x88;e\x01\\\xbc\x86\xb2z\xbdWln\xbcA\xe4!=\xf2\x8f\xfb\xbbu\x8e\n=A<\xcb\xbd\x97\xbb\x05=l3\x81<\xde\xb4\x8f\xbc\xf5Rz\t\xbd\xd4\x9f\xbc \xfe\xc5\xbc\xac^\x18=\x88\xa5\xc6=\xbb\xfa+=\xeae =7`\xb8:)\xad\x98\xbc\x82\xf3);xIQ\xbcqi\xdf\xb9\xbd\\9<\x9c)\x1e<\xff\xb3\x85\xbbz\xc3\x0b\xbd\"\x92\xa8\xbbyq\xa5<]b\x04\xbd\xb4U\x04<[\xaa\x8b=\x96K\x8c<\xcaY =\xd1\xd1\xbc<\xed\xc8\x9b\xbc\x9c\x85\xba<\xf1M\x0e<\xdd\xe0\x04=Q[-<\xa4\xb2\xc9\xbc\xfeP\xbc\xbc\x87\xc4+=\xcc.\x14\xbb\xb1=\x13\xbd\xc3\xceL\xbd~\x8d\x1d\xbb\xa7\xbdS\xbd8\x08\xf0;\x92\xb6,=\x99\x15\xb8\xba\xe6\xa3\xca\xbb\xf8\xa1\x18\xbc\x83\x94\xe4\xbc/\x19\x81<\xc3|\xa9<\xdd\xb2\x1a=MX4\xbd\xa0\xcb\xa5=\xddE\"\xbd\xae\xc8\x12\xbd\x18\xef\x80\x85\xbd\"\xe6e<\xf2P\x9b<\x0e\xae\xfc\xbc\xd3F\x80\xbc\xf3i\x18\xbd\xfd-n\xbd\x0f!o=s\xf1\x9d\xba7\xcc\xd3<\x01M\x92\xbc5\xf5\x06<|\x95\x98<\xd7\x84\x05=\x10\xcd\xed<\x17\xcfi\xbd\xd1\xf2\xd7<\"\xec\xab\xbc\xba.\xed<\xbcch\xbd\xd8\x8c\x05\xbca\xef\x83\xbd\x91\xd6\xbb\x8e<\x1a\xdc =\xb7\xf6\xe2;\x8e:\x99\xbcF\xed\x85\xbd\xdd\n==Nq\x00\xbd\xc6\xdf\xf0\xbc\r\xe3\x88:{\xf2\xdf\xbcA\xdf\xbc\xbb\x03C\xce\xbcG\x1a\x0c\xbd\xda\xb8\x94=(\x17\xd9\x1d\xbd\x85\x06\xf0\xbc\x0f\x0e\xac\xbc\xc2\x88\t\xbc\x1b\\A\xbd^\xe43=\xb2Y\xa2<1u\xc5\xbb\xc8A5<\x80\xf5\xad\xbd\xeb~\xb8\xbc7\x0b\xf3\xbc\xc6\xc1\x1a\xbd\xb1\xfc0\xbd\x9e\xc1f;\xf2\x18\xd2\xbc\xed\xf7\x1f\xbd\xf7C!\xbd\t\x9e\x83\xba\xb8J\x97\xbc\xf4\xb2\xa1\xbd\xe1d\xe4\xbc\xe0\xaf\xbd\xbc\x1b\xd6\x0b<\xad\xec\xa3\xbb\xc1S$\xbcI\x8e\xf6\xbcf<\x02=t\xa2\xab;\xccX\x04=\x99E\x8a\xbc\x8as2\xbd\xcb\x9c?<\x7f\x11\xd2;\xd8\x1c\x02\xbc\x80@\xdb\xbck-|\xbd\xd1B\x8d\x91\x1a=v\x92\x8f=\xa9\xb8.\xbd\xf8rD<\x98\x05\xca\xbc\xff\xc3I=\xf6\xb3 \xbd" +HSET bikes:10094 model 'Triton' brand 'Bicyk' price 3978 type 'Enduro bikes' material 'carbon' weight 13.4 description 'This bike fills the space between a pure XC race bike, and a trail bike. It is light, with shorter travel (115mm rear and 120mm front), and quick handling. The Plush saddle softens over time with use. The included Seatpost, however, is easily adjustable and adds to this bike’s fantastic rating, as do the hydraulic disc brakes from Tektro. It’s for the rider who wants both efficiency and capability.' description_embeddings "\xf2\x0c1\xbcj\x19\xcb\xbc\x9c\xfa\xd0\xbcN\x842<\xfc\xb6\x9b\xbc\xee~3=\x141\x04\xbdDJ\x14\xbb\xa7\x1d\xa9=\xafSh<]\x1b\xd8\xbd\xf0\xcb\xe2\r =Dn)\xbd\x17qR<\xf8\xb1V=\xd3\xbd\x92\xbd\x8a\xbf\xfc<\xcc\xa2\x1d\xbc\x1b\x8d\x0e=57\x83\xbc\xb4\xe2\xb6\xbc\xcf\x07\xe7<[I\xc6<\x1d\xcb\x04\xbdQ\x9a\x82\xbd\x06(\xa1\xbd\x92?*\xbd\xc86\xc5\xbc\x12}\x9a<\xf3\xeb5\xbd\"\x83\x87\xbc\xfc\x85(=\xc9\r(\xbd\xe6\x17\xf2\xbb\x8d\xcfj<\x99\xef\x07=\xbf\x03\xce\x02\xbd\xb7\xc8j;\xb6O#=\xc8\xd6h\xbcw\xa0\x9b\xbc\xe3\xe4\x18=i\x04\x8f\xbd\xf9\xfc\x0f\xbb\x08\x8aI\xbd\xca\x86\x81=p\nY<\x98\xf9\xf5:\xbfvR\xbbq\xfe\xbe;\xec\x98V;\xfb\xef\x8a\xbb\x1a\xb18\xbd\xe8\xabW<\xfad\xb6\xbb\xb1\xbc\xd6\xbcq\x97\xba< \x8f4\xbc\xf1\x084=\xae\x8f\x9e\xbd\x886\xe8\xbb\xf3\xaet<\x05\xf7\xae=5\x89\x02=\t\xa7\x08\xbc\"\x97R\xbb\"\x9b\x0e\xbdY\xf2\x93\xbd\xc2\xba\x8b:\x81\xe7\x8a=~6\xb3:9z\x0c=$b\x15;\xb0\xf7\xe4\xbc\x0f:.=%r\xbe\xbb\x08\x0e\x06=\x98\xc6\x95<\x89\x87\xe9\xbc\xcc\xbd\x14<\xd79\xb1\xbdE\xfdC\xbcqZ\xc8\xbc\xa0\x90\x81\xbd\xcfBJ\xbc\x18\xa4\x99\xbc\xaa)\xd5;\xbe\xc1;\xbd\x981i\xbe\xbcY\x9e\xb1\xbd<1\x06=\xbe\x82\x8a<\x9d\x81\x1c<\x15.7=\xec\xe1\xc0=\xed\xf6!\xbcx\xd0T=3\xf9:=d\x91\xc59ADJ=i\x7f\x8e\xbd\xb9\xb1\x99<\xbe\xe4\x98\xbc" +HSET bikes:10095 model 'Titania' brand 'Bicyk' price 2055 type 'Road bikes' material 'carbon' weight 8.8 description 'The women-specific, race-ready frameset has received significant upgrades for 2022 and now has a stiffer front end thanks to the upgraded SL fork. The hydraulic disc brakes provide powerful and modulated braking even in wet conditions, whilst the 3x8 drivetrain offers a huge choice of gears. All bikes are great in their own way, but this bike will be one of the best you’ve ridden.' description_embeddings " \xfd)=\xf438;}\x10\x10\xbd\xe2E_;*c?<\x17&\xc4=\"g\xc2<\xee\xfaJ;\x0fv\x8c<\xb2\x01\x95\xbc\xf5F\x8d\xbd\xa8\\\xe7\xbcZ\xd3\xe2<\xcb\xe33=\xc6-\xca\xbc\xc8\x1cf=g\xb10\xbc\xa5\x96,\xbd\x15t\xa2\xbcV#\xa1<}\xe8S=\xd6R\x10\xbd\x93\xf6\x8c=L\xe99=gL\x87\xbaL\x8f4\xbd9\xf1\xdb<\xa6\xe2\x05=\xa0\xa4B<\xd6\x7f\xf3<4\x8f\x8e=w\xf2\xba:\x85a\xdd:\xaf\x1e\x95=z\xb5\x03\xbd\xee\x16h\xbds\x8b\x1d<\x8b8\xbe<\xf6\"\xa2\xbd\xc8S\xa0=\x1e\xb2\xd3\xbb\xef\xd5\x82\xbc\x9f2\xf9\xba\r\x021\x14\xbd).\x8f\xbcq\xb6\t\xba\n,<\xba\xcc\x80\x8b\xbc<\x89O<+\x95\xf6<\x05!\x16=lB!\xbd\xc9y\x81=)\xec\xcc\xbbX\xef\t\xbd\xc1*+\xbc\xd6dF\xbd\x0f\x95\xa5\xbd\x9a\x81y=\xb6\x12K=V\xd5#<~\x8e\xfa\xb9\xc5n0\xbb\x8c\xb0\xde\xba\xf1y)=5\xef[=\xbd\x19\x0b=\xef\x08\xb1\xbb\xa6\xa0\x0b<\x11\xc4m\xbc\x82vl\xbdq^\x84\xbd\x93r\x02\xbd\xc9`\xb7;\x97\x93\x19=\xcbj\x0b=4:\xe2;\xedu\xa3\xbc\xf1!\x9b\xbc\x0c<\xfd\x15\xcb;\r\xae\xc8\xbc\xbe\x93!=\xaf_\x8b\xbc\xb7\xfc\xbe<\xadV\x0c\xbd3\xb4\x13\xbdn\x9c:<\x84\xee\x15\xbcK\x04#=f\xf4;<\x7f\xad\x92\xbb\xabRd\xbd\x08%S;a\xa9Y:\xe3B\xcc<\xb3[\r\xbd\xd1\x97\x0b=\x9e\x80\xf9\xb9>\xd2r9\x8bi\xab\xbb\x0e\"M\xbd\xeb5\x82\xbd\x11\x95\xa2\xbb\xe9zH=\x87\xe1\x05\xbd\xadc\n=3\xf8\xa4\xbc\xc4<\xd5\xbc\xf9\x81\x08\xbd\xf2j\xc7\xbbF Y=\xe3r\xe7<\x8c\x8d\xc1\xbc\xceT\xad<\xa4nf\xbc\xce|\xee\xbbE\x9f\x92\xbd{\xa3\xf5\xbd\x9a\xbd\x92\xbc1\xd8\xab\xbd\xb5\x04B\xbd\x80\xa0\x0f\xbc\xca\x12\xee<\x17\x84\n\xbd\x8b\x1a>\xbc\xb1\x1b\x10\xbd\x9a$\xf5<\x9e\x90v\xbc\x9f\xe0\xe2\xba\x91\x0b\x12<\xd7\xd0.\xbcBh\x03\xbd\xe2v\xcb<\x80O\xe4\xbc\x1e\x85%\xbd\xba\xd1J\xbd/\xe4G\xbd:\x94-<\xf4\xef\xf7\xbd9\xbc\xb5\xd8\xf8\xbc\xd05\xe7;\xa3\n\n\xbdO\xbdw<\xda\x00\x1a\xbd\x0e!*\xbd\xb4\x1b\x97=P\xd3\x87\xbc\xfef\x08=\xb9\xf2\xcd\xbde\"\xfe\xbc\xac.\x9a\xbc\x1d\xcb\xdd\xbb\xc7SM<(\x90\x93\xbd\xc9\xbe\xbc=jt\xac\xbc$i\x0e\xbc\x91\xc0*\xbd\x1fl\xcf<\x1a]\n<\xd2\x87\xcd\xbcY\xf2\x18=\xf8}d=\xeb\xe62\xbb+*\xf5\xbcy\xc2f=*C\x14<\x1b\xc6\x96\xbcv\xf5\x8c\xbc\xe1\xf6\x11=p\xb8\x99\xbd\xe4\x176=\xa1\xcb\x9b<}-T\xbc\x18\x80 <\xce\xc6\x12=\x94\x9aF\xbb\xedj\xc99\x87r\x0f=\xa4\xa0\xc3<\xa7\xf0\x91=\xd0\xee\x8e;\x0b\xd5\xb5<\x85ed\xbd\xbc\x8e\xea;-\xdc\xfa<\xb5w\xa6\xbc\xef\xf6\xc3<\x0e\x03\xb5<\x0e\x97\x9a<\xdc\xa8z\xbdM\x1a=\xbd6\xf6u\xbd\xc8\x86\x8e=|\xa1\x1d\xbd\x81S5\xbd\\o\xc3\xbd\x1f\xde\xd3:\xb3\xa3\x91<\xbe$/<2w\x10\xbdn\xc1\xd4\xbc\xf6\x84\xe8&-\xbd(b\x87\xbd\x07\xa9\xd6\xb9\xfe\xf1\x82<\x06\xadF\xbd\xc4X\x04=\x80\xcb\x84\xbc.\xc9P\xbc\x12\\T=\xe87\xd1\xbcq\x93\x8a\xbdbL}=\xe4a\x95=v\x17\x9e\xbcj\x9f\xbb\xbb\x9e\xd6A;\xab_n\xbd%\r_=\x1f{#=\xa0\x8fz\xbd\xdcFL;\xee\xdb-\xbd\x00\x07\x06=\xbb\xbf\xef\xbc\xf3\x00\xa4\xe5<\xaa\xce\xfc<\xd1T\xd9<\x8eo\xb6<\x109\x96\xbc\xbbz\x16\xbd\xb1\xbc\xe4\xbc~( <\xb3\xa7t\xbc\xf3\xb7\x97<\xded\xf1;52\x9e<\xfb\x16\x15\xbd\xab\x9a\"=\xc2\xfb\x9b\xbd\x943\x87\xbd\"M\x13\xbd\\\xeb\x82\xbdg\x86v=\xd6\x0b\xb0<\r\x1c2\xbcp\xcd\xc4\xbc<#!<\x9d\xaa\xd1=\xda\x00\\=[d\xf7\xbc\x14\xe86=\xb2]\x17\xbd\xccrx<\x03m\xf4<\xae\xf1\x8d<*\xa2:\xbd\xdd\x13:;g\x04\x1b<\xaay&<\xf0\xfe\xce<^\xc7\x92\xbc\xf5@\xf2\xbc\xea\xa7\x9a\xba\x0f\n`<5?\xd5=\xb1\x932\xbd\xeb\xa9M=\x7f\xfc\xc7\xbc\xc1\xb2\x08<0-\xbd;_\xb2a=D\xef\x89\xbc\xe8\xbe\x01=\x93\x8c]=\x97,\x89=k>U\xbcAF\x97<\x1a\\\x9b\xbc\xbb\x9f\xb3;\xfe?q=<\xca\x19\xbaZ\xd6\xed<\xd1w/=\xc5\x87\x06\xbd\xd9\xa7U\xbc\xe2\x17\x90\xbc\x1a#\xe0<\x91\\\r<\xbaK\x89:\xc3\x8f\x1a\xc0\x81\xf8<\xa2X\xe9<\x9f\x83\xce\xbc\xf0\x8f\x95<\x06\xb6\xc1\xbd\xfc\x17)=\xce9\xa8<\x93\xf0\xb0<\xd4\xe8\xe6<\xabH{=)\xf0<:\x11k\x98;\xca<\xc4;\x18\xf2\xc9<&\xb5\x96\xbd\xdc8\x14\xbc\\K\\\xbd4j\x99=\xbe\x19\x12\xbcR\xf7\x86=X8=< \xdb\xf9<\x06/v\xbdq\xfeQ<::\xd8<\xc8\xb1\x0c\xbc\xff\xfc\x18\xbd\x98*\x80\xbb\xca\x1f\x85<\x11\xecf<\x94|:\xbc\xd0\x7f\xf1=\xa6\xc0\x11<\xda\n\xaf\xbc)sd=\\2\x87<\x82\xbb\xe3<\xef\x10w=\x1228\xbc\xe9\xf4\xd6;\xd4d\x7f<\xb5\xc23\xbc\x99\x98\x06\xbd\xd8e\x9b\xbd\xe3\x18^<\x19O\xc2\xbc\xe7X\x08<\xb7t\xb2;\x9b\x13k\xba5\xd7\x06\xbd\x1dd\xf0\xbdv\xb3n<\xc3W\xd1;#\x9b)<\xc4\xc4U\xbd\xb3\xcb\x97;\x9d/\xcd\xbdh\x12\x88\xbd6\xf6\x9b=\x99p\xf3,-\xbc\xf9\x82\xb4\xbb(\x93\xf1:\xbe4\xa5\xbc\x9c\xa6\xc2\xbc\xab\rD\xbd}s*\xbc\xfd\xb3u=\x0e\x83\x91<\xc9\xd1A\xbc\xff\xed6<\xf2w\xff\xbc\xfd<\xe5\xb9\xc7\xcd>\xbd\xe1\x92\xea\xbbs\x92\x11=\xe5\x02\xc5\xbc\xba;\xdfZ\xe0\xbc8_\xdf\xbc\xbd\x10\xc1<\xcd\xa3\x06<\xcf6\x8f\xbc{t\n\xbcm\x1f\x17\xbc\xf7-\xb4\xbc\xfe\x0bc=d\xc1\xbf;\xdb\xce\x86<;\xd3\x9f<\xce\xcf)=\xef\x9d\xc0\xbb\\\x16\x13\xbd\xb2\x99\x07<%\xde\xb8<\r&\x98\xbd\xb6(W\xbcX\x91\x9b=\xf5_\x9b\xbde\xc7f=\x08\xd6K\xba\xd0\xb0\x13\xbc\xaf\xc7q\xbd\xc2\xa4W=D\x1f(<4`\x9d\xbb\x1b\t\x00\xbd\xe0V\x9c\xbc\xd2*}\xbd\xbcQ\x93=-d\xd1\xbb*\x9b\xa5\xbc\xce[\x1c=\xd3\xd1\x86\xbcB\xc9d<%xB\xbc`\xfd\x0b\xbdk=\xdb<\x06\r\xab\xbbS<\x14\xbc\xb6\xf5\x06\xbc\xe8\xcey<\x82\xb3\x88\xbc\xc5\xa6\xac\xbd\xab\x9f\x97\xbc\x07\x8b\x83=\"d\x9b\xbc\xf0\xb3\xed<\"<\xe3\xbb*\x8a{\xbcC\xc2\xec\xbc\xcd\xf1\x03=\x06\xc7\xb0\xbdlS\xbe<\xbdm\xbe\xbc\"\xf4\xee\xbc\x12\xcb\x91=\x80\x8c\x96\xbch|\x1a\xbc\"!E\xbdB\xe0\x15=\xff\x14\xcc<\xc6\x85z<\xf1QK\xbc\xe1C \xbd\x11\xb7\x8f=n\xf3\x1e\xbc\xd6\x8f\xbd:\xc6\xbc\x11\xbc\x04z@\xbcs\xbb\xec<\xd3\xbd\x8d\xbc\xbeF.\xbd\x85\xe0\x87\xbb\x7f\xfb\xa8\xbb\x87d\xf0\xbco+\x13\xbdZ\xbe$\xbd\x119\xae;TXA\xbd\xf8D$\xb9\xbc\xcb\xbf\xfe=l\x97\xa3\xd2\x19\xbb\xac\xf8\xd2;\xbe\xcb\x0c=P\xa5%\xbc\xbc\xb2\x1c=\r3\xab\xbc\x89\xe1\xb2\xbc\xd8\xcf\x16=\xa3\xef\x85\xbc&*\xb3<\xe2?\\;\xf0\xb7L\xbdh|#\xbdx\x99\x14=c\x16\x15\xbd\xa1\xdd8\xbd\"\xadl=\x93\xe3\xc9=\x95\xec\x1a=\xaa#Q<\x8aM\x14<\xd5\xe0 ;\x1aj\xa7\xbd\x13\xa5\x16\xbbQ7n<\xf5\xda\x1a!\xa9\xbb\xa2\xea\x15=N\"\xa5\xbaQoX\xbdn\x9e\x9f9\xc0\x0fA<`!\xa5<\xfa^\x0b\xbcR\x1d\xe2;s\xcd\x89\xbcD\xe70\xbd\xf4\xfd\xdb<=\xe6\x0e:\x81&;\xbd\xdfPB<\xf5\xba\x86<\xa3Z\xe6\xbaQ\xa4\xe8\xbcBE\x90;}\x8b\xa1\xbc\x8d?Y\xba\xc0\xfa\xe6;N\x80b\xbd\xf3B\x01\xbdM\x0f\xab=\x0c)\xef\xbcD\x03v=\xa5\x7f\xeb<\xa3\x1f\xcf;\xa1]\x9a<\t9\xb3\xbc\xdb4\x8397N\xc5\xbc\xad)\xbb;\xe7\xa9%\xbc\xd6ZQ<\x96\x96\xdc\xb9\xfdS\xbb\xbb],\xaa\xbdr\xcelFP\xbd\r\xd9\xe5;\xaa\xf8\xe2\xbcY\xcc\x0c<\xde\xcd\x8a<\x17\xce(>p\xc9\xac;\xf4\x07\x0f=\xfeo\xdf\xbc\xb1\xac\xe8\xbc\xec\xc9&:;\x18\x8e=29\xdb<\xedY\xde\xbc\xdc\xb2\x9b\xbdc\xf3\xaa\xbc\xc1\xb2\x9c<\xb3\x9a\x1f:R\xb7\xeb<\xa8E\x9f;H\x93N=o}\x02<\xa7\x7f&\xbdE\r2=k\xa9\x82=\x19-\xf2\xbc>_\xaf<-\xb6\x01\xbc\xec\xc6R\xbd\x00\x82\x02\xbdY.\xa7\xbc\x1e\xd4\x19\xbd\x9c\xe6N=\xa9%\xea\xbc\xce\x90\xe0\xba[FX=\xd7\xa2E\xbd\xbe\xbf*\xbd)\xeaN<\xd1\xaf\t\xbco\x85]\xbd\xc7\xc3\x8e\xbd\x1c!|\xbc\xcaqe=X\x13K\xbd\xb2\x07c<\xd4;\xe3\xbc8\xd1p=,*\xc8;\xed\xa6Z;\xfe\xe5\x02\xbd\x88\xa7\xb8<\x02\xf1\xc4\xbb\x92\x98!\x92\x17;?\xe1\x0e\xbc$\xa2m\xbd\xeb\xcc(=\xf1P\xad\xbc\xc4\xf8\xd9<\xc0\xeeW=ha\xd6\xba\xe4\x93\x82=?;\xc1<\xb4\xba-\xbc\x91\xef\x15<|\x9c\x01=cB\xbc\xbb\xb9\xe5\xc9\xbc\xa6\x0f:\xbd\"_\xd5\xbc\xf6H\x07=\x1c\xb1U<\x92\xc5%\xbc\xa4h\xc6:\xf1\x1bJ<\xed3\xd5\xbdAK\x919\x95\xe0\x8a\x1e\x8f\xbb\x01\xa0-9\xfdx\xb1\xbbN\x81\x9a=\xe6\x00\x00\xbc\xe7\xc5\xd1\xbc\xd7P\x00<;\xbb\x08\xbd\x85\xdeb\xbd\xc9_\x9c\xbb\xa0\xdc\xf1\xbd\xb15\x9b\\\xe96s\xbd\xfd`\x9a\xba\x02\x1e\xab\xbb\x8a]\xb0;\xfc^\xb8\xbc\x96\xf4\xa9\xbc\xfb\xaa\x8c;\x1d\xa5\xbe;\xf3\x15\x97\xbd\xdd]\x05\xbaw\xac\xd1<\xd5\xa8\x80\xbb\xf7\x07Q<\x02T\xa1\xbd\xe4\xcc\x18=Q\xe2.n\xa1\xbc\xa4aI\xbdx\xd5\x0b\xbc\xa4\xb9G<\xb8\xbb\xe4<\r\xeb\xb2=\xd2\x0eD:\xa2\x8a\x1d=q\x0b\xca\xbb\xaa\xa7N\xbd\xf5S\xd4=\xdc\x02V\xbc-f\xee\xbc\x15/\xc3\xbc\x01\xf6l\xbdo\xcb\x16=D\x86\xfd<)\x99\xab<\x0e\xa5\xd2\xbbc\xa5L=\xec\xc1\xc2\xbb4\x98\x00\xbd\xbe\xbb\x84<\x92\xb1\x8a\xbb\x9d}\\=\x97\x8c\x85\xbb\x8e\x90\xc3<\x8e=\x84=[3\x08<>:\xe2<\xfdV\xe3\xbb\x10,\x84\xbcf\xd9\xa9\xbcx\xff^=g\x83\x81;_}\x1b\xbd\xc4\xb6w\xbc/\xea\x12\xbdB\xa4\x85<%\xd3\xaa9\xc3mF=\xef\xf6Q;x\xdd\t\xbc\x06G\x83\xbc\xad\xd5\x8b\xbd\xd5\x12y\xbc\xaf\xb0/=`\x80m\xbc\x18\xc2\xa6\xbc=B\x13\xbd\x86TD\xbd\xfc{\xfa\xbc\xae$\xda<\xde\x84\x87\xbc\x0fV\xc2<\xb6\x1b\xb4\xbc\x19\x10\xb7\xbb\xed\xfb\x04=\xc8\xce\xe7;}\xcaT;\xcd|H<6@X\xbb\x8a\xda\xd8\xbc\x10\xa0\x83\xbc\xe8]K\xbc\x97\\\x06=\x1c_Q<\xbe_\xd0\xbc\xac\xaf\x94<\x07\xb4\x82;\x1a\xd0\xe4\xbb\xbdK\"\xbc\x9f\x17\xd2<\":l=\xc7\xae\x05\xbd\xbf\xafK=\xc5\xad \xbd-\x8f\xf3\xbc\xea\x0c\x93\xbc\xf0_i=*\xcf\x84<\x96\xa8\x81\xbc\\\xda\x9b\xbc\x90\xb6\xd8\xbc9I\xfb\xbc2\xa1o=\x9cc\xb4\xbb\xb6\xd0\xe5<\xaf\xa7\x96\xbc/\xfd\xb7<\xbej;\xbc8/\x16\xbb\xab\"\x84=\x17\xc9\xe9V\xbb\x83\xb5c\xbd\x02\xc1R\xbc{\x10X=\xf0Sb\xbdX\xa8\x9b=\xfbr\x10\xbc#)!=\x03\xd4\x1a\xbd\x95+,\xbc\xd1^,\xbc\"S\xb6\xbc\xe8\x03\x15\xbc\xc6\xfb\xc3\xbdpD\xee\xbc\xe8\x0c\x8e\xbb}h\x98\xbc\xa4\"\x1c=^qG:]V\xd3\xbcXdn9\xa7\x1a\x85\xbde\xec\x9e\xbb\x9aZ\x86:\xa5\xf2\xf0\x87\xbc[<\xbb<\xe9\xdf9\xba\x92\x8f\xaa<\xab\x03Y\xbd\xbaq\x0f=>\xfa\x9f\xbb@\xde\x8d\xbc\xf5bP\xbb\xbd\x0b\x9e=\xb2\xcc3\xbd\xad\xd2R=j\x8a\xd9\xbc\x00\xa3\xba\xbbB=2\xbd\x1bgi=\x85\xc0%<\xe1\x9c\x16\xbd\xb1\x7f\xad\xbcek\xf4\xbcy\x9b\r<9B\r<9\x1a\xdd\xbduW\x06\xbd\xbb\x9f\x02=h\xbc\xf8\xbc\xdc\xa0\xaa<\x1a\xca\xb4<\x7f!K=\x82\xb5A\xbc\x0b\x0b\x7f<\xc1\x13\xba\xbd\xd3\x06\xcb\x8a=\xfc\xa1J=\x14|\xaf\xbb\xe2g\xb3\xbcpB\x8d\xbd|\xaa4<\x914\x08\x02;\xdc\x89\xc2\xbcz[\xf5;\x95\xdd\xe9<\xe3\xed\xa2\xbc\xf2\xe1\xd0;uS\xb4\xbc\xc2\x9d\x0f=^q\xdb\xbc\x96Y\xab\xbdp\x99\x17\xbd\xee\xc9:<\xd2\x10\xcf<&\x99\x1a=\x0b\x85\xe2<\xc4qK\xbb>\x88\x8c\xbbN\x11\x17=\x05ry<\xccwa=\x80\xee\x12=y\x03\x9f\xbc\x07\x1e\r>\xb9%d\xbc\xacAN\xbc\x95s\xf8;\x96\xdf0\xbd\xd9\xa7\xca\xbd\xe8k5=\xd3Zm\xbc\x0e\"\xba<\xfb\xbd9=\xb3(c\xbc\xc4\xb0\xce;\xd0W\"=s\x9c\xd5<|H\x1a=*\xc6-\xbc\x15V9\xbc\xa4\xbc_<\xc5\xd6~\xbd\x1c\xe1\r\xbd\xcc\x91\xa7\x01=s\xab\xa6\xbc2\x15_\xbd\xed\xe6M\xbd\x984\x12\xbd\x91\x9a\xae\xbc\xc9\xcfV=\x96X\xea<\x1c\xc0\x96=\"\x06\xb69\x0b\x0c1=\x0b]\x91\xbc)\x01\xbd\xbd\xe2\x06\xf9<\\\xf5\x84\xbcI\x15\x99\xbd=\x1d\xe5<\x99e!=" +HSET bikes:20001 model 'Jigger' brand 'Velorim' price 270 type 'Kids bikes' material 'aluminium' weight 10 description 'Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go. We say rare because this smokin’ little bike is not ideal for a nervous first-time rider, but it’s a true giddy up for a true speedster. The Jigger is a 12 inch lightweight kids bicycle and it will meet your little one’s need for speed. It’s a single speed bike that makes learning to pump pedals simple and intuitive. It even has a handle in the bottom of the saddle so you can easily help your child during training! The Jigger is among the most lightweight children’s bikes on the planet. It is designed so that 2-3 year-olds fit comfortably in a molded ride position that allows for efficient riding, balanced handling and agility. The Jigger’s frame design and gears work together so your buddingbiker can stand up out of the seat, stop rapidly, rip over trails and pump tracks. The Jigger’s is amazing on dirt or pavement. Your tike will speed down the bike path in no time. The Jigger will ship with a coaster brake. A freewheel kit is provided at no cost. ' description_embeddings "\xbc\x07\xd3\xbc&E.=>\xbe\x1c\xbd\xd6\x05N=N\xe5\"\xbd\xa8o0<\x8c\x12\xf0\xbb\xb3\xb1\xfd\xbc\x91\xdf\x98=\x07=\x8a\xbcv\xdc\x0b=A\xe5\x15\xbdy\xd1+=\xeea\xfa\xbc\xc3\xbbS=\x1a,\x04<\x9a\x9f\xfc<\x92\xf9q\xbdkyz<\xe1\xc2\xc4\xbd\xb7d\x9f<\xc2\xcc\x90\xbd\xcb\xb9T=\x93\x1e[\xbc\xb6oB=,\x057\xbc@0\xa4<(\x01g<\r\xb2\x8b\xbd\x91\xcb\xb4=\x07\xc5\xdc\xbc6\xfe>\xbd\xd5\xbc\x1e=6\x96%=;\xb4\xc4=yy\xbd\xbc\xc9/\xf3;(\xb6\xf9\xbc\x19\x8e\xe4;Jb\xd0<\x918\xc5\xbc\x15\xcdn\xbcO\x92\x99\xbc\xd0t\xd4\xbc\\J9<\xdf\xb7j<\xb4\xfa\xc9\xbc\xba\x1f\x04\xbd\x04\xf0\xaf\xbc\xa0Ph\xbc\xfc\xb6\xf8;\xfc\xcf\x9e\xbb\x8a\xedo\xbcX\x92\xa1<\x84(\xff<\xf0\xd9\xfe<\xd9P\xf4\xbd\xab\x0bV\xbd5U\x9a<%\xa6,\xbd\x14\x02G=\x8fH\x86=\xb0\xf3\x8c=t\xd8p\xbc\xc7v\xbb\xbcXB\xb0\xbcU\x1f\xad\xbd\\\x83\xc4<^\xf5\r\xbd\x19}x<\xd8\x97\x0e\xbd\xe9\xcf\x81\xb9\x8a\xbdW\xc2\xab=\xea\xbb\xed\xbc\x14\xd4i\xbc\n\xcf\xe9\xbc\xf9\xc4\xe7\x12\xbc}\xeb!=a\xc9R*\\=\xc64]<\xb7\x8f\xcd\xbb_\td;\xfes%;\x8f\xf5D;\xf5x\xea\xba\xdcLU\xbd@k\x88<\x01~f<\x1e\x15\n\xbc\xeb\x00A=\x92\xd8\xb8<\x92\x11\xb5\xbb\xb1\xad\x98\xbb \xd5\xa3;J\xf7\x10=\x15\xcc4\xbd\xe2`;\xbd\xe7\xa1\x1c\xbd\x19{\xf1<\xeb\x8a\xfa\xbb\x9e\xea\x85<\xa5\xee\xa5\xb2\xce;\xfa\x90u\xbd-\x07\xd3\xbc\xc6G\x94\xbd\x9fi\xd9\xbcP\x94p=\x02\xff4\xbd\xca\x98\x849\xf7f<\xbc\xe6\xeaQ;\xba\xe9\xb1=o\x10\x1f\xbd\xfc\xecO\xbcV_\xa2=\xed\x18 \xbd\x1e\xd9\x83\xbc\xf1[\"\xbdR\xd6\x04<\xf4a\x08\xbd7@`\xba\xff}\xb6<\xa4-\x7f\xbd\x84\xb4\x82=9\xe8d\xbd\xe1\xbd\xab\xbc\x7f\x10\xab\xbd\xc2\x1e\xac\xbc\xd4(+=\xc0\xb61<\x9d1a\xbd\xd2\xae5\xbc>0<\xbb9d\xa9\xbd\xc5\x90\x08=\xe7#\xf7\xd2C\xbd\x10]H\xba\x8dN1=\xba]\xdb\xbc9\xf7\xd3\xbc\x15\xa2\x14\xbd\xb8\xa8\xde;\x93\x95\xb6\xbatz:\xbd\xb2(:\xbd\xd7\x9f\x04\xbe\x19\x1e\x80\xbc\x14\x0fo\xbc\x04\x8e\x84\xbbH\xe5\\\xbc3\x97\xb7:\xa9j\x88<\xdc\t\xb6\xba\"bv\xbc\x18\"\x13=\x10\x89\x97<\xb5a\x95\xbd\xe5=\"<.\xc6/\xbc\x8b \x89<|s\xd3\xbc\x1f\xc3#=\xc9\x8c\x07\xbdkR\x11\xbc\x89C\x9d\xbc3\x8f!\xbd$gV=\xc1\x1d\xf2\xbc\xc7\r\x9a<3\xf6\xf5<\x08\x9b+\xbc\xfd\xaf[=\xea\xd8\x86\xbdb\x1b\xf3;W\xde&\xbd\xa3l-\xbct\t\xe1\xbc\xb1\xc8\xfb\xbc\xdd\x0fI\xbdp2\x0e\xbd\\*\xb0<\x8eb\xf3<\"r\xb8<<\xaa\xb5\xbc\xc8\xbf\x87\xbc\x9c\xb1\x07\xbdtf(=\x8bF\r=\xec\xbf\xd1\xba?\x01\x8b\xbd\xd3\x9f\xce<]n\x1d\xbd\x03<&\xbd\xbb\xdd\xbf<\x00\xc2\xae<\x13\xa6\x95\xbc\xdc\xf2/\xbd\x9d\xb8o\xbc\xb4\x0e\x92<2E\x01\xbdh\xf3[\xbd\xff7\xf6\xbb%\x9cj=\xab\x81\x9d=p5p\xbc\xab6\x88\xbc\xd7E\x97\xbd\x84n\x9f\xbd\xf9\x8a\xa0\xbb\x10h\x83=rD]<+\x94\x86\xa6Z<\x89\x1eD=\xdb\x8d\x9d=\xc3\x05Q\xa9\x95<0~\x8c\xbb\xe2\xa5\x12=\xdav\t\xbcE%+=\xde\xd7\xcb<\x1e\xdc\xae;3\x08\xf2<\x1e\xf3\x0e\xbd\x95>\xa7\xbd\xc9\x10:\xbdR\x0b\xe2;\xb3\x9cN;=6\xf1\xbb\xe9\xed-\xbd&\xc6Q=\xcf\"\xa2\xbb\x1bgx\xbc\xc9(u\t\x8e\xe8W\xbc\xb7G+=\xff\x7f\xed<\xa4\xc1\x1b\xbd6\xac\t\xbc\xd3\x15#=H\x9b\xdc<.k\x03\xbb\xe2&n\xbb\xa8\xbc\x17;= \xb6=f\x89\xa2=\x1a]\xb1\xbc8wF=\xb7\x1b\xbc\xbcT\xd7\x06<\xb7\x9a\xa1<\x15\xbd\xcc{f<\x1f.\xee\xbc\xf9\x1b\xd9\xbc\x90V\x10=\x8b\x85\xd8<\x19\x828<\xb1\x11e\xbc?\x10\x8a;\xc0\x9bi\xbd\xc1\xac\xb4\xbbWc0\xbc\xbf\xe2F=+\x04\xff\xb9H(\xf0\xbcA\"\x8a<+d\x8d\xbc\xbbA~\xbb$\xf7F\xbd2\xb2\x1c\xbdm\x1d\x8d<:\xca\x9c\xbd\x8119\xbc7e\xa9<2\x9e\"=\x149\x1a=\x15X\xf0\xba\xfaZ\x08\xbd\xd1\xcb\x1d\xbd\xb7\xb7P=\"\xa0\x90<\xcf\xc9\xd4\xbc\"\x1di\xbc\xa0\xe2\x9c%\x9e\xbb\x08\xceY\xbb\x99Lg\xbc\xd8-0\xbdq\xb2\xb4<\x9a\x8ak\xbcG\xab\x9e\xbc\x98\xd8\x14=!\x17W\xbcJ+\x15<\xf6\x87:Yn\xbcG\xa0x=C\x9fC\xb9[\xef0:\xcf\xb1\xa7n\xbc\xbc\xa7{\x8b\xbc.\x98\xca:\xf4i\xc8<\xec\x00~<\xf0\x8f7;\xa4\xa4\x9e\xbc\x99f\x8d=\xfbP\xe3\xbc\xba\xd2\xd5<\x96\t\xdf\xbcH\x08e;\xf7\xfd\xc5\xf2\x11\xbdu5\xac\xbc#\x03\x8a=\xd9M\xf19`\xb2\x03\xbd{\xc9\xbf<\xbc\xffy<\xe5\xdb\x97\xbb\x14\xeb\xb1\xbc`vZ\x8a\xf2\xbc\xc1\x11\xa9\xbd\\zr\xbd\x06J\x19\xfa~;\xdak\xfb\xbb\x9f\xc0==\xb4H\xc5;U\xc61=BaG\xbc\xbb\x1c\x00;\x8c[\xa7\xbc\x96\t\x8b:\xc4\xde\xfc;\xd0\xbb\xa0\xe5e\xbd@\x9e#\xbd\xb2|T=\x03H2\xbcs\xd0\xbd;#h\x01\xbd+\xb4/=\x00\xd2\x92\xbb\xdb\x9f\xbf\xb9s\xeb\xf9;\xe5\xb7\x82\xbc\xeb\xc8;r<\xb3\x03\\=\x80\xe4\x00\xbdh7!=x9\xbd:^\xaf%=R;\xb3<[I^\xbdiV\"\xbd4\x93^\xbbJ\xba\x87\xbb\x00\xe3\x04\xbd\xe2@\xbfb*\xbc\xf2g\xd5;4\x10\xbc\xb9\xb5\xf84\xbc\x14{\x84\xbb\x11y\xb4<\xe3\xcc\x97\xbc;\xbe\n\xbc9\x10+<\xdc2\"\xbcl\x13\xe6\xbc\xfaw9;\x93\xd9\xcb\xbc\xcfS\xaa<\x1e!\xc0=Y\x8d\xa5=\xa0\x9e\x8a<\xdc`\xc4;&s6\xbc\xc1>\x16\xbd\xb5\xbe\xe8<\xa8\x8a\r\xbd\xa71\x01=O\x1d\xb8\xbb\xc1\xa9W<(\xec;\xbd&r\xaa\xbc\x83[\x04\xbb{-{\xbc+\xd8\x89=[.\xe5<\x8c\xa8\xbe\xbc-j\xdd;5\xc5v<\x19-\x91<\x0b\x93.\xbc\x07\x9e\xca<\xbd\"\xcf\xba\x91\xe3\x13\xbc\xe9kS\xbd\xdcf\xb2\xbbGu\xfc\xbb\x868G#I\xbd\xdf\xd2\x81\xbc\xb6\x91\xc0\xba\xa7\xf9f\xbd" +HSET bikes:20004 model 'Eva 291' brand 'Eva' price 3400 type 'Mountain Bikes' material 'carbon' weight 9.1 description 'The sister company to Nord, Eva launched in 2005 as the first and only women-dedicated bicycle brand. Designed by women for women, allEva bikes are optimized for the feminine physique using analytics from a body metrics database. If you like 29ers, try the Eva 291. It’s a brand new bike for 2022.. This full-suspension, cross-country ride has been designed for velocity. The 291 has 100mm of front and rear travel, a superlight aluminum frame and fast-rolling 29-inch wheels. Yippee!' description_embeddings "\x1f\xbb\xe9<\xd3/\xcc<\xda\xb8\x15\xbd\xc6V\x98<\x0bhq\xbd\xc8G\xa1<\xe1V\x1f=jIP\xbd\xa5\xc3\x1f=\x89\x877=\xd6\x9b\xaf\xbbqJ$\xbd0\x9c^=\xfbK\x0c\xbd\xe9_\x86=)\x83V\xbc4\xc8\xbb<\xe3ly=\\O\x9f<\xee\x88}\xbd6\x18%<~\x03\x12:\xf0\xe5@\xbc0\xbb\x11;\xe3=\xb0<&-\xa7<\xbddH=\xd8\xd4\xc1\xbb7\xe2\x1c\xbb=\x0e\x94=\x85\xb0\x1b<\xd8N\x1c\xbd\x94-\xfb:a\xe4\x8f\xbbo\x00\xd7<\x06\xe0s\xbc5\xbe\xee<\xfb\\\x1b\xbd$\xb1-\xbbVK_t\xbd\x1b\xb5\xfd\xbbF<\x89\xb8\xbfH;\xbd\r|\xa6\xbd\xe7Y\xfd:\x93W\xab=\xd4{4<\x84\x19\x12=\x14\x7f-=\xc6]}=\x8c\x17\xd1=4\xf4\x8f\xbc\xdf\x94\x9f=DtP<\x14\x926\xbc\xbc\x02\xf6\xbbz\xde\x03<\xf1\x11\xa3\xba\xb3\xe6\xc7\xbc]\xc3\x11\xbd\x18\xbeg:\x8c\xdaN\xbd\xb4\xca\x91<\xe8\xe7/\xbc)\x95\xe6\xbb/\xa1\xb7\xbc\xe6{\xd5<\xa6\xf7\x9c\xbc?oQ\xba\xdf\xc5\x1e<\x99\x9e\xbf\xbdlD\xd8<\xb5P\x91<\x00\xc4\xc1\xbaA\xb3\xb6<\x03\xa5\x07:\xfc\xe1\xce\xbc=;4\xbd\xd8i\x06\xbd\xba\x13\xa4<\xf6\xc7\x81<\xb9\x88\\\xbc\x90\xe1\x17\xbd\xcf\xb7\xf5\xbc`\x9f\x90\xbc\xdd$\x98\xbdn^\x14=8Zj<\xfe\x10\"=\xfev\x0f<\xa8\xcd!\xc4>\xbd[N\"\xbd\xb2c\x80\xbd9\xca%\xbdzc\x97\xbd\x16/\xf2\xbc\xb7\x1d\x15=\x08\x9f\x10\xbd\xa6{\xbd\xbc\xdd\x90\xa8\xbc\xba\xaf\xdf<\\\x1c\xb0<>\xa4(\xbds\x94\x88\xbc\xb6\x9e\xde\xbc\x94\x1a\x90\xbd\x0f\x08\xf6\xbc\xa8\x9e\x9c\xbd\xa37#=\xb6\xc7\xed\xbbc\x18\xbc\xbc\x01\xbeH\xbc\x05l\xd3\xbb$\x89\xe9\xba$\xb9q\xbd\xc8\xda\xb8:\xe3\xe5\xaf=\xd0G\x1b\xbd\xda\xd9R=7\xa9\x1a\xbd:A\x91;\xf2u\x10\xbdL?\x18=]\xd5\x8f=\xae\xb0\x96\xbd@H_=\xe3\xe5\xc3\xbd\xce\xe6\xc8\xbcG\xbd\xa0\xbc\xc7\xb0\xc9\xbc:\x86}=\x11D\"\xbd\xdb\xa4\x10=\x19\xcc\xc7\xba\xd7\xf2\xf0\xbc<\xd78\xba\x1a\xccd=y\x0e\xdc\xbcC\xe9T\xbc\x88,\x90\x9e\xf2\xbb\xa2\x0eu;\x95\xa22<\xb5\x8f\xeb\xbd\x02<\xe5\xf6\x02\xbd\x9f\xcb\x04\xbc\xf0\xbaS\xbb>\xc1\xc2<&\xa3\xbc<\xf3y\xa1<\xcdRh=Dq\x00\xbc0\xf5\x84;U~L\xbb\xf2\xfe\xa9<\xb1\x1e\x15\xbc\xc5\x12\x06\xbdg\xdf\x80;ndT\xbd\\}9=\xde\xc7\x8e\xbc\xdfI\x00<\xb9\xb1\xf7;D\xc0\x07<\xd7\xb4(\xbch\xbdL\xba\xb3\x1d\xa0=pU@\xbcV\xbdr<]\xb5\x94\xbc\x9e9\x9e<^X\xfa<\x99\x0fU\xbb\xcb\xa3A\xbdWN\x9c<\x1aX\xe7<\x80hN<\xe8*k\xbc.\x1d%<\xd6S\x92<\x00=\xa0;D\x16\xbd<\x06\r\xf5<{\nm\xbdS\x9d\xce\xbc\xfd\xfcz\xbc\xfc\xeag=&.\xf2\xbc]\x1a\x03\xbd{\x1e\x81\xbbY^\x02\xb9\xd4\xf5.\xbd\x01\xb4\xb0<\xd3\x8e\xb1\xbd\xfc\xe2\xbe;D\xf1\xab\xbc\xba%\x17\xbcz2\xcc=\x92\xc1\x04<\x80\xe2\x82\xbd\xff\xceF=\xf5\x01&\xbbq\x1f\x82\x0e\xd7x\xbc\xde\xc9i<\xd0\x8b\x00<\x11\x13\x81\xbc{~-\xbb\x8b\xe7B\xbb\x07\x1c&\xbc\x8c\x88\x9f\xbd\x88\xba\x0e\xbd\xae\x9fT\xbd\xe1t\xd9;\xbd_\xec\xbb\x1b\x1f\xc8;v&V=Lt0=U\xbc\x80\xbd\x1e\x9c$\xbd\x13\x81)= ;\xa4=\xacV\x0e\xbd)#\x1b=\x87\x86\xd9\xbc\xc6X\xdd\xbb(w\x85\xbcV|\x95;@\x9c\x10=\xb5\xd6#\xbbk\x10/;Q\xff\x03\xbc\xbe\x8d9= l\x0b=\xf0~$\xb5\x91\x10=\x82K\xf8\x94<\x06\x06\x0e<\xb4\xd5y\xbd\xec\x9a?\xbb\xa0\x8ajG=\xebu\x94\xbc\x00\xbb\xeb=[w<=\xb5)\x9d=lX\x19\xbd" +HSET bikes:20005 model 'Kahuna' brand 'Noka Bikes' price 3200 type 'Mountain Bikes' material 'alloy' weight 9.8 description 'Whether you want to try your hand at XC racing or are looking for a lively trail bike that\'s just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.' description_embeddings "G\x1e\xaf\xbc\xd2\x06\xd1\x01\xf0<\xb0\x8c/<\xfbk\xa0=\x15\xd7y\xbbP\xb3k=\xae\x01\x03$;A\xb4\xa8\xbc\x9d\xbf\x1c;\xcfa\x1d\xbd\xe0\x9b]\xbc\x89\\\x9c\xbc\x88\xe2Z<\x1c\x05Y\xbc|\xbe\xca\xbd\xfcL\xdf\xba\xd9@/\xbcU\xbd\xd3\xbcI\x8e6=~\x9d6:Ns\xc6=\x99\xcbD=\x12,\xa2<\xe8P~\xbd\xfb2Q\xbc\n\xaaV<\xb5\xb6\xdf<\xdebo=\xbd\xe8\x88<\xb3\xbf\xef<\x12\xc1\xcc\xbc\xc3\x8f\x81\xba8\xe2r<\x80\xc1\x0f\xbd\x94\xc5\x0c=T\xca\xbd\xbcr\xa9h<\x83\x1b\x08<0\x83\x10=$IB\x96Z\xbd\xa3\xc1,=\x9c:\xf7\x1f;\x14\x8a\xd1\xbc\xe8l\xd9<%P\xdf\xbc\xe0\xf1\x0f=\xa5\x967=\xb7\xe9:\xbd~\x90\x8a=\xdf~\x80:\xcc\xe0p\xbdoS\xdf\xbbB\x87\xf2<\xd0W\xf3<\xb3\xb0k=\xa7\xeaa\xbd\x02/;\xbc\xd3Bd\xbdm&\x82\xbb\xe6\x16\x81\t\x12\xd3?\xbd\x8a\xd5\x0e\xbd\x91\xb12\xbd\x1dI\t\xbc\x80\xc0\x14\xbd\xa9\x97\x84\xb9\xcc\x0c\xd7\xbc\xe5e\xf7\xbcf\xfe\xfe\xbc%%\xbd\xbc\xeb\tZ\xbb\x8a\xa6\x90=A\x97W\xbc5\x0e\xb3=\x95\xc2\x98\xbc=K\xfc;v\x98W\xbc!\x1f\x8a\xbd:\xf5\x1e=yCw<=\xde\xc8\xbbdb\x1b\x8d\x17P\xbc\xc6\x97\x15<\xe7\xb1T\xbc=\x8c\r=\xca\xcc\xd7;n\x85\x8d=\xc6\xb8\x88<\xab\x1bC\xbc\xde\xa4\xbe\xbc\xbeg`\xbd(\xf6|\xbd\xf2\x9d\xfc\xbc4\x86R<\x82NY=c\xbf\x12<\xeb_\xe4\x9a\xf9\xbcO\xf5W\xbd2\x8e\xb7<\xa3\x9bb;\xaeG\xbe<>\xc7\xf2;\"MF\xbc\t\x0c2\xbb\xa2_==\x9fq4\xbcxzA=\x1c\xb9\x08\xbb\xab\xd9!;\t\xed\x8f-\xbd\xb41\"\xbc\x93\xe6\xc6\x11\xbc\x86\xb4\xd3\xbd\xc5)\";7|\x90=\x94\xa9\x9f<\xacQ:\xbb\xacE\xe9\xbc&k\x8b=\xdcT\xce<+\xef\x9a\xbc\x92x0\xbd\x11\xe4p\xbd\x00\x94d=.\xe1)\xbc\x8cL\xf3\xba\xd0\xa3w<\xe9h\xbd=\xff\x88\x15\xbc[\x1a*==\xa6\x0f9\xa1\xf9d=\xbc\xb1\x11\xbd\xe5\xfb2=m\xe9X\xba\xff\x8d\x9b\xbd\x81\xbd\xbc=\x02z)\xbcw\x90I\xbbW\x00\r\xbd\xb4T\x8d\xbd\x1b\xa7\xb7\xbc\xa7VX=x@\xeb<\xe8\x93\x88=\xe5\x90\xfc<\x00Ur=;\\\xa0\xbd\x9f\xbc\xcf<\xc6\xe9\r\xbdA\x0f^\xbc\xf5\xbdR=UJ-\xbd00\xc4\xbbVFD=\xee\xab|\xbc\"\x92O<\x8e\x0b?\xbd\x18\x95b\xbd\x9fU\xed\xbb\xaf\x8c\xe3;i\xae\x17\xbd\x8d\x8d\";\x07`6\xbd^\xb4Q<\xce_\xda\xbcO\x04\x82<\x05\xbd\x80\xbd\xe0<$=\xbe \xb0<\xae\xe7\xc2\xbcb\xda\xe2\xbc\xbb\x15\x82=\xed\xb2`=\xd1\xd9\xa1\xb9kO\x0b<\x80\n\x19\xbd\x05\x10\x85\xbc\xd6\x1bR;\xbd\x1f\x88=\x15\x84{\xbd=\xc5\xc0<\xcd?\xf4\xbc\x08\x1d\xba\xbc\xc9_\x96=6>:=[{\xd9\xbb\x85\x03\x90\xbb\xc2~\xdb\xbc\xac\xf1\xf4\xbc\"\xf4\xaa=\xde\xd2$=\xa3F2=.d\xa8;\x03\xa2\xa8\xbc? 5\xbb\xe9\x8a2\xbc\xcdS\xd4\xbb\xc1[\xdd\xbd\xd4\xae\xc59v\x84T=o\r\x82\xbb\x94\x12\x1a\xbdTQ\x16=\xd2\xdcB=,n\x19\xbb\xdd\x9c\x15\xbd54\xcd<\x01\x8d\xea<\x94UI<\x8a\xb4V\xbd\xd0^J\xbd\xf0\xd3\x18<\xf5?a\xbc\xeepv\xbd\xe1\xeb.\xbc\x85\x86\x9b<\x15\xf8\x07=y\xa6*=\xd7\xfah\xbcS@B=\xc3\xe1\x0f\xbd\xdeq\xf4\xbc\n#9=+`\x0b\xbd\\\x18\x8e\xbb\x8e\x06\xbd\xbb\xc5$\x8b=\xa0\xd4\x85\xbbW\x0e5\xbd\x86\xbf5=\xe1h\xf2\xbcck\x14:\xd0\xef\xb3\xbb\xb3\xc1\x99\xbdH\x91\x8b;@I}<\xb7I?;6\xf3\x8f<$\xa0#\xbcZ2\x16=\xea\xf5\xf7<_\x16\x05\xbdlm\xaa<\xb57\x15\xbd\x8fg\xf7\xbb\x965\xa5\xe9\xf3\xbc\x7f\xa8\xd8<\xbem\xe8<\xc6p\x06\xbc\xc3v\x00=\x12r\xf7\xbcP\xac\x9d\xbd=\xe4\x82=\x0e{5\xbdc\xc0\x82\xbd,\xf1\xd5;\xbcJ\x1b\xbdF\"\x03=w\x8f\x8c=\xd7\x7f\x90\xbdU\xc2\x1b=i\xc7a\xbdT#%<\xba\x804\xbd\x91\x97-=\xcc\x82\x18=w\x14\x15\xbcaw+<\x13\xba=\xbc\rX\x02\xbd\x00}\xa2\xbboh\xf2\x87f:\xfd\xed\"\xbd\xc8\xbb\x80;\"\xbb\x91\xbc\xbd\xd5\xd7\xbb\x03\xb3^=\xc4\xbcv<\xaa\xdc\x80\xbc\xadg:\xbc+B\x15\xbd+\xe5t<\xde\x90:=\xdc\x88\x14<^\xfc/\xbd\xbd\xf5\x8b<\x04\xc3\xc5<\x89\xc8\x86\xbb\xcbMc\xbd L\xad;\x15\xa2I<8\xaf\x1b=\xefSd<\x10\\\xcf\xbb\x0f\x0c\xba\xbbZ\x85.\xbd\x9a\xb8\xca\xbc\xf0\xd4\xab\xbd8\xe8\x87=\xa6W\xcd\xbc\xbajX\xbdN\xb7\xa1\xbc\xf3\x1eP\xbd\xfd\xdal\xbb\xe3\x93\xfd\xbc\xd1\xf8`\xbc\x00\xfc\x82\xbc\xe3\xca+\xbd\nv\xad\xbc\x99\x11E=\x81\x9f\xbc<\xe3\xb8\xf9<\xfe\xbe\x07\xbcWw\xb0\xbb\x07>3<\x9f)\x8b\xbb\xb8W\xc3<\xaeE\xe9\xbc\x8cl\x89\xbc6qi\xbd\xafAS\xbcB\xa9=<\x92\xe3\x05\xbc\xd4Q+=\xe7\xf6\x92\xbdJ\xc2\xde<\xad\xdb\x92\xf7\xbcpI-=]\x99\"\xbd3@(<~\x1d>=\xa5\xd7\xc6\xbcm\xb3\x01=Z2\xc9\xbb\xb0N4=\xa1\xe6\x17=T\xd8\xec\xbc\x8d\xf2\x98\xbde\x08\"\xbd\xdb\xc1\x7f<\n\xf3v<7\xfe\xb0<\xcf\x15c\xbd\x85\xf2\xcb\xbb\x1b\x96Y\xbc\xacM\x10\xbc\x96\x88\x8a\t\x19\xee\x1c\xbdn\xdb\xc2=\xfe\x9b\xdd\xbck\xe2\xe8<,A\"=[\xbf%=Q\x12\xa6<\x12!d\xbd\x19\x14l:\x98\xbf\x1b\xbcQ\x97\x81<\x06m\r=\xb8\x0f\xb9;\xe7t\xc4\xbcLM\r\xbd\x17p\x1d=\x88\x91+<:0:\xbcr\xe7\xc0\xbc\xad\xb9i=6\x1b\x9b<7\xbc\xbb9b\xbe\x1c\xbc\xf9\xfb\x90:QH\xfc\xbb\xd9\xce\x89\xbch[\xf4\xbb\xb7\x1e(<\xde\xca!\xbb\x93J\xae\xbc\xf2\x9a+=\xe0z\x90<\xe0\x991\xbb=\xc1r\xbd\x7f\xca\xc1;\xdf\x942\xc5<\xe6\xed\xaf9\xf75\xcb\xbbi\x92\x9b<\x0b%\x12<\x19Sn\xbb8\xbe\xba;\xce&s=x\xae\x8f\xbck\xe0\x99\xbc\xd3\xb3*\xbd\xf0\xca:\xbd\x85\x13p\xbc\xb9\xb6\xb5=+\xde\x9d=\x0f:G\xbb\x98\xc2\xb1\x8f<3\xa7)\xbd\xf6q?\xbc\x85\x92`\xbd\x044\xc9;EM\x03\xbd?\x94\x02:7y\xcd\xbd\x03\xad\x9f=%\xe6\x0c\xd7*\xbd\x11_X\xbd3ly<\x8e$\xdb\xbc\xb4\x91m\xbd\xe0\x00\x7f\xbd\x9e\xbep\xbc\xb98\x88\xbd6W\xa2<0\x9a\xa5:\xc2\xe7\x81\xbd\xc6\x87\xa0\xbd\x16\xfd\xe9\xbbm\xde\"=\x01V\xaf\xbc\x83\x9c\xbc\xba\x97#+\xbcef\x9d\xbc8\x02G=\x98\x1d\x14=b2$\xbdW\xf2*=\xbfaz=N\xdf\\\xbb1\xf9\xf1;\xd4C\xe7\xbc\xd3*\x1a\xbd\t\x1d\x92\xbc?\xcc\x05\xbd\r\x93G\xbd\xab\x9e\x0e=\xc8\xfb\xd1<\nU\x02\xbdU\xba\x0e\xbb\xb7)\x9f=!v\x16<+\x1b\xcd\xbcG\xf1\xdc;\x88u\x91\xbc\\fG=\xaa\xc6\xd0\xbc\x92i\xbb\xbb\xea\x002=\xe2\xfdz<\xe14\x86=\xc6\xa9*\xbc\x93V5<\xae7&\xbd\xd7\xd5\xb4;\xad\xe0\xd3\xbc@\t4\x14@<\x93\x93\xf2\xba\" \xd19\x9e\xcfG\xbd\x97\xa5\xcf<\xa4\xdb\x87=jQ\xcd;\x83j\xec<\xbcn2=D\xbaE=\xd7|];\x06\x07\xce\xbc\xdd5E=\xc1\xa9d<\xaf\xb1\x06\xbd\xeb\xbf\x89\xbdH\xe6\x1d<>\xd2\x85\xbcl\xf3\xb7=n\x02\x19\xbcD.\xfb\xbb\xad\x18\xa9;*\xb9\xfe\xbc\x8c\x13-\xbd\x16\xbd\xc9\xbcub\xc2\xbd\xed\x10\x1d\xbc\xf9\x19\xe8\xed\xd9\xbc\xdeS[;,\x1d8\xbdj\xd9(\xbdF\x1b\x8e<\xda\"m\xbdg\xc7\x9f\xbd\xc4Z\x90=\x180\x05;\x12T>9\xcd\xece\xbdt\xd25<\xc9\x1f.\xbcy\x81\x81=\xd8Do<7\x0b\xd6;3 \xa2<\xfd\xe9\xcb\xbcz\x94==e\xbf\x19\xbd\xbd\x14\x92;\xbc\xa5\xcc\xbc\xf3y`\xbc^$2<\x1c\xd3\r<<\xa6\xb9<\x08O|\xae\x19\xbc\xb5?\\=\xfa8\x1b\xbce\xa6\xdf\xbc#\x01\xdf:\xc62\xe0<\xeb7i\xbd\x96\xa1k\xbb&i\x1b=\x03\xfd#8+\x02w\xbc%\xad\x05\xbd\xe5\xe6\xcd=\xc4C\x87\xbc\x11\xda\xdc<\x08\xe9\x8d\xbc\xe4\x87x\xbdRk\xef\xbcv?w\xbcz;t\xbb&\x1c\xa3;\"\x1e\xa6\xbc\xc9v\x8b\xb1\"=APj\xbc\xc2\xcb\x18=\xfdp\x83;W\xf9\xd4<\xf7Q\xd4<\\B\xa5=\x1a\xde\xff\xd7\x08<&\x1f.\xb9Xc9=CB\xf2<\xb1n\x14\xbd\xfdh\x0f=\xe7nQ\xbdB\x0b\x14\xbd\xc50><3\xcey<\x02q=\xb9g\x13M\xbb\xac\xea1\xbb\xed\x86%\xbc\x99*\xd1<\xdf\"\x03\xbd\xf8\x13\r<\x15\x17\xa1;!\x8b\xef\xbc\x1b\xcf)\xbd\x0b\xc5V\xbd\xa8:4=\x7f)h:\xc5\xceMT;\xe9`\xab\xbb\x82\xc0\xec\xbb,\x9c\xa2\xbb\x9eC$\xbc\x84s&=\xab\xda\xc1\xbd\xda\x83\xd2\xbb\xcd11\xbd~5\x90;\xf4+\x03=\xf7\xb9\xf2<\xa5d\xce;\x0b\xe1\x18=\xdcm\x10\xbd\xcd]]\xbb\xe3\xf5+=\xd5^\x01\xbcL\xb9\x1c<11\xa4\xbc>\xaeK<\x1d(\x1c\xbdnU\xf0<\x9eLl=>\x95\xbd;\x88\xc6\xc7<\xfe\x9f\xe4<\x82o`\xbc\x95%0=\x92D=\xbd\xd2<\x94<\xab\xc2\x1f\xbd\xfcc)\xbdP\xe3\xc4;\x9f^P\xbc\x98~\n\xbc\x96\xd4;=\x92\xdba\xbd&:\x95=6Gc<\xf4yu<[\x1e\xb5<+\xeb\xb9\xbb\x98\x1d\xf3\xbcdG5\xbd\x1e\x8b\x81=2\xf6o=\xacT\x8b\xbb\t\xa5\xdd\xda<\xac\xa5(\xbdK\x05\x03\xbd\xbe\x1e\xd5;\xd92$<\"\x10M=\n\xb1\xb0\xbdV~\xec\xbb\xea\xe9H\xbc\x1d\n\xfa\xc2;\xe4\x07\xbc\xbdo\x0f\xc1=S\xf3[<\xa9\x9eD\xbd3~`\xbc\x9f\xe9\xd9<\xb8\xc1@\xbd=(\xb8=\xf5\xb6\x92<\x8b\x9e\x83<\xbdi\x15;\xb5\xbbm=\x11\xb2\xc7\xb7\xaa\xeb\xa3;\xc0\xd9L\xbd\xa8/_=\xeb\xc4\xae\xb9\xe6\xd4\x01=>\xf1Q=\x84\x1a\x18<)\xc5_<\xd6\xd1\xaa\xbdA23\xbc\xfb\x1c\xae\xbc]\xeb(\xbd\xb0Z\xf1<\x00G\xdf\xbc\xc6G\xd3\xbc\x07n0=F@\xa8\xba\xd2k\x02\xbd\x98\xf7&=\x16%\x94\xbc\x08\x9a\\\xbd\xb5H|\xbc\xdaqE=\x87\xd8\xa6\xbd\xe7\xf0\xd5<\xd2\xe7T<\x8c=\xa3\xbcT`\x1e=\x7fX\x80\xbc\xc8\xe7\xab<1\x07\xf2\xbc\x1c{$\xbd{\xc1\x81<\xd61\xba;\xd3G\x00\xbd\x1b\r\x97:\xf6\x07(\xbd\xc0\x02\x86<\x1c\x89\x03\xbd\x8c\xaa@\xbc\\\x13\x03\xbd\x8b\xe0\\=VW\x8a\xae<\x8fY\x8b\xbc\xcc\xba\x9d<\xc8\xde*=\r\x03\xe9;1\xea\xec\xb8\"\xddX\xbc\x1e@\xac\xbc&G\x04\xbd\xc4\xfd%\xbd:3\\\xbd\xbd\xf3\xae\xbc\xf7Go=.\x0e\xf0<\xc4\x98\x1b\xbd\xd2*\xc4\xbc[,`;X+~=\xfa\xc4\x84\xbc\xec9{\xbd\x86U\x06\xbd\x00zG\xbc\x08z\x88\xbd\x9d\xbc\x84\xbd\xb1\x1e\x05:x\xe3\xb8=\xed\xa5\xcb<\x97\x1d\xa5=H\xc5\r\xbd\xe0\xd8m=A\xc0\x8d\xbcfFN\xbcM\x0c\xf3\xbc\xd5&x=\xde\x98g\xbcJ\x17\xb0;c\x83\x89;\xfd\x06l\xbd4\xd4\xeb<\xe7\x16\xe6\xbca\xd8\x05\xbc\xd4\x9d\xea\xbb\xe5\x04\xc9\xbbX\x9b\xdc\xbcE\xbcD\xbd\"\xcaF=w\x0e\xbc;\x06\xd7\x1d<\xad\x9d\x99\xbb)\x9e\x83<\xb12?=\x82\xdb\xb3\xbc\x10v\xba;\xba\xf8 =\xf2*\xeb<\x94\xedn\xbbMk\x1d\xbd\x13\x99\xb9\xbc4\xb3\"\xbd\xe9\xfb\xc1\xbbD\xd1\x80\xbc\x83E\xe3\xbcd\xa4\x84\xbb\xe0s\xef\xbc\r\xe0\x88\xbc]A%=\x97\xaa\xab<\x06}\xef9\x03\x98\x1d\xbd\x03\xc9\xdb\xbc\xa4&\x8d<\xdf\x8d\xac\xbc\xe4\xdaw=@\xd2\xa0\xbc\x03\x9a{\xbd\xf1\xb9\x9brY=:\xdd\x87<\xc9\xbfX==\x90\xdf<\x0c[\x1d=\xe1\xa8\xcf\xbc/`\n=\x1b\xd5\x87\xbcSX\x92\xbd\x0f\x89\x82\xbd6\xc4\x06=9\xd6\x92\xbd\x8dzJ\xbc\x0cj\xac\xbc\xae+\x96=\xc3\x8e\x95\xbc\xfc\x8cw;\xaf\x9e\xa5\xbd^\xcek\xbb\xd6\xafo;\x80H\x84<\r\x1c\xb1\xbc\xf6\xef\xe3\xbc\xb0\xfe\x02=\xd6#\xe2;;\xed*<\x04b\xf4\x06\xc2<\x81\xed\x88\xbd\xd1\xcf\x1e\xbc@\x01b\xbb\xc6\xa7\x0b\xbd\xa8\xf2\x85<\xfc\xc8\x97\xbd\xbf/M<\xb4\xb9_=c\xa9\x81=\xd3\\u\xbc,3O;\x1b7\xb4;\xd9P\x1f=\x8b+:=xOf\xbd\xb2\xb4\xc1\xbcb\xe9\x1a\xbc\xae\xac\xda\xbc\x8d<\x95=\xa3\x07(<\xa7\x16\xb6\xbb\t\xab9\xbdx\xb1\xdb\xb9\xf6\x85\x0f=\x16H3\xbc\xe1M\x14\xbc\xd7~\x9d<\x88\x9d =\xf8C\x86=b\x8c\x98\xbb\x05\xf6\x05=hw\xfe\xbc\xf1\xaa\x01\xbd\x13\xd4\x10=\xe0S\x96<\xbf{\x1d\xbc\x18O;=T\xbbA;v\xae\xd1\xbcnL\x11\xbd\x06\x1d\xd7\xbdS\x86\xcf\xbc\x06\xaf\x0f=i\x98\x1b\xbcp\x99\xc4=\x9ebI=\xf5\xd8q\xbb\xfb\x83\x97<\x19\x0e\xaf<|X\x8a\xbd\xa9%\x98\xbc\xdb\xc3\t=P\xb8V=\x99\x82\x16\xbb\xb2\x16\x84;\xf0<\xa1<\xd04\xb3<1\x8d\x19\xbc\xfa\xdaz\xbdC\xd2\xca\xb9[\xae\x84\xbb0\x84+\xbd\xe6\xc0F\xbd\x0b|9\xbc\x7f\xd9o;\xb1\x98H\xbd\xacd\xb95\xc3;\xbd\x8e\xb48\xcc\xb7\x80\xbdm\x9e$=\x18\x855<\xc6r\xb4\xbc\x12\x1d|<\xe8\xec\xc9<\xd7\xdbG\xbd\x16\xf0\x95\xbdu?\x12\xbd\xe6L\xa9=\xbf$\x85P\xc1\xbckS\x91\xbc3\xff(\xbc}\x93\x7f\xba\xa3v\x19\xbd\x1a\xbd\xdf<\xf2\x12^\xbc\xb7\n\x0e\xbc\xecM\xf2<1PU\xbbb\x81\xa3\xbd<\xd8\x19\xbdf\xca\x14<\x87\xddW\xbc\xe5\xd0\xc5\xbb0j\x15\xbd#\xe4\xf7<\xfdL\x1f=\x1b\x00\xbd\xbc\x16\xf8m\t\x92\xcc\xa1\xbc\xf3\xe9\x04\xbd\xfa\xaaL=\x85\x07\x8b<)j\xe9\xbb\xbd\x90 =\n\xf4\x07\xbc\xf2\xf0\x8d<\x82=8A\xdb<\x1b\xed\x0f\x01\xbbd$\x9b\xbdp\xc1p=\xdf\xf0\\<\xdc\x1fK=\x14\xe3c<\xb3\x14\"=\x10\xff\xb4\xbd\x81\xf5\x00\x03\xbc\x04na\xbc_\xd4\xde<\xec\xd5\xcb:\xd2\xba\x06:\xb61\x83\xbb\x01V#\xbd\x98\xe1\x90\x9d;\x8d\x8f\x06=}\x9f4\xbdF6+=\\\xe9S\xbd\x9co+=x\xa3\x0c\xbdW\x8f8\xbds\x97\xaf<\x9d\x0e\xa9\xbcj\xae\xd9;QM8\xbdz%\xe2\xbd\xf0\xa9\x91\xbcp\xbc\x8b\xbcX=\xe8<\xc1n\xce\xbc\x82\xd3\xcb<\t\xa4\x12;\xa34o<\xcb\xdfL<\xcbU\xa2<)S\xf7-=\x94\xc3\xd1\xbd\xf3\xf3\xcaj\xbbs\x9cR\xbd\x9a%\xc0\xbd\xff\xf5\xf6<\xdb\xc3\x13=\xe5\xc2\x1a=\x9c\xa8\"\xbd\xf5\x0b9\xbd\xa5t\xe7=\x94\xc5\xcf=R\x00\xad;\x1c.2;\x14 \xaf\xbc\xbf\xb1\x81\xbd\xc0e\x06\xbd\x1bT\xc4<\x8b\xc0\x8b\xbd\xab\xc6\x9f\xbc\x10\x92\x0f\xbdUP6=&P\xd6<\x0c\xef\n<\x88\x83\xc7<\xbar\xf9<\x9cl\x95;\x9d\x0c\"=e*\xc4;!\xcd\x12=\xac\xae,=\xd1:\x05=0\xf2|;S\xe4\xff\xbb\xa9M;\xbc_\x899\xbd\xf4\xbe4\xbd9\xb1\xda\xbb\x91%\xc5<<\x9e\xad\xbc\xed\x80S\xbdbHp<\xe5\x91\xdc;h\x9b\x12\xbbv$#<\x10\xd0f\xbdocU=d\x17\xe1\xbc\"WC\xbd\xed\xdb \xbcJ!\xa4<\xb4\x7f5\xbd\xb9\x00\x98\xbdAr\x96\xbd\xe6\x18\xb6=:\xf1\xb9@\xf9\xbb\xb7\xa62=\x9a\x03~<\x16[\x99\xbc\x806\x87\xbd\x80\xcf==d\xf9.:\xd8\xfd\xb9<\x9a\xea{\t\xf9\xads\xbcx\x93\x14=\xcbB1\xbc/\xf8\xf5:\x03\"\xd3<\"\x912\xbc\x00+\xf1;\xee\xc4\xf67\xd1\xf4\x14=\xe8\xbcR\xbd\xc6\xb9\r=z$\xd0;\xea\x99A\xbd\x91d\x06=f1\x11\xbd\xd0_+:\x14J\x96\xbc\x87\x87\xc6\xbce\x13.?\xc2\xf1\xbc\x8d\xf6\xf2<\x97hf<\xef7\xf2;\x0e\xfd\xbe\xbc\xd9\x88\xd0\xbc\xfc\xc8\xc4<\xe7D\xc6\xbb5\x0b\x9e=\xc8\xac\xcb<+\x06\n=\xf1\x89\x93\x0b*=\xea\xc5\xd5\xbb\xf3\xc5\\\xbc\xd2\x0eT<\x9a\xc7\x1e:\xbf\xccE<\x00\xf5\xe6<\x94\x8a\xf5\xbbxl\xa7<\xb2A\"\xbc\xc9\xdb\x02\xbc\xdf\x7f\x12=.:s\xbc\xc7\"v<~v\x83\xbb2iF\xbc}\xd5\x1a\xbd\x10\"\xba\xbc^\x92\x1a\xbc\x11\x02\xcf<4:1<\x86\x1b\xae=\x83\x19\xf0 { return await this.service.getInfo(clientMetadata, dto); } + + @Delete('') + @HttpCode(204) + @ApiOperation({ description: 'Delete index' }) + async delete( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: IndexDeleteRequestBodyDto, + ): Promise { + return await this.service.deleteIndex(clientMetadata, dto); + } } diff --git a/redisinsight/api/src/modules/browser/redisearch/redisearch.service.spec.ts b/redisinsight/api/src/modules/browser/redisearch/redisearch.service.spec.ts index a3a3f99273..e4ec3ee538 100644 --- a/redisinsight/api/src/modules/browser/redisearch/redisearch.service.spec.ts +++ b/redisinsight/api/src/modules/browser/redisearch/redisearch.service.spec.ts @@ -3,6 +3,7 @@ import { ConflictException, ForbiddenException, InternalServerErrorException, + NotFoundException, } from '@nestjs/common'; import { when } from 'jest-when'; import { @@ -453,4 +454,71 @@ describe('RedisearchService', () => { ).rejects.toThrow(InternalServerErrorException); }); }); + + describe('deleteIndex', () => { + it('should delete index for standalone', async () => { + const mockIndexName = 'idx:movie'; + when(standaloneClient.sendCommand) + .calledWith(expect.arrayContaining(['FT.DROPINDEX'])) + .mockResolvedValue(undefined); + + await service.deleteIndex(mockBrowserClientMetadata, { + index: mockIndexName, + }); + + expect(standaloneClient.sendCommand).toHaveBeenCalledWith( + ['FT.DROPINDEX', mockIndexName], + { replyEncoding: 'utf8' }, + ); + }); + + it('should delete index for cluster', async () => { + const mockIndexName = 'idx:movie'; + databaseClientFactory.getOrCreateClient = jest + .fn() + .mockResolvedValue(clusterClient); + when(clusterClient.sendCommand) + .calledWith(expect.arrayContaining(['FT.DROPINDEX'])) + .mockResolvedValue(undefined); + + await service.deleteIndex(mockBrowserClientMetadata, { + index: mockIndexName, + }); + + expect(clusterClient.sendCommand).toHaveBeenCalledWith( + ['FT.DROPINDEX', mockIndexName], + { replyEncoding: 'utf8' }, + ); + }); + + it('should handle index not found error', async () => { + const mockIndexName = 'idx:movie'; + when(standaloneClient.sendCommand) + .calledWith(expect.arrayContaining(['FT.DROPINDEX'])) + .mockRejectedValue(mockRedisUnknownIndexName); + + try { + await service.deleteIndex(mockBrowserClientMetadata, { + index: mockIndexName, + }); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + } + }); + + it('should handle ACL error', async () => { + const mockIndexName = 'idx:movie'; + when(standaloneClient.sendCommand) + .calledWith(expect.arrayContaining(['FT.DROPINDEX'])) + .mockRejectedValue(mockRedisNoPermError); + + try { + await service.deleteIndex(mockBrowserClientMetadata, { + index: mockIndexName, + }); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); }); diff --git a/redisinsight/api/src/modules/browser/redisearch/redisearch.service.ts b/redisinsight/api/src/modules/browser/redisearch/redisearch.service.ts index 0b30f268c7..44d2acf6b9 100644 --- a/redisinsight/api/src/modules/browser/redisearch/redisearch.service.ts +++ b/redisinsight/api/src/modules/browser/redisearch/redisearch.service.ts @@ -3,6 +3,7 @@ import { ConflictException, Injectable, Logger, + NotFoundException, } from '@nestjs/common'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { catchRedisSearchError } from 'src/utils'; @@ -28,6 +29,7 @@ import { RedisClientNodeRole, } from 'src/modules/redis/client'; import { convertIndexInfoReply } from '../utils/redisIndexInfo'; +import { IndexDeleteRequestBodyDto } from './dto/index.delete.dto'; @Injectable() export class RedisearchService { @@ -269,6 +271,36 @@ export class RedisearchService { } } + public async deleteIndex( + clientMetadata: ClientMetadata, + dto: IndexDeleteRequestBodyDto, + ): Promise { + this.logger.debug('Deleting redisearch index ', clientMetadata); + + try { + const { index } = dto; + const client: RedisClient = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + + await client.sendCommand(['FT.DROPINDEX', index], { + replyEncoding: 'utf8', + }); + + this.logger.debug( + 'Successfully deleted redisearch index ', + clientMetadata, + ); + } catch (error) { + this.logger.error( + 'Failed to delete redisearch index ', + error, + clientMetadata, + ); + + throw catchRedisSearchError(error); + } + } + /** * Get array of shards (client per each master node) * for STANDALONE will return array with a single shard diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts b/redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts index 4ac507bb32..9e745823cd 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts @@ -18,7 +18,10 @@ import { BulkImportService } from 'src/modules/bulk-actions/bulk-import.service' import { ClientMetadataParam } from 'src/common/decorators'; import { ClientMetadata } from 'src/common/models'; import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface'; -import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto'; +import { + UploadImportFileByPathDto, + ImportVectorCollectionDto, +} from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto'; @UsePipes(new ValidationPipe({ transform: true })) @UseInterceptors(ClassSerializerInterceptor) @@ -85,4 +88,21 @@ export class BulkImportController { ): Promise { return this.service.importDefaultData(clientMetadata); } + + @Post('/vector-collection') + @HttpCode(200) + @ApiEndpoint({ + description: 'Import vector collection data', + responses: [ + { + type: Object, + }, + ], + }) + async importVectorCollection( + @Body() dto: ImportVectorCollectionDto, + @ClientMetadataParam() clientMetadata: ClientMetadata, + ): Promise { + return this.service.importVectorCollection(clientMetadata, dto); + } } diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts index 0a4de22f72..0468e09ef5 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BulkImportService } from 'src/modules/bulk-actions/bulk-import.service'; import { mockBulkActionsAnalytics, + mockBulkActionOverviewMatcher, mockClientMetadata, mockClusterRedisClient, mockCombinedStream, @@ -541,4 +542,81 @@ describe('BulkImportService', () => { } }); }); + + describe('importVectorCollection', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + (mockedFs.pathExists as jest.Mock).mockReset(); + (mockedFs.createReadStream as jest.Mock).mockReset(); + }); + + it('should import vector collection successfully', async () => { + const spy = jest.spyOn(service, 'import'); + spy.mockResolvedValue(mockBulkActionOverviewMatcher); + + (mockedFs.pathExists as jest.Mock).mockResolvedValue(true); + (mockedFs.createReadStream as jest.Mock).mockReturnValue(new Readable()); + + const result = await service.importVectorCollection(mockClientMetadata, { + collectionName: 'bikes', + }); + + expect(mockedFs.pathExists).toHaveBeenCalledWith( + expect.stringContaining('vector-collections/bikes'), + ); + expect(mockedFs.createReadStream).toHaveBeenCalledWith( + expect.stringContaining('vector-collections/bikes'), + ); + expect(spy).toHaveBeenCalledWith( + mockClientMetadata, + expect.any(Readable), + ); + expect(result).toEqual(mockBulkActionOverviewMatcher); + }); + + it('should throw BadRequestException when collectionName file does not exist', async () => { + (mockedFs.pathExists as jest.Mock).mockResolvedValue(false); + + await expect( + service.importVectorCollection(mockClientMetadata, { + collectionName: 'bikes', + }), + ).rejects.toThrow('No data file found for collection: bikes'); + + expect(mockedFs.pathExists).toHaveBeenCalledWith( + expect.stringContaining('vector-collections/bikes'), + ); + }); + + it('should handle import errors', async () => { + const spy = jest.spyOn(service, 'import'); + const importError = new Error('Import failed'); + spy.mockRejectedValue(importError); + + (mockedFs.pathExists as jest.Mock).mockResolvedValue(true); + (mockedFs.createReadStream as jest.Mock).mockReturnValue(new Readable()); + + await expect( + service.importVectorCollection(mockClientMetadata, { + collectionName: 'bikes', + }), + ).rejects.toThrow('Import failed'); + + expect(spy).toHaveBeenCalledWith( + mockClientMetadata, + expect.any(Readable), + ); + }); + + it('should throw BadRequestException when collectionName is not in allowed list', async () => { + await expect( + service.importVectorCollection(mockClientMetadata, { + collectionName: '../../etc/passwd', // malicious input + }), + ).rejects.toThrow('Invalid collection name'); + }); + }); }); diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts index 2affda0d9f..9068a93e7a 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts @@ -19,7 +19,10 @@ import { BulkActionType, } from 'src/modules/bulk-actions/constants'; import { BulkActionsAnalytics } from 'src/modules/bulk-actions/bulk-actions.analytics'; -import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto'; +import { + UploadImportFileByPathDto, + ImportVectorCollectionDto, +} from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto'; import { RedisClient, RedisClientCommand, @@ -34,6 +37,8 @@ const BATCH_LIMIT = 10_000; const PATH_CONFIG = config.get('dir_path') as Config['dir_path']; const SERVER_CONFIG = config.get('server') as Config['server']; +const ALLOWED_VECTOR_INDEX_COLLECTIONS = ['bikes']; + @Injectable() export class BulkImportService { private logger = new Logger('BulkImportService'); @@ -280,4 +285,42 @@ export class BulkImportService { ); } } + + /** + * Import vector collection data + * @param clientMetadata + * @param dto + */ + public async importVectorCollection( + clientMetadata: ClientMetadata, + dto: ImportVectorCollectionDto, + ): Promise { + try { + if (!ALLOWED_VECTOR_INDEX_COLLECTIONS.includes(dto.collectionName)) { + throw new BadRequestException('Invalid collection name'); + } + + const collectionFilePath = join( + PATH_CONFIG.dataDir, + 'vector-collections', + dto.collectionName, + ); + + if (!(await fs.pathExists(collectionFilePath))) { + throw new BadRequestException( + `No data file found for collection: ${dto.collectionName}`, + ); + } + + const fileStream = fs.createReadStream(collectionFilePath); + return this.import(clientMetadata, fileStream); + } catch (e) { + this.logger.error( + 'Unable to import vector collection data', + e, + clientMetadata, + ); + throw wrapHttpError(e); + } + } } diff --git a/redisinsight/api/src/modules/bulk-actions/dto/upload-import-file-by-path.dto.ts b/redisinsight/api/src/modules/bulk-actions/dto/upload-import-file-by-path.dto.ts index d9a73964fd..85b0e3bd3e 100644 --- a/redisinsight/api/src/modules/bulk-actions/dto/upload-import-file-by-path.dto.ts +++ b/redisinsight/api/src/modules/bulk-actions/dto/upload-import-file-by-path.dto.ts @@ -10,3 +10,14 @@ export class UploadImportFileByPathDto { @IsNotEmpty() path: string; } + +export class ImportVectorCollectionDto { + @ApiProperty({ + type: 'string', + description: 'Collection name to load vector data', + example: 'bikes', + }) + @IsString() + @IsNotEmpty() + collectionName: string; +} diff --git a/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action-overview.interface.ts b/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action-overview.interface.ts index 5f5e0e971c..54b11e0955 100644 --- a/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action-overview.interface.ts +++ b/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action-overview.interface.ts @@ -11,8 +11,8 @@ export interface IBulkActionOverview { databaseId: string; duration: number; type: BulkActionType; - status: BulkActionStatus; - filter: IBulkActionFilterOverview; + status: BulkActionStatus; // Note: This can be null, according to the API response + filter: IBulkActionFilterOverview; // Note: This can be null, according to the API response progress: IBulkActionProgressOverview; summary: IBulkActionSummaryOverview; } diff --git a/redisinsight/api/src/utils/catch-redis-errors.ts b/redisinsight/api/src/utils/catch-redis-errors.ts index 5b75ebf86c..82abd00444 100644 --- a/redisinsight/api/src/utils/catch-redis-errors.ts +++ b/redisinsight/api/src/utils/catch-redis-errors.ts @@ -173,7 +173,10 @@ export const catchRedisSearchError = ( ); } - if (error.message?.includes('Unknown index')) { + if ( + error.message?.toLowerCase()?.includes('unknown index') || + error.message?.toLowerCase()?.includes('no such index') + ) { throw new NotFoundException(error.message); } diff --git a/redisinsight/api/test/api/.mocharc.yml b/redisinsight/api/test/api/.mocharc.yml index 2522bfc111..06831b6520 100644 --- a/redisinsight/api/test/api/.mocharc.yml +++ b/redisinsight/api/test/api/.mocharc.yml @@ -1,5 +1,5 @@ spec: - - 'test/**/*.test.ts' + - test/**/*.test.ts require: 'test/api/api.deps.init.ts' project: ./test/api/api.tsconfig.json retries: 2 diff --git a/redisinsight/api/test/api/redisearch/DELETE-databases-id-redisearch.test.ts b/redisinsight/api/test/api/redisearch/DELETE-databases-id-redisearch.test.ts new file mode 100644 index 0000000000..4ac47d8f6e --- /dev/null +++ b/redisinsight/api/test/api/redisearch/DELETE-databases-id-redisearch.test.ts @@ -0,0 +1,140 @@ +import { + expect, + describe, + before, + Joi, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + getMainCheckFn, +} from '../deps'; + +const { server, request, constants, rte, localDb } = deps; + +// API endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).delete( + `/${constants.API.DATABASES}/${instanceId}/redisearch`, + ); + +// Input data schema +const dataSchema = Joi.object({ + index: Joi.string().required(), +}).strict(); + +const validInputData = { + index: constants.TEST_SEARCH_HASH_INDEX_1, +}; + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('DELETE /databases/:id/redisearch', () => { + requirements('!rte.bigData', 'rte.modules.search'); + + before(async () => { + await rte.data.generateRedisearchIndexes(true); + }); + + describe('Main', () => { + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).forEach( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + before(async () => rte.data.generateRedisearchIndexes(true)); + + [ + { + name: 'Should delete index', + data: validInputData, + statusCode: 204, + before: async () => { + // Verify index exists before deletion + expect(await rte.client.call('FT._LIST')).to.include( + constants.TEST_SEARCH_HASH_INDEX_1, + ); + }, + after: async () => { + // Verify index is deleted after deletion + expect(await rte.client.call('FT._LIST')).to.not.include( + constants.TEST_SEARCH_HASH_INDEX_1, + ); + }, + }, + ].map(mainCheckFn); + }); + + describe('RediSearch version < 2.10.X', () => { + requirements('rte.modules.search.version<21000'); + before(async () => rte.data.generateRedisearchIndexes(true)); + + [ + { + name: 'Should return 404 if index does not exist', + data: { index: 'non-existing-index' }, + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Unknown Index name', + error: 'Not Found', + }, + }, + ].map(mainCheckFn); + }); + + describe('RediSearch version >= 2.10.X', () => { + requirements('rte.modules.search.version>=21000'); + before(async () => rte.data.generateRedisearchIndexes(true)); + + [ + { + name: 'Should return 404 if index does not exist', + data: { index: 'non-existing-index' }, + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'non-existing-index: no such index', + error: 'Not Found', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => { + await rte.data.generateRedisearchIndexes(true); + await rte.data.setAclUserRules('~* +@all'); + }); + + [ + { + name: 'Should delete regular index', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: validInputData, + statusCode: 204, + }, + { + name: 'Should throw error if no permissions for "FT.DROPINDEX" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + index: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => { + // Remove permission for "FT.DROPINDEX" command + return rte.data.setAclUserRules('~* +@all -FT.DROPINDEX'); + }, + }, + ].map(mainCheckFn); + }); + }); +}); diff --git a/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-info.test.ts b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-info.test.ts index 1045503a5d..8dec18ea91 100644 --- a/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-info.test.ts +++ b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-info.test.ts @@ -146,11 +146,11 @@ describe('POST /databases/:id/redisearch/info', () => { data: { index: 'Invalid index', }, - statusCode: 500, + statusCode: 404, responseBody: { + statusCode: 404, message: INVALID_INDEX_ERROR_MESSAGE_V1, - error: 'Internal Server Error', - statusCode: 500, + error: 'Not Found', }, }, ].forEach(mainCheckFn); @@ -177,11 +177,11 @@ describe('POST /databases/:id/redisearch/info', () => { data: { index: 'Invalid index', }, - statusCode: 500, + statusCode: 404, responseBody: { + statusCode: 404, message: INVALID_INDEX_ERROR_MESSAGE_V2, - error: 'Internal Server Error', - statusCode: 500, + error: 'Not Found', }, }, ].forEach(mainCheckFn); @@ -204,11 +204,11 @@ describe('POST /databases/:id/redisearch/info', () => { data: { index: 'Invalid index', }, - statusCode: 500, + statusCode: 404, responseBody: { - message: INVALID_INDEX_ERROR_MESSAGE_V2, - error: 'Internal Server Error', - statusCode: 500, + statusCode: 404, + message: 'Invalid index: no such index', + error: 'Not Found', }, }, ].forEach(mainCheckFn); diff --git a/redisinsight/ui/src/assets/img/icons/bike.svg b/redisinsight/ui/src/assets/img/icons/bike.svg new file mode 100644 index 0000000000..ca52e5673a --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/bike.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/icons/popcorn.svg b/redisinsight/ui/src/assets/img/icons/popcorn.svg new file mode 100644 index 0000000000..f477aeb19a --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/popcorn.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/components/base/icons/iconRegistry.tsx b/redisinsight/ui/src/components/base/icons/iconRegistry.tsx index 882bfeb212..4b0190e2de 100644 --- a/redisinsight/ui/src/components/base/icons/iconRegistry.tsx +++ b/redisinsight/ui/src/components/base/icons/iconRegistry.tsx @@ -52,6 +52,8 @@ import UserInCircleSvg from 'uiSrc/assets/img/icons/user_in_circle.svg?react' import UserSvg from 'uiSrc/assets/img/icons/user.svg?react' import VersionSvg from 'uiSrc/assets/img/icons/version.svg?react' import VisTagCloudSvg from 'uiSrc/assets/img/workbench/vis_tag_cloud.svg?react' +import BikeSvg from 'uiSrc/assets/img/icons/bike.svg?react' +import PopcornSvg from 'uiSrc/assets/img/icons/popcorn.svg?react' // Import guides icons import ProbabilisticDataSvg from 'uiSrc/assets/img/guides/probabilistic-data.svg?react' @@ -217,6 +219,8 @@ export const Trigger = createIconComponent(TriggerIcon) export const UserInCircle = createIconComponent(UserInCircleSvg) export const VersionIcon = createIconComponent(VersionSvg) export const VisTagCloudIcon = createIconComponent(VisTagCloudSvg) +export const BikeIcon = createIconComponent(BikeSvg) +export const PopcornIcon = createIconComponent(PopcornSvg) // Guides icons export const ProbabilisticDataIcon = createIconComponent(ProbabilisticDataSvg) diff --git a/redisinsight/ui/src/components/base/layout/flex/flex.styles.ts b/redisinsight/ui/src/components/base/layout/flex/flex.styles.ts index bcd9ec26eb..aa2c662454 100644 --- a/redisinsight/ui/src/components/base/layout/flex/flex.styles.ts +++ b/redisinsight/ui/src/components/base/layout/flex/flex.styles.ts @@ -311,10 +311,12 @@ export type FlexItemProps = React.HTMLAttributes & grow?: (typeof VALID_GROW_VALUES)[number] $direction?: (typeof dirValues)[number] $padding?: (typeof VALID_PADDING_VALUES)[number] + $gap?: GapSizeType } export const StyledFlexItem = styled.div` display: flex; + gap: ${({ $gap = 'none' }) => ($gap ? flexGroupStyles.gapSizes[$gap] : '')}; flex-direction: ${({ $direction = 'column' }) => flexGroupStyles.direction[$direction] ?? 'column'}; ${({ grow }) => { diff --git a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts index f9503f7f74..44e5d94a31 100644 --- a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts @@ -2,6 +2,7 @@ import { lazy } from 'react' import { IRoute, FeatureFlags, PageNames, Pages } from 'uiSrc/constants' import { BrowserPage, + VectorSearchPageRouter, HomePage, InstancePage, RedisCloudDatabasesPage, @@ -20,8 +21,12 @@ import PipelineManagementPage from 'uiSrc/pages/rdi/pipeline-management' import { ANALYTICS_ROUTES, RDI_PIPELINE_MANAGEMENT_ROUTES } from './sub-routes' import COMMON_ROUTES from './commonRoutes' import { getRouteIncludedByEnv, LAZY_LOAD } from '../config' +import { VECTOR_SEARCH_ROUTES } from './sub-routes/vectorSearchRoutes' const LazyBrowserPage = lazy(() => import('uiSrc/pages/browser')) +const LazyVectorSearchPageRouter = lazy( + () => import('uiSrc/pages/vector-search/pages/VectorSearchPageRouter'), +) const LazyHomePage = lazy(() => import('uiSrc/pages/home')) const LazyWorkbenchPage = lazy(() => import('uiSrc/pages/workbench')) const LazyPubSubPage = lazy(() => import('uiSrc/pages/pub-sub')) @@ -55,6 +60,12 @@ const INSTANCE_ROUTES: IRoute[] = [ path: Pages.browser(':instanceId'), component: LAZY_LOAD ? LazyBrowserPage : BrowserPage, }, + { + pageName: PageNames.vectorSearch, + path: Pages.vectorSearch(':instanceId'), + component: LAZY_LOAD ? LazyVectorSearchPageRouter : VectorSearchPageRouter, + routes: VECTOR_SEARCH_ROUTES, + }, { pageName: PageNames.workbench, path: Pages.workbench(':instanceId'), diff --git a/redisinsight/ui/src/components/main-router/constants/sub-routes/vectorSearchRoutes.ts b/redisinsight/ui/src/components/main-router/constants/sub-routes/vectorSearchRoutes.ts new file mode 100644 index 0000000000..f9b29863ed --- /dev/null +++ b/redisinsight/ui/src/components/main-router/constants/sub-routes/vectorSearchRoutes.ts @@ -0,0 +1,27 @@ +import { lazy } from 'react' + +import { IRoute, PageNames, Pages } from 'uiSrc/constants' +import { VectorSearchCreateIndexPage, VectorSearchPage } from 'uiSrc/pages' +import { LAZY_LOAD } from '../../config' + +const LazyVectorSearchPage = lazy( + () => import('uiSrc/pages/vector-search/pages/VectorSearchPage'), +) +const LazyVectorSearchCreateIndexPage = lazy( + () => import('uiSrc/pages/vector-search/pages/VectorSearchCreateIndexPage'), +) + +export const VECTOR_SEARCH_ROUTES: IRoute[] = [ + { + pageName: PageNames.vectorSearchCreateIndex, + path: Pages.vectorSearchCreateIndex(':instanceId'), + component: LAZY_LOAD + ? LazyVectorSearchCreateIndexPage + : VectorSearchCreateIndexPage, + }, + { + pageName: PageNames.vectorSearch, + path: Pages.vectorSearch(':instanceId'), + component: LAZY_LOAD ? LazyVectorSearchPage : VectorSearchPage, + }, +] diff --git a/redisinsight/ui/src/components/navigation-menu/hooks/useNavigation.ts b/redisinsight/ui/src/components/navigation-menu/hooks/useNavigation.ts index a7d65a9936..62e4968bf7 100644 --- a/redisinsight/ui/src/components/navigation-menu/hooks/useNavigation.ts +++ b/redisinsight/ui/src/components/navigation-menu/hooks/useNavigation.ts @@ -66,6 +66,9 @@ export function useNavigation() { Pages.rdiPipelineManagement(connectedRdiInstanceId), ) + const isVectorSearchPath = () => + location.pathname.split('/')[2] === PageNames.vectorSearch + const getAdditionPropsForHighlighting = ( pageName: string, ): Omit => { @@ -92,6 +95,16 @@ export function useNavigation() { iconType: BrowserIcon, onboard: ONBOARDING_FEATURES.BROWSER_PAGE, }, + { + tooltipText: 'Search', + pageName: PageNames.vectorSearch, + ariaLabel: 'Search', + onClick: () => handleGoPage(Pages.vectorSearch(connectedInstanceId)), + dataTestId: 'vector-search-page-btn', + connectedInstanceId, + isActivePage: isVectorSearchPath(), + iconType: SlowLogIcon, + }, { tooltipText: 'Workbench', pageName: PageNames.workbench, diff --git a/redisinsight/ui/src/components/new-index/create-index-step/CreateIndexStepWrapper.spec.tsx b/redisinsight/ui/src/components/new-index/create-index-step/CreateIndexStepWrapper.spec.tsx new file mode 100644 index 0000000000..4da7ad0657 --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/CreateIndexStepWrapper.spec.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import { cleanup, fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { + CreateIndexStepWrapper, + CreateIndexStepWrapperProps, + IndexStepTab, + VectorIndexTab, +} from './CreateIndexStepWrapper' +import { BuildNewIndexTabTrigger } from './build-new-index-tab/BuildNewIndexTabTrigger' + +const VECTOR_INDEX_TABS: IndexStepTab[] = [ + { + value: VectorIndexTab.BuildNewIndex, + label: , + disabled: true, + }, + { + value: VectorIndexTab.UsePresetIndex, + label: 'Use preset index', + content: ( +
+ Use preset index content +
+ ), + }, +] + +const renderComponent = (props?: Partial) => + render() + +describe('CreateIndexStepWrapper', () => { + beforeEach(() => { + cleanup() + }) + + it('should render', () => { + const { container } = renderComponent() + + expect(container).toBeTruthy() + + // Check if the tabs are rendered + const buildNewIndexTabTrigger = screen.getByText('Build new index') + const usePresetIndexTabTrigger = screen.getByText('Use preset index') + + expect(buildNewIndexTabTrigger).toBeInTheDocument() + expect(usePresetIndexTabTrigger).toBeInTheDocument() + + // Check if the "Use preset index" tab content is selected by default + const usePresetIIndexTabContent = screen.queryByTestId( + 'vector-index-tabs--use-preset-index-content', + ) + expect(usePresetIIndexTabContent).toBeInTheDocument() + }) + + it('should switch to "Use preset index" tab when clicked', () => { + const props: CreateIndexStepWrapperProps = { + tabs: [ + { + value: VectorIndexTab.BuildNewIndex, + label: 'Build new index', + content: ( +
+ Build new index content +
+ ), + }, + { + value: VectorIndexTab.UsePresetIndex, + label: 'Use preset index', + content: ( +
+ Use preset index content +
+ ), + }, + ], + } + + renderComponent(props) + + // Verify the initial render to ensure "Build new index" is selected + const buildNewIndexTabContent = screen.queryByTestId( + 'vector-index-tabs--build-new-index-content', + ) + expect(buildNewIndexTabContent).toBeInTheDocument() + + // Click on the "Use preset index" tab + const buildNewIndexTabTrigger = screen.getByText('Use preset index') + fireEvent.click(buildNewIndexTabTrigger) + + // Check if the "Use preset index" tab is rendered + const usePresetIndexTabContent = screen.queryByTestId( + 'vector-index-tabs--use-preset-index-content', + ) + expect(usePresetIndexTabContent).toBeInTheDocument() + }) + + it("shouldn't switch to 'Build new index' tab when clicked, since it is disabled", () => { + renderComponent() + + const buildNewIndexTabTriggerLabel = screen.getByText('Build new index') + const buildNewIndexTabTriggerButton = + buildNewIndexTabTriggerLabel.closest('[type="button"]') + + expect(buildNewIndexTabTriggerButton).toBeDisabled() + + // And when clicked, it should not change the active tab + fireEvent.click(buildNewIndexTabTriggerLabel) + + // Check if the "Use preset index" tab is still active + const usePresetIndexTabContent = screen.queryByTestId( + 'vector-index-tabs--use-preset-index-content', + ) + expect(usePresetIndexTabContent).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/components/new-index/create-index-step/CreateIndexStepWrapper.styles.ts b/redisinsight/ui/src/components/new-index/create-index-step/CreateIndexStepWrapper.styles.ts new file mode 100644 index 0000000000..f650455ff3 --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/CreateIndexStepWrapper.styles.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components' + +export const StyledCreateIndexStepWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.core.space.space300}; +` diff --git a/redisinsight/ui/src/components/new-index/create-index-step/CreateIndexStepWrapper.tsx b/redisinsight/ui/src/components/new-index/create-index-step/CreateIndexStepWrapper.tsx new file mode 100644 index 0000000000..1b8fda6167 --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/CreateIndexStepWrapper.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react' +import { ButtonGroup, ButtonGroupProps } from '@redis-ui/components' +import { StyledCreateIndexStepWrapper } from './CreateIndexStepWrapper.styles' + +export enum VectorIndexTab { + BuildNewIndex = 'build-new-index', + UsePresetIndex = 'use-preset-index', +} + +export interface IndexStepTab { + value: VectorIndexTab + label: React.ReactNode + disabled?: boolean + content?: React.ReactNode +} +export interface CreateIndexStepWrapperProps extends ButtonGroupProps { + tabs: IndexStepTab[] +} + +export const CreateIndexStepWrapper = (props: CreateIndexStepWrapperProps) => { + const { tabs, ...rest } = props + + const [selectedTab, setSelectedTab] = useState( + tabs.filter((tab) => !tab.disabled)[0] ?? null, + ) + + const isTabSelected = (value: VectorIndexTab) => selectedTab?.value === value + + return ( + + + {tabs.map((tab) => ( + setSelectedTab(tab)} + key={`vector-index-tab-${tab.value}`} + > + {tab.label} + + ))} + + {selectedTab?.content} + + ) +} diff --git a/redisinsight/ui/src/components/new-index/create-index-step/build-new-index-tab/BuildNewIndexTabTrigger.styles.ts b/redisinsight/ui/src/components/new-index/create-index-step/build-new-index-tab/BuildNewIndexTabTrigger.styles.ts new file mode 100644 index 0000000000..5715bf56bf --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/build-new-index-tab/BuildNewIndexTabTrigger.styles.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components' + +export const StyledBuildNewIndexTabTrigger = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.core.space.space050}; +` diff --git a/redisinsight/ui/src/components/new-index/create-index-step/build-new-index-tab/BuildNewIndexTabTrigger.tsx b/redisinsight/ui/src/components/new-index/create-index-step/build-new-index-tab/BuildNewIndexTabTrigger.tsx new file mode 100644 index 0000000000..4c9eba37e8 --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/build-new-index-tab/BuildNewIndexTabTrigger.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { Badge } from '@redis-ui/components' +import { StyledBuildNewIndexTabTrigger } from './BuildNewIndexTabTrigger.styles' + +export const BuildNewIndexTabTrigger = () => ( + + Build new index + +) diff --git a/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldBox.spec.tsx b/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldBox.spec.tsx new file mode 100644 index 0000000000..20ae5897f6 --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldBox.spec.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { BoxSelectionGroup } from '@redis-ui/components' + +import { cleanup, fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { vectorSearchBoxFactory } from 'uiSrc/mocks/factories/redisearch/VectorSearchBox.factory' + +import { FieldBox, FieldBoxProps } from './FieldBox' +import { VectorSearchBox } from './types' + +const renderFieldBoxComponent = (props?: FieldBoxProps) => { + const defaultProps: FieldBoxProps = { + box: vectorSearchBoxFactory.build(), + } + + return render( + + + , + ) +} + +describe('CreateIndexStepWrapper', () => { + beforeEach(() => { + cleanup() + }) + + it('should render', () => { + const props: FieldBoxProps = { + box: vectorSearchBoxFactory.build(), + } + + const { container } = renderFieldBoxComponent(props) + + expect(container).toBeTruthy() + + // Check if the box is rendered with the correct visual elements + const label = screen.getByText(props.box.label!) + const description = screen.getByText(props.box.text!) + const tag = screen.getByText(props.box.tag.toUpperCase()!) + const checkbox = screen.getByRole('checkbox') + + expect(label).toBeInTheDocument() + expect(description).toBeInTheDocument() + expect(tag).toBeInTheDocument() + expect(checkbox).toBeInTheDocument() + }) + + it('should select the box when clicked', async () => { + const props: FieldBoxProps = { + box: vectorSearchBoxFactory.build({ + disabled: false, + }), + } + + renderFieldBoxComponent(props) + + // Verify that the checkbox is not checked initially + const checkbox = screen.getByRole('checkbox') + expect(checkbox).not.toBeChecked() + + // Click on the box to select it + const box = screen.getByTestId(`field-box-${props.box.value}`) + fireEvent.click(box) + + // Wait for the checkbox to be checked + expect(checkbox).toBeChecked() + }) + + it('should not select the box when clicked if disabled', () => { + const disabledBox: VectorSearchBox = vectorSearchBoxFactory.build({ + disabled: true, + }) + + renderFieldBoxComponent({ box: disabledBox }) + + const checkbox = screen.getByRole('checkbox') + expect(checkbox).not.toBeChecked() + + const box = screen.getByTestId(`field-box-${disabledBox.value}`) + fireEvent.click(box) + + expect(checkbox).not.toBeChecked() + }) +}) diff --git a/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldBox.styles.ts b/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldBox.styles.ts new file mode 100644 index 0000000000..5028b0511f --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldBox.styles.ts @@ -0,0 +1,25 @@ +import { BoxSelectionGroup } from '@redis-ui/components' +import styled from 'styled-components' + +export const StyledFieldBox = styled(BoxSelectionGroup.Item.Compose)` + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + padding: ${({ theme }) => theme.core.space.space100}; + gap: ${({ theme }) => theme.components.boxSelectionGroup.defaultItem.gap}; +` + +export const BoxHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +` + +export const BoxHeaderActions = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.components.boxSelectionGroup.defaultItem.gap}; +` + +export const BoxContent = styled.div`` diff --git a/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldBox.tsx b/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldBox.tsx new file mode 100644 index 0000000000..08c016229e --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldBox.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { + BoxSelectionGroup, + BoxSelectionGroupItemComposeProps, + Checkbox, +} from '@redis-ui/components' + +import { EditIcon } from 'uiSrc/components/base/icons' +import { IconButton } from 'uiSrc/components/base/forms/buttons/IconButton' +import { Text } from 'uiSrc/components/base/text' + +import { + BoxContent, + BoxHeader, + BoxHeaderActions, + StyledFieldBox, +} from './FieldBox.styles' +import { FieldTag } from './FieldTag' +import { VectorSearchBox } from './types' + +export interface FieldBoxProps extends BoxSelectionGroupItemComposeProps { + box: VectorSearchBox +} + +export const FieldBox = ({ box, ...rest }: FieldBoxProps) => { + const { label, text, tag, disabled } = box + + return ( + + + + {(props) => } + + + + + + + + + + {label} + + + {text && ( + + {text} + + )} + + + ) +} diff --git a/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldTag.tsx b/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldTag.tsx new file mode 100644 index 0000000000..b35f48ad8c --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/field-box/FieldTag.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { Badge } from '@redis-ui/components' +import { + FIELD_TYPE_OPTIONS, + FieldTypes, +} from 'uiSrc/pages/browser/components/create-redisearch-index/constants' + +// TODO: Add colors mapping for tags when @redis-ui/components v38.6.0 is released +export const FieldTag = ({ tag }: { tag: FieldTypes }) => { + const tagLabel = FIELD_TYPE_OPTIONS.find( + (option) => option.value === tag, + )?.text + + return tagLabel ? : null +} diff --git a/redisinsight/ui/src/components/new-index/create-index-step/field-box/types.ts b/redisinsight/ui/src/components/new-index/create-index-step/field-box/types.ts new file mode 100644 index 0000000000..db9a8111ad --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/field-box/types.ts @@ -0,0 +1,7 @@ +import { BoxSelectionGroupBox } from '@redis-ui/components' +import { FieldTypes } from 'uiSrc/pages/browser/components/create-redisearch-index/constants' + +export interface VectorSearchBox extends BoxSelectionGroupBox { + text: string + tag: FieldTypes +} diff --git a/redisinsight/ui/src/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup.spec.tsx b/redisinsight/ui/src/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup.spec.tsx new file mode 100644 index 0000000000..ef98f19fe1 --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup.spec.tsx @@ -0,0 +1,69 @@ +import React from 'react' + +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { vectorSearchBoxFactory } from 'uiSrc/mocks/factories/redisearch/VectorSearchBox.factory' +import { FieldBoxesGroup, FieldBoxesGroupProps } from './FieldBoxesGroup' + +const renderFieldBoxesGroupComponent = ( + props?: Partial, +) => { + const defaultProps: FieldBoxesGroupProps = { + boxes: vectorSearchBoxFactory.buildList(3, { disabled: false }), + value: [], + onChange: jest.fn(), + } + + return render() +} + +describe('FieldBoxesGroup', () => { + it('should render', () => { + const { container } = renderFieldBoxesGroupComponent() + + expect(container).toBeTruthy() + + const fieldBoxesGroup = screen.getByTestId('field-boxes-group') + expect(fieldBoxesGroup).toBeInTheDocument() + + const fieldBoxes = screen.getAllByTestId(/field-box-/) + expect(fieldBoxes).toHaveLength(3) + }) + + it('should call onChange when clicking on a box to select it', () => { + const mockVectorSearchBox = vectorSearchBoxFactory.build({ + disabled: false, + }) + + const props: FieldBoxesGroupProps = { + boxes: [mockVectorSearchBox], + value: [], + onChange: jest.fn(), + } + + renderFieldBoxesGroupComponent(props) + + const box = screen.getByTestId(`field-box-${mockVectorSearchBox.value}`) + + fireEvent.click(box) + expect(props.onChange).toHaveBeenCalledWith([mockVectorSearchBox.value]) + }) + + it('should call onChange when clicking on a box to deselect it', () => { + const mockVectorSearchBox = vectorSearchBoxFactory.build({ + disabled: false, + }) + + const props: FieldBoxesGroupProps = { + boxes: [mockVectorSearchBox], + value: [mockVectorSearchBox.value], + onChange: jest.fn(), + } + + renderFieldBoxesGroupComponent(props) + + const box = screen.getByTestId(`field-box-${mockVectorSearchBox.value}`) + + fireEvent.click(box) + expect(props.onChange).toHaveBeenCalledWith([]) + }) +}) diff --git a/redisinsight/ui/src/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup.styles.ts b/redisinsight/ui/src/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup.styles.ts new file mode 100644 index 0000000000..b535d1369c --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup.styles.ts @@ -0,0 +1,10 @@ +import styled from 'styled-components' +import { MultiBoxSelectionGroup } from '@redis-ui/components' + +export const StyledFieldBoxesGroup = styled(MultiBoxSelectionGroup.Compose)` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: ${({ theme }) => theme.core.space.space150}; + align-items: flex-start; + align-self: stretch; +` diff --git a/redisinsight/ui/src/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup.tsx b/redisinsight/ui/src/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup.tsx new file mode 100644 index 0000000000..75305c70cb --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { MultiBoxSelectionGroupProps } from '@redis-ui/components' +import { StyledFieldBoxesGroup } from './FieldBoxesGroup.styles' +import { VectorSearchBox } from '../field-box/types' +import { FieldBox } from '../field-box/FieldBox' + +export interface FieldBoxesGroupProps extends MultiBoxSelectionGroupProps { + boxes: VectorSearchBox[] + value: string[] + onChange: (value: string[] | undefined) => void +} + +export const FieldBoxesGroup = ({ + boxes, + value, + onChange, + ...rest +}: FieldBoxesGroupProps) => ( + + {boxes.map((box) => ( + + ))} + +) diff --git a/redisinsight/ui/src/components/new-index/create-index-step/index.ts b/redisinsight/ui/src/components/new-index/create-index-step/index.ts new file mode 100644 index 0000000000..34afe1c4da --- /dev/null +++ b/redisinsight/ui/src/components/new-index/create-index-step/index.ts @@ -0,0 +1,4 @@ +import { CreateIndexStepWrapper } from './CreateIndexStepWrapper' + +export type { IndexStepTab } from './CreateIndexStepWrapper' +export default CreateIndexStepWrapper diff --git a/redisinsight/ui/src/components/new-index/selection-box/SelectionBox.spec.tsx b/redisinsight/ui/src/components/new-index/selection-box/SelectionBox.spec.tsx new file mode 100644 index 0000000000..d9ae9a91ae --- /dev/null +++ b/redisinsight/ui/src/components/new-index/selection-box/SelectionBox.spec.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { BoxSelectionGroup } from '@redis-ui/components' + +import { cleanup, render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import SelectionBox from './SelectionBox' + +const mockBox = { + value: 'rqe', + label: 'Test Label', + text: 'Test Description', +} + +const renderWithBoxSelectionGroup = (ui: React.ReactElement) => + render({ui}) + +describe('SelectionBox', () => { + beforeEach(() => { + cleanup() + }) + + it('should render label and text', () => { + renderWithBoxSelectionGroup() + + expect(screen.getByText('Test Label')).toBeInTheDocument() + expect(screen.getByText('Test Description')).toBeInTheDocument() + }) + + it('should render label without text when text is not provided', () => { + renderWithBoxSelectionGroup( + , + ) + + expect(screen.getByText(mockBox.label)).toBeInTheDocument() + expect(screen.queryByText(mockBox.text)).not.toBeInTheDocument() + }) + + it('should show disabled bar when disabled is true', () => { + renderWithBoxSelectionGroup( + , + ) + + expect(screen.getByText(/coming soon/i)).toBeInTheDocument() + }) + + it('should not show disabled bar when disabled is false', () => { + renderWithBoxSelectionGroup( + , + ) + + expect(screen.queryByText(/coming soon/i)).not.toBeInTheDocument() + }) + + it('should call onClick handler when clicked', () => { + const onClick = jest.fn() + + renderWithBoxSelectionGroup( + , + ) + + fireEvent.click(screen.getByText(mockBox.label)) + + expect(onClick).toHaveBeenCalled() + }) +}) diff --git a/redisinsight/ui/src/components/new-index/selection-box/SelectionBox.styles.tsx b/redisinsight/ui/src/components/new-index/selection-box/SelectionBox.styles.tsx new file mode 100644 index 0000000000..f3fb32f8cb --- /dev/null +++ b/redisinsight/ui/src/components/new-index/selection-box/SelectionBox.styles.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import styled from 'styled-components' +import { Text, Title } from 'uiSrc/components/base/text' + +export const StyledBoxContent = styled.div` + padding: ${({ theme }) => theme.core.space.space200}; + text-align: left; +` + +export const StyledTitle = styled(Title)` + margin-top: ${({ theme }) => theme.core.space.space050}; +` + +export const StyledText = styled(Text)` + margin-top: ${({ theme }) => theme.core.space.space050}; + white-space: normal; + overflow-wrap: break-word; +` + +export const StyledDisabledBar = styled.div` + padding: ${({ theme }) => theme.core.space.space025} 0; + background: ${({ theme }) => theme.color.dusk100}; + color: ${({ theme }) => theme.color.dusk400}; + /* Theme adjustments TODO: add radii scale */ + border-radius: ${({ theme }) => theme.core.space.space025}; + /* Theme adjustments TODO: border width scale */ + border-bottom: 1px solid ${({ theme }) => theme.color.gray500}; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +` + +export const DisabledBar = () => ( + + Coming soon + +) diff --git a/redisinsight/ui/src/components/new-index/selection-box/SelectionBox.tsx b/redisinsight/ui/src/components/new-index/selection-box/SelectionBox.tsx new file mode 100644 index 0000000000..7b5b1f9a3a --- /dev/null +++ b/redisinsight/ui/src/components/new-index/selection-box/SelectionBox.tsx @@ -0,0 +1,39 @@ +import React, { HTMLAttributes } from 'react' +import { BoxSelectionGroup, BoxSelectionGroupBox } from '@redis-ui/components' +import { + DisabledBar, + StyledBoxContent, + StyledText, + StyledTitle, +} from './SelectionBox.styles' + +export interface BoxSelectionOption + extends BoxSelectionGroupBox { + text?: string +} + +type SelectionBoxProps = { + box: BoxSelectionOption +} & HTMLAttributes + +const SelectionBox = ({ + box, + ...rest +}: SelectionBoxProps) => { + const { label, text, disabled } = box + + return ( + + {disabled && } + + + + + {label} + {text && {text}} + + + ) +} + +export default SelectionBox diff --git a/redisinsight/ui/src/components/new-index/selection-box/index.tsx b/redisinsight/ui/src/components/new-index/selection-box/index.tsx new file mode 100644 index 0000000000..d59f53fad0 --- /dev/null +++ b/redisinsight/ui/src/components/new-index/selection-box/index.tsx @@ -0,0 +1 @@ +export { default as SelectionBox } from './SelectionBox' diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index f819dd3575..0b86481c91 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -196,6 +196,14 @@ export default { title: 'Index has been created', message: 'Open the list of indexes to see it.', }), + DELETE_INDEX: (indexName: string) => ({ + title: 'Index has been deleted', + message: ( + <> + {formatNameShort(indexName)} has been deleted from Redis Insight. + + ), + }), TEST_CONNECTION: () => ({ title: 'Connection is successful', }), diff --git a/redisinsight/ui/src/components/query/context/view-mode-context.spec.tsx b/redisinsight/ui/src/components/query/context/view-mode-context.spec.tsx new file mode 100644 index 0000000000..29f067986f --- /dev/null +++ b/redisinsight/ui/src/components/query/context/view-mode-context.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' + +import { + useViewModeContext, + ViewMode, + ViewModeContextProvider, +} from './view-mode.context' + +// Test component to consume the context +const TestComponent: React.FC = () => { + const { viewMode } = useViewModeContext() + + return ( +
+

Current View Mode: {viewMode}

+
+ ) +} + +describe('ViewModeContext', () => { + it('provides the default view mode', () => { + render( + + + , + ) + + expect(screen.getByTestId('view-mode')).toHaveTextContent( + `Current View Mode: ${ViewMode.Workbench}`, + ) + }) + + it('uses the initial view mode if provided', () => { + render( + + + , + ) + + expect(screen.getByTestId('view-mode')).toHaveTextContent( + `Current View Mode: ${ViewMode.VectorSearch}`, + ) + }) +}) diff --git a/redisinsight/ui/src/components/query/context/view-mode.context.tsx b/redisinsight/ui/src/components/query/context/view-mode.context.tsx new file mode 100644 index 0000000000..7d5278f0f1 --- /dev/null +++ b/redisinsight/ui/src/components/query/context/view-mode.context.tsx @@ -0,0 +1,33 @@ +import React, { createContext, ReactNode, useContext } from 'react' + +export enum ViewMode { + Workbench = 'workbench', + VectorSearch = 'vector-search', +} + +interface ViewModeContextType { + viewMode: ViewMode +} + +const ViewModeContext = createContext({ + viewMode: ViewMode.Workbench, +}) + +// Props for the provider +interface ViewModeContextProviderProps { + children: ReactNode + viewMode?: ViewMode +} + +export const ViewModeContextProvider: React.FC< + ViewModeContextProviderProps +> = ({ children, viewMode = ViewMode.Workbench }) => { + return ( + + {children} + + ) +} + +export const useViewModeContext = (): ViewModeContextType => + useContext(ViewModeContext) diff --git a/redisinsight/ui/src/components/query/index.ts b/redisinsight/ui/src/components/query/index.ts index 7f6af0effd..181a007301 100644 --- a/redisinsight/ui/src/components/query/index.ts +++ b/redisinsight/ui/src/components/query/index.ts @@ -1,5 +1,6 @@ import QueryCard from './query-card' import QueryActions from './query-actions' +import QueryLiteActions from './query-lite-actions' import QueryTutorials from './query-tutorials' -export { QueryCard, QueryActions, QueryTutorials } +export { QueryCard, QueryActions, QueryLiteActions, QueryTutorials } diff --git a/redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx index 36010f470a..729231a562 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx @@ -12,6 +12,7 @@ import { } from 'uiSrc/utils/test-utils' import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' import QueryCard, { Props, getSummaryText } from './QueryCard' +import { ViewMode, ViewModeContextProvider } from '../context/view-mode.context' const mockedProps = mock() @@ -44,15 +45,27 @@ jest.mock('uiSrc/slices/app/plugins', () => ({ }), })) +const renderQueryCardComponent = (props: Partial = {}) => { + return render( + + + , + { + store, + }, + ) +} + describe('QueryCard', () => { it('should render', () => { - expect(render()).toBeTruthy() + const { container } = renderQueryCardComponent() + expect(container).toBeTruthy() }) it('Cli result should not in the document before Expand', () => { const cliResultTestId = 'query-cli-result' - const { queryByTestId } = render() + const { queryByTestId } = renderQueryCardComponent() const cliResultEl = queryByTestId(cliResultTestId) expect(cliResultEl).not.toBeInTheDocument() @@ -61,9 +74,10 @@ describe('QueryCard', () => { it('Cli result should in the document when "isOpen = true"', () => { const cliResultTestId = 'query-cli-result' - const { queryByTestId } = render( - , - ) + const { queryByTestId } = renderQueryCardComponent({ + isOpen: true, + result: mockResult, + }) const cliResultEl = queryByTestId(cliResultTestId) @@ -73,13 +87,10 @@ describe('QueryCard', () => { it('Cli result should not in the document when "isOpen = false"', () => { const cliResultTestId = 'query-cli-result' - const { queryByTestId } = render( - , - ) + const { queryByTestId } = renderQueryCardComponent({ + isOpen: false, + result: mockResult, + }) const cliResultEl = queryByTestId(cliResultTestId) @@ -89,14 +100,11 @@ describe('QueryCard', () => { it('Should be in the document when resultsMode === ResultsMode.GroupMode', () => { const cliResultTestId = 'query-cli-result' - const { queryByTestId } = render( - , - ) + const { queryByTestId } = renderQueryCardComponent({ + isOpen: false, + result: mockResult, + resultsMode: ResultsMode.GroupMode, + }) const cliResultEl = queryByTestId(cliResultTestId) @@ -107,9 +115,10 @@ describe('QueryCard', () => { const cardHeaderTestId = 'query-card-open' const mockId = '123' - const { queryByTestId } = render( - , - ) + const { queryByTestId } = renderQueryCardComponent({ + id: mockId, + result: mockResult, + }) const cardHeaderTestEl = queryByTestId(cardHeaderTestId) @@ -131,15 +140,13 @@ describe('QueryCard', () => { }) it('should render QueryCardCliResultWrapper when command is null', () => { - const { queryByTestId } = render( - , - ) + const { queryByTestId } = renderQueryCardComponent({ + resultsMode: ResultsMode.GroupMode, + result: null, + isOpen: true, + command: null, + }) + const queryCommonResultEl = queryByTestId('query-common-result-wrapper') const queryCliResultEl = queryByTestId('query-cli-result-wrapper') @@ -148,62 +155,53 @@ describe('QueryCard', () => { }) it('should render QueryCardCliResult when result reached response size threshold', () => { - const { queryByTestId } = render( - , - ) + const { queryByTestId } = renderQueryCardComponent({ + resultsMode: ResultsMode.GroupMode, + result: [ + { + status: CommandExecutionStatus.Success, + response: 'Any message about size limit threshold exceeded', + sizeLimitExceeded: true, + }, + ], + isOpen: true, + command: null, + }) const queryCliResultEl = queryByTestId('query-cli-result') expect(queryCliResultEl).toBeInTheDocument() }) it('should render properly result when it has pure number', () => { - const { getByTestId } = render( - , - ) + const { getByTestId } = renderQueryCardComponent({ + resultsMode: ResultsMode.GroupMode, + result: [ + { + status: CommandExecutionStatus.Success, + response: 1, + }, + ], + isOpen: true, + command: 'del key', + }) const queryCliResultEl = getByTestId('query-cli-result') expect(queryCliResultEl.textContent).toBe('(integer) 1') }) it('should render QueryCardCliResult when result reached response size threshold even w/o flag', () => { - const { queryByTestId } = render( - , - ) + const { queryByTestId } = renderQueryCardComponent({ + resultsMode: ResultsMode.GroupMode, + result: [ + { + status: CommandExecutionStatus.Success, + response: + 'Results have been deleted since they exceed 1 MB. Re-run the command to see new results.', + }, + ], + isOpen: true, + command: null, + }) const queryCliResultEl = queryByTestId('query-cli-result') expect(queryCliResultEl).toBeInTheDocument() diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx index 59844a90ed..cd15069ae6 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx @@ -12,7 +12,17 @@ import { } from 'uiSrc/utils/test-utils' import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' -import QueryCardHeader, { Props } from './QueryCardHeader' +import QueryCardHeader, { HIDE_FIELDS, Props } from './QueryCardHeader' +import { + ViewMode, + ViewModeContextProvider, +} from '../../context/view-mode.context' + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) const mockedProps = mock() @@ -34,7 +44,16 @@ jest.mock('uiSrc/services', () => ({ jest.mock('uiSrc/slices/app/plugins', () => ({ ...jest.requireActual('uiSrc/slices/app/plugins'), appPluginsSelector: jest.fn().mockReturnValue({ - visualizations: [], + visualizations: [ + { + id: '1', + uniqId: '1', + name: 'test', + plugin: '', + activationMethod: 'render', + matchCommands: ['FT.SEARCH'], + }, + ], }), })) @@ -43,7 +62,25 @@ jest.mock('uiSrc/telemetry', () => ({ sendEventTelemetry: jest.fn(), })) +const renderQueryCardHeaderComponent = ( + props: Props, + viewMode: ViewMode = ViewMode.Workbench, +) => { + return render( + + + , + { + store, + }, + ) +} + describe('QueryCardHeader', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + it('should render', () => { // connectedInstanceSelector.mockImplementation(() => ({ // id: '123', @@ -54,15 +91,16 @@ describe('QueryCardHeader', () => { // sendCliClusterCommandAction.mockImplementation(() => sendCliClusterActionMock); - expect(render()).toBeTruthy() + expect( + renderQueryCardHeaderComponent({ ...instance(mockedProps) }), + ).toBeTruthy() }) + it('should render tooltip in milliseconds', async () => { - render( - , - ) + renderQueryCardHeaderComponent({ + ...instance(mockedProps), + executionTime: 12345678910, + }) await act(async () => { fireEvent.focus(screen.getByTestId('command-execution-time-icon')) @@ -75,30 +113,261 @@ describe('QueryCardHeader', () => { }) it('should render disabled copy button', async () => { - render() + renderQueryCardHeaderComponent({ + ...instance(mockedProps), + emptyCommand: true, + }) expect(screen.getByTestId('copy-command')).toBeDisabled() }) - it('event telemetry WORKBENCH_COMMAND_COPIED should be call after click on copy btn', async () => { - const command = 'info' - const sendEventTelemetryMock = jest.fn() - ;(sendEventTelemetry as jest.Mock).mockImplementation( - () => sendEventTelemetryMock, - ) - render() + it('should hide Profiler button', async () => { + renderQueryCardHeaderComponent({ + ...instance(mockedProps), + query: 'FT.GET something', + isOpen: true, + hideFields: [HIDE_FIELDS.profiler], + }) - await act(async () => { - fireEvent.click(screen.getByTestId('copy-command')) + expect(screen.queryByTestId('run-profile-type')).not.toBeInTheDocument() + }) + + it('should hide Change View Type button', async () => { + renderQueryCardHeaderComponent({ + ...instance(mockedProps), + query: 'FT.SEARCH index somethingCool', + isOpen: true, + hideFields: [HIDE_FIELDS.viewType], }) - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.WORKBENCH_COMMAND_COPIED, - eventData: { - command, - databaseId: INSTANCE_ID_MOCK, - }, + expect(screen.queryByTestId('select-view-type')).not.toBeInTheDocument() + }) + + describe('Workbech View Mode', () => { + it('should call event telemetry for workbench after click on copy btn', async () => { + const command = 'info' + + renderQueryCardHeaderComponent({ + ...instance(mockedProps), + query: command, + }) + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-command')) + }) + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.WORKBENCH_COMMAND_COPIED, + eventData: { + command, + databaseId: INSTANCE_ID_MOCK, + }, + }) + }) + + it('should collect telemetry when clicking on the "collapse" button', async () => { + const command = 'info' + const mockToggleOpen = jest.fn() + + renderQueryCardHeaderComponent({ + ...instance(mockedProps), + query: command, + isOpen: true, + toggleOpen: mockToggleOpen, + }) + + // Simulate clicking the collapse button + const collapseButton = screen.getByTestId('query-card-open') + expect(collapseButton).toBeInTheDocument() + + fireEvent.click(collapseButton) + expect(mockToggleOpen).toHaveBeenCalled() + + // Verify telemetry event is sent for collapsing + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.WORKBENCH_RESULTS_COLLAPSED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + command, + }, + }) + }) + + it('should collect telemetry when clicking on the "un-collapse" button', async () => { + const command = 'info' + const mockToggleOpen = jest.fn() + + renderQueryCardHeaderComponent({ + ...instance(mockedProps), + query: command, + isOpen: false, + toggleOpen: mockToggleOpen, + }) + + // Simulate clicking the collapse button + const collapseButton = screen.getByTestId('query-card-open') + expect(collapseButton).toBeInTheDocument() + + fireEvent.click(collapseButton) + expect(mockToggleOpen).toHaveBeenCalled() + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.WORKBENCH_RESULTS_EXPANDED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + command, + }, + }) + }) + + it('should collect telemetry when clicking on the "delete" button', async () => { + const command = 'info' + const mockOnQueryDelete = jest.fn() + + renderQueryCardHeaderComponent({ + ...instance(mockedProps), + query: command, + isOpen: true, + onQueryDelete: mockOnQueryDelete, + }) + + // Simulate clicking the delete button + const deleteButton = screen.getByTestId('delete-command') + expect(deleteButton).toBeInTheDocument() + + fireEvent.click(deleteButton) + expect(mockOnQueryDelete).toHaveBeenCalled() + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.WORKBENCH_CLEAR_RESULT_CLICKED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + command, + }, + }) + }) + }) + + describe('Vector Search View Mode', () => { + it('should call event telemetry for vector search after click on copy btn', async () => { + const command = 'MOCK_COMMAND' + + renderQueryCardHeaderComponent( + { + ...instance(mockedProps), + query: command, + }, + ViewMode.VectorSearch, + ) + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-command')) + }) + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_COMMAND_COPIED, + eventData: { + command, + databaseId: INSTANCE_ID_MOCK, + }, + }) + }) + + it('should collect telemetry when clicking on the "collapse" button', async () => { + const command = 'info' + const mockToggleOpen = jest.fn() + + renderQueryCardHeaderComponent( + { + ...instance(mockedProps), + query: command, + isOpen: true, + toggleOpen: mockToggleOpen, + }, + ViewMode.VectorSearch, + ) + + // Simulate clicking the collapse button + const collapseButton = screen.getByTestId('query-card-open') + expect(collapseButton).toBeInTheDocument() + + fireEvent.click(collapseButton) + expect(mockToggleOpen).toHaveBeenCalled() + + // Verify telemetry event is sent for collapsing + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_RESULTS_COLLAPSED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + command, + }, + }) + }) + + it('should collect telemetry when clicking on the "un-collapse" button', async () => { + const command = 'info' + const mockToggleOpen = jest.fn() + + renderQueryCardHeaderComponent( + { + ...instance(mockedProps), + query: command, + isOpen: false, + toggleOpen: mockToggleOpen, + }, + ViewMode.VectorSearch, + ) + + // Simulate clicking the collapse button + const collapseButton = screen.getByTestId('query-card-open') + expect(collapseButton).toBeInTheDocument() + + fireEvent.click(collapseButton) + expect(mockToggleOpen).toHaveBeenCalled() + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_RESULTS_EXPANDED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + command, + }, + }) + }) + + it('should collect telemetry when clicking on the "delete" button', async () => { + const command = 'info' + const mockOnQueryDelete = jest.fn() + + renderQueryCardHeaderComponent( + { + ...instance(mockedProps), + query: command, + isOpen: true, + onQueryDelete: mockOnQueryDelete, + }, + ViewMode.VectorSearch, + ) + + // Simulate clicking the delete button + const deleteButton = screen.getByTestId('delete-command') + expect(deleteButton).toBeInTheDocument() + + fireEvent.click(deleteButton) + expect(mockOnQueryDelete).toHaveBeenCalled() + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_CLEAR_RESULT_CLICKED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + command, + }, + }) }) - ;(sendEventTelemetry as jest.Mock).mockRestore() }) }) diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx index 94ca58316f..9931a7c80e 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx @@ -53,6 +53,7 @@ import { RiSelect } from 'uiSrc/components/base/forms/select/RiSelect' import QueryCardTooltip from '../QueryCardTooltip' import styles from './styles.module.scss' +import { useViewModeContext, ViewMode } from '../../context/view-mode.context' export interface Props { query: string @@ -72,6 +73,7 @@ export interface Props { executionTime?: number emptyCommand?: boolean db?: number + hideFields?: string[] toggleOpen: () => void toggleFullScreen: () => void setSelectedValue: (type: WBQueryType, value: string) => void @@ -80,6 +82,11 @@ export interface Props { onQueryProfile: (type: ProfileQueryType) => void } +export const HIDE_FIELDS = { + viewType: 'viewType', + profiler: 'profiler', +} + const getExecutionTimeString = (value: number): string => { if (value < 1) { return '0.001 msec' @@ -137,6 +144,7 @@ const QueryCardHeader = (props: Props) => { onQueryReRun, onQueryProfile, db, + hideFields = [], } = props const { visualizations = [] } = useSelector(appPluginsSelector) @@ -144,6 +152,7 @@ const QueryCardHeader = (props: Props) => { const { instanceId = '' } = useParams<{ instanceId: string }>() const { theme } = useContext(ThemeContext) + const { viewMode } = useViewModeContext() const eventStop = (event: React.MouseEvent) => { event.preventDefault() @@ -166,7 +175,12 @@ const QueryCardHeader = (props: Props) => { } const handleCopy = (event: React.MouseEvent, query: string) => { - sendEvent(TelemetryEvent.WORKBENCH_COMMAND_COPIED, query) + const telemetryEvent = + viewMode === ViewMode.Workbench + ? TelemetryEvent.WORKBENCH_COMMAND_COPIED + : TelemetryEvent.SEARCH_COMMAND_COPIED + + sendEvent(telemetryEvent, query) eventStop(event) navigator.clipboard?.writeText?.(query) } @@ -194,7 +208,13 @@ const QueryCardHeader = (props: Props) => { const handleQueryDelete = (event: React.MouseEvent) => { eventStop(event) onQueryDelete() - sendEvent(TelemetryEvent.WORKBENCH_CLEAR_RESULT_CLICKED, query) + + const telemetryEvent = + viewMode === ViewMode.Workbench + ? TelemetryEvent.WORKBENCH_CLEAR_RESULT_CLICKED + : TelemetryEvent.SEARCH_CLEAR_RESULT_CLICKED + + sendEvent(telemetryEvent, query) } const handleQueryReRun = (event: React.MouseEvent) => { @@ -207,12 +227,16 @@ const QueryCardHeader = (props: Props) => { !isFullScreen && !isSilentModeWithoutError(resultsMode, summary?.fail) ) { - sendEvent( - isOpen - ? TelemetryEvent.WORKBENCH_RESULTS_COLLAPSED - : TelemetryEvent.WORKBENCH_RESULTS_EXPANDED, - query, - ) + const telemetryEvent = + viewMode === ViewMode.Workbench + ? isOpen + ? TelemetryEvent.WORKBENCH_RESULTS_COLLAPSED + : TelemetryEvent.WORKBENCH_RESULTS_EXPANDED + : isOpen + ? TelemetryEvent.SEARCH_RESULTS_COLLAPSED + : TelemetryEvent.SEARCH_RESULTS_EXPANDED + + sendEvent(telemetryEvent, query) } toggleOpen() } @@ -410,54 +434,58 @@ const QueryCardHeader = (props: Props) => { )} - - {isOpen && canCommandProfile && !summaryText && ( -
-
- - onQueryProfile(value as ProfileQueryType) - } - options={profileOptions} - data-testid="run-profile-type" - valueRender={({ option, isOptionValue }) => { - if (isOptionValue) { - return option.dropdownDisplay as JSX.Element + {!hideFields?.includes(HIDE_FIELDS.profiler) && ( + + {isOpen && canCommandProfile && !summaryText && ( +
+
+ + onQueryProfile(value as ProfileQueryType) } - return option.inputDisplay as JSX.Element - }} - /> + options={profileOptions} + data-testid="run-profile-type" + valueRender={({ option, isOptionValue }) => { + if (isOptionValue) { + return option.dropdownDisplay as JSX.Element + } + return option.inputDisplay as JSX.Element + }} + /> +
-
- )} - - - {isOpen && options.length > 1 && !summaryText && ( -
-
- { - if (isOptionValue) { - return option.dropdownDisplay as JSX.Element - } - return option.inputDisplay as JSX.Element - }} - value={selectedValue} - onChange={(value: string) => onChangeView(value)} - data-testid="select-view-type" - /> + )} + + )} + {!hideFields?.includes(HIDE_FIELDS.viewType) && ( + + {isOpen && options.length > 1 && !summaryText && ( +
+
+ { + if (isOptionValue) { + return option.dropdownDisplay as JSX.Element + } + return option.inputDisplay as JSX.Element + }} + value={selectedValue} + onChange={(value: string) => onChangeView(value)} + data-testid="select-view-type" + /> +
-
- )} - + )} + + )} { + const onSubmit = jest.fn() + const onClear = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render both buttons', () => { + render() + + expect(screen.getByTestId('btn-submit')).toBeInTheDocument() + expect(screen.getByTestId('btn-clear')).toBeInTheDocument() + }) + + it('should call onSubmit when Run button is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('btn-submit')) + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it('should call onClear when Clear button is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('btn-clear')) + expect(onClear).toHaveBeenCalledTimes(1) + }) + + it('should disable buttons and show loading tooltip when isLoading is true', () => { + render() + + const submitBtn = screen.getByTestId('btn-submit') as HTMLButtonElement + const clearBtn = screen.getByTestId('btn-clear') as HTMLButtonElement + + expect(submitBtn).toBeDisabled() + expect(clearBtn).toBeDisabled() + }) +}) diff --git a/redisinsight/ui/src/components/query/query-lite-actions/QueryLiteActions.tsx b/redisinsight/ui/src/components/query/query-lite-actions/QueryLiteActions.tsx new file mode 100644 index 0000000000..959c1fd914 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-lite-actions/QueryLiteActions.tsx @@ -0,0 +1,77 @@ +import React from 'react' + +import { KEYBOARD_SHORTCUTS } from 'uiSrc/constants' +import { KeyboardShortcut, RiTooltip } from 'uiSrc/components' + +import { PlayFilledIcon } from 'uiSrc/components/base/icons' + +import { Spacer } from 'uiSrc/components/base/layout/spacer' +import { Button, EmptyButton } from 'uiSrc/components/base/forms/buttons' +import { Text } from 'uiSrc/components/base/text' + +export interface Props { + onSubmit: () => void + onClear: () => void + isLoading?: boolean +} + +const QueryLiteActions = (props: Props) => { + const { isLoading, onSubmit, onClear } = props + const KeyBoardTooltipContent = KEYBOARD_SHORTCUTS?.workbench?.runQuery && ( + <> + {KEYBOARD_SHORTCUTS.workbench.runQuery?.label}: + + + + ) + + return ( + <> + + + Clear + + + + + + + + ) +} + +export default QueryLiteActions diff --git a/redisinsight/ui/src/components/query/query-lite-actions/index.ts b/redisinsight/ui/src/components/query/query-lite-actions/index.ts new file mode 100644 index 0000000000..f71f62ec52 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-lite-actions/index.ts @@ -0,0 +1,3 @@ +import QueryLiteActions from './QueryLiteActions' + +export default QueryLiteActions diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index bfe563e35b..e762b6c81a 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -13,6 +13,7 @@ enum ApiEndpoints { BULK_ACTIONS_IMPORT = 'bulk-actions/import', BULK_ACTIONS_IMPORT_DEFAULT_DATA = 'bulk-actions/import/default-data', BULK_ACTIONS_IMPORT_TUTORIAL_DATA = 'bulk-actions/import/tutorial-data', + BULK_ACTIONS_IMPORT_VECTOR_COLLECTION = 'bulk-actions/import/vector-collection', CA_CERTIFICATES = 'certificates/ca', CLIENT_CERTIFICATES = 'certificates/client', diff --git a/redisinsight/ui/src/constants/pages.ts b/redisinsight/ui/src/constants/pages.ts index f99d024c81..5fbaa39cc0 100644 --- a/redisinsight/ui/src/constants/pages.ts +++ b/redisinsight/ui/src/constants/pages.ts @@ -15,6 +15,8 @@ export interface IRoute { export enum PageNames { workbench = 'workbench', + vectorSearch = 'vector-search', + vectorSearchCreateIndex = 'create-index', browser = 'browser', search = 'search', slowLog = 'slowlog', @@ -49,6 +51,10 @@ export const Pages = { sentinelDatabases: `${sentinel}/databases`, sentinelDatabasesResult: `${sentinel}/databases-result`, browser: (instanceId: string) => `/${instanceId}/${PageNames.browser}`, + vectorSearch: (instanceId: string) => + `/${instanceId}/${PageNames.vectorSearch}`, + vectorSearchCreateIndex: (instanceId: string) => + `/${instanceId}/${PageNames.vectorSearch}/${PageNames.vectorSearchCreateIndex}`, workbench: (instanceId: string) => `/${instanceId}/${PageNames.workbench}`, search: (instanceId: string) => `/${instanceId}/${PageNames.search}`, pubSub: (instanceId: string) => `/${instanceId}/${PageNames.pubSub}`, diff --git a/redisinsight/ui/src/mocks/factories/browser/bulkActions/bulkActionOverview.factory.ts b/redisinsight/ui/src/mocks/factories/browser/bulkActions/bulkActionOverview.factory.ts new file mode 100644 index 0000000000..7f45e0fc72 --- /dev/null +++ b/redisinsight/ui/src/mocks/factories/browser/bulkActions/bulkActionOverview.factory.ts @@ -0,0 +1,45 @@ +import { Factory } from 'fishery' +import { faker } from '@faker-js/faker' +import { RedisDataType } from 'uiSrc/constants' +import { IBulkActionOverview } from 'apiSrc/modules/bulk-actions/interfaces/bulk-action-overview.interface' +import { IBulkActionFilterOverview } from 'apiSrc/modules/bulk-actions/interfaces/bulk-action-filter-overview.interface' +import { IBulkActionProgressOverview } from 'apiSrc/modules/bulk-actions/interfaces/bulk-action-progress-overview.interface' +import { IBulkActionSummaryOverview } from 'apiSrc/modules/bulk-actions/interfaces/bulk-action-summary-overview.interface' +import { + BulkActionStatus, + BulkActionType, +} from 'apiSrc/modules/bulk-actions/constants' + +export const bulkActionOverviewFactory = Factory.define( + ({ sequence }) => ({ + id: `bulk-action-${sequence}`, + databaseId: faker.string.ulid(), + type: faker.helpers.enumValue(BulkActionType), + summary: bulkActionSummaryOverviewFactory.build(), + progress: bulkActionProgressOverviewFactory.build(), + filter: bulkActionFilterOverviewFactory.build(), + status: faker.helpers.enumValue(BulkActionStatus), + duration: faker.number.int({ min: 10, max: 100 }), + }), +) + +export const bulkActionSummaryOverviewFactory = + Factory.define(() => ({ + processed: faker.number.int({ min: 200, max: 299 }), + succeed: faker.number.int({ min: 300, max: 399 }), + failed: faker.number.int({ min: 400, max: 499 }), + errors: [], + keys: [], + })) + +export const bulkActionProgressOverviewFactory = + Factory.define(() => ({ + total: faker.number.int({ min: 100, max: 1000 }), + scanned: faker.number.int({ min: 0, max: 1000 }), + })) + +export const bulkActionFilterOverviewFactory = + Factory.define(() => ({ + type: faker.helpers.enumValue(RedisDataType), + match: faker.string.uuid(), + })) diff --git a/redisinsight/ui/src/mocks/factories/redisearch/IndexInfo.factory.ts b/redisinsight/ui/src/mocks/factories/redisearch/IndexInfo.factory.ts new file mode 100644 index 0000000000..bc8beefe8d --- /dev/null +++ b/redisinsight/ui/src/mocks/factories/redisearch/IndexInfo.factory.ts @@ -0,0 +1,131 @@ +import { Factory } from 'fishery' +import { faker } from '@faker-js/faker' +import { FieldTypes } from 'uiSrc/pages/browser/components/create-redisearch-index/constants' +import { + FieldStatisticsDto, + IndexAttibuteDto, + IndexInfoDto, +} from 'apiSrc/modules/browser/redisearch/dto' + +export const INDEX_INFO_SEPARATORS: string[] = [',', ';', '|', ':'] + +// Note: Current data is replica of the sample data, but we can make it more realistic/diverse in the future +export const indexInfoFactory = Factory.define(() => ({ + index_name: `idx:${faker.word.noun()}`, + index_options: {}, + index_definition: { + key_type: 'JSON', + prefixes: [`$${faker.word.noun()}:`], + default_score: '1', + indexes_all: 'false', + }, + attributes: indexInfoAttributeFactory.buildList(3), + num_docs: faker.number.int({ min: 0, max: 100 }).toString(), // Note: DTO and actual response have different types, it should be a number + max_doc_id: faker.number.int({ min: 101, max: 200 }).toString(), // Note: DTO and actual response have different types, it should be a number + num_terms: faker.number.int({ min: 201, max: 300 }).toString(), // Note: DTO and actual response have different types, it should be a number + num_records: faker.number.int({ min: 301, max: 400 }).toString(), // Note: DTO and actual response have different types, it should be a number + inverted_sz_mb: '0.06543350219726563', + vector_index_sz_mb: '0', + total_inverted_index_blocks: faker.number + .int({ min: 401, max: 500 }) + .toString(), // Note: DTO and actual response have different types, it should be a number + offset_vectors_sz_mb: '0.0022459030151367188', + doc_table_size_mb: '0.023920059204101563', + sortable_values_size_mb: '0', + key_table_size_mb: '0.0032911300659179688', + tag_overhead_sz_mb: '6.361007690429688e-4', + text_overhead_sz_mb: '0.017991065979003906', + total_index_memory_sz_mb: '0.11714744567871094', + geoshapes_sz_mb: '0', + records_per_doc_avg: '24.952829360961914', + bytes_per_record_avg: '25.940263748168945', + offsets_per_term_avg: '0.8903591632843018', + offset_bits_per_record_avg: '8', + hash_indexing_failures: '0', // Note: DTO and actual response have different types, it should be a number + total_indexing_time: '1.7289999723434448', + indexing: '0', // Note: DTO and actual response have different types, it should be a number + percent_indexed: '1', + number_of_uses: 39, + cleaning: 0, + gc_stats: { + bytes_collected: '0', + total_ms_run: '0', + total_cycles: '0', + average_cycle_time_ms: 'nan', + last_run_time_ms: '0', + gc_numeric_trees_missed: '0', + gc_blocks_denied: '0', + }, + cursor_stats: { + global_idle: 0, + global_total: 0, + index_capacity: 128, + index_total: 0, + }, + dialect_stats: { + dialect_1: 0, + dialect_2: 0, + dialect_3: 0, + dialect_4: 0, + }, + 'Index Errors': { + 'indexing failures': 0, + 'last indexing error': 'N/A', + 'last indexing error key': 'N/A', + 'background indexing status': 'OK', + }, + 'field statistics': indexInfoFieldStatisticsFactory.buildList(3), +})) + +type IndexInfoAttributeFactoryTransientParams = { + includeWeight?: boolean + includeSeparator?: boolean + includeNoIndex?: boolean +} + +export const indexInfoAttributeFactory = Factory.define< + IndexAttibuteDto, + IndexInfoAttributeFactoryTransientParams +>(({ transientParams }) => { + const name = faker.word.noun() + + const { + includeWeight = faker.datatype.boolean(), + includeSeparator = faker.datatype.boolean(), + includeNoIndex = faker.datatype.boolean(), + } = transientParams + + return { + identifier: `$.${name}`, + attribute: name, + type: faker.helpers.enumValue(FieldTypes).toString(), + + // Optional fields + ...(includeWeight && { + WEIGHT: faker.number + .float({ min: 0.1, max: 10, fractionDigits: 1 }) + .toString(), + }), + ...(includeSeparator && { + SEPARATOR: faker.helpers.arrayElement(INDEX_INFO_SEPARATORS), + }), + ...(includeNoIndex && { + NOINDEX: faker.datatype.boolean(), + }), + } +}) + +export const indexInfoFieldStatisticsFactory = + Factory.define(() => { + const name = faker.word.noun() + + return { + identifier: `$.${name}`, + attribute: name, + 'Index Errors': { + 'indexing failures': 0, + 'last indexing error': 'N/A', + 'last indexing error key': 'N/A', + }, + } + }) diff --git a/redisinsight/ui/src/mocks/factories/redisearch/VectorSearchBox.factory.ts b/redisinsight/ui/src/mocks/factories/redisearch/VectorSearchBox.factory.ts new file mode 100644 index 0000000000..2c1fd52b47 --- /dev/null +++ b/redisinsight/ui/src/mocks/factories/redisearch/VectorSearchBox.factory.ts @@ -0,0 +1,12 @@ +import { Factory } from 'fishery' +import { faker } from '@faker-js/faker' +import { VectorSearchBox } from 'uiSrc/components/new-index/create-index-step/field-box/types' +import { FieldTypes } from 'uiSrc/pages/browser/components/create-redisearch-index/constants' + +export const vectorSearchBoxFactory = Factory.define(() => ({ + value: faker.string.alpha({ length: { min: 5, max: 12 } }), + label: faker.word.noun(), + text: faker.lorem.sentence(), + tag: faker.helpers.enumValue(FieldTypes), + disabled: faker.datatype.boolean(), +})) diff --git a/redisinsight/ui/src/mocks/factories/workbench/commandExectution.factory.ts b/redisinsight/ui/src/mocks/factories/workbench/commandExectution.factory.ts new file mode 100644 index 0000000000..9d4a99e7f5 --- /dev/null +++ b/redisinsight/ui/src/mocks/factories/workbench/commandExectution.factory.ts @@ -0,0 +1,70 @@ +import { Factory } from 'fishery' +import { faker } from '@faker-js/faker' +import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' +import { + CommandExecution, + CommandExecutionResult, + CommandExecutionType, + CommandExecutionUI, + ResultsMode, + RunQueryMode, +} from 'uiSrc/slices/interfaces' + +export const commandExecutionFactory = Factory.define( + ({ sequence }) => ({ + id: sequence.toString() ?? faker.string.uuid(), + databaseId: faker.string.ulid(), + db: faker.number.int({ min: 0, max: 15 }), + type: faker.helpers.enumValue(CommandExecutionType), + mode: faker.helpers.enumValue(RunQueryMode), + resultsMode: faker.helpers.enumValue(ResultsMode), + command: faker.lorem.paragraph(), + result: commandExecutionResultFactory.buildList(1), + executionTime: faker.number.int({ min: 1000, max: 5000 }), + createdAt: faker.date.past(), + }), +) + +export const commandExecutionResultFactory = + Factory.define(() => { + const includeSizeLimitExceeded = faker.datatype.boolean() + + return { + status: faker.helpers.enumValue(CommandExecutionStatus), + response: faker.lorem.paragraph(), + + // Optional properties + ...(includeSizeLimitExceeded && { + sizeLimitExceeded: faker.datatype.boolean(), + }), + } + }) + +export const commandExecutionUIFactory = Factory.define( + () => { + const commandExecution = commandExecutionFactory.build() as CommandExecution + + const includeLoading = faker.datatype.boolean() + const includeIsOpen = faker.datatype.boolean() + const includeError = faker.datatype.boolean() + const includeEmptyCommand = faker.datatype.boolean() + + return { + ...commandExecution, + + // Optional properties + ...(includeLoading && { + loading: faker.datatype.boolean(), + }), + ...(includeIsOpen && { + isOpen: faker.datatype.boolean(), + }), + ...(includeError && { + error: faker.lorem.sentence(), + }), + ...(includeEmptyCommand && { + emptyCommand: faker.datatype.boolean(), + }), + } + }, +) diff --git a/redisinsight/ui/src/mocks/handlers/browser/bulkActionsHandlers.ts b/redisinsight/ui/src/mocks/handlers/browser/bulkActionsHandlers.ts new file mode 100644 index 0000000000..5ea28a186e --- /dev/null +++ b/redisinsight/ui/src/mocks/handlers/browser/bulkActionsHandlers.ts @@ -0,0 +1,22 @@ +import { rest, RestHandler } from 'msw' +import { ApiEndpoints } from 'uiSrc/constants' +import { getMswURL } from 'uiSrc/utils/test-utils' +import { getUrl } from 'uiSrc/utils' +import { IBulkActionOverview } from 'uiSrc/slices/interfaces' +import { bulkActionOverviewFactory } from 'uiSrc/mocks/factories/browser/bulkActions/bulkActionOverview.factory' +import { INSTANCE_ID_MOCK } from '../instances/instancesHandlers' + +const handlers: RestHandler[] = [ + rest.post( + getMswURL( + getUrl( + INSTANCE_ID_MOCK, + ApiEndpoints.BULK_ACTIONS_IMPORT_VECTOR_COLLECTION, + ), + ), + async (_req, res, ctx) => + res(ctx.status(200), ctx.json(bulkActionOverviewFactory.build())), + ), +] + +export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/browser/index.ts b/redisinsight/ui/src/mocks/handlers/browser/index.ts index 14008797fc..2635a980be 100644 --- a/redisinsight/ui/src/mocks/handlers/browser/index.ts +++ b/redisinsight/ui/src/mocks/handlers/browser/index.ts @@ -1,8 +1,10 @@ import { DefaultBodyType, MockedRequest, RestHandler } from 'msw' import redisearch from './redisearchHandlers' +import bulkActions from './bulkActionsHandlers' const handlers: RestHandler>[] = [].concat( redisearch, + bulkActions, ) export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts b/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts index 775410ea48..de3b7e1c61 100644 --- a/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts +++ b/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts @@ -2,7 +2,11 @@ import { rest, RestHandler } from 'msw' import { ApiEndpoints } from 'uiSrc/constants' import { getMswURL } from 'uiSrc/utils/test-utils' import { getUrl, stringToBuffer } from 'uiSrc/utils' -import { ListRedisearchIndexesResponse } from 'apiSrc/modules/browser/redisearch/dto' +import { indexInfoFactory } from 'uiSrc/mocks/factories/redisearch/IndexInfo.factory' +import { + IndexInfoDto, + ListRedisearchIndexesResponse, +} from 'apiSrc/modules/browser/redisearch/dto' import { INSTANCE_ID_MOCK } from '../instances/instancesHandlers' export const REDISEARCH_LIST_DATA_MOCK_UTF8 = ['idx: 1', 'idx:2'] @@ -16,9 +20,18 @@ const handlers: RestHandler[] = [ // fetchRedisearchListAction rest.get( getMswURL(getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH)), - async (req, res, ctx) => + async (_req, res, ctx) => res(ctx.status(200), ctx.json(REDISEARCH_LIST_DATA_MOCK)), ), + rest.post( + getMswURL(getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH_INFO)), + async (_req, res, ctx) => + res(ctx.status(200), ctx.json(indexInfoFactory.build())), + ), + rest.delete( + getMswURL(getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH)), + async (_req, res, ctx) => res(ctx.status(204)), + ), ] export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/index.ts b/redisinsight/ui/src/mocks/handlers/index.ts index 34a7c95448..b02d9fceb0 100644 --- a/redisinsight/ui/src/mocks/handlers/index.ts +++ b/redisinsight/ui/src/mocks/handlers/index.ts @@ -9,6 +9,7 @@ import cloud from './oauth' import tutorials from './tutorials' import rdi from './rdi' import user from './user' +import workbench from './workbench' // @ts-ignore export const handlers: RestHandler[] = [].concat( @@ -22,4 +23,5 @@ export const handlers: RestHandler[] = [].concat( tutorials, rdi, user, + workbench, ) diff --git a/redisinsight/ui/src/mocks/handlers/workbench/commands.ts b/redisinsight/ui/src/mocks/handlers/workbench/commands.ts new file mode 100644 index 0000000000..2f197b93fa --- /dev/null +++ b/redisinsight/ui/src/mocks/handlers/workbench/commands.ts @@ -0,0 +1,19 @@ +import { rest, RestHandler } from 'msw' +import { ApiEndpoints } from 'uiSrc/constants' +import { getMswURL } from 'uiSrc/utils/test-utils' +import { getUrl } from 'uiSrc/utils' +import { CommandExecution } from 'uiSrc/slices/interfaces' +import { commandExecutionFactory } from 'uiSrc/mocks/factories/workbench/commandExectution.factory' +import { INSTANCE_ID_MOCK } from '../instances/instancesHandlers' + +const handlers: RestHandler[] = [ + rest.post( + getMswURL( + getUrl(INSTANCE_ID_MOCK, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS), + ), + async (_req, res, ctx) => + res(ctx.status(200), ctx.json(commandExecutionFactory.buildList(1))), + ), +] + +export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/workbench/index.ts b/redisinsight/ui/src/mocks/handlers/workbench/index.ts new file mode 100644 index 0000000000..85701ab6a7 --- /dev/null +++ b/redisinsight/ui/src/mocks/handlers/workbench/index.ts @@ -0,0 +1,8 @@ +import { DefaultBodyType, MockedRequest, RestHandler } from 'msw' + +import commands from './commands' + +const handlers: RestHandler>[] = [].concat( + commands, +) +export default handlers diff --git a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts index afde715b26..64690a5da6 100644 --- a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts +++ b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts @@ -5,6 +5,7 @@ export enum FieldTypes { TAG = 'tag', NUMERIC = 'numeric', GEO = 'geo', + VECTOR = 'vector', } export enum RedisearchIndexKeyType { @@ -42,4 +43,8 @@ export const FIELD_TYPE_OPTIONS = [ text: 'GEO', value: FieldTypes.GEO, }, + { + text: 'VECTOR', + value: FieldTypes.VECTOR, + }, ] diff --git a/redisinsight/ui/src/pages/index.ts b/redisinsight/ui/src/pages/index.ts index 88c293352c..fd36c3c6a7 100644 --- a/redisinsight/ui/src/pages/index.ts +++ b/redisinsight/ui/src/pages/index.ts @@ -4,3 +4,4 @@ export * from './instance' export * from './home' export * from './redis-cluster' export * from './autodiscover-cloud' +export * from './vector-search' diff --git a/redisinsight/ui/src/pages/vector-search/components/QueryCard.spec.tsx b/redisinsight/ui/src/pages/vector-search/components/QueryCard.spec.tsx new file mode 100644 index 0000000000..57d037aed5 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/QueryCard.spec.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { RunQueryMode } from 'uiSrc/slices/interfaces' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' +import QueryCard, { Props } from './QueryCard' +import { + ViewMode, + ViewModeContextProvider, +} from 'uiSrc/components/query/context/view-mode.context' + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const renderQueryCardComponent = (props?: Partial) => { + const defaultProps: Props = { + id: '1', + command: 'FT.SEARCH', + isOpen: true, + result: [], // Maybe + activeMode: RunQueryMode.ASCII, + onQueryDelete: jest.fn(), + onQueryReRun: jest.fn(), + onQueryOpen: jest.fn(), + onQueryProfile: jest.fn(), + } + + return render( + + + , + ) +} + +describe('QueryCard', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render', () => { + const { container } = renderQueryCardComponent() + expect(container).toBeInTheDocument() + + // TODO: Verify the rendered content + }) + + describe('Telemetry', () => { + it('should collect telemetry when clicking the "toggle full-screen" button', () => { + renderQueryCardComponent() + + // Simulate clicking the full-screen button + const fullScreenButton = screen.getByTestId('toggle-full-screen') + expect(fullScreenButton).toBeInTheDocument() + + fireEvent.click(fullScreenButton) + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_RESULTS_IN_FULL_SCREEN, + eventData: { + databaseId: INSTANCE_ID_MOCK, + state: 'Open', + }, + }) + + // Simulate closing full-screen + fireEvent.click(fullScreenButton) + + // Verify telemetry event is sent for closing full-screen + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_RESULTS_IN_FULL_SCREEN, + eventData: { + databaseId: INSTANCE_ID_MOCK, + state: 'Close', + }, + }) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/components/QueryCard.tsx b/redisinsight/ui/src/pages/vector-search/components/QueryCard.tsx new file mode 100644 index 0000000000..6ebfc4e564 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/QueryCard.tsx @@ -0,0 +1,302 @@ +import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import cx from 'classnames' +import { useParams } from 'react-router-dom' +import { isNull } from 'lodash' +import { KeyboardKeys as keys } from 'uiSrc/constants/keys' + +import { LoadingContent } from 'uiSrc/components/base/layout' +import { + DEFAULT_TEXT_VIEW_TYPE, + ProfileQueryType, + WBQueryType, +} from 'uiSrc/pages/workbench/constants' +import { + ResultsMode, + ResultsSummary, + RunQueryMode, +} from 'uiSrc/slices/interfaces/workbench' +import { + getVisualizationsByCommand, + getWBQueryType, + isGroupResults, + isSilentModeWithoutError, + Maybe, +} from 'uiSrc/utils' +import { appPluginsSelector } from 'uiSrc/slices/app/plugins' +import { + CommandExecutionResult, + IPluginVisualization, +} from 'uiSrc/slices/interfaces' + +import QueryCardCommonResult, { + CommonErrorResponse, +} from 'uiSrc/components/query/query-card/QueryCardCommonResult' +import QueryCardCliResultWrapper from 'uiSrc/components/query/query-card/QueryCardCliResultWrapper' +import QueryCardCliPlugin from 'uiSrc/components/query/query-card/QueryCardCliPlugin' +import queryStyles from 'uiSrc/components/query/query-card/styles.module.scss' +import QueryCardHeader from 'uiSrc/components/query/query-card/QueryCardHeader' +import { collectQueryToggleFullScreenTelemetry } from '../telemetry' + +export interface Props { + id: string + command: string + isOpen: boolean + result: Maybe + activeMode: RunQueryMode + mode?: RunQueryMode + activeResultsMode?: ResultsMode + resultsMode?: ResultsMode + emptyCommand?: boolean + summary?: ResultsSummary + createdAt?: Date + loading?: boolean + clearing?: boolean + isNotStored?: boolean + executionTime?: number + db?: number + hideFields?: string[] + onQueryDelete: () => void + onQueryReRun: () => void + onQueryOpen: () => void + onQueryProfile: (type: ProfileQueryType) => void +} + +const getDefaultPlugin = (views: IPluginVisualization[], query: string) => + getVisualizationsByCommand(query, views).find((view) => view.default) + ?.uniqId || DEFAULT_TEXT_VIEW_TYPE.id + +export const getSummaryText = ( + summary?: ResultsSummary, + mode?: ResultsMode, +) => { + if (summary) { + const { total, success, fail } = summary + const summaryText = `${total} Command(s) - ${success} success` + if (!isSilentModeWithoutError(mode, summary?.fail)) { + return `${summaryText}, ${fail} error(s)` + } + return summaryText + } + return summary +} + +const QueryCard = (props: Props) => { + const { + id, + command = '', + result, + activeMode, + mode, + activeResultsMode, + resultsMode, + summary, + isOpen, + createdAt, + onQueryOpen, + onQueryDelete, + onQueryProfile, + onQueryReRun, + loading, + clearing, + emptyCommand, + isNotStored, + executionTime, + db, + hideFields, + } = props + + const { visualizations = [] } = useSelector(appPluginsSelector) + + const { instanceId = '' } = useParams<{ instanceId: string }>() + const [isFullScreen, setIsFullScreen] = useState(false) + const [queryType, setQueryType] = useState( + getWBQueryType(command, visualizations), + ) + const [viewTypeSelected, setViewTypeSelected] = + useState(queryType) + const [message, setMessage] = useState('') + const [selectedViewValue, setSelectedViewValue] = useState( + getDefaultPlugin(visualizations, command || '') || queryType, + ) + + useEffect(() => { + window.addEventListener('keydown', handleEscFullScreen) + return () => { + window.removeEventListener('keydown', handleEscFullScreen) + } + }, [isFullScreen]) + + const handleEscFullScreen = (event: KeyboardEvent) => { + if (event.key === keys.ESCAPE && isFullScreen) { + toggleFullScreen() + } + } + + const toggleFullScreen = () => { + setIsFullScreen((isFull) => { + collectQueryToggleFullScreenTelemetry({ + instanceId, + isFullScreen: !isFull, + }) + + return !isFull + }) + } + + useEffect(() => { + setQueryType(getWBQueryType(command, visualizations)) + }, [command]) + + useEffect(() => { + if (visualizations.length) { + const type = getWBQueryType(command, visualizations) + setQueryType(type) + setViewTypeSelected(type) + setSelectedViewValue( + getDefaultPlugin(visualizations, command) || queryType, + ) + } + }, [visualizations]) + + const toggleOpen = () => { + if (isFullScreen || isSilentModeWithoutError(resultsMode, summary?.fail)) + return + + onQueryOpen() + } + + const changeViewTypeSelected = (type: WBQueryType, value: string) => { + setViewTypeSelected(type) + setSelectedViewValue(value) + } + + const commonError = CommonErrorResponse(id, command, result) + + const isSizeLimitExceededResponse = ( + result: Maybe, + ) => { + const resultObj = result?.[0] + // response.includes - to be backward compatible with responses which don't include sizeLimitExceeded flag + return ( + resultObj?.sizeLimitExceeded === true || + resultObj?.response?.includes?.('Results have been deleted') + ) + } + + return ( +
+
+ + {isOpen && ( + <> + {React.isValidElement(commonError) && + (!isGroupResults(resultsMode) || isNull(command)) ? ( + + ) : ( + <> + {isSizeLimitExceededResponse(result) ? ( + + ) : ( + <> + {isGroupResults(resultsMode) && ( + + )} + {(resultsMode === ResultsMode.Default || !resultsMode) && ( + <> + {viewTypeSelected === WBQueryType.Plugin && ( + <> + {!loading && result !== undefined ? ( + + ) : ( +
+ +
+ )} + + )} + {viewTypeSelected === WBQueryType.Text && ( + + )} + + )} + + )} + + )} + + )} +
+
+ ) +} + +export default React.memo(QueryCard) diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.spec.tsx b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.spec.tsx new file mode 100644 index 0000000000..d604e5e3c3 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.spec.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { RunQueryMode } from 'uiSrc/slices/interfaces' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' +import { commandExecutionUIFactory } from 'uiSrc/mocks/factories/workbench/commandExectution.factory' +import CommandsView, { Props } from './CommandsView' +import { + ViewMode, + ViewModeContextProvider, +} from 'uiSrc/components/query/context/view-mode.context' + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const renderCommandsViewComponent = (props?: Partial) => { + const defaultProps: Props = { + isResultsLoaded: true, + items: commandExecutionUIFactory.buildList(1), + clearing: true, + processing: false, + activeMode: RunQueryMode.ASCII, + scrollDivRef: React.createRef(), + onQueryReRun: jest.fn(), + onQueryDelete: jest.fn(), + onAllQueriesDelete: jest.fn(), + onQueryOpen: jest.fn(), + onQueryProfile: jest.fn(), + } + + return render( + + + , + ) +} + +describe('CommandsView', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render', () => { + const { container } = renderCommandsViewComponent() + expect(container).toBeInTheDocument() + + // TODO: Verify the rendered content + }) + + describe('Telemetry', () => { + it('should collect telemetry when clicking the re-run button', () => { + const mockCommand = commandExecutionUIFactory.build({ + isOpen: false, // in order to get only SEARCH_RESULTS_EXPANDED or SEARCH_COMMAND_RUN_AGAIN events + }) + + const props: Partial = { + items: [mockCommand], + onQueryReRun: jest.fn(), + } + + renderCommandsViewComponent(props) + + const reRunButton = screen.getByTestId('re-run-command') + expect(reRunButton).toBeInTheDocument() + + fireEvent.click(reRunButton) + + // Hack: looks like there is a race condition between the two telemetry events + // so until we fix it, we'll just check for either event + const calls = (sendEventTelemetry as jest.Mock).mock.calls + const hasReRunEvent = calls.some( + (call) => + call[0].event === TelemetryEvent.SEARCH_COMMAND_RUN_AGAIN && + call[0].eventData.databaseId === INSTANCE_ID_MOCK && + call[0].eventData.commands?.includes(mockCommand.command), + ) + const hasExpandEvent = calls.some( + (call) => + call[0].event === TelemetryEvent.SEARCH_RESULTS_EXPANDED && + call[0].eventData.databaseId === INSTANCE_ID_MOCK, + ) + + expect(hasReRunEvent || hasExpandEvent).toBe(true) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.styles.ts b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.styles.ts new file mode 100644 index 0000000000..cfcb939657 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.styles.ts @@ -0,0 +1,39 @@ +import styled from 'styled-components' + +/* TODO: use theme when it supports theme.semantic.core.radius */ +// to replace var(--border-radius-medium) +export const StyledWrapper = styled.div` + flex: 1; + height: calc(100% - var(--border-radius-medium)); + width: 100%; + background-color: ${({ theme }) => + theme.semantic?.color.background.neutral100}; + border: 1px solid ${({ theme }) => theme.semantic.color.border.neutral500}; + border-radius: var(--border-radius-medium); + // HACK: to fix rectangle like view in rounded borders wrapper + padding-bottom: ${({ theme }) => theme.core.space.space050}; + + display: flex; + flex-direction: column; + + position: relative; +` + +export const StyledContainer = styled.div` + flex: 1; + width: 100%; + overflow: auto; + color: ${({ theme }) => theme.color.gray700}; +` + +export const StyledHeader = styled.div` + height: 42px; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 ${({ theme }) => theme.core.space.space150}; + + flex-shrink: 0; + border-bottom: 1px solid + ${({ theme }) => theme.semantic.color.border.neutral500}; +` diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.tsx b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.tsx new file mode 100644 index 0000000000..95f566b767 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.tsx @@ -0,0 +1,171 @@ +import React from 'react' +import { useParams } from 'react-router-dom' + +import { CodeButtonParams } from 'uiSrc/constants' +import { ProfileQueryType } from 'uiSrc/pages/workbench/constants' +import { generateProfileQueryForCommand } from 'uiSrc/pages/workbench/utils/profile' +import { Nullable } from 'uiSrc/utils' +import { CommandExecutionUI } from 'uiSrc/slices/interfaces' +import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' + +import { EmptyButton } from 'uiSrc/components/base/forms/buttons' +import { DeleteIcon } from 'uiSrc/components/base/icons' +import { ProgressBarLoader } from 'uiSrc/components/base/display' +import { collectTelemetryQueryReRun } from 'uiSrc/pages/vector-search/telemetry' +import QueryCard from '../../QueryCard' + +import { + StyledContainer, + StyledHeader, + StyledWrapper, +} from './CommandsView.styles' + +export interface Props { + isResultsLoaded: boolean + items: CommandExecutionUI[] + clearing: boolean + processing: boolean + activeMode: RunQueryMode + activeResultsMode?: ResultsMode + scrollDivRef: React.Ref + noResultsPlaceholder?: React.ReactNode + hideFields?: string[] + onQueryReRun: ( + query: string, + commandId?: Nullable, + executeParams?: CodeButtonParams, + ) => void + onQueryDelete: (commandId: string) => void + onAllQueriesDelete: () => void + onQueryOpen: (commandId: string) => void + onQueryProfile: ( + query: string, + commandId?: Nullable, + executeParams?: CodeButtonParams, + ) => void +} +const CommandsView = (props: Props) => { + const { + isResultsLoaded, + items = [], + clearing, + processing, + activeMode, + activeResultsMode, + noResultsPlaceholder, + hideFields, + onQueryReRun, + onQueryProfile, + onQueryDelete, + onAllQueriesDelete, + onQueryOpen, + scrollDivRef, + } = props + const { instanceId } = useParams<{ instanceId: string }>() + + const handleQueryProfile = ( + profileType: ProfileQueryType, + commandExecution: { + command: string + mode?: RunQueryMode + resultsMode?: ResultsMode + }, + ) => { + const { command, mode, resultsMode } = commandExecution + const profileQuery = generateProfileQueryForCommand(command, profileType) + if (profileQuery) { + onQueryProfile(profileQuery, null, { + mode, + results: resultsMode, + clearEditor: false, + }) + } + } + + return ( + + {!isResultsLoaded && ( + + )} + {!!items?.length && ( + + onAllQueriesDelete?.()} + disabled={clearing || processing} + data-testid="clear-history-btn" + > + Clear Results + + + )} + +
+ {items?.length + ? items.map( + ({ + command = '', + isOpen = false, + result = undefined, + summary = undefined, + id = '', + loading, + createdAt, + mode, + resultsMode, + emptyCommand, + isNotStored, + executionTime, + db, + }) => ( + onQueryOpen(id)} + onQueryProfile={(profileType) => + handleQueryProfile(profileType, { + command, + mode, + resultsMode, + }) + } + onQueryReRun={() => { + onQueryReRun(command, null, { + mode, + results: resultsMode, + clearEditor: false, + }) + collectTelemetryQueryReRun({ + instanceId, + query: command, + }) + }} + onQueryDelete={() => onQueryDelete(id)} + /> + ), + ) + : null} + {isResultsLoaded && !items.length && (noResultsPlaceholder ?? null)} + + + ) +} + +export default React.memo(CommandsView) diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/index.ts b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/index.ts new file mode 100644 index 0000000000..4dc31b50e8 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/index.ts @@ -0,0 +1,3 @@ +import CommandsView from './CommandsView' + +export default CommandsView diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsViewWrapper.tsx b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsViewWrapper.tsx new file mode 100644 index 0000000000..a9a0fc3770 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsViewWrapper.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Nullable } from 'uiSrc/utils' +import { CommandExecutionUI } from 'uiSrc/slices/interfaces' +import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' +import { CodeButtonParams } from 'uiSrc/constants' +import CommandsView from './CommandsView' + +export interface Props { + isResultsLoaded: boolean + items: CommandExecutionUI[] + clearing: boolean + processing: boolean + activeMode: RunQueryMode + activeResultsMode: ResultsMode + scrollDivRef: React.Ref + noResultsPlaceholder?: React.ReactNode + hideFields?: string[] + onQueryReRun: ( + query: string, + commandId?: Nullable, + executeParams?: CodeButtonParams, + ) => void + onQueryOpen: (commandId: string) => void + onQueryDelete: (commandId: string) => void + onAllQueriesDelete: () => void + onQueryProfile: ( + query: string, + commandId?: Nullable, + executeParams?: CodeButtonParams, + ) => void +} + +const CommandsViewWrapper = (props: Props) => + +export default React.memo(CommandsViewWrapper) diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/index.ts b/redisinsight/ui/src/pages/vector-search/components/commands-view/index.ts new file mode 100644 index 0000000000..92c999ddbe --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/index.ts @@ -0,0 +1,3 @@ +import CommandsViewWrapper from './CommandsViewWrapper' + +export default CommandsViewWrapper diff --git a/redisinsight/ui/src/pages/vector-search/create-index/VectorSearchCreateIndex.spec.tsx b/redisinsight/ui/src/pages/vector-search/create-index/VectorSearchCreateIndex.spec.tsx new file mode 100644 index 0000000000..059c311aeb --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/VectorSearchCreateIndex.spec.tsx @@ -0,0 +1,178 @@ +import React from 'react' +import reactRouterDom from 'react-router-dom' +import { + render, + screen, + fireEvent, + initialStateDefault, + mockStore, +} from 'uiSrc/utils/test-utils' +import { RootState } from 'uiSrc/slices/store' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { Pages } from 'uiSrc/constants' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/analytics/dbAnalysisHistoryHandlers' +import { + VectorSearchCreateIndex, + VectorSearchCreateIndexProps, +} from './VectorSearchCreateIndex' +import { SampleDataContent, SampleDataType, SearchIndexType } from './types' +import { useCreateIndex } from './hooks/useCreateIndex' + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('./hooks/useCreateIndex', () => ({ + useCreateIndex: jest.fn(), +})) + +const mockedUseCreateIndex = useCreateIndex as jest.MockedFunction< + typeof useCreateIndex +> + +const renderVectorSearchCreateIndexComponent = ( + props?: VectorSearchCreateIndexProps, +) => { + const testState: RootState = { + ...initialStateDefault, + connections: { + ...initialStateDefault.connections, + instances: { + ...initialStateDefault.connections.instances, + connectedInstance: { + ...initialStateDefault.connections.instances.connectedInstance, + id: INSTANCE_ID_MOCK, + name: 'test-instance', + host: 'localhost', + port: 6379, + modules: [], + }, + }, + }, + } + const store = mockStore(testState) + const utils = render(, { store }) + + return { ...utils, store } +} + +describe('VectorSearchCreateIndex', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedUseCreateIndex.mockReturnValue({ + run: jest.fn(), + loading: false, + error: null, + success: false, + } as any) + }) + + it('should render correctly', () => { + const { container } = renderVectorSearchCreateIndexComponent() + + expect(container).toBeInTheDocument() + }) + + it('should redirect to vector search page after index creation', async () => { + const pushMock = jest.fn() + reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: pushMock }) + + mockedUseCreateIndex.mockReturnValue({ + run: jest.fn(), + loading: false, + error: null, + success: true, + } as any) + + renderVectorSearchCreateIndexComponent({ initialStep: 2 }) + + // Effect should dispatch success notification and navigate + expect(pushMock).toHaveBeenCalledWith(Pages.vectorSearch(INSTANCE_ID_MOCK)) + }) + + it('should dispatch error notification on error', () => { + mockedUseCreateIndex.mockReturnValue({ + run: jest.fn(), + loading: false, + error: { message: 'Some error' }, + success: false, + } as any) + + const { store } = renderVectorSearchCreateIndexComponent({ + initialStep: 2, + }) + + // Should dispatch addErrorNotification + const actions = store?.getActions?.() || [] + const hasErrorAction = actions.some( + (a: any) => a.type === addErrorNotification.type, + ) + expect(hasErrorAction).toBe(true) + }) + + describe('Telemetry', () => { + it('should send telemetry events on start step', () => { + renderVectorSearchCreateIndexComponent() + + expect(sendEventTelemetry).toHaveBeenCalledTimes(1) + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_TRIGGERED, + eventData: { databaseId: INSTANCE_ID_MOCK }, + }) + }) + + it('should send telemetry events on index info step', () => { + renderVectorSearchCreateIndexComponent() + + // Select the index type + const indexTypeRadio = screen.getByText('Redis Query Engine') + fireEvent.click(indexTypeRadio) + + // Select the sample dataset + const sampleDataRadio = screen.getByText('Pre-set data') + fireEvent.click(sampleDataRadio) + + // Select data content + const dataContentRadio = screen.getByText('E-commerce Discovery') + fireEvent.click(dataContentRadio) + + // Simulate going to the index info step + const buttonNext = screen.getByText('Proceed to index') + fireEvent.click(buttonNext) + + expect(sendEventTelemetry).toHaveBeenCalledTimes(2) + expect(sendEventTelemetry).toHaveBeenNthCalledWith(2, { + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_PROCEED_TO_INDEX_INFO, + eventData: { + databaseId: INSTANCE_ID_MOCK, + indexType: SearchIndexType.REDIS_QUERY_ENGINE, + sampleDataType: SampleDataType.PRESET_DATA, + dataContent: SampleDataContent.E_COMMERCE_DISCOVERY, + }, + }) + }) + + it('should send telemetry events on create index step', () => { + renderVectorSearchCreateIndexComponent() + + // Simulate going to the index info step + const buttonNext = screen.getByText('Proceed to index') + fireEvent.click(buttonNext) + + // Simulate creating the index + const buttonCreateIndex = screen.getByText('Create index') + fireEvent.click(buttonCreateIndex) + + expect(sendEventTelemetry).toHaveBeenCalledTimes(3) + expect(sendEventTelemetry).toHaveBeenNthCalledWith(3, { + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_PROCEED_TO_QUERIES, + eventData: { + databaseId: INSTANCE_ID_MOCK, + }, + }) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/create-index/VectorSearchCreateIndex.tsx b/redisinsight/ui/src/pages/vector-search/create-index/VectorSearchCreateIndex.tsx new file mode 100644 index 0000000000..ee341660a9 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/VectorSearchCreateIndex.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useState } from 'react' +import { useHistory, useParams } from 'react-router-dom' +import { useDispatch } from 'react-redux' + +import { Stepper } from '@redis-ui/components' +import { Title } from 'uiSrc/components/base/text' +import { Button, SecondaryButton } from 'uiSrc/components/base/forms/buttons' +import { ChevronLeftIcon } from 'uiSrc/components/base/icons' + +import { selectedBikesIndexFields, stepContents } from './steps' +import { + CreateSearchIndexParameters, + PresetDataType, + SampleDataContent, + SampleDataType, + SearchIndexType, +} from './types' +import { useCreateIndex } from './hooks/useCreateIndex' +import { + VectorSearchScreenContent, + VectorSearchScreenFooter, + VectorSearchScreenHeader, + VectorSearchScreenWrapper, +} from '../styles' +import { + collectCreateIndexStepTelemetry, + collectCreateIndexWizardTelemetry, +} from '../telemetry' +import { Pages } from 'uiSrc/constants' +import { + addMessageNotification, + addErrorNotification, +} from 'uiSrc/slices/app/notifications' +import successMessages from 'uiSrc/components/notifications/success-messages' +import { parseCustomError } from 'uiSrc/utils' + +const stepNextButtonTexts = [ + 'Proceed to adding data', + 'Proceed to index', + 'Create index', +] + +export type VectorSearchCreateIndexProps = { + initialStep?: number +} + +export const VectorSearchCreateIndex = ({ + initialStep = 1, +}: VectorSearchCreateIndexProps) => { + const dispatch = useDispatch() + const history = useHistory() + const { instanceId } = useParams<{ instanceId: string }>() + const [step, setStep] = useState(initialStep) + const [createSearchIndexParameters, setCreateSearchIndexParameters] = + useState({ + instanceId, + searchIndexType: SearchIndexType.REDIS_QUERY_ENGINE, + sampleDataType: SampleDataType.PRESET_DATA, + dataContent: SampleDataContent.E_COMMERCE_DISCOVERY, + usePresetVectorIndex: true, + indexName: PresetDataType.BIKES, + indexFields: selectedBikesIndexFields, + }) + + const { run: createIndex, error, success, loading } = useCreateIndex() + + const setParameters = (params: Partial) => { + setCreateSearchIndexParameters((prev) => ({ ...prev, ...params })) + } + const showBackButton = step > initialStep + const StepContent = stepContents[step] + const onNextClick = () => { + const isFinalStep = step === stepContents.length - 1 + if (isFinalStep) { + createIndex(createSearchIndexParameters) + collectCreateIndexStepTelemetry(instanceId) + return + } + + setStep(step + 1) + } + const onBackClick = () => { + setStep(step - 1) + } + + useEffect(() => { + collectCreateIndexWizardTelemetry({ + instanceId, + step, + parameters: createSearchIndexParameters, + }) + }, [step]) + + useEffect(() => { + if (error) { + dispatch(addErrorNotification(parseCustomError(error.message) as any)) + } else if (success) { + dispatch(addMessageNotification(successMessages.CREATE_INDEX())) + + history.push(Pages.vectorSearch(instanceId)) + } + }, [success, error]) + + return ( + + + + New vector search + + + Select a database + Adding data + Create Index + + + + + + + {showBackButton && ( + + Back + + )} +
+ + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.spec.ts b/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.spec.ts new file mode 100644 index 0000000000..d5b1ec0717 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.spec.ts @@ -0,0 +1,117 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import executeQuery from 'uiSrc/services/executeQuery' +import { + CreateSearchIndexParameters, + SampleDataContent, + SampleDataType, + SearchIndexType, +} from '../types' +import { useCreateIndex } from './useCreateIndex' + +const mockLoad = jest.fn() +const mockAddCommands = jest.fn() + +jest.mock('uiSrc/services/hooks', () => ({ + useLoadData: () => ({ + load: mockLoad, + }), +})) + +jest.mock('uiSrc/services/workbenchStorage', () => ({ + addCommands: (...args: any[]) => mockAddCommands(...args), +})) + +jest.mock('uiSrc/utils/index/generateFtCreateCommand', () => ({ + generateFtCreateCommand: () => 'FT.CREATE idx:bikes_vss ...', +})) + +jest.mock('uiSrc/services/executeQuery', () => ({ + __esModule: true, + default: jest.fn(), +})) +const mockExecute = executeQuery as jest.Mock + +describe('useCreateIndex', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const defaultParams: CreateSearchIndexParameters = { + instanceId: 'test-instance-id', + dataContent: SampleDataContent.E_COMMERCE_DISCOVERY, + sampleDataType: SampleDataType.PRESET_DATA, + searchIndexType: SearchIndexType.REDIS_QUERY_ENGINE, + usePresetVectorIndex: true, + indexName: 'bikes', + indexFields: [], + } + + it('should complete flow successfully', async () => { + mockLoad.mockResolvedValue(undefined) + mockExecute.mockResolvedValue([{ id: '1', databaseId: 'test-instance-id' }]) + + const { result } = renderHook(() => useCreateIndex()) + + await act(async () => { + await result.current.run(defaultParams) + }) + + expect(mockLoad).toHaveBeenCalledWith('test-instance-id', 'bikes') + expect(mockExecute).toHaveBeenCalledWith( + 'test-instance-id', + 'FT.CREATE idx:bikes_vss ...', + ) + expect(mockAddCommands).toHaveBeenCalled() + expect(result.current.success).toBe(true) + expect(result.current.error).toBeNull() + expect(result.current.loading).toBe(false) + }) + + it('should handle error if instanceId is missing', async () => { + const { result } = renderHook(() => useCreateIndex()) + + await act(async () => { + await result.current.run({ ...defaultParams, instanceId: '' }) + }) + + expect(result.current.success).toBe(false) + expect(result.current.error?.message).toMatch(/Instance ID is required/) + expect(result.current.loading).toBe(false) + expect(mockLoad).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() + }) + + it('should handle failure in data loading', async () => { + const error = new Error('Failed to load') + mockLoad.mockRejectedValue(error) + + const { result } = renderHook(() => useCreateIndex()) + + await act(async () => { + await result.current.run(defaultParams) + }) + + expect(mockLoad).toHaveBeenCalled() + expect(result.current.success).toBe(false) + expect(result.current.error).toBe(error) + expect(result.current.loading).toBe(false) + expect(mockExecute).not.toHaveBeenCalled() + }) + + it('should handle execution failure', async () => { + mockLoad.mockResolvedValue(undefined) + mockExecute.mockRejectedValue(new Error('Execution failed')) + + const { result } = renderHook(() => useCreateIndex()) + + await act(async () => { + await result.current.run(defaultParams) + }) + + expect(mockExecute).toHaveBeenCalled() + expect(result.current.success).toBe(false) + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe('Execution failed') + expect(result.current.loading).toBe(false) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.ts b/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.ts new file mode 100644 index 0000000000..92749c917e --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.ts @@ -0,0 +1,69 @@ +import { useCallback, useState } from 'react' +import { reverse } from 'lodash' +import { useLoadData } from 'uiSrc/services/hooks' +import { addCommands } from 'uiSrc/services/workbenchStorage' +import { generateFtCreateCommand } from 'uiSrc/utils/index/generateFtCreateCommand' +import { CreateSearchIndexParameters, PresetDataType } from '../types' +import executeQuery from 'uiSrc/services/executeQuery' + +interface UseCreateIndexResult { + run: (params: CreateSearchIndexParameters) => Promise + loading: boolean + error: Error | null + success: boolean +} + +const collectionNameByPresetDataChoiceMap = { + [PresetDataType.BIKES]: 'bikes', +} + +export const useCreateIndex = (): UseCreateIndexResult => { + const [success, setSuccess] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const { load } = useLoadData() + + const run = useCallback( + async ({ instanceId }: CreateSearchIndexParameters) => { + setSuccess(false) + setError(null) + setLoading(true) + + try { + const collectionName = + collectionNameByPresetDataChoiceMap[PresetDataType.BIKES] + + if (!instanceId) { + throw new Error('Instance ID is required') + } + + // Step 1: Load the vector collection data + await load(instanceId, collectionName) + + // Step 2: Create the search index + const cmd = generateFtCreateCommand() + const data = await executeQuery(instanceId, cmd) + + // Step 3: Persist results locally so Vector Search history (CommandsView) shows it + if (Array.isArray(data) && data.length) { + await addCommands(reverse(data)) + } + + setSuccess(true) + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))) + } finally { + setLoading(false) + } + }, + [load, executeQuery], + ) + + return { + run, + loading, + error, + success, + } +} diff --git a/redisinsight/ui/src/pages/vector-search/create-index/steps/AddDataStep.spec.tsx b/redisinsight/ui/src/pages/vector-search/create-index/steps/AddDataStep.spec.tsx new file mode 100644 index 0000000000..10825c2c06 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/steps/AddDataStep.spec.tsx @@ -0,0 +1,219 @@ +import React from 'react' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import { AddDataStep } from './AddDataStep' +import { selectedBikesIndexFields } from './config' +import { + SearchIndexType, + SampleDataType, + SampleDataContent, + StepComponentProps, + PresetDataType, +} from '../types' + +const mockSetParameters = jest.fn() + +const defaultProps: StepComponentProps = { + parameters: { + instanceId: '', + searchIndexType: SearchIndexType.REDIS_QUERY_ENGINE, + sampleDataType: SampleDataType.PRESET_DATA, + dataContent: SampleDataContent.E_COMMERCE_DISCOVERY, + usePresetVectorIndex: true, + indexName: PresetDataType.BIKES, + indexFields: selectedBikesIndexFields, + }, + setParameters: mockSetParameters, +} + +describe('AddDataStep', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render all search index type options', () => { + render() + + expect(screen.getByText('Redis Query Engine')).toBeInTheDocument() + expect( + screen.getByText('For advanced, large-scale search needs'), + ).toBeInTheDocument() + expect(screen.getByText('Vector Set')).toBeInTheDocument() + expect( + screen.getByText('For quick and simple vector use cases'), + ).toBeInTheDocument() + }) + + it('should render sample dataset section', () => { + render() + + expect(screen.getByText('Select sample dataset')).toBeInTheDocument() + expect(screen.getByText('Pre-set data')).toBeInTheDocument() + expect(screen.getByText('Custom data')).toBeInTheDocument() + }) + + it('should render data content section', () => { + render() + + expect(screen.getByText('Data content')).toBeInTheDocument() + expect(screen.getByText('E-commerce Discovery')).toBeInTheDocument() + expect(screen.getByText('Movie Recommendations')).toBeInTheDocument() + }) + + describe('Search Index Type Selection', () => { + it('should call setParameters with Redis Query Engine when clicked', () => { + render() + + const redisQueryEngineOption = screen + .getByText('Redis Query Engine') + .closest('div') + fireEvent.click(redisQueryEngineOption!) + + expect(mockSetParameters).toHaveBeenCalledWith({ + searchIndexType: SearchIndexType.REDIS_QUERY_ENGINE, + }) + }) + + it('should not call setParameters when Vector Set (disabled) is clicked', () => { + render() + + const vectorSetOption = screen.getByText('Vector Set').closest('div') + fireEvent.click(vectorSetOption!) + + // Disabled options should not trigger onClick + expect(mockSetParameters).not.toHaveBeenCalled() + }) + + it('should show "Coming soon" text for Vector Set option', () => { + render() + + const comingSoonTexts = screen.getAllByText('Coming soon') + expect(comingSoonTexts.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Sample Dataset Selection', () => { + it('should not call setParameters when preset data is clicked (already selected by default)', () => { + render() + + const presetDataRadio = screen.getByLabelText('Pre-set data') + fireEvent.click(presetDataRadio) + + // Since preset data is already selected by default, clicking it won't trigger onChange + expect(mockSetParameters).not.toHaveBeenCalled() + }) + + it('should expect custom data to be disabled', () => { + render() + + const customDataRadio = screen.getByLabelText('Custom data') + expect(customDataRadio).toBeDisabled() + }) + + it('should have preset data selected by default', () => { + render() + + const presetDataRadio = screen.getByLabelText('Pre-set data') + expect(presetDataRadio).toBeChecked() + }) + }) + + describe('Data Content Selection', () => { + it('should call setParameters with E-commerce Discovery when clicked', () => { + render() + + const eCommerceOption = screen + .getByText('E-commerce Discovery') + .closest('div') + fireEvent.click(eCommerceOption!) + + expect(mockSetParameters).toHaveBeenCalledWith({ + dataContent: SampleDataContent.E_COMMERCE_DISCOVERY, + }) + }) + + it('should not call setParameters when Movie Recommendations (disabled) is clicked', () => { + render() + + const movieRecommendationsOption = screen + .getByText('Movie Recommendations') + .closest('div') + fireEvent.click(movieRecommendationsOption!) + + // Disabled options should not trigger onClick + expect(mockSetParameters).not.toHaveBeenCalled() + }) + + it('should show "Coming soon" text for disabled options', () => { + render() + + // There should be 3 "Coming soon" texts - Vector Set, and Movie Recommendations + const comingSoonTexts = screen.getAllByText('Coming soon') + expect(comingSoonTexts).toHaveLength(2) + }) + }) + + describe('Default Values', () => { + it('should have preset data selected by default in radio group', () => { + render() + + const presetDataRadio = screen.getByLabelText('Pre-set data') + expect(presetDataRadio).toBeChecked() + + const customDataRadio = screen.getByLabelText('Custom data') + expect(customDataRadio).not.toBeChecked() + }) + + it('should render with default values set in BoxSelectionGroups', () => { + render() + + // The BoxSelectionGroups have defaultValue set, but we can't easily test the internal state + // Instead, we verify that the components render without errors and show the expected content + expect(screen.getByText('Redis Query Engine')).toBeInTheDocument() + expect(screen.getByText('E-commerce Discovery')).toBeInTheDocument() + }) + }) + + describe('Component Structure', () => { + it('should render three main sections', () => { + render() + + // Search index type section (no explicit title, but has options) + expect(screen.getByText('Redis Query Engine')).toBeInTheDocument() + + // Sample dataset section + expect(screen.getByText('Select sample dataset')).toBeInTheDocument() + + // Data content section + expect(screen.getByText('Data content')).toBeInTheDocument() + }) + + it('should render all icons for search index types', () => { + render() + + // Icons are rendered as SVG elements, we can check for their presence + const svgElements = screen.getAllByRole('img', { hidden: true }) + expect(svgElements.length).toBeGreaterThan(0) + }) + }) + + describe('Accessibility', () => { + it('should have proper radio group for sample dataset', () => { + render() + + const radioGroup = screen.getByRole('radiogroup') + expect(radioGroup).toBeInTheDocument() + }) + + it('should have proper labels for radio buttons', () => { + render() + + expect(screen.getByLabelText('Pre-set data')).toBeInTheDocument() + expect(screen.getByLabelText('Custom data')).toBeInTheDocument() + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/create-index/steps/AddDataStep.tsx b/redisinsight/ui/src/pages/vector-search/create-index/steps/AddDataStep.tsx new file mode 100644 index 0000000000..a5a7a0418a --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/steps/AddDataStep.tsx @@ -0,0 +1,55 @@ +import React from 'react' + +import { FlexItem } from 'uiSrc/components/base/layout/flex' +import { Text } from 'uiSrc/components/base/text' +import { RiRadioGroup } from 'uiSrc/components/base/forms/radio-group/RadioGroup' + +import { + LargeSelectionBox, + SmallSelectionBox, + StyledBoxSelectionGroup, +} from './styles' +import { indexDataContent, indexType, sampleDatasetOptions } from './config' +import { IStepComponent, SampleDataType, StepComponentProps } from '../types' + +export const AddDataStep: IStepComponent = ({ + parameters, + setParameters, +}: StepComponentProps) => ( + <> + + + {indexType.map((type) => ( + setParameters({ searchIndexType: type.value })} + /> + ))} + + + + Select sample dataset + + setParameters({ sampleDataType: id as SampleDataType }) + } + /> + + + Data content + + {indexDataContent.map((type) => ( + setParameters({ dataContent: type.value })} + /> + ))} + + + +) diff --git a/redisinsight/ui/src/pages/vector-search/create-index/steps/CreateIndexStep.spec.tsx b/redisinsight/ui/src/pages/vector-search/create-index/steps/CreateIndexStep.spec.tsx new file mode 100644 index 0000000000..22f0473db9 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/steps/CreateIndexStep.spec.tsx @@ -0,0 +1,211 @@ +import React from 'react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' + +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { CreateIndexStep } from './CreateIndexStep' +import { selectedBikesIndexFields } from './config' +import { + SearchIndexType, + SampleDataType, + SampleDataContent, + PresetDataType, + StepComponentProps, +} from '../types' + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +// Workaround for @redis-ui/components Title component issue with react-children-utilities +// TypeError: react_utils.childrenToString is not a function +jest.mock('uiSrc/components/base/layout/drawer', () => ({ + ...jest.requireActual('uiSrc/components/base/layout/drawer'), + DrawerHeader: jest.fn().mockReturnValue(null), +})) + +const mockSetParameters = jest.fn() + +const defaultProps: StepComponentProps = { + setParameters: mockSetParameters, + parameters: { + instanceId: 'test-instance', + searchIndexType: SearchIndexType.REDIS_QUERY_ENGINE, + sampleDataType: SampleDataType.PRESET_DATA, + dataContent: SampleDataContent.E_COMMERCE_DISCOVERY, + usePresetVectorIndex: true, + indexName: PresetDataType.BIKES, + indexFields: selectedBikesIndexFields, + }, +} + +describe('CreateIndexStep', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render the main heading and description', () => { + render() + + expect(screen.getByText('Vector index')).toBeInTheDocument() + expect( + screen.getByText( + 'Indexes tell Redis how to search your data. Creating an index enables fast, accurate retrieval across your dataset.', + ), + ).toBeInTheDocument() + }) + + it('should render the index name input field', () => { + render() + + expect(screen.getByText('Index name')).toBeInTheDocument() + expect(screen.getByTestId('search-for-index')).toBeInTheDocument() + expect(screen.getByDisplayValue(PresetDataType.BIKES)).toBeInTheDocument() + }) + + it('should render the index name input as disabled', () => { + render() + + const indexNameInput = screen.getByTestId('search-for-index') + expect(indexNameInput).toBeDisabled() + }) + + it('should render the command preview button', () => { + render() + + expect(screen.getByText('Command preview')).toBeInTheDocument() + }) + + it('should render the tab labels', () => { + render() + + expect(screen.getByText('Use preset index')).toBeInTheDocument() + // Build new index tab should be present but disabled + expect(screen.getByText('Build new index')).toBeInTheDocument() + }) + + it('should render field boxes for the bikes index', () => { + render() + + // Check for some of the expected field labels from bikesIndexFieldsBoxes + expect(screen.getByText('id')).toBeInTheDocument() + expect(screen.getByText('description')).toBeInTheDocument() + expect(screen.getByText('price')).toBeInTheDocument() + expect(screen.getByText('name')).toBeInTheDocument() + expect(screen.getByText('category')).toBeInTheDocument() + expect(screen.getByText('embedding')).toBeInTheDocument() + }) + + it('should render field descriptions', () => { + render() + + // Check for some field descriptions + expect(screen.getByText('Unique product identifier')).toBeInTheDocument() + expect(screen.getByText('Product description')).toBeInTheDocument() + expect(screen.getByText('Product name')).toBeInTheDocument() + expect(screen.getByText('Product category')).toBeInTheDocument() + }) + + it('should render field type tags', () => { + render() + + // Check for field type tags (these appear as text content) + expect(screen.getAllByText('TAG')).toHaveLength(2) // id and category fields + expect(screen.getAllByText('TEXT')).toHaveLength(2) // description and name fields + expect(screen.getAllByText('NUMERIC')).toHaveLength(2) // price and price_1 fields + expect(screen.getAllByText('VECTOR')).toHaveLength(2) // embedding and embedding_1 fields + }) + + it('should not render the preview command drawer by default', () => { + render() + + // The drawer should not be visible initially + expect(screen.queryByText('Command Preview')).not.toBeInTheDocument() + }) + + describe('Component Structure', () => { + it('should render the main wrapper', () => { + const { container } = render() + + // Check that the main component structure is present + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render field boxes group', () => { + render() + + // The FieldBoxesGroup should be rendered (we can check by looking for its test id) + expect(screen.getByTestId('field-boxes-group')).toBeInTheDocument() + }) + }) + + describe('Default Values', () => { + it('should display the correct index name from parameters', () => { + render() + + const indexNameInput = screen.getByTestId('search-for-index') + expect(indexNameInput).toHaveValue(PresetDataType.BIKES) + }) + + it('should render with all expected field boxes', () => { + render() + + // Verify all 8 field boxes are rendered (from bikesIndexFieldsBoxes config) + const fieldLabels = [ + 'id', + 'description', + 'price', + 'price_1', + 'name', + 'category', + 'embedding', + 'embedding_1', + ] + + fieldLabels.forEach((label) => { + expect(screen.getByText(label)).toBeInTheDocument() + }) + }) + }) + + describe('Accessibility', () => { + it('should have proper input labeling', () => { + render() + + const indexNameInput = screen.getByTestId('search-for-index') + expect(indexNameInput).toHaveAttribute('placeholder', 'Search for index') + }) + + it('should have proper button text', () => { + render() + + const commandPreviewButton = screen.getByText('Command preview') + expect(commandPreviewButton).toBeInTheDocument() + }) + }) + + describe('Telemetry', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should send telemetry event when opening the command preview', async () => { + render() + + const commandPreviewButton = screen.getByText('Command preview') + expect(commandPreviewButton).toBeInTheDocument() + + fireEvent.click(commandPreviewButton) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_VIEW_COMMAND_PREVIEW, + eventData: { databaseId: defaultProps.parameters.instanceId }, + }) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/create-index/steps/CreateIndexStep.tsx b/redisinsight/ui/src/pages/vector-search/create-index/steps/CreateIndexStep.tsx new file mode 100644 index 0000000000..51680f07a9 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/steps/CreateIndexStep.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react' + +import { FlexGroup, FlexItem } from 'uiSrc/components/base/layout/flex' +import { Text } from 'uiSrc/components/base/text' +import CreateIndexStepWrapper, { + IndexStepTab, +} from 'uiSrc/components/new-index/create-index-step' +import { FieldBoxesGroup } from 'uiSrc/components/new-index/create-index-step/field-boxes-group/FieldBoxesGroup' +import { VectorSearchBox } from 'uiSrc/components/new-index/create-index-step/field-box/types' +import { generateFtCreateCommand } from 'uiSrc/utils/index/generateFtCreateCommand' +import { EmptyButton } from 'uiSrc/components/base/forms/buttons' +import { VectorIndexTab } from 'uiSrc/components/new-index/create-index-step/CreateIndexStepWrapper' +import { BuildNewIndexTabTrigger } from 'uiSrc/components/new-index/create-index-step/build-new-index-tab/BuildNewIndexTabTrigger' +import { TextInput } from 'uiSrc/components/base/inputs' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import { PlayFilledIcon } from 'uiSrc/components/base/icons' +import { bikesIndexFieldsBoxes } from './config' +import { CreateIndexStepScreenWrapper, SearchInputWrapper } from './styles' +import { PreviewCommandDrawer } from './PreviewCommandDrawer' +import { IStepComponent, StepComponentProps } from '../types' + +// eslint-disable-next-line arrow-body-style, @typescript-eslint/no-unused-vars +const useIndexFieldsBoxes = (_indexName: string): VectorSearchBox[] => { + return bikesIndexFieldsBoxes +} + +export const CreateIndexStep: IStepComponent = ({ + parameters, + setParameters, +}: StepComponentProps) => { + const indexFieldsBoxes = useIndexFieldsBoxes(parameters.indexName) + const [isDrawerOpen, setIsDrawerOpen] = useState(false) + + const indexFieldsTabs: IndexStepTab[] = [ + { + value: VectorIndexTab.BuildNewIndex, + label: , + disabled: true, + }, + { + value: VectorIndexTab.UsePresetIndex, + label: 'Use preset index', + disabled: false, + content: ( + <> + + + Index name + setParameters({ indexName: value })} + data-testid="search-for-index" + /> + + + setParameters({ indexFields: value })} + /> + + ), + }, + ] + + const handlePreviewCommandClick = () => { + setIsDrawerOpen(true) + sendEventTelemetry({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_VIEW_COMMAND_PREVIEW, + eventData: { + databaseId: parameters.instanceId, + }, + }) + } + + return ( + + + + Vector index + + Indexes tell Redis how to search your data. Creating an index + enables fast, accurate retrieval across your dataset. + + + + + + Command preview + + + + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/create-index/steps/PreviewCommandDrawer.tsx b/redisinsight/ui/src/pages/vector-search/create-index/steps/PreviewCommandDrawer.tsx new file mode 100644 index 0000000000..608b5fe4d0 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/steps/PreviewCommandDrawer.tsx @@ -0,0 +1,36 @@ +import React from 'react' + +import { CodeBlock } from 'uiSrc/components' +import { + Drawer, + DrawerHeader, + DrawerBody, + DrawerFooter, +} from 'uiSrc/components/base/layout/drawer' + +import { CodeBlocKWrapper } from './styles' + +type PreviewCommandDrawerProps = { + commandContent: React.ReactNode + isOpen: boolean + onOpenChange: (value: boolean) => void +} + +export const PreviewCommandDrawer = ({ + commandContent, + isOpen, + onOpenChange, +}: PreviewCommandDrawerProps) => ( + + + + + {commandContent} + + + onOpenChange(false)} + /> + +) diff --git a/redisinsight/ui/src/pages/vector-search/create-index/steps/SelectDatabaseStep.tsx b/redisinsight/ui/src/pages/vector-search/create-index/steps/SelectDatabaseStep.tsx new file mode 100644 index 0000000000..4252c3deac --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/steps/SelectDatabaseStep.tsx @@ -0,0 +1,3 @@ +import { IStepComponent } from '../types' + +export const SelectDatabaseStep: IStepComponent = () => null diff --git a/redisinsight/ui/src/pages/vector-search/create-index/steps/config.ts b/redisinsight/ui/src/pages/vector-search/create-index/steps/config.ts new file mode 100644 index 0000000000..d765cb27ae --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/steps/config.ts @@ -0,0 +1,124 @@ +import { FieldTypes } from 'uiSrc/pages/browser/components/create-redisearch-index/constants' +import { VectorSearchBox } from 'uiSrc/components/new-index/create-index-step/field-box/types' +import { BoxSelectionOption } from 'uiSrc/components/new-index/selection-box/SelectionBox' +import { + BikeIcon, + PopcornIcon, + VectorSearchIcon, + WandIcon, +} from 'uiSrc/components/base/icons' + +import { SearchIndexType, SampleDataType, SampleDataContent } from '../types' + +// ** Add data step */ + +export const indexType: BoxSelectionOption[] = [ + { + value: SearchIndexType.REDIS_QUERY_ENGINE, + label: 'Redis Query Engine', + text: 'For advanced, large-scale search needs', + icon: VectorSearchIcon, + }, + { + value: SearchIndexType.VECTOR_SET, + label: 'Vector Set', + text: 'For quick and simple vector use cases', + icon: WandIcon, + disabled: true, + }, +] + +export const sampleDatasetOptions = [ + { + id: SampleDataType.PRESET_DATA, + value: SampleDataType.PRESET_DATA, + label: 'Pre-set data', + }, + { + id: SampleDataType.CUSTOM_DATA, + value: SampleDataType.CUSTOM_DATA, + label: 'Custom data', + disabled: true, + }, +] + +export const indexDataContent: BoxSelectionOption[] = [ + { + value: SampleDataContent.E_COMMERCE_DISCOVERY, + label: 'E-commerce Discovery', + text: 'Find products by meaning, not just keywords.', + icon: BikeIcon, + }, + { + value: SampleDataContent.CONTENT_RECOMMENDATIONS, + label: 'Movie Recommendations', + text: 'Suggest movies based on the true meaning of plots or themes.', + icon: PopcornIcon, + disabled: true, + }, +] + +// ** Create index step */ + +export const bikesIndexFieldsBoxes: VectorSearchBox[] = [ + { + value: 'id', + label: 'id', + text: 'Unique product identifier', + tag: FieldTypes.TAG, + disabled: true, + }, + { + value: 'description', + label: 'description', + text: 'Product description', + tag: FieldTypes.TEXT, + disabled: true, + }, + { + value: 'price', + label: 'price', + text: 'Product price', + tag: FieldTypes.NUMERIC, + disabled: true, + }, + { + value: 'price_1', + label: 'price_1', + text: 'Product price', + tag: FieldTypes.NUMERIC, + disabled: true, + }, + { + value: 'name', + label: 'name', + text: 'Product name', + tag: FieldTypes.TEXT, + disabled: true, + }, + { + value: 'category', + label: 'category', + text: 'Product category', + tag: FieldTypes.TAG, + disabled: true, + }, + { + value: 'embedding', + label: 'embedding', + text: 'Product embedding vector', + tag: FieldTypes.VECTOR, + disabled: true, + }, + { + value: 'embedding_1', + label: 'embedding_1', + text: 'Product embedding vector', + tag: FieldTypes.VECTOR, + disabled: true, + }, +] + +export const selectedBikesIndexFields = bikesIndexFieldsBoxes.map( + (field) => field.value, +) diff --git a/redisinsight/ui/src/pages/vector-search/create-index/steps/index.ts b/redisinsight/ui/src/pages/vector-search/create-index/steps/index.ts new file mode 100644 index 0000000000..149f776442 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/steps/index.ts @@ -0,0 +1,6 @@ +import { SelectDatabaseStep } from './SelectDatabaseStep' +import { AddDataStep } from './AddDataStep' +import { CreateIndexStep } from './CreateIndexStep' + +export * from './config' +export const stepContents = [SelectDatabaseStep, AddDataStep, CreateIndexStep] diff --git a/redisinsight/ui/src/pages/vector-search/create-index/steps/styles.ts b/redisinsight/ui/src/pages/vector-search/create-index/steps/styles.ts new file mode 100644 index 0000000000..e320484367 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/steps/styles.ts @@ -0,0 +1,45 @@ +import { BoxSelectionGroup } from '@redis-ui/components' +import styled from 'styled-components' +import { SelectionBox } from 'uiSrc/components/new-index/selection-box' + +export const SmallSelectionBox = styled(SelectionBox)` + max-width: 228px; +` + +export const LargeSelectionBox = styled(SelectionBox)` + max-width: 424px; +` + +export const StyledBoxSelectionGroup = styled(BoxSelectionGroup.Compose)` + text-align: center; + justify-content: flex-start; +` + +export const SearchInputWrapper = styled.div` + max-width: 600px; + display: flex; +` + +export const CreateIndexStepScreenWrapper = styled.div` + display: flex; + flex-direction: column; + border: 1px solid; + border-color: ${({ theme }) => theme.color.dusk200}; + padding: ${({ theme }) => theme.core.space.space300}; + border-radius: ${({ theme }) => theme.core.space.space100}; +` +export const CodeBlocKWrapper = styled.div` + overflow: auto; + height: 100%; + padding: 0; + /* TODO: Remove this when can be styled with styled() */ + padding-top: ${({ theme }) => theme.core.space.space100}; + + border: 1px solid; + border-color: ${({ theme }) => theme.color.dusk200}; + border-radius: 8px; + + background: ${({ theme }) => theme.color.dusk100}; + word-wrap: break-word; + white-space: break-spaces; +` diff --git a/redisinsight/ui/src/pages/vector-search/create-index/types.ts b/redisinsight/ui/src/pages/vector-search/create-index/types.ts new file mode 100644 index 0000000000..8053f579a2 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/create-index/types.ts @@ -0,0 +1,42 @@ +export enum SearchIndexType { + REDIS_QUERY_ENGINE = 'redis_query_engine', + VECTOR_SET = 'vector_set', +} + +export enum SampleDataType { + PRESET_DATA = 'preset_data', + CUSTOM_DATA = 'custom_data', +} + +export enum SampleDataContent { + E_COMMERCE_DISCOVERY = 'e-commerce-discovery', + CONTENT_RECOMMENDATIONS = 'content-recommendations', +} + +export enum PresetDataType { + BIKES = 'bikes', +} + +export type CreateSearchIndexParameters = { + // Select a database step + instanceId: string + + // Adding data step + searchIndexType: SearchIndexType + sampleDataType: SampleDataType + dataContent: SampleDataContent + + // Create index step + usePresetVectorIndex: boolean + indexName: string + indexFields: string[] +} + +export type StepComponentProps = { + setParameters: (params: Partial) => void + parameters: CreateSearchIndexParameters +} + +export interface IStepComponent { + (props: StepComponentProps): JSX.Element | null +} diff --git a/redisinsight/ui/src/pages/vector-search/index.ts b/redisinsight/ui/src/pages/vector-search/index.ts new file mode 100644 index 0000000000..af1dc4d48a --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/index.ts @@ -0,0 +1,3 @@ +export { default as VectorSearchCreateIndexPage } from './pages/VectorSearchCreateIndexPage' +export { default as VectorSearchPage } from './pages/VectorSearchPage' +export { default as VectorSearchPageRouter } from './pages/VectorSearchPageRouter' diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/DeleteConfirmationButton.spec.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/DeleteConfirmationButton.spec.tsx new file mode 100644 index 0000000000..0db673972e --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/DeleteConfirmationButton.spec.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import userEvent from '@testing-library/user-event' +import DeleteConfirmationButton from './DeleteConfirmationButton' +import { render, screen } from 'uiSrc/utils/test-utils' + +describe('DeleteConfirmationButton', () => { + it('renders the trigger IconButton', () => { + const onConfirm = jest.fn() + render() + + const trigger = screen.getByTestId('manage-index-delete-btn') + expect(trigger).toBeInTheDocument() + }) + + it('opens the popover on trigger click and shows content', async () => { + const user = userEvent.setup() + const onConfirm = jest.fn() + render() + + expect( + screen.queryByText(/are you sure you want to delete this index\?/i), + ).not.toBeInTheDocument() + + await user.click(screen.getByTestId('manage-index-delete-btn')) + + expect( + screen.getByText(/are you sure you want to delete this index\?/i), + ).toBeInTheDocument() + + const deleteBtn = screen.getByTestId('manage-index-delete-confirmation-btn') + expect(deleteBtn).toBeInTheDocument() + expect(deleteBtn).toHaveTextContent(/delete/i) + }) + + it('calls onConfirm when the Delete button is clicked', async () => { + const onConfirm = jest.fn() + + render() + + // In JSDOM, Popover content may be rendered with `pointer-events: none` + // or `visibility: hidden` due to missing layout measurements. + // This disables userEvent's pointer-events check so the button can be clicked in tests. + const user = userEvent.setup({ pointerEventsCheck: 0 }) + + await user.click(screen.getByTestId('manage-index-delete-btn')) + const deleteBtn = await screen.findByTestId( + 'manage-index-delete-confirmation-btn', + ) + await user.click(deleteBtn) + + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('forwards RiPopover props via ...rest and keeps hardcoded ones', async () => { + const user = userEvent.setup() + const onConfirm = jest.fn() + + render( + , + ) + + await user.click(screen.getByTestId('manage-index-delete-btn')) + + const popoverRoot = document.querySelector('#test-id-manage-index') + expect(popoverRoot).not.toBeNull() + + expect( + screen.getByText(/are you sure you want to delete this index\?/i), + ).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/DeleteConfirmationButton.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/DeleteConfirmationButton.tsx new file mode 100644 index 0000000000..1911d9f34c --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/DeleteConfirmationButton.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { RiPopover, RiPopoverProps } from 'uiSrc/components' +import { Button, IconButton } from 'uiSrc/components/base/forms/buttons' +import { + ButtonWrapper, + IconAndTitleWrapper, + IconWrapper, + PopoverContent, + Title, +} from './styles' +import { DeleteIcon, RiIcon } from 'uiSrc/components/base/icons' + +export type DeleteConfirmationButtonProps = Omit< + RiPopoverProps, + 'children' | 'button' +> & { + onConfirm: () => void +} + +const DeleteConfirmationButton = ({ + onConfirm, + ...rest +}: DeleteConfirmationButtonProps) => ( + + } + > + + + + + + + + Are you sure you want to delete this index? + + + + + + + + +) + +export default DeleteConfirmationButton diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexAttributesList.spec.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexAttributesList.spec.tsx new file mode 100644 index 0000000000..bd82d5143e --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexAttributesList.spec.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import { cleanup, render, screen } from 'uiSrc/utils/test-utils' +import { + indexInfoAttributeFactory, + indexInfoFactory, +} from 'uiSrc/mocks/factories/redisearch/IndexInfo.factory' +import { + IndexAttributesList, + IndexAttributesListProps, +} from './IndexAttributesList' + +const renderComponent = (props?: Partial) => { + const defaultProps: IndexAttributesListProps = { + indexInfo: indexInfoFactory.build(), + } + + return render() +} + +describe('IndexAttributesList', () => { + beforeEach(() => { + cleanup() + }) + + it('should render', () => { + const props: IndexAttributesListProps = { + indexInfo: indexInfoFactory.build(), + } + + const { container } = renderComponent(props) + expect(container).toBeTruthy() + + const list = screen.getByTestId('index-attributes-list') + expect(list).toBeInTheDocument() + + const table = screen.getByTestId('index-attributes-list--table') + const summaryInfo = screen.getByTestId( + 'index-attributes-list--summary-info', + ) + + expect(table).toBeInTheDocument() + expect(summaryInfo).toBeInTheDocument() + }) + + it('should render loader when index info is not provided', () => { + renderComponent({ indexInfo: undefined }) + + const loader = screen.getByTestId('index-attributes-list--loader') + expect(loader).toBeInTheDocument() + }) + + it('should render index attributes in the table', () => { + const mockIndexAttribute = indexInfoAttributeFactory.build( + {}, + { transient: { includeWeight: true, includeNoIndex: true } }, + ) + + const props: IndexAttributesListProps = { + indexInfo: indexInfoFactory.build({ + attributes: [mockIndexAttribute], + }), + } + + const { container } = renderComponent(props) + expect(container).toBeTruthy() + + const list = screen.getByTestId('index-attributes-list') + expect(list).toBeInTheDocument() + + // Verify data is rendered correctly + const identifier = screen.getByText(mockIndexAttribute.identifier) + const attribute = screen.getByText(mockIndexAttribute.attribute) + const type = screen.getByText(mockIndexAttribute.type) + const weight = screen.getByText(mockIndexAttribute.WEIGHT!) + const noIndex = screen.getByTestId('index-attributes-list--noindex-icon') + + expect(identifier).toBeInTheDocument() + expect(attribute).toBeInTheDocument() + expect(type).toBeInTheDocument() + expect(weight).toBeInTheDocument() + expect(noIndex).toBeInTheDocument() + expect(noIndex).toHaveAttribute( + 'data-attribute', + mockIndexAttribute.NOINDEX?.toString(), + ) + }) + + it('should display index summary info', () => { + const mockIndexInfo = indexInfoFactory.build() + + const props: IndexAttributesListProps = { + indexInfo: mockIndexInfo, + } + + renderComponent(props) + + const summaryInfo = screen.getByTestId( + 'index-attributes-list--summary-info', + ) + expect(summaryInfo).toBeInTheDocument() + + // Verify Number of documents + const numberOfDocumentLabel = screen.getByText(/Number of docs:/) + const numberOfDocumentValue = screen.getByText( + new RegExp(mockIndexInfo.num_docs), + ) + expect(numberOfDocumentLabel).toBeInTheDocument() + expect(numberOfDocumentValue).toBeInTheDocument() + + // Verify Max document ID + const maxDocumentIdLabel = screen.getByText(/max/) + const maxDocumentIdValue = screen.getByText( + new RegExp(mockIndexInfo.max_doc_id!), + ) + expect(maxDocumentIdLabel).toBeInTheDocument() + expect(maxDocumentIdValue).toBeInTheDocument() + + // Verify Number of records + const numberOfRecordsLabel = screen.getByText(/Number of records:/) + const numberOfRecordsValue = screen.getByText( + new RegExp(mockIndexInfo.num_records!), + ) + expect(numberOfRecordsLabel).toBeInTheDocument() + expect(numberOfRecordsValue).toBeInTheDocument() + + // Verify Number of terms + const numberOfTermsLabel = screen.getByText(/Number of terms:/) + const numberOfTermsValue = screen.getByText( + new RegExp(mockIndexInfo.num_terms!), + ) + expect(numberOfTermsLabel).toBeInTheDocument() + expect(numberOfTermsValue).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexAttributesList.styles.ts b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexAttributesList.styles.ts new file mode 100644 index 0000000000..f114e15dd7 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexAttributesList.styles.ts @@ -0,0 +1,16 @@ +import styled from 'styled-components' + +export const StyledIndexAttributesList = styled.div` + display: flex; + gap: ${({ theme }) => theme.core.space.space150}; + flex-direction: column; +` + +export const StyledIndexAttributesTable = styled.div` + // Drawer width (60rem) minus its padding (2 * 3.2rem), we don't have them as variables in Redis UI + width: calc(60rem - 2 * 3.2rem); +` + +export const StyledIndexSummaryInfo = styled.div` + font-size: ${({ theme }) => theme.core.font.fontSize.s12}; +` diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexAttributesList.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexAttributesList.tsx new file mode 100644 index 0000000000..9b0b2d4fcc --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexAttributesList.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import { ColumnDefinition, Table } from 'uiSrc/components/base/layout/table' +import { RiIcon } from 'uiSrc/components/base/icons' +import { Loader } from 'uiSrc/components/base/display' +import { IndexInfoDto } from 'apiSrc/modules/browser/redisearch/dto' +import { + StyledIndexAttributesList, + StyledIndexAttributesTable, + StyledIndexSummaryInfo, +} from './IndexAttributesList.styles' + +export interface IndexInfoTableData { + identifier: string + attribute: string + type: string + weight?: string + noindex?: boolean +} + +const tableColumns: ColumnDefinition[] = [ + { + header: 'Identifier', + id: 'identifier', + accessorKey: 'identifier', + }, + { + header: 'Attribute', + id: 'attribute', + accessorKey: 'attribute', + }, + { + header: 'Type', + id: 'type', + accessorKey: 'type', + enableSorting: false, + }, + { + header: 'Weight', + id: 'weight', + accessorKey: 'weight', + enableSorting: false, + }, + { + header: 'Noindex', + id: 'noindex', + accessorKey: 'noindex', + enableSorting: false, + cell: ({ row }) => ( + + ), + }, +] + +export interface IndexAttributesListProps { + indexInfo: IndexInfoDto | undefined +} + +export const IndexAttributesList = ({ + indexInfo, +}: IndexAttributesListProps) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { num_docs, max_doc_id, num_records, num_terms } = indexInfo || {} + + if (!indexInfo) { + return + } + + return ( + + + + + + +

+ Number of docs: {num_docs} (max {max_doc_id}) | Number of records:{' '} + {num_records} | Number of terms: {num_terms} +

+
+ + ) +} + +const parseIndexAttributes = (indexInfo: IndexInfoDto): IndexInfoTableData[] => + indexInfo.attributes.map((field) => ({ + identifier: field.identifier, + attribute: field.attribute, + type: field.type, + weight: field.WEIGHT, + noindex: field.NOINDEX ?? true, + })) diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexSection.spec.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexSection.spec.tsx new file mode 100644 index 0000000000..e91a53b2f7 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexSection.spec.tsx @@ -0,0 +1,391 @@ +import React from 'react' +import { Provider } from 'react-redux' +import { rest } from 'msw' +import { configureStore, combineReducers } from '@reduxjs/toolkit' +import { mswServer } from 'uiSrc/mocks/server' +import { + cleanup, + render, + screen, + initialStateDefault, + userEvent, + getMswURL, + waitForRiPopoverVisible, + fireEvent, +} from 'uiSrc/utils/test-utils' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' +import Notifications from 'uiSrc/components/notifications' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { ApiEndpoints } from 'uiSrc/constants' +import { getUrl } from 'uiSrc/utils' +import { RootState } from 'uiSrc/slices/store' +import notificationsReducer from 'uiSrc/slices/app/notifications' +import appInfoReducer from 'uiSrc/slices/app/info' +import redisearchReducer from 'uiSrc/slices/browser/redisearch' +import instancesReducer from 'uiSrc/slices/instances/instances' +import { + indexInfoAttributeFactory, + indexInfoFactory, +} from 'uiSrc/mocks/factories/redisearch/IndexInfo.factory' +import { IndexInfoDto } from 'apiSrc/modules/browser/redisearch/dto' +import { IndexSection, IndexSectionProps } from './IndexSection' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const createTestStore = () => { + // TODO: Use rootReducer instead, once you realize how to solve the issue with the instancesReducer + // console.error No reducer provided for key "instances" + // > 81 | connections: combineReducers({ + const testReducer = combineReducers({ + app: combineReducers({ + notifications: notificationsReducer, + info: appInfoReducer, + }), + browser: combineReducers({ + redisearch: redisearchReducer, + }), + connections: combineReducers({ + instances: instancesReducer, + }), + }) + + const testState: RootState = { + ...initialStateDefault, + + connections: { + ...initialStateDefault.connections, + instances: { + ...initialStateDefault.connections.instances, + connectedInstance: { + ...initialStateDefault.connections.instances.connectedInstance, + id: INSTANCE_ID_MOCK, + name: 'test-instance', + host: 'localhost', + port: 6379, + modules: [], + }, + }, + }, + } + + return configureStore({ + reducer: testReducer, + preloadedState: testState, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ serializableCheck: false }), + }) +} + +const renderComponent = (props?: Partial) => { + const defaultProps: IndexSectionProps = { + index: 'test-index', + } + + const store = createTestStore() + + return render( + + + + , + ) +} + +describe('IndexSection', () => { + beforeEach(() => { + cleanup() + jest.clearAllMocks() + }) + + afterEach(() => { + mswServer.resetHandlers() + }) + + it('should render', async () => { + const props: IndexSectionProps = { + index: 'test-index', + } + + const { container } = renderComponent(props) + expect(container).toBeTruthy() + + const section = screen.getByTestId( + `manage-indexes-list--item--${props.index}`, + ) + expect(section).toBeInTheDocument() + + // Verify index name is formatted correctly + const indexName = screen.getByText('test-index') + expect(indexName).toBeInTheDocument() + }) + + it('should display index summary when collapsed', async () => { + const mockIndexInfo = indexInfoFactory.build() + const props: IndexSectionProps = { + index: mockIndexInfo.index_name, + } + + // Override the MSW handler to return an error for this test + mswServer.use( + rest.post( + getMswURL(getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH_INFO)), + async (_req, res, ctx) => res(ctx.status(200), ctx.json(mockIndexInfo)), + ), + ) + + renderComponent(props) + + const section = screen.getByTestId( + `manage-indexes-list--item--${props.index}`, + ) + expect(section).toBeInTheDocument() + + // Verify index name is formatted correctly + const indexName = screen.getByText(mockIndexInfo.index_name) + expect(indexName).toBeInTheDocument() + + // Verify the index summary info is displayed + const recordsLabel = screen.getByText('Records') + const recordsValue = await screen.findByText(mockIndexInfo.num_records!) + + expect(recordsLabel).toBeInTheDocument() + expect(recordsValue).toBeInTheDocument() + + const termsLabel = screen.getByText('Terms') + const termsValue = await screen.findByText(mockIndexInfo.num_terms!) + + expect(termsLabel).toBeInTheDocument() + expect(termsValue).toBeInTheDocument() + + const fieldsLabel = screen.getByText('Fields') + const fieldsValue = await screen.findByText( + mockIndexInfo.attributes.length.toString(), + ) + + expect(fieldsLabel).toBeInTheDocument() + expect(fieldsValue).toBeInTheDocument() + }) + + it('should display index attributes when expanded', async () => { + const mockIndexAttribute = indexInfoAttributeFactory.build( + {}, + { transient: { includeWeight: true, includeNoIndex: true } }, + ) + const mockIndexInfo = indexInfoFactory.build({ + attributes: [mockIndexAttribute], + }) + const props: IndexSectionProps = { + index: mockIndexInfo.index_name, + } + + // Override the MSW handler to return an error for this test + mswServer.use( + rest.post( + getMswURL(getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH_INFO)), + async (_req, res, ctx) => res(ctx.status(200), ctx.json(mockIndexInfo)), + ), + ) + + const { container } = renderComponent(props) + + // Verify index name is formatted correctly + const indexName = screen.getByText(props.index as string) + expect(indexName).toBeInTheDocument() + + // Click to expand the section + await userEvent.click(indexName) + + // Verify the index attributes are displayed + const identifier = await screen.findByText('Identifier') + const attribute = await screen.findByText('Attribute') + const type = await screen.findByText('Type') + const weight = await screen.findByText('Weight') + const noindex = await screen.findByText('Noindex') + + expect(identifier).toBeInTheDocument() + expect(attribute).toBeInTheDocument() + expect(type).toBeInTheDocument() + expect(weight).toBeInTheDocument() + expect(noindex).toBeInTheDocument() + + // Verify that data rows are rendered + const regularRows = container.querySelectorAll( + 'tr[data-row-type="regular"]', + ) + expect(regularRows.length).toBe(mockIndexInfo.attributes.length) + + // Verify their values as well + const identifierValue = await screen.findByText( + mockIndexAttribute.identifier, + ) + const attributeValue = await screen.findByText(mockIndexAttribute.attribute) + const typeValue = await screen.findAllByText(mockIndexAttribute.type) + const weightValue = await screen.findAllByText(mockIndexAttribute.WEIGHT!) + const noIndexValue = await screen.findAllByTestId( + 'index-attributes-list--noindex-icon', + ) + + expect(identifierValue).toBeInTheDocument() + expect(attributeValue).toBeInTheDocument() + expect(typeValue[0]).toBeInTheDocument() + expect(weightValue[0]).toBeInTheDocument() + expect(noIndexValue[0]).toBeInTheDocument() + }) + + it('should send telemetry when expanding and collapsing the section information', async () => { + const mockIndexInfo = indexInfoFactory.build() + const props: IndexSectionProps = { + index: mockIndexInfo.index_name, + } + + renderComponent(props) + + const section = screen.getByTestId( + `manage-indexes-list--item--${props.index}`, + ) + expect(section).toBeInTheDocument() + + // Verify we start with collapsed section with summary info and no index details + const indexSummaryInitial = screen.getByText('Records') + const indexDetailsInitial = screen.queryByText('Identifier') + + expect(indexSummaryInitial).toBeInTheDocument() + expect(indexDetailsInitial).not.toBeInTheDocument() + + // Click to expand the section + const indexName = screen.getByText(mockIndexInfo.index_name) + expect(indexName).toBeInTheDocument() + + fireEvent.click(indexName) + + // Verify the index attributes are displayed and + const indexDetailsExpanded = await screen.findByText('Identifier') + expect(indexDetailsExpanded).toBeInTheDocument() + + // Verify the telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_MANAGE_INDEX_DETAILS_OPENED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + }, + }) + + // Click again to collapse the section + fireEvent.click(indexName) + + // Verify the index summary info is displayed again + const indexSummaryVisible = screen.getByText('Records') + const indexDetailsCollapsed = screen.queryByText('Identifier') + + expect(indexSummaryVisible).toBeInTheDocument() + expect(indexDetailsCollapsed).not.toBeInTheDocument() + + // Verify the telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_MANAGE_INDEX_DETAILS_CLOSED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + }, + }) + }) + + describe('delete index', () => { + let telemetryMock: jest.Mock + + beforeEach(() => { + // Mock the telemetry function + telemetryMock = sendEventTelemetry as jest.Mock + telemetryMock.mockClear() + }) + + afterEach(() => { + mswServer.resetHandlers() + }) + + it('should delete an index when the delete button is clicked and confirmed', async () => { + renderComponent() + + // Trigger confirm dialog + const deleteButton = screen.getByTestId('manage-index-delete-btn') + expect(deleteButton).toBeInTheDocument() + fireEvent.click(deleteButton) + + // Confirm dialog is visible + await waitForRiPopoverVisible() + expect( + screen.getByText('Are you sure you want to delete this index?'), + ).toBeInTheDocument() + + // Confirm actual delete + const confirmDeleteButton = screen.getByTestId( + 'manage-index-delete-confirmation-btn', + ) + expect(confirmDeleteButton).toBeInTheDocument() + fireEvent.click(confirmDeleteButton) + + // Wait for the success notification to appear + const successNotification = await screen.findByText( + 'Index has been deleted', + ) + expect(successNotification).toBeInTheDocument() + + // Verify the telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_MANAGE_INDEX_DELETED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + }, + }) + }) + + it('should handle deletion failure gracefully', async () => { + // Override the MSW handler to return an error for this test + mswServer.use( + rest.delete( + getMswURL(getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH)), + async (_req, res, ctx) => + res( + ctx.status(500), + ctx.json({ + error: 'Internal Server Error', + statusCode: 500, + message: 'Failed to delete index', + }), + ), + ), + ) + + renderComponent() + + // Trigger confirm dialog + const deleteButton = screen.getByTestId('manage-index-delete-btn') + expect(deleteButton).toBeInTheDocument() + fireEvent.click(deleteButton) + + // Confirm dialog is visible + await waitForRiPopoverVisible() + expect( + screen.getByText('Are you sure you want to delete this index?'), + ).toBeInTheDocument() + + // Confirm actual delete + const confirmDeleteButton = screen.getByTestId( + 'manage-index-delete-confirmation-btn', + ) + expect(confirmDeleteButton).toBeInTheDocument() + fireEvent.click(confirmDeleteButton) + + // Wait for the error notification to appear + const errorNotification = await screen.findByText( + 'Failed to delete index', + ) + expect(errorNotification).toBeInTheDocument() + + // Verify that telemetry event was not sent on error + expect(telemetryMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexSection.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexSection.tsx new file mode 100644 index 0000000000..52e80ee37d --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/IndexSection.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react' +import { CategoryValueList, Section, SectionProps } from '@redis-ui/components' +import { useDispatch, useSelector } from 'react-redux' +import { CategoryValueListItem } from '@redis-ui/components/dist/Section/components/Header/components/CategoryValueList' +import { RedisString } from 'uiSrc/slices/interfaces' +import { bufferToString, formatLongName, stringToBuffer } from 'uiSrc/utils' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { + deleteRedisearchIndexAction, + fetchRedisearchInfoAction, +} from 'uiSrc/slices/browser/redisearch' +import { + IndexInfoDto, + IndexDeleteRequestBodyDto, +} from 'apiSrc/modules/browser/redisearch/dto' +import { IndexAttributesList } from './IndexAttributesList' +import { + collectManageIndexesDeleteTelemetry, + collectManageIndexesDetailsToggleTelemetry, +} from '../telemetry' +import DeleteConfirmationButton from './DeleteConfirmationButton' + +export interface IndexSectionProps extends Omit { + index: RedisString +} + +export const IndexSection = ({ index, ...rest }: IndexSectionProps) => { + const dispatch = useDispatch() + const indexName = bufferToString(index) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const { id: instanceId } = useSelector(connectedInstanceSelector) + + const [indexInfo, setIndexInfo] = useState() + const [indexSummaryInfo, setIndexSummaryInfo] = useState< + CategoryValueListItem[] + >(parseIndexSummaryInfo({} as IndexInfoDto)) + + useEffect(() => { + dispatch( + fetchRedisearchInfoAction(indexName, (data) => { + const indexInfo = data as unknown as IndexInfoDto + + setIndexInfo(indexInfo) + setIndexSummaryInfo(parseIndexSummaryInfo(indexInfo)) + }), + ) + }, [indexName, dispatch]) + + const handleDelete = () => { + const data: IndexDeleteRequestBodyDto = { + index: stringToBuffer(indexName), + } + + dispatch(deleteRedisearchIndexAction(data, onDeletedIndexSuccess)) + } + + const onDeletedIndexSuccess = () => { + collectManageIndexesDeleteTelemetry({ + instanceId, + }) + } + + const handleOpenChange = (open: boolean) => { + collectManageIndexesDetailsToggleTelemetry({ + instanceId, + isOpen: open, + }) + } + + // TODO: Add FieldTag component to list the types of the different fields + return ( + } + content={} + // TODO: Add FieldTag component to list the types of the different fields + defaultOpen={false} + onOpenChange={handleOpenChange} + onAction={() => setIsPopoverOpen(true)} + data-testid={`manage-indexes-list--item--${indexName}`} + {...rest} + > + + } + > + + + + + setIsPopoverOpen(true)}> + setIsPopoverOpen(false)} + onConfirm={handleDelete} + /> + + + + + } /> + + ) +} + +const parseIndexSummaryInfo = ( + indexInfo: IndexInfoDto, +): CategoryValueListItem[] => [ + { + category: 'Records', + value: indexInfo?.num_records?.toString() || '', + key: 'num_records', + }, + { + category: 'Terms', + value: indexInfo?.num_terms?.toString() || '', + key: 'num_terms', + }, + { + category: 'Fields', + value: indexInfo?.attributes?.length.toString() || '', + key: 'num_fields', + }, + // TODO: Date info not available in IndexInfoDto + // { + // category: 'Date', + // value: '', + // key: 'date', + // }, +] diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesDrawer.spec.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesDrawer.spec.tsx new file mode 100644 index 0000000000..8febc3e241 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesDrawer.spec.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react' +import { cleanup, fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { + ManageIndexesDrawer, + ManageIndexesDrawerProps, +} from './ManageIndexesDrawer' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' + +// Workaround for @redis-ui/components Title component issue with react-children-utilities +// TypeError: react_utils.childrenToString is not a function +jest.mock('uiSrc/components/base/layout/drawer', () => ({ + ...jest.requireActual('uiSrc/components/base/layout/drawer'), + DrawerHeader: jest.fn().mockReturnValue(null), +})) + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const MockDrawer = ({ open, ...rest }: Partial) => { + const [isDrawerOpen, setIsDrawerOpen] = useState(open ?? true) + + return ( + <> + + + + + ) +} + +const renderComponent = (props?: Partial) => { + const defaultProps: ManageIndexesDrawerProps = { + open: true, + onOpenChange: jest.fn(), + } + + return render() +} + +describe('ManageIndexesDrawer', () => { + beforeEach(() => { + cleanup() + jest.clearAllMocks() + }) + + it('should render', () => { + const { container } = renderComponent() + + expect(container).toBeTruthy() + + const drawer = screen.getByTestId('manage-indexes-drawer') + expect(drawer).toBeInTheDocument() + + // Note: Since we mocked DrawerHeader, we can't check its presence + // const header = screen.getByText('Manage indexes') + // expect(header).toBeInTheDocument() + + const body = screen.getByTestId('manage-indexes-drawer-body') + expect(body).toBeInTheDocument() + + const list = screen.getByTestId('manage-indexes-list') + expect(list).toBeInTheDocument() + }) + + describe('Telemetry', () => { + it('should send telemetry event on drawer open', async () => { + renderComponent({ open: false }) + + // Click the toggle button to open the drawer + const toggleButton = screen.getByTestId('toggle-drawer') + fireEvent.click(toggleButton) + + // Simulate the animation lifecycle so Drawer fires didOpen (dirty hack) + const dialog = screen.getByRole('dialog') + fireEvent.animationStart(dialog) + fireEvent.animationEnd(dialog) + + const drawer = screen.getByTestId('manage-indexes-drawer') + expect(drawer).toBeInTheDocument() + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_MANAGE_INDEXES_DRAWER_OPENED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + }, + }) + }) + + it('should send telemetry event on drawer close', async () => { + renderComponent({ open: true }) + + const openDrawer = screen.getByTestId('manage-indexes-drawer') + expect(openDrawer).toBeInTheDocument() + + // Click the toggle button to open the drawer + const toggleButton = screen.getByTestId('toggle-drawer') + + // Dialog stays mounted but hidden during exit + // const closingDialog = screen.getByRole('dialog', { hidden: true }) + + // Simulate the animation lifecycle so Drawer fires didClose + fireEvent.click(toggleButton) + // fireEvent.animationStart(closingDialog) + // fireEvent.animationEnd(closingDialog) + + // Note: For some reason, the dirty hackwith the animated dialog is not working here and the onDidCLosed is not trigerred in the tests + // await waitFor(() => + // expect(sendEventTelemetry).toHaveBeenCalledWith({ + // event: TelemetryEvent.SEARCH_MANAGE_INDEXES_DRAWER_CLOSED, + // eventData: { databaseId: INSTANCE_ID_MOCK }, + // }), + // ) + + // Verify the drawer is no longer open + const closedDrawer = screen.queryByTestId('manage-indexes-drawer') + expect(closedDrawer).not.toBeInTheDocument() + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesDrawer.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesDrawer.tsx new file mode 100644 index 0000000000..b7490f6b19 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesDrawer.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { DrawerProps } from '@redis-ui/components' +import { useParams } from 'react-router-dom' +import { + Drawer, + DrawerBody, + DrawerHeader, +} from 'uiSrc/components/base/layout/drawer' +import { ManageIndexesList } from './ManageIndexesList' +import { + collectManageIndexesDrawerClosedTelemetry, + collectManageIndexesDrawerOpenedTelemetry, +} from '../telemetry' + +export interface ManageIndexesDrawerProps extends DrawerProps {} + +export const ManageIndexesDrawer = ({ + open, + onOpenChange, + ...rest +}: ManageIndexesDrawerProps) => { + const { instanceId } = useParams<{ instanceId: string }>() + + const onDrawerDidOpen = () => { + collectManageIndexesDrawerOpenedTelemetry({ + instanceId, + }) + } + + const onDrawerDidClose = () => { + collectManageIndexesDrawerClosedTelemetry({ + instanceId, + }) + } + + return ( + + + + + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesList.spec.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesList.spec.tsx new file mode 100644 index 0000000000..85794c08d1 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesList.spec.tsx @@ -0,0 +1,162 @@ +import React from 'react' +import { cleanup, render, screen } from 'uiSrc/utils/test-utils' +import { + redisearchListSelector, + fetchRedisearchListAction, +} from 'uiSrc/slices/browser/redisearch' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { ManageIndexesList } from './ManageIndexesList' + +jest.mock('uiSrc/slices/browser/redisearch', () => ({ + ...jest.requireActual('uiSrc/slices/browser/redisearch'), + redisearchListSelector: jest.fn().mockReturnValue({ + data: [], + loading: false, + error: '', + }), + fetchRedisearchListAction: jest + .fn() + .mockReturnValue({ type: 'FETCH_REDISEARCH_LIST' }), +})) + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + id: 'test-instance-123', + connectionType: 'STANDALONE', + host: 'localhost', + modules: [{ name: 'search' }], + db: 0, + }), +})) + +const renderComponent = () => render() + +describe('ManageIndexesList', () => { + beforeEach(() => { + cleanup() + jest.clearAllMocks() + + // Reset mocks to default state before each test + ;(redisearchListSelector as jest.Mock).mockReturnValue({ + data: [], + loading: false, + error: '', + }) + ;(connectedInstanceSelector as jest.Mock).mockReturnValue({ + id: 'test-instance-123', + connectionType: 'STANDALONE', + host: 'localhost', + modules: [{ name: 'search' }], + db: 0, + }) + }) + + it('should render', () => { + const { container } = renderComponent() + + expect(container).toBeTruthy() + + const list = screen.getByTestId('manage-indexes-list') + expect(list).toBeInTheDocument() + }) + + it('should render Loader spinner while fetching data', async () => { + ;(redisearchListSelector as jest.Mock).mockReturnValue({ + data: [], + loading: true, + error: '', + }) + + renderComponent() + + const loader = await screen.findByTestId('manage-indexes-list--loader') + expect(loader).toBeInTheDocument() + }) + + it('should render indexes boxes when data is available', () => { + const mockIndexes = [ + Buffer.from('test-index-1'), + Buffer.from('test-index-2'), + ] + + ;(redisearchListSelector as jest.Mock).mockReturnValue({ + data: mockIndexes, + loading: false, + error: '', + }) + + renderComponent() + + // Make sure the loader is not present + const loader = screen.queryByTestId('manage-indexes-list--loader') + expect(loader).not.toBeInTheDocument() + + // Check if each index is rendered + const items = screen.getAllByTestId(/^manage-indexes-list--item--/) + expect(items.length).toBe(mockIndexes.length) + + mockIndexes.forEach((index) => { + const indexName = index.toString() + const indexElement = screen.getByTestId( + `manage-indexes-list--item--${indexName}`, + ) + + expect(indexElement).toBeInTheDocument() + }) + }) + + it('should not render indexes boxes when there is no instanceHost', () => { + // Mock connectedInstanceSelector to return no host + // TODO: Potential candidate for a factory function to create mock instances + ;(connectedInstanceSelector as jest.Mock).mockReturnValue({ + id: 'test-instance-123', + connectionType: 'STANDALONE', + host: null, // No host means no instanceId + modules: [{ name: 'search' }], + db: 0, + }) + + renderComponent() + + // Verify that fetchRedisearchListAction was not called + expect(fetchRedisearchListAction).not.toHaveBeenCalled() + + // Should still render the container but no data/loader + const list = screen.getByTestId('manage-indexes-list') + expect(list).toBeInTheDocument() + + const loader = screen.queryByTestId('manage-indexes-list--loader') + expect(loader).not.toBeInTheDocument() + + const items = screen.queryAllByTestId(/^manage-indexes-list--item--/) + expect(items.length).toBe(0) + }) + + it('should not render indexes boxes when redisearch module is not available', () => { + // Mock connectedInstanceSelector to return modules without redisearch + // TODO: Potential candidate for a factory function to create mock instances + ;(connectedInstanceSelector as jest.Mock).mockReturnValue({ + id: 'test-instance-123', + connectionType: 'STANDALONE', + host: 'localhost', + modules: [{ name: 'timeseries' }, { name: 'graph' }], // No search/redisearch module + db: 0, + }) + + renderComponent() + + // Verify that fetchRedisearchListAction was not called + expect(fetchRedisearchListAction).not.toHaveBeenCalled() + + // Should still render the container but no data/loader + const list = screen.getByTestId('manage-indexes-list') + expect(list).toBeInTheDocument() + + const loader = screen.queryByTestId('manage-indexes-list--loader') + expect(loader).not.toBeInTheDocument() + + const items = screen.queryAllByTestId(/^manage-indexes-list--item--/) + expect(items.length).toBe(0) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesList.styles.ts b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesList.styles.ts new file mode 100644 index 0000000000..bb07604c17 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesList.styles.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components' +import { FlexGroup } from 'uiSrc/components/base/layout/flex' + +export const StyledManageIndexesListAction = styled(FlexGroup)` + flex-direction: column; + gap: ${({ theme }) => theme.core.space.space150}; +` diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesList.tsx b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesList.tsx new file mode 100644 index 0000000000..13038a4185 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/ManageIndexesList.tsx @@ -0,0 +1,21 @@ +import { Loader } from '@redis-ui/components' +import React from 'react' + +import { bufferToString } from 'uiSrc/utils' +import { StyledManageIndexesListAction } from './ManageIndexesList.styles' +import { IndexSection } from './IndexSection' +import { useRedisearchListData } from '../useRedisearchListData' + +export const ManageIndexesList = () => { + const { data, loading } = useRedisearchListData() + + return ( + + {loading && } + + {data.map((index) => ( + + ))} + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/manage-indexes/styles.ts b/redisinsight/ui/src/pages/vector-search/manage-indexes/styles.ts new file mode 100644 index 0000000000..4ee6799ac0 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/manage-indexes/styles.ts @@ -0,0 +1,28 @@ +import styled from 'styled-components' +import { Text } from 'uiSrc/components/base/text' + +export const PopoverContent = styled.div` + padding: ${({ theme }) => theme.core?.space.space200}; +` + +export const Title = styled(Text)` + margin-top: ${({ theme }) => theme.core?.space.space100}; + margin-bottom: ${({ theme }) => theme.core?.space.space100}; + font-weight: bold; + color: ${({ theme }) => theme.color.danger500}; +` + +export const IconWrapper = styled.div` + text-align: center; +` +export const ButtonWrapper = styled.div` + margin-top: ${({ theme }) => theme.core?.space.space100}; + display: flex; + justify-content: flex-end; +` + +export const IconAndTitleWrapper = styled.div` + display: flex; + gap: ${({ theme }) => theme.core?.space.space100}; + align-items: center; +` diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage.spec.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage.spec.tsx new file mode 100644 index 0000000000..fe009e9b91 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage.spec.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +import { cleanup, render } from 'uiSrc/utils/test-utils' +import VectorSearchCreateIndexPage from './VectorSearchCreateIndexPage' + +const renderVectorSearchCreateIndexPageComponent = () => + render() + +describe('VectorSearchCreateIndexPage', () => { + beforeEach(() => { + cleanup() + }) + + it('should render ', () => { + const { container } = renderVectorSearchCreateIndexPageComponent() + + expect(container).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage.tsx new file mode 100644 index 0000000000..6ec1a91060 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { TelemetryPageView } from 'uiSrc/telemetry' +import { usePageViewTelemetry } from 'uiSrc/telemetry/usePageViewTelemetry' + +import { VectorSearchCreateIndex } from './../create-index/VectorSearchCreateIndex' +import { VectorSearchPageWrapper } from './../styles' + +const VectorSearchCreateIndexPage = () => { + usePageViewTelemetry({ + page: TelemetryPageView.VECTOR_SEARCH_PAGE, + }) + + return ( + + + + ) +} + +export default VectorSearchCreateIndexPage diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchPage.spec.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchPage.spec.tsx new file mode 100644 index 0000000000..a6fd46f63e --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchPage.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import * as reactRedux from 'react-redux' +import { cleanup, render } from 'uiSrc/utils/test-utils' +import { TelemetryPageView } from 'uiSrc/telemetry/pageViews' +import { sendPageViewTelemetry } from 'uiSrc/telemetry' +import { + INSTANCE_ID_MOCK, + INSTANCES_MOCK, +} from 'uiSrc/mocks/handlers/instances/instancesHandlers' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { redisearchListSelector } from 'uiSrc/slices/browser/redisearch' +import VectorSearchPage from './VectorSearchPage' + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendPageViewTelemetry: jest.fn(), +})) + +const renderVectorSearchPageComponent = () => render() + +describe('VectorSearchPage', () => { + beforeEach(() => { + cleanup() + }) + + it('should render ', () => { + const { container } = renderVectorSearchPageComponent() + + expect(container).toBeTruthy() + }) + + describe('Telemetry', () => { + let mockUseSelector: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + + mockUseSelector = jest.spyOn(reactRedux, 'useSelector') + mockUseSelector.mockImplementation((selector) => { + if (selector === connectedInstanceSelector) { + return INSTANCES_MOCK[0] + } + if (selector === redisearchListSelector) { + return { + loading: false, + data: [], + } + } + // Default fallback for other selectors + return { + loading: false, + spec: {}, + commandsArray: [], + commandGroups: [], + } + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should send page view telemetry on mount', () => { + renderVectorSearchPageComponent() + + expect(sendPageViewTelemetry).toHaveBeenCalledTimes(1) + expect(sendPageViewTelemetry).toHaveBeenCalledWith({ + name: TelemetryPageView.VECTOR_SEARCH_PAGE, + eventData: { databaseId: INSTANCE_ID_MOCK }, + }) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchPage.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchPage.tsx new file mode 100644 index 0000000000..5a015b2ca9 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchPage.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { TelemetryPageView } from 'uiSrc/telemetry' +import { usePageViewTelemetry } from 'uiSrc/telemetry/usePageViewTelemetry' + +import { VectorSearchQuery } from './../query/VectorSearchQuery' +import { VectorSearchPageWrapper } from './../styles' + +const VectorSearchPage = () => { + usePageViewTelemetry({ + page: TelemetryPageView.VECTOR_SEARCH_PAGE, + }) + + // TODO: Set title, once we know the name of the page + // setTitle( + // `${formatLongName(connectedInstanceName, 33, 0, '...')} ${getDbIndex(db)} - Vector Search`, + // ) + + return ( + + + + ) +} + +export default VectorSearchPage diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchPageRouter.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchPageRouter.tsx new file mode 100644 index 0000000000..d67667108a --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchPageRouter.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Switch } from 'react-router-dom' +import RouteWithSubRoutes from 'uiSrc/utils/routerWithSubRoutes' +import { IRoute } from 'uiSrc/constants' + +export interface Props { + routes: IRoute[] +} +const VectorSearchPageRouter = ({ routes }: Props) => ( + + {routes.map((route, i) => ( + + ))} + +) + +export default React.memo(VectorSearchPageRouter) diff --git a/redisinsight/ui/src/pages/vector-search/query/HeaderActions.spec.tsx b/redisinsight/ui/src/pages/vector-search/query/HeaderActions.spec.tsx new file mode 100644 index 0000000000..b45b75dff9 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/HeaderActions.spec.tsx @@ -0,0 +1,128 @@ +import React from 'react' +import { cleanup, render, screen, userEvent } from 'uiSrc/utils/test-utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import { HeaderActions, HeaderActionsProps } from './HeaderActions' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' + +// Workaround for @redis-ui/components Title component issue with react-children-utilities +// TypeError: react_utils.childrenToString is not a function +jest.mock('uiSrc/components/base/layout/drawer', () => ({ + ...jest.requireActual('uiSrc/components/base/layout/drawer'), + DrawerHeader: jest.fn().mockReturnValue(null), +})) + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const mockProps: HeaderActionsProps = { + isManageIndexesDrawerOpen: false, + setIsManageIndexesDrawerOpen: jest.fn(), + isSavedQueriesOpen: false, + setIsSavedQueriesOpen: jest.fn(), +} + +const renderComponent = (props = mockProps) => + render() + +describe('HeaderActions', () => { + beforeEach(() => { + cleanup() + jest.clearAllMocks() + }) + + it('should render', () => { + const { container } = renderComponent() + + expect(container).toBeTruthy() + + const headerActions = screen.getByTestId('vector-search-header-actions') + expect(headerActions).toBeInTheDocument() + + // Verify the presence of the actions + const savedQueriesButton = screen.getByText('Saved queries') + expect(savedQueriesButton).toBeInTheDocument() + + const manageIndexesButton = screen.getByText('Manage indexes') + expect(manageIndexesButton).toBeInTheDocument() + }) + + it('should call setIsSavedQueriesOpen when "Saved queries" is clicked', async () => { + const mockSetIsSavedQueriesOpen = jest.fn() + renderComponent({ + ...mockProps, + setIsSavedQueriesOpen: mockSetIsSavedQueriesOpen, + }) + + const savedQueriesButton = screen.getByText('Saved queries') + await userEvent.click(savedQueriesButton) + + expect(mockSetIsSavedQueriesOpen).toHaveBeenCalledWith(true) + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_SAVED_QUERIES_PANEL_OPENED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + }, + }) + }) + + it('should call setIsSavedQueriesOpen with false when "Saved queries" is clicked and isSavedQueriesOpen is true', async () => { + const mockSetIsSavedQueriesOpen = jest.fn() + renderComponent({ + ...mockProps, + isSavedQueriesOpen: true, + setIsSavedQueriesOpen: mockSetIsSavedQueriesOpen, + }) + + const savedQueriesButton = screen.getByText('Saved queries') + await userEvent.click(savedQueriesButton) + + expect(mockSetIsSavedQueriesOpen).toHaveBeenCalledWith(false) + + // Verify telemetry event is sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_SAVED_QUERIES_PANEL_CLOSED, + eventData: { + databaseId: INSTANCE_ID_MOCK, + }, + }) + }) + + it('should call setIsManageIndexesDrawerOpen when "Manage indexes" is clicked', async () => { + const mockSetIsManageIndexesDrawerOpen = jest.fn() + renderComponent({ + ...mockProps, + setIsManageIndexesDrawerOpen: mockSetIsManageIndexesDrawerOpen, + }) + + const manageIndexesButton = screen.getByText('Manage indexes') + await userEvent.click(manageIndexesButton) + + expect(mockSetIsManageIndexesDrawerOpen).toHaveBeenCalledWith(true) + }) + + it('should render ManageIndexesDrawer when isManageIndexesDrawerOpen is true', () => { + renderComponent({ + ...mockProps, + isManageIndexesDrawerOpen: true, + }) + + const drawer = screen.getByTestId('manage-indexes-drawer') + expect(drawer).toBeInTheDocument() + }) + + it('should not render ManageIndexesDrawer when isManageIndexesDrawerOpen is false', () => { + renderComponent({ + ...mockProps, + isManageIndexesDrawerOpen: false, + }) + + const drawer = screen.queryByTestId('manage-indexes-drawer') + expect(drawer).not.toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/query/HeaderActions.styles.ts b/redisinsight/ui/src/pages/vector-search/query/HeaderActions.styles.ts new file mode 100644 index 0000000000..21e41b58ce --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/HeaderActions.styles.ts @@ -0,0 +1,13 @@ +import styled from 'styled-components' +import { FlexGroup } from 'uiSrc/components/base/layout/flex' + +export const StyledHeaderAction = styled(FlexGroup)` + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: ${({ theme }) => theme.core.space.space100}; +` + +export const StyledWrapper = styled(FlexGroup)` + margin-bottom: ${({ theme }) => theme.core.space.space100}; +` diff --git a/redisinsight/ui/src/pages/vector-search/query/HeaderActions.tsx b/redisinsight/ui/src/pages/vector-search/query/HeaderActions.tsx new file mode 100644 index 0000000000..fdc5ee2354 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/HeaderActions.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { useParams } from 'react-router-dom' + +import { StyledHeaderAction, StyledWrapper } from './HeaderActions.styles' +import { ManageIndexesDrawer } from '../manage-indexes/ManageIndexesDrawer' +import { collectSavedQueriesPanelToggleTelemetry } from '../telemetry' +import { StartWizardButton } from './StartWizardButton' +import { EmptyButton } from 'uiSrc/components/base/forms/buttons' + +export type HeaderActionsProps = { + isManageIndexesDrawerOpen: boolean + setIsManageIndexesDrawerOpen: (value: boolean) => void + isSavedQueriesOpen: boolean + setIsSavedQueriesOpen: (value: boolean) => void +} + +export const HeaderActions = ({ + isManageIndexesDrawerOpen, + setIsManageIndexesDrawerOpen, + isSavedQueriesOpen, + setIsSavedQueriesOpen, +}: HeaderActionsProps) => { + const { instanceId } = useParams<{ instanceId: string }>() + + const handleSavedQueriesClick = () => { + setIsSavedQueriesOpen(!isSavedQueriesOpen) + + collectSavedQueriesPanelToggleTelemetry({ + instanceId, + isSavedQueriesOpen, + }) + } + + return ( + + + + + + Saved queries + + setIsManageIndexesDrawerOpen(true)}> + Manage indexes + + + + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/query/StartWizardButton.spec.tsx b/redisinsight/ui/src/pages/vector-search/query/StartWizardButton.spec.tsx new file mode 100644 index 0000000000..5804bb92d2 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/StartWizardButton.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { cleanup, render, screen, userEvent } from 'uiSrc/utils/test-utils' +import { Pages } from 'uiSrc/constants' + +import { StartWizardButton } from './StartWizardButton' + +const renderComponent = () => render() + +describe('StartWizardButton', () => { + beforeEach(() => { + cleanup() + jest.clearAllMocks() + }) + + it('should navigate to vector search create index page when "Get started" is clicked', async () => { + const mockPush = jest.fn() + + const useHistoryMock = jest.spyOn(require('react-router-dom'), 'useHistory') + useHistoryMock.mockImplementation(() => ({ + push: mockPush, + })) + + renderComponent() + + const getStartedButton = screen.getByText('Get started') + await userEvent.click(getStartedButton) + + expect(mockPush).toHaveBeenCalledWith( + Pages.vectorSearchCreateIndex('instanceId'), + ) + expect(mockPush).toHaveBeenCalledTimes(1) + + useHistoryMock.mockRestore() + }) + + it('should maintain callback reference stability with useCallback', () => { + const { rerender } = renderComponent() + + const firstRenderButton = screen.getByText('Get started') + + rerender() + + const secondRenderButton = screen.getByText('Get started') + + // Both buttons should exist (they're the same element after rerender) + expect(firstRenderButton).toBeInTheDocument() + expect(secondRenderButton).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/query/StartWizardButton.tsx b/redisinsight/ui/src/pages/vector-search/query/StartWizardButton.tsx new file mode 100644 index 0000000000..d787c8d172 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/StartWizardButton.tsx @@ -0,0 +1,27 @@ +import React, { useCallback } from 'react' +import { useHistory, useParams } from 'react-router-dom' +import { CallOut } from 'uiSrc/components/base/display/call-out/CallOut' +import { Pages } from 'uiSrc/constants' + +export const StartWizardButton = () => { + const history = useHistory() + const { instanceId } = useParams<{ instanceId: string }>() + + const startCreateIndexWizard = useCallback(() => { + history.push(Pages.vectorSearchCreateIndex(instanceId)) + }, [history, instanceId]) + + return ( + + Power fast, real-time semantic AI search with vector search. + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.spec.tsx b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.spec.tsx new file mode 100644 index 0000000000..3fb16ecb8d --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.spec.tsx @@ -0,0 +1,135 @@ +import React from 'react' +import { faker } from '@faker-js/faker' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { TelemetryEvent } from 'uiSrc/telemetry/events' +import { sendEventTelemetry } from 'uiSrc/telemetry' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' +import { VectorSearchQuery } from './VectorSearchQuery' + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const renderVectorSearchQueryComponent = () => render() + +describe('VectorSearchQuery', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render correctly', () => { + const { container } = renderVectorSearchQueryComponent() + + expect(container).toBeTruthy() + expect(container).toBeInTheDocument() + }) + + describe('Telemetry', () => { + it('should collect telemetry when inserting a saved query', () => { + renderVectorSearchQueryComponent() + + // Open the saved queries screen + const savedQueriesButton = screen.getByText('Saved queries') + expect(savedQueriesButton).toBeInTheDocument() + + fireEvent.click(savedQueriesButton) + + // Select a saved query + const insertQueryButton = screen.getAllByTestId('btn-insert-query')[0] + expect(insertQueryButton).toBeInTheDocument() + + fireEvent.click(insertQueryButton) + + // Verify telemetry event was sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_SAVED_QUERIES_INSERT_CLICKED, + eventData: { databaseId: INSTANCE_ID_MOCK }, + }) + }) + + // TODO: We have hardocked mockSavedIndexes with only one index, so we cannot test index change telemetry at the moment + it.skip('should collect telemetry when changing the index for the saved queries', () => { + renderVectorSearchQueryComponent() + + // Open the saved queries screen + const savedQueriesButton = screen.getByText('Saved queries') + expect(savedQueriesButton).toBeInTheDocument() + + fireEvent.click(savedQueriesButton) + + // Change the index in the select dropdown + const selectSavedIndex = screen.getByTestId('select-saved-index') + expect(selectSavedIndex).toBeInTheDocument() + + // TODO: Replace with a valid index value from mockSavedIndexes once we have more than one index + fireEvent.change(selectSavedIndex, { + target: { value: faker.string.uuid() }, + }) + + // Verify telemetry event was sent + expect(sendEventTelemetry).toHaveBeenCalledTimes(2) + expect(sendEventTelemetry).toHaveBeenNthCalledWith(2, { + event: TelemetryEvent.SEARCH_SAVED_QUERIES_INDEX_CHANGED, + eventData: { databaseId: INSTANCE_ID_MOCK }, + }) + }) + + it('should collect telemetry on query submit', () => { + const mockQuery = faker.lorem.sentence() + + renderVectorSearchQueryComponent() + + // Enter a dummy query + const queryInput = screen.getByTestId('monaco') + fireEvent.change(queryInput, { target: { value: mockQuery } }) + + // Find and click the "Run" button + const runQueryButton = screen.getByText('Run') + expect(runQueryButton).toBeInTheDocument() + + fireEvent.click(runQueryButton) + + // Verify telemetry event was sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_COMMAND_SUBMITTED, + eventData: { databaseId: INSTANCE_ID_MOCK, commands: [mockQuery] }, + }) + }) + + // Note: Enable this test once you implement the other tests and find a way to render the component with items + it.skip('should collect telemetry on clear results', () => { + // TODO: Find a way to mock the items in the useQuery hook, so we have what to clear + renderVectorSearchQueryComponent() + + // Find and click the "Clear Results" button + const clearResultsButton = screen.getByText('Clear Results') + expect(clearResultsButton).toBeInTheDocument() + + fireEvent.click(clearResultsButton) + + // Verify telemetry event was sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_CLEAR_ALL_RESULTS_CLICKED, + eventData: { databaseId: INSTANCE_ID_MOCK }, + }) + }) + + it('should collect telemetry on query clear', () => { + renderVectorSearchQueryComponent() + + // Find and click the "Clear" button + const clearButton = screen.getByTestId('btn-clear') + expect(clearButton).toBeInTheDocument() + + fireEvent.click(clearButton) + + // Verify telemetry event was sent + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_CLEAR_EDITOR_CLICKED, + eventData: { databaseId: INSTANCE_ID_MOCK }, + }) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.styles.ts b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.styles.ts new file mode 100644 index 0000000000..8e2f38e5af --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.styles.ts @@ -0,0 +1,10 @@ +import styled from 'styled-components' + +export const StyledNoResultsWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + align-items: center; + justify-content: center; +` diff --git a/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.tsx b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.tsx new file mode 100644 index 0000000000..bd5f2b9b08 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.tsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react' +import { useParams } from 'react-router-dom' +import { + ResizableContainer, + ResizablePanel, + ResizablePanelHandle, +} from 'uiSrc/components/base/layout' +import QueryWrapper from 'uiSrc/pages/workbench/components/query' +import { HIDE_FIELDS } from 'uiSrc/components/query/query-card/QueryCardHeader/QueryCardHeader' +import { StyledNoResultsWrapper } from './VectorSearchQuery.styles' +import { useQuery } from './useQuery' +import { HeaderActions } from './HeaderActions' +import CommandsViewWrapper from '../components/commands-view' +import { VectorSearchScreenWrapper } from '../styles' +import { SavedQueriesScreen } from '../saved-queries/SavedQueriesScreen' +import { SavedIndex } from '../saved-queries/types' +import { + collectChangedSavedQueryIndexTelemetry, + collectInsertSavedQueryTelemetry, + collectTelemetryQueryClear, + collectTelemetryQueryClearAll, + collectTelemetryQueryRun, +} from '../telemetry' +import { + ViewMode, + ViewModeContextProvider, +} from 'uiSrc/components/query/context/view-mode.context' + +const mockSavedIndexes: SavedIndex[] = [ + { + value: 'idx:bikes_vss', + tags: ['tag', 'text', 'vector'], + queries: [ + { + label: 'Search for "Nord" bikes ordered by price', + value: 'FT.SEARCH idx:bikes_vss "@brand:Nord" SORTBY price ASC', + }, + { + label: 'Find road alloy bikes under 20kg', + value: 'FT.SEARCH idx:bikes_vss "@material:{alloy} @weight:[0 20]"', + }, + ], + }, +] + +export const VectorSearchQuery = () => { + const { + query, + setQuery, + items, + clearing, + processing, + isResultsLoaded, + activeMode, + resultsMode, + scrollDivRef, + onSubmit, + onQueryOpen, + onQueryDelete, + onAllQueriesDelete, + onQueryChangeMode, + onChangeGroupMode, + onQueryReRun, + onQueryProfile, + } = useQuery() + const { instanceId } = useParams<{ instanceId: string }>() + + const [isSavedQueriesOpen, setIsSavedQueriesOpen] = useState(false) + const [isManageIndexesDrawerOpen, setIsManageIndexesDrawerOpen] = + useState(false) + const [queryIndex, setQueryIndex] = useState(mockSavedIndexes[0].value) + const selectedIndex = mockSavedIndexes.find( + (index) => index.value === queryIndex, + ) + + const onQuerySubmit = () => { + onSubmit() + collectTelemetryQueryRun({ + instanceId, + query, + }) + } + + const handleClearResults = () => { + onAllQueriesDelete() + collectTelemetryQueryClearAll({ + instanceId, + }) + } + + const onQueryClear = () => { + collectTelemetryQueryClear({ instanceId }) + } + + const handleIndexChange = (value: string) => { + setQueryIndex(value) + + collectChangedSavedQueryIndexTelemetry({ + instanceId, + }) + } + + const handleQueryInsert = (query: string) => { + setQuery(query) + + collectInsertSavedQueryTelemetry({ + instanceId, + }) + } + + return ( + + + + + + + + + {}} + onSubmit={onQuerySubmit} + onQueryChangeMode={onQueryChangeMode} + onChangeGroupMode={onChangeGroupMode} + onClear={onQueryClear} + queryProps={{ useLiteActions: true }} + /> + + + + + + + The calm before the output + + } + /> + + + + + {isSavedQueriesOpen && ( + <> + + + + + + + )} + + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/query/useQuery.ts b/redisinsight/ui/src/pages/vector-search/query/useQuery.ts new file mode 100644 index 0000000000..1bf58e6a45 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/useQuery.ts @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useParams } from 'react-router-dom' +import { chunk, reverse } from 'lodash' +import { + Nullable, + getCommandsForExecution, + getExecuteParams, + isGroupResults, + isSilentMode, +} from 'uiSrc/utils' +import { CodeButtonParams } from 'uiSrc/constants' +import { + RunQueryMode, + ResultsMode, + CommandExecutionUI, + CommandExecution, +} from 'uiSrc/slices/interfaces' +import { PIPELINE_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { + addCommands, + clearCommands, + findCommand, + removeCommand, +} from 'uiSrc/services/workbenchStorage' +import { + createErrorResult, + createGroupItem, + executeApiCall, + generateCommandId, + limitHistoryLength, + loadHistoryData, + prepareNewItems, + scrollToElement, + sortCommandsByDate, +} from './utils' + +const useQuery = () => { + const { instanceId } = useParams<{ instanceId: string }>() + const scrollDivRef = useRef(null) + + const [query, setQuery] = useState('') + const [items, setItems] = useState([]) + const [clearing, setClearing] = useState(false) + const [processing, setProcessing] = useState(false) + const [isLoaded, setIsLoaded] = useState(false) + + const resultsMode = ResultsMode.Default + const activeRunQueryMode = RunQueryMode.ASCII + + useEffect(() => { + const loadHistory = async () => { + try { + const historyData = await loadHistoryData(instanceId) + setItems(historyData) + } catch (error) { + // Silently handle error + } finally { + setIsLoaded(true) + } + } + + loadHistory() + }, [instanceId]) + + const handleApiSuccess = useCallback( + async ( + data: CommandExecution[], + commandId: string, + isNewCommand: boolean, + ) => { + setItems((prevItems) => { + const updatedItems = prevItems.map((item) => { + const result = data.find((_, i) => item.id === commandId + i) + if (result) { + return { + ...result, + loading: false, + error: '', + isOpen: !isSilentMode(resultsMode), + } + } + return item + }) + return sortCommandsByDate(updatedItems) + }) + + await addCommands(reverse(data)) + + if (isNewCommand) { + scrollToElement(scrollDivRef.current, 'start') + } + }, + [resultsMode], + ) + + const handleApiError = useCallback((error: unknown) => { + const message = + error instanceof Error ? error.message : 'Failed to execute command' + + setItems((prevItems) => + prevItems.map((item) => { + if (item.loading) { + return { + ...item, + loading: false, + error: message, + result: createErrorResult(message), + isOpen: true, + } + } + return item + }), + ) + setProcessing(false) + }, []) + + const executeCommandBatch = useCallback( + async ( + commandInit: string, + commandId: Nullable | undefined, + executeParams: CodeButtonParams, + ) => { + const currentExecuteParams = { + activeRunQueryMode, + resultsMode, + batchSize: PIPELINE_COUNT_DEFAULT, + } + + const { batchSize } = getExecuteParams( + executeParams, + currentExecuteParams, + ) + const commandsForExecuting = getCommandsForExecution(commandInit) + const chunkSize = isGroupResults(resultsMode) + ? commandsForExecuting.length + : batchSize > 1 + ? batchSize + : 1 + + const [commands, ...restCommands] = chunk(commandsForExecuting, chunkSize) + + if (!commands?.length) { + setProcessing(false) + return + } + + const newCommandId = commandId || generateCommandId() + const newItems = prepareNewItems(commands, newCommandId) + + setItems((prevItems) => { + const updatedItems = isGroupResults(resultsMode) + ? [createGroupItem(newItems.length, newCommandId), ...prevItems] + : [...newItems, ...prevItems] + return limitHistoryLength(updatedItems) + }) + + const data = await executeApiCall( + instanceId, + commands, + activeRunQueryMode, + resultsMode, + ) + + await handleApiSuccess(data, newCommandId, !commandId) + + // Handle remaining command batches + if (restCommands.length > 0) { + const nextCommands = restCommands[0] + if (nextCommands?.length) { + await executeCommandBatch( + nextCommands.join('\n'), + undefined, + executeParams, + ) + } + } else { + setProcessing(false) + } + }, + [activeRunQueryMode, resultsMode, instanceId, handleApiSuccess], + ) + + const onSubmit = useCallback( + async ( + commandInit: string = query, + commandId?: Nullable, + executeParams: CodeButtonParams = {}, + ) => { + if (!commandInit?.length) return + + setProcessing(true) + + try { + await executeCommandBatch(commandInit, commandId, executeParams) + } catch (error) { + handleApiError(error) + } + }, + [query, executeCommandBatch, handleApiError], + ) + + const handleQueryDelete = useCallback( + async (commandId: string) => { + try { + await removeCommand(instanceId, commandId) + setItems((prevItems) => + prevItems.filter((item) => item.id !== commandId), + ) + } catch (error) { + // Silently handle error + } + }, + [instanceId], + ) + + const handleAllQueriesDelete = useCallback(async () => { + try { + setClearing(true) + await clearCommands(instanceId) + setItems([]) + } catch (error) { + // Keep clearing state false on error + } finally { + setClearing(false) + } + }, [instanceId]) + + const handleQueryOpen = useCallback(async (commandId: string) => { + try { + setItems((prevItems) => + prevItems.map((item) => + item.id === commandId ? { ...item, loading: true } : item, + ), + ) + + const command = await findCommand(commandId) + setItems((prevItems) => + prevItems.map((item) => { + if (item.id !== commandId) return item + + if (command) { + return { + ...item, + ...command, + loading: false, + isOpen: !item.isOpen, + error: '', + } + } + + return { ...item, loading: false } + }), + ) + } catch (error) { + setItems((prevItems) => + prevItems.map((item) => + item.id === commandId + ? { + ...item, + loading: false, + error: 'Failed to load command details', + } + : item, + ), + ) + } + }, []) + + const handleQueryProfile = useCallback(() => {}, []) + const handleChangeQueryRunMode = useCallback(() => {}, []) + const handleChangeGroupMode = useCallback(() => {}, []) + + return { + // State + query, + setQuery, + items, + clearing, + processing, + isResultsLoaded: isLoaded, + + // Configuration + activeMode: activeRunQueryMode, + resultsMode, + scrollDivRef, + + // Actions + onSubmit, + onQueryOpen: handleQueryOpen, + onQueryDelete: handleQueryDelete, + onAllQueriesDelete: handleAllQueriesDelete, + onQueryChangeMode: handleChangeQueryRunMode, + onChangeGroupMode: handleChangeGroupMode, + onQueryReRun: onSubmit, + onQueryProfile: handleQueryProfile, + } +} + +export { useQuery } diff --git a/redisinsight/ui/src/pages/vector-search/query/utils.ts b/redisinsight/ui/src/pages/vector-search/query/utils.ts new file mode 100644 index 0000000000..3e7c873803 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/utils.ts @@ -0,0 +1,116 @@ +import { scrollIntoView, getUrl, isStatusSuccessful } from 'uiSrc/utils' +import { EMPTY_COMMAND, ApiEndpoints } from 'uiSrc/constants' +import { + RunQueryMode, + ResultsMode, + CommandExecutionUI, + CommandExecution, + CommandExecutionType, +} from 'uiSrc/slices/interfaces' +import { apiService } from 'uiSrc/services' +import { WORKBENCH_HISTORY_MAX_LENGTH } from 'uiSrc/pages/workbench/constants' +import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' +import { getLocalWbHistory } from 'uiSrc/services/workbenchStorage' + +export const sortCommandsByDate = ( + commands: CommandExecutionUI[], +): CommandExecutionUI[] => + commands.sort((a, b) => { + const dateA = new Date(a.createdAt || 0).getTime() + const dateB = new Date(b.createdAt || 0).getTime() + return dateB - dateA + }) + +export const prepareNewItems = ( + commands: string[], + commandId: string, +): CommandExecutionUI[] => + commands.map((command, i) => ({ + command, + id: commandId + i, + loading: true, + isOpen: true, + error: '', + })) + +export const createGroupItem = ( + itemCount: number, + commandId: string, +): CommandExecutionUI => ({ + command: `${itemCount} - Command(s)`, + id: commandId, + loading: true, + isOpen: true, + error: '', +}) + +export const createErrorResult = (message: string) => [ + { + response: message, + status: CommandExecutionStatus.Fail, + }, +] + +export const scrollToElement = ( + element: HTMLDivElement | null, + inline: ScrollLogicalPosition = 'start', +) => { + if (!element) return + + requestAnimationFrame(() => { + scrollIntoView(element, { + behavior: 'smooth', + block: 'nearest', + inline, + }) + }) +} + +export const limitHistoryLength = ( + items: CommandExecutionUI[], +): CommandExecutionUI[] => + items.length > WORKBENCH_HISTORY_MAX_LENGTH + ? items.slice(0, WORKBENCH_HISTORY_MAX_LENGTH) + : items + +export const loadHistoryData = async ( + instanceId: string, +): Promise => { + const commandsHistory = await getLocalWbHistory(instanceId) + if (!Array.isArray(commandsHistory)) { + return [] + } + + const processedHistory = commandsHistory.map((item) => ({ + ...item, + command: item.command || EMPTY_COMMAND, + emptyCommand: !item.command, + })) + + return sortCommandsByDate(processedHistory) +} + +export const executeApiCall = async ( + instanceId: string, + commands: string[], + activeRunQueryMode: RunQueryMode, + resultsMode: ResultsMode, +): Promise => { + const { data, status } = await apiService.post( + getUrl(instanceId, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS), + { + commands, + mode: activeRunQueryMode, + resultsMode, + type: CommandExecutionType.Search, + }, + ) + + if (!isStatusSuccessful(status)) { + throw new Error(`API call failed with status: ${status}`) + } + + return data +} + +export const generateCommandId = (): string => `${Date.now()}` diff --git a/redisinsight/ui/src/pages/vector-search/saved-queries/SavedQueriesScreen.spec.tsx b/redisinsight/ui/src/pages/vector-search/saved-queries/SavedQueriesScreen.spec.tsx new file mode 100644 index 0000000000..99bbb07f84 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/saved-queries/SavedQueriesScreen.spec.tsx @@ -0,0 +1,160 @@ +/** + * @jest-environment jsdom + */ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' + +import { SavedQueriesScreen } from './SavedQueriesScreen' +import { SavedIndex } from './types' + +const mockOnIndexChange = jest.fn() +const mockOnQueryInsert = jest.fn() + +const mockSavedIndexes: SavedIndex[] = [ + { + value: 'bicycle_index', + tags: ['tag', 'text', 'vector'], + queries: [ + { + label: 'Search for bikes', + value: 'FT.SEARCH idx:bike "@category:mountain"', + }, + { + label: 'Find road bikes', + value: 'FT.SEARCH idx:bike "@type:road"', + }, + ], + }, + { + value: 'restaurant_index', + tags: ['text', 'vector'], + queries: [ + { + label: 'Search for restaurants', + value: 'FT.SEARCH idx:restaurant "@cuisine:Italian"', + }, + ], + }, +] + +const defaultProps = { + savedIndexes: mockSavedIndexes, + selectedIndex: mockSavedIndexes[0], + onIndexChange: mockOnIndexChange, + onQueryInsert: mockOnQueryInsert, +} + +describe('SavedQueriesScreen', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render the main content', () => { + render() + + expect(screen.getByText('Saved queries')).toBeInTheDocument() + expect(screen.getByText('Index:')).toBeInTheDocument() + + // Check that all queries from the selected index are rendered + expect(screen.getByText('Search for bikes')).toBeInTheDocument() + expect(screen.getByText('Find road bikes')).toBeInTheDocument() + }) + + it('should render insert buttons for each query', () => { + render() + + const insertButtons = screen.getAllByText('Insert') + expect(insertButtons).toHaveLength(2) // 2 queries in the selected index + }) + + it('should call onQueryInsert when insert button is clicked', () => { + render() + + const firstInsertButton = screen.getAllByText('Insert')[0] + fireEvent.click(firstInsertButton) + + expect(mockOnQueryInsert).toHaveBeenCalledTimes(1) + expect(mockOnQueryInsert).toHaveBeenCalledWith( + 'FT.SEARCH idx:bike "@category:mountain"', + ) + }) + + it('should call onQueryInsert with correct query value for second button', () => { + render() + + const insertButtons = screen.getAllByText('Insert') + + // Click second insert button + fireEvent.click(insertButtons[1]) + expect(mockOnQueryInsert).toHaveBeenCalledWith( + 'FT.SEARCH idx:bike "@type:road"', + ) + }) + + it('should render field tags for the selected index', () => { + render() + + // The tags should be rendered + defaultProps.selectedIndex.tags + .map((tag) => tag.toUpperCase()) + .forEach((tag) => { + expect(screen.getByText(tag)).toBeInTheDocument() + }) + }) + + describe('with different selected index', () => { + it('should render queries for restaurant index when selected', () => { + const propsWithRestaurantIndex = { + ...defaultProps, + selectedIndex: mockSavedIndexes[1], // restaurant_index + } + + render() + + expect(screen.getByText('Search for restaurants')).toBeInTheDocument() + + const insertButtons = screen.getAllByText('Insert') + expect(insertButtons).toHaveLength(1) // 1 query in restaurant index + }) + + it('should call onQueryInsert with restaurant query values', () => { + const propsWithRestaurantIndex = { + ...defaultProps, + selectedIndex: mockSavedIndexes[1], // restaurant_index + } + + render() + + const insertButtons = screen.getAllByText('Insert') + + fireEvent.click(insertButtons[0]) + expect(mockOnQueryInsert).toHaveBeenCalledWith( + 'FT.SEARCH idx:restaurant "@cuisine:Italian"', + ) + }) + }) + + describe('with empty queries', () => { + it('should handle index with no queries', () => { + const indexWithNoQueries: SavedIndex = { + value: 'empty_index', + tags: ['text'], + queries: [], + } + + const propsWithEmptyQueries = { + ...defaultProps, + savedIndexes: [indexWithNoQueries], + selectedIndex: indexWithNoQueries, + } + + render() + + expect(screen.queryByText('Insert')).not.toBeInTheDocument() + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/saved-queries/SavedQueriesScreen.tsx b/redisinsight/ui/src/pages/vector-search/saved-queries/SavedQueriesScreen.tsx new file mode 100644 index 0000000000..3278db8a08 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/saved-queries/SavedQueriesScreen.tsx @@ -0,0 +1,87 @@ +import React from 'react' + +import { Title, Text } from 'uiSrc/components/base/text' + +import { RiSelect } from 'uiSrc/components/base/forms/select/RiSelect' +import { Button } from 'uiSrc/components/base/forms/buttons' +import { FieldTag } from 'uiSrc/components/new-index/create-index-step/field-box/FieldTag' + +import { PlayFilledIcon } from 'uiSrc/components/base/icons' +import { + RightAlignedWrapper, + TagsWrapper, + VectorSearchSavedQueriesContentWrapper, + VectorSearchSavedQueriesSelectWrapper, +} from './styles' +import { SavedIndex } from './types' +import { + VectorSearchScreenBlockWrapper, + VectorSearchScreenFooter, + VectorSearchScreenHeader, + VectorSearchScreenWrapper, +} from '../styles' + +type SavedQueriesScreenProps = { + savedIndexes: SavedIndex[] + selectedIndex?: SavedIndex + onIndexChange: (value: string) => void + onQueryInsert: (value: string) => void +} + +export const SavedQueriesScreen = ({ + savedIndexes, + selectedIndex, + onIndexChange, + onQueryInsert, +}: SavedQueriesScreenProps) => ( + + + + Saved queries + + + + + + Index: + + isOptionValue ? ( + option.value + ) : ( + + {option.value} + {option.tags.map((tag) => ( + + ))} + + ) + } + /> + + {selectedIndex?.queries.map((query) => ( + + {query.label} + + + + + ))} + + + +) diff --git a/redisinsight/ui/src/pages/vector-search/saved-queries/styles.ts b/redisinsight/ui/src/pages/vector-search/saved-queries/styles.ts new file mode 100644 index 0000000000..616502fd3a --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/saved-queries/styles.ts @@ -0,0 +1,25 @@ +import styled from 'styled-components' + +export const VectorSearchSavedQueriesContentWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.core?.space.space150}; +` + +export const VectorSearchSavedQueriesSelectWrapper = styled.div` + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.core?.space.space100}; + justify-content: space-between; + align-items: center; +` + +export const RightAlignedWrapper = styled.div` + display: flex; + align-self: flex-end; +` + +export const TagsWrapper = styled.div` + display: flex; + gap: ${({ theme }) => theme.core?.space.space100}; +` diff --git a/redisinsight/ui/src/pages/vector-search/saved-queries/types.ts b/redisinsight/ui/src/pages/vector-search/saved-queries/types.ts new file mode 100644 index 0000000000..a73d870ed7 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/saved-queries/types.ts @@ -0,0 +1,8 @@ +export type SavedIndex = { + value: string + tags: string[] + queries: { + label: string + value: string + }[] +} diff --git a/redisinsight/ui/src/pages/vector-search/styles.ts b/redisinsight/ui/src/pages/vector-search/styles.ts new file mode 100644 index 0000000000..e20526d67f --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/styles.ts @@ -0,0 +1,63 @@ +import styled, { css } from 'styled-components' +import { FlexGroup, FlexItem } from 'uiSrc/components/base/layout/flex' + +export const VectorSearchPageWrapper = styled.div` + background-color: ${({ theme }) => + theme.semantic?.color.background.neutral100}; + padding-left: ${({ theme }) => theme.core?.space.space200}; + padding-right: ${({ theme }) => theme.core?.space.space200}; + + display: flex; + height: 100%; + width: 100%; +` + +export const VectorSearchScreenWrapper = styled(FlexGroup)` + ${({ theme }) => css` + background-color: ${theme.semantic?.color.background.neutral100}; + border-radius: 8px; + `} + + width: 100%; + height: 100%; + margin-left: auto; + margin-right: auto; + overflow: auto; +` + +export const VectorSearchScreenHeader = styled(FlexItem)` + padding: ${({ theme }) => theme.core?.space.space300}; + justify-content: space-between; + border: 1px solid; + border-color: ${({ theme }) => theme.color?.dusk200}; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +` + +export const VectorSearchScreenContent = styled(FlexItem)` + padding: ${({ theme }) => theme.core?.space.space300}; + gap: ${({ theme }) => theme.core?.space.space550}; + border: 1px solid; + border-top: none; + border-color: ${({ theme }) => theme.color?.dusk200}; +` + +export const VectorSearchScreenFooter = styled(FlexItem)` + padding: ${({ theme }) => theme.core?.space.space300}; + border: 1px solid; + border-color: ${({ theme }) => theme.color?.dusk200}; + border-top: none; + justify-content: space-between; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +` + +export const VectorSearchScreenBlockWrapper = styled.div` + display: flex; + flex-direction: column; + border: 1px solid; + border-color: ${({ theme }) => theme.color?.dusk200}; + border-radius: ${({ theme }) => theme.core?.space.space100}; + padding: ${({ theme }) => theme.core?.space.space200}; + gap: ${({ theme }) => theme.core?.space.space200}; +` diff --git a/redisinsight/ui/src/pages/vector-search/telemetry.spec.ts b/redisinsight/ui/src/pages/vector-search/telemetry.spec.ts new file mode 100644 index 0000000000..13e23b1e94 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/telemetry.spec.ts @@ -0,0 +1,406 @@ +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { faker } from '@faker-js/faker' +import { Factory } from 'fishery' +import { + collectChangedSavedQueryIndexTelemetry, + collectCreateIndexStepTelemetry, + collectCreateIndexWizardTelemetry, + collectIndexInfoStepTelemetry, + collectInsertSavedQueryTelemetry, + collectManageIndexesDeleteTelemetry, + collectManageIndexesDetailsToggleTelemetry, + collectManageIndexesDrawerClosedTelemetry, + collectManageIndexesDrawerOpenedTelemetry, + collectQueryToggleFullScreenTelemetry, + collectSavedQueriesPanelToggleTelemetry, + collectStartStepTelemetry, + collectTelemetryQueryClear, + collectTelemetryQueryClearAll, + collectTelemetryQueryReRun, + collectTelemetryQueryRun, +} from './telemetry' +import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' +import { + CreateSearchIndexParameters, + SampleDataContent, + SampleDataType, + SearchIndexType, +} from './create-index/types' + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +export const createSearchIndexParametersFactory = + Factory.define(() => ({ + instanceId: 'test-instance', + searchIndexType: faker.helpers.enumValue(SearchIndexType), + sampleDataType: faker.helpers.enumValue(SampleDataType), + dataContent: faker.helpers.enumValue(SampleDataContent), + usePresetVectorIndex: true, + indexName: 'BIKES', + indexFields: [], + })) + +describe('telemetry', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('collectCreateIndexWizardTelemetry', () => { + it('should collect telemetry for the start step', () => { + const mockParameters = createSearchIndexParametersFactory.build() + const instanceId = INSTANCE_ID_MOCK + + collectCreateIndexWizardTelemetry({ + step: 1, + instanceId, + parameters: mockParameters, + }) + + // Verify that the telemetry event was sent with the correct parameters + expect(sendEventTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_TRIGGERED, + }), + ) + }) + + it('should collect telemetry for the index info step', () => { + const mockParameters = createSearchIndexParametersFactory.build() + const instanceId = INSTANCE_ID_MOCK + + collectCreateIndexWizardTelemetry({ + step: 2, + instanceId, + parameters: mockParameters, + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_PROCEED_TO_INDEX_INFO, + }), + ) + }) + + it('should collect telemetry for the create index step', () => { + const instanceId = INSTANCE_ID_MOCK + + collectCreateIndexWizardTelemetry({ + step: 3, + instanceId, + parameters: createSearchIndexParametersFactory.build(), + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_PROCEED_TO_QUERIES, + }), + ) + }) + + it('should not collect telemetry for steps other than 1, 2, or 3', () => { + const mockParameters = createSearchIndexParametersFactory.build() + const instanceId = INSTANCE_ID_MOCK + + collectCreateIndexWizardTelemetry({ + step: 4, + instanceId, + parameters: mockParameters, + }) + + expect(sendEventTelemetry).not.toHaveBeenCalled() + }) + }) + + describe('collectStartStepTelemetry', () => { + it('should collect telemetry for the start step', () => { + const instanceId = INSTANCE_ID_MOCK + + collectStartStepTelemetry(instanceId) + + // Verify that the telemetry event was sent with the correct parameters + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_TRIGGERED, + eventData: { databaseId: instanceId }, + }) + }) + }) + + describe('collectIndexInfoStepTelemetry', () => { + it('should collect telemetry for the index info step', () => { + const instanceId = INSTANCE_ID_MOCK + const mockParameters = createSearchIndexParametersFactory.build() + + collectIndexInfoStepTelemetry(instanceId, mockParameters) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_PROCEED_TO_INDEX_INFO, + eventData: { + databaseId: instanceId, + indexType: mockParameters.searchIndexType, + sampleDataType: mockParameters.sampleDataType, + dataContent: mockParameters.dataContent, + }, + }) + }) + }) + + describe('collectCreateIndexStepTelemetry', () => { + it('should collect telemetry for the create index step', () => { + const instanceId = INSTANCE_ID_MOCK + + collectCreateIndexStepTelemetry(instanceId) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_PROCEED_TO_QUERIES, + eventData: { databaseId: instanceId }, + }) + }) + }) + + describe('collectSavedQueriesPanelToggleTelemetry', () => { + it('should collect telemetry for saved queries panel toggle on open', () => { + const instanceId = INSTANCE_ID_MOCK + const isSavedQueriesOpen = false + + collectSavedQueriesPanelToggleTelemetry({ + instanceId, + isSavedQueriesOpen, + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_SAVED_QUERIES_PANEL_OPENED, + eventData: { + databaseId: instanceId, + }, + }) + }) + + it('should collect telemetry for saved queries panel toggle on close', () => { + const instanceId = INSTANCE_ID_MOCK + const isSavedQueriesOpen = true + + collectSavedQueriesPanelToggleTelemetry({ + instanceId, + isSavedQueriesOpen, + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_SAVED_QUERIES_PANEL_CLOSED, + eventData: { + databaseId: instanceId, + }, + }) + }) + }) + + describe('collectChangedSavedQueryIndexTelemetry', () => { + it('should collect telemetry for changed saved query index', () => { + const instanceId = INSTANCE_ID_MOCK + + collectChangedSavedQueryIndexTelemetry({ instanceId }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_SAVED_QUERIES_INDEX_CHANGED, + eventData: { + databaseId: instanceId, + }, + }) + }) + }) + + describe('collectInsertSavedQueryTelemetry', () => { + it('should collect telemetry for insert saved query', () => { + const instanceId = INSTANCE_ID_MOCK + + collectInsertSavedQueryTelemetry({ instanceId }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_SAVED_QUERIES_INSERT_CLICKED, + eventData: { + databaseId: instanceId, + }, + }) + }) + }) + + describe('collectManageIndexesDrawerOpenedTelemetry', () => { + it('should collect telemetry for the manage indexes drawer opened', () => { + const instanceId = INSTANCE_ID_MOCK + + collectManageIndexesDrawerOpenedTelemetry({ instanceId }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_MANAGE_INDEXES_DRAWER_OPENED, + eventData: { databaseId: instanceId }, + }) + }) + }) + + describe('collectManageIndexesDrawerClosedTelemetry', () => { + it('should collect telemetry for the manage indexes drawer closed', () => { + const instanceId = INSTANCE_ID_MOCK + + collectManageIndexesDrawerClosedTelemetry({ instanceId }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_MANAGE_INDEXES_DRAWER_CLOSED, + eventData: { databaseId: instanceId }, + }) + }) + }) + + describe('collectManageIndexesDetailsToggleTelemetry', () => { + it('should collect telemetry for the manage indexes details toggle on open', () => { + const instanceId = INSTANCE_ID_MOCK + const isOpen = true + + collectManageIndexesDetailsToggleTelemetry({ instanceId, isOpen }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_MANAGE_INDEX_DETAILS_OPENED, + eventData: { databaseId: instanceId }, + }) + }) + + it('should collect telemetry for the manage indexes details toggle on close', () => { + const instanceId = INSTANCE_ID_MOCK + const isOpen = false + + collectManageIndexesDetailsToggleTelemetry({ instanceId, isOpen }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_MANAGE_INDEX_DETAILS_CLOSED, + eventData: { databaseId: instanceId }, + }) + }) + }) + + describe('collectManageIndexesDeleteTelemetry', () => { + it('should collect telemetry for the manage indexes delete', () => { + const instanceId = INSTANCE_ID_MOCK + + collectManageIndexesDeleteTelemetry({ instanceId }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_MANAGE_INDEX_DELETED, + eventData: { databaseId: instanceId }, + }) + }) + }) + + describe('collectTelemetryQueryRun', () => { + it('should collect telemetry for query run', () => { + const instanceId = INSTANCE_ID_MOCK + const query = 'TEST_QUERY' + + collectTelemetryQueryRun({ + instanceId, + query, + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_COMMAND_SUBMITTED, + eventData: { + databaseId: instanceId, + commands: [query], + }, + }) + }) + }) + + describe('collectTelemetryQueryReRun', () => { + it('should collect telemetry for query re-run', () => { + const instanceId = INSTANCE_ID_MOCK + const query = 'TEST_QUERY' + + collectTelemetryQueryReRun({ + instanceId, + query, + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_COMMAND_RUN_AGAIN, + eventData: { + databaseId: instanceId, + commands: [query], + }, + }) + }) + }) + + describe('collectTelemetryQueryClearAll', () => { + it('should collect telemetry for clearing all queries', () => { + const instanceId = INSTANCE_ID_MOCK + + collectTelemetryQueryClearAll({ + instanceId, + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_CLEAR_ALL_RESULTS_CLICKED, + eventData: { + databaseId: instanceId, + }, + }) + }) + }) + + describe('collectTelemetryQueryClear', () => { + it('should collect telemetry for clearing a query', () => { + const instanceId = INSTANCE_ID_MOCK + + collectTelemetryQueryClear({ + instanceId, + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_CLEAR_EDITOR_CLICKED, + eventData: { + databaseId: instanceId, + }, + }) + }) + }) + + describe('collectQueryToggleFullScreenTelemetry', () => { + it('should collect telemetry for opening full screen', () => { + const instanceId = INSTANCE_ID_MOCK + const isFullScreen = true + + collectQueryToggleFullScreenTelemetry({ + instanceId, + isFullScreen, + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_RESULTS_IN_FULL_SCREEN, + eventData: { + databaseId: instanceId, + state: 'Open', + }, + }) + }) + + it('should collect telemetry for closing full screen', () => { + const instanceId = INSTANCE_ID_MOCK + const isFullScreen = false + + collectQueryToggleFullScreenTelemetry({ + instanceId, + isFullScreen, + }) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_RESULTS_IN_FULL_SCREEN, + eventData: { + databaseId: instanceId, + state: 'Close', + }, + }) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/vector-search/telemetry.ts b/redisinsight/ui/src/pages/vector-search/telemetry.ts new file mode 100644 index 0000000000..064f43e498 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/telemetry.ts @@ -0,0 +1,211 @@ +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { CreateSearchIndexParameters } from './create-index/types' + +interface CollectTelemetry { + instanceId: string +} + +export const collectSavedQueriesPanelToggleTelemetry = ({ + instanceId, + isSavedQueriesOpen, +}: CollectTelemetry & { + isSavedQueriesOpen: boolean +}): void => { + sendEventTelemetry({ + event: isSavedQueriesOpen + ? TelemetryEvent.SEARCH_SAVED_QUERIES_PANEL_CLOSED + : TelemetryEvent.SEARCH_SAVED_QUERIES_PANEL_OPENED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectChangedSavedQueryIndexTelemetry = ({ + instanceId, +}: CollectTelemetry): void => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_SAVED_QUERIES_INDEX_CHANGED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectInsertSavedQueryTelemetry = ({ + instanceId, +}: CollectTelemetry): void => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_SAVED_QUERIES_INSERT_CLICKED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectCreateIndexWizardTelemetry = ({ + instanceId, + step, + parameters, +}: CollectTelemetry & { + step: number + parameters: CreateSearchIndexParameters +}): void => { + switch (step) { + case 1: + collectStartStepTelemetry(instanceId) + break + case 2: + collectIndexInfoStepTelemetry(instanceId, parameters) + break + case 3: + collectCreateIndexStepTelemetry(instanceId) + break + default: + // No telemetry for other steps + break + } +} + +export const collectStartStepTelemetry = (instanceId: string): void => { + sendEventTelemetry({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_TRIGGERED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectIndexInfoStepTelemetry = ( + instanceId: string, + parameters: CreateSearchIndexParameters, +): void => { + sendEventTelemetry({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_PROCEED_TO_INDEX_INFO, + eventData: { + databaseId: instanceId, + indexType: parameters.searchIndexType, + sampleDataType: parameters.sampleDataType, + dataContent: parameters.dataContent, + }, + }) +} + +export const collectCreateIndexStepTelemetry = (instanceId: string): void => { + sendEventTelemetry({ + event: TelemetryEvent.VECTOR_SEARCH_ONBOARDING_PROCEED_TO_QUERIES, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectManageIndexesDrawerOpenedTelemetry = ({ + instanceId, +}: CollectTelemetry): void => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_MANAGE_INDEXES_DRAWER_OPENED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectManageIndexesDrawerClosedTelemetry = ({ + instanceId, +}: CollectTelemetry): void => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_MANAGE_INDEXES_DRAWER_CLOSED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectManageIndexesDetailsToggleTelemetry = ({ + instanceId, + isOpen, +}: CollectTelemetry & { + isOpen: boolean +}): void => { + sendEventTelemetry({ + event: isOpen + ? TelemetryEvent.SEARCH_MANAGE_INDEX_DETAILS_OPENED + : TelemetryEvent.SEARCH_MANAGE_INDEX_DETAILS_CLOSED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectManageIndexesDeleteTelemetry = ({ + instanceId, +}: CollectTelemetry): void => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_MANAGE_INDEX_DELETED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectTelemetryQueryRun = ({ + instanceId, + query, +}: CollectTelemetry & { query: string }) => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_COMMAND_SUBMITTED, + eventData: { + databaseId: instanceId, + commands: [query], + }, + }) +} + +export const collectTelemetryQueryReRun = ({ + instanceId, + query, +}: CollectTelemetry & { query: string }) => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_COMMAND_RUN_AGAIN, + eventData: { + databaseId: instanceId, + commands: [query], + }, + }) +} + +export const collectTelemetryQueryClearAll = ({ + instanceId, +}: CollectTelemetry) => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_CLEAR_ALL_RESULTS_CLICKED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectTelemetryQueryClear = ({ + instanceId, +}: CollectTelemetry) => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_CLEAR_EDITOR_CLICKED, + eventData: { + databaseId: instanceId, + }, + }) +} + +export const collectQueryToggleFullScreenTelemetry = ({ + instanceId, + isFullScreen, +}: CollectTelemetry & { isFullScreen: boolean }) => { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_RESULTS_IN_FULL_SCREEN, + eventData: { + databaseId: instanceId, + state: isFullScreen ? 'Open' : 'Close', + }, + }) +} diff --git a/redisinsight/ui/src/pages/vector-search/useRedisearchListData.ts b/redisinsight/ui/src/pages/vector-search/useRedisearchListData.ts new file mode 100644 index 0000000000..6b2e61cbdb --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/useRedisearchListData.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react' +import { useSelector, useDispatch } from 'react-redux' + +import { + redisearchListSelector, + fetchRedisearchListAction, +} from 'uiSrc/slices/browser/redisearch' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { isRedisearchAvailable } from 'uiSrc/utils' + +export const useRedisearchListData = () => { + const dispatch = useDispatch() + const { loading, data } = useSelector(redisearchListSelector) + const { modules, host: instanceHost } = useSelector(connectedInstanceSelector) + + useEffect(() => { + if (!instanceHost) { + return + } + + const moduleExists = isRedisearchAvailable(modules) + if (moduleExists) { + dispatch(fetchRedisearchListAction()) + } + }, [dispatch, instanceHost, modules]) + + return { + loading, + data, + } +} diff --git a/redisinsight/ui/src/pages/workbench/components/query/Query/Query.spec.tsx b/redisinsight/ui/src/pages/workbench/components/query/Query/Query.spec.tsx index d7df7c2ea3..fc8c8255d7 100644 --- a/redisinsight/ui/src/pages/workbench/components/query/Query/Query.spec.tsx +++ b/redisinsight/ui/src/pages/workbench/components/query/Query/Query.spec.tsx @@ -1,7 +1,14 @@ import { cloneDeep } from 'lodash' import React from 'react' import { instance, mock } from 'ts-mockito' -import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' +import { + cleanup, + fireEvent, + mockedStore, + render, + screen, + waitFor, +} from 'uiSrc/utils/test-utils' import Query, { Props } from './Query' const mockedProps = mock() @@ -36,4 +43,30 @@ describe('Query', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + it('should call onClear when clear button is clicked', async () => { + const props: Props = { + ...instance(mockedProps), + query: 'test query', + useLiteActions: true, + setQuery: jest.fn(), + onClear: jest.fn(), + } + + render() + + // Ensure we start with the query input populated + const queryInput = screen.getByTestId('monaco') + expect(queryInput).toHaveValue(props.query) + + // Find the clear button and click it + const clearButton = screen.getByTestId('btn-clear') + expect(clearButton).toBeInTheDocument() + + fireEvent.click(clearButton) + + // Verify that the onClear function was called and the query input is cleared + expect(props.setQuery).toHaveBeenCalled() + expect(props.onClear).toHaveBeenCalled() + }) }) diff --git a/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx b/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx index 696db86a48..a23b3c2f6a 100644 --- a/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx +++ b/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx @@ -40,7 +40,11 @@ import { workbenchResultsSelector, } from 'uiSrc/slices/workbench/wb-results' import DedicatedEditor from 'uiSrc/components/monaco-editor/components/dedicated-editor' -import { QueryActions, QueryTutorials } from 'uiSrc/components/query' +import { + QueryActions, + QueryTutorials, + QueryLiteActions, +} from 'uiSrc/components/query' import { getRange, @@ -78,11 +82,13 @@ export interface Props { activeMode: RunQueryMode resultsMode?: ResultsMode setQueryEl: Function + useLiteActions?: boolean setQuery: (script: string) => void onSubmit: (query?: string) => void onKeyDown?: (e: React.KeyboardEvent, script: string) => void onQueryChangeMode: () => void onChangeGroupMode: () => void + onClear?: () => void } let execHistoryPos: number = 0 @@ -97,12 +103,14 @@ const Query = (props: Props) => { indexes = [], activeMode, resultsMode, + useLiteActions = false, setQuery = () => {}, onKeyDown = () => {}, onSubmit = () => {}, setQueryEl = () => {}, onQueryChangeMode = () => {}, onChangeGroupMode = () => {}, + onClear = () => {}, } = props let contribution: Nullable = null const [isDedicatedEditorOpen, setIsDedicatedEditorOpen] = useState(false) @@ -473,6 +481,11 @@ const Query = (props: Props) => { onSubmit(value) } + const handleClear = () => { + setQuery('') + onClear?.() + } + const handleSuggestions = ( editor: monacoEditor.editor.IStandaloneCodeEditor, command?: Nullable, @@ -736,19 +749,29 @@ const Query = (props: Props) => { />
- - + {useLiteActions ? ( + + ) : ( + <> + + + + )}
{isDedicatedEditorOpen && ( diff --git a/redisinsight/ui/src/pages/workbench/components/query/Query/styles.module.scss b/redisinsight/ui/src/pages/workbench/components/query/Query/styles.module.scss index 18b46e1931..1feddd704a 100644 --- a/redisinsight/ui/src/pages/workbench/components/query/Query/styles.module.scss +++ b/redisinsight/ui/src/pages/workbench/components/query/Query/styles.module.scss @@ -12,7 +12,7 @@ .container { display: flex; flex-direction: column; - padding: 8px 16px; + padding: 16px; width: 100%; height: 100%; word-break: break-word; @@ -21,6 +21,7 @@ background-color: var(--rsInputWrapperColor); color: var(--euiTextSubduedColor) !important; border: 1px solid var(--euiColorLightShade); + border-radius: var(--border-radius-medium); } .disabled { @@ -62,7 +63,7 @@ align-items: center; justify-content: space-between; - margin-top: 8px; + margin-top: 16px; flex-shrink: 0; } diff --git a/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.spec.tsx b/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.spec.tsx index 3cd93c44c5..17245722ea 100644 --- a/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.spec.tsx @@ -1,7 +1,13 @@ import { cloneDeep } from 'lodash' import React from 'react' import { instance, mock } from 'ts-mockito' -import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' +import { + cleanup, + fireEvent, + mockedStore, + render, + screen, +} from 'uiSrc/utils/test-utils' import QueryWrapper, { Props } from './QueryWrapper' const mockedProps = mock() @@ -45,4 +51,28 @@ describe('QueryWrapper', () => { expect(render()).toBeTruthy() }) + + it('should call onClear callback when clear button is clicked', () => { + const props: Props = { + ...instance(mockedProps), + queryProps: { + useLiteActions: true, + }, + query: 'test query', + onClear: jest.fn(), + } + + render() + + // Ensure we start with the query input populated + const queryInput = screen.getByTestId('monaco') + expect(queryInput).toHaveValue(props.query) + + // Find the clear button and click it + const clearButton = screen.getByTestId('btn-clear') + fireEvent.click(clearButton) + + // Verify that the onClear function was called and the query input is cleared + expect(props.onClear).toHaveBeenCalled() + }) }) diff --git a/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.tsx index e93c4d78f4..021050ca7c 100644 --- a/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.tsx @@ -13,17 +13,22 @@ import { mergeRedisCommandsSpecs } from 'uiSrc/utils/transformers/redisCommands' import SEARCH_COMMANDS_SPEC from 'uiSrc/pages/workbench/data/supported_commands.json' import styles from './Query/styles.module.scss' import Query from './Query' +import { Props as BaseQueryProps } from './Query/Query' + +type QueryProps = Pick export interface Props { query: string activeMode: RunQueryMode resultsMode?: ResultsMode + queryProps?: QueryProps setQuery: (script: string) => void setQueryEl: Function onKeyDown?: (e: React.KeyboardEvent, script: string) => void onSubmit: (value?: string) => void onQueryChangeMode: () => void onChangeGroupMode: () => void + onClear?: () => void } const QueryWrapper = (props: Props) => { @@ -37,6 +42,8 @@ const QueryWrapper = (props: Props) => { onSubmit, onQueryChangeMode, onChangeGroupMode, + onClear, + queryProps = {}, } = props const { loading: isCommandsLoading } = useSelector(appRedisCommandsSelector) const { id: connectedIndstanceId } = useSelector(connectedInstanceSelector) @@ -79,6 +86,8 @@ const QueryWrapper = (props: Props) => { onSubmit={onSubmit} onQueryChangeMode={onQueryChangeMode} onChangeGroupMode={onChangeGroupMode} + onClear={onClear} + {...queryProps} /> ) } diff --git a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.spec.tsx b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.spec.tsx index 96e4355a1b..39e7e6eccd 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.spec.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.spec.tsx @@ -4,6 +4,11 @@ import { instance, mock } from 'ts-mockito' import { CommandExecutionUI } from 'uiSrc/slices/interfaces' import { cleanup, mockedStore, render, screen } from 'uiSrc/utils/test-utils' import WBResults, { Props } from './WBResults' +import { + ViewMode, + ViewModeContextProvider, +} from 'uiSrc/components/query/context/view-mode.context' +import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' const mockedProps = mock() @@ -22,31 +27,49 @@ jest.mock('uiSrc/services', () => ({ }, })) +const renderWBResultsComponent = (props: Partial = {}) => { + return render( + + + , + { + store, + }, + ) +} + describe('WBResults', () => { it('should render', () => { - expect(render()).toBeTruthy() + const { container } = renderWBResultsComponent() + expect(container).toBeTruthy() }) it('should render NoResults component with empty items', () => { - const { getByTestId } = render( - , - ) + const { getByTestId } = renderWBResultsComponent({ + items: [], + isResultsLoaded: true, + }) expect(getByTestId('wb_no-results')).toBeInTheDocument() }) it('should not render NoResults component with empty items and loading state', () => { - render( - , - ) + renderWBResultsComponent({ + items: [], + isResultsLoaded: false, + }) expect(screen.queryByTestId('wb_no-results')).not.toBeInTheDocument() }) + it('should render with custom props', () => { + renderWBResultsComponent({ + ...instance(mockedProps), + items: [], + isResultsLoaded: false, + }) + }) + it('should render with custom props', () => { const itemsMock: CommandExecutionUI[] = [ { @@ -55,7 +78,7 @@ describe('WBResults', () => { result: [ { response: 'data1', - status: 'success', + status: CommandExecutionStatus.Success, }, ], }, @@ -65,14 +88,18 @@ describe('WBResults', () => { result: [ { response: 'data2', - status: 'success', + status: CommandExecutionStatus.Success, }, ], }, ] - expect( - render(), - ).toBeTruthy() + const { container } = renderWBResultsComponent({ + ...instance(mockedProps), + items: itemsMock, + isResultsLoaded: true, + }) + + expect(container).toBeTruthy() }) }) diff --git a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/styles.module.scss b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/styles.module.scss index 94e0db31c4..7fce04573c 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/styles.module.scss +++ b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/styles.module.scss @@ -1,9 +1,12 @@ .wrapper { flex: 1; - height: 100%; + height: calc(100% - var(--border-radius-medium)); width: 100%; background-color: var(--euiColorEmptyShade); border: 1px solid var(--euiColorLightShade); + border-radius: var(--border-radius-medium); + // HACK: to fix rectangle like view in rounded borders wrapper + padding-bottom: var(--border-radius-medium); display: flex; flex-direction: column; diff --git a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResultsWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResultsWrapper.tsx index 87ff8540cf..d053761efa 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResultsWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResultsWrapper.tsx @@ -4,6 +4,10 @@ import { CommandExecutionUI } from 'uiSrc/slices/interfaces' import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' import { CodeButtonParams } from 'uiSrc/constants' import WBResults from './WBResults' +import { + ViewMode, + ViewModeContextProvider, +} from 'uiSrc/components/query/context/view-mode.context' export interface Props { isResultsLoaded: boolean @@ -28,6 +32,10 @@ export interface Props { ) => void } -const WBResultsWrapper = (props: Props) => +const WBResultsWrapper = (props: Props) => ( + + + +) export default React.memo(WBResultsWrapper) diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/styles.module.scss b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/styles.module.scss index ba9013a098..86f297195f 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/styles.module.scss +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/styles.module.scss @@ -19,12 +19,6 @@ width: 100%; } -.queryResults { - & > div { - border-bottom-width: 0; - } -} - :global(.show-cli) { .queryResults { & > div { diff --git a/redisinsight/ui/src/services/executeQuery.spec.ts b/redisinsight/ui/src/services/executeQuery.spec.ts new file mode 100644 index 0000000000..fa03231810 --- /dev/null +++ b/redisinsight/ui/src/services/executeQuery.spec.ts @@ -0,0 +1,88 @@ +import { rest } from 'msw' +import { ApiEndpoints } from 'uiSrc/constants' +import { mswServer } from 'uiSrc/mocks/server' +import { getMswURL } from 'uiSrc/utils/test-utils' +import { getUrl } from 'uiSrc/utils' +import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces' +import executeQuery from './executeQuery' + +describe('executeQuery', () => { + const instanceId = 'test-instance-id' + const command = 'FT.CREATE idx:bikes_vss ...' + + beforeEach(() => { + mswServer.resetHandlers() + jest.clearAllMocks() + }) + + it.each([null, undefined])( + 'returns empty array and does not call API when data is %s', + async (data) => { + const result = await executeQuery(instanceId, data as any) + expect(result).toEqual([]) + }, + ) + + it('calls API with correct parameters and returns result', async () => { + const mockResponse = [{ id: '1', databaseId: instanceId }] + + mswServer.use( + rest.post( + getMswURL( + getUrl(instanceId, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS), + ), + async (req, res, ctx) => { + const body = await req.json() + expect(body).toEqual({ + commands: [command], + mode: RunQueryMode.ASCII, + resultsMode: ResultsMode.Default, + type: 'SEARCH', + }) + return res(ctx.status(200), ctx.json(mockResponse)) + }, + ), + ) + + const returned = await executeQuery(instanceId, command) + expect(returned).toEqual(mockResponse) + }) + + it('invokes afterAll callback on success', async () => { + const mockResponse = [{ id: '1', databaseId: instanceId }] + + mswServer.use( + rest.post( + getMswURL( + getUrl(instanceId, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS), + ), + async (_req, res, ctx) => res(ctx.status(200), ctx.json(mockResponse)), + ), + ) + + const afterAll = jest.fn() + + await executeQuery(instanceId, command, { afterAll }) + + expect(afterAll).toHaveBeenCalled() + }) + + it('invokes onFail and rethrows on error', async () => { + mswServer.use( + rest.post( + getMswURL( + getUrl(instanceId, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS), + ), + async (_req, res, ctx) => res(ctx.status(500)), + ), + ) + + const onFail = jest.fn() + + await expect( + executeQuery(instanceId, command, { onFail }), + ).rejects.toThrow() + + expect(onFail).toHaveBeenCalled() + }) +}) diff --git a/redisinsight/ui/src/services/executeQuery.ts b/redisinsight/ui/src/services/executeQuery.ts new file mode 100644 index 0000000000..9dba65dcd4 --- /dev/null +++ b/redisinsight/ui/src/services/executeQuery.ts @@ -0,0 +1,31 @@ +import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces' +import { executeApiCall } from 'uiSrc/pages/vector-search/query/utils' + +export interface ExecuteQueryOptions { + afterAll?: () => void + onFail?: (error?: unknown) => void +} + +const executeQuery = async ( + instanceId: string, + data: string | null | undefined, + options: ExecuteQueryOptions = {}, +): Promise>> => { + if (!data) return [] as unknown as Awaited> + + try { + const result = await executeApiCall( + instanceId, + [data], + RunQueryMode.ASCII, + ResultsMode.Default, + ) + options.afterAll?.() + return result + } catch (e) { + options.onFail?.(e) + throw e + } +} + +export default executeQuery diff --git a/redisinsight/ui/src/services/hooks/index.ts b/redisinsight/ui/src/services/hooks/index.ts index 4b1ac5c8b8..f3e3a1986b 100644 --- a/redisinsight/ui/src/services/hooks/index.ts +++ b/redisinsight/ui/src/services/hooks/index.ts @@ -2,3 +2,4 @@ export * from './hooks' export * from './useWebworkers' export * from './useCabability' export * from './useStateWithContext' +export * from './useLoadData' diff --git a/redisinsight/ui/src/services/hooks/useLoadData.spec.ts b/redisinsight/ui/src/services/hooks/useLoadData.spec.ts new file mode 100644 index 0000000000..ba76b335cc --- /dev/null +++ b/redisinsight/ui/src/services/hooks/useLoadData.spec.ts @@ -0,0 +1,252 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { rest } from 'msw' +import { ApiEndpoints } from 'uiSrc/constants' +import { mswServer } from 'uiSrc/mocks/server' +import { getMswURL } from 'uiSrc/utils/test-utils' +import { getUrl } from 'uiSrc/utils' +import { useLoadData } from './useLoadData' + +describe('useLoadData', () => { + const instanceId = 'test-instance-id' + const collectionName = 'test-collection' + + beforeEach(() => { + mswServer.resetHandlers() + }) + + it('should return initial state correctly', () => { + const { result } = renderHook(() => useLoadData()) + + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('should successfully load data and return response', async () => { + const mockResponse = { + id: 'bulk-action-123', + summary: { + processed: 100, + succeed: 95, + failed: 5, + }, + status: 'completed', + } + + mswServer.use( + rest.post( + getMswURL( + getUrl( + instanceId, + ApiEndpoints.BULK_ACTIONS_IMPORT_VECTOR_COLLECTION, + ), + ), + async (req, res, ctx) => { + const body = await req.json() + expect(body).toEqual({ collectionName }) + return res(ctx.status(200), ctx.json(mockResponse)) + }, + ), + ) + + const { result } = renderHook(() => useLoadData()) + + let returnedData + await act(async () => { + returnedData = await result.current.load(instanceId, collectionName) + }) + + expect(returnedData).toEqual(mockResponse) + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('should set loading state correctly during API call', async () => { + const mockResponse = { id: '123' } + let resolveRequest: () => void + const requestPromise = new Promise((resolve) => { + resolveRequest = resolve + }) + + mswServer.use( + rest.post( + getMswURL( + getUrl( + instanceId, + ApiEndpoints.BULK_ACTIONS_IMPORT_VECTOR_COLLECTION, + ), + ), + async (_, res, ctx) => { + await requestPromise + return res(ctx.status(200), ctx.json(mockResponse)) + }, + ), + ) + + const { result } = renderHook(() => useLoadData()) + + expect(result.current.loading).toBe(false) + + // Start the request without awaiting + act(() => { + result.current.load(instanceId, collectionName) + }) + + // Loading should be true while request is pending + expect(result.current.loading).toBe(true) + expect(result.current.error).toBeNull() + + // Complete the request + await act(async () => { + resolveRequest() + }) + + // Loading should be false after completion + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('should handle API errors correctly when error is an Error instance', async () => { + const errorMessage = 'Network error occurred' + + mswServer.use( + rest.post( + getMswURL( + getUrl( + instanceId, + ApiEndpoints.BULK_ACTIONS_IMPORT_VECTOR_COLLECTION, + ), + ), + async (_, res, ctx) => + res(ctx.status(500), ctx.json({ message: errorMessage })), + ), + ) + + const { result } = renderHook(() => useLoadData()) + + await act(async () => { + try { + await result.current.load(instanceId, collectionName) + } catch (err) { + expect(err).toBeInstanceOf(Error) + } + }) + + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeInstanceOf(Error) + }) + + it('should reset error state on new load attempt', async () => { + const mockResponse = { id: '123' } + let callCount = 0 + + // Mock first call to fail, second to succeed + mswServer.use( + rest.post( + getMswURL( + getUrl( + instanceId, + ApiEndpoints.BULK_ACTIONS_IMPORT_VECTOR_COLLECTION, + ), + ), + async (_, res, ctx) => { + callCount++ + if (callCount === 1) { + return res(ctx.status(500), ctx.json({ message: 'Server error' })) + } + return res(ctx.status(200), ctx.json(mockResponse)) + }, + ), + ) + + const { result } = renderHook(() => useLoadData()) + + // First call fails + await act(async () => { + try { + await result.current.load(instanceId, collectionName) + } catch { + // Expected to fail + } + }) + + expect(result.current.error).toBeInstanceOf(Error) + + // Second call succeeds + await act(async () => { + await result.current.load(instanceId, collectionName) + }) + + expect(result.current.error).toBeNull() + expect(result.current.loading).toBe(false) + }) + + it('should handle multiple concurrent calls correctly', async () => { + const mockResponse1 = { id: '123' } + const mockResponse2 = { id: '456' } + let callCount = 0 + + mswServer.use( + rest.post( + getMswURL( + getUrl( + instanceId, + ApiEndpoints.BULK_ACTIONS_IMPORT_VECTOR_COLLECTION, + ), + ), + async (_, res, ctx) => { + callCount++ + const response = callCount === 1 ? mockResponse1 : mockResponse2 + return res(ctx.status(200), ctx.json(response)) + }, + ), + ) + + const { result } = renderHook(() => useLoadData()) + + let result1: any + let result2: any + + await act(async () => { + const [promise1, promise2] = await Promise.all([ + result.current.load(instanceId, 'collection1'), + result.current.load(instanceId, 'collection2'), + ]) + result1 = promise1 + result2 = promise2 + }) + + expect(result1).toEqual(mockResponse1) + expect(result2).toEqual(mockResponse2) + expect(result.current.loading).toBe(false) + }) + + it('should call API with correct parameters for different collections', async () => { + const mockResponse = { id: '123' } + const requestBodies: any[] = [] + + mswServer.use( + rest.post( + '*/bulk-actions/import/vector-collection', + async (req, res, ctx) => { + const body = await req.json() + requestBodies.push(body) + return res(ctx.status(200), ctx.json(mockResponse)) + }, + ), + ) + + const { result } = renderHook(() => useLoadData()) + + await act(async () => { + await result.current.load('instance-1', 'bikes') + }) + + await act(async () => { + await result.current.load('instance-2', 'cars') + }) + + expect(requestBodies).toHaveLength(2) + expect(requestBodies[0]).toEqual({ collectionName: 'bikes' }) + expect(requestBodies[1]).toEqual({ collectionName: 'cars' }) + }) +}) diff --git a/redisinsight/ui/src/services/hooks/useLoadData.ts b/redisinsight/ui/src/services/hooks/useLoadData.ts new file mode 100644 index 0000000000..a1a33b5ce0 --- /dev/null +++ b/redisinsight/ui/src/services/hooks/useLoadData.ts @@ -0,0 +1,54 @@ +import { useState, useCallback } from 'react' +import { apiService } from 'uiSrc/services' +import { getUrl } from 'uiSrc/utils' +import { IBulkActionOverview } from 'uiSrc/slices/interfaces' +import { ApiEndpoints } from 'uiSrc/constants' + +interface UseLoadDataResult { + load: (instanceId: string, collection: string) => Promise + loading: boolean + error: Error | null +} + +export const useLoadData = (): UseLoadDataResult => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const loadData = useCallback( + async ( + instanceId: string, + collectionName: string, + ): Promise => { + setLoading(true) + setError(null) + + try { + const { data } = await apiService.post( + getUrl( + instanceId, + ApiEndpoints.BULK_ACTIONS_IMPORT_VECTOR_COLLECTION, + ), + { collectionName }, + ) + + return data + } catch (err) { + const error = + err instanceof Error + ? err + : new Error('Failed to import vector collection') + setError(error) + throw error + } finally { + setLoading(false) + } + }, + [], + ) + + return { + load: loadData, + loading, + error, + } +} diff --git a/redisinsight/ui/src/setup-tests.ts b/redisinsight/ui/src/setup-tests.ts index 33a2803af2..19a1417e53 100644 --- a/redisinsight/ui/src/setup-tests.ts +++ b/redisinsight/ui/src/setup-tests.ts @@ -42,3 +42,15 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ // we need this since jsdom doesn't support PointerEvent window.HTMLElement.prototype.hasPointerCapture = jest.fn() + +// Mock window.indexedDB for test environments (jsdom/Node) +if (!window.indexedDB) { + window.indexedDB = { + open: jest.fn(() => ({ + onerror: jest.fn(), + onsuccess: jest.fn(), + onupgradeneeded: jest.fn(), + result: {}, + })), + } as any +} diff --git a/redisinsight/ui/src/slices/browser/redisearch.ts b/redisinsight/ui/src/slices/browser/redisearch.ts index 4e5a5a034c..f955c3bd25 100644 --- a/redisinsight/ui/src/slices/browser/redisearch.ts +++ b/redisinsight/ui/src/slices/browser/redisearch.ts @@ -23,6 +23,7 @@ import { SearchHistoryItem } from 'uiSrc/slices/interfaces/keys' import { GetKeysWithDetailsResponse } from 'apiSrc/modules/browser/keys/dto' import { CreateRedisearchIndexDto, + IndexDeleteRequestBodyDto, ListRedisearchIndexesResponse, } from 'apiSrc/modules/browser/redisearch/dto' @@ -521,6 +522,43 @@ export function createRedisearchIndexAction( } } +export function deleteRedisearchIndexAction( + data: IndexDeleteRequestBodyDto, + onSuccess?: (data: IndexDeleteRequestBodyDto) => void, + onFailed?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + try { + const state = stateInit() + const { encoding } = state.app.info + const { status } = await apiService.delete( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.REDISEARCH, + ), + { + data, + params: { encoding }, + }, + ) + + if (isStatusSuccessful(status)) { + dispatch( + addMessageNotification( + successMessages.DELETE_INDEX(bufferToString(data.index as string)), + ), + ) + dispatch(fetchRedisearchListAction()) + onSuccess?.(data) + } + } catch (_err) { + const error = _err as AxiosError + dispatch(addErrorNotification(error)) + onFailed?.() + } + } +} + export function fetchRedisearchHistoryAction( onSuccess?: () => void, onFailed?: () => void, diff --git a/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts b/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts index 94848f16a0..49e7fe33a2 100644 --- a/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts @@ -19,6 +19,7 @@ import { fetchKeys, fetchMoreKeys } from 'uiSrc/slices/browser/keys' import { initialState as initialStateInstances } from 'uiSrc/slices/instances/instances' import { RedisDefaultModules } from 'uiSrc/slices/interfaces' import { MOCK_TIMESTAMP } from 'uiSrc/mocks/data/dateNow' +import { IndexDeleteRequestBodyDto } from 'apiSrc/modules/browser/redisearch/dto' import reducer, { initialState, loadKeys, @@ -54,6 +55,7 @@ import reducer, { fetchRedisearchHistoryAction, deleteRedisearchHistoryAction, fetchRedisearchInfoAction, + deleteRedisearchIndexAction, } from '../../browser/redisearch' let store: typeof mockedStore @@ -1392,5 +1394,84 @@ describe('redisearch slice', () => { expect(onFailed).toBeCalled() }) }) + + describe('deleteRedisearchIndexAction', () => { + const indexName = 'index' + const deleteIndexRequestPayload: IndexDeleteRequestBodyDto = { + index: indexName, + } + + const mockSuccessCallback = jest.fn() + const mockErrorCallback = jest.fn() + + beforeEach(() => { + store.clearActions() + jest.clearAllMocks() + }) + + it('should delete index successfully', async () => { + // Arrange + const responsePayload = { status: 204 } + const listResponsePayload = { + data: REDISEARCH_LIST_DATA_MOCK, + status: 200, + } + + apiService.delete = jest.fn().mockResolvedValue(responsePayload) + apiService.get = jest.fn().mockResolvedValue(listResponsePayload) + + // Act + await store.dispatch( + deleteRedisearchIndexAction( + deleteIndexRequestPayload, + mockSuccessCallback, + mockErrorCallback, + ), + ) + + // Assert + const expectedActions = [ + addMessageNotification(successMessages.DELETE_INDEX(indexName)), + loadList(), + loadListSuccess(REDISEARCH_LIST_DATA_MOCK.indexes), + ] + expect(store.getActions()).toEqual(expectedActions) + expect(mockSuccessCallback).toHaveBeenCalled() + expect(mockErrorCallback).not.toHaveBeenCalled() + }) + + it('should fail to delete index', async () => { + // Arrange + const errorMessage = 'Mock error message' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.delete = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + deleteRedisearchIndexAction( + deleteIndexRequestPayload, + mockSuccessCallback, + mockErrorCallback, + ), + ) + + // Assert + const expectedActions = [ + { + type: 'notifications/addErrorNotification', + payload: responsePayload, + }, + ] + expect(store.getActions()).toEqual(expectedActions) + expect(mockSuccessCallback).not.toHaveBeenCalled() + expect(mockErrorCallback).toHaveBeenCalled() + }) + }) }) }) diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 711f046398..9be4862d4b 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -227,6 +227,7 @@ export enum TelemetryEvent { SEARCH_INDEX_ADD_CANCELLED = 'SEARCH_INDEX_ADD_CANCELLED', SEARCH_KEYS_SEARCHED = 'SEARCH_KEYS_SEARCHED', SEARCH_INDEX_ADDED = 'SEARCH_INDEX_ADDED', + SEARCH_INDEX_DELETED = 'SEARCH_INDEX_DELETED', ONBOARDING_TOUR_CLICKED = 'ONBOARDING_TOUR_CLICKED', ONBOARDING_TOUR_ACTION_MADE = 'ONBOARDING_TOUR_ACTION_MADE', ONBOARDING_TOUR_TRIGGERED = 'ONBOARDING_TOUR_TRIGGERED', @@ -343,4 +344,28 @@ export enum TelemetryEvent { OVERVIEW_AUTO_REFRESH_ENABLED = 'OVERVIEW_AUTO_REFRESH_ENABLED', OVERVIEW_AUTO_REFRESH_DISABLED = 'OVERVIEW_AUTO_REFRESH_DISABLED', + + VECTOR_SEARCH_ONBOARDING_TRIGGERED = 'VECTOR_SEARCH_ONBOARDING_TRIGGERED', + VECTOR_SEARCH_ONBOARDING_PROCEED_TO_INDEX_INFO = 'VECTOR_SEARCH_ONBOARDING_PROCEED_TO_INDEX_INFO', + VECTOR_SEARCH_ONBOARDING_VIEW_COMMAND_PREVIEW = 'VECTOR_SEARCH_ONBOARDING_VIEW_COMMAND_PREVIEW', + VECTOR_SEARCH_ONBOARDING_PROCEED_TO_QUERIES = 'VECTOR_SEARCH_ONBOARDING_PROCEED_TO_QUERIES', + SEARCH_SAVED_QUERIES_PANEL_OPENED = 'SEARCH_SAVED_QUERIES_PANEL_OPENED', + SEARCH_SAVED_QUERIES_PANEL_CLOSED = 'SEARCH_SAVED_QUERIES_PANEL_CLOSED', + SEARCH_SAVED_QUERIES_INSERT_CLICKED = 'SEARCH_SAVED_QUERIES_INSERT_CLICKED', + SEARCH_SAVED_QUERIES_INDEX_CHANGED = 'SEARCH_SAVED_QUERIES_INDEX_CHANGED', + SEARCH_COMMAND_SUBMITTED = 'SEARCH_COMMAND_SUBMITTED', + // SEARCH_RESULT_VIEW_CHANGED = 'SEARCH_RESULT_VIEW_CHANGED', Note: Currently, we don't have this action in the UI, so we can't track it. + SEARCH_COMMAND_COPIED = 'SEARCH_COMMAND_COPIED', + SEARCH_COMMAND_RUN_AGAIN = 'SEARCH_COMMAND_RUN_AGAIN', + SEARCH_RESULTS_IN_FULL_SCREEN = 'SEARCH_RESULTS_IN_FULL_SCREEN', + SEARCH_RESULTS_COLLAPSED = 'SEARCH_RESULTS_COLLAPSED', + SEARCH_RESULTS_EXPANDED = 'SEARCH_RESULTS_EXPANDED', + SEARCH_CLEAR_RESULT_CLICKED = 'SEARCH_CLEAR_RESULT_CLICKED', + SEARCH_CLEAR_ALL_RESULTS_CLICKED = 'SEARCH_CLEAR_ALL_RESULTS_CLICKED', + SEARCH_CLEAR_EDITOR_CLICKED = 'SEARCH_CLEAR_EDITOR_CLICKED', + SEARCH_MANAGE_INDEXES_DRAWER_OPENED = 'SEARCH_MANAGE_INDEXES_DRAWER_OPENED', + SEARCH_MANAGE_INDEXES_DRAWER_CLOSED = 'SEARCH_MANAGE_INDEXES_DRAWER_CLOSED', + SEARCH_MANAGE_INDEX_DETAILS_OPENED = 'SEARCH_MANAGE_INDEX_DETAILS_OPENED', + SEARCH_MANAGE_INDEX_DETAILS_CLOSED = 'SEARCH_MANAGE_INDEX_DETAILS_CLOSED', + SEARCH_MANAGE_INDEX_DELETED = 'SEARCH_MANAGE_INDEX_DELETED', } diff --git a/redisinsight/ui/src/telemetry/pageViews.ts b/redisinsight/ui/src/telemetry/pageViews.ts index 1d4960beb0..675a3595ab 100644 --- a/redisinsight/ui/src/telemetry/pageViews.ts +++ b/redisinsight/ui/src/telemetry/pageViews.ts @@ -13,4 +13,5 @@ export enum TelemetryPageView { RDI_CONFIG = 'RDI Configuration', RDI_JOBS = 'RDI Jobs', RDI_STATUS = 'RDI Status', + VECTOR_SEARCH_PAGE = 'Vector Search', } diff --git a/redisinsight/ui/src/telemetry/tests/usePageViewTelemetry.spec.ts b/redisinsight/ui/src/telemetry/tests/usePageViewTelemetry.spec.ts new file mode 100644 index 0000000000..f5e0072316 --- /dev/null +++ b/redisinsight/ui/src/telemetry/tests/usePageViewTelemetry.spec.ts @@ -0,0 +1,104 @@ +import { renderHook, act, cleanup } from '@testing-library/react-hooks' +import * as reactRedux from 'react-redux' +import { faker } from '@faker-js/faker' +import { cloneDeep } from 'lodash' + +import { mockedStore } from 'uiSrc/utils/test-utils' +import { sendPageViewTelemetry } from 'uiSrc/telemetry' +import { + INSTANCE_ID_MOCK, + INSTANCES_MOCK, +} from 'uiSrc/mocks/handlers/instances/instancesHandlers' + +import { usePageViewTelemetry } from '../usePageViewTelemetry' +import { TelemetryPageView } from '../pageViews' + +// Mock the telemetry module, so we don't send actual telemetry data during tests +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendPageViewTelemetry: jest.fn(), +})) + +describe('usePageViewTelemetry', () => { + let store: typeof mockedStore + let mockUseSelector: jest.SpyInstance + + const mockPage = faker.helpers.enumValue(TelemetryPageView) + + beforeEach(() => { + jest.clearAllMocks() + + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() + + mockUseSelector = jest.spyOn(reactRedux, 'useSelector') + mockUseSelector.mockReturnValue(INSTANCES_MOCK[0]) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should send page view telemetry on mount if connected to instance', () => { + renderHook(() => usePageViewTelemetry({ page: mockPage })) + + expect(sendPageViewTelemetry).toHaveBeenCalledTimes(1) + expect(sendPageViewTelemetry).toHaveBeenCalledWith({ + name: mockPage, + eventData: { databaseId: INSTANCE_ID_MOCK }, + }) + }) + + it('should not send page view telemetry if instanceId is not available', () => { + mockUseSelector.mockReturnValueOnce(null) + + renderHook(() => usePageViewTelemetry({ page: mockPage })) + + expect(sendPageViewTelemetry).not.toHaveBeenCalled() + }) + + it('should not send page view telemetry if already sent', () => { + const { rerender } = renderHook(() => + usePageViewTelemetry({ page: mockPage }), + ) + + // Verify initial telemetry call + expect(sendPageViewTelemetry).toHaveBeenCalledTimes(1) + + // Simulate instance id change in the selector + mockUseSelector.mockReturnValue(INSTANCES_MOCK[1]) + rerender() + + // Should not send telemetry again + expect(sendPageViewTelemetry).toHaveBeenCalledTimes(1) + }) + + it('should send page view telemetry with when called manually', () => { + const { result } = renderHook(() => + usePageViewTelemetry({ page: mockPage }), + ) + + // Verify initial telemetry call + expect(sendPageViewTelemetry).toHaveBeenCalledTimes(1) + expect(sendPageViewTelemetry).toHaveBeenCalledWith({ + name: mockPage, + eventData: { databaseId: INSTANCE_ID_MOCK }, + }) + + // Call the sendPageView method manually, with custom parameters + const customPage = faker.helpers.enumValue(TelemetryPageView) + const customInstanceId = 'custom-instance-1' + + act(() => { + result.current.sendPageView(customPage, customInstanceId) + }) + + // Verify that the telemetry was sent with the custom parameters + expect(sendPageViewTelemetry).toHaveBeenCalledTimes(2) + expect(sendPageViewTelemetry).toHaveBeenCalledWith({ + name: customPage, + eventData: { databaseId: customInstanceId }, + }) + }) +}) diff --git a/redisinsight/ui/src/telemetry/usePageViewTelemetry.ts b/redisinsight/ui/src/telemetry/usePageViewTelemetry.ts new file mode 100644 index 0000000000..07b1915a9f --- /dev/null +++ b/redisinsight/ui/src/telemetry/usePageViewTelemetry.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' + +import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' + +interface PageViewTelemetryProps { + page: TelemetryPageView +} + +interface PageViewTelemetryHook { + sendPageView: (page: TelemetryPageView, instanceId: string) => void // Let the developers manually send page view telemetry, with custom parameters when needed +} + +export const usePageViewTelemetry = ({ + page, +}: PageViewTelemetryProps): PageViewTelemetryHook => { + const [isPageViewSent, setIsPageViewSent] = useState(false) + const { id: instanceId } = useSelector(connectedInstanceSelector) + + // By default, send page view telemetry on page mount if instanceId is available + useEffect(() => { + if (instanceId && !isPageViewSent) { + sendPageView(page, instanceId) + } + }, [instanceId, isPageViewSent]) + + const sendPageView = (page: TelemetryPageView, instanceId: string) => { + sendPageViewTelemetry({ + name: page, + eventData: { + databaseId: instanceId, + }, + }) + setIsPageViewSent(true) + } + + return { + sendPageView, + } +} diff --git a/redisinsight/ui/src/utils/index/generateFtCreateCommand.spec.ts b/redisinsight/ui/src/utils/index/generateFtCreateCommand.spec.ts new file mode 100644 index 0000000000..9771d00b2a --- /dev/null +++ b/redisinsight/ui/src/utils/index/generateFtCreateCommand.spec.ts @@ -0,0 +1,24 @@ +import { generateFtCreateCommand } from './generateFtCreateCommand' + +describe('generateFtCreateCommand', () => { + it('returns the expected hardcoded FT.CREATE command', () => { + const result = generateFtCreateCommand() + + expect(result).toBe(`FT.CREATE idx:bikes_vss + ON HASH + PREFIX 1 "bikes:" + SCHEMA + "model" TEXT NOSTEM SORTABLE + "brand" TEXT NOSTEM SORTABLE + "price" NUMERIC SORTABLE + "type" TAG + "material" TAG + "weight" NUMERIC SORTABLE + "description_embeddings" VECTOR "FLAT" 10 + "TYPE" FLOAT32 + "DIM" 768 + "DISTANCE_METRIC" "L2" + "INITIAL_CAP" 111 + "BLOCK_SIZE" 111`) + }) +}) diff --git a/redisinsight/ui/src/utils/index/generateFtCreateCommand.ts b/redisinsight/ui/src/utils/index/generateFtCreateCommand.ts new file mode 100644 index 0000000000..84c4a313ee --- /dev/null +++ b/redisinsight/ui/src/utils/index/generateFtCreateCommand.ts @@ -0,0 +1,19 @@ +// TODO: Since v1 would use predefined data, return a hardcoded command +// instead of generating it dynamically. + +export const generateFtCreateCommand = (): string => `FT.CREATE idx:bikes_vss + ON HASH + PREFIX 1 "bikes:" + SCHEMA + "model" TEXT NOSTEM SORTABLE + "brand" TEXT NOSTEM SORTABLE + "price" NUMERIC SORTABLE + "type" TAG + "material" TAG + "weight" NUMERIC SORTABLE + "description_embeddings" VECTOR "FLAT" 10 + "TYPE" FLOAT32 + "DIM" 768 + "DISTANCE_METRIC" "L2" + "INITIAL_CAP" 111 + "BLOCK_SIZE" 111` diff --git a/yarn.lock b/yarn.lock index a93d975e97..1432f75cb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1485,6 +1485,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@faker-js/faker@^9.9.0": + version "9.9.0" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-9.9.0.tgz#3ad015fbbaaae7af3149555e0f22b4b30134c69d" + integrity sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA== + "@floating-ui/core@^1.7.0": version "1.7.0" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.0.tgz#1aff27a993ea1b254a586318c29c3b16ea0f4d0a" @@ -1548,18 +1553,6 @@ dependencies: "@isaacs/balanced-match" "^4.0.1" -"@isaacs/balanced-match@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" - integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== - -"@isaacs/brace-expansion@^5.0.0": - version "5.0.0" - resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" - integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== - dependencies: - "@isaacs/balanced-match" "^4.0.1" - "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -7745,6 +7738,13 @@ find-yarn-workspace-root@^2.0.0: dependencies: micromatch "^4.0.2" +fishery@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/fishery/-/fishery-2.3.1.tgz#94b896a0a8f6c6c7f5987f8dafcdd0b8b1aa81c9" + integrity sha512-eKgpAfx88/dFnLUGhJmq9eslN6nsHUcCR13Th1z6tLZixUtKjW/33MqKuzxGtYmhzUh2yLYZxq4jHxIQd3F04A== + dependencies: + lodash.mergewith "^4.6.2" + flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" @@ -10133,6 +10133,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.mergewith@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" + integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== + lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"