]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: better toast notifications management (#8980)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Fri, 7 Feb 2025 07:06:16 +0000 (23:06 -0800)
committerGitHub <noreply@github.com>
Fri, 7 Feb 2025 07:06:16 +0000 (23:06 -0800)
21 files changed:
src-ui/messages.xlf
src-ui/src/app/components/app-frame/app-frame.component.html
src-ui/src/app/components/app-frame/app-frame.component.scss
src-ui/src/app/components/app-frame/app-frame.component.ts
src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html [new file with mode: 0644]
src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.scss [new file with mode: 0644]
src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/toast/toast.component.html [new file with mode: 0644]
src-ui/src/app/components/common/toast/toast.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/toast/toast.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/toast/toast.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/toasts/toasts.component.html
src-ui/src/app/components/common/toasts/toasts.component.scss
src-ui/src/app/components/common/toasts/toasts.component.spec.ts
src-ui/src/app/components/common/toasts/toasts.component.ts
src-ui/src/app/services/toast.service.spec.ts
src-ui/src/app/services/toast.service.ts
src-ui/src/main.ts
src-ui/src/styles.scss
src-ui/src/theme.scss

index caab96d4b2abb24f8ca7818ac5d889709080cf84..220f851c0b5aabeb9cc222b4b80402e7862d39fc 100644 (file)
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">81</context>
+          <context context-type="linenumber">82</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">83</context>
+          <context context-type="linenumber">84</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">88</context>
+          <context context-type="linenumber">89</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">90</context>
+          <context context-type="linenumber">91</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">50</context>
+          <context context-type="linenumber">51</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">244</context>
+          <context context-type="linenumber">245</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">246</context>
+          <context context-type="linenumber">247</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2501522447884928778" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">279</context>
+          <context context-type="linenumber">280</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">282</context>
+          <context context-type="linenumber">283</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2272120016352772836" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">204</context>
+          <context context-type="linenumber">205</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">206</context>
+          <context context-type="linenumber">207</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
           <context context-type="linenumber">344</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="8545554728558600606" datatype="html">
         <source>Document processing</source>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">159</context>
+          <context context-type="linenumber">161</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2991443309752293110" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">267</context>
+          <context context-type="linenumber">268</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">269</context>
+          <context context-type="linenumber">270</context>
         </context-group>
       </trans-unit>
       <trans-unit id="103921551219467537" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">227</context>
+          <context context-type="linenumber">228</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">230</context>
+          <context context-type="linenumber">231</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3818027200170621545" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">258</context>
+          <context context-type="linenumber">259</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">260</context>
+          <context context-type="linenumber">261</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4569276013106377105" datatype="html">
         <source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">42</context>
+          <context context-type="linenumber">43</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2127032578120864096" datatype="html">
         <source>My Profile</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">46</context>
+          <context context-type="linenumber">47</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3797778920049399855" datatype="html">
         <source>Logout</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">53</context>
+          <context context-type="linenumber">54</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4895326106573044490" datatype="html">
         <source>Documentation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">58</context>
+          <context context-type="linenumber">59</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">288</context>
+          <context context-type="linenumber">289</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">291</context>
+          <context context-type="linenumber">292</context>
         </context-group>
       </trans-unit>
       <trans-unit id="472206565520537964" datatype="html">
         <source>Saved views</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">98</context>
+          <context context-type="linenumber">99</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">103</context>
+          <context context-type="linenumber">104</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6988090220128974198" datatype="html">
         <source>Open documents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">130</context>
+          <context context-type="linenumber">131</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5687256342387781369" datatype="html">
         <source>Close all</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">150</context>
+          <context context-type="linenumber">151</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">152</context>
+          <context context-type="linenumber">153</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3897348120591552265" datatype="html">
         <source>Manage</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">161</context>
+          <context context-type="linenumber">162</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7437910965833684826" datatype="html">
         <source>Correspondents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">167</context>
+          <context context-type="linenumber">168</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">169</context>
+          <context context-type="linenumber">170</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
         <source>Tags</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">174</context>
+          <context context-type="linenumber">175</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">177</context>
+          <context context-type="linenumber">178</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
         <source>Document Types</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">183</context>
+          <context context-type="linenumber">184</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">185</context>
+          <context context-type="linenumber">186</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
         <source>Storage Paths</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">190</context>
+          <context context-type="linenumber">191</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">192</context>
+          <context context-type="linenumber">193</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
         <source>Custom Fields</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">197</context>
+          <context context-type="linenumber">198</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">199</context>
+          <context context-type="linenumber">200</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
         <source>Workflows</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">213</context>
+          <context context-type="linenumber">214</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">215</context>
+          <context context-type="linenumber">216</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
         <source>Mail</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">220</context>
+          <context context-type="linenumber">221</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">223</context>
+          <context context-type="linenumber">224</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7844706011418789951" datatype="html">
         <source>Administration</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">238</context>
+          <context context-type="linenumber">239</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3008420115644088420" datatype="html">
         <source>Configuration</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">251</context>
+          <context context-type="linenumber">252</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">253</context>
+          <context context-type="linenumber">254</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1534029177398918729" datatype="html">
         <source>GitHub</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">298</context>
+          <context context-type="linenumber">299</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4112664765954374539" datatype="html">
         <source>is available.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">307,308</context>
+          <context context-type="linenumber">308,309</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1175891574282637937" datatype="html">
         <source>Click to view.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">308</context>
+          <context context-type="linenumber">309</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9811291095862612" datatype="html">
         <source>Paperless-ngx can automatically check for updates</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">312</context>
+          <context context-type="linenumber">313</context>
         </context-group>
       </trans-unit>
       <trans-unit id="894819944961861800" datatype="html">
         <source> How does this work? </source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">319,321</context>
+          <context context-type="linenumber">320,322</context>
         </context-group>
       </trans-unit>
       <trans-unit id="509090351011426949" datatype="html">
         <source>Update available</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">332</context>
+          <context context-type="linenumber">333</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1542489069631984294" datatype="html">
         <source>Sidebar views updated</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">243</context>
+          <context context-type="linenumber">245</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3547923076537026828" datatype="html">
         <source>Error updating sidebar views</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">246</context>
+          <context context-type="linenumber">248</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2526035785704676448" datatype="html">
         <source>An error occurred while saving update checking settings.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">267</context>
+          <context context-type="linenumber">269</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4580988005648117665" datatype="html">
           <context context-type="linenumber">250</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="8193912662253833654" datatype="html">
+        <source>Clear All</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html</context>
+          <context context-type="linenumber">16</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1656872994210958357" datatype="html">
+        <source>No notifications</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html</context>
+          <context context-type="linenumber">20</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="8700121026680200191" datatype="html">
         <source>Clear</source>
         <context-group purpose="location">
           <context context-type="linenumber">111</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
-          <context context-type="linenumber">28</context>
+          <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
+          <context context-type="linenumber">30</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6886003843406464884" datatype="html">
           <context context-type="linenumber">47</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
-          <context context-type="linenumber">26</context>
+          <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
+          <context context-type="linenumber">28</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
       <trans-unit id="6732151329960766506" datatype="html">
         <source>Copy Raw Error</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
-          <context context-type="linenumber">41</context>
+          <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
+          <context context-type="linenumber">43</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6581372518205328477" datatype="html">
index 442f9f366e275a1a7ee4cde7f5b967bbeafd288b..b3d515274095df72eabd05c61765f8d1ba04b950 100644 (file)
     </div>
   </div>
   <ul ngbNav class="order-sm-3">
+    <pngx-toasts-dropdown></pngx-toasts-dropdown>
     <li ngbDropdown class="nav-item dropdown">
-      <button class="btn border-0" id="userDropdown" ngbDropdownToggle>
-        <span class="small me-2 d-none d-sm-inline">
+      <button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
+        <i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
+        <span class="small ms-2 d-none d-sm-inline">
           {{this.settingsService.displayName}}
         </span>
-        <i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
       </button>
       <div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
         <div class="d-sm-none">
index 9d1110ef4a2f5e843d97ce10d4fe7a918764e0d3..718d7ea41b450a55ab2273a199a5cc1e1e97cbfd 100644 (file)
@@ -250,8 +250,8 @@ main {
   }
 }
 
-.dropdown.show .dropdown-toggle,
-.dropdown-toggle:hover {
+:host ::ng-deep .dropdown.show .dropdown-toggle,
+:host ::ng-deep .dropdown-toggle:hover {
   opacity: 0.7;
 }
 
index 4990beb09d7286ae4dae7d8e6039da5f1171668c..fabcbf7d1eac74867208d41f67c4d4e60acd27f4 100644 (file)
@@ -48,6 +48,7 @@ import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profil
 import { DocumentDetailComponent } from '../document-detail/document-detail.component'
 import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
 import { GlobalSearchComponent } from './global-search/global-search.component'
+import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
 
 @Component({
   selector: 'pngx-app-frame',
@@ -57,6 +58,7 @@ import { GlobalSearchComponent } from './global-search/global-search.component'
     GlobalSearchComponent,
     DocumentTitlePipe,
     IfPermissionsDirective,
+    ToastsDropdownComponent,
     RouterModule,
     NgClass,
     NgbDropdownModule,
diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html
new file mode 100644 (file)
index 0000000..6e49c17
--- /dev/null
@@ -0,0 +1,28 @@
+
+<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
+  @if (toasts.length) {
+    <span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
+  }
+  <button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
+    <i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
+  </button>
+  <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
+    <div class="btn-toolbar align-items-center" role="toolbar">
+      <h6 i18n>Notifications</h6>
+      <div class="btn-group ms-auto">
+        <button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
+          (click)="toastService.clearToasts()"
+          [disabled]="toasts.length === 0"
+          i18n>Clear All</button>
+      </div>
+    </div>
+    @if (toasts.length === 0) {
+      <p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
+    }
+    <div class="scroll-list">
+      @for (toast of toasts; track toast.id) {
+        <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast>
+      }
+      </div>
+  </div>
+</li>
diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.scss b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.scss
new file mode 100644 (file)
index 0000000..2332e71
--- /dev/null
@@ -0,0 +1,22 @@
+.dropdown-menu {
+  width: var(--pngx-toast-max-width);
+}
+
+.dropdown-menu .scroll-list {
+  max-height: 500px;
+  overflow-y: auto;
+}
+
+.dropdown-toggle::after {
+  display: none;
+}
+
+.dropdown-item {
+  white-space: initial;
+}
+
+@media screen and (max-width: 400px) {
+  :host ::ng-deep .dropdown-menu-end {
+    right: -3rem;
+  }
+}
diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.spec.ts b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.spec.ts
new file mode 100644 (file)
index 0000000..33b948f
--- /dev/null
@@ -0,0 +1,112 @@
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
+import {
+  ComponentFixture,
+  TestBed,
+  discardPeriodicTasks,
+  fakeAsync,
+  flush,
+} from '@angular/core/testing'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { Subject } from 'rxjs'
+import { Toast, ToastService } from 'src/app/services/toast.service'
+import { ToastsDropdownComponent } from './toasts-dropdown.component'
+
+const toasts = [
+  {
+    id: 'abc-123',
+    content: 'foo bar',
+    delay: 5000,
+  },
+  {
+    id: 'def-123',
+    content: 'Error 1 content',
+    delay: 5000,
+    error: 'Error 1 string',
+  },
+  {
+    id: 'ghi-123',
+    content: 'Error 2 content',
+    delay: 5000,
+    error: {
+      url: 'https://example.com',
+      status: 500,
+      statusText: 'Internal Server Error',
+      message: 'Internal server error 500 message',
+      error: { detail: 'Error 2 message details' },
+    },
+  },
+]
+
+describe('ToastsDropdownComponent', () => {
+  let component: ToastsDropdownComponent
+  let fixture: ComponentFixture<ToastsDropdownComponent>
+  let toastService: ToastService
+  let toastsSubject: Subject<Toast[]> = new Subject()
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      imports: [
+        ToastsDropdownComponent,
+        NgxBootstrapIconsModule.pick(allIcons),
+      ],
+      providers: [
+        provideHttpClient(withInterceptorsFromDi()),
+        provideHttpClientTesting(),
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(ToastsDropdownComponent)
+    toastService = TestBed.inject(ToastService)
+    jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
+
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should call getToasts and return toasts', fakeAsync(() => {
+    const spy = jest.spyOn(toastService, 'getToasts')
+
+    component.ngOnInit()
+    toastsSubject.next(toasts)
+    fixture.detectChanges()
+
+    expect(spy).toHaveBeenCalled()
+    expect(component.toasts).toContainEqual({
+      id: 'abc-123',
+      content: 'foo bar',
+      delay: 5000,
+    })
+
+    component.ngOnDestroy()
+    flush()
+    discardPeriodicTasks()
+  }))
+
+  it('should show a toast', fakeAsync(() => {
+    component.ngOnInit()
+    toastsSubject.next(toasts)
+    fixture.detectChanges()
+
+    expect(fixture.nativeElement.textContent).toContain('foo bar')
+
+    component.ngOnDestroy()
+    flush()
+    discardPeriodicTasks()
+  }))
+
+  it('should toggle suppressPopupToasts', fakeAsync((finish) => {
+    component.ngOnInit()
+    fixture.detectChanges()
+    toastsSubject.next(toasts)
+
+    const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
+    component.onOpenChange(true)
+    expect(spy).toHaveBeenCalledWith(true)
+
+    component.ngOnDestroy()
+    flush()
+    discardPeriodicTasks()
+  }))
+})
diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.ts b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.ts
new file mode 100644 (file)
index 0000000..c04d758
--- /dev/null
@@ -0,0 +1,42 @@
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import {
+  NgbDropdownModule,
+  NgbProgressbarModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { Subscription } from 'rxjs'
+import { Toast, ToastService } from 'src/app/services/toast.service'
+import { ToastComponent } from '../../common/toast/toast.component'
+
+@Component({
+  selector: 'pngx-toasts-dropdown',
+  templateUrl: './toasts-dropdown.component.html',
+  styleUrls: ['./toasts-dropdown.component.scss'],
+  imports: [
+    ToastComponent,
+    NgbDropdownModule,
+    NgbProgressbarModule,
+    NgxBootstrapIconsModule,
+  ],
+})
+export class ToastsDropdownComponent implements OnInit, OnDestroy {
+  constructor(public toastService: ToastService) {}
+
+  private subscription: Subscription
+
+  public toasts: Toast[] = []
+
+  ngOnDestroy(): void {
+    this.subscription?.unsubscribe()
+  }
+
+  ngOnInit(): void {
+    this.subscription = this.toastService.getToasts().subscribe((toasts) => {
+      this.toasts = [...toasts]
+    })
+  }
+
+  onOpenChange(open: boolean): void {
+    this.toastService.suppressPopupToasts = open
+  }
+}
diff --git a/src-ui/src/app/components/common/toast/toast.component.html b/src-ui/src/app/components/common/toast/toast.component.html
new file mode 100644 (file)
index 0000000..ede75dd
--- /dev/null
@@ -0,0 +1,56 @@
+<ngb-toast
+    [autohide]="autohide"
+    [delay]="toast.delay"
+    [class]="toast.classname"
+    [class.mb-2]="true"
+    (shown)="onShown(toast)"
+    (hidden)="hidden.emit(toast)">
+        @if (autohide) {
+            <ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
+            <span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
+        }
+        <div class="d-flex align-items-top">
+        @if (!toast.error) {
+            <i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
+        }
+        @if (toast.error) {
+            <i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
+        }
+        <div>
+            <p class="ms-2 mb-0">{{toast.content}}</p>
+            @if (toast.error) {
+            <details class="ms-2">
+                <div class="mt-2 ms-n4 me-n2 small">
+                @if (isDetailedError(toast.error)) {
+                    <dl class="row mb-0">
+                    <dt class="col-sm-3 fw-normal text-end">URL</dt>
+                    <dd class="col-sm-9">{{ toast.error.url }}</dd>
+                    <dt class="col-sm-3 fw-normal text-end" i18n>Status</dt>
+                    <dd class="col-sm-9">{{ toast.error.status }} <em>{{ toast.error.statusText }}</em></dd>
+                    <dt class="col-sm-3 fw-normal text-end" i18n>Error</dt>
+                    <dd class="col-sm-9">{{ getErrorText(toast.error) }}</dd>
+                    </dl>
+                }
+                <div class="row">
+                    <div class="col offset-sm-3">
+                    <button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)">
+                        @if (!copied) {
+                        <i-bs name="clipboard"></i-bs>&nbsp;
+                        }
+                        @if (copied) {
+                        <i-bs name="clipboard-check"></i-bs>&nbsp;
+                        }
+                        <ng-container i18n>Copy Raw Error</ng-container>
+                    </button>
+                    </div>
+                </div>
+                </div>
+            </details>
+            }
+            @if (toast.action) {
+            <p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(toast); toast.action()">{{toast.actionName}}</button></p>
+            }
+        </div>
+        <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="close.emit(toast);"></button>
+    </div>
+</ngb-toast>
diff --git a/src-ui/src/app/components/common/toast/toast.component.scss b/src-ui/src/app/components/common/toast/toast.component.scss
new file mode 100644 (file)
index 0000000..3783445
--- /dev/null
@@ -0,0 +1,20 @@
+::ng-deep .toast-body {
+    position: relative;
+}
+
+::ng-deep .toast.error {
+    border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
+}
+
+::ng-deep .toast.error .toast-body {
+    background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
+    border-top-left-radius: inherit;
+    border-top-right-radius: inherit;
+    border-bottom-left-radius: inherit;
+    border-bottom-right-radius: inherit;
+}
+
+.progress {
+    background-color: var(--pngx-primary);
+    opacity: .07;
+}
diff --git a/src-ui/src/app/components/common/toast/toast.component.spec.ts b/src-ui/src/app/components/common/toast/toast.component.spec.ts
new file mode 100644 (file)
index 0000000..c5d52a2
--- /dev/null
@@ -0,0 +1,104 @@
+import {
+  ComponentFixture,
+  discardPeriodicTasks,
+  fakeAsync,
+  flush,
+  TestBed,
+  tick,
+} from '@angular/core/testing'
+
+import { Clipboard } from '@angular/cdk/clipboard'
+import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { ToastComponent } from './toast.component'
+
+const toast1 = {
+  content: 'Error 1 content',
+  delay: 5000,
+  error: 'Error 1 string',
+}
+
+const toast2 = {
+  content: 'Error 2 content',
+  delay: 5000,
+  error: {
+    url: 'https://example.com',
+    status: 500,
+    statusText: 'Internal Server Error',
+    message: 'Internal server error 500 message',
+    error: { detail: 'Error 2 message details' },
+  },
+}
+
+describe('ToastComponent', () => {
+  let component: ToastComponent
+  let fixture: ComponentFixture<ToastComponent>
+  let clipboard: Clipboard
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(ToastComponent)
+    clipboard = TestBed.inject(Clipboard)
+    component = fixture.componentInstance
+  })
+
+  it('should create', () => {
+    expect(component).toBeTruthy()
+  })
+
+  it('should countdown toast', fakeAsync(() => {
+    component.toast = toast2
+    fixture.detectChanges()
+    component.onShown(toast2)
+    tick(5000)
+    expect(component.toast.delayRemaining).toEqual(0)
+    flush()
+    discardPeriodicTasks()
+  }))
+
+  it('should show an error if given with toast', fakeAsync(() => {
+    component.toast = toast1
+    fixture.detectChanges()
+
+    expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
+    expect(fixture.nativeElement.textContent).toContain('Error 1 content')
+
+    flush()
+    discardPeriodicTasks()
+  }))
+
+  it('should show error details, support copy', fakeAsync(() => {
+    component.toast = toast2
+    fixture.detectChanges()
+
+    expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
+    expect(fixture.nativeElement.textContent).toContain(
+      'Error 2 message details'
+    )
+
+    const copySpy = jest.spyOn(clipboard, 'copy')
+    component.copyError(toast2.error)
+    expect(copySpy).toHaveBeenCalled()
+
+    flush()
+    discardPeriodicTasks()
+  }))
+
+  it('should parse error text, add ellipsis', () => {
+    expect(component.getErrorText(toast2.error)).toEqual(
+      'Error 2 message details'
+    )
+    expect(component.getErrorText({ error: 'Error string no detail' })).toEqual(
+      'Error string no detail'
+    )
+    expect(component.getErrorText('Error string')).toEqual('')
+    expect(
+      component.getErrorText({ error: { message: 'foo error bar' } })
+    ).toContain('{"message":"foo error bar"}')
+    expect(
+      component.getErrorText({ error: new Array(205).join('a') })
+    ).toContain('...')
+  })
+})
diff --git a/src-ui/src/app/components/common/toast/toast.component.ts b/src-ui/src/app/components/common/toast/toast.component.ts
new file mode 100644 (file)
index 0000000..5ebfdbe
--- /dev/null
@@ -0,0 +1,76 @@
+import { Clipboard } from '@angular/cdk/clipboard'
+import { DecimalPipe } from '@angular/common'
+import { Component, EventEmitter, Input, Output } from '@angular/core'
+import {
+  NgbProgressbarModule,
+  NgbToastModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { interval, take } from 'rxjs'
+import { Toast } from 'src/app/services/toast.service'
+
+@Component({
+  selector: 'pngx-toast',
+  imports: [
+    DecimalPipe,
+    NgbToastModule,
+    NgbProgressbarModule,
+    NgxBootstrapIconsModule,
+  ],
+  templateUrl: './toast.component.html',
+  styleUrl: './toast.component.scss',
+})
+export class ToastComponent {
+  @Input() toast: Toast
+
+  @Input() autohide: boolean = true
+
+  @Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
+
+  @Output() close: EventEmitter<Toast> = new EventEmitter<Toast>()
+
+  public copied: boolean = false
+
+  constructor(private clipboard: Clipboard) {}
+
+  onShown(toast: Toast) {
+    if (!this.autohide) return
+
+    const refreshInterval = 150
+    const delay = toast.delay - 500 // for fade animation
+
+    interval(refreshInterval)
+      .pipe(take(Math.round(delay / refreshInterval)))
+      .subscribe((count) => {
+        toast.delayRemaining = Math.max(
+          0,
+          delay - refreshInterval * (count + 1)
+        )
+      })
+  }
+
+  public isDetailedError(error: any): boolean {
+    return (
+      typeof error === 'object' &&
+      'status' in error &&
+      'statusText' in error &&
+      'url' in error &&
+      'message' in error &&
+      'error' in error
+    )
+  }
+
+  public copyError(error: any) {
+    this.clipboard.copy(JSON.stringify(error))
+    this.copied = true
+    setTimeout(() => {
+      this.copied = false
+    }, 3000)
+  }
+
+  getErrorText(error: any) {
+    let text: string = error.error?.detail ?? error.error ?? ''
+    if (typeof text === 'object') text = JSON.stringify(text)
+    return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`
+  }
+}
index 36623161b3b180b9ec950c0718de388ff5f2ba7c..2178a20230f91fb2c7e9a6e37512b0aa9a77c653 100644 (file)
@@ -1,55 +1,3 @@
-@for (toast of toasts; track toast) {
-  <ngb-toast
-    [autohide]="true" [delay]="toast.delay"
-    [class]="toast.classname"
-    [class.mb-2]="true"
-    (shown)="onShow(toast)"
-    (hidden)="toastService.closeToast(toast)">
-    <ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
-    <span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
-    <div class="d-flex align-items-top">
-      @if (!toast.error) {
-        <i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
-      }
-      @if (toast.error) {
-        <i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
-      }
-      <div>
-        <p class="ms-2 mb-0">{{toast.content}}</p>
-        @if (toast.error) {
-          <details class="ms-2">
-            <div class="mt-2 ms-n4 me-n2 small">
-              @if (isDetailedError(toast.error)) {
-                <dl class="row mb-0">
-                  <dt class="col-sm-3 fw-normal text-end">URL</dt>
-                  <dd class="col-sm-9">{{ toast.error.url }}</dd>
-                  <dt class="col-sm-3 fw-normal text-end" i18n>Status</dt>
-                  <dd class="col-sm-9">{{ toast.error.status }} <em>{{ toast.error.statusText }}</em></dd>
-                  <dt class="col-sm-3 fw-normal text-end" i18n>Error</dt>
-                  <dd class="col-sm-9">{{ getErrorText(toast.error) }}</dd>
-                </dl>
-              }
-              <div class="row">
-                <div class="col offset-sm-3">
-                  <button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)">
-                    @if (!copied) {
-                      <i-bs name="clipboard"></i-bs>&nbsp;
-                    }
-                    @if (copied) {
-                      <i-bs name="clipboard-check"></i-bs>&nbsp;
-                    }
-                    <ng-container i18n>Copy Raw Error</ng-container>
-                  </button>
-                </div>
-              </div>
-            </div>
-          </details>
-        }
-        @if (toast.action) {
-          <p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p>
-        }
-      </div>
-      <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="toastService.closeToast(toast);"></button>
-    </div>
-  </ngb-toast>
+@for (toast of toasts; track toast.id) {
+  <pngx-toast [toast]="toast" [autohide]="true" (close)="closeToast()"></pngx-toast>
 }
index 463f964956f95d92e1fb51959dd986a1c3cffe76..e0a069dda470d200957a7538cde976dc8656f2b9 100644 (file)
@@ -1,7 +1,7 @@
 :host {
   position: fixed;
   top: 0;
-  right: 0;
+  right: calc(50% - (var(--pngx-toast-max-width) / 2));
   margin: 0.3em;
   z-index: 1200;
 }
@@ -9,24 +9,3 @@
 .toast:not(.show) {
   display: block; // this corrects an ng-bootstrap bug that prevented animations
 }
-
-::ng-deep .toast-body {
-  position: relative;
-}
-
-::ng-deep .toast.error {
-  border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
-}
-
-::ng-deep .toast.error .toast-body {
-  background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
-  border-top-left-radius: inherit;
-  border-top-right-radius: inherit;
-  border-bottom-left-radius: inherit;
-  border-bottom-right-radius: inherit;
-}
-
-.progress {
-  background-color: var(--pngx-primary);
-  opacity: .07;
-}
index 4493961348f85565009ba6b0f1d91110ddf5f899..bbea04c9c3660a8d6e1d2e56a55c06b226f14691 100644 (file)
@@ -1,58 +1,33 @@
-import { Clipboard } from '@angular/cdk/clipboard'
 import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
 import { provideHttpClientTesting } from '@angular/common/http/testing'
-import {
-  ComponentFixture,
-  TestBed,
-  discardPeriodicTasks,
-  fakeAsync,
-  flush,
-  tick,
-} from '@angular/core/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
-import { of } from 'rxjs'
-import { ToastService } from 'src/app/services/toast.service'
+import { Subject } from 'rxjs'
+import { Toast, ToastService } from 'src/app/services/toast.service'
 import { ToastsComponent } from './toasts.component'
 
-const toasts = [
-  {
-    content: 'foo bar',
-    delay: 5000,
+const toast = {
+  content: 'Error 2 content',
+  delay: 5000,
+  error: {
+    url: 'https://example.com',
+    status: 500,
+    statusText: 'Internal Server Error',
+    message: 'Internal server error 500 message',
+    error: { detail: 'Error 2 message details' },
   },
-  {
-    content: 'Error 1 content',
-    delay: 5000,
-    error: 'Error 1 string',
-  },
-  {
-    content: 'Error 2 content',
-    delay: 5000,
-    error: {
-      url: 'https://example.com',
-      status: 500,
-      statusText: 'Internal Server Error',
-      message: 'Internal server error 500 message',
-      error: { detail: 'Error 2 message details' },
-    },
-  },
-]
+}
 
 describe('ToastsComponent', () => {
   let component: ToastsComponent
   let fixture: ComponentFixture<ToastsComponent>
   let toastService: ToastService
-  let clipboard: Clipboard
+  let toastSubject: Subject<Toast> = new Subject()
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
       imports: [ToastsComponent, NgxBootstrapIconsModule.pick(allIcons)],
       providers: [
-        {
-          provide: ToastService,
-          useValue: {
-            getToasts: () => of(toasts),
-          },
-        },
         provideHttpClient(withInterceptorsFromDi()),
         provideHttpClientTesting(),
       ],
@@ -60,95 +35,37 @@ describe('ToastsComponent', () => {
 
     fixture = TestBed.createComponent(ToastsComponent)
     toastService = TestBed.inject(ToastService)
-    clipboard = TestBed.inject(Clipboard)
+    jest.replaceProperty(toastService, 'showToast', toastSubject)
 
     component = fixture.componentInstance
 
     fixture.detectChanges()
   })
 
-  it('should call getToasts and return toasts', fakeAsync(() => {
-    const spy = jest.spyOn(toastService, 'getToasts')
-
-    component.ngOnInit()
-    fixture.detectChanges()
-
-    expect(spy).toHaveBeenCalled()
-    expect(component.toasts).toContainEqual({
-      content: 'foo bar',
-      delay: 5000,
-    })
-
-    component.ngOnDestroy()
-    flush()
-    discardPeriodicTasks()
-  }))
-
-  it('should show a toast', fakeAsync(() => {
-    component.ngOnInit()
-    fixture.detectChanges()
-
-    expect(fixture.nativeElement.textContent).toContain('foo bar')
-
-    component.ngOnDestroy()
-    flush()
-    discardPeriodicTasks()
-  }))
-
-  it('should countdown toast', fakeAsync(() => {
-    component.ngOnInit()
-    fixture.detectChanges()
-    component.onShow(toasts[0])
-    tick(5000)
-    expect(component.toasts[0].delayRemaining).toEqual(0)
-    component.ngOnDestroy()
-    flush()
-    discardPeriodicTasks()
-  }))
-
-  it('should show an error if given with toast', fakeAsync(() => {
-    component.ngOnInit()
-    fixture.detectChanges()
-
-    expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
-    expect(fixture.nativeElement.textContent).toContain('Error 1 content')
-
-    component.ngOnDestroy()
-    flush()
-    discardPeriodicTasks()
-  }))
+  it('should create', () => {
+    expect(component).toBeTruthy()
+  })
 
-  it('should show error details, support copy', fakeAsync(() => {
-    component.ngOnInit()
-    fixture.detectChanges()
+  it('should close toast', () => {
+    component.toasts = [toast]
+    const closeToastSpy = jest.spyOn(toastService, 'closeToast')
+    component.closeToast()
+    expect(component.toasts).toEqual([])
+    expect(closeToastSpy).toHaveBeenCalledWith(toast)
+  })
 
-    expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
-    expect(fixture.nativeElement.textContent).toContain(
-      'Error 2 message details'
+  it('should unsubscribe', () => {
+    const unsubscribeSpy = jest.spyOn(
+      (component as any).subscription,
+      'unsubscribe'
     )
-
-    const copySpy = jest.spyOn(clipboard, 'copy')
-    component.copyError(toasts[2].error)
-    expect(copySpy).toHaveBeenCalled()
-
     component.ngOnDestroy()
-    flush()
-    discardPeriodicTasks()
-  }))
+    expect(unsubscribeSpy).toHaveBeenCalled()
+  })
 
-  it('should parse error text, add ellipsis', () => {
-    expect(component.getErrorText(toasts[2].error)).toEqual(
-      'Error 2 message details'
-    )
-    expect(component.getErrorText({ error: 'Error string no detail' })).toEqual(
-      'Error string no detail'
-    )
-    expect(component.getErrorText('Error string')).toEqual('')
-    expect(
-      component.getErrorText({ error: { message: 'foo error bar' } })
-    ).toContain('{"message":"foo error bar"}')
-    expect(
-      component.getErrorText({ error: new Array(205).join('a') })
-    ).toContain('...')
+  it('should subscribe to toastService', () => {
+    component.ngOnInit()
+    toastSubject.next(toast)
+    expect(component.toasts).toEqual([toast])
   })
 })
index bb791de11f23c47b305fbb98cfa44e8cbc44c6a9..53b6e1895d6afd45de94526925fc3f1b91f9167a 100644 (file)
@@ -1,92 +1,43 @@
-import { Clipboard } from '@angular/cdk/clipboard'
-import { DecimalPipe } from '@angular/common'
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import {
+  NgbAccordionModule,
   NgbProgressbarModule,
-  NgbToastModule,
 } from '@ng-bootstrap/ng-bootstrap'
 import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
-import { Subscription, interval, take } from 'rxjs'
+import { Subscription } from 'rxjs'
 import { Toast, ToastService } from 'src/app/services/toast.service'
+import { ToastComponent } from '../toast/toast.component'
 
 @Component({
   selector: 'pngx-toasts',
   templateUrl: './toasts.component.html',
   styleUrls: ['./toasts.component.scss'],
   imports: [
-    DecimalPipe,
-    NgbToastModule,
+    ToastComponent,
+    NgbAccordionModule,
     NgbProgressbarModule,
     NgxBootstrapIconsModule,
   ],
 })
 export class ToastsComponent implements OnInit, OnDestroy {
-  constructor(
-    public toastService: ToastService,
-    private clipboard: Clipboard
-  ) {}
+  constructor(public toastService: ToastService) {}
 
   private subscription: Subscription
 
-  public toasts: Toast[] = []
-
-  public copied: boolean = false
-
-  public seconds: number = 0
+  public toasts: Toast[] = [] // array to force change detection
 
   ngOnDestroy(): void {
     this.subscription?.unsubscribe()
   }
 
   ngOnInit(): void {
-    this.subscription = this.toastService.getToasts().subscribe((toasts) => {
-      this.toasts = toasts
-      this.toasts.forEach((t) => {
-        if (typeof t.error === 'string') {
-          try {
-            t.error = JSON.parse(t.error)
-          } catch (e) {}
-        }
-      })
+    this.subscription = this.toastService.showToast.subscribe((toast) => {
+      this.toasts = toast ? [toast] : []
     })
   }
 
-  onShow(toast: Toast) {
-    const refreshInterval = 150
-    const delay = toast.delay - 500 // for fade animation
-
-    interval(refreshInterval)
-      .pipe(take(delay / refreshInterval))
-      .subscribe((count) => {
-        toast.delayRemaining = Math.max(
-          0,
-          delay - refreshInterval * (count + 1)
-        )
-      })
-  }
-
-  public isDetailedError(error: any): boolean {
-    return (
-      typeof error === 'object' &&
-      'status' in error &&
-      'statusText' in error &&
-      'url' in error &&
-      'message' in error &&
-      'error' in error
-    )
-  }
-
-  public copyError(error: any) {
-    this.clipboard.copy(JSON.stringify(error))
-    this.copied = true
-    setTimeout(() => {
-      this.copied = false
-    }, 3000)
-  }
-
-  getErrorText(error: any) {
-    let text: string = error.error?.detail ?? error.error ?? ''
-    if (typeof text === 'object') text = JSON.stringify(text)
-    return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`
+  closeToast() {
+    this.toastService.closeToast(this.toasts[0])
+    this.toasts = []
   }
 }
index 274ea9db66f01060631679ed1211d5af5880b6c5..ce50b165e5cc7150144dfe84de38cbdcf478dc04 100644 (file)
@@ -25,6 +25,33 @@ describe('ToastService', () => {
     })
   })
 
+  it('adds a unique id to toast on show', () => {
+    const toast = {
+      title: 'Title',
+      content: 'content',
+      delay: 5000,
+    }
+    toastService.show(toast)
+
+    toastService.getToasts().subscribe((toasts) => {
+      expect(toasts[0].id).toBeDefined()
+    })
+  })
+
+  it('parses error string to object on show', () => {
+    const toast = {
+      title: 'Title',
+      content: 'content',
+      delay: 5000,
+      error: 'Error string',
+    }
+    toastService.show(toast)
+
+    toastService.getToasts().subscribe((toasts) => {
+      expect(toasts[0].error).toEqual('Error string')
+    })
+  })
+
   it('creates toasts with defaults on showInfo and showError', () => {
     toastService.showInfo('Info toast')
     toastService.showError('Error toast')
@@ -54,4 +81,29 @@ describe('ToastService', () => {
       expect(toasts).toHaveLength(0)
     })
   })
+
+  it('clears all toasts on clearToasts', () => {
+    toastService.showInfo('Info toast')
+    toastService.showError('Error toast')
+    toastService.clearToasts()
+
+    toastService.getToasts().subscribe((toasts) => {
+      expect(toasts).toHaveLength(0)
+    })
+  })
+
+  it('suppresses popup toasts if suppressPopupToasts is true', (finish) => {
+    toastService.showToast.subscribe((toast) => {
+      expect(toast).not.toBeNull()
+    })
+    toastService.showInfo('Info toast')
+
+    toastService.showToast.subscribe((toast) => {
+      expect(toast).toBeNull()
+      finish()
+    })
+
+    toastService.suppressPopupToasts = true
+    toastService.showInfo('Info toast')
+  })
 })
index 16c534b5c4f20e8f922a5d946b1d524e1a112bcc..b917bf94b871f6dad925b99b2cdb5d5e49b8cac7 100644 (file)
@@ -1,7 +1,10 @@
 import { Injectable } from '@angular/core'
 import { Subject } from 'rxjs'
+import { v4 as uuidv4 } from 'uuid'
 
 export interface Toast {
+  id?: string
+
   content: string
 
   delay: number
@@ -22,13 +25,32 @@ export interface Toast {
 })
 export class ToastService {
   constructor() {}
+  _suppressPopupToasts: boolean
+
+  set suppressPopupToasts(value: boolean) {
+    this._suppressPopupToasts = value
+    this.showToast.next(null)
+  }
 
   private toasts: Toast[] = []
 
   private toastsSubject: Subject<Toast[]> = new Subject()
 
+  public showToast: Subject<Toast> = new Subject()
+
   show(toast: Toast) {
-    this.toasts.push(toast)
+    if (!toast.id) {
+      toast.id = uuidv4()
+    }
+    if (typeof toast.error === 'string') {
+      try {
+        toast.error = JSON.parse(toast.error)
+      } catch (e) {}
+    }
+    this.toasts.unshift(toast)
+    if (!this._suppressPopupToasts) {
+      this.showToast.next(toast)
+    }
     this.toastsSubject.next(this.toasts)
   }
 
@@ -46,7 +68,7 @@ export class ToastService {
   }
 
   closeToast(toast: Toast) {
-    let index = this.toasts.findIndex((t) => t == toast)
+    let index = this.toasts.findIndex((t) => t.id == toast.id)
     if (index > -1) {
       this.toasts.splice(index, 1)
       this.toastsSubject.next(this.toasts)
@@ -56,4 +78,10 @@ export class ToastService {
   getToasts() {
     return this.toastsSubject
   }
+
+  clearToasts() {
+    this.toasts = []
+    this.toastsSubject.next(this.toasts)
+    this.showToast.next(null)
+  }
 }
index 83aa12dc2134e26d54382dee2f0ed0d673e006e7..484a77c8212e90d8f35a1bb40b01908158c4c280 100644 (file)
@@ -34,6 +34,7 @@ import {
   arrowRightShort,
   arrowUpRight,
   asterisk,
+  bell,
   bodyText,
   boxArrowUp,
   boxArrowUpRight,
@@ -235,6 +236,7 @@ const icons = {
   arrowRightShort,
   arrowUpRight,
   asterisk,
+  bell,
   braces,
   bodyText,
   boxArrowUp,
index 1257798b930d87e6f2079b3ae7db727ed6044721..589356566aef9861e114ceb1cd60d870e952f52f 100644 (file)
@@ -570,6 +570,10 @@ table.table {
   color: var(--bs-body-color);
 }
 
+.toast {
+  --bs-toast-max-width: var(--pngx-toast-max-width);
+}
+
 .alert-primary {
   --bs-alert-color: var(--bs-primary);
   --bs-alert-bg: var(--pngx-primary-faded);
index 9f3c9cbe9dfbb47909d8529d857b194a6f523be6..fc8c13d3bbf88019ae3288e13fc81ade1759c2c7 100644 (file)
   --pngx-bg-alt2: var(--bs-gray-200);
   --pngx-bg-disabled: #f7f7f7;
   --pngx-focus-alpha: 0.3;
+  --pngx-toast-max-width: 360px;
+  @media screen and (min-width: 1024px) {
+    --pngx-toast-max-width: 450px;
+  }
 }
 
 // Dark text colors allow for maintain contrast with theme color changes