mirror of https://github.com/rancher/dashboard.git
Merge 520d8ce675 into ea9fd6f214
This commit is contained in:
commit
e0622e2246
|
|
@ -0,0 +1,5 @@
|
|||
# Built-in icon images that can be used for Dynamic Content
|
||||
|
||||
These are referenced in the dynamic content package with the '~' prefix.
|
||||
|
||||
Light theme mode images are in this folder. The version of the image for dark theme mode should be in the `dark` sub-folder.
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 25.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 360 360"
|
||||
style="enable-background:new 0 0 360 360;"
|
||||
xml:space="preserve"
|
||||
id="svg3"
|
||||
sodipodi:docname="cloud-native.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3">
|
||||
|
||||
|
||||
</defs><sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="2.1666667"
|
||||
inkscape:cx="179.76923"
|
||||
inkscape:cy="179.76923"
|
||||
inkscape:window-width="2288"
|
||||
inkscape:window-height="1302"
|
||||
inkscape:window-x="757"
|
||||
inkscape:window-y="36"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg3" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style1">
|
||||
.st0{display:none;}
|
||||
.st1{display:inline;fill:#0C322C;}
|
||||
.st2{fill:none;stroke:#EFEFEF;stroke-width:17;stroke-miterlimit:10;}
|
||||
.st3{fill:none;stroke:#30BA77;stroke-width:17;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<g
|
||||
id="Background"
|
||||
class="st0">
|
||||
<rect
|
||||
x="-207"
|
||||
y="-1080"
|
||||
class="st1"
|
||||
width="2862"
|
||||
height="2070"
|
||||
id="rect1" />
|
||||
</g>
|
||||
<path
|
||||
class="st2"
|
||||
d="M 72,216 H 36 v -54 c 0,-24.85 20.15,-45 45,-45 h 9 V 90 c 0,-24.85 20.15,-45 45,-45 h 99 c 24.85,0 45,20.15 45,45 v 45 0 c 24.85,0 45,20.15 45,45 v 36 h -36"
|
||||
id="path1"
|
||||
style="stroke:#e05f00;stroke-opacity:1" /><g
|
||||
id="g3">
|
||||
<polyline
|
||||
class="st3"
|
||||
points="194.46,168.24 239.99,159.99 231.74,114.46 "
|
||||
id="polyline1" />
|
||||
<path
|
||||
class="st3"
|
||||
d="m 106.61,245.4 c -13.06,-28.81 -7.75,-63.92 15.94,-87.61 30.54,-30.54 80.06,-30.54 110.6,0"
|
||||
id="path2" />
|
||||
<polyline
|
||||
class="st3"
|
||||
points="167.26,262.25 121.73,270.5 129.98,316.03 "
|
||||
id="polyline2" />
|
||||
<path
|
||||
class="st3"
|
||||
d="m 256.76,189.04 c 10.89,28.02 5.02,61.05 -17.6,83.67 -30.54,30.54 -80.06,30.54 -110.6,0"
|
||||
id="path3" />
|
||||
</g>
|
||||
<g
|
||||
id="Outlines">
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 361 361" style="enable-background:new 0 0 361 361;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:none;stroke:#0C322C;stroke-width:17;stroke-miterlimit:10;}
|
||||
.st1{fill:none;stroke:#30BA77;stroke-width:17;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<g id="Layer_1">
|
||||
<path class="st0" d="M72.5,216.5h-36v-54c0-24.85,20.15-45,45-45h9v-27c0-24.85,20.15-45,45-45h99c24.85,0,45,20.15,45,45v45h0
|
||||
c24.85,0,45,20.15,45,45v36h-36"/>
|
||||
<g>
|
||||
<polyline class="st1" points="194.96,168.74 240.49,160.49 232.24,114.96 "/>
|
||||
<path class="st1" d="M107.11,245.9c-13.06-28.81-7.75-63.92,15.94-87.61c30.54-30.54,80.06-30.54,110.6,0"/>
|
||||
<polyline class="st1" points="167.76,262.75 122.23,271 130.48,316.53 "/>
|
||||
<path class="st1" d="M257.26,189.54c10.89,28.02,5.02,61.05-17.6,83.67c-30.54,30.54-80.06,30.54-110.6,0"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Outlines">
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,59 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
id="Artwork"
|
||||
viewBox="0 0 40 40"
|
||||
version="1.1"
|
||||
sodipodi:docname="shield.svg"
|
||||
width="400"
|
||||
height="400"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
style="fill:none;stroke:#30ba78;stroke-width:2;"
|
||||
xml:space="preserve"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#f6f6f6"
|
||||
bordercolor="#515151"
|
||||
borderopacity="0.65098039"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#e2e2e2"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="297.5"
|
||||
inkscape:cy="-39.5"
|
||||
inkscape:window-width="1616"
|
||||
inkscape:window-height="1186"
|
||||
inkscape:window-x="745"
|
||||
inkscape:window-y="46"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="Artwork"
|
||||
showguides="true"><inkscape:grid
|
||||
id="grid3"
|
||||
units="px"
|
||||
originx="0"
|
||||
originy="0"
|
||||
spacingx="1"
|
||||
spacingy="1"
|
||||
empcolor="#5a5a5a"
|
||||
empopacity="0.2627451"
|
||||
color="#555555"
|
||||
opacity="0.0862745"
|
||||
empspacing="2"
|
||||
dotted="false"
|
||||
gridanglex="30"
|
||||
gridanglez="30"
|
||||
visible="false" /></sodipodi:namedview><defs
|
||||
id="defs1"><style
|
||||
id="style1">.cls-1,.cls-2{fill:#2453ff;stroke-width:0}.cls-2{fill:#30ba78}</style><style
|
||||
id="style1-2">.cls-1,.cls-2{fill:#2453ff;stroke-width:0}.cls-2{fill:#30ba78}</style></defs><path
|
||||
style="color:#ffffff;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#30ba78;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 30,10 v 13 l -9,7.375 V 33 L 32,24 V 10 Z"
|
||||
id="path25"
|
||||
sodipodi:nodetypes="ccccccc" /><path
|
||||
style="fill:#90ebcd;fill-rule:evenodd;stroke:none;fill-opacity:1"
|
||||
d="M 21,1.5859375 16.585937,6 H 6 V 25.458984 L 21,38.316406 36,25.458984 V 6 H 25.414062 Z M 21,4.4140625 24.585937,8 H 34 V 24.541016 L 21,35.683594 8,24.541016 V 8 h 9.414062 z"
|
||||
id="rect18" /></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0" width="180" height="165" viewBox="0, 0, 180, 165">
|
||||
<g id="Layer_1">
|
||||
<g>
|
||||
<path d="M148.462,130.726 L134.906,130.726 C133.862,130.726 133.01,129.877 133.01,128.831 L133.01,121.982 L146.093,121.982 C147.217,121.982 148.13,121.071 148.13,119.946 C148.13,118.821 147.217,117.91 146.093,117.91 L133.01,117.91 L133.01,111.171 C133.01,110.125 133.862,109.273 134.906,109.273 L148.462,109.273 C149.642,109.273 150.6,108.318 150.6,107.138 C150.6,105.958 149.642,105 148.462,105 L134.906,105 C131.507,105 128.738,107.768 128.738,111.171 L128.738,128.831 C128.738,132.232 131.507,135 134.906,135 L148.462,135 C149.642,135 150.6,134.044 150.6,132.864 C150.6,131.683 149.642,130.726 148.462,130.726 M108.601,117.965 C105.736,117.451 103.743,116.842 102.627,116.133 C101.511,115.424 100.952,114.466 100.952,113.259 C100.952,111.963 101.525,110.899 102.672,110.069 C103.819,109.24 105.432,108.825 107.516,108.825 C109.627,108.825 111.317,109.231 112.585,110.047 C113.266,110.485 113.888,111.069 114.448,111.797 C115.274,112.868 116.832,113.009 117.837,112.105 C118.783,111.251 118.866,109.786 118.014,108.837 C116.987,107.688 115.817,106.778 114.507,106.109 C112.561,105.114 110.215,104.616 107.471,104.616 C105.116,104.616 103.057,105.024 101.294,105.838 C99.529,106.651 98.176,107.754 97.243,109.141 C96.306,110.528 95.839,112.068 95.839,113.757 C95.839,115.357 96.207,116.715 96.949,117.83 C97.686,118.947 98.872,119.882 100.501,120.636 C102.129,121.391 104.316,122.024 107.063,122.537 C109.809,123.05 111.731,123.638 112.832,124.301 C113.934,124.966 114.485,125.84 114.485,126.925 C114.485,128.284 113.874,129.333 112.65,130.071 C111.431,130.811 109.748,131.18 107.606,131.18 C105.343,131.18 103.45,130.758 101.926,129.913 C101.056,129.43 100.277,128.79 99.586,127.993 C98.718,126.985 97.162,126.962 96.222,127.902 L96.213,127.911 C95.353,128.77 95.287,130.162 96.099,131.066 C98.681,133.949 102.533,135.388 107.65,135.388 C110.004,135.388 112.078,135.034 113.874,134.325 C115.669,133.617 117.063,132.599 118.059,131.271 C119.056,129.943 119.553,128.404 119.553,126.654 C119.553,125.025 119.191,123.661 118.467,122.559 C117.742,121.459 116.59,120.538 115.005,119.799 C113.422,119.059 111.286,118.449 108.601,117.965 M41.771,117.975 C38.905,117.459 36.913,116.852 35.796,116.143 C34.68,115.432 34.122,114.475 34.122,113.267 C34.122,111.971 34.696,110.907 35.842,110.077 C36.989,109.249 38.602,108.833 40.685,108.833 C42.795,108.833 44.485,109.24 45.753,110.055 C46.436,110.493 47.057,111.077 47.617,111.807 C48.442,112.877 50.001,113.017 51.007,112.114 C51.953,111.258 52.035,109.795 51.183,108.845 C50.156,107.697 48.985,106.788 47.676,106.117 C45.73,105.122 43.383,104.625 40.639,104.625 C38.285,104.625 36.227,105.032 34.462,105.846 C32.697,106.661 31.346,107.763 30.411,109.149 C29.476,110.538 29.009,112.076 29.009,113.766 C29.009,115.365 29.377,116.724 30.117,117.838 C30.856,118.956 32.041,119.891 33.67,120.645 C35.299,121.399 37.486,122.032 40.231,122.546 C42.977,123.059 44.9,123.647 46.002,124.311 C47.102,124.973 47.654,125.849 47.654,126.934 C47.654,128.292 47.042,129.341 45.821,130.08 C44.599,130.82 42.917,131.189 40.776,131.189 C38.512,131.189 36.619,130.767 35.096,129.921 C34.225,129.438 33.446,128.798 32.757,128.001 C31.889,126.994 30.33,126.97 29.39,127.911 L29.382,127.92 C28.522,128.778 28.457,130.171 29.267,131.075 C31.85,133.957 35.701,135.397 40.82,135.397 C43.173,135.397 45.247,135.042 47.042,134.333 C48.838,133.625 50.232,132.606 51.228,131.279 C52.225,129.953 52.722,128.412 52.722,126.662 C52.722,125.034 52.36,123.669 51.635,122.568 C50.911,121.467 49.759,120.547 48.173,119.808 C46.59,119.067 44.456,118.457 41.771,117.975 M86.342,107.06 L86.342,123.487 C86.342,127.409 85.302,130.373 83.221,132.38 C81.138,134.386 78.107,135.388 74.125,135.388 C70.142,135.388 67.109,134.386 65.028,132.38 C62.947,130.373 61.906,127.409 61.906,123.487 L61.906,107.06 C61.906,105.71 63,104.616 64.349,104.616 C65.697,104.616 66.794,105.71 66.794,107.06 L66.794,122.899 C66.794,125.735 67.389,127.824 68.581,129.166 C69.773,130.508 71.619,131.18 74.125,131.18 C76.629,131.18 78.476,130.508 79.668,129.166 C80.86,127.824 81.455,125.735 81.455,122.899 L81.455,107.06 C81.455,105.71 82.55,104.616 83.9,104.616 C85.248,104.616 86.342,105.71 86.342,107.06" fill="#ffffff"/>
|
||||
<path d="M144.609,50.569 C143.952,51.005 143.071,51.006 142.414,50.569 C141.335,49.853 141.232,48.358 142.102,47.49 C142.874,46.69 144.148,46.69 144.921,47.489 C145.789,48.357 145.684,49.853 144.609,50.569 M147.749,46.208 C149.001,51.537 144.212,56.329 138.883,55.077 C136.171,54.44 134.015,52.287 133.381,49.574 C132.13,44.248 136.917,39.46 142.245,40.709 C144.956,41.342 147.11,43.495 147.749,46.208 M111.867,74.867 C112.479,75.743 112.983,76.588 113.269,77.433 C113.469,78.035 113.725,78.826 114.319,79.144 C114.353,79.162 114.381,79.177 114.416,79.188 C115.503,79.584 118.3,79.517 118.3,79.517 L123.442,79.517 C123.881,79.524 127.743,79.513 127.647,79.08 C127.184,77.014 124.792,76.646 122.976,75.565 C121.297,74.565 119.71,73.432 118.986,71.483 C118.612,70.477 118.834,68.156 119.482,67.31 C119.955,66.7 120.647,66.294 121.398,66.13 C122.225,65.953 123.085,66.106 123.916,66.19 C124.937,66.294 125.948,66.479 126.965,66.606 C128.934,66.862 130.918,66.965 132.901,66.911 C136.175,66.82 139.456,66.298 142.559,65.241 C144.724,64.513 146.858,63.53 148.7,62.163 C150.794,60.606 150.246,60.753 148.123,60.97 C145.581,61.231 143.015,61.267 140.467,61.117 C138.088,60.979 135.743,60.699 133.59,59.593 C131.894,58.719 130.439,57.843 129.098,56.488 C128.897,56.284 128.771,55.686 129.139,55.305 C129.495,54.934 130.252,55.15 130.485,55.346 C132.829,57.306 136.322,58.918 139.942,59.094 C141.897,59.191 143.803,59.229 145.761,59.141 C146.738,59.097 148.218,59.104 149.196,59.094 C149.704,59.088 151.085,59.233 151.343,58.697 C151.42,58.54 151.414,58.359 151.408,58.184 C151.12,50.355 150.541,41.521 142.347,37.778 C136.233,34.982 127.066,30.651 123.195,28.854 C122.295,28.429 121.245,29.103 121.245,30.102 C121.245,32.718 121.38,36.476 121.38,39.896 C119.526,38.006 116.402,36.815 114.021,35.722 C111.319,34.481 108.526,33.432 105.689,32.547 C99.969,30.776 94.053,29.686 88.103,29.093 C81.357,28.421 74.495,28.743 67.846,30.115 C56.904,32.385 46.148,37.649 37.982,45.344 C32.969,50.068 29.036,56.78 28.769,63.594 C28.39,73.237 31.091,78.417 36.058,83.754 C43.977,92.26 61.02,93.45 67.922,83.364 C71.025,78.822 71.702,72.663 69.447,67.646 C67.195,62.629 62.014,59.003 56.52,58.819 C52.256,58.679 47.715,60.846 46.082,64.788 C44.835,67.797 45.545,71.516 47.814,73.854 C48.698,74.767 49.895,75.511 51.205,75.219 C51.974,75.049 52.619,74.469 52.736,73.687 C52.908,72.533 51.897,71.785 51.277,70.899 C50.156,69.3 50.383,66.902 51.787,65.543 C52.971,64.396 54.725,64.057 56.374,64.06 C57.908,64.063 59.478,64.339 60.803,65.113 C62.664,66.207 63.902,68.212 64.327,70.333 C65.606,76.666 60.458,81.812 53.476,82.217 C49.905,82.429 46.269,81.489 43.481,79.245 C36.421,73.566 34.691,61.957 42.763,55.759 C50.422,49.877 60.094,51.393 65.793,54.45 C70.358,56.895 73.759,60.897 76.336,65.321 C77.628,67.545 78.73,69.869 79.753,72.232 C80.737,74.502 81.655,76.79 83.624,78.454 C84.928,79.557 86.535,79.517 88.243,79.517 L97.989,79.517 C99.311,79.517 98.991,78.635 98.417,78.05 C97.125,76.73 95.269,76.431 93.549,75.958 C89.623,74.878 90.023,69.679 91.109,69.679 C94.622,69.679 94.733,69.783 97.809,69.742 C102.251,69.682 103.592,69.424 107.063,70.709 C108.921,71.396 110.705,73.211 111.867,74.867" fill="#30BA78"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
|
|
@ -0,0 +1,59 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
id="Artwork"
|
||||
viewBox="0 0 40 40"
|
||||
version="1.1"
|
||||
sodipodi:docname="shield.svg"
|
||||
width="400"
|
||||
height="400"
|
||||
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||
style="fill:none;stroke:#30ba78;stroke-width:2;"
|
||||
xml:space="preserve"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#f6f6f6"
|
||||
bordercolor="#515151"
|
||||
borderopacity="0.65098039"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#e2e2e2"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="298"
|
||||
inkscape:cy="-39"
|
||||
inkscape:window-width="1614"
|
||||
inkscape:window-height="1186"
|
||||
inkscape:window-x="26"
|
||||
inkscape:window-y="23"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="Artwork"
|
||||
showguides="true"><inkscape:grid
|
||||
id="grid3"
|
||||
units="px"
|
||||
originx="0"
|
||||
originy="0"
|
||||
spacingx="1"
|
||||
spacingy="1"
|
||||
empcolor="#5a5a5a"
|
||||
empopacity="0.2627451"
|
||||
color="#555555"
|
||||
opacity="0.0862745"
|
||||
empspacing="2"
|
||||
dotted="false"
|
||||
gridanglex="30"
|
||||
gridanglez="30"
|
||||
visible="false" /></sodipodi:namedview><defs
|
||||
id="defs1"><style
|
||||
id="style1">.cls-1,.cls-2{fill:#2453ff;stroke-width:0}.cls-2{fill:#30ba78}</style><style
|
||||
id="style1-2">.cls-1,.cls-2{fill:#2453ff;stroke-width:0}.cls-2{fill:#30ba78}</style></defs><path
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#30ba78;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 30,10 v 13 l -9,7.375 V 33 L 32,24 V 10 Z"
|
||||
id="path25"
|
||||
sodipodi:nodetypes="ccccccc" /><path
|
||||
style="fill:#0c322c;fill-rule:evenodd;stroke:none"
|
||||
d="M 21,1.5859375 16.585937,6 H 6 V 25.458984 L 21,38.316406 36,25.458984 V 6 H 25.414062 Z M 21,4.4140625 24.585937,8 H 34 V 24.541016 L 21,35.683594 8,24.541016 V 8 h 9.414062 z"
|
||||
id="rect18" /></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0" width="180" height="165" viewBox="0, 0, 180, 165">
|
||||
<g id="Layer_1">
|
||||
<g>
|
||||
<path d="M148.462,130.726 L134.906,130.726 C133.862,130.726 133.01,129.877 133.01,128.831 L133.01,121.982 L146.093,121.982 C147.217,121.982 148.13,121.071 148.13,119.946 C148.13,118.821 147.217,117.91 146.093,117.91 L133.01,117.91 L133.01,111.171 C133.01,110.125 133.862,109.273 134.906,109.273 L148.462,109.273 C149.642,109.273 150.6,108.318 150.6,107.138 C150.6,105.958 149.642,105 148.462,105 L134.906,105 C131.507,105 128.738,107.768 128.738,111.171 L128.738,128.831 C128.738,132.232 131.507,135 134.906,135 L148.462,135 C149.642,135 150.6,134.044 150.6,132.864 C150.6,131.683 149.642,130.726 148.462,130.726 M108.601,117.965 C105.736,117.451 103.743,116.842 102.627,116.133 C101.511,115.424 100.952,114.466 100.952,113.259 C100.952,111.963 101.525,110.899 102.672,110.069 C103.819,109.24 105.432,108.825 107.516,108.825 C109.627,108.825 111.317,109.231 112.585,110.047 C113.266,110.485 113.888,111.069 114.448,111.797 C115.274,112.868 116.832,113.009 117.837,112.105 C118.783,111.251 118.866,109.786 118.014,108.837 C116.987,107.688 115.817,106.778 114.507,106.109 C112.561,105.114 110.215,104.616 107.471,104.616 C105.116,104.616 103.057,105.024 101.294,105.838 C99.529,106.651 98.176,107.754 97.243,109.141 C96.306,110.528 95.839,112.068 95.839,113.757 C95.839,115.357 96.207,116.715 96.949,117.83 C97.686,118.947 98.872,119.882 100.501,120.636 C102.129,121.391 104.316,122.024 107.063,122.537 C109.809,123.05 111.731,123.638 112.832,124.301 C113.934,124.966 114.485,125.84 114.485,126.925 C114.485,128.284 113.874,129.333 112.65,130.071 C111.431,130.811 109.748,131.18 107.606,131.18 C105.343,131.18 103.45,130.758 101.926,129.913 C101.056,129.43 100.277,128.79 99.586,127.993 C98.718,126.985 97.162,126.962 96.222,127.902 L96.213,127.911 C95.353,128.77 95.287,130.162 96.099,131.066 C98.681,133.949 102.533,135.388 107.65,135.388 C110.004,135.388 112.078,135.034 113.874,134.325 C115.669,133.617 117.063,132.599 118.059,131.271 C119.056,129.943 119.553,128.404 119.553,126.654 C119.553,125.025 119.191,123.661 118.467,122.559 C117.742,121.459 116.59,120.538 115.005,119.799 C113.422,119.059 111.286,118.449 108.601,117.965 M41.771,117.975 C38.905,117.459 36.913,116.852 35.796,116.143 C34.68,115.432 34.122,114.475 34.122,113.267 C34.122,111.971 34.696,110.907 35.842,110.077 C36.989,109.249 38.602,108.833 40.685,108.833 C42.795,108.833 44.485,109.24 45.753,110.055 C46.436,110.493 47.057,111.077 47.617,111.807 C48.442,112.877 50.001,113.017 51.007,112.114 C51.953,111.258 52.035,109.795 51.183,108.845 C50.156,107.697 48.985,106.788 47.676,106.117 C45.73,105.122 43.383,104.625 40.639,104.625 C38.285,104.625 36.227,105.032 34.462,105.846 C32.697,106.661 31.346,107.763 30.411,109.149 C29.476,110.538 29.009,112.076 29.009,113.766 C29.009,115.365 29.377,116.724 30.117,117.838 C30.856,118.956 32.041,119.891 33.67,120.645 C35.299,121.399 37.486,122.032 40.231,122.546 C42.977,123.059 44.9,123.647 46.002,124.311 C47.102,124.973 47.654,125.849 47.654,126.934 C47.654,128.292 47.042,129.341 45.821,130.08 C44.599,130.82 42.917,131.189 40.776,131.189 C38.512,131.189 36.619,130.767 35.096,129.921 C34.225,129.438 33.446,128.798 32.757,128.001 C31.889,126.994 30.33,126.97 29.39,127.911 L29.382,127.92 C28.522,128.778 28.457,130.171 29.267,131.075 C31.85,133.957 35.701,135.397 40.82,135.397 C43.173,135.397 45.247,135.042 47.042,134.333 C48.838,133.625 50.232,132.606 51.228,131.279 C52.225,129.953 52.722,128.412 52.722,126.662 C52.722,125.034 52.36,123.669 51.635,122.568 C50.911,121.467 49.759,120.547 48.173,119.808 C46.59,119.067 44.456,118.457 41.771,117.975 M86.342,107.06 L86.342,123.487 C86.342,127.409 85.302,130.373 83.221,132.38 C81.138,134.386 78.107,135.388 74.125,135.388 C70.142,135.388 67.109,134.386 65.028,132.38 C62.947,130.373 61.906,127.409 61.906,123.487 L61.906,107.06 C61.906,105.71 63,104.616 64.349,104.616 C65.697,104.616 66.794,105.71 66.794,107.06 L66.794,122.899 C66.794,125.735 67.389,127.824 68.581,129.166 C69.773,130.508 71.619,131.18 74.125,131.18 C76.629,131.18 78.476,130.508 79.668,129.166 C80.86,127.824 81.455,125.735 81.455,122.899 L81.455,107.06 C81.455,105.71 82.55,104.616 83.9,104.616 C85.248,104.616 86.342,105.71 86.342,107.06" fill="#0C322C"/>
|
||||
<path d="M144.609,50.569 C143.952,51.005 143.071,51.006 142.414,50.569 C141.335,49.853 141.232,48.358 142.102,47.49 C142.874,46.69 144.148,46.69 144.921,47.489 C145.789,48.357 145.684,49.853 144.609,50.569 M147.749,46.208 C149.001,51.537 144.212,56.329 138.883,55.077 C136.171,54.44 134.015,52.287 133.381,49.574 C132.13,44.248 136.917,39.46 142.245,40.709 C144.956,41.342 147.11,43.495 147.749,46.208 M111.867,74.867 C112.479,75.743 112.983,76.588 113.269,77.433 C113.469,78.035 113.725,78.826 114.319,79.144 C114.353,79.162 114.381,79.177 114.416,79.188 C115.503,79.584 118.3,79.517 118.3,79.517 L123.442,79.517 C123.881,79.524 127.743,79.513 127.647,79.08 C127.184,77.014 124.792,76.646 122.976,75.565 C121.297,74.565 119.71,73.432 118.986,71.483 C118.612,70.477 118.834,68.156 119.482,67.31 C119.955,66.7 120.647,66.294 121.398,66.13 C122.225,65.953 123.085,66.106 123.916,66.19 C124.937,66.294 125.948,66.479 126.965,66.606 C128.934,66.862 130.918,66.965 132.901,66.911 C136.175,66.82 139.456,66.298 142.559,65.241 C144.724,64.513 146.858,63.53 148.7,62.163 C150.794,60.606 150.246,60.753 148.123,60.97 C145.581,61.231 143.015,61.267 140.467,61.117 C138.088,60.979 135.743,60.699 133.59,59.593 C131.894,58.719 130.439,57.843 129.098,56.488 C128.897,56.284 128.771,55.686 129.139,55.305 C129.495,54.934 130.252,55.15 130.485,55.346 C132.829,57.306 136.322,58.918 139.942,59.094 C141.897,59.191 143.803,59.229 145.761,59.141 C146.738,59.097 148.218,59.104 149.196,59.094 C149.704,59.088 151.085,59.233 151.343,58.697 C151.42,58.54 151.414,58.359 151.408,58.184 C151.12,50.355 150.541,41.521 142.347,37.778 C136.233,34.982 127.066,30.651 123.195,28.854 C122.295,28.429 121.245,29.103 121.245,30.102 C121.245,32.718 121.38,36.476 121.38,39.896 C119.526,38.006 116.402,36.815 114.021,35.722 C111.319,34.481 108.526,33.432 105.689,32.547 C99.969,30.776 94.053,29.686 88.103,29.093 C81.357,28.421 74.495,28.743 67.846,30.115 C56.904,32.385 46.148,37.649 37.982,45.344 C32.969,50.068 29.036,56.78 28.769,63.594 C28.39,73.237 31.091,78.417 36.058,83.754 C43.977,92.26 61.02,93.45 67.922,83.364 C71.025,78.822 71.702,72.663 69.447,67.646 C67.195,62.629 62.014,59.003 56.52,58.819 C52.256,58.679 47.715,60.846 46.082,64.788 C44.835,67.797 45.545,71.516 47.814,73.854 C48.698,74.767 49.895,75.511 51.205,75.219 C51.974,75.049 52.619,74.469 52.736,73.687 C52.908,72.533 51.897,71.785 51.277,70.899 C50.156,69.3 50.383,66.902 51.787,65.543 C52.971,64.396 54.725,64.057 56.374,64.06 C57.908,64.063 59.478,64.339 60.803,65.113 C62.664,66.207 63.902,68.212 64.327,70.333 C65.606,76.666 60.458,81.812 53.476,82.217 C49.905,82.429 46.269,81.489 43.481,79.245 C36.421,73.566 34.691,61.957 42.763,55.759 C50.422,49.877 60.094,51.393 65.793,54.45 C70.358,56.895 73.759,60.897 76.336,65.321 C77.628,67.545 78.73,69.869 79.753,72.232 C80.737,74.502 81.655,76.79 83.624,78.454 C84.928,79.557 86.535,79.517 88.243,79.517 L97.989,79.517 C99.311,79.517 98.991,78.635 98.417,78.05 C97.125,76.73 95.269,76.431 93.549,75.958 C89.623,74.878 90.023,69.679 91.109,69.679 C94.622,69.679 94.733,69.783 97.809,69.742 C102.251,69.682 103.592,69.424 107.063,70.709 C108.921,71.396 110.705,73.211 111.867,74.867" fill="#30BA78"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
|
|
@ -0,0 +1,96 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Component top render an announcement as a banner
|
||||
*/
|
||||
import DynamicContentIcon from './DynamicContentIcon.vue';
|
||||
import DynamicContentCloseButton from './DynamicContentCloseButton.vue';
|
||||
import { useDynamicContent, DynamicInputProps } from './content';
|
||||
|
||||
const props = defineProps<DynamicInputProps>();
|
||||
const {
|
||||
dynamicContent,
|
||||
invokeAction,
|
||||
primaryButtonStyle,
|
||||
} = useDynamicContent(props, 'banner');
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="dynamicContent"
|
||||
class="home-page-dynamic-content"
|
||||
>
|
||||
<template v-if="dynamicContent.data">
|
||||
<DynamicContentIcon
|
||||
v-if="dynamicContent.data.icon"
|
||||
:icon="dynamicContent.data.icon"
|
||||
:class="{'mr-10': dynamicContent.data.icon }"
|
||||
/>
|
||||
</template>
|
||||
<div class="dc-content">
|
||||
<div class="dc-title">
|
||||
{{ dynamicContent.title }}
|
||||
</div>
|
||||
<div class="dc-message">
|
||||
{{ dynamicContent.message }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dc-actions">
|
||||
<button
|
||||
v-if="dynamicContent.primaryAction"
|
||||
role="button"
|
||||
class="btn btn-sm"
|
||||
:class="primaryButtonStyle"
|
||||
@click.stop.prevent="invokeAction(dynamicContent.primaryAction)"
|
||||
>
|
||||
{{ dynamicContent.primaryAction.label }}
|
||||
</button>
|
||||
<button
|
||||
v-if="dynamicContent.secondaryAction"
|
||||
role="button"
|
||||
class="btn btn-sm role-secondary"
|
||||
@click.stop.prevent="invokeAction(dynamicContent.secondaryAction)"
|
||||
>
|
||||
{{ dynamicContent.secondaryAction.label }}
|
||||
</button>
|
||||
<DynamicContentCloseButton
|
||||
:id="dynamicContent.id"
|
||||
class="dc-close-button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-page-dynamic-content {
|
||||
background-color: var(--box-bg);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
|
||||
.dc-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dc-title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dc-message {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.dc-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Common close button for dynamic component banners and panels
|
||||
*/
|
||||
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
const props = defineProps<{id: string}>();
|
||||
const store = useStore();
|
||||
|
||||
/*
|
||||
* Marks the notification as read when the close button is clicked
|
||||
*/
|
||||
const markRead = () => {
|
||||
store.dispatch('notifications/markRead', props.id);
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<i
|
||||
class="dc-close-button icon icon-close"
|
||||
@click="markRead()"
|
||||
/>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.dc-close-button {
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Icon component to render an icon from the icon data in an announcement
|
||||
*/
|
||||
|
||||
import { useStore } from 'vuex';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { AnnouncementNotificationIconData } from '@shell/utils/dynamic-content/types';
|
||||
|
||||
type KeyValues = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
const ICON_FORMAT = /(@|#|>)/;
|
||||
|
||||
const props = defineProps<{icon: AnnouncementNotificationIconData}>();
|
||||
const store = useStore();
|
||||
const theme = computed(() => store.getters['prefs/theme']);
|
||||
|
||||
/**
|
||||
* Get the correct url to use for light/dark mode
|
||||
*/
|
||||
const url = computed(() => {
|
||||
const darkTheme = theme.value === 'dark';
|
||||
|
||||
return darkTheme ? props.icon?.dark || props.icon?.light : props.icon?.light;
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
const decodedIcon = url.value.split(ICON_FORMAT);
|
||||
|
||||
if (decodedIcon[0].startsWith('!')) {
|
||||
return `icon-${ decodedIcon[0].substring(1) }`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the src value for an image tag
|
||||
*/
|
||||
const src = computed(() => {
|
||||
const decodedIcon = url.value.split(ICON_FORMAT);
|
||||
|
||||
// If the icon name starts with ~, then it references a built-in icon/image
|
||||
if (decodedIcon[0].startsWith('~')) {
|
||||
const img = decodedIcon[0].substring(1);
|
||||
const themePrefix = theme.value === 'dark' ? 'dark/' : '';
|
||||
|
||||
try {
|
||||
return require(`~shell/assets/images/content/${ themePrefix }${ img }`);
|
||||
} catch {
|
||||
return require(`~shell/assets/images/content/${ img }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular URL, use it directly
|
||||
return decodedIcon[0];
|
||||
});
|
||||
|
||||
/**
|
||||
* Icon value can include some custom style information:
|
||||
*
|
||||
* '@wxh' (or @w) To change the width/height
|
||||
* '>x' to set the padding to x px
|
||||
* '<x' to set the margin to x px
|
||||
* '#rrggbb' to set the color
|
||||
*
|
||||
*/
|
||||
const style = computed(() => {
|
||||
const decodedIcon = props.icon.light.split(ICON_FORMAT).slice(1);
|
||||
const OPTIONS: { [key: string]: (v: string, result: KeyValues) => void } = {
|
||||
'@': (v: string, result: KeyValues) => {
|
||||
const wh = v.split('x');
|
||||
|
||||
result.width = `${ wh[0] }px`;
|
||||
result.height = (wh.length === 2) ? `${ wh[1] }px` : `${ wh[0] }px`;
|
||||
result.fontSize = result.width;
|
||||
},
|
||||
'#': (v: string, result: KeyValues) => {
|
||||
result.color = `#${ v }`;
|
||||
},
|
||||
'>': (v: string, result: KeyValues) => {
|
||||
result.padding = `${ v }px`;
|
||||
},
|
||||
'<': (v: string, result: KeyValues) => {
|
||||
result.margin = `${ v }px`;
|
||||
}
|
||||
};
|
||||
|
||||
const pairs = Math.floor(decodedIcon.length / 2);
|
||||
const result = {};
|
||||
|
||||
for (let i = 0; i < pairs; i++) {
|
||||
const index = 2 * i;
|
||||
|
||||
if (OPTIONS[decodedIcon[index]]) {
|
||||
const handler = OPTIONS[decodedIcon[index]];
|
||||
const value = decodedIcon[index + 1];
|
||||
|
||||
if (handler) {
|
||||
handler(value, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<i
|
||||
v-if="iconName"
|
||||
class="icon"
|
||||
:style="style"
|
||||
:class="iconName"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:style="style"
|
||||
:src="src"
|
||||
class="dc-icon"
|
||||
>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dc-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Component top render an announcement as a side panel
|
||||
*/
|
||||
import DynamicContentIcon from './DynamicContentIcon.vue';
|
||||
import DynamicContentCloseButton from './DynamicContentCloseButton.vue';
|
||||
import Markdown from '@shell/components/Markdown.vue';
|
||||
import { useDynamicContent, DynamicInputProps } from './content';
|
||||
|
||||
const props = defineProps<DynamicInputProps>();
|
||||
const {
|
||||
dynamicContent,
|
||||
invokeAction,
|
||||
primaryButtonStyle,
|
||||
} = useDynamicContent(props, 'rhs');
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="dynamicContent"
|
||||
:compact="true"
|
||||
:can-close="true"
|
||||
class="dc-side-panel mt-10"
|
||||
>
|
||||
<div class="dc-title-block">
|
||||
<DynamicContentIcon
|
||||
v-if="dynamicContent.data.icon"
|
||||
:icon="dynamicContent.data.icon"
|
||||
:class="{'mr-10': dynamicContent.data.icon }"
|
||||
/>
|
||||
<div class="dc-title">
|
||||
{{ dynamicContent.title }}
|
||||
</div>
|
||||
<DynamicContentCloseButton
|
||||
:id="dynamicContent.id"
|
||||
class="dc-close-button"
|
||||
/>
|
||||
</div>
|
||||
<div class="dc-content">
|
||||
<div class="dc-message">
|
||||
<Markdown
|
||||
v-if="dynamicContent.message"
|
||||
v-model:value="dynamicContent.message"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dc-actions">
|
||||
<button
|
||||
v-if="dynamicContent.primaryAction"
|
||||
role="button"
|
||||
class="btn btn-sm"
|
||||
:class="primaryButtonStyle"
|
||||
@click.stop.prevent="invokeAction(dynamicContent.primaryAction)"
|
||||
>
|
||||
{{ dynamicContent.primaryAction.label }}
|
||||
</button>
|
||||
<button
|
||||
v-if="dynamicContent.secondaryAction"
|
||||
role="button"
|
||||
class="btn btn-sm role-secondary"
|
||||
@click.stop.prevent="invokeAction(dynamicContent.secondaryAction)"
|
||||
>
|
||||
{{ dynamicContent.secondaryAction.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$dc-padding: 8px;
|
||||
|
||||
.dc-side-panel {
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.dc-title-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 $dc-padding;
|
||||
|
||||
.dc-title {
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.dc-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: $dc-padding;
|
||||
|
||||
.dc-message {
|
||||
font-size: 1em;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
}
|
||||
|
||||
.dc-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: $dc-padding;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Composable to provide access to an announcement
|
||||
*/
|
||||
|
||||
import { computed, ComputedRef } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { Notification, StoredNotification, NotificationAction } from '@shell/types/notifications';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
export type Styles = { [key: string]: string };
|
||||
|
||||
export interface UseDynamicInput {
|
||||
dynamicContent: ComputedRef<Notification | undefined>;
|
||||
primaryButtonStyle: ComputedRef<string>;
|
||||
styles: ComputedRef<Styles>;
|
||||
invokeAction: (action: NotificationAction) => void;
|
||||
}
|
||||
|
||||
export interface DynamicInputProps {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export const useDynamicContent = (props: DynamicInputProps, defaultLocation: string): UseDynamicInput => {
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Return the first un-read hidden notification for the given location
|
||||
const dynamicContent = computed(() => {
|
||||
const location = props.location || defaultLocation;
|
||||
const hiddenUnreadNotificationsForLocation: Notification[] = store.getters['notifications/hidden'].filter((n: StoredNotification) => !n.read && n.data?.location === location);
|
||||
|
||||
return hiddenUnreadNotificationsForLocation.length > 0 ? hiddenUnreadNotificationsForLocation[0] : undefined;
|
||||
});
|
||||
|
||||
const styles = computed(() => {
|
||||
const parts = dynamicContent?.value?.data?.style?.trim().split(',') || [];
|
||||
const res: Styles = {};
|
||||
|
||||
parts.forEach((part: string) => {
|
||||
const kv = part.split(':');
|
||||
|
||||
if (kv.length === 2) {
|
||||
res[kv[0].trim()] = kv[1].trim();
|
||||
}
|
||||
});
|
||||
|
||||
return res;
|
||||
});
|
||||
|
||||
const primaryButtonStyle = computed(() => {
|
||||
const buttonStyle = styles.value.btn === 'link' ? 'tertiary' : styles.value.btn || 'primary';
|
||||
|
||||
return `role-${ buttonStyle }`;
|
||||
});
|
||||
|
||||
// Invoke action (typically from either the primary or secondary buttons of a notification)
|
||||
// This can open a URL in a new tab OR navigate to an application route
|
||||
const invokeAction = (action: NotificationAction) => {
|
||||
if (action.target) {
|
||||
window.open(action.target, '_blank');
|
||||
} else if (action.route) {
|
||||
try {
|
||||
router.push(action.route);
|
||||
} catch (e) {
|
||||
console.error('Error navigating to route for the notification action', e); // eslint-disable-line no-console
|
||||
}
|
||||
} else {
|
||||
console.error('Notification action must either specify a "target" or a "route"'); // eslint-disable-line no-console
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
dynamicContent,
|
||||
invokeAction,
|
||||
primaryButtonStyle,
|
||||
styles
|
||||
};
|
||||
};
|
||||
|
|
@ -11,9 +11,11 @@ import {
|
|||
RcDropdownSeparator,
|
||||
RcDropdownTrigger
|
||||
} from '@components/RcDropdown';
|
||||
import { NotificationLevel, Notification as NotificationType } from '@shell/types/notifications';
|
||||
|
||||
const store = useStore();
|
||||
const allNotifications = computed(() => store.getters['notifications/all']);
|
||||
// We don't want any hidden notifications showing in the notification center (these are shown elsewhere, e.g. home page dynamic content announcements)
|
||||
const allNotifications = computed(() => store.getters['notifications/all'].filter((n: NotificationType) => n.level !== NotificationLevel.Hidden));
|
||||
const unreadLevelClass = computed(() => {
|
||||
return store.getters['notifications/unreadCount'] === 0 ? '' : 'unread';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import internalApiPlugin from '@shell/plugins/internal-api';
|
|||
import 'floating-vue/dist/style.css';
|
||||
import { floatingVueOptions } from '@shell/plugins/floating-vue';
|
||||
|
||||
import dynamicContent from '@shell/plugins/dynamic-content';
|
||||
|
||||
export async function installPlugins(vueApp) {
|
||||
vueApp.use(globalFormatters);
|
||||
vueApp.use(PortalVue);
|
||||
|
|
@ -46,7 +48,7 @@ export async function installPlugins(vueApp) {
|
|||
}
|
||||
|
||||
export async function installInjectedPlugins(app, vueApp) {
|
||||
const pluginDefinitions = [config, axios, plugins, pluginsLoader, axiosShell, intNumber, codeMirror, nuxtClientInit, replaceAll, plugin, steveCreateWorker, emberCookie, internalApiPlugin];
|
||||
const pluginDefinitions = [config, axios, plugins, pluginsLoader, axiosShell, intNumber, codeMirror, nuxtClientInit, replaceAll, plugin, steveCreateWorker, emberCookie, internalApiPlugin, dynamicContent];
|
||||
|
||||
const installations = pluginDefinitions.map(async(pluginDefinition) => {
|
||||
if (typeof pluginDefinition === 'function') {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import PaginatedResourceTable from '@shell/components/PaginatedResourceTable.vue
|
|||
import { BadgeState } from '@components/BadgeState';
|
||||
import CommunityLinks from '@shell/components/CommunityLinks.vue';
|
||||
import SingleClusterInfo from '@shell/components/SingleClusterInfo.vue';
|
||||
import DynamicContentBanner from '@shell/components/DynamicContent/DynamicContentBanner.vue';
|
||||
import DynamicContentPanel from '@shell/components/DynamicContent/DynamicContentPanel.vue';
|
||||
import { mapGetters, mapState } from 'vuex';
|
||||
import { MANAGEMENT, CAPI, COUNT } from '@shell/config/types';
|
||||
import { NAME as MANAGER } from '@shell/config/product/manager';
|
||||
|
|
@ -47,6 +49,8 @@ export default defineComponent({
|
|||
SingleClusterInfo,
|
||||
TabTitle,
|
||||
ResourceTable,
|
||||
DynamicContentBanner,
|
||||
DynamicContentPanel,
|
||||
},
|
||||
|
||||
mixins: [PageHeaderActions, Preset],
|
||||
|
|
@ -610,6 +614,7 @@ export default defineComponent({
|
|||
pref-key="welcomeBanner"
|
||||
data-testid="home-banner-graphic"
|
||||
/>
|
||||
<DynamicContentBanner location="banner" />
|
||||
<IndentedPanel class="mt-20 mb-20">
|
||||
<div class="row home-panels">
|
||||
<div class="col main-panel">
|
||||
|
|
@ -936,7 +941,10 @@ export default defineComponent({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CommunityLinks class="col span-3 side-panel" />
|
||||
<div class="col span-3 side-panel">
|
||||
<CommunityLinks />
|
||||
<DynamicContentPanel location="rhs" />
|
||||
</div>
|
||||
</div>
|
||||
</IndentedPanel>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { createHandler, DynamicContentAnnouncementHandlerName } from '@shell/utils/dynamic-content/notification-handler';
|
||||
import { NotificationHandlerExtensionName } from '@shell/types/notifications';
|
||||
|
||||
/**
|
||||
* Register the notification handler for dynamic content
|
||||
*/
|
||||
export default function(context) {
|
||||
const { store, $extension } = context;
|
||||
|
||||
const handler = createHandler(store);
|
||||
|
||||
$extension.register(NotificationHandlerExtensionName, DynamicContentAnnouncementHandlerName, handler);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { md5 } from '@shell/utils/crypto';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { EncryptedNotification, Notification, StoredNotification } from '@shell/types/notifications';
|
||||
import { EncryptedNotification, Notification, NotificationLevel, StoredNotification } from '@shell/types/notifications';
|
||||
import { encrypt, decrypt, deriveKey } from '@shell/utils/crypto/encryption';
|
||||
|
||||
/**
|
||||
|
|
@ -65,6 +65,9 @@ async function saveEncryptedNotification(getters: any, notification: Notificatio
|
|||
level: notification.level,
|
||||
primaryAction: notification.primaryAction,
|
||||
secondaryAction: notification.secondaryAction,
|
||||
preference: notification.preference,
|
||||
handlerName: notification.handlerName,
|
||||
data: notification.data,
|
||||
};
|
||||
|
||||
const localStorageKey = getters['localStorageKey'];
|
||||
|
|
@ -108,6 +111,14 @@ export const getters = {
|
|||
return state.notifications;
|
||||
},
|
||||
|
||||
visible: (state: NotificationsStore) => {
|
||||
return state.notifications.filter((n) => n.level !== NotificationLevel.Hidden);
|
||||
},
|
||||
|
||||
hidden: (state: NotificationsStore) => {
|
||||
return state.notifications.filter((n) => n.level === NotificationLevel.Hidden);
|
||||
},
|
||||
|
||||
item: (state: NotificationsStore) => {
|
||||
return (id: string) => {
|
||||
return state.notifications.find((i) => i.id === id);
|
||||
|
|
@ -116,7 +127,7 @@ export const getters = {
|
|||
|
||||
// Count of unread notifications
|
||||
unreadCount: (state: NotificationsStore) => {
|
||||
return state.notifications.filter((n) => !n.read).length;
|
||||
return state.notifications.filter((n) => !n.read && n.level !== NotificationLevel.Hidden).length;
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -194,7 +205,7 @@ export const mutations = {
|
|||
|
||||
markAllRead(state: NotificationsStore) {
|
||||
state.notifications.forEach((notification) => {
|
||||
if (!notification.read) {
|
||||
if (!notification.read && notification.level !== NotificationLevel.Hidden) {
|
||||
notification.read = true;
|
||||
}
|
||||
});
|
||||
|
|
@ -252,6 +263,20 @@ export const mutations = {
|
|||
},
|
||||
};
|
||||
|
||||
async function callNotifyHandler({ $extension }: any, notification: Notification, read: boolean) {
|
||||
if (notification?.handlerName) {
|
||||
const handler = $extension.getDynamic('notificationHandler', notification.handlerName);
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
await handler.onReadUpdated(notification, read);
|
||||
} catch (e) {
|
||||
console.error('Error invoking notification handler', e); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
async add( { commit, dispatch, getters }: any, notification: Notification) {
|
||||
// We encrypt the notification on add - this is the only time we will encrypt it
|
||||
|
|
@ -295,6 +320,10 @@ export const actions = {
|
|||
if (notification?.preference) {
|
||||
await dispatch('prefs/set', notification.preference, { root: true });
|
||||
}
|
||||
|
||||
if (notification?.handlerName) {
|
||||
await callNotifyHandler({ $extension: (this as any).$extension }, notification, true);
|
||||
}
|
||||
},
|
||||
|
||||
async markUnread({ commit, dispatch, getters }: any, id: string) {
|
||||
|
|
@ -309,6 +338,10 @@ export const actions = {
|
|||
value: notification.preference.unsetValue || '',
|
||||
}, { root: true });
|
||||
}
|
||||
|
||||
if (notification?.handlerName) {
|
||||
await callNotifyHandler({ $extension: (this as any).$extension }, notification, false);
|
||||
}
|
||||
},
|
||||
|
||||
async markAllRead({ commit, dispatch, getters }: any) {
|
||||
|
|
@ -318,9 +351,18 @@ export const actions = {
|
|||
// For all notifications that have a preference, set the preference, since they are now read
|
||||
const withPreference = getters.all.filter((n: Notification) => !!n.preference);
|
||||
|
||||
// TODO: THIS NEEDS TO INVOKE CUSTOM HANDLERS TOO - This should use markRead?
|
||||
|
||||
for (let i = 0; i < withPreference.length; i++) {
|
||||
await dispatch('prefs/set', withPreference[i].preference, { root: true });
|
||||
}
|
||||
|
||||
// For all notifications that have a handler, call the handler
|
||||
const withHandler = getters.all.filter((n: Notification) => !!n.handlerName);
|
||||
|
||||
for (let i = 0; i < withHandler.length; i++) {
|
||||
await callNotifyHandler({ $extension: (this as any).$extension }, withHandler[i], true);
|
||||
}
|
||||
},
|
||||
|
||||
remove({ commit, getters }: any, id: string) {
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export const SCALE_POOL_PROMPT = create('scale-pool-prompt', null, { parseJSON }
|
|||
export const READ_NEW_RELEASE = create('read-new-release', '', { parseJSON });
|
||||
export const READ_SUPPORT_NOTICE = create('read-support-notice', '', { parseJSON });
|
||||
export const READ_UPCOMING_SUPPORT_NOTICE = create('read-upcoming-support-notice', '', { parseJSON });
|
||||
export const READ_ANNOUNCEMENTS = create('read-announcements', '', { parseJSON });
|
||||
|
||||
// --------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export enum NotificationLevel {
|
|||
Success, // eslint-disable-line no-unused-vars
|
||||
Warning, // eslint-disable-line no-unused-vars
|
||||
Error, // eslint-disable-line no-unused-vars
|
||||
Hidden, // eslint-disable-line no-unused-vars
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -29,7 +30,7 @@ export type NotificationAction = {
|
|||
* Defines the User Preference linked to a notification
|
||||
*/
|
||||
export type NotificationPreference = {
|
||||
key: string; // User preference key to use when setting the preference when the notification is marked as read
|
||||
key: string; // User preference key to use when setting the preference when the notification is marked as read/unread
|
||||
value: string; // User preference value to use when setting the preference when the notification is marked as read
|
||||
unsetValue?: string; // User preference value to use when setting the preference when the notification is marked as unread - defaults to empty string
|
||||
};
|
||||
|
|
@ -47,6 +48,13 @@ export type EncryptedNotification = {
|
|||
primaryAction?: NotificationAction;
|
||||
// Secondary to be shown in the notification (optional)
|
||||
secondaryAction?: NotificationAction;
|
||||
// User Preference tied to the notification (optional) (the preference will be updated when the notification is marked read)
|
||||
preference?: NotificationPreference;
|
||||
// Handler to be associated with this notification that can invoke additional behaviour when the notification changes
|
||||
// This is the name of the handler (the handlers are added as extensions). Notifications are persisted in the store, so can't use functions.
|
||||
handlerName?: string;
|
||||
// Additional data to be stored with the notification (optional)
|
||||
data?: any;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -57,8 +65,6 @@ export type Notification = {
|
|||
id: string;
|
||||
// Progress (0-100) for notifications of type `Task` (optional)
|
||||
progress?: number;
|
||||
// User Preference tied to the notification (optional) (the preference will be updated when the notification is marked read)
|
||||
preference?: NotificationPreference;
|
||||
} & EncryptedNotification;
|
||||
|
||||
/**
|
||||
|
|
@ -72,3 +78,21 @@ export type StoredNotification = {
|
|||
created: Date;
|
||||
read: Boolean;
|
||||
} & Notification;
|
||||
|
||||
/**
|
||||
* Name to use when registering a custom notification handler
|
||||
*/
|
||||
export const NotificationHandlerExtensionName = 'notification-handler';
|
||||
|
||||
/**
|
||||
* Interface for notification handler
|
||||
*/
|
||||
export interface NotificationHandler {
|
||||
/**
|
||||
* Called when a notification with this handler has its read status is updated (read or unread)
|
||||
*
|
||||
* @param notification Notification that was marked read or unread
|
||||
* @param read Indicates whether the notification was updated to be read or unread
|
||||
*/
|
||||
onReadUpdated(notification: Notification, read: boolean): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,498 @@
|
|||
import { processAnnouncements, ANNOUNCEMENT_PREFIX } from '../announcement';
|
||||
import { NotificationLevel, Notification } from '@shell/types/notifications';
|
||||
import { READ_ANNOUNCEMENTS } from '@shell/store/prefs';
|
||||
import { DynamicContentAnnouncementHandlerName } from '../notification-handler';
|
||||
import { Context, VersionInfo, Announcement } from '../types';
|
||||
import semver from 'semver';
|
||||
|
||||
jest.mock('semver', () => ({
|
||||
...jest.requireActual('semver'),
|
||||
satisfies: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('processAnnouncements', () => {
|
||||
let mockDispatch: jest.Mock;
|
||||
let mockGetters: any;
|
||||
let mockLogger: any;
|
||||
let mockContext: Context;
|
||||
|
||||
const VERSION_270 = { version: semver.coerce('v2.7.0') as semver.SemVer, isPrime: false };
|
||||
|
||||
beforeEach(() => {
|
||||
mockDispatch = jest.fn();
|
||||
mockGetters = {
|
||||
'notifications/item': jest.fn(),
|
||||
'prefs/get': jest.fn(),
|
||||
};
|
||||
mockLogger = {
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
mockContext = {
|
||||
dispatch: mockDispatch,
|
||||
getters: mockGetters,
|
||||
logger: mockLogger,
|
||||
isAdmin: false, // Default to non-admin
|
||||
} as unknown as Context;
|
||||
|
||||
// Reset all mocks before each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock for semver.satisfies to return true
|
||||
(semver.satisfies as jest.Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
// --- Early Exit Conditions ---
|
||||
it('should return early if no announcements are provided', async() => {
|
||||
await processAnnouncements(mockContext, undefined, VERSION_270);
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early if announcements array is empty', async() => {
|
||||
await processAnnouncements(mockContext, [], VERSION_270);
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early if versionInfo is undefined', async() => {
|
||||
await processAnnouncements(mockContext, [{
|
||||
id: '1',
|
||||
target: 'notification/announcement',
|
||||
title: 'Test',
|
||||
message: 'Msg'
|
||||
}], undefined as any);
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early if versionInfo.version is undefined', async() => {
|
||||
await processAnnouncements(mockContext, [{
|
||||
id: '1',
|
||||
target: 'notification/announcement',
|
||||
title: 'Test',
|
||||
message: 'Msg'
|
||||
}], { version: undefined as any, isPrime: false });
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- Version and Audience Filtering ---
|
||||
it('should not process announcement if version does not satisfy requirement', async() => {
|
||||
(semver.satisfies as jest.Mock).mockReturnValue(false);
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: '1',
|
||||
target: 'notification/announcement',
|
||||
title: 'Test',
|
||||
message: 'Msg',
|
||||
version: 'v2.8.0'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(semver.satisfies).toHaveBeenCalledWith(VERSION_270.version, announcements[0].version);
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not process admin-only announcement if user is not admin', async() => {
|
||||
mockContext.isAdmin = false;
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: '1',
|
||||
target: 'notification/announcement',
|
||||
title: 'Test',
|
||||
message: 'Msg',
|
||||
audience: 'admin'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process admin-only announcement if user is admin', async() => {
|
||||
mockContext.isAdmin = true;
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: '1',
|
||||
target: 'notification/announcement',
|
||||
title: 'Test',
|
||||
message: 'Msg',
|
||||
audience: 'admin'
|
||||
},
|
||||
];
|
||||
const versionInfo: VersionInfo = { version: VERSION_270.version };
|
||||
|
||||
await processAnnouncements(mockContext, announcements, versionInfo);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith('notifications/add', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should process all-audience announcement regardless of admin status', async() => {
|
||||
mockContext.isAdmin = false; // Test with non-admin
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: '1',
|
||||
target: 'notification/announcement',
|
||||
title: 'Test',
|
||||
message: 'Msg',
|
||||
audience: 'all'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith('notifications/add', expect.any(Object));
|
||||
});
|
||||
|
||||
// --- Target Type Handling ---
|
||||
it('should log error for unsupported announcement target type', async() => {
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: '1',
|
||||
target: 'unsupported/type',
|
||||
title: 'Test',
|
||||
message: 'Msg'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Announcement type unsupported/type is not supported');
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log error for unsupported notification sub-type', async() => {
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: '1',
|
||||
target: 'notification/unsupported',
|
||||
title: 'Test',
|
||||
message: 'Msg'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Announcement notification type unsupported is not supported');
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- Notification Creation Logic ---
|
||||
it('should log error and not add notification if announcement has no ID', async() => {
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
target: 'notification/announcement',
|
||||
title: 'Test',
|
||||
message: 'Msg'
|
||||
} as Announcement, // Missing ID
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('No ID For announcement - not going to add a notification for the announcement');
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add notification if one with the same ID already exists', async() => {
|
||||
const announcementId = 'existing-announcement';
|
||||
|
||||
mockGetters['notifications/item'].mockReturnValue({ id: `${ ANNOUNCEMENT_PREFIX }${ announcementId }` });
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: announcementId,
|
||||
target: 'notification/announcement',
|
||||
title: 'Test',
|
||||
message: 'Msg'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockGetters['notifications/item']).toHaveBeenCalledWith(`${ ANNOUNCEMENT_PREFIX }${ announcementId }`);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Not adding announcement with ID'));
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add notification if announcement ID is in READ_ANNOUNCEMENTS preference', async() => {
|
||||
const announcementId = 'read-announcement';
|
||||
|
||||
mockGetters['notifications/item'].mockReturnValue(undefined); // No existing notification
|
||||
mockGetters['prefs/get'].mockImplementation((key: string) => {
|
||||
if (key === READ_ANNOUNCEMENTS) {
|
||||
return `some-other-id,${ announcementId },another-one`;
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: announcementId,
|
||||
target: 'notification/announcement',
|
||||
title: 'Test',
|
||||
message: 'Msg'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockGetters['prefs/get']).toHaveBeenCalledWith(READ_ANNOUNCEMENTS);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Not adding announcement with ID'));
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add a new announcement notification with default level (announcement)', async() => {
|
||||
const announcementId = 'new-announcement';
|
||||
|
||||
mockGetters['notifications/item'].mockReturnValue(undefined);
|
||||
mockGetters['prefs/get'].mockReturnValue('');
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: announcementId,
|
||||
target: 'notification/announcement',
|
||||
title: 'New Announcement',
|
||||
message: 'This is a new message.'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
const expectedNotification: Notification = {
|
||||
id: `${ ANNOUNCEMENT_PREFIX }${ announcementId }`,
|
||||
level: NotificationLevel.Announcement,
|
||||
title: 'New Announcement',
|
||||
message: 'This is a new message.',
|
||||
handlerName: DynamicContentAnnouncementHandlerName,
|
||||
};
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith('notifications/add', expectedNotification);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining(`Adding announcement with ID ${ ANNOUNCEMENT_PREFIX }${ announcementId }`));
|
||||
});
|
||||
|
||||
it('should add a new info level notification', async() => {
|
||||
const announcementId = 'new-info';
|
||||
|
||||
mockGetters['notifications/item'].mockReturnValue(undefined);
|
||||
mockGetters['prefs/get'].mockReturnValue('');
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: announcementId,
|
||||
target: 'notification/info',
|
||||
title: 'Info Title',
|
||||
message: 'Info Message'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
const expectedNotification: Notification = {
|
||||
id: `${ ANNOUNCEMENT_PREFIX }${ announcementId }`,
|
||||
level: NotificationLevel.Info,
|
||||
title: 'Info Title',
|
||||
message: 'Info Message',
|
||||
handlerName: DynamicContentAnnouncementHandlerName,
|
||||
};
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith('notifications/add', expectedNotification);
|
||||
});
|
||||
|
||||
it('should add a new warning level notification', async() => {
|
||||
const announcementId = 'new-warning';
|
||||
|
||||
mockGetters['notifications/item'].mockReturnValue(undefined);
|
||||
mockGetters['prefs/get'].mockReturnValue('');
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: announcementId,
|
||||
target: 'notification/warning',
|
||||
title: 'Warning Title',
|
||||
message: 'Warning Message'
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
const expectedNotification: Notification = {
|
||||
id: `${ ANNOUNCEMENT_PREFIX }${ announcementId }`,
|
||||
level: NotificationLevel.Warning,
|
||||
title: 'Warning Title',
|
||||
message: 'Warning Message',
|
||||
handlerName: DynamicContentAnnouncementHandlerName,
|
||||
};
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith('notifications/add', expectedNotification);
|
||||
});
|
||||
|
||||
// --- Call To Action (CTA) Handling ---
|
||||
it('should add notification with primary CTA', async() => {
|
||||
const announcementId = 'cta-primary';
|
||||
|
||||
mockGetters['notifications/item'].mockReturnValue(undefined);
|
||||
mockGetters['prefs/get'].mockReturnValue('');
|
||||
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: announcementId,
|
||||
target: 'notification/announcement',
|
||||
title: 'CTA Primary',
|
||||
message: 'Message',
|
||||
cta: { primary: { action: 'Click Me', link: '/some/path' } },
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
const notification = mockDispatch.mock.calls[0][1];
|
||||
|
||||
expect(notification.primaryAction).toStrictEqual({
|
||||
label: 'Click Me',
|
||||
target: '/some/path'
|
||||
});
|
||||
expect(notification.secondaryAction).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add notification with secondary CTA', async() => {
|
||||
const announcementId = 'cta-secondary';
|
||||
|
||||
mockGetters['notifications/item'].mockReturnValue(undefined);
|
||||
mockGetters['prefs/get'].mockReturnValue('');
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: announcementId,
|
||||
target: 'notification/announcement',
|
||||
title: 'CTA Secondary',
|
||||
message: 'Message',
|
||||
cta: { secondary: { action: 'More Info', link: 'http://example.com' } },
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
const notification = mockDispatch.mock.calls[0][1];
|
||||
|
||||
expect(notification.secondaryAction).toStrictEqual({ label: 'More Info', target: 'http://example.com' });
|
||||
expect(notification.primaryAction).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add notification with both primary and secondary CTAs', async() => {
|
||||
const announcementId = 'cta-both';
|
||||
|
||||
mockGetters['notifications/item'].mockReturnValue(undefined);
|
||||
mockGetters['prefs/get'].mockReturnValue('');
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: announcementId,
|
||||
target: 'notification/announcement',
|
||||
title: 'CTA Both',
|
||||
message: 'Message',
|
||||
cta: {
|
||||
primary: {
|
||||
action: 'Primary',
|
||||
link: '/primary'
|
||||
},
|
||||
secondary: {
|
||||
action: 'Secondary',
|
||||
link: '/secondary'
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
const notification = mockDispatch.mock.calls[0][1];
|
||||
|
||||
expect(notification.primaryAction).toStrictEqual({ label: 'Primary', target: '/primary' });
|
||||
expect(notification.secondaryAction).toStrictEqual({ label: 'Secondary', target: '/secondary' });
|
||||
});
|
||||
|
||||
// --- Multiple Announcements ---
|
||||
it('should process multiple announcements correctly, skipping invalid ones', async() => {
|
||||
mockContext.isAdmin = true; // Ensure admin-only can be processed
|
||||
mockGetters['notifications/item'].mockImplementation((id: string) => {
|
||||
if (id === `${ ANNOUNCEMENT_PREFIX }existing-id`) {
|
||||
return { id };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
mockGetters['prefs/get'].mockImplementation((key: string) => {
|
||||
if (key === READ_ANNOUNCEMENTS) {
|
||||
return 'read-id';
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
const announcements: Announcement[] = [
|
||||
{
|
||||
id: 'valid-1',
|
||||
target: 'notification/info',
|
||||
title: 'Valid 1',
|
||||
message: 'Msg 1',
|
||||
audience: 'all'
|
||||
},
|
||||
{
|
||||
id: 'existing-id',
|
||||
target: 'notification/announcement',
|
||||
title: 'Existing',
|
||||
message: 'Msg Existing'
|
||||
}, // Should be skipped
|
||||
{
|
||||
id: 'read-id',
|
||||
target: 'notification/warning',
|
||||
title: 'Read',
|
||||
message: 'Msg Read'
|
||||
}, // Should be skipped
|
||||
{
|
||||
id: 'valid-2',
|
||||
target: 'notification/announcement',
|
||||
title: 'Valid 2',
|
||||
message: 'Msg 2',
|
||||
audience: 'admin'
|
||||
},
|
||||
{
|
||||
id: 'invalid-target',
|
||||
target: 'unsupported/type',
|
||||
title: 'Invalid',
|
||||
message: 'Msg Invalid'
|
||||
}, // Should log error
|
||||
{
|
||||
id: 'valid-3',
|
||||
target: 'notification/info',
|
||||
title: 'Valid 3',
|
||||
message: 'Msg 3',
|
||||
version: 'v1.0.0'
|
||||
}, // semver.satisfies is mocked to true by default
|
||||
];
|
||||
|
||||
await processAnnouncements(mockContext, announcements, VERSION_270);
|
||||
|
||||
// Expect 3 notifications to be added (valid-1, valid-2, valid-3)
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(3);
|
||||
expect(mockDispatch).toHaveBeenCalledWith('notifications/add', expect.objectContaining({ id: `${ ANNOUNCEMENT_PREFIX }valid-1` }));
|
||||
expect(mockDispatch).toHaveBeenCalledWith('notifications/add', expect.objectContaining({ id: `${ ANNOUNCEMENT_PREFIX }valid-2` }));
|
||||
expect(mockDispatch).toHaveBeenCalledWith('notifications/add', expect.objectContaining({ id: `${ ANNOUNCEMENT_PREFIX }valid-3` }));
|
||||
|
||||
// Expect errors for invalid target
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Announcement type unsupported/type is not supported');
|
||||
|
||||
// Expect info logs for skipped announcements
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Not adding announcement with ID '));
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining(`${ ANNOUNCEMENT_PREFIX }existing-id`));
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining(`${ ANNOUNCEMENT_PREFIX }read-id`));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
*
|
||||
* The code in this file is responsible for adding 'announcement 'notifications driven off of the dynamic content metadata
|
||||
*
|
||||
* Announcements will be able to be shown in different places in the UI
|
||||
*
|
||||
*/
|
||||
|
||||
import semver from 'semver';
|
||||
import { NotificationLevel, Notification } from '@shell/types/notifications';
|
||||
import { READ_ANNOUNCEMENTS } from '@shell/store/prefs';
|
||||
import { Context, VersionInfo, Announcement } from './types';
|
||||
import { DynamicContentAnnouncementHandlerName } from './notification-handler';
|
||||
|
||||
// Prefixes used in the notifications IDs created here
|
||||
export const ANNOUNCEMENT_PREFIX = 'announcement-';
|
||||
|
||||
const TARGET_NOTIFICATION_CENTER = 'notification';
|
||||
const TARGET_HOME_PAGE = 'homepage';
|
||||
const ALLOWED_TARGETS = [TARGET_NOTIFICATION_CENTER, TARGET_HOME_PAGE];
|
||||
|
||||
const ALLOWED_NOTIFICATIONS: Record<string, NotificationLevel> = {
|
||||
announcement: NotificationLevel.Announcement,
|
||||
info: NotificationLevel.Info,
|
||||
warning: NotificationLevel.Warning,
|
||||
homepage: NotificationLevel.Hidden,
|
||||
};
|
||||
|
||||
/**
|
||||
* Main exported function that will process the announcements
|
||||
*
|
||||
* @param context Context helper providing access to config, logger, store
|
||||
* @param announcements Announcement information
|
||||
* @param versionInfo Version information
|
||||
*/
|
||||
export async function processAnnouncements(context: Context, announcements: Announcement[] | undefined, versionInfo: VersionInfo): Promise<void> {
|
||||
if (!announcements || !announcements.length || !versionInfo?.version) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatch, getters, logger } = context;
|
||||
|
||||
// Process each announcement
|
||||
await Promise.all(announcements.map(async(announcement: Announcement) => {
|
||||
// Check version
|
||||
if (announcement.version && !semver.satisfies(versionInfo.version, announcement.version)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check audience (currently only admin or all, but may add more in the future)
|
||||
if (announcement.audience === 'admin' && !context.isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check type
|
||||
const targetSplit = announcement.target.split('/');
|
||||
const target = targetSplit[0];
|
||||
|
||||
// Make sure that the target is supported
|
||||
if (ALLOWED_TARGETS.includes(target)) {
|
||||
let level = NotificationLevel.Announcement;
|
||||
let data: any = {};
|
||||
|
||||
if (target === TARGET_NOTIFICATION_CENTER) {
|
||||
// Show a notification
|
||||
const subType = targetSplit.length === 2 ? targetSplit[1] : 'announcement';
|
||||
|
||||
// Because 0 is a falsy, see if we find something of type number to check for existence
|
||||
if (typeof ALLOWED_NOTIFICATIONS[subType] !== 'number') {
|
||||
logger.error(`Announcement notification type ${ subType } is not supported`);
|
||||
} else {
|
||||
level = ALLOWED_NOTIFICATIONS[subType];
|
||||
}
|
||||
} else if (target === TARGET_HOME_PAGE) {
|
||||
level = NotificationLevel.Hidden;
|
||||
data = {
|
||||
icon: announcement.icon,
|
||||
location: targetSplit.length === 2 ? targetSplit[1] : 'banner',
|
||||
style: announcement.style,
|
||||
};
|
||||
|
||||
if (announcement.style) {
|
||||
data.style = announcement.style;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Going to add a notification for announcement ${ announcement.target }`);
|
||||
|
||||
// We should check if the notification already exists
|
||||
const id = `${ ANNOUNCEMENT_PREFIX }${ announcement.id }`;
|
||||
const existing = getters['notifications/item'](id);
|
||||
|
||||
// Check if the pref for 'read announcements' has the id
|
||||
const pref = getters['prefs/get'](READ_ANNOUNCEMENTS) || '';
|
||||
const prefExists = pref.split(',').includes(announcement.id);
|
||||
|
||||
if (existing || prefExists) {
|
||||
logger.info(`Not adding announcement with ID ${ id } as it already exists or has been read previously (title: ${ announcement.title })`);
|
||||
} else {
|
||||
const notification: Notification = {
|
||||
id,
|
||||
level,
|
||||
title: announcement.title,
|
||||
message: announcement.message,
|
||||
handlerName: DynamicContentAnnouncementHandlerName,
|
||||
data,
|
||||
};
|
||||
|
||||
if (announcement.cta?.primary) {
|
||||
notification.primaryAction = {
|
||||
label: announcement.cta.primary.action,
|
||||
target: announcement.cta.primary.link,
|
||||
};
|
||||
}
|
||||
|
||||
if (announcement.cta?.secondary) {
|
||||
notification.secondaryAction = {
|
||||
label: announcement.cta.secondary.action,
|
||||
target: announcement.cta.secondary.link,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`Adding announcement with ID ${ id } (title: ${ announcement.title }, target: ${ announcement.target })`);
|
||||
|
||||
await dispatch('notifications/add', notification);
|
||||
}
|
||||
} else {
|
||||
logger.error(`Announcement type ${ announcement.target } is not supported`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"version": 1,
|
||||
"releases": [
|
||||
{
|
||||
"name": "2.12.2"
|
||||
},
|
||||
{
|
||||
"name": "2.11.3"
|
||||
},
|
||||
{
|
||||
"name": "2.10.3"
|
||||
}
|
||||
],
|
||||
"announcements": [
|
||||
{
|
||||
"id": "security-update",
|
||||
"target": "notification/announcement",
|
||||
"title": "Important Security Update",
|
||||
"message": "A critical security vulnerability has been discovered in version 2.10.1. Users are strongly advised to update to version 2.12.2 immediately to ensure their systems remain secure.",
|
||||
"cta": {
|
||||
"primary" : {
|
||||
"action": "Update Now",
|
||||
"link": "https://www.suse.com/"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "suse-rocks",
|
||||
"target": "homepage/banner",
|
||||
"title": "Important Security Update",
|
||||
"message": "A critical security vulnerability has been discovered in version 2.10.1. Users are strongly advised to update to version 2.12.2 immediately to ensure their systems remain secure.",
|
||||
"cta": {
|
||||
"primary" : {
|
||||
"action": "Update Now",
|
||||
"link": "https://www.suse.com/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { Context, DynamicContent, VersionInfo } from './types';
|
|||
import { createLogger, LOCAL_STORAGE_CONTENT_DEBUG_LOG } from './util';
|
||||
import { getConfig } from './config';
|
||||
import { SystemInfoProvider } from './info';
|
||||
import { processAnnouncements } from './announcement';
|
||||
|
||||
const FETCH_DELAY = 3 * 1000; // Short delay to let UI settle before we fetch the updates document
|
||||
const FETCH_REQUEST_TIMEOUT = 15000; // Time out the request after 15 seconds
|
||||
|
|
@ -83,7 +84,7 @@ export async function fetchAndProcessDynamicContent(dispatch: Function, getters:
|
|||
}
|
||||
|
||||
const versionInfo: VersionInfo = {
|
||||
version,
|
||||
version: version as semver.SemVer, // Will be defined, can not be null here
|
||||
isPrime: config.prime,
|
||||
};
|
||||
|
||||
|
|
@ -103,7 +104,7 @@ export async function fetchAndProcessDynamicContent(dispatch: Function, getters:
|
|||
// If the cached content has a debug version then use that as an override for the current version number
|
||||
// This is only for debug and testing purposes
|
||||
if (content.settings?.debugVersion) {
|
||||
versionInfo.version = semver.coerce(content.settings.debugVersion);
|
||||
versionInfo.version = semver.coerce(content.settings.debugVersion) || version;
|
||||
logger.debug(`Overriding version number to ${ content.settings.debugVersion }`);
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +117,9 @@ export async function fetchAndProcessDynamicContent(dispatch: Function, getters:
|
|||
|
||||
// EOM, EOL notifications
|
||||
processSupportNotices(context, content.support, versionInfo);
|
||||
|
||||
// Announcements
|
||||
processAnnouncements(context, content.announcements, versionInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error reading or processing dynamic content', e);
|
||||
|
|
@ -266,6 +270,8 @@ export async function fetchDynamicContent(context: Context): Promise<Partial<Dyn
|
|||
|
||||
logger.debug('End fetchDynamicContent');
|
||||
|
||||
logger.debug('End fetchDynamicContent');
|
||||
|
||||
// Remove the local storage key that indicates a tab is fetching the content
|
||||
window.localStorage.removeItem(LOCAL_STORAGE_UPDATE_FETCHING);
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export class SystemInfoProvider {
|
|||
params.push(`v=${ systemData.version }`);
|
||||
params.push(`dev=${ systemData.isDeveloperVersion }`);
|
||||
params.push(`p=${ systemData.isPrime }`);
|
||||
params.push(`lts=${ systemData.isLTS }`);
|
||||
|
||||
// Remove LTS for now, until we can determine LTS status
|
||||
// params.push(`lts=${ systemData.isLTS }`);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export async function processReleaseVersion(context: Context, releaseInfo: Relea
|
|||
const versions = releaseInfo.map((v: any) => semver.coerce(v.name));
|
||||
|
||||
// Sort the versions, so that the newest is first in the list
|
||||
versions.sort((a: any, b: any) => semver.gt(b, a) ? 1 : -1);
|
||||
versions.sort((a: any, b: any) => semver.rcompare(a, b));
|
||||
|
||||
// Find first newer version
|
||||
const newer = versions.find((v: any) => semver.gt(v, version));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Notification handler for dynamic content announcements
|
||||
*
|
||||
* This provides custom handling for read/unread state using a single user preference
|
||||
*/
|
||||
import { Notification, NotificationHandler } from '@shell/types/notifications';
|
||||
import { READ_ANNOUNCEMENTS } from '@shell/store/prefs';
|
||||
import { ANNOUNCEMENT_PREFIX } from './announcement';
|
||||
|
||||
// Global name for this handler that can be used when creating notifications to associate them with this handler
|
||||
export const DynamicContentAnnouncementHandlerName = 'dc-announcements';
|
||||
|
||||
/**
|
||||
* Create the dynamic content notification handler
|
||||
*
|
||||
* This is for announcements, where we need to manage an array of IDs of announcements that have been read, which
|
||||
* is taken care of by this custom handler.
|
||||
*
|
||||
* When a notification is read/unread that specifies this handler, we will add or remove its ID from the list of
|
||||
* read IDs that we maintain in the user preference value.
|
||||
*
|
||||
* This allows us to use a single user preference to track read announcements
|
||||
*/
|
||||
export function createHandler(store: any): NotificationHandler {
|
||||
return {
|
||||
async onReadUpdated(notification: Notification, read: boolean) {
|
||||
if (!notification.id.startsWith(ANNOUNCEMENT_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = notification.id.substring(ANNOUNCEMENT_PREFIX.length);
|
||||
const announcements = store.getters['notifications/all'].filter((n: any) => n.id.startsWith(ANNOUNCEMENT_PREFIX));
|
||||
const pref = store.getters['prefs/get'](READ_ANNOUNCEMENTS) || '';
|
||||
const values = !pref.length ? [] : pref.split(',').filter((v: string) => !announcements.includes(v));
|
||||
const valuesUnique = new Set(values);
|
||||
|
||||
if (read) {
|
||||
valuesUnique.add(id);
|
||||
} else {
|
||||
valuesUnique.delete(id);
|
||||
}
|
||||
|
||||
const newValues = Array.from(valuesUnique).sort();
|
||||
|
||||
await store.dispatch('prefs/set', { key: READ_ANNOUNCEMENTS, value: newValues.join(',') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ export type Context = {
|
|||
* Version information
|
||||
*/
|
||||
export type VersionInfo = {
|
||||
version: SemVer | null;
|
||||
version: SemVer;
|
||||
isPrime: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -90,6 +90,49 @@ export type SupportInfo = {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Call to action for an announcement
|
||||
*/
|
||||
export type CallToAction = {
|
||||
action: string;
|
||||
link: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Announcements to be shown in the notification center or on the home page
|
||||
*/
|
||||
export type Announcement = {
|
||||
id: string; // Unique id for this announcement
|
||||
title: string; // Title to be shown
|
||||
message: string; // Message/Body for the announcement
|
||||
target: string; // Where the announcement should be shown
|
||||
version?: string; // Version or semver expression for when to show this announcement
|
||||
audience?: 'admin' | 'all'; // Audience - show for just Admins or for all users
|
||||
icon?: string;
|
||||
cta?: {
|
||||
primary: CallToAction, // Must have a primary call to action, if we have a cta field
|
||||
secondary?: CallToAction,
|
||||
primaryStyle?: string;
|
||||
},
|
||||
style?: string; // Styling information that will be interpreted by the rendering component
|
||||
};
|
||||
|
||||
/**
|
||||
* Icon information
|
||||
*/
|
||||
export type AnnouncementNotificationIconData = {
|
||||
light: string; // Light mode icon/image
|
||||
dark?: string; // Light mode icon/image
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom data for announcements stored with the notification
|
||||
*/
|
||||
export type AnnouncementNotificationData = {
|
||||
icon?: AnnouncementNotificationIconData; // Icon/Image to show
|
||||
location: string; // Location of the announcement in the UI
|
||||
};
|
||||
|
||||
/**
|
||||
* Main type for the metadata that is retrieved from the dynamic content endpoint
|
||||
*/
|
||||
|
|
@ -97,5 +140,6 @@ export type DynamicContent = {
|
|||
version: string;
|
||||
releases: ReleaseInfo[],
|
||||
support: SupportInfo,
|
||||
announcements: Announcement[],
|
||||
settings?: Partial<SettingsInfo>,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue