mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Feature: better toast notifications management (#8980)
This commit is contained in:
parent
e08606af6e
commit
b8bdc10f25
@ -297,11 +297,11 @@
|
||||
</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>
|
||||
@ -316,11 +316,11 @@
|
||||
</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>
|
||||
@ -359,15 +359,15 @@
|
||||
</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">
|
||||
@ -658,11 +658,11 @@
|
||||
</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">
|
||||
@ -995,11 +995,11 @@
|
||||
</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>
|
||||
@ -1374,6 +1374,10 @@
|
||||
<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>
|
||||
@ -1557,7 +1561,7 @@
|
||||
</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">
|
||||
@ -1568,11 +1572,11 @@
|
||||
</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">
|
||||
@ -1966,11 +1970,11 @@
|
||||
</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">
|
||||
@ -2323,11 +2327,11 @@
|
||||
</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">
|
||||
@ -2647,83 +2651,83 @@
|
||||
<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>
|
||||
@ -2734,11 +2738,11 @@
|
||||
<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>
|
||||
@ -2769,11 +2773,11 @@
|
||||
<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>
|
||||
@ -2784,11 +2788,11 @@
|
||||
<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>
|
||||
@ -2799,11 +2803,11 @@
|
||||
<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>
|
||||
@ -2818,11 +2822,11 @@
|
||||
<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>
|
||||
@ -2833,92 +2837,92 @@
|
||||
<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">
|
||||
@ -3106,6 +3110,20 @@
|
||||
<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">
|
||||
@ -4071,8 +4089,8 @@
|
||||
<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">
|
||||
@ -5863,8 +5881,8 @@
|
||||
<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>
|
||||
@ -5955,8 +5973,8 @@
|
||||
<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">
|
||||
|
@ -30,12 +30,13 @@
|
||||
</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">
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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()
|
||||
}))
|
||||
})
|
@ -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
|
||||
}
|
||||
}
|
56
src-ui/src/app/components/common/toast/toast.component.html
Normal file
56
src-ui/src/app/components/common/toast/toast.component.html
Normal file
@ -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>
|
||||
}
|
||||
@if (copied) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
<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>
|
20
src-ui/src/app/components/common/toast/toast.component.scss
Normal file
20
src-ui/src/app/components/common/toast/toast.component.scss
Normal file
@ -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;
|
||||
}
|
104
src-ui/src/app/components/common/toast/toast.component.spec.ts
Normal file
104
src-ui/src/app/components/common/toast/toast.component.spec.ts
Normal file
@ -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('...')
|
||||
})
|
||||
})
|
76
src-ui/src/app/components/common/toast/toast.component.ts
Normal file
76
src-ui/src/app/components/common/toast/toast.component.ts
Normal file
@ -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 ? '...' : ''}`
|
||||
}
|
||||
}
|
@ -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>
|
||||
}
|
||||
@if (copied) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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')
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy()
|
||||
})
|
||||
|
||||
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(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 show error details, support copy', fakeAsync(() => {
|
||||
component.ngOnInit()
|
||||
fixture.detectChanges()
|
||||
|
||||
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])
|
||||
})
|
||||
})
|
||||
|
@ -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 = []
|
||||
}
|
||||
}
|
||||
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -24,6 +24,10 @@
|
||||
--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
|
||||
|
Loading…
x
Reference in New Issue
Block a user